本文是极客时间里王争专栏《设计模式之美》的学习笔记,你可以通过链接阅读原文获取更加详尽的描述,也可以通过该链接进行订阅和购买获取优惠。
开闭原则
在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。
如何理解“对扩展开放、修改关闭”?
开闭原则(Open Closed Principle),简写为OCP。其定义:
software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。
软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。
添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
为了便于理解该原则,这里举一个例子。下面是一个Web容器中的一段代码,意在根据操作类型判断处理是否导航到新的内容。
class HybridWebController: UIViewController {
lazy var wkWebView: WKWebView = createWkWebView()
var url = "https://taobao.com"
override func viewDidLoad() {
super.viewDidLoad()
setupWkWebView()
wkWebView.load(URLRequest(url: URL(string: url)!))
}
func setupWkWebView() {
view.addSubview(wkWebView)
wkWebView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
wkWebView.navigationDelegate = self
}
func createWkWebView() -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.dataDetectorTypes = []
let wkWebView = WKWebView(frame: CGRect.zero, configuration: configuration)
return wkWebView
}
}
extension HybridWebController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// 处理特殊scheme的事件
let hasHandledNavigation = handleNavigation(navigationAction)
if hasHandledNavigation {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
假如我们现在有一种新的业务场景,比如需要对接快电,我们需要判断navigationAction
中的url
是否包含快电的域名,然后做出对应的处理。主要的改动有:
- 增加判断是否包含快电的域名,并做出处理快电业务的逻辑
- 根据判断结果,进行对应的回调处理
修改的代码如下:
extension HybridWebController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// 处理特殊scheme的事件
let hasHandledNavigation = handleNavigation(navigationAction)
if hasHandledNavigation {
decisionHandler(.cancel)
return
}
// 改动点1:针对处理快电的逻辑进行回调
// 处理第三方对接
if handleFleetingPower(navigationAction) {
decisionHandler(.cancel)
return
}
// ...
decisionHandler(.allow)
}
// 改动点2:处理快电的业务逻辑
func handleFleetingPower(_ navigationAction: WKNavigationAction) -> Bool {
if let url = navigationAction.request.url?.absoluteString,
url.contains("fleetingpower.com") {
// 处理快电业务
return true
}
return false
}
}
上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?
我们先重构一下之前的 代码,让它的扩展性更好一些。重构的内容主要包含两部分:
- 引入
HybridWebNavigationPolicy
协议,用于抽象在上述代理方法中进行处理的各种判断策略 - 引入
WebNavigationPolicyManager
管理类,用户处理各种策略的优先级以及策略集合的组装
具体代码实现如下所示:
protocol HybridWebNavigationPolicy {
func decidePolicy(for navigationAction: WKNavigationAction, webController: HybridWebController?) -> Bool
}
/// 处理特殊scheme的事件
struct CommonNavigationPolicy: HybridWebNavigationPolicy {
func decidePolicy(for navigationAction: WKNavigationAction, webController: HybridWebController?) -> Bool {
let url = navigationAction.request.url as NSURL?
let scheme = url?.scheme
if let url = url, let scheme = scheme {
if scheme == "tel" {
let resourceSpecifier = url.resourceSpecifier
DispatchQueue.main.async {
// 拨打电话
}
return false
}
if scheme == "itms-apps" {
// 跳转到AppStore
return false
}
}
return true
}
}
class WebNavigationPolicyManager {
static func navigationPolicics() -> [HybridWebNavigationPolicy] {
let common = CommonNavigationPolicy()
return [common]
}
}
class HybridWebController: UIViewController {
/// 依赖注入的方式
var policies = WebNavigationPolicyManager.navigationPolicics()
}
extension HybridWebController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
for policy in policies {
if !policy.decidePolicy(for: navigationAction, webController: owner) {
decisionHandler(.cancel)
return
}
}
decisionHandler(.allow)
}
}
现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,对接快电的业务,我们又该如何改动代码呢?主要的改动有下面四处。
- 改动点1,新增
FleetingPowerNavigationPolicy
,遵循HybridWebNavigationPolicy
协议,用于处理快电业务 - 改动点2,在
WebNavigationPolicyManager
中static func navigationPolicics() -> [HybridWebNavigationPolicy]
函数返回数组中增加FleetingPowerNavigationPolicy
的实例
具体代码实现如下:
protocol HybridWebNavigationPolicy {
// 代码未改动
}
/// 处理特殊scheme的事件
struct CommonNavigationPolicy: HybridWebNavigationPolicy {
// 代码未改动
}
/// 改动点1:处理快电业务
struct FleetingPowerNavigationPolicy: HybridWebNavigationPolicy {
func decidePolicy(for navigationAction: WKNavigationAction, webController: HybridWebController?) -> Bool {
if let url = navigationAction.request.url?.absoluteString,
url.contains("fleetingpower.com") {
return true
}
return false
}
}
class WebNavigationPolicyManager {
static func navigationPolicics() -> [HybridWebNavigationPolicy] {
let common = CommonNavigationPolicy()
// 改动点2:新增`FleetingPowerNavigationPolicy`的实例
let fleetingPower = FleetingPowerNavigationPolicy()
return [common, fleetingPower]
}
}
class HybridWebController: UIViewController {
// 代码未改动
}
extension HybridWebController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// 代码未改动
}
}
重构之后的代码更加灵活和易扩展。如果我们要想添加新的基于操作的导航判断,只需要基于扩展的方式创建新的Policy
类即可,不需要改动原来的webView(_:decidePolicyFor:decisionHandler:)
函数的逻辑。
修改代码就意味着违背开闭原则吗?
从开闭原则的定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。
看了上面重构之后的代码,你可能还会有疑问:在添加新的判断处理逻辑的时候,尽管改动点1(添加新的Policy
类)是基于扩展而非修改的方式来完成,但是改动点2貌似不是基于扩展而是基于修改的方式来完成的,那改动点1不就违背开闭原则了吗?
实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
在重构之后的webView(_:decidePolicyFor:decisionHandler:)
代码实现中,我们的核心逻辑集中在该方法中以及各个Policy
中,当我们在添加新的判断处理逻辑的时候,该方法完全不需要修改,而只需要扩展一个新的Policy
类。如果我们把该方法及各个Policy
类合起来看做一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。
添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
如何做到“对扩展开放、修改关闭”?
开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。
在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。
如何在项目中灵活应用开闭原则?
写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?
如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。