Разработка
Архитектурный линтинг для 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-проект.
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.16
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.17
-
Разработка4 недели назад
Расширенные архитектурные правила в SwiftLint: часть 1
-
Видео и подкасты для разработчиков4 недели назад
Не два байта переслать: эмуляция бесконтактных карт на мобильных устройствах