在设计易于维护的架构和系统时,关注点分离是一个核心原则。它的理念是,每种物体或类型对其周围环境的了解仅够完成其工作,仅此而已。
然而,即使这是大多数程序员在他们的职业生涯早期学习的原则,在实践中应用它并不总是很容易的。本周,让我们看看如何使用协议更容易地分离Swift中不同类型的关注点。
Let's start with an example
假设我们正在构建一个ContactSearchViewController让我们的用户搜索本地保存在应用中的联系人。应用程序目前正在使用Core Data进行持久化,所以我们最初的解决方案可能是简单地将应用程序的Core Data context (NSManagedObjectContext)作为依赖注入到我们的新视图控制器中
class ContactSearchViewController: UIViewController {
private let coreDataContext: NSManagedObjectContext
init(coreDataContext: NSManagedObjectContext) {
self.coreDataContext = coreDataContext
super.init(nibName: nil, bundle: nil)
}
}
像上面这样的做法是一个非常常见的解决方案,也是完全有效的,但它有两个问题:
我们的视图控制器知道我们的应用程序正在使用Core Data。虽然这很方便,但它也使我们的代码变得不那么灵活(例如,如果我们想把我们的数据库解决方案更改为其他的东西——比如Realm)。 它还使测试变得非常困难(即使我们使用依赖项注入),因为模拟像NSManagedObjectContext这样的系统类是困难的,而且经常会导致不稳定的测试。
因为我们的视图控制器可以完全访问我们的数据库,它可以对它做任何它想做的事情。 它可以读和写,这在这种情况下真的是不必要的-视图控制器将只搜索联系人,这只需要读访问。如果我们能把读写分离开来,只给给定的视图控制器提供它需要的功能,那就更好了
Another abstraction
当面对具有可测试性和关注点分离的问题时,最经常需要的是另一种形式的抽象。就像我在“每个人都是API设计师”的演讲中提到的,协议是定义这种抽象的好方法。
我们不需要让视图控制器直接访问数据库的具体实现,我们可以创建一个协议,定义应用程序加载和保存对象所需的所有api,就像这样:
protocol Database {
func loadObjects<T: Model>(matching query: Query) -> [T]
func loadObject<T: Model>(withID id: String) -> T?
func save<T: Model>(_ object: T)
}
我们现在可以在初始化ContactSearchViewController时使用新的数据库协议,而不是直接注入核心数据上下文:
class ContactSearchViewController: UIViewController {
private let database: Database
init(database: Database) {
self.database = database
super.init(nibName: nil, bundle: nil)
}
}
上面的做法解决了第一个问题——我们的代码现在更灵活,更容易测试。在我们的测试中,我们现在可以通过实现数据库协议来创建模拟,如果我们想要迁移到一个新的数据库解决方案(甚至使用一个特殊的解决方案,如UI测试),我们可以通过添加新的数据库实现来轻松实现
extension NSManagedObjectContext: Database {
...
}
extension Realm: Database {
...
}
extension MockedDatabase: Database {
...
}
extension UITestingDatabase: Database {
...
}
但是第二个问题——解耦读写呢?🤔
Protocol composition
我们不再为所有数据库功能使用单一协议,而是使用协议组合来分割内容。 在本例中,我们将创建一个用于读取的协议和一个用于写入的协议。相同的api,只是分成了两个协议而不是一个:
protocol ReadableDatabase {
func loadObjects<T: Model>(matching query: Query) -> [T]
func loadObject<T: Model>(withID id: String) -> T?
}
protocol WritableDatabase {
func save<T: Model>(_ object: T)
}
typealias Database = ReadableDatabase & WritableDatabase
我喜欢协议组合的原因是,根据不同类型的需求,可以更容易地混合和匹配不同的功能。 它还使测试变得更加容易,因为您可以通过简单地实现少量方法来创建模拟,而不必遵从一个大型协议。
对于像数据库这样的东西,它还让我们可以更细粒度地控制数据在应用程序中的流动方式。 我们现在可以限制只需要读取的视图控制器的数据库访问,给它们只读协议而不是完整的数据库:
class ContactSearchViewController: UIViewController {
private let database: ReadableDatabase
init(database: ReadableDatabase) {
self.database = database
super.init(nibName: nil, bundle: nil)
}
}
好的方面是,当需要完全访问时,我们仍然可以很容易地有一个数据库类型,通过使用Swift的协议组合操作符和一个类型别名:
typealias Database = ReadableDatabase & WritableDatabase
Conclusion
当您希望更细粒度地控制给定对象可以访问哪些功能时,协议可以是一个很好的工具。 通常情况下,一个对象需要关注的内容越少,缺陷的表面积就越小。通过保持你的类型的简单、小和尽可能少的关注,你通常会得到更容易测试和维护的系统。
上面的技术在为框架设计api时也非常有用——无论是内部的还是开源的。与包含大量功能的大型协议不同,您可以更容易地选择将哪些内容作为公共API的一部分共享,并避免泄露实现细节。 我将在接下来的文章中写更多关于框架API设计的内容。