Архітектура від Монті Пайтонів

Настав час і мені спробувати себе в ролі технічного блогера. Здається, після того, як я пішов у вільне фрілансерське плавання на Апворку, я нарешті почав любити програмування достатньо, щоби про нього писати.

Сьогодні буде про архітектуру в iOS/Mac OS програмах і чому деякі її зразки викликають стійку асоціацію з творчістю Монті Пайтонів


Взагалі, пік архітектурного хайпу, здається, минув кілька років тому, проте час від часу зустрічаються проєкти, де команда дотримується таких стандартів, що виникає підозра, чи не отримали вони, бува, гранта від Міністерства химерних архітектур.

Принесло недавно й мені хвилями вільного апворківського ринку клієнта з вимогою використовувати Swift Composable Architecture (в подальшому SCA). На питання “чому вона?” я отримав відповідь “а чом би й ні” (правда, з техлідом мені так і не довелося поговорити, спілкувався з нетехнічним менеджером). З першого погляду в мене виникло враження, що від мене вимагають ходити, роблячи монтіпайтонівські вихиляси на кожному другому кроці. Після спроби написати невеличку програмку в потрібному стилі враження тільки зміцніло.

Якщо в деталях, то от що пропонують нам автори SCA, з моїми коментарями.

  • State management
    How to manage the state of your application using simple value types, and share state across many screens so that mutations in one screen can be immediately observed in another screen.

– стандартні iOS фреймворки дозволяють це робити, причому багатьма способами. Зокрема, з версії iOS 13 з’явився Combine. До того, якщо йдеться спеціально про комунікацію між різними екранами/сторінками, існували NotificationCenter та Key-Value Observing. Я зазирнув у код SCA – він побудований на Combine. То що такого може робити SCA, чого Combine не вміє?

  • Composition
    How to break down large features into smaller components that can be extracted to their own, isolated modules and be easily glued back together to form the feature.

– це один із загальних аспектів майстерності розробника. Усі товсті книжки з об’єктно-орієнтованого програмування та інших парадигм розробки – розгорнута
відповідь на це питання. Якщо SCA дає універсальне рішення, тоді це просто священний Грааль.

  • Side effects
    How to let certain parts of the application talk to the outside world in the most testable and understandable way possible.

– а чому це взагалі проблема? Насправді я здогадуюсь. Автори SCA забагато займались функціональним програмуванням. Вони занадто вірять у чисті функції. Проблема в тому, що з самих чистих функцій неможливо побудувати реальну програму, яка має робити щось корисне: навіть друк у консоль – це вже побічний ефект. Тобто природа проблеми суто доктринальна. Якщо ви не вірите в астрологію, який-небудь ретроградний Меркурій для вас не проблема. Так і тут.

  • Testing
    How to not only test a feature built in the architecture, but also write integration tests for features that have been composed of many parts, and write end-to-end tests to understand how side effects influence your application. This allows you to make strong guarantees that your business logic is running in the way you expect.

– по-перше, інші архітектурні рішення теж пропонують тестабельність. Тут SCA не має жодної переваги. По-друге, якщо автоматичні тести не є частиною вашого процесу розробки, тестабельність вашого коду залишається суто абстрактним благом. А так запросто може бути, якщо ви, наприклад, розробляєте якесь експериментальне рішення. Ви не знаєте, як воно в кінцевому підсумку працюватиме й чи працюватиме взагалі, тому не можете наперед спланувати інтерфейси й написати тести на них. Не кажучи вже про те, що далеко не всім командам вистачає (само)дисципліни, щоб систематично покривати код тестами.

  • Ergonomics
    How to accomplish all of the above in a simple API with as few concepts and moving parts as possible.

– можливо, це й економить час на вивчення самої SCA, та коли доходить до реального використання, виникає інше питання: “як його в біса зробити в SCA те, що в Combine робиться в один рядок?”. Як визнають самі автори SCA на іншій сторінці,

 It’s definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components. It also gives us a concise way of expressing side effects. And we can immediately test this logic, including the effects, without doing much additional work.

Тобто вам пропонують витрачати додаткові робочі години на такі досить абстрактні блага як “послідовний спосіб застосування змін стану” та “лаконічний спосіб опису побічних ефектів”. За моєю приблизною оцінкою, з 12 годин, які я витратив на реалізацію одного екрану на SCA, 4 пішли на боротьбу з фреймворком.

От що пропонують автори SCA замість “scattering logic in some observable objects and in various action closures of UI components”:

struct Feature: ReducerProtocol {
  struct State: Equatable { … }
  enum Action: Equatable { … }
  
  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
      case .factAlertDismissed:
        state.numberFactAlert = nil
        return .none

      case .decrementButtonTapped:
        state.count -= 1
        return .none

      case .incrementButtonTapped:
        state.count += 1
        return .none

      case .numberFactButtonTapped:
        return .task { [count = state.count] in
          await .numberFactResponse(
            TaskResult {
              String(
                decoding: try await URLSession.shared
                  .data(from: URL(string: "http://numbersapi.com/\(count)/trivia")!).0,
                as: UTF8.self
              )
            }
          )
        }

      case let .numberFactResponse(.success(fact)):
        state.numberFactAlert = fact
        return .none

      case .numberFactResponse(.failure):
        state.numberFactAlert = "Could not load a number fact :("
        return .none
    }
  }
}

Якщо порівняти з іншими архітектурами, як-от Модель – Представлення – Контролер (Model – View – Controller) чи Модель – Представлення – Модель Представлення (Model – View – View Model), то видно, що в SCA всю логіку середньої ланки (контролера чи моделі представлення) запхано в метод func reduce(into state: inout State, action: Action) -> EffectTask<Action>.
Уже в наведеному вище іграшковому прикладі, взятому з README на гітхабі SCA, switch-case усередині цього метода досить великий. Боюся, що в реальних прикладах він матиме схильність до безконтрольного розростання. Знаєте, що це мені нагадує? Відомий антипатерн “The Blob”.

А якщо подивитися на код відповідного представлення,

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        HStack {
          Button("−") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

можна помітити що це також схоже на те, як працює мова Objective-C та @objc методи на Свіфті. Тут Feature.Action – відповідник селектора, а viewStore.send – відповідник objc_msgSend. Тобто SCA змушує розробника виконувати роботу за компілятор, котрий перетворює використання @objc методів на виклики objc_msgSend, і для кожного конкретного випадку створювати свій відповідник тієї частини стандартної бібліотеки Objective-C, яка пов’язує селектори з відповідними імплементаціями. Крім людських зусиль, тут марнується ще й процесорний час (і заряд батареї пристрою, якщо йдеться про iOS), адже замість простого виклику метода (наприклад, Button("−") { viewModel.decrement() } )  SCA-еквівалент
(Button("−") { viewStore.send(.decrementButtonTapped) } ) робить
– виклик viewStore.send;
– виклик проміжної логіки viewStore;
– виклик Feature.reduce;
– розв’язання Action всередині Feature.reduce.

Священного Грааля не вийшло. Вийшла додатково погіршена обгортка над можливостями стандартних бібліотек, яка просто полегшує писання в певному (неоптимальному) стилі. Тобто виграш зводиться до абстрактної коректності коду. Як уже було сказано, навіть тестабельність не є безумовним благом. Якщо ви не знаєте, як абстрактні переваги ваших архітектурних рішень перетворюються на реальну економію часу та грошей, у вас сумнівні рішення. Це не лише SCA стосується. Іншим разом, може, напишу про VIPER: теж маю історію.

З SCA мені досить було двох днів. Там у клієнта вистачало особливостей і крім архітектури, так що я попрощався й грошей не взяв. Щоби брати гроші за всякі вихиляси, треба було ставати не програмістом, а професійним блазнем. Як Монті Пайтони.

Leave a Reply

Your email address will not be published. Required fields are marked *