设计原则之开闭原则

本文是极客时间里王争专栏《设计模式之美》的学习笔记,你可以通过链接阅读原文获取更加详尽的描述,也可以通过该链接进行订阅和购买获取优惠。

开闭原则

在 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是否包含快电的域名,然后做出对应的处理。主要的改动有:

  1. 增加判断是否包含快电的域名,并做出处理快电业务的逻辑
  2. 根据判断结果,进行对应的回调处理

修改的代码如下:

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,在WebNavigationPolicyManagerstatic 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类合起来看做一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。

添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

如何做到“对扩展开放、修改关闭”?

开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。

在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。

在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。

如何在项目中灵活应用开闭原则?

写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?

如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。

即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。

最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容