一、六大设计原则
1.1 开闭原则(Open-Closed Principle, OCP)
1.1.1 解释
开闭原则由Bertrand Meyer于1988年提出,其定义如下:
- 一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
1.1.2 好处
好处显而易见,不修改源码的情况下进行扩展,对已有逻辑没有影响。
1.1.3 不遵守有什么坏处
随着软件规模越来越大,软件寿命越来越长,若系统设计不满足开闭原则,则维护成本变得越来越大。修一个bug,又引出10个新bug,最终变得无法维护。
1.1.4 出现违反原则的原因
职责不单一是主要影响开闭原则的原因之一;缺乏抽象也会造成开闭原则的破坏。
1.1.5 实际运用中的取舍
为了满足开闭原则,需要对系统进行抽象化设计,针对具体业务领域识别出抽象层并抽象化是开闭原则的关键。应用层基于上下文参数识别业务场景,针对不同场景选择不同的实现。
1.2 依赖反转原则(Dependency Inversion Principle, DIP)
1.2.1 解释
依赖反转原则是Robert C. Martin在1996年为“C++Reporter”所写的专栏Engineering Notebook的第三篇,后来加入到他在2002年出版的经典著作“Agile Software Development, Principles, Patterns, and Practices”一书中。依赖反转原则定义如下:
- 抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。
因为正常的依赖顺序是,A依赖B,而为了避免依赖具体实现,引入了抽象层C,使A依赖C,B成为C的具体实现。因为继承、实现关系本身是特殊的依赖关系,所以变成被依赖的实现B反过来依赖C这个抽象层,所以叫依赖反转。
1.2.2 好处
依赖反转是实现开闭原则目标的另一个重要手段。
1.2.3 不遵守有什么坏处
少了抽象层,很多设计原则都无法做到,后果可想而知。
1.2.4 出现违反原则的原因
这个原则基本上不会违反,因为有了Spring框架提供的依赖注入。
1.3 职责单一原则(Single Responsibility Principle, SRP)
1.3.1 解释
把因相同原因而变化的东西聚在一起,把因不同原因而变化的东西分离开来。
1.3.2 好处
如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
1.3.3 不遵守有什么坏处
修改一段代码,在实现了一个feature升级的同时,影响了另一个feature的功能。
1.3.4 出现违反原则的原因
职责不单一不是从第一天写代码就开始的,往往是需求变更导致职责拆分后引起的。此时应该将一个方法拆成两个方法,一个类拆成两个类。
1.3.5 实际运用中的取舍
代码结构并不能随着需求的变更每次都做出重构,会影响团队协作效率,需要把握一个度:
- 只有逻辑足够简单,才可以在方法级别上违反单一职责原则
- 只有类中方法数量足够少,才可以在方法级别上违反单一职责原则
- 在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。
1.4 里氏替换原则(Liskov Substitution Principle, LSP)
1.4.1 解释
里氏代换原则由2008年图灵奖得主、美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出。
- 定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
- 定义2:所有引用父类的地方必须能透明地使用其子类的对象。
可以简单理解为:如果在软件中将一个用父类声明的对象,运行期替换成它的某个子类对象,程序没有产生任何错误和异常,则这个子类的实现是符合里氏替换原则的。
1.4.2 好处
里氏代换原则是实现开闭原则的重要方式之一。由于使用父类对象的地方都可以使用子类对象,因此在程序中尽量使用父类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。从而实现对原有系统的扩展的同时,不影响原有功能。
1.3.3 不遵守有什么坏处
如果子类集成父类后,又增加了特定的方法,那么只有使用子类来声明对象,这个对象才能使用子类特有的方法,导致后续扩展的时候,必须打开子类来修改代码,没办法替换为父类的其他子类。
1.3.4 出现违反原则的原因
代码设计初期没有考虑将来复杂性的可能,没有预留好扩展点,直接依赖具体实现,没有使用接口隔离实现。
1.3.5 实际运用中的取舍
软件架构往往是分层的,层与层之间必须使用接口隔离实现,层内的具体业务逻辑如果看不到有多种场景下使用多种实现的可能性,暂时可以直接依赖实现。当出现第二种实现时,立即使用接口隔离。
声明对象时,必须使用集成结构中适合当前场景的某一个层级的抽象来声明,不要直接使用子类来声明对象。如:当前需要一个有序可重复队列,则使用List来声明对象,因为Collection过于抽象,无法表达有序可重复的要求,直接使用ArrayList声明,则将来传入LinkedList对象会出错。
1.5 接口隔离原则(Interface Segregation Principle, ISP)
1.5.1 解释
使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
- 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时,这个原则可以叫做“角色隔离原则”。
- 如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。
1.5.2 好处
容易实现职责单一原则,客户端更容易灵活选择合适的实现,扩展性增强。
1.5.3 不遵守有什么坏处
接口太大,降低了扩展的灵活性。有时,一个接口中仅仅一个方法在不同场景有不同实现,此时增加一个全新的实现,则新的实现中有大部分代码和已有实现是代码重复的。而且客户端面对一个大接口,失去了根据场景灵活组合实现的可能性,只能定制一个全新的实现来适应当前场景,导致复用变得难以实现。
1.5.4 出现违反原则的原因
往往初期系统中接口并不大,随着需求的变化,增加了方法,或增加了角色的职责,而没有及时拆分接口。
1.5.5 实际运用中的取舍
在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些它们不用的方法。
1.6 迪米特法则(Law of Demeter, LoD)
1.6.1 解释
迪米特法则来自于1987年美国东北大学(Northeastern University)一个名为“Demeter”的研究项目。迪米特法则又称为最少知识原则(LeastKnowledge Principle, LKP),其定义如下:
- 一个软件实体应当尽可能少地与其他实体发生相互作用。
1.6.2 好处
如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。
1.6.3 不遵守有什么坏处
不遵守迪米特法则,会使对象间过渡耦合,其结果就是对象间关系过于复杂,而破坏了扩展性和可维护性。
1.6.4 出现违反原则的原因
不要和“陌生人”说话。除以下类型的对象外,其他的都是“陌生人”。缺乏经验的开发,很容易把“陌生人”之间建立起通信关系,使得系统对象间出现不该有的耦合。
- 当前对象本身(this);
- 以参数形式传入到当前对象方法中的对象;
- 当前对象的成员对象;
- 如果当前对象的成员对象是一个集合,集合中的元素;
- 当前对象所创建的对象。
通常开发容易把本应属于职责边界内的逻辑暴露到外部,调用这段逻辑的客户端代码容易写的过厚,比如客户端依赖n个组件,然后组织n个组件的关系,最后计算出结果,导致客户端和过多的不相关组件通信。其实这n个组件中,很可能大部分逻辑应该封装在几个组件之中,而客户端只需要和少量几个组件通信即可。
当出现连续“.”的方法调用时,比如a.foo().bar(),此时应该警惕是否违反了迪米特法则。
1.6.5 实际运用中的取舍
实际开发中,为了避免对象间的直接耦合,往往借助“Mediator”、“Proxy”等设计模式,引入中间人,拆开耦合。
二、GoF的23个设计模式
《Design Patterns Elements of Reusable Object-Oriented Software》
作者介绍:
- Erich Gamma博士是瑞士苏黎士国际面向对象技术软件中心的技术主管。
- Richard Helm博士是澳大利亚悉尼IBM顾问集团公司面向对象技术公司的成员。
- Ralph Johnson博士是Urbana-Champaign伊利诺大学计算机科学系成员。
- John Vlissides博士是位于纽约Hawthorne的IBN托马斯J.沃森研究中心的研究人员。
2.1 创建型设计模式
创建型模式用来解决对象的创建问题,用于解耦对象的使用者和对象的创建过程。
对象的使用者通常不应该包含创建复杂对象的逻辑,否则,对象的创建逻辑的变化会导致对象的使用者需要修改代码。
- Factory Method Pattern (工厂方法模式)
- Abstract Factory Pattern (抽象工厂模式)
- Singleton Pattern (单例模式)
- Builder Pattern (建造者模式)
- Prototype Pattern (原型模式)
2.2 结构型设计模式
结构型模式用来解决对象间的组合或组装的问题。用于简化客户端代码逻辑,使得客户端依赖的对象可以方便的扩展而不影响客户端。或节省客户端内存。
通常我们希望客户端使用其他对象的时候代码格外精简,优雅,而不是在使用前还要用大量的代码对目标对象做处理。因为这些对目标对象做处理的逻辑很可能发生变化,而这部分逻辑并不是客户端的业务职责。
- Proxy Pattern (代理模式)
- Adapter Pattern (适配器模式)
- Decorator Pattern (装饰器模式)
- Facade Pattern (门面模式)
- Composition Pattern (组合模式)
- Flyweight Pattern (享元模式)
- Bridge Pattern (桥接模式)
3.3 行为型设计模式
行为型模式解决的是对象之间交互方式的问题。
不适当的交互方式很容易产生不必要的耦合,使日后扩展受阻,不利于代码复用。
- Observer Pattern (观察者模式)
- Template Method Pattern (模板方法模式)
- Strategy Pattern (策略模式)
- Chain of Responsibility Pattern (责任链模式)
- State Pattern (状态模式)
- Iterator Pattern (迭代器模式)
- Visitor Pattern (访问者模式)
- Memento Pattern (备忘录模式)
- Command Pattern (命令模式)
- Interpreter Pattern (解释器模式)
- Mediator Pattern (中介模式)
三、The 12-Factor App
Originally devised by developers at Heroku, this methodology identifies key principles shared by successful applications.
The Twelve-Factor App
3.1 Single Codebase (基准代码)
微服务之间不共享代码:多个微服务可以共享一个project,但每个微服务要有一个完整的代码仓库,且微服务之间没有公共的底层代码。
避免一个微服务修改代码影响其他微服务。
3.2 Dependencies (依赖)
显式声明依赖关系
要显式声明依赖,且打包后使微服务自包含;不要假设微服务之外一定存在某个服务而不加依赖声明就去依赖。
避免微服务在不同环境间部署产生差异;使新人上手容易,拉出代码,使用构建工具打包后即可运行。
3.3 Config (配置)
在环境中存储配置
微服务环境相关配置外置。微服务可以从外部拉取对应环境配置,使得微服务在构建之后在不同环境间切换时不用修改构建结果。
避免微服务在不同环境间部署时需要修改源代码引入意外bug,或忘记修改代码中配置导致部署失败。
3.4 Backing Services (后端服务)
把后端服务当做附加资源
微服务依赖的所有第三方服务都应该被视作一个资源,应该使用地址标识该资源,通过防腐层和依赖反转隔离微服务对外部资源的直接依赖,使得在不同环境只需要修改对应资源地址而不用修改代码即可和第三方服务对接,及时第三方服务的技术实现发生了变化微服务也不用修改代码。
避免在不同环境间部署微服务时,因为第三方服务地址不同或实现不同,版本不同而需要修改代码。
3.5 Build, Release and Run (构建,发布,运行)
严格分离构建和运行。
微服务应该严格区分构建,发布和运行三个阶段。构建是从代码变成二进制的过程,和环境无关,而且不可再变;发布是给二进制加上对应环境的配置,在不同环境长需要使用相同的二进制和不同的环境配置重复发布;运行是在对应环境上启动进程。每个环境有各自的职责,禁止在运行阶段直接修改二进制。
避免微服务在上线过程中不同阶段引入不可预知bug。
3.6 Processes (进程)
以一个或多个无状态进程运行应用
微服务的每个实例都应该状态外置,存在缓存或DB中,使微服务实例自身无状态。可以任意水平扩容,负载均衡或代理可以把请求给到任意实例上。
避免无法水平扩容,避免单实例故障后丢失状态。
3.7 Port Binding (端口绑定)
通过端口绑定来提供服务
微服务不应该依赖任何已经存在的服务来对外提供网络服务,应该通过依赖服务提供程序形成自包含,微服务本身就可以对外提供网络服务。
避免网络服务提供能力依赖已存在的其他服务,导致如果其他服务不存在就无法提供网络服务。
3.8 Concurrency (并发)
通过进程模型进行扩展
每个微服务职责单一,不同微服务可根据流量分别水平扩容。微服务不要自己做进程管理。
避免导致微服务扩容的因素过多,造成浪费
3.9 Disposable (易处理)
快速启动和优雅终止可最大化健壮性
微服务应该追求最小启动时间和优雅的终止服务。优雅的终止服务意味着微服务接收到终止信号后首先关闭网络端口不再接收新的请求,而后继续处理已经接收的请求,最后终止服务。
避免因启动时间太长而影响对突发问题的响应能力。避免在重新调度微服务时由于不优雅终止服务而影响业务。
3.10 Dev / Prod Parity (开发环境和线上环境等价)
尽可能的保持开发、预发布、线上环境相同
微服务应该反对在不同环境间使用不同的后端服务。
避免不同的后端服务突然出现的不兼容,导致测试、预发布都正常的代码在线上出现问题。
3.11 Logs (日志)
把日志当做事件流
微服务的日志应当被看作事件流,微服务不要自己管理日志存储,日志应当被日志汇聚底层基础设施统一的处理。
避免登录主机查看日志的麻烦,避免单实例故障导致日志丢失。避免历史事件的不可查询。
3.12 Admin Processes (管理进程)
后台管理任务当做一次性进程运行
后台管理任务的代码随正常业务代码一同升级交付,后台管理任务使用不同的入口,一次性执行,但是基于正常业务的进程执行,两者使用相同的权限
避免管理任务的代码落后于业务代码导致无法使用,避免管理任务的权限超出正常业务进程,导致故障。
四、微服务设计关注点
4.1 系统分层
- 后端微服务使用领域模型驱动开发模式
- 使用六边形或整洁架构作为代码的分层架构依据
4.2 持久层设计
- 禁止全表扫描操作,sql执行要有监控,持续优化慢sql
- 禁止把大量日志写入关系型数据库
根据业务逻辑复杂度,对性能的诉求,可以考虑是用CQRS模式——即读写分离模式。读写分离模式分为数据库层面的读写分离——即主库用来写,从库用来读,和模型层面读写分离。模型层面读写分离针对写场景和读场景可以完全分开设计,模型可以完全不同,以便基于写和读场景的特定需求定制优化,数据库表结构也可以完全不同,甚至写和读场景使用完全不同的数据库产品,如写场景使用RDBMS,读场景使用NoSQL。
4.3 远程通信
- 服务注册、发现、负载均衡使用API Gateway,系统间通过API Gateway通信
- 系统暴露RestfulAPI给其他系统
4.4 数据一致性
- 数据变更要同源,禁止同一个数据在两处变更然后同步
- 分布式系统随时可能出现故障而使一次请求失败,对于系统间通信场景,系统设计时要考虑各个环节异常的处理,如:
- 对方无法正常响应
- 对方已经接到请求,但是没来得及返回就挂了
- 接到对方返回,没来的及处理自己挂了
- 数据已经正常持久化,但是没能及时同步到其他副本,导致从其他副本读数据出现不一致
4.5 性能
- 消耗资源的服务要以非阻塞方式对外提供,接到请求先持久化,然后立即响应,任务执行完通过回调或消息通知方式通知调用方
- 禁止轮询的方式读取其他系统状态变化,也禁止通过api的方式提供资源状态变化查询服务,应该使用消息通知的方式
- 消耗资源较多的api要预估资源占用情况,不要一次调用就把内存耗尽,要有防御性设计,给运维留出余地
- api设计要支持水平扩展,禁止绑定单台服务器处理任务,任务量上来后要可以通过扩容来降低单机压力
- 写日志等和主业务逻辑没有强依赖的步骤要做成异步的
- 对关键系统的写入操作要考虑是否有可能在某些条件下产生写入量突增,要做好预防,以免把关键系统写挂
- 上线新功能前要预估新功能对资源的需求增量,提前做好扩容准备
4.6 依赖原则
4.7 部署规范
- 每个组件一个IP,统一端口,不允许多个组件合部署在一个IP上
4.8 架构设计交付件
五、微服务设计成熟度衡量标准
| 分类 |
度量项 |
参考文档 |
| API |
API Rest成熟度 (使用Spring-HATEOAS提供可导航的API为4级最高级) |
|
| API |
客户端代码自动生成 |
|
| API |
通过Swagger输出API文档 |
|
| 服务注册 |
服务自动注册发现 |
|
| 服务治理 |
接入API Gateway或Service mesh |
|
| 配置 |
使用分布式动态配置 |
|
| 分布式事务 |
利用消息总线实现分布式事务最终一致性 |
|