Разработка
Архитектурный линтинг для Swift: часть 3
Как мы обеспечиваем архитектурную целостность больших legacy приложений на Swift с помощью Harmonize.
В этой серии статей мы рассказали о трудностях написания правил архитектурного линтинга в SwiftLint и представили Harmonize, новый архитектурный линтер с открытым исходным кодом для Swift. В предыдущей статье мы рассказали, как Harmonize позволяет разработчикам, используя простой в применении DSL, писать правила для того, чтобы слои приложения сочетались друг с другом.
В этой статье мы поделимся практическими советами, которые мы нашли, используя Harmonize в течение почти года в нашей компании (обратите внимание, что эти правила также применимы и к проектам Konsist для Android).
Совет 1: ссылайтесь на код по одному слою за раз
Правила архитектуры часто обусловлены проблемами, связанными с конкретными слоями. В нашем проекте мы организуем наши тесты Harmonize по слоям, и это начинается с доступа к вспомогательным методам фронтенда Harmonize, productionCode и testCode. В наших тестах мы определим глобальную статику, которая будет ресолвится один раз:
static var productionCode = { Harmonize.productionCode() }()
static var productionAndTestCode = { Harmonize.productionAndTestCode() }()
Далее мы можем фильтровать по пакетам:
static var presentationPackage = { productionCode.on("SwiftPackages/Presentation/Sources/Screens") }()
Или мы можем разделить наш код на слои, используя окончания имен файлов:
static var classesProduction = { productionCode.classes(includeNested: true) }()
static var viewModelsProduction = { classesProduction.withSuffix("ViewModel") }()
static var logicClassesProduction = { classesProduction.withSuffix("Logic") }()
static var repositoryClassesProduction = { classesProduction.withSuffix("Repository") }()
В данном тесте мы начнем с того, что определим, какой слой рассматривается:
let viewModels = PSSHarmonize.viewModelsProduction
Совет 2: организуйте правила в папки
Тест может ссылаться на любой необходимый ему слой, используя вышеприведенный синтаксис, но мы также организуем наши правила Harmonize в структуру папок, что значительно облегчает поиск старых правил и предлагает рекомендации по добавлению новых правил. Тесты интегрируются в Xcode, так что вы можете увидеть точную строку или класс, вызывающий определенный сбой. Наша структура папок с правилами Harmonize включает папки для логики (она же UseCases), моделей, сети, протоколов, ui, репозиториев и тестов (да, мы даже пишем правила линтинга для наших тестов!).
Совет 3: используйте базовые линии для внедрения новых правил в условиях технического долга
Что вы используете в большом Swift-проекте, когда хотите заблокировать новые использования старого паттерна, но не имеете времени на рефакторинг всего проекта сразу? Базовые линии (Baselines).
Базовые линии позволяют вам освободить линтер от применения к определенным файлам или частям вашего исходного кода. SwiftLint только недавно представил поддержку базовых линий, но подходит к ним непрактично: либо запуская скрипт, который применяет их вплоть до строки и столбца, что требует от разработчиков постоянного повторного запуска скрипта при любом изменении файла, либо включая специальные комментарии вокруг каждого нарушающего участка в коде, что утомительно, если у вас большое количество нарушений.
Как видите, в нашем проекте базовый файл имеет длину более 50 тысяч строк и размер 1.7 М!
% wc -l baseline.json 50325 baseline.json % ls -lh baseline.json -rwxr-xr-x 1 user staff 1.7M Apr 4 10:39 baseline.json
В качестве альтернативы можно ввести правило SwiftLint как предупреждение, но это создаст ситуацию, когда ваша кодовая база будет перегружена предупреждающими сообщениями до такой степени, что вы будете получать нарушения, в их все будут проигнорировать.
Мы считаем, что предупреждений быть не должно — любое новое нарушение правила является ошибкой и прошлые нарушения либо исправляются, либо помещаются в базовую линию. В Harmonize все неудачные ассерты являются ошибками, поэтому вы просто исключаете файлы с помощью .withoutName(Self.baseline) и задаете имена для каждого класса или каждого файла.
Вот пример:
PSSHarmonize.logicClassesProduction.withoutName(baseline) private static let baseline: [String] = [...]
В принципе, технический долг не должен мешать нам внедрять новые правила линтера. Теперь он задокументирован в массиве baseline каждого нового правила Harmonize, и его можно будет устранить, когда потребуется модифицировать эти унаследованные классы.
Создание сложных правил
Стоит сравнить наши более сложные правила с эквивалентным подходом в SwiftLint, чтобы понять, почему большинство инженеров не пытаются использовать SwiftLint для правильного насаждения архитектуры.
Чтобы написать эффективное правило проверки, вам нужны три ограничения: слой, тип объекта и логика. В SwiftLint ограничение слоя обычно простое и достигается с помощью фильтра имен файлов. Ограничения типа объекта — менее известная особенность SwiftLint, которая достигается с помощью загадочного синтаксиса, например source.lang.swift.decl.class. Здесь на помощь приходят регулярные выражения, простота которых сильно зависит от разрабатываемого правила.
Пример 1: отслеживаем файловую структуру проекта
По мере развития проектов разные разработчики придумывают различные шаблоны, связанные со структурой файлов и папок. В нашем проекте, в слое представления, мы остановились на следующей структуре:
// Screens/ // └── Feature/ // ├── FeatureScreen.swift // ├── FeatureAdapter.swift // ├── FeatureDrawer.swift // ├── Components/ // │ ├── FeatureCard.swift // │ └── FeatureHeader.swift // ├── Extensions/ // │ ├── SomeLogicError+Extensions.swift // ├── ViewControllers/ // │ └── FeatureViewController.swift
Это правило нельзя легко построить с помощью регулярных выражений SwiftLint, поскольку регулярные выражения применяются к содержимому файла, а не к его местоположению. Его можно построить с помощью более сложного правила SwiftSyntaxRule.
В Harmonize создать такое правило гораздо проще. SwiftSourceCode — это объект первого класса, который дает вам доступ к местоположению и содержимому файла. В результате, благодаря методу sources(), который является частью HarmonizeScope, мы смогли определить следующие правила, построенные на основе ряда удобных и многократно используемых вспомогательных методов:
When("File is a screen file") {
let screenFiles = screenPackage.sources()
.withoutName(Self.screenFilesBaseline)
.withNameEndingWith("Screen.swift")
Then("it should be in the root") {
screenFiles.shouldBeInRoot()
}
}
When("File is an extensions file") {
let extensionFiles = screenPackage.sources()
.withoutName(Self.extensionFilesBaseline)
.withNameEndingWith("+Extensions.swift")
Then("File should be in the extensions folder") {
extensionFiles.shouldBeIn(expectedSubpackage: "Extensions")
}
}
Given("A View in production") {
let views = PSSHarmonize.presentationViews
Then("Baseline can use the legacy LinkableActionImplementing protocol") {
views.withName(baselineViews).variables().withType(named: "LinkableActionImplementing")
.assertNotEmpty(message: message)
}
Then("New files are not using the legacy LinkableActionImplementing protocol") {
views.withoutName(baselineViews).variables().withType(named: "LinkableActionImplementing")
.assertEmpty(message: message)
}
}
Пример 2: обеспечиваем последовательные значения в дизайн-системе
Вот правило, которое гарантирует, что мы используем последовательные значения, соответствующие нашей системе дизайн-системе:
Given("I have the GridSpacer enum") {
let gridSpacerEnum = PSSHarmonize.designSystemPackage.enums()
.withName("GridSpacer")
.first
When("I check its' cases") {
let gridSpacerCases = gridSpacerEnum?.cases
Then("All their values are divisible by 4") {
gridSpacerCases?.assertFalse(
message: Message,
condition: {
guard let value = $0.initializerClause?.value,
let intValue = Int(value) else { return true }
return intValue < 0 || intValue % 4 != 0
}
)
}
}
}
Спойлер: regex не может выполнять арифметические действия, поэтому он не может определить делимость на 4!
Пример 3: убеждаемся в использовании ObservedObjects
Это правило гарантирует, что ObservedObjects внутри представления SwiftUI действительно используются; в противном случае представление будет обновляться при изменении значения @Published во ViewModel без веской причины. Это правило длиннее, но оно читается сверху вниз, и при необходимости его можно отлаживать на каждом промежуточном шаге:
final class ObservedObjectIsUsed: QuickSpec {
static let MESSAGE = "An @ObservedObject must be used"
override class func spec() {
Given("A screen or feature in the husband module") {
let featuresPackage = PSSHarmonize.featuresPackage
When("It has an observed view model") {
let structsWithObservedViewModels = featuresPackage.structs()
.withVariables { variable in
variable.hasAnnotation(.observedObject) && variable.name.contains("viewModel")
}
Then("The view model is used in the screen or feature") {
structsWithObservedViewModels.forEach { someStruct in
let observedViewModels = someStruct.variables.filter { variable in
variable.hasAnnotation(.observedObject) && variable.name.range(of: "viewModel", options: .caseInsensitive) != nil
}
let varsThatAreNotUsed = observedViewModels.filter { variable in
let varsThatUseThisVariable =
someStruct.variables.filter { someFunc in
(someFunc.getter?.body?.statements ?? []).filter({ stmt in
stmt.description.contains(variable.name)
}).isNotEmpty
}
let funcsThatUseThisVariable =
someStruct.functions.filter { someFunc in
(someFunc.body?.statements ?? []).filter({ stmt in
stmt.description.contains(variable.name)
}).isNotEmpty
}
return varsThatUseThisVariable.isEmpty && funcsThatUseThisVariable.isEmpty
}
varsThatAreNotUsed.assertEmpty(message: Self.MESSAGE)
}
}
}
}
}
}
В SwiftLint требуется негативное опережение (negative-lookahead), которое, вероятно, может написать только искусственный интеллект (который мы и использовали), но это все равно не выявит некоторые случаи (согласно ChatGPT) в зависимости от того, где в файле объявлены переменные:
custom_rules:
observedobject_must_be_used:
name: "ObservedObject must be used"
included: "Sources/"
regex: |
(?m) # multi-line mode
^\s*@ObservedObject\s+(var|let)\s+(viewModel\w*)\b.*$ # capture the variable name
(?![\s\S]*\2(?![\w])) # fail if that name doesn't appear again in file
message: "An @ObservedObject named 'viewModel*' must be used somewhere in the file"
severity: error
И если по какой-то причине это выражение не сработает… удачи в отладке регулярного выражения.
Harmonize против SwiftLint: итоги
SwiftLint — мощный инструмент, но его неспособность упростить AST приводит к сложным, трудным для написания и поддержки регулярным выражениям, на создание которых мало кто из разработчиков тратит время. Более того, не существует простого способа перенести правила, написанные в SwiftLint, на Android, что мешает кроссплатформенным командам. Хрупкий подход SwiftLint к базовым линиям и отсутствие поддержки организации правил делают его непрактичным для проектов, стремящихся к внедрению архитектуры.
Сегодня в нашей компании технические ретро как минимум на 50% состоят из разговоров о правилах линтера.
Недавно, когда мы представили новый паттерн для ViewModel, мы не сказали «пожалуйста, запомните»; мы сказали «это правило вступает в силу».
Естественно, в течение следующей недели некоторые разработчики столкнулись с ошибками, и им пришлось поднимать дополнительные вопросы, но правила Harmonize гарантировали, что паттерн будет применяться и изучаться точно в срок.
Что дальше
В следующей статье мы расскажем о том, как интегрировать Harmonize в ваш Swift-проект.
Статьи из этой серии
- Архитектурный линтинг для Swift: часть 4
- Архитектурный линтинг для Swift: часть 3
- Архитектурный линтинг для Swift: часть 2
- Расширенные архитектурные правила в SwiftLint: часть 1
-
Аналитика магазинов2 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Интегрированные среды разработки3 недели назад
Chad: The Brainrot IDE — дикая среда разработки с играми и развлечениями
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.45
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.46



