Пишем декларативный Package.swift: DSL для модульной архитектуры iOS проекта
Swift Package Manager сегодня является стандартным инструментом для модульной архитектуры iOS-проектов. Он позволяет разделять код на независимые модули,
ускорять сборку и явно описывать зависимости. Однако по мере роста проекта файл Package.swift часто превращается в длинный список строковых зависимостей:
.target(
name: "SomeFeature",
dependencies: [
"Core",
"UI",
"Resources"
]
)Меня всегда раздражала одна особенность Package.swift:
мы описываем зависимости, но не описываем архитектуру, из-за этого:
- переименование модулей усложняется;
- архитектурные правила не проверяются компилятором;
- количество повторяющегося кода быстро растёт.
В этой статье вместо того, чтобы рассматривать Package.swift как простой конфигурационный файл, превратим его в типобезопасный DSL для модульной архитектуры, где:
- модули описываются через enum;
- фичи генерируются декларативно;
- архитектурные правила фиксируются в коде.
В итоге объявление зависимостей будет выглядеть так:
Libraries.allCases.map { $0.info.buildDependency() }
Local.Core.target()
Local.UI.target(deps: [.module(.Core)])
Local.DI.target(deps: [.module(.Core)])
Local.Resources.target()
featureTargets(module: { .SomeFeature($0)}) Погнали!
Ключевая особенность SwiftPM в том, что манифесты — это обычные Swift-файлы. Это значит, что мы можем использовать возможности языка для описания архитектуры, пусть и с некоторыми ограничениями.
и при попытке сделать проект с многомодульной архитектурой я получал примерно следующее:
.target(
name: "NewsPresentation",
dependencies: [
"NewsDomain",
"Core",
"UI",
"Resources"
]
)
.target(
name: "NewsDomain",
dependencies: [
"NewsData",
"Core"
]
)
.target(
name: "NewsData",
dependencies: [
"Core"
]
)Если в проекте пять фич — это уже 15 объявлений target. Если десять — то 30.
Используя DSL, ту же архитектуру можно выразить одной строкой:
featureTargets(module: { .News($0)})при этом будут сгенерированы следующие слои:
с уже правильно настроенным графом зависимостей
Проектируем DSL
Основная идея очень простая: большинство модульных архитектур следуют предсказуемым шаблонам. Вместо того чтобы повторять эти шаблоны в Package.swift, мы можем описать их прямо на Swift.
Объявление сторонних библиотек:
enum RemotePackages: CaseIterable {
case Alamofire
var spec: RemotePackageSpec {
switch self {
case .Alamofire:
return .init(
"https://github.com/Alamofire/Alamofire.git",
packageName: "Alamofire",
version: "5.10.0"
)
}
}
}Теперь список зависимостей можно сгенерировать декларативно:
RemotePackages.allCases.map { $0.info.buildDependency() }enum Local {
case Core
case UI
case DI
case Resources
case Networking
}После этого объявление target выглядит так:
Local.Core.target() Local.UI.target(deps: [.module(.Core)]) Local.DI.target(deps: [.module(.Core)])
enum Local {
case Core
case UI
case DI
case Resources
case Networking
case News(_ layer: FeatureLayer) // обьявляем фича модуль
}Фича состоит из нескольких слоев:
enum FeatureLayer: String {
case Presentation
case Domain
case Data
}Эти слои будут использоваться для автоматической генерации target-модулей.
Настоящая же ценность DSL — в кодировании правил зависимостей между слоями:
func featureTargets(
module: ( _ layer: FeatureLayer) -> Local,
presentationExtra: [Target.Dependency] = [],
domainExtra: [Target.Dependency] = [],
dataExtra: [Target.Dependency] = []
) -> [Target] {
let presentation = module(.Presentation)
let domain = module(.Domain)
let data = module(.Data)
return [
presentation.target(deps: [
.module(domain.name),
.module(.Core),
.module(.UI),
.module(.Resources)
] + presentationExtra),
domain.target(deps: [
.module(data.name),
.module(.Core)
] + domainExtra),
data.target(deps: [
.module(.Core),
.module(.Networking)
] + dataExtra)
]
}И теперь объявление feature-модуля выглядит так:
featureTargets(module: { .Authorisation($0)} ])
featureTargets(module: { .News($0)} )Настройка FeatureLayer
Еще одно преимущество такого подхода — структура слоев полностью настраиваемая. Команда может выбирать ее в зависимости от архитектуры проекта.
Например, можно разделить фичу на API и реализацию:
Или использовать более детализированную структуру, например VIPER:
Важно не количество слоев, а правила зависимостей между ними.
Именно эти правила DSL позволяет зафиксировать в коде.
// swift-tools-version: 5.9
import PackageDescription
import Foundation
// MARK: - Declarations
enum ProjectPaths {
static let sources = "Sources"
}
// MARK: Local Modules
enum Local {
case Core
case UI
case DI
case Resources
case Networking
case Router(_ layer: FeatureImplLayer)
case MainScreen(_ layer: FeatureLayer)
case DetailScreen(_ layer: FeatureLayer)
}
// MARK: Remote Packages
enum RemotePackages: CaseIterable {
case Alamofire
var spec: RemotePackageSpec {
switch self {
case .Alamofire:
return .init(
"https://github.com/Alamofire/Alamofire.git",
packageName: "Alamofire",
version: "5.8.0"
)
}
}
}
// MARK: Feature Layering System (Optional)
enum FeatureLayer: String {
case Presentation, Domain, Data
}
enum FeatureImplLayer: String {
case Impl, Api
}
// MARK: - Package Formation
let packageName = "DemoApp"
let package = buildPackage(
name: packageName,
defaultLocalization: "en",
platforms: [.iOS(.v15)]
) {
[
Local.Router(.Impl).product(),
Local.Router(.Api).product(),
Local.Core.product()
]
} dependencies: {
RemotePackages.allCases.map { $0.spec.buildDependency() }
} targets: {
// Base modules
Local.Networking.target(deps: [.module(.Core), .library(.Alamofire)])
Local.UI.target(deps: [.module(.Core)])
Local.DI.target(deps: [.module(.Core)])
Local.Core.target()
Local.Resources.target(resources: [
.process("Resources.xcassets")
])
featureTargets(module: { .Router($0) }, implementationExtra: [
.module(.MainScreen(.Presentation)),
.module(.DetailScreen(.Presentation))
])
featureTargets(module: { .MainScreen($0)},
presentationExtra: [ .module(Local.DI)] )
featureTargets(module: { .DetailScreen($0)},
presentationExtra: [ .module(Local.DI)] )
}
// MARK: - Feature configuration
func featureTargets(
module: ( _ layer: FeatureLayer) -> Local,
presentationExtra: [Target.Dependency] = [],
domainExtra: [Target.Dependency] = [],
dataExtra: [Target.Dependency] = []
) -> [Target] {
let presentation = module(.Presentation)
let domain = module(.Domain)
let data = module(.Data)
return [
presentation.target(deps: [
.module(domain.name),
.module(.Core),
.module(.UI),
.module(.Resources)
] + presentationExtra),
domain.target(deps: [
.module(data.name),
.module(.Core)
] + domainExtra),
data.target(deps: [
.module(.Core),
.module(.Networking)
] + dataExtra)
]
}
func featureTargets(
module: ( _ layer: FeatureImplLayer) -> Local,
implementationExtra: [Target.Dependency] = [],
apiExtra: [Target.Dependency] = []
) -> [Target] {
let implementation = module(.Impl)
let api = module(.Api)
return [
implementation.target(deps: [
.module(api),
.module(.Core)
] + implementationExtra),
api.target(deps: apiExtra)
]
}
// DSL PART
// MARK: - Helpers превращает enum Local в рабочее описание модуля
extension Local {
var name: String {
let parsed = parsedDescription
if let layer = parsed.layer {
return "\(parsed.base)_\(layer)"
}
return parsed.base
}
private var path: String {
let parsed = parsedDescription
if let layer = parsed.layer {
return "\(ProjectPaths.sources)/Features/\(parsed.base)/\(layer)"
}
return "\(ProjectPaths.sources)/\(parsed.base)"
}
private func module(_ resources: [Resource]?) -> TargetSpec {
return TargetSpec(name: name, path: path, resources: resources)
}
private var module: TargetSpec { TargetSpec(name: name, path: path) }
func target(deps: [Target.Dependency] = [], resources: [Resource]? = nil) -> Target {
module(resources).target(deps: deps)
}
func product() -> Product {
module.product()
}
}
//Упрощает объявление зависимостей
extension Target.Dependency {
static func module(_ m: Local) -> Target.Dependency {
.target(name: m.name)
}
static func module(_ name: String) -> Target.Dependency {
.target(name: name)
}
static func library(_ lib: RemotePackages) -> Target.Dependency {
.product(name: lib.spec.productName,
package: lib.spec.packageName)
}
}
// MARK: - DSL Core
// Позволяет декларативно собирать список Target
@resultBuilder
enum TargetsBuilder {
static func buildBlock(_ parts: [Target]...) -> [Target] {
parts.flatMap { $0 }
}
static func buildExpression(_ t: Target) -> [Target] { [t] }
static func buildExpression(_ ts: [Target]) -> [Target] { ts }
}
// Позволяет декларативно собирать список Product
@resultBuilder
enum ProductsBuilder {
static func buildBlock(_ parts: [Product]...) -> [Product] {
parts.flatMap { $0 }
}
static func buildExpression(_ p: Product) -> [Product] { [p] }
static func buildExpression(_ ps: [Product]) -> [Product] { ps }
}
// Позволяет декларативно собирать список Package.Dependency.
@resultBuilder
enum DependenciesBuilder {
static func buildBlock(_ parts: [Package.Dependency]...) -> [Package.Dependency] {
parts.flatMap { $0 }
}
static func buildExpression(_ d: Package.Dependency) -> [Package.Dependency] { [d] }
static func buildExpression(_ ds: [Package.Dependency]) -> [Package.Dependency] { ds }
}
// Обёртка над Package, чтобы собирать package через твой DSL
func buildPackage(
name: String,
defaultLocalization: LanguageTag? = nil,
platforms: [SupportedPlatform] = [],
@ProductsBuilder products: () -> [Product],
@DependenciesBuilder dependencies: () -> [Package.Dependency],
@TargetsBuilder targets: () -> [Target]
) -> Package {
PackageSpec(
name: name,
defaultLocalization: defaultLocalization,
platforms: platforms,
products: products(),
dependencies: dependencies(),
targets: targets()
).build()
}
// MARK: - Specs
// Промежуточная модель для сборки Package
struct PackageSpec {
var name: String
var defaultLocalization: LanguageTag?
var platforms: [SupportedPlatform] = []
var products: [Product] = []
var dependencies: [Package.Dependency] = []
var targets: [Target] = []
func build() -> Package {
Package(
name: name,
defaultLocalization: defaultLocalization,
platforms: platforms,
products: products,
dependencies: dependencies,
targets: targets
)
}
}
// Модель для описания внешнего пакета
struct RemotePackageSpec {
let url: String
let packageName: String
let productName: String
let version: Version
init(_ url: String,
packageName: String,
productName: String? = nil,
version: Version) {
self.url = url
self.packageName = packageName
self.productName = productName ?? packageName
self.version = version
}
func buildDependency() -> Package.Dependency {
.package(url: url, from: version)
}
}
// Модель для описания target
struct TargetSpec {
private let name: String
private let path: String
private let resources: [Resource]?
init(name: String, path: String, resources: [Resource]? = nil) {
self.name = name
self.path = path
self.resources = resources
}
func target(deps: [Target.Dependency] = []) -> Target {
.target(
name: name,
dependencies: deps,
path: path,
resources: resources
)
}
func testTarget(deps: [Target.Dependency] = []) -> Target {
.testTarget(
name: name,
dependencies: deps,
path: path
)
}
func product() -> Product {
.library(name: name, targets: [name])
}
}
// MARK: - Helper to automatically generate the feature path and name for import
extension Local {
private var parsedDescription: (base: String, layer: String?) {
let description = String(describing: self)
guard
let start = description.firstIndex(of: "("),
let end = description.firstIndex(of: ")")
else {
return (description, nil)
}
let base = String(description[..<start])
var layer = String(description[description.index(after: start)..<end])
// remove type prefix like "Main.FeatureLayer."
if let last = layer.split(separator: ".").last {
layer = String(last).capitalizedFirst
}
return (base, layer)
}
}
extension String {
var capitalizedFirst: String {
prefix(1).uppercased() + dropFirst()
}
}
Все что используется для настройки проекта, находится до "DSL PART"
Код демо-проекта
Если хотите попробовать DSL в реальном проекте:
P.S. Архитектура в каждой команде "немного" своя. Поэтому, пример в статье намеренно упрощён — чтобы было легче увидеть саму идею DSL.