Кастомні CLI-інструменти для автоматизації рутини в iOS-розробці

Всім привіт! Мене звати Дмитро Ковригін і я відповідаю за напрямок iOS в команді Uklon.

Час від часу мені доводиться приймати рішення, які мають мультиплікативний ефект, оскільки визначають флоу роботи для всієї команди. Пропоную розглянути деякі найцікавіші рішення, які мали позитивні наслідки.

Наведу кілька прикладів оптимізації флоу роботи iOS-команди. Всі вони зводяться до використання сторонніх CLI-інструментів, доповнених певними власними розробками.

В нашому випадку, бажаного ефекту ми досягаємо за допомогою Ruby скриптів, хоча в цьому питанні вибір інструменту не вважаю ключовим. Пропоную сфокусуватись на ідеях, які дозволяють досягти бажаної автоматизації. Приклади релевантного коду на Ruby — там, де це має сенс, — я додам або прямо в тексті, або за посиланням.

API-first та генерація коду на основі openapi специфікації

Довгий час для нашої команди актуальною була проблема узгодження контрактів. Полягала вона у відсутності єдиного джерела правди щодо очікувань між клієнтом та http-сервером.

Розробник API звичайно хоче зробити його ідеальним. А оскільки досконалості немає меж, надто великою була спокуса зробити респонс ще гарнішим, навіть після виходу клієнтів у продакшн.

Рішення знайшлось в адаптуванні підходу, відомого як API-first. Перш ніж переходити до розробки фічі з будь-якого боку, узгоджується контракт, всюди, де такі зміни потрібні. І фіксується він у вигляді OpenAPI специфікації. В окремому репозиторії, з історією змін, посиланнями на відповідні задачі й апрувами на кожен МР з боку всіх зацікавлених сторін.

Також з можливістю зручно переглядати контракт у SwaggerUI і, що особливо для нас цікаво зараз, — навіть генерувати код. В тому числі клієнтський, в тому числі і на swift.

Приклад згенерованого коду для https://petstore.swagger.io

Наші очікування від компонента для взаємодії з http-сервером в цілому дещо відрізняються від можливостей, запропонованих swagger-codegen.

Проте ми сходимося у необхідності мати Codable DTOs для серіалізації запитів і парсингу відповідей. Тому було прийнято рішення цю частину згенерованого коду використовувати у наших проєктах.

Однак, через певний час після виходу згенерованих DTOs в продакшн, ми стикнулись з наступною ситуацією. Всюди, де це можливо, приємніше мати в якості типу даних рядковий Enum, замість довільної String. Таких полів у нас в контрактах достатньо.

Що ж відбувається, коли виникає необхідність розширити перелік можливих значень такого Enum’у? Зміни вносяться в контракт, з нього генеруються нові DTOs і оновлені клієнти будуть готові сприймати нове значення поля.

А що з неоновленими версіями? Наші очікування були такі: поле з опціональним значенням Enum’у матиме значення nil. До такого ми були готові.

Досвід інтеграції CLI-інструментів в роботу 👇Однак реальність виявилась такою: виникає помилка парсингу всієї DTO і застосунок без видимих причин або відображає не повний перелік елементів у списку, або взагалі не може відобразити інформацію на екрані.

Рішення, яке дасть очікувану нами поведінку не складно знайти в інтернеті. Згідно з ним, достатньо для property типу Enum вказати враппер @DecodeUnknownAsNil. І бажано цей процес автоматизувати, щоб оновлення DTOs і надалі зводилось для нас до запуску скрипта без потреби ручного втручання.

Цю задачу цілком можливо вирішити за допомогою регулярного виразу, оскільки патерн, за яким можна впізнати декларацію Enum у файлі є передбачуваним.

def find_enums(text)
	enums = text.to_enum(:scan, /public enum (.+): String, Codable {/)
		.map { Regexp.last_match }
		.map { |m| m.captures.first }
end

А маючи перелік задекларованих у файлі Enum’ів, нескладно впізнати і проперті відповідного типу.

def wrap_enum_property(text, enum)
	regexp_result = text.match(/public var (.+): #{enum}?/)
	if regexp_result
		variable = regexp_result.captures.first
		variable_s = variable.to_s
		if variable_s.length > 0
# Замінюємо декларацію property на таку ж, але з врапером
			text.gsub!("public var #{variable_s}: #{enum}?", "@DecodeUnknownAsNil public var #{variable_s}: #{enum}?")
		end
	end
	return text
end

Відзначу лише певну особливість з пропертями, що мають своїм типом не сам Enum, а масив Enum’ів. З такими не вдалось обійтись propertyWrapper’ом, а довелось використовувати тип DecodeUnknownAsNil напряму. Це викликає необхідність звертатись до .wrappedValue при мапінгу DTO в доменну модель.

Рішення хоч і не ідеальне, але для нас — цілком прийнятне.

def wrap_array_of_enums_property(text, enum)
	text.gsub!("[#{enum}]", "[DecodeUnknownAsNil<#{enum}>]")
	return text
end

Робочу версію прикладу коду для генерації DTO з openapi-специфікації і автоматичним додаванням propertyWrapper @DecodeUnknownAsNil до Enum’ів можна подивитись за посиланням.

Генерація коду для аналітичних івентів на основі openapi специфікації

Випуск нової фічі в нашому продукті зазвичай викликає питання щодо її доцільності. Тому застосунок збирає певну аналітику, щоб зрозуміти, чи є випущений функціонал популярним, чи зручним.

А це своєю чергою означає, що десь у коді ми маємо трекер аналітичних івентів з подібним інтерфейсом:

func log(event: String, properties: [String: Any]? = nil)

Кожен івент має назву і опціональний набір додаткових властивостей.

Такий інтерфейс, щоправда, несе з собою певні ризики, всі з яких доставляли нам цілком реальні неприємності:

  • одруки в назві івенту ❌
  • одруки в назві custom_property ❌
  • неправильний набір custom_property ❌
  • неправильний тип значення custom_property ❌
  • метод у ролі значення custom_property замість результату його виклику ❌
  • відсутність єдиного джерела правди щодо вимог ❌

Маючи позитивний досвід використання кодогенерації на основі openapi документу, на думку спала ідея вирішити більшість проблем шляхом ведення переліку актуальних аналітичних івентів в репозиторіях, також у вигляді openapi.yaml файлу.

З історією змін, посиланнями на задачі, апрувами від усіх зацікавлених сторін і, звичайно, — кодогенерацією.

# Приклад опису івенту у файлі openapi.yaml
…
trip_report_tap:
      description: "Event Trip Report Tap"
      type: object
      properties:
        Source:
          type: string
          enum: 
            - Trips List
            - Trip Details
          description: "Екран, з якого натиснута кнопка"
      required:
        - Source

Генеруючи з такого файлу Codable структури для custom_properties, інтерфейс нашого аналітичного трекера може мати вже подібний вигляд:

func log<T: Encodable>(event: String, properties: T? = nil)

що допомагає усунути більшість з вищезгаданих проблем:

  • одруки в назві івенту ❌
  • одруки в назві custom_property ✅
  • неправильний набір custom_property ❌
  • неправильний тип значення custom_property ✅
  • метод у ролі значення custom_property замість результату його виклику ✅
  • джерелом правди щодо вимог до івентів є openapi-документ ✅

Проте, ми можемо ще краще. Зазначимо, що в полі «description» для кожного івенту ми все одно вказуємо оригінальну назву івенту. Мається на увазі — в тому регістрі, в якому її очікує сервер, а не компілятор.

Оскільки openapi.yaml є перш за все YAML-файлом, нічого не заважає нам вчинити наступне:

  • розпарсимо openapi-документ як YAML-файл;
  • заберемо Event name з дескрипшена кожного з них;
  • згенеруємо Enum з кейсом під кожен з івентів;
  • DTO під custom_properties стане асоційованим значенням для відповідного кейсу;
  • згенеруємо код для імплементації Codable Enum’ом.

Таким чином, інтерфейс аналітичного трекера тепер матиме ще приємніший інтерфейс:

func log(event: AnalyticEvent)

де AnalyticEvent — Enum з переліком івентів.

Тепер, в разі, якщо ми не помилились з тим, в який момент треба затрекати певний івент і з самим івентом, все інше проконтролюють кодогенератор і компілятор:

  • одруки в назві івенту ✅
  • одруки в назві custom_property ✅
  • неправильний набір custom_property ✅
  • неправильний тип значення custom_property ✅
  • метод у ролі значення custom_property замість результату його виклику ✅
  • джерелом правди щодо вимог до івентів є openapi-документ ✅

Приклад використання згенерованого коду:

logger.log(
	.trip_report_tap(TripReportTap(source: .tripDetails))
)

Робочу версію прикладу генерації Enum’у з аналітичними івентами на основі даних openapi-специфікації можна подивитись за посиланням.

Оптимізації використання ресурсів

Джерелом правди щодо перекладів для нашої команди є портал стороннього сервісу Lokalise. Основна причина такого вибору є можливість оновлювати переклади в разі виявлення одруків OverTheAir, тобто без необхідності випуску хот-фікс релізу.

Відповідно, певні об’єми трафіку передаються між застосунком і серверами Lokalise.

В якийсь момент, у зв’язку зі змінами у правилах тарифікації цим сервісом, виникла задача оптимізації трафіку, який ми передаємо.

Тобто необхідно скоротити кількість ключів перекладу шляхом видалення ключів, які більше не використовуються в коді проєкту та видалення ключів, які мають однакові переклади. Це різні «Так»/ «Ні», «Зрозуміло», «Відміна», які для більшої гнучкості заводили різні під різні екрани.

Парсинг файлів Localizable.strings для нас не є задачею складною, формат їх нам добре відомий, розташування — також.

lokalized_strings = File.foreach('path_to/Localizations/en.lproj/Localizable.strings')
	.filter { |s| !s.start_with?("/*") && s.split(' = ').count == 2 }
	.map { |s| s.chomp(';').split(' = ').first[1...-1] }
}

Задача пошуку ключів, що не використовуються, зводиться до того, щоб розпарсити всі ключі з файлів перекладів і, заходячи послідовно в кожен з файлів .swift, знайти, які ключі перекладів в них використовуються.

Це може бути або використання ключа як параметра для NSLocalizedString, або відповідний патерн в разі використання інструментів для генерації Enum’ів з перекладами, Swiftgen чи аналогів.

Всі знайдені у файлах .swift ключі видаляємо з загального переліку і після проходження по коду всього проєкту маємо відповідь, які ключі перекладу не використовувались ніде.

Dir.glob("path_to/Classes/**/*.swift").each { |filename|
	lokalized_strings_used_in_file = lokalized_strings.filter { |string| is_localised_string_used?(string, filename) }
	if lokalized_strings_used_in_file.count > 0
		lokalized_strings = lokalized_strings - lokalized_strings_used_in_file
	end
}

З пошуком дублікатів ще простіше: агрегуємо по ключу переклади з усіх мов, що маємо на проєкті, і шукаємо однакові входження по конкатенації перекладів. Всі ключі, що мають однакові конкатеновані переклади — дублюють один одного.

class Lokalization
...
# Клас має змінні
# @key - ключ перекладу
# @ua - переклад українською
# @en - переклад англійською 
# тощо
... 
# І визначає метод з конкатенацією перекладів під всі мови
	def full_translation
		"#{@en}/#{@ua}/#{@ru}"
	end
end

...

Збираємо на основі перекладів у масив translations об’єкти класу Lokalization і групуємо:

...
groups = translations.group_by { |x| x.full_translation }
		            .filter { |g, v| v.count > 1 }
groups.each do |key, array|
	puts "Translation '#{key}' is found in keys:"
	array.each { |x| puts "\t#{x.key}" }
end

Робочу версію прикладу коду для пошуку ключів, які можна видалити з проєкту, можна знайти за посиланням (скрипти find_duplicates.rb та check_unused.rb).

Визначення релізних версій у CI

Випуск релізу після інтеграції fastlane і певних налаштувань CI, очікувано, зводиться до мержу в master-гілку. Однак, для релізної збірки необхідно вказати два унікальні параметри — номер релізу та номер збірки.

Донедавна ці параметри у нас вказувались вручну останнім комітом перед мержом в master. І довгий час ну дуже хотілось кудись прибрати потребу у цьому останньому коміті.

Наша модифікація gitflow передбачає роботу над конкретним релізом у гілці `release/<номер релізу>`, а коміт-меседж при мержі в master генерується GitLab’ом у форматі `Merge branch ’release/<номер релізу>’ into ’master’`

Виходячи з того, що ми можемо покладатись на

  • такий формат коміт-меседжів,
  • мерж в master завжди заходить з релізної гілки,

залишається лише отримати у Fastfile доступ до коміт-меседжу з найсвіжішого мержа в master-гілку.

Для розв’язання цієї задачі, скористаємось можливостями Gitlab API:

  • отримаємо історію комітів в гілці за замовчуванням;
  • шукаємо перший коміт, що має більше одного «parent_id», тобто є мержем іншої гілки в master
  • беремо «title» цього коміту, тобто коміт-месседж;
  • парсимо номер релізу.
def merged_release
	headers = {"PRIVATE-TOKEN" => "#{ENV['GITLAB_PRIVATE_TOKEN']}"}
	url = "#{ENV['GITLAB_URL']}/api/v4/projects/#{ENV['PROJECT_ID']}/repository/commits"
	response = HTTP.get(url, :headers => headers)
	json = response.parse
	title = json.find { |item| item["parent_ids"].count > 1 }["title"]
	latest_release = parse_release(title)
	return latest_release
end

Парсинг номера релізу з коміт-меседжу тривіальний:

def parse_release(title)
	regexp_result = title.match /Merge branch 'release\/(.+?)' into \'master\'/
	if regexp_result
              key = regexp_result.captures.first
              key_s = key.to_s
              return key_s
    end
end

Висновки

  1. Всюди, де можливо, монотонну роботу варто пробувати автоматизувати. Це збереже час і допоможе уникати необов’язкових помилок, викликаних людським фактором.
  2. Зокрема, у цьому нам можуть допомогти сторонні CLI-інструменти чи API, які надаються сервісами, з якими ми інтегруємось.
  3. А в разі, якщо ті інструменти не покривають повністю наші потреби з коробки, їх поведінку можна доповнювати чи коригувати за допомогою власних CLI-інструментів, наприклад, скриптів.

А чи маєте ви у своїй роботі рутину, яку хотілось би автоматизувати? Або бачите, як можна вдосконалити наш Ruby-код?

Впевнений, це можливо. Чекаю на ваші коментарі і дякую за увагу!

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

A API має версію?

Для мене додання нового значення для інаму є breaking change. Та потребує підвищення версії апі.

Дякую за ідею! Варіант робочий, але якщо я правильно розумію, кожен інам має тоді передбачати значення «unknown». Щоб неоновлені клієнти також щось отримували. Ми чомусь так не робимо (

Тут треба дивитись у деталі. Взагалі нул або unknown вурішують проблему крешу, але не вирішують що далі робити з цим значенням. Можно його фільтрувати, писти щось дефотне. Але це не покращує життя користувача.

Може потребує, а може ні — тобто сервер не буде нічого посилати, або буде посилати те що було до цього.

Підписатись на коментарі