Внедрение зависимостей для приложений iOS, часть 2 — DI на основе контейнера

В части 1 нашей статьи «Внедрение зависимостей в приложения iOS» мы представили внедрение зависимостей (DI) и рассмотрели DI на основе Init. Во второй части мы обсудим еще один распространенный тип — DI на основе контейнера.

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

Подход DI на основе контейнера отличается от подхода на основе инициализации. В DI мы не создаем экземпляры зависимостей внутри класса, который их использует, а предоставляем их извне.

В DI на основе контейнера это «внешнее» четко определено. Мы вводим концепцию контейнера внедрения зависимостей. Контейнер внедрения зависимостей — это объект, который имеет два метода:

  • register: используется для «регистрации» зависимостей. Мы учим контейнер внедрения зависимостей, как создать зависимость.
  • разрешение: используется для получения зависимости, когда нам нужно ее использовать.

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

Swinject

Swinject — одна из наиболее широко используемых контейнерных сред внедрения зависимостей для приложений Swift. Как объяснялось ранее, наиболее важной концепцией в такого рода фреймворках является Контейнер и то, как мы можем регистрировать и разрешать зависимости с его помощью.

Итак, после установки Swinject по инструкции по установке, мы можем начать с создания Контейнера:

let container = Container()

Этот контейнер можно использовать для регистрации наших зависимостей:

let container = Container()
container.register(FriendsRepository.self /* The protocol type */) { _ in
    // We return the concrete implementation
    return DefaultFriendsRepository()
}
container.register(ShareService.self) { _ in
  return DefaultShareService()
}
container.register(NotificationService.self) { _ in
  return DefaultNotificationService()
}

Игнорируемый параметр в закрытии фабрики регистров — это объект Resolver, который можно использовать для построения зависимостей на основе других зависимостей (называемых графом зависимостей). Для этого простого варианта использования это не понадобится, но это очень важная функция для более сложных вариантов использования.

Теперь это все, что нам нужно. Мы «научили» наш контейнер строить наши зависимости. Прежде чем приступить к разрешению зависимостей в ShareViewModel, мы можем создать для этого удобный код.

Нам нужно где-то хранить наш Контейнер. Я видел руководства, объясняющие, что AppDelegate — хорошее место для его хранения. Я предпочитаю создавать одноэлементный класс Injection:

final class Injection {
    static let shared = Injection()
    var container: Container {
        get {
            if _container == nil {
                _container = buildContainer()
            }
            return _container!
        }
        set {
            _container = newValue
        }
    }
    private var _container: Container?
     
    private func buildContainer() -> Container {
        let container = Container()
        container.register(FriendsRepository.self) { _ in
            return DefaultFriendsRepository()
        }
        container.register(ShareService.self) { _ in
            return DefaultShareService()
        }
        container.register(NotificationService.self) { _ in
            return DefaultNotificationService()
        }
        return container
    }
}

Используя этот класс Injection, мы можем разрешить наши зависимости следующим образом:

let friendsRepository = Injection.shared.container.resolve(FriendsRepository.self)!

Все еще не очень удобно… но мы можем сделать что-то еще. Используя обертки свойств Swift, мы можем определить оболочку свойства @Injected, чтобы упростить этот процесс:

@propertyWrapper struct Injected<Dependency> {
  let wrappedValue: Dependency
 
  init() {
    self.wrappedValue =
            Injection.shared.container.resolve(Dependency.self)!
  }
}

Обернутое значение здесь определяет фактическое значение свойства.

И это намного проще в использовании:

// Это должен быть `var`

// It needs to be a `var`
@Injected var friendsRepository: FriendsRepository

Теперь давайте реорганизуем нашу модель ShareViewModel для использования этого нового контейнера:

class ShareViewModel: ObservableObject {
  @Published var friends: [User] = []
 
  private let post: Post
 
  // Dependencies aren't instantiated in this class
  @Injected private var friendsRepository: FriendsRepository
  @Injected private var shareService: ShareService
  @Injected private var notificationService: NotificationService
 
    // The init is not used to inject dependencies now
  init(post: Post) { 
    self.post = post
  }
 
  func loadFriends() async {
    do {
      self.friends = try await friendsRepository.getFriends()
    } catch {
      self.friends = []
    }
  }
 
  func share(with friend: User) async {
    do {
      try await shareService.share(post, with: friend)
      notificationService.showSuccess(message: "Post shared with \\(friend.username)")
    } catch {
      notificationService.showError(message: "An error ocurred")
    }
    }
}

Модульное тестирование с использованием пользовательского интерфейса на основе контейнера

Этот класс Injection имеет еще одну полезную черту: он не создает экземпляр переменной контейнера до тех пор, пока она не будет использована впервые, поэтому можно определить собственный контейнер до того, как мы воспользуемся его геттером. Это будет очень важно для модульного тестирования.

Итак, используя наш новый класс Injection и его контейнер, мы можем выполнить модульное тестирование модели ShareViewModel. Во-первых, давайте ответим на вопрос, который вы, возможно, задаете себе: куда мы должны поместить фиктивную конфигурацию контейнера в тестах? Простой ответ, который я нашел для этого, заключается в создании класса BaseTestCase, который содержит инициализацию контейнера:

import Foundation
import XCTest
import Swinject
@testable import SwinjectExample
 
class BaseTestCase: XCTestCase {
  override func setUp() {
    super.setUp()
 
    Injection.shared.container = buildMockContainer()
  }
 
  func injectedMock<Dependency, Mock>(for dependencyType: Dependency.Type) -> Mock {
    return Injection.shared.container.resolve(Dependency.self) as! Mock
  }
 
  private func buildMockContainer() -> Container {
    let container = Container()
    container
      .register(FriendsRepository.self) { _ in
        return MockFriendsRepository()
      }
      .inObjectScope(.container)
    container
      .register(ShareService.self) { _ in
        return MockShareService()
      }
      .inObjectScope(.container)
    container
      .register(NotificationService.self) { _ in
        return MockNotificationService()
      }
      .inObjectScope(.container)
    return container
  }
}

В этом примере происходит много вещей. Давайте проанализируем их.

Во-первых, внутри buildMockContainer мы создаем и возвращаем контейнер Swinject. Этот контейнер имеет фиктивные версии для всех имеющихся у нас зависимостей. InObjectScope(.container) важен, потому что, если мы его не используем, зависимость будет воссоздаваться каждый раз, когда мы хотим ее разрешить. Для реальных зависимостей это не имеет большого значения. Однако для наших макетов это очень важно, потому что они сохраняют состояние, и эти переменные будут очищаться каждый раз, когда мы разрешаем зависимость. Более того, у ShareViewModel будут экземпляры зависимостей, отличные от тех, которые будут у ShareViewModelTests.

Обратите внимание, что Container регенерируется при каждом вызове функции setUp, поэтому макеты не будут использовать общие значения в тестовых примерах.

Наконец, функция InjectedMock — это вспомогательная функция для доступа к макетам в модульных тестах.

Вот как должны быть написаны наши тесты ShareViewModelTest, чтобы использовать все это:

class ShareViewModelTests: BaseTestCase {
  enum TestError: Error {
    case testCase
  }
 
  private let post = Post(id: 1234)
 
  // We use `injectedMock` for getting references to the mocks.
  // Not these are lazy var, because otherwise we would be accessing
  // an instance member (injectedMock) before the class finished instantiating.
  private lazy var friendsRepository: MockFriendsRepository = injectedMock(for: FriendsRepository.self)
  private lazy var shareService: MockShareService = injectedMock(for: ShareService.self)
  private lazy var notificationService: MockNotificationService = injectedMock(for: NotificationService.self)
   
  private var viewModel: ShareViewModel!
 
  override func setUp() {
    super.setUp()
 
    // We don't need to inject the dependencies inside the `init`
    viewModel = ShareViewModel(post: post)
  }
 
    // All the following test cases are unaffected
 
  func testLoadFriendsSuccess() async {
    // GIVEN
    let friends = [
      User(id: 1),
      User(id: 2),
      User(id: 3)
    ]
 
    friendsRepository.getFriendsResult = .success(friends)
 
    // WHEN
    await viewModel.loadFriends()
 
    // THEN
    XCTAssertEqual(viewModel.friends, friends)
  }
 
  func testLoadFriendsFailure() async {
    // GIVEN
    friendsRepository.getFriendsResult = .failure(TestError.testCase)
 
    // WHEN
    await viewModel.loadFriends()
 
    // THEN
    XCTAssertEqual(viewModel.friends, [])
  }
 
  func testShareSuccess() async {
    // GIVEN
    shareService.shareError = nil // Not needed, added for clarity
 
    // WHEN
    await viewModel.share(with: User(id: 1))
 
    // THEN
    XCTAssertEqual(notificationService.showSuccessCallCount, 1)
  }
 
  func testShareFailure() async {
    // GIVEN
    shareService.shareError = TestError.testCase
 
    // WHEN
    await viewModel.share(with: User(id: 1))
 
    // THEN
    XCTAssertEqual(notificationService.showErrorCallCount, 1)
  }
}

Когда использовать каждый тип внедрения зависимостей

По моему опыту, DI на основе Init является самым простым из обоих подходов. Однако мы теряем возможность настроить глобальный контейнер для внедрения зависимостей. Итак, вот в чем хитрость. Мы можем разделить зависимости на локальные и глобальные.

  • Локальные зависимости — это зависимости, которые имеют смысл только в пределах модуля или небольшого набора типов. Модели представлений, например, являются локальными зависимостями. Если вы используете координаторов навигации, интеракторов, презентаторов и т. д., они, вероятно, также будут локальными зависимостями.
  • Глобальные зависимости — это зависимости, которые используются в разных местах приложения. Репозитории, сетевые клиенты, сервисы — примеры глобальных зависимостей.

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

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

Итак, мое эмпирическое правило:

  • Используйте DI на основе Init для всех локальных зависимостей, держите их внутри области, в которой они должны использоваться.
  • Используйте DI на основе контейнера для глобальных зависимостей, определите конфигурацию контейнера, которая будет использоваться в основном целевом приложении, и другую конфигурацию, которая будет использоваться в тестах. Держите код на них максимально простым и понятным.

Вывод

DI — очень широкий термин, поэтому его можно реализовать разными способами. В этой статье мы рассмотрели два разных способа его реализации с примерами кода и советами о том, когда и как реализовать каждый из них. Мы также объясним, как их можно использовать для модульного тестирования.

Есть много других интересных применений внедрения зависимостей, таких как обмен данными о разных модулях при разработке приложений в масштабе. Однако это выходит за рамки данной статьи.

Я надеюсь, что это было полезно для вас, и что то, что вы здесь узнали, поможет вам разрабатывать лучший, более тестируемый код.