Программисты постоянно создают утилиты и готовые решения, которые помогают ускорить разработку. Но важно использовать эти инструменты правильно. Как не попасть в ад из внешних зависимостей? Как сделать так, чтобы проект собирался с одинаковыми версиями зависимостей на машинах разных разработчиков?

Я — Никита Коробейников, iOS Team Lead в Surf. Проинициализировал не один десяток проектов. Поделюсь с вами опытом в этой статье.

Копипаста — отстой

Думаю, немногие программисты являются сторонниками подхода «копировать-вставить», или «copy-paste». Этот подход может использоваться одиночкой, но совсем не подходит для командного программирования. 

Чтобы что-то скопировать, нужно знать откуда. Копипаста не имеет централизованного места дислокации. Обычно это просто рабочий код, написанный программистом в какие-то бородатые года, возможно, даже на другом проекте. 

Старый код приходится адаптировать под новые нужды: это дополнительные затраты времени. Любые изменения порождают новую версию, из-за чего становится ещё труднее определиться с тем, где же найти нужный кусочек кода. В конце концов, программист может потерять доступ к нужному коду.

Минусы копипасты:

  • не централизованное место хранения,

  • понадобятся доработки,

  • отсутствие явного версионирования,

  • высокая вероятность потерять код.

Проблемы может решить менеджер зависимостей, который позволяет делиться готовыми утилитами, пакетами, фреймворками через централизованное место хранения (обычно — через отдельный репозиторий) с явным версионированием.

Зависимости без фиксированных версий — тоже отстой

Допустим, команда перешла на использование менеджеров зависимостей. Программист Петя использовал суперутилиту версии 1.0.0 для новой суперфичи, но версию суперутилиты не зафиксировал. 

Программист Вася подтянул изменения Пети, но не заметил, что суперутилита успела обновиться до версии 1.0.1. Иногда даже минорные изменения в opensource-проектах могут положить сборку проекта или изменить работу утилиты.

Незафиксированные версии делают проект нестабильным. Никогда не знаешь, соберется ли сборка на CI (continuous integration) и будет ли проект собираться у новенького программиста, подключившегося к проекту.

Существует два метода фиксации версий:

  1. слабая,

  2. жёсткая.

Слабая фиксация разрешает минорные обновления и позволяет время от времени получать улучшения или исправления утилиты. Жёсткая запрещает любые обновления.

Конечно, в реальности редко бывает, когда минорное обновление ломает утилиту, но всё же мы сталкивались с подобными ситуациями. К тому же в наше непростое время появились случаи диверсий в opensource-проектах. Поэтому мы рекомендуем использовать жёсткую фиксацию.

Ожидания от менеджера зависимостей

Давайте проанализируем разные менеджеры зависимостей и постараемся ответить на вопросы:

  • Как зафиксировать версию: слабо или жёстко?

  • Как добавить свою утилиту?

Забегая вперёд, скажу, что у всех менеджеров зависимостей проглядывается общая схема фиксации версий. Требования к распространяемым утилитам во многом похожи. Понимание этих общих принципов поможет вам, даже если вы не iOS-разработчик.

RubyGems

Хаб для утилит, написанных на языке Ruby. Утилиты здесь зовутся гемами (gems).

Фиксация версий контролируется отдельным гемом bundler. Этот гем позволяет описать зависимости в файле Gemfile и зафиксировать версии.

После установки зависимостей будет создан файл Gemfile.lock, в котором будет зафиксирована версия каждой утилиты и её зависимостей. 

Примечательно, что lock-файл создаётся гемом, и его нельзя редактировать вручную: иначе хэш-суммы файла испортятся и установка зависимостей будет провалена. Выходом из такой ситуации будет только удаление lock-файла и новая установка либо откат к предыдущей версии, сохранённой в репозитории.

Команда 

Описание

bundle init

создаст пустой Gemfile

bundle install

установит зависимости согласно Gemfile и Gemfile.lock

bundle update

установит зависимости согласно Gemfile, но проигнорирует Gemfile.lock

Рассмотрим пример Gemfile.

source "https://rubygems.org"

# Ensure github repositories are fetched using HTTPS
git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
#  puts(repo_name)
  "https://github.com/#{repo_name}.git"
end if Gem::Version.new(Bundler::VERSION) < Gem::Version.new('2')

gem "fastlane", "~> 2.199.0"
gem 'cocoapods', "~> 1.11.2"
gem 'synx', "~> 0.2.1"
gem 'xcpretty', "~> 0.3.0"

gem 'generamba', github: 'surfstudio/Generamba', branch: 'danger-compatible'

…

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

Спецификатор ~> разрешает получать минорные обновления версии: в данном случае фиксация слабая. Gem generamba зафиксирован на форке и конкретной ветке, но это тоже является слабой фиксацией, потому что коммит не зафиксирован. Хотя зафиксировать коммит возможно: нужно указать commit: '12345', где вместо 12345 должен находиться хэш коммита.

GIT
  remote: https://github.com/surfstudio/Generamba.git
  revision: 5de6003e3fa74bb39df50a2e592682439889d818
  branch: danger-compatible
  specs:
	generamba (1.4.1)
  	cocoapods-core (>= 1.4.0, < 2.0.0)
  	git (~> 1.7)
  	liquid (= 4.0.0)
  	terminal-table (= 1.4.5)
  	thor (= 0.19.1)
  	xcodeproj (>= 1.5.0, < 2.0.0)

GEM
  remote: https://rubygems.org/
  specs:
	CFPropertyList (3.0.5)
  	rexml
	activesupport (6.1.5)
  	concurrent-ruby (~> 1.0, >= 1.0.2)
  	i18n (>= 1.6, < 2)
  	minitest (>= 5.1)
  	…

PLATFORMS
  x86_64-darwin-19

DEPENDENCIES
  cocoapods (~> 1.11.2)
  danger (~> 8.5.0)
  danger-duplicate_localizable_strings (~> 0.3.0)
  danger-swiftlint (~> 0.29.4)
  danger-the_coding_love (~> 0.0.9)
  danger-xcode_summary (~> 1.0.1)
  danger-xcodebuild (~> 0.0.6)
  fastlane (~> 2.199.0)
  fastlane-plugin-firebase_app_distribution
  fastlane-plugin-git_tags
  fastlane-plugin-versioning
  generamba!
  synx (~> 0.2.1)
  xcpretty (~> 0.3.0)
  xcpretty-json-formatter (~> 0.1.1)

BUNDLED WITH
   2.2.5

Lock-файл создастся автоматически после первого bundle install. В нем фиксируются версии нужных гемов и их зависимостей. При следующем bundle install будет использован lock-файл. Таким образом можно зафиксировать версии жестко. Лишь команда bundle update изменит lock-файл. 

Чтобы поделиться ruby-скриптом — назовём его my-script.rb — понадобится:

  • Добавить описание скрипта в файле .gemspec.

  • Выложить исходники в публичный репозиторий.

  • Зафиксировать версию, добавив tag.

  • Опубликовать версию:

    • на RubyGems.org — командой gem push,

    • на собственном сервере.

Публикация на собственном сервере позволяет контролировать доступность скрипта. Например, эта опция подойдёт, если нужно использовать его только внутри своей студии. Однако стоит понимать, что в таком случае сервер должен быть доступен круглосуточно, чтобы у разработчиков не возникало проблем.

Homebrew 

Хаб для любых утилит командной строки для macOS и Linux. Язык написания утилиты не важен.

Для распространения утилита должна быть размещена в публичном репозитории и «расфасована по бутылкам». Как видите, шуточная параллель с крафтовой пивоварней —не только в логотипе этого менеджера зависимостей, но и в самом устройстве платформы.

Фиксация версий работает «из коробки». В её основе — Brewfile и Brewfile.lock.json. Уже догадались, какой файл используется для слабой фиксации, а какой — для жёсткой? Правильно: Brewfile.lock.json, как и в RubyGems, нельзя редактировать, потому что он создаётся менеджером зависимостей.

Команда

Описание

touch Cartfile

создаст пустой Brewfile

brew bundle —no-upgrade

установит зависимости, упомянутые в Brewfile и Brewfile.lock.json

brew bundle —force

установит зависимости, упомянутые в Brewfile, но перепишет Brewfile.lock.json

# 'brew tap'
tap "homebrew/cask"
# 'brew tap' with custom Git URL
tap "yonaskolb/XcodeGen", "https://github.com/yonaskolb/XcodeGen.git"
tap "krzysztofzablocki/Sourcery", "https://github.com/krzysztofzablocki/Sourcery.git"

# 'brew install'
brew "xcodegen"
brew "sourcery"

В отличие от Gemfile мы не можем указать версии утилит в Brewfile для слабой фиксации.
Можно лишь указать, из какого репозитория будет взята утилита.

{
  "entries": {
	"tap": {
  	"homebrew/cask": {
    	"revision": "647192becc3e9389d7f217ea45779b317d106172"
  	},
  	"yonaskolb/xcodegen": {
    	"revision": "29bcb9259136f04781ef5d736471f01357e43b04",
    	"options": {
      	"clone_target": "https://github.com/yonaskolb/XcodeGen.git"
    	}
  	},
  	"krzysztofzablocki/sourcery": {
    	"revision": "d64c263e0b112d1a3a21fbd3cf8128507e1382d4",
    	"options": {
      	"clone_target": "https://github.com/krzysztofzablocki/Sourcery.git"
    	}
  	}
	},
	"brew": {
  	"xcodegen": {
    	"version": "2.29.0",
    	"bottle": {
      	"rebuild": 0,
      	"root_url": "https://ghcr.io/v2/homebrew/core",
      	"files": {
        	"arm64_monterey": {
          	"cellar": ":any_skip_relocation",
          	"url": "https://ghcr.io/v2/homebrew/core/xcodegen/blobs/sha256:f76deffe6ad019b5004774c27175af44d1e2a17f2bb932e3053c43338f4dc9e2",
          	"sha256": "f76deffe6ad019b5004774c27175af44d1e2a17f2bb932e3053c43338f4dc9e2"
        	},
            …
        	"monterey": {
          	"cellar": ":any_skip_relocation",
          	"url": "https://ghcr.io/v2/homebrew/core/xcodegen/blobs/sha256:b1aeb953a94bd3bf0e32365c9f7eb52e75d4340f2ff2e2298ae6a822f87b12b7",
          	"sha256": "b1aeb953a94bd3bf0e32365c9f7eb52e75d4340f2ff2e2298ae6a822f87b12b7"
        	},
            …
  },
  "system": {
	"macos": {
  	"monterey": {
    	"HOMEBREW_VERSION": "3.4.11-102-gef0d5fc",
    	"HOMEBREW_PREFIX": "/usr/local",
    	"Homebrew/homebrew-core": "703ab164573ff9da5637fbf810b5a1b873832c4c",
    	"CLT": "",
    	"Xcode": "13.3.1",
    	"macOS": "12.3.1"
  	}
	}
  }
}

Lock-файл в свою очередь фиксирует версию слабо. Флаг команды —no-upgrade позволит приблизить фиксацию к сильной, но это не панацея: после обновления Xcode или MacOS lock-файл так или иначе обновится.

Cocoapods

Это gem, который позволяет импортировать вместе с исходниками проекты библиотек, написанные на Swift или Objective C. Благодаря этой особенности можно производить отладку импортированных модулей и сообщать о проблеме создателю библиотеки или же исправлять проблемный код в ответвлении от основного репозитория. 

Следует понимать, что импортированные модули буду собираться вместе с проектом, а не только в момент подключения. Это увеличивает время сборки проекта в целом, из-за чего этот менеджер зависимостей иногда не используют на проектах, где это важно.

Чтобы собрать зависимости воедино, cocoapods генерирует xcworkspace-файл. Об этом стоит помнить, если вы используете кодогенерацию для своего проекта: ведь xcworkspace может быть лишь один.

Команда pod

Описание

init

создаст пустой Podfile

install

установит зависимости согласно Podfile и Podfile.lock

update

установит зависимости согласноPodfile, но проигнорирует Podfile.lock

Рассмотрим пример Podfile.

use_frameworks!

target 'ReactiveDataDisplayManagerExample_iOS' do
  pod 'ReactiveDataDisplayManager', :path => '../'
  pod 'SurfUtils/ItemsScrollManager', :git => "https://github.com/surfstudio/iOS-Utils.git", :tag => '11.0.0'
  pod 'Nuke', :git => "https://github.com/kean/Nuke.git", :tag => '9.5.1'
  pod 'DifferenceKit', '1.1.5'
  target 'ReactiveDataDisplayManagerExampleUITests' do
  end
end


Обратите внимание, что Cocoapods позволяет ссылаться на локальный pod, находящийся в разработке. Это очень удобно: так можно совершенствовать и дорабатывать pod. Однако это самая слабая фиксация. 

В качестве примера сильной фиксации можно рассмотреть DifferenceKit. В нашем Podfile версия жёстко зафиксирована, не допускаются даже минорные апдейты. Фиксация на tag тоже является сильной.

PODS:
  - DifferenceKit (1.1.5):
	- DifferenceKit/Core (= 1.1.5)
	- DifferenceKit/UIKitExtension (= 1.1.5)
  - DifferenceKit/Core (1.1.5)
  - DifferenceKit/UIKitExtension (1.1.5):
	- DifferenceKit/Core
  - Nuke (9.5.1)
  - ReactiveDataDisplayManager (7.2.1)
  - SurfUtils/ItemsScrollManager (11.0.0)

DEPENDENCIES:
  - DifferenceKit (= 1.1.5)
  - Nuke (from `https://github.com/kean/Nuke.git`, tag `9.5.1`)
  - ReactiveDataDisplayManager (from `../`)
  - SurfUtils/ItemsScrollManager (from `https://github.com/surfstudio/iOS-Utils.git`, tag `11.0.0`)

SPEC REPOS:
  trunk:
	- DifferenceKit

EXTERNAL SOURCES:
  Nuke:
	:git: https://github.com/kean/Nuke.git
	:tag: 9.5.1
  ReactiveDataDisplayManager:
	:path: "../"
  SurfUtils:
	:git: https://github.com/surfstudio/iOS-Utils.git
	:tag: 11.0.0

CHECKOUT OPTIONS:
  Nuke:
	:git: https://github.com/kean/Nuke.git
	:tag: 9.5.1
  SurfUtils:
	:git: https://github.com/surfstudio/iOS-Utils.git
	:tag: 11.0.0

SPEC CHECKSUMS:
  DifferenceKit: 516f12e336ed65a3a0665847b5c3cb5cad4bd4ea
  Nuke: 2c1e5d49c9f92433c7f74a78b21292853aeda727
  ReactiveDataDisplayManager: 0803ea2950bb959e47f1d7bf5d5a7bc1bb290abb
  SurfUtils: ede1c5862f090d35be768a0b01d3ef447e24f54e

PODFILE CHECKSUM: dde7fa4fcb10df907a521cca9d054d4e969abba1

COCOAPODS: 1.10.2

Lock-файл создастся автоматически после команды pod install. Он не будет обновляться, пока не будет выполнена команда pod update. Отметим, что в конце файла зафиксирована версия cocoapods, которая использовалась для установки зависимостей. Вызов pod install с более новой cocoapods будет сопровождаться предупреждением.

Для распространения библиотеки средствами cocoapods понадобится:

  • Выложить исходники проекта в публичный репозиторий.

  • Добавить конфигурацию .podspec.

  • Провести проверку сборки проекта командой pod lib lint.

  • Зафиксировать версию библиотеки, поставив tag.

  • Отправить сборку в хранилище cocoapods командой pod trunk.

Последний шаг совсем не обязательный: и без него библиотека будет доступна, если указать git-репозиторий в Podfile. Заливка позволит получать библиотеку лишь по номеру версии, что несколько удобнее. А также добавит содержимое описания библиотеки из Readme.md в каталог cocoadocs.

Carthage

Аналогично cocoapods, позволяет импортировать библиотеки, написанные на Swift или Objective C. Однако сборка проектов происходит разово — в момент подключения зависимостей. 

В идеальном случае могут быть использованы уже собранные версии библиотеки. Однако версия Xcode, используемая для сборки проекта и библиотек, должна совпадать. Только тогда удастся использовать подготовленные версии.

Существенный минус carthage — сложность подключения зависимостей. Нужно понимать особенности линковки статических и динамических фреймворков и собственноручно интегрировать пути к ним в шагах Build Phases проекта. Частично упростить работу с carthage помогут утилиты кодогенерации для проектов Xcodegen или Tuist.

Команда

Описание

touch Cartfile

создаст пустой Cartfile

carthage bootstrap

установит зависимости согласно Carfile и Cartfile.resolved

carthage update

установит зависимости согласно Cartfile, но проигнорирует Cartfile.resolved

Чтобы поделиться библиотекой через Carthage, потребуется:

  • Выложить исходники проекта в публичный репозиторий.

  • Убедиться в том, что основная схема проекта публична.

  • Проверить сборку командой carthage build.

  • Зафиксировать версию библиотеки, поставив tag:

    • Запаковать результат сборки командой carthage archive.

    • Добавить полученные архивы к релизу.

Последний шаг можно сделать несколькими версиями Xcode, отметив версию в названии архива. Тогда carthage поймёт, кому какой архив стоит предложить.

Swift Package Manager

Децентрализованный менеджер зависимостей: исходники каждой библиотеки хранятся лишь в их репозиториях и не заливаются в общий хаб, как это возможно с cocoapods trunk. 

Отличительная особенность этого менеджера зависимостей — он убирает необходимость в xcworkspace и xcproject-файлах. Проект основывается на структуре папок, описаниях пакетов и их зависимостей в Package.swift. 

Команда 

Описание

swift package init

создаст пустой Package.swift — заглушку для проекта и тестов

swift package resolve

установит зависимости согласно Package.swift и Package.resolved

swift package update

установит зависимости согласно Package.swift, но перепишет Package.resolved

Рассмотрим пример Package.swift.

// swift-tools-version:5.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
	name: "3",
	platforms: [
   	.macOS(.v10_12),
	],
	dependencies: [
    	.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.2.0"))
	],
	targets: [
    	// Targets are the basic building blocks of a package. A target can define a module or a test suite.
    	// Targets can depend on other targets in this package, and on products in packages which this package depends on.
    	.target(
        	name: "3",
        	dependencies: ["Alamofire"]),
    	.testTarget(
        	name: "3Tests",
        	dependencies: ["3"]),
	]
)

Описание зависимостей написано прямо на Swift, это удобно. Поддерживаются и контекстные подсказки Xcode, позволяющие выбрать политику фиксации в удобочитаемом виде. UpToNextMajor говорит само за себя: позволяем минорные апдейты, но игнорируем обновление мажорной версии (Эквивалент ~> из cocoapods). Как и у других менеджеров зависимостей, источник каждого импортируемого модуля — публичный git-репозиторий.

{
  "object": {
	"pins": [
  	{
    	"package": "Alamofire",
    	"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
    	"state": {
      	"branch": null,
      	"revision": "becd9a729a37bdbef5bc39dc3c702b99f9e3d046",
      	"version": "5.2.2"
    	}
  	}
	]
  },
  "version": 1
}

Lock-файл фиксирует больше информации о зависимости. Например, в нём прописан конкретный хэш коммита, соответствующий указанной в Package.swift версии.

Для подготовки библиотеки к распространению через SwiftPM понадобится:

  • Подготовить структуру проекта, разделив исходники и тесты.

  • Добавить Package.swift с описанием зависимостей и расположением исходников и тестов.

  • Выложить исходники проекта в публичный репозиторий.

  • Зафиксировать версию библиотеки, поставив tag.


Условная схема фиксации версий для любого менеджера зависимостей выглядит так:

При этом при обновлении зависимостей мы просто игнорируем проверку на существование lock-файла.

Любое распространение — это публичная деятельность. Даже если вы распространяете скомпилированный фреймворк, в качестве промежуточного звена будет использован публичный репозиторий. Репозиторий позволяет хранить историю версий и получать помощь в разработке утилиты от неравнодушных пользователей.

Не стесняйтесь сохранять этот справочник в закладки, пользуйтесь opensource-проектами утилит и развивайте их. Спасибо за внимание, будьте на волне ????‍♂️.

Комментарии (2)


  1. a25
    17.06.2022 17:25

    Следует понимать, что импортированные модули буду собираться вместе с проектом, а не только в момент подключения.

    Скажите, пожалуйста, использовали ли или рассматривали инструменты вроде этого - https://guides.cocoapods.org/plugins/pre-compiling-dependencies.html? Если да, то какие результаты?


    1. NullIsOne
      17.06.2022 18:01

      Пробовали как-то..
      Это было актуально еще со старой системой сборки (Xcode Legacy Build System)
      Плагин `cocoapods-binary` действительно помогал уменьшить время инкрементальной сборки.
      Однако не с каждым подом это срабатывало. Приходилось настраивать индивидуально.
      С новой системой сборки проблема не так жестко стоит как раньше.