Connect with us

Разработка

Архитектурный линтинг для 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, репозиториев и тестов (да, мы даже пишем правила линтинга для наших тестов!).

Архитектурный линтинг для Swift: часть 3

Совет 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 как предупреждение, но это создаст ситуацию, когда ваша кодовая база будет перегружена предупреждающими сообщениями до такой степени, что вы будете получать нарушения, в их все будут проигнорировать.

Архитектурный линтинг для Swift: часть 3

Мы считаем, что предупреждений быть не должно — любое новое нарушение правила является ошибкой и прошлые нарушения либо исправляются, либо помещаются в базовую линию. В 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")
    }
}

Тестирование тестов

Один из плюсов создания SwiftSyntaxRule, совместимого со SwiftLint, заключается в том, что вы можете включить в него triggeringExamples и nonTriggeringExamples, что, по сути, гарантирует, что вы сможете протестировать введенные вами правила.

Ни в кастомных правилах на основе регулярных выражений SwiftLint, ни в правилах Harmonize, написанных на Quick, нет хорошего подхода в том, как тестировать сами правила. В Harmonize потенциально можно взять baseline файлы и перевернуть логику, чтобы убедиться, что правило дает положительные, срабатывающие результаты.

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-проект.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: