浅谈模块化

2,936

你将会了解到

  • 基本模块化知识
  • 接口分离的意义
  • 探讨依赖注入,服务定位器模式
  • Swinject基本使用
  • 思索一个优秀的项目架构应该是什么样子的
  • 立足于iOS + Swift,但也不局限于此,剥离出来,从软件工程的高度来思考

关于模块化

  • 模块化设计是指在对一定范围内的不同功能或相同功能不同性能、不同规格的产品进行功能分析的基础上,划分并设计出一系列功能模块,通过模块的选择和组合可以构成不同的产品,以满足市场的不同需求的设计方法
  • 采取模块化的原因个人认为最重要的还是便于测试, 由于模块化之后,由于各个模块之间互不依赖,我们可以将单独模块拿出来测试,不会因为相互依赖导致测试不便

依赖注入

  • SOLID是面向对象设计的五个基本原则,旨在提高程序的可维护性以及可扩展性

    image-20191227112441327

  • 今天主要探讨的依赖注入就是依赖反转原则的一种实现方式

概念辨析

  • 我们首先试着区分依赖反转,控制反转,依赖注入三个概念

依赖反转原则(Dependency inversion principle,DIP)

依赖反转说明图

  • 👆这张图展示了:

    • Figure 1中展示了A依赖于B
    • Figure 2中展示了依赖反转,我们A不再直接依赖于B而是依赖于一个抽象的A接口,B需要实现这个接口A
    • 好处是A与B不再绑定在一起,当我们需要让A去依赖别的类的时候,不再需要去修改A里的代码,只需要让那些类去实现接口A即可
  • 台灯与按钮的例子:例子

控制反转(Inversion of Control,IC)

  • 维基百科:控制反转
  • 依赖反转(DIP)讲的是A不再依赖于B去实现,而是B去实现A的接口
  • 控制反转(IOC)讲的是A不再拥有B的控制权,即不在A里面去初始化B
  • 这里个人认为不用分的那么明白,也有观点认为依赖反转就已经包括了控制反转的过程

依赖注入(Dependency injection,DI)

  • 一篇很有意思的科普文章:浅谈控制反转与依赖注入
    • 在了解了控制反转的概念后,就可以理解依赖注入了,依赖注入本质就是一种实现控制反转的方式

    • 我们可以这样理解:

      • 假如A依赖了B,正常情况下,我们需要需要让A去依赖于C的话,需要修改A内部的代码,使之依赖于C
        • 现在,再有了抽象接口A'后,我们仅仅需要把C注入到A'中,即注入新的依赖
  • 辨析三个概念:依赖倒置(DIP)、控制反转(IOC)和依赖注入(DI)

实战:使用协议实现依赖注入

  • 在进入正题前,我们先来看不做依赖注入,没有进行结耦的做法

👇的🌰都来自于GitHub:Swinject

// 没有进行任何结耦的做法
class Cat {
    let name: String

    init(name: String) {
        self.name = name
    }

    func sound() -> String {
        "Miao!"
    }
}

class Person {
    let pet = Cat(name: "sd")

    func play() -> String {
        "I'm playing with \(pet.name). \(pet.sound())"
    }
}

let per = Person()
print(per.play())    // 输出I'm playing with sd. Miao!

// 此时,假设现在我需要去养一条叫kb的狗,叫声是wang,我该怎么办呢?
// 事实上,在这种做法的情况下,我必须要新建/修改person对象,才能实现效果
// 用上面的原理来说,现在我的Person依赖了Cat,两者进行了绑定
  • 下面来看使用协议结耦,进行依赖注入的做法
// 使用协议进行结耦
protocol AnimalType {
    var name: String { get }
    func sound() -> String
}

class Cat: AnimalType {
    let name: String

    init(name: String) {
        self.name = name
    }

    func sound() -> String {
        "Miao"
    }
}

class Person {
    let pet: AnimalType

    init(pet: AnimalType) {
        self.pet = pet
    }

    func play() -> String {
        "I'm playing with \(pet.name). \(pet.sound())"
    }
}

let catPerson = Person(pet: Cat(name: "sd"))
print(catPerson.play()) // 输出 "I'm playing with sd. Miao"

// 此时假如我希望改成养一只狗,我需要怎么做呢?
class Dog: AnimalType {
    let name: String

    init(name: String) {
        self.name = name
    }

    func sound() -> String {
        return "wang"
    }
}

let dogPerson = Person(pet: Dog(name: "kb"))
print(dogPerson.play()) // 输出 "I'm playing with kb. wang"

// 现在这种情况下我的Person依赖的仅仅是AnimalType这个抽象接口,只要符合该接口的pet都能正常使用
// 这样,我们就降低了耦合性,倒置了依赖关系

实战:使用Swinject实现依赖注入

  • Swinject是Swift的轻量级依赖注入框架

依赖项注入(DI)是一种软件设计模式,可实现控制反转(IoC)来解决依赖项。 在这种模式下,Swinject可以帮助您的应用拆分为松耦合的组件,可以更轻松地对其进行开发,测试和维护。 Swinject由Swift通用类型系统和一流的功能提供支持,可轻松,流畅地定义应用程序的依赖项。

import UIKit
import Swinject

// MARK: - 定义类,协议与容器
protocol Animal {
    var name: String? { get }
}

class Cat: Animal {
    let name: String?

    init(name: String?) {
        self.name = name
    }
}

protocol Person {
    func play()
}

class PetOwner: Person {
    let pet: Animal

    init(pet: Animal) {
        self.pet = pet
    }

    func play() {
        let name = pet.name ?? "someone"
        print("I'm playing with \(name).")
    }
}
// 我们一般会定义一个自己的容器类,Container类表示一个依赖项注入容器,它存储服务的注册并检索注入了依赖项的注册服务
class DIContainer {
    static let container: Container = {
        let con = Container()
        // 首先,将一个服务和组件对注册到一个Container
        // 在该容器中,该组件由注册的Closure作为工厂创建
        // 在此示例中,Cat和PetOwner是分别实现Animal和Person服务协议的组件类
        con.register(Animal.self) { _ in Cat(name: "sd") }
        con.register(Person.self) { r in
           PetOwner(pet: r.resolve(Animal.self)!)
        }
        return con
    }()
}
// MARK: - 实际使用
class TestViewController: UIViewController {
   
     let container = DIContainer.container
       override func viewDidLoad() {
           super.viewDidLoad()
            // 然后从容器中获取服务的实例。
           let per = container.resolve(Person.self)!
           per.play()
       }
}

服务定位器模式(Service Locator Pattern)

  • 在进入服务定位器模式之前,我们先明确抽象接口的理解,整片文章中我都会使用抽象接口的概念来实现我要讲述的模式概念
    • 抽象接口就是像Swift中的协议一样,只是申明需要实现的函数,由遵守协议的人来实现,但请注意,这其实不是必须的,虽然这是一种很常见的做法,但并不是说服务定位器模式,依赖注入等等是需要和抽象接口打包接受的
    • 比如依赖注入,我们常见有三种实现方式:
      • 构造方法注入
      • Setter方式注入
      • 接口注入
    • 接口注入只是其中一种方式而已,它不是必须的,请注意

概念

  • 服务定位器模式(Service Locator Pattern)介绍
  • 我们可以想象出一个单例,假设叫做服务中心,每当我们需要某个实例对象是,我们向中心发送我们的需求(抽象接口),由服务中心向我们返回该实例对象,也就是说,我们的类之间互不依赖,所有的类都依赖于服务中心,通过它的交接相互联系
  • 👇我会举一个组装汽车的🌰来说明情况,这个例子会贯穿全文始终

例子:使用服务定位器模式组装一辆汽车

  • 我们简化一辆车子的构成,假设其只有轮胎,车门,车灯三部分组成

Car Simplified Structure

  • 现在我们希望从轮胎开始入手组装,而轮胎是由胎面胶,钢丝环带,垫胶,钢圈构成

CarTire Simplified Structure

  • 现在我们希望从胎面胶开始入手组装,而胎面胶是由原料A,B,C构成

TireTread Simplified Structure

  • 假设原料A,B,C已经不可再分,此时我们的服务中心等于就是一个原料市场,里面没有轮胎,车门这样的成品,也没有胎面胶,钢丝环带这样的半成品,只有最最基础的原料A,B,C,D。。。

Raw Materials Market Simplified Structure

  • 我们组装车子的过程就是一次又一次的向服务中心请求原料,先拼出胎面胶,再汇聚原料拼出钢丝环带,这样一层一层自下往上,不断汇聚原料,拼出最后的车子。这个过程中的所有部件,彼此之间确实没有依赖关系,减少了耦合性,所有的部件,不管处于哪一层级依赖或者间接依赖的都是原料市场的都是原料市场

Manufacturing Process with Service Locator

  • 现在,我们接到一个需求,给CarDoor添加一个openDoor方法,要求是在开门的同时需要打开车灯,比如说就要调用CarLight里面一个openLight方法。由于CarDoor不依赖于CarLight,需要借助Market进行桥接(这里为了方便画图,简化了CarLight,假设就是由原料D,E,F构成)

CarDoor and CarLight Simplified Structure

  • 由于与我们的Market直接关联的只有最底层的原料D,F,D,所以要实现这个功能我们等同于就需要知道CarLight的内部实现,实现该效果
  • 用编程世界的话来说就是,服务中心包含的东西太多,包含的东西过于底层,过于抽象,不方便我们调用。用这个汽车例子来讲,我们为了实现开灯,可能需要对灯芯,灯丝,灯泡都分别进行操作,才能实现开灯这件事,而且现在只是开门开灯,假如我们给轮胎加入轮胎滚动开灯的功能,需要将这个流程重新走一遍【除非我们让轮胎去依赖车灯,使用车灯的功能,但这就背离最小化依赖的原则】

使用组装器(Assembly)

  • 现在,我们引入一套新的概念,摒弃前面的服务中心一类的概念,用更加规范,标准的一套流程来实现这个汽车的例子,也是我目前个人认为比较好的模块化实现

约定术语

  • xxxInterface:抽象接口
  • Servicexxx:集成抽象接口
  • xxxAssembly:组装器
  • Container:容器

例子:使用组装器组装一辆车

  • 我们现在还是要组装车子,但现在我们引入一个车架的概念,车架负责集成轮胎,车门,车灯拼出一辆车

Car Simplified Structure(Assembly)

  • 看起来似乎平平无奇,看不出什么变化?因为虽然车架可以帮助我们集成部件,但部件还是需要从底下拼起
  • 在模块化中有一个非常重要的思想,可以用编译原理的T型图来类比(没了解过也不要紧,看懂图就行)

image-20191227172045959

  • 我们会认为一个原料是一个T型,但是若干个T型也会组成新的T型,新的T型同样可以作为一个部件用来和别人组装。放在汽车这个例子里就是说,车有车架,轮胎有轮胎架,车门有车门架,自上而下一层层的拼装起来

Car Structure(Assembly)

  • 先不考虑抽象接口的问题,加入容器,看一下原料级别的完整实现:

Manufacturing Process(Assembly)

  • 我们先暂时不考虑抽象接口,容器的问题,先考虑架子的概念带来的好处:
    • 拼装车子的逻辑变成了自上而下,逻辑清晰直观(作为工程师,肯定是希望自己是面对设计图从车子的角度出发来拼装这辆车,而不是面对钢铁,橡胶等一系列原料,从底向上攒出一辆车子)
    • 各个部件之间的关联跟更加具体,不再需要调度过于底层的原料(也就是说,假如我们希望实现上面提到的这个开门的同时开灯的功能,我们不需要使用Container进行桥接,而是使用CarAssembly进行桥接,使用比较具体的功能)
    • 模块化的更加彻底
      • 好的项目架构应该像乐高积木一样,每个部件都是一块积木,可以随意插取
      • 我们使用车架可以造个四轮车,也可以造个六轮车,轮子可以使用橡胶来填充,也可以使用木头来填充
  • 这样子的架构从一辆车子的角度可以依然看不出优势在哪,但假如我们拼装的是一个变形金刚级别的工程,这样的架构就十分有必要了🌝

软件工程角度下的模块化

在这一模块,我们将会引入抽象接口的概念,更加宏观的讲述模块化与接口分离,不会再根据👆的汽车例子来讲,因为这图已经复杂到我看不懂了🌚

随意依赖

Before software can be reusable, it first has to be usable.

  • 最开始的代码没有任何限制,每个类想依赖谁就依赖谁,代码约等于一次性,想要加一个功能可能由于耦合性太高已经不如直接重写来的简单

Random Dependence

  • 这样的代码几乎没有重用性,因为彼此间的依赖过多,想要冲用一个模块的代码,就需要接受同样的依赖;同理,想要修改某一部分的代码也需要修改各个依赖的部分的代码

抽离依赖

If builders built buildings the way programmers wrote programs, then the first woodpecker that came along would destroy civilization.

  • 于是我们开始尝试着将依赖抽离,每一个模块不再直接依赖于其他模块,而是先声明自己的需要的依赖接口,由外部进行依赖注入

  • 未完待续(写的时候发现自己感悟还不够,还是要修炼修炼)