Проверка внедрения зависимостей на Swift

в 15:34, , рубрики: dependency injection, swift, tinkoff, Блог компании Tinkoff.ru

image

Проект с Dependency Injection похож на елочную гирлянду — он красив, радует детей и взрослых. Но если где-то не внедрить зависимость, отключится целый сегмент приложения. И, чтобы найти источник проблем, придется проверять все зависимости в этом сегменте.

В этой статье описаны несколько вариантов поиска пустых зависимостей. А в нашем репозитории есть небольшая библиотека, которая поможет в этом: TinkoffCreditSystems/InjectionsCheck

Внедрение зависимостей в Init

Объявить все зависимости как non-optional и внедрить их в Init — это самый надежный способ.

class TheObject: IObjectProtocol {

  var service: IService

  init( _ service: IService) {
    self.service = service
  }
}

Плюсы:

  • Проект не соберется, если что-то забыли инициализировать.
  • Компилятор укажет, что и где забыли.

Минусы:

  • Невозможно использовать циклические зависимости.
  • Много параметров в Init.
  • Нельзя внедрить зависимости во ViewController и подобные объекты.

Проверка зависимостей в Тестах и DI-контейнере

Можно использовать тесты, чтобы проверить, все ли зависимости на месте. Но следить за ними сложнее, чем за объектами, поэтому нужен простой способ поддерживать такие тесты в актуальном состоянии. С этим поможет функция, которая проверяет все optional-свойства объекта и сообщает об ошибке, если находит nil.

Чтобы быть уверенными, что тесты не пропустят пустые зависимости, необходимо заставить их проверять всё, кроме зависимостей из списка исключений. Проще поддерживать список исключений, чем список свойств, которые надо проверять. Если исключение не будет прописано, тест провалится, и это будет заметно.

Плюсы:

  • Больше свободы во внедрении зависимостей.
  • Можно внедрять зависимости про протоколу.

Минусы:

  • Нужно запускать тесты, чтобы убедиться, что ничего не забыли.
  • Все проверяемые объекты должны быть в тестах.
  • Для зависимостей нужно использовать guard или service?.doSomething.

В Swift эту функцию можно создать с помощью рефлексии и класса Mirror. Он позволяет обойти все свойства и посмотреть их значения.

func checkInjections(of object: Any) {
    print(“Properties of (String(reflecting: object))”)
    for child in Mirror(reflecting: object).children {
        print(“t(child.label) = (child.value)”)
    }
}

label: String — имя свойства,
value: Any — значение, которое может быть nil.

Но есть одна неприятность:

if child.value == nil { // Ошибка компиляции
}

Swift не дает сравнить Any с nil. Формально он прав, ведь «Any» и «Any?» — разные типы. Поэтому нужно снова воспользоваться классом Mirror, чтобы найти в Any финальный Optional и узнать, равен ли он nil.

fileprivate func unwrap<ObjectType: Any>(_ object: ObjectType) -> ObjectType? {

    let mirror = Mirror(reflecting: object)

    guard mirror.displayStyle == .optional else {
        return object
    }

    guard let child = mirror.children.first else {
        return nil

    }
    return unwrap(any: child.value) as? ObjectType
}

Рекурсия используется из-за того, что в Any может быть вложенный Optional, например, IService??.. Результат этой функции выдаст обычный Optional, который можно сравнить с nil.

Список исключений для свойств

Для некоторых объектов не все свойства нужно проверять на nil, поэтому добавим список свойств-исключений. Из-за того, что child.label — это строка, задать исключения можно только так:

  • Массив имен свойств [String]
  • Массив селекторов #Selector(keypath:)(только для свойств с objc)
  • Массив ключей #keyPath()(только для свойств с objc)

Почему нельзя использовать Swift 4 KeyPath

Swift 4 KeyPath позволяет получить значение по ключу — объекту, а не строке. Сейчас невозможно получить имя свойства в виде строки. И нельзя получить полный список всех KeyPath, чтобы пройтись по ним.

Используем протокол SelectorName, чтобы поддержать все варианты без приведения типа, а также enum:

protocol SelectorName {
  var value: String
}

class TheObject: IObjectProtocol {

  var notService: INotService?

  enum SelectorsToIgnore: String, SelectorName {
    case notService
  }
}

Поддержка протокола SelectorName для String, Selector, и RawRepresentable

extension String: SelectorName {
  var value: String {
    return self 
  }
}

extension Selectoe: SelectorName {
  var value: String {
    return String(describing: self) 
  }
}

extension SelectorName where Self: RawRepresentable, RawType == String {
  var valur: String {
    return self.rawValue
  }
}

Итоговый код функции для проверки внедрения зависимостей будет выглядеть так:

func checkInjections

enum InjectionCheckError: Error {
  case notInjected(properties: [String], in: Any)
}

public func checkInjections<ObjectType>(
    _ object: ObjectType,
    ignoring selectorsToIgnore: [SelectorName] = []
    ) throws -> ObjectType {

    let selectorsSet = Set<String>(selectorsToIgnore.flatMap { $0.stringValue } )
    let mirror = Mirror(reflecting: object)
    var uninjectedProperties: [String] = []
    for child in mirror.children {
        guard let label = child.label, !selectorsSet.contains(label), unwrap(child.value) == nil else {
            continue
        }

        uninjectedProperties.append(label)
    }

    guard uninjectedProperties.count == 0 else {
        let error = InjectionCheckError.notInjected(properties: uninjectedProperties, in: object)
        throw error
    }

    return object
}

Forced Unwrap зависимости

Зависимости можно объявить как Force unwraped:

class TheObject: IObjectProtocol {
  var service: IService!
}

Плюсы:

  • Приложение сразу падает, если находится nil.
  • Лог ошибки указывает, что и где было nil.
  • Удобно использовать зависимости.

Минусы:

  • Приложение падает.
  • Можно пропустить в production код, который будет падать.
  • В приложении надо найти место с пустыми зависимостями, чтобы оно упало.

Падение приложения создает негативный пользовательский опыт. Как в примере с гирляндами: получится лампочка, которая ярко взрывается, если перегорает. Скорее всего, такая пустая зависимость пройдет мимо разработчика и тестировщика, если она отвечает за не очень важную или редко используемую функцию, а приложение способно работать, хоть и с ограниченной функциональностью.

Например, без форматировщика номера телефона в списке заказов приложение будет работоспособно, выведет остальную информацию, а не упадет. И, конечно же, сообщит разработчикам о проблеме.

Проверки на выходе из DI-контейнера + падение в debug-режиме

В Swift есть условная компиляция, что позволяет использовать философию Force unwrapped-зависимостей только в Debug-режиме. Условия компилятора позволяют сделать функцию, которая вызовет fatalError в режиме отладки, если найдет пустые зависимости. И ее удобно использовать на выходе из сервис-локатора, Assembly или фабрики.

Плюсы:

  • Работает без запуска тестов.
  • Приложение падает только в debug-режиме.
  • Лог ошибки укажет, что и где было nil.
  • Встроить в проект просто.

Минусы:

  • Все проверяемые объекты должны пройти через функцию.
  • Для зависимостей нужно использовать guard или service?.doSomething.
  • Тестирование релизной сборки может выявить ошибки только по логам.

Эта функция-обертка проверяет зависимости объекта и роняет его с ошибкой, если для компилятора задан флаг -DDEBUG или -DINJECTION_CHECK_ENABLED. В остальных случая тихо пишет в лог:

@discardableResult public func debugCheckInjections<ObjectType>(
    _ object: ObjectType,
    ignoring selectorsToIgnore: [IgnorableSelector] = [],
    errorClosure: (_ error: Error) -> Void = { 
        fatalError("Injection check error: ($0)") 
    }) -> ObjectType? {

    do {
        let object = try checkInjections(object, ignoring: selectorsToIgnore)
        return object
    }
    catch {
        #if DEBUG || INJECTION_CHECK_ENABLED
            errorClosure(error)
        #else
            print("Injection check error: (error)")
        #endif
        return nil
    }
}

Вместо заключения

У всех способов есть свои плюсы и минусы. В зависимости от проекта, команды, устоявшихся принципов работы будет лучше работать тот или иной способ.

Я предпочитаю не внедрять зависимости через Init, потому что так невозможно внедрить во ViewController зависимости, которые создаются из Storyboard. И это усложняет рефакторинг и внесение изменений.

Forced Unwrap не используется не только для управления зависимостями, но и в production вообще. У SwiftLint даже активировано правило ‘force_unwrapping’, которое не дает его использовать.

Проверка зависимостей в тестах хорошо работает для поддержки старого кода. Гарантирует, что старые зависимости не пропали, и все продолжает работать. Но неудобна для текущей разработки.

Поэтому я предпочитаю проверку на выходе из DI-контейнера с падением в Debug-режиме. Так быстрее всего можно обнаружить пустые зависимости и сразу их исправить.

Все представленные здесь функции есть в библиотеке:
TinkoffCreditSystems/InjectionsCheck

Автор: Андрей Зарембо

Источник

Поделиться

* - обязательные к заполнению поля