Dependency Injection Strategies in Swift
今天我们将深入研究Swift中的依赖注入,它是软件开发中最重要的技术之一,也是许多编程语言中大量使用的概念。 具体来说,我们将探讨我们可以使用哪些策略/模式,包括Swift中的Service Locator模式。
Dependency Injection背后的意图是通过让一个对象提供另一个对象的依赖关系来解耦对象。 它用于为模块提供不同的配置,对于为(单元)测试模块和/或应用程序提供模拟依赖项特别有用。
在本文中,我们将仅使用术语依赖注入作为设计模式,描述一个对象如何向其他对象提供依赖关系。 不要将依赖注入与帮助您注入依赖项的框架或库混淆。
Why should I use it?
依赖注入帮助我们使组件在不同的上下文中耦合更少、更可重用。基本上,它是关注点分离的一种形式,因为它将使用依赖项的算法从它们的初始化和配置中分离出来。为了实现这一点,我们可以应用不同的技术将依赖项注入到模块中。
正如上面提到的,依赖项注入的一个非常重要的方面是它使我们的代码更易于测试。 我们可以为我们想要测试的类/模块的依赖项注入模拟实例。这允许我们将测试集中在模块中的代码单元上,并确保这一部分按照预期工作,而不会产生模糊的副作用,从而导致不明确的测试失败,因为它的一个依赖项没有按照预期的行为。这些依赖项应该单独进行测试,以便更容易地找到真正的错误,并加快您的开发工作流。
我们在之前的一篇文章中已经描述了我们的测试策略。如果您想了解更多关于我们的测试设置,请务必阅读这篇文章。
此外,依赖注入允许我们绕过软件开发中最常见的错误之一:代码库中过度或误用单例。 如果你想读更多关于为什么单例很糟糕的文章,看看这个或那个的文章。
Different strategies to do Dependency Injection in Swift
在处理Swift代码库时,有很多方法可以使用依赖注入。大多数原则也适用于其他编程语言,尽管在大多数其他环境中(特别是在Java社区中),人们倾向于使用特殊的依赖注入框架来完成繁重的工作。
是的,也有Swift的依赖注入框架,比如Swinject或XServiceLocator。 但今天我们将向您展示一些简单的技巧,在不引入另一个第三方框架的情况下注入依赖项。
Initializer-based Dependency Injection
在Swift中注入依赖项最常见的方法是将依赖项作为参数传递给init函数,然后将它们存储为类的成员变量。 此方法称为基于初始化器的依赖项注入。
为了了解该技术的实际应用,让我们看一个使用存储库对象获取数据的服务类的简短示例。
class BasketService {
private let repository: Repository<Article>
init(repository: Repository<Article>) {
self.repository = repository
}
func addAllArticles(to basket: Basket) {
let allArticles = repository.getAll()
basket.articles.append(contentsOf: allArticles)
}
}
我们将一个存储库注入到我们的BasketService中,这样我们的服务就不需要知道所使用的文章是如何提供的。它们可能来自从本地JSON文件获取数据的存储库,也可能来自本地数据库,甚至来自网络服务。
这允许我们在不同的环境中使用BasketService,如果我们想为这个类编写测试,我们可以注入模拟版本的存储库,通过始终使用相同的测试数据,使我们的测试更可预测。
class BasketServiceTests: XCTestCase {
func testAddAllArticles() {
let expectedArticle = Article(title: "Article 1")
let mockRepository = MockRepository<Article>(objects: [expectedArticle])
let basketService = BasketService(repository: mockRepository)
let basket = Basket()
basketService.addAllArticles(to: basket)
XCTAssertEqual(basket.articles.count, 1)
XCTAssertEqual(basket.articles[0], expectedArticle)
}
}
很好,我们可以用一个虚拟的项目注入一个模拟版本的存储库,以检查我们的服务是否按照预期工作,并将我们的测试项目添加到所提供的篮子中。
Property-based Dependency Injection
基于初始化器的依赖注入似乎是一个很好的解决方案,但在某些情况下它并不合适,例如在ViewControllers中使用初始化器就不那么容易了,特别是当你使用XIB或故事板文件时。
我们都知道这个错误信息和烦人的Xcode解决方案。但是如何在不覆盖所有默认初始化器的情况下使用依赖注入?
这就是基于属性的依赖注入发挥作用的地方。我们在初始化模块后为它赋值属性。
让我们看看这个在我们的BasketViewController中的作用,它有一个依赖于BasketService类。
class BasketViewController: UIViewController {
var basketService: BasketService! = nil
}
let basketViewController = BasketViewController()
basketViewController.basketService = BasketService()
这里我们强制使用一个force unwrapped可选选项,以确保当之前没有正确注入basketService属性时,应用程序崩溃。
如果我们想要摆脱强制解包装可选,并提供默认实现,我们可以在声明属性时声明默认值。
class BasketViewController: UIViewController {
var basketService: BasketService = BasketService()
}
基于属性的依赖注入也有一些缺点:首先,我们的类需要处理依赖项的动态变化,其次,我们需要使属性从外部可访问和可变,不能再将它们定义为私有。
Factory Classes
到目前为止,我们看到的两种解决方案都将注入依赖项的责任转移到了创建新模块的类上。这可能比将依赖项硬编码到模块中要好,但将这种责任转移到自己的类型中通常是更好的解决方案。它还确保了,我们不需要在整个代码库中使用重复的代码来初始化模块。
这些类型处理类的创建并设置它们的所有依赖项。 这些所谓的Factory类还解决了传递依赖关系的问题。 我们以前必须对所有其他解决方案都这样做,如果你的类有大量的依赖项,或者你有多个层次的依赖项,就像我们上面的例子:BasketViewController –> BasketService –> Repository.
让我们看一下与Basket相关的类的Factory。
protocol BasketFactory {
func makeBasketService() -> BasketService
func makeBasketViewController() -> BasketViewController
}
通过使工厂声明为协议,我们可以有它的多个实现,例如测试用例的一个特殊工厂。
基于工厂的依赖注入与我们以前见过的解决方案携手工作,并允许我们混合不同的技术,但对如何创建类的实例保持一个清晰的接口。
没有比给你举个例子更好的解释了:
class DefaultBasketFactory: BasketFactory {
func makeBasketService() -> BasketService {
let repository = makeArticleRepository()
return BasketService(repository: repository)
}
func makeBasketViewController() -> BasketViewController {
let basketViewController = BasketViewController()
basketViewController.basketService = makeBasketService()
return basketViewController
}
// MARK: Private factory methods
private func makeArticleRepository() -> Repository<Article> {
return DatabaseRepository()
}
}
我们的DefaultBasketFactory实现了上面定义的协议,并拥有公共工厂方法和私有工厂方法。工厂方法可以也应该使用类中的其他工厂方法来创建较低的依赖项。
上面的示例完美地展示了我们是如何将基于初始化器和基于属性的依赖项注入技术结合在一起的,但它的优点是拥有一个优雅而简单的接口来创建依赖项。
要初始化我们的BasketViewController的实例,我们只需要写这一行代码。
let basketViewController = factory.makeBasketViewController()
The Service Locator Pattern
基于到目前为止我们已经看到的解决方案,我们将使用所谓的Service Locator设计模式构建一个更通用和灵活的解决方案。让我们从定义Service Locator所涉及的实体开始:
- Container: 存储关于如何创建已注册类型的实例的配置
- Resolver: 通过使用Container的配置创建类的实例,解析类型的实际实现
- ServiceFactory: 用于创建泛型类型实例的泛型工厂解决方案
Resolver
我们首先为Service Locator模式定义(Resolver)解析器协议。这是一个简单的协议,只有一个方法来创建符合所传递的ServiceType类型的实例。
protocol Resolver {
func resolve<ServiceType>(_ type: ServiceType.Type) -> ServiceType
}
我们可以按照以下方式使用符合该协议的对象:
let resolver: Resolver = ...
let instance = resolver.resolve(SomeProtocol.self)
ServiceFactory
接下来,我们用一个关联的类型ServiceType定义ServiceFactory协议。我们的工厂将创建符合ServiceType协议的类型的实例。
protocol ServiceFactory {
associatedtype ServiceType
func resolve(_ resolver: Resolver) -> ServiceType
}
这看起来与我们之前看到的Resolver协议非常相似,但是它引入了额外的关联类型,以便为我们的实现添加更多的类型安全性。
让我们定义符合这个协议的第一个类型BasicServiceFactory。这个工厂类使用注入的工厂方法来生成类型为ServiceType的类/结构的实例。通过将Resolver作为参数传递给工厂闭包,我们可以使用它来创建创建该类型实例所需的低级依赖项。
struct BasicServiceFactory<ServiceType>: ServiceFactory {
private let factory: (Resolver) -> ServiceType
init(_ type: ServiceType.Type, factory: @escaping (Resolver) -> ServiceType) {
self.factory = factory
}
func resolve(_ resolver: Resolver) -> ServiceType {
return factory(resolver)
}
}
这个BasicServiceFactory结构体可以作为独立的、比我们上面看到的Factory类更通用的解决方案使用。但我们还没有完成。在Swift中,我们需要实现服务定位器模式的最后一件事是Container。
Container
在开始编写Container类之前。让我们简单重复一下它应该为我们做什么:
- 它应该允许我们为某种类型注册新工厂
- 它应该存储ServiceFactory实例
- 它应该用作任何存储类型的Resolver
为了能够以类型安全的方式存储ServiceFactory类的实例,我们需要能够在Swift中实现可变参数泛型。 这在Swift中还不可能实现,但这是通用宣言的一部分,并将在未来版本中添加到Swift中。同时,我们需要使用名为AnyServiceFactory的类型擦除版本来消除泛型类型。
为了简单起见,我们不会向您展示它的实现,但如果您对它感兴趣,请查看下面链接的完整游乐场。
现在我们要创建我们的Container结构体:
struct Container: Resolver {
let factories: [AnyServiceFactory]
init() {
self.factories = []
}
private init(factories: [AnyServiceFactory]) {
self.factories = factories
}
...
我们将Container定义为一个结构体,它充当解析器并存储被擦除类型的工厂。 接下来,我们将添加注册新类型的代码与他们的工厂。
// MARK: Register
func register<T>(_ type: T.Type, instance: T) -> Container {
return register(type) { _ in instance }
}
func register<ServiceType>(_ type: ServiceType.Type, _ factory: @escaping (Resolver) -> ServiceType) -> Container {
assert(!factories.contains(where: { $0.supports(type) }))
let newFactory = BasicServiceFactory<ServiceType>(type, factory: { resolver in
factory(resolver)
})
return .init(factories: factories + [AnyServiceFactory(newFactory)])
}
...
第一个方法允许我们为ServiceType注册一个类的特定实例。这对于注入像UserDefaults和Bundle这样的单例类特别有用。
第二个方法甚至更重要,它创建一个新工厂,并返回一个包含该新工厂的新的不可变容器。
最后缺少的部分是实际遵守Resolver协议并使用存储的工厂解析实例。
// MARK: Resolver
func resolve<ServiceType>(_ type: ServiceType.Type) -> ServiceType {
guard let factory = factories.first(where: { $0.supports(type) }) else {
fatalError("No suitable factory found")
}
return factory.resolve(self)
}
我们使用一个守卫语句来检查它是否包含一个能够解决我们的依赖关系并抛出致命错误的工厂。 最后,我们返回由支持该类型的第一个工厂创建的实例。
Usage of Service Locators
让我们回到之前的篮子例子,为所有与篮子相关的类定义一个容器:
let basketContainer = Container()
.register(Bundle.self, instance: Bundle.main)
.register(Repository<Article>.self) { _ in DatabaseRepository() }
.register(BasketService.self) { resolver in
let repository = resolver.resolve(Repository<Article>.self)
return BasketService(repository: repository)
}
.register(BasketViewController.self) { resolver in
let basketViewController = BasketViewController()
basketViewController.basketService = resolver.resolve(BasketService.self)
return basketViewController
}
这已经显示了我们超级简单的解决方案的力量和优雅。我们可以使用链式寄存器方法存储所有工厂,同时使用和混合我们之前看到的所有不同的依赖注入技术。
最后但并非最不重要的是,我们用于创建实例的接口保持了简单和优雅。
let basketViewController = basketContainer.resolve(BasketViewController.self)
Conclusion
我们已经看到了在Swift中使用依赖注入的不同技术。更重要的是,我们已经看到您不需要决定一个单一的解决方案。它们可以混合在一起,以获得每种技术的综合优势。为了让一切都进入下一个层次,我们引入了工厂类和Swift中更通用的ServiceLocator模式解决方案。这可以通过增加对多个参数的额外支持或在Swift引入可变参数泛型时增加更多类型安全来改进。
为了简单起见,我们忽略了像作用域、动态依赖和循环依赖这样的东西。 所有这些问题都是可以解决的,但超出了本文的范围。你可以在这里看到我们今天向你展示的一切。此外,如果你需要一个轻量级的依赖注入库,它仍然可以为你完成大部分繁重的工作,请在GitHub上查看我们的库XServiceLocator。