неділя, 29 листопада 2020 р.

Внедрение зависимостей (dependency injection) в Swift 5.1

Внедрение зависимостей очень горячая тема в любой области разработки, где мы пишем что-то более сложное чем Hello, World. Однако несмотря на казалось бы изученный вдоль и поперек вопрос, вариантов его решения вы можете на просторах интернета найти великое множество. И в каждом месте оно подается как единственно правильное. И как же выбрать? Предлагаю в этой статье немножко рассмотреть подходы, их плюсы и минусы, немножко поиграться со Swift’ом вообще и попробовать его новые фичи в виде @PropertyWrapper’s.

Итак, постановка задачи у нас будет такая - у нас есть два класса BooksRenderer, который просто каким-то образом рисует книжки, и BooksProvider, который ему их поставляет. На Swift это будет выглядеть примерно так:

final class BooksRenderer {
    let provider: BooksProvider = ... /* за это троеточие и будет вестись основная борьба */

    func draw() {
        let books = provider.books
        /* тут каким-то образом рисуются книги из массива books */
    }
}

protocol BooksProvider {
    var books: [Book] { get }
}

Будем также считать что есть некая реализация протокола BooksProvider, например такая наивная

final class NaiveBooksProvider: BooksProvider {
    var books: [Book] {
        return [
            Book(title: "Dune", author: "Frank Herbert"),
            Book(title: "Lord of the Rings", author: "John R.R. Tolkien")
        ]
    }
}

Теперь наша задача каким-то образом доставить экземпляр класса NaiveBooksProvider в BooksRenderer. Самый наивный подход такой, создать экземлляр класса прямо на месте:

final class BooksRenderer {
    let provider: BooksProvider = NaiveBooksProvider()
}

Несмотря на то, что этот подход, каким бы наивным он не был, много где применяется, у него есть очевидные недостатки:

  1. Мы можем захотеть как-то менять конкретный класс реализации, а он тут “прибит гвоздями”
  2. Мы можем захотеть unit-протестировать класс BooksRenderer, и тогда вместо провайдера захотим вставить какой-нибудь мок и т.д.

Нам надо что-то лучше. И много где предлагают хорошо известный паттер ServiceLocator. Если его применить, то выглядеть это будет примерно так:

final class ServiceLocator {
    static let booksProvider: BooksProvider = NaiveBooksProvider()
}

final class BooksRenderer {
    let provider: BooksProvider = ServiceLocator.booksProvider
}

Уже лучше, ответственность за выбор конкретного класса мы достали из BooksRenderer и наделили этой почетной обязанностью класс ServiceLocator. И мы даже можем сделать разные ServiceLocator’ы для основного приложения и для тестов, которые будут создавать разные BooksProvider’ы, однако:

  1. Теперь 90% кода будет знать про класс ServiceLocator
  2. Класс ServiceLocator будет огромным (кто там что говорил про Massive View Controller?, у нас тут Massive Service Locator)

Прежде чем пойти дальше, давайте сделаем некоторое лирическое отступление, разберемся в терминологии зависимостей. Вообще внедряемых зависимостей может быть два типа: прости хоспади singleton (но это не то что вы подумали) и prototype. “singleton” зависимости - это такие зависимости, которые сколько бы вы не внедряли в рамках одного конкретного модуля, это всегда будет один экземпляр. “prototype” же - даст на каждую точку внедрения новый экземпляр.

Поэтому если говорить про наш пример с ServiceLocator’ом, то например booksProvider - это singleton зависимость, а bookUpdateOperation - prototype:

final class ServiceLocator {
    static let booksProvider: BooksProvider = NaiveBooksProvider()

    static func bookUpdateOperation() -> Operation & BookUpdate {
        return NaiveBookUpdateOperation(...)
    }
}

Теперь давайте сделаем еще одно лирическое отступление, подчерпнутое мной когда я еще занимался “кровавым” enterprise и работал с Srping Framework. Хороший DI контейнер это такой контейнер, который не видно. Тут можно еще пофилософствовать и вспомнить ТРИЗ с ее идеальным конечным результатом, который на наш DI’ный контекст перефразируется так: “хороший DI контейнер - это такой, которого нет, а зависимости внедряются”.

Таким образом, можно сделать такой DI на базе initializer injection (оно лучше property injection, потому что компилятор в этом случае не даст вам озорничать, а с property injection легко забыть что-нибудь присвоить и грохнуться в рантайме):

final class AppContainer {
    let booksProvider: BooksProvider

    init(with appDelegate: AppDelegate) {
        booksProvider = NaiveBooksProvider(...)
        appDelegate.booksRenderer = BooksRenderer(provider: booksProvider)
    }
}

@UIApplicationMain
final class AppDelegate: UIResponder {
    let container: AppContainer
    var booksRenderer: BooksRenderer!

    func applicationDidFinishLauncherWithOptions(...) {
        container = AppContainer(with: self)
    }
}

final class BooksRenderer {
    let provider: BooksProvider

    init(provider: BooksProvider) {
        self.provider = provider
    }
}

Причем такой подход будет гарантировать вам проверку компилятором. И при этом про AppContainer будет знать только AppDelegate. Да, корневые зависимости в самом AppDelegate’е будут force unwrapped (что не хорошо, но лучше я не придумал), но эта вольность доступна только тут.

prototype зависимости в таком подходе можно оформить либо в виде фабрики

final class SomeFactory {
    let superDep: SuperDep

    init(superDep: SuperDep) {
        self.superDep = superDep
    }

    func makeSomeDep(...) -> SomeDep {
        return SomeDep(superDep, ...)
    }
}

и потом внедрять это фабрику как singleton зависимость туда где нужно генерить prototype’ные, или в виде замыкания

typealias SomeFactory = (_ superDep: SuperDep, ...) -> SomeDep { SomeDep(superDep, ...) }

и также ее внедрять как singleton зависимость.

Но я обещал немножко Swift 5.1 и @PropertyWrapper, их легко сделать так (пусть будет наш пример с ServiceLocator’ом, хотя его можно легко модифицировать):

@propertyWrapper
public class Inject<Dep> {
    private let name: String?
    private var kept: Dep?

    public var wrappedValue: Dep {
        kept ?? {
            let dependency: Dep = ServiceLocator.resolve(for: name)
            kept = dependency
            return dependency
        }()
    }

    public convenience init() {
        self.init(nil)
    }
    
    public init(_ name: String?) {
        self.name = name
    }
}

final class ServiceLocator {
    static func register<T>(for name: String, resolver: @escaping () -> T) {
        // register
    }

    static func resolve<T>(for name: String?) -> T {
        // do some magic
    }
}

final class BooksRenderer {
    @Inject private var provider: BooksProvider
}

И вуаля! Однако несмотря на всю прелесть такой магии, есть проблема в месте где творится магия (“do some magic”). Если вы вдруг забыли сделать register, то упс, вы получаете рантайм крэш. И сделать это красиво с compile time check непонятно как, так как регистрация динамическая.

Немає коментарів:

Дописати коментар

HyperComments for Blogger

comments powered by HyperComments