码农戏码

新生代农民工的自我修养

0%

《DDD之形》把当前一些流行的架构给通览了一篇,那是不是万事大吉,随便挑一个形态实践就行呢?

正常情况对于有追求的程序员来讲,肯定不行,有两个原因

一因为完美,人人都是想要完美,每个架构实践都不是完美的,尤其离开业务场景去探讨架构,会使架构没法落地;因此你小抄的时候会变形,想把原先至少不太好的地方改得相对好些,但这样可能造成四不像

二因为没有意的形,只知其形,不得其意,必然会东施效颦;类似于第一点,完美,何为完美,还是得根据自身经验理解程序得出的结论,如果高度不够必然会画蛇添足

标准形态

根据DDD的理论,或者说DDD带来的优势,将三层架构进行演化,把业务逻辑层再细拆分成三层:应用层、领域层和基础设施层

分离业务逻辑与技术细节

DDD的标准形态

分层架构分为两种:严格分层架构和松散分层架构

严格分层,某层只能与直接位于其下方的层发生耦合;松散分层,允许任意上方层与任意下方层发生耦合

  1. User Interface是用户接口层,主要用于处理用户发送的Restful请求和解析用户输入的配置文件等,并将信息传递给Application层的接口
  2. Application层是应用层,负责多进程管理及调度、多线程管理及调度、多协程调度和维护业务实例的状态模型。当调度层收到用户接口层的请求后,委托Context层与本次业务相关的上下文进行处理
  3. Domain层是领域层,定义领域模型,不仅包括领域对象及其之间关系的建模,还包括对象的角色role的显式建模
  4. Infrastructure层是基础实施层,为其他层提供通用的技术能力:业务平台,编程框架,持久化机制,消息机制,第三方库的封装,通用算法,等等

DIP

上面的标准形态图形的左半边图,跟以往的很不一样,但从DIP角度看,低层服务应该依赖于高层组件

这是COLA2.0的分层,作者利用DIP对1.0版本进行了优化

1、核心业务逻辑和技术细节相分离,使用DIP,让Infrastructure反向依赖Domain

2、将repository上移到application层,把组装entity的责任转移到application


到此一切都很常规,很标准,但在落地时有几个问题:

一、Controller是哪一层,user interface层?还是infrastructure层?

这好像不是个问题,一般都放在user interface层,负责向用户展现信息以及解释用户命令;但细想一下,我们的controller都是基于底层框架,是框架提供的能力,那理论上放在infrastructure更合理一些。

二、再细化的架构元素放哪里?

其实这只是分层的大体方向,还有更细节的元素,在实践DDD的过程中,最常见的问题就是元素到底放在哪儿,比如Event,有各样的event,eventHandler,应用层、领域层都有,怎么区分呢?貌似回到了怎么区分应用服务与领域服务;还有各处javabean,哪些层能复用,谁复用谁

再比如COLA2.0,单从层次依赖图明显形成了循环依赖,落地不了;但作者把repository上移到了application层,又有些走不寻常路,一般来讲repository是放在domain层

如何办呢?大方向有了,但到小细节时,又有各种困惑,《SOLID之DIP》文中提到,分层至少有两层,一是业务领域层,二是其它层

这就是端口和适配器架构,可以算是六边形的简化版,但也从整体方向分成了两层,对应用层与用户接口层以及基础设施层进行了合并,无论是接受请求,还是输出数据都是gateway

但六边形架构仅仅区分了内外边界,提炼了端口与适配器角色,并没有规划限界上下文内部各个层次与各个对象之间的关系;怎么破?


这似乎是两个矛盾体,标准的层次分明了,但还是有些不确定性,对象放在哪个包不明确;而端口与适配器架构又太粗放;如何平衡?

本质上,领域驱动设计的限界上下文同样是对软件系统的切割,依据的关注点主要是根据领域知识的语境,从而体现业务能力的差异。在进入限界上下文内部,我们又可以针对限界上下文进行关注点的切割,并在其内部体现出清晰的层次结构,这个层次遵循整洁架构

根据张逸老师DDD课程中的案例

  • 领域层:包含 PlaceOrderService、Order、Notification、OrderConfirmed 与抽象的 OrderRepository,封装了纯粹的业务逻辑,不掺杂任何与业务无关的技术实现。
  • 应用层:包含 OrderAppService 以及抽象的 EventBus 与 NotificationService,提供对外体现业务价值的统一接口,同时还包含了基础设施功能的抽象接口。
  • 基础设施层:包含 OrderMapper、RabbitEventBus 与 EmailSender,为业务实现提供对应的技术功能支撑,但真正的基础设施访问则委派给系统边界之外的外部框架或驱动器

重新审视六边形架构,匹配分层架构,两者可以融合

位于六边形边线之上的出口端口就应该既不属于领域层,又不属于基础设施层。它的职责与属于应用层的入口端口也不同,因为应用层的应用服务是对外部请求的封装,相当于是一个业务用例的外观

DDD引入repository放在了领域层,一是对应聚合根的概念,二是抽象了数据库访问,,但DDD限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口EventPublisher支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想

依赖反转原则 DIP, Dependency inversion principle

  1. 高层模块不应该依赖于低层模块。二者都应该依赖于抽象
  2. 抽象不应该依赖于细节。细节应该依赖于抽象

高低层

首先理解一下什么高层和低层

高层

高层包含了一个应用程序中的重要策略选择和业务模型

也就是业务逻辑是高层

低层

相对于高层,低层包括框架、数据库、消息队列等其它系统部分


这也符合我们的预期,不管数据库,还是框架改变时,不应该影响到业务逻辑;也就是说业务逻辑不应该依赖于具体实现技术细节

反转

反转想起了IOC,之前总结了一篇《IOC理解》

DIP里面指的反转是什么?

这是一个典型的调用树例子,main函数调用了一些高层函数,这些高层函数又调用了一些中层函数,中层函数继续调用低层函数。源代码层面的依赖不可避免地要跟随程序的控制流

这造成了程序耦合性特别的高,若程序需要改变实现方式,那就是灾难,也违反了OCP

也导致了在软件架构上别无选择。系统行为决定了控制流,而控制流则决定了源代码依赖关系


有这么多的问题,怎么办呢? 反转

反转依赖方向

如何达到呢?利用面向对象的多态特性,这是面向对象编程的好处,也是面向对象的核心本质,无论面向怎么样的源代码级别的依赖关系,都可以将其反转

控制流是从高层到低层,但低层模块与接口抽象类的方向正好相反

面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制能力

架构师可以完全采用面向对象系统中所有源代码依赖关系,而不再受到系统控制流的限制。不管哪个模块调用或者被调用,架构师都可以随意更改源代码依赖关系

业务逻辑控制用户界面与数据库,但源代码依赖关系相反,这样业务逻辑模块的源代码不需要引入用户界面和数据库两个模块,于是业务逻辑组件就可以独立于用户界面和数据库独立部署,不会对业务逻辑产生任何影响

反转控制流方向与源代码依赖关系方向相反,源代码依赖方向永远是控制流方向的反转

分层

延伸一下DIP,探讨一下分层架构

架构模式有很多种,分层、六边形等等,分层架构是运用最为广泛的架构模式

为什么要分层

虽然分层架构很流行,尤其常用的MVC,但为什么需要分层呢?

对系统的结构分层,把系统中相关联的部分被集中放在一个独立的层内,分而治之,这正好是SRP,每一层只能有一个引起他变化的原因

如何分层呢?变化原因可以有多个维度

一、基于关注点,如MVC,其上面向用户的体验与交互,中间面向应用与业务逻辑,其下面向各种外部资源与设备

二、基于变化,针对不同变化原因确定层次边界,如数据库结构的修改自然会影响到基础设施的数据模型以及领域层的领域模型,但当我们仅修改数据库访问实现时,就不应该影响到领域层

不管以何种原因将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层

源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略

Entities业务实体:封装了整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数集合。

如果我们写的不是一个大型系统,而是一个单一应用的话,那么我们的业务实体就是该应用的业务对象。这些对象封装了该应用中最通用、最高层的业务逻辑

而分层也不必为经典的三层架构又或是DDD的四层的固有思维,而是将分层视为关注点分离的水平抽象层次的体现。层太少可能导致关注点不够分离,导致系统的结构不合理;层太多则引入太多的间接而增加不必要的开支

层协作

在固有认知中,分层架构的依赖都是自顶向下传递的

比如:Controller依赖Service,Service依赖Dao;如此控制流决定了源代码的依赖关系,也就是没有反转,是违背DIP的

从DIP定义,为什么要依赖抽象呢?除了能反转从而解耦,还有别的原因吗?

我们每次修改接口时,一定会去修改实现;但修改实现时不一定修改接口;也就是抽象比实现稳定,抽象层相对是个稳定的层次;抓住不变的,控制住变化的,是我们的目标,优秀工程师就得多花时间在设计接口上,减少未来对其改动,争取在不修改接口的情况下增加新功能

其实除了自顶向下的请求也有自底向上的通信:通知,观察者模式,在上层定义Observer接口,提供update()方法供下层在感知状态发生变更时高用

层与层之间的协作,就得借助DIP,打破高层依赖低层的固有思维,从解除耦合的角度探索层之间可能的协作关系,再配合IOC,具体依赖关系由IOC框架实现,更好地解除了高层对低层的依赖

实践

  1. 在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类
  2. 不要在具体实现类上创建子类
  3. 不要override包含具体实现的函数
  4. 应避免在代码中写入与任何具体实现相关的名字,或者是其它容易变动的事物的名字

DDD现在已然变成哲学,正因为是哲学,所以法无定法,到底怎么具体怎么实施,各显神通,心法固然重要,但心法有几人能真正领悟,一说就懂,一问就不会,一讨论就吵架;所以还是从外形看看,收集一些实践后的形态,由表入里,以形学形,慢慢品

看下面两个分层,左边是Vaughn Vernon 在《实现领域驱动设计》一书中给出了改良版的分层架构,他将基础设施层奇怪地放在了整个架构的最上面;右边就是DDD最标准的分层形态

形一

这是DDD专家张逸老师形态之一,除了controller在gateway中,其它还算常态

  • ecommerce
    • core
      • Identity
      • ValueObject
      • Entity
      • DomainEvent
      • AggregateRoot
    • controllers
      • HealthController
      • MonitorController
    • application(视具体情况而定)
    • interfaces
      • io
      • telnet
      • message
    • gateways
      • io
      • telnet
      • message
    • ordercontext
      • application
      • interfaces
      • domain
      • repositories
      • gateways
    • productcontext
      • application
      • interfaces
      • domain

除了限界上下文自身需要的基础设施之外,在系统架构层面仍然可能需要为这些限界上下文提供公共的基础设施组件,例如对 Excel 或 CSV 文件的导入导出,消息的发布与订阅、Telnet 通信等。这些组件往往是通用的,许多限界上下文都会使用它们,因而应该放在系统的基础设施层而被限界上下文重用,又或者定义为完全独立的与第三方框架同等级别的公共组件。理想状态下,这些公共组件的调用应由属于限界上下文自己的基础设施实现调用。倘若它们被限界上下文的领域对象或应用服务直接调用(即绕开自身的基础设施层),则应该遵循整洁架构思想,在系统架构层引入 interfaces 包,为这些具体实现定义抽象接口

controller被放到了gateway中,包含远程调用,数据库;所有对外的接口都属于一种网关

形二

cola在github开源,作者模块与包划分,每个架构元素都很明确

controller

这是一个可选层,正如《分层架构》所讲,现在的框架都已经帮助从底层的具体HttpRequest转换成了requestDto,很多时候都是透传service,而像thrift类的框架,为了透明化入口,需要转换一下,

xxx.controller

client

二方库,里面存放RPC调用的DTO

xxx.api:存放应用对外接口

xxx.dto.domainmodel:数据传输的轻量级领域对象

xxx.dto.domainevent:数据传输的领域事件

application

应用层

xxx.service:接口实现的facade,没有业务逻辑,可以对应不同的adapter

xxx.event.handler:处理领域事件

xxx.interceptor:对所有请求的AOP处理机制

domain

领域层

xxx.domain:领域实现

xxx.service:领域服务,用来提供更粗粒度的领域能力

xxx.gateway:对外依赖的网关接口,包括存储、RPC等

infrastructure

基础层

xxx.config:配置信息相关

xxx.message:消息处理相关

xxx.repository:存储相关,gateway的实现类,主要用来做数据的CRUD操作

xxx.gateway:对外依赖网关接口(domain里面的gateway)的实现

形三

这是张逸老师课程的又一形态

六边形架构仅仅区分了内外边界,提炼了端口与适配器角色,并没有规划限界上下文内部各个层次与各个对象之间的关系;而整洁架构又过于通用,提炼的是企业系统架构设计的基本规则与主题。因此,当我们将六边形架构与整洁架构思想引入到领域驱动设计的限界上下文时,还需要引入分层架构给出更为细致的设计指导,即确定层、模块与角色构造型之间的关系


这是老师最新总结的菱形对称架构

南向网关引入了抽象的端口来隔离内部领域模型对外部环境的访问。这一价值等同于上下文映射的防腐层(Anti-Corruption Layer,简称为 ACL) 模式,只是它扩大了防腐层隔离的范围

形态四

该架构由端口和适配器组成,所谓端口是应用的入口和出口,在许多语言中,它以接口的形式存在

Martin Fowler将“封装访问外部系统或资源行为的对象”定义为网关(Gateway),在限界上下文的内部架构中,它代表了领域层与外部环境之间交互的出入口,即:

gateway = port + adapter

这个形态,简单入里,算是菱形对称架构的简易形,甚至可以说是菱形的初形

driving adapter + domain + driven adapter

总结

形态之多,背后的理论支撑之丰富,可见DDD的博大精深,谁能说是正宗,就算是Eric Evans都要怀疑人生,但不迷信,没有银弹。自己实践的才是最合适的

从年初就开始温习SOLID原则,原则看似很简单,有些其实就是一句话,但对照这些原则去观看自己的过往代码,还是很多违背这些原则的,诚然,没有完美的实践,但需要保持在走向完美的路上

当然,如果你说天天在CRUD,从没有用到过这些原则,这就是另一个话题了

最近就写了一段很臭的代码,反省一下

1
2
3
4
5
6
7
8
9
10
public AbstractInvoice(InvoiceCode invoiceCode, InvoiceNo invoiceNo, PaperDrewDate paperDrewDate, CheckCode checkCode) {
this.invoiceCode = invoiceCode;
this.invoiceNo = invoiceNo;
this.paperDrewDate = paperDrewDate;
if (checkCode instanceof VerifyCheckCode) {
this.checkCode = checkCode.toNormal();
} else {
this.checkCode = checkCode;
}
}

这段代码很简单,业务是发票,一张发票由基本的几个要素组成:发票代码、发票号码、开票时间、检验码、金额

形象点,找个平常普通发票的票样

其次,这是一个抽象类,构建时带有发票几票素,这些InvoiceCode,InvoiceNo,PaperDrewDate,CheckCode都是简单的Primitive Domain(不了解这个概念也没关系,下回分解;简单讲就是业务自包含的基本类型)

完整的抽象类关系

但代码里面用到了instanceof,当用到这个关键字,而且是在抽象实体时,基本上可以断定是抽象的层次不够,
可能违背了LSP

LSP原则很明了:子类可以随时替换父类;这儿用了instanceof,说明有不可替换的成份在

再追看CheckCode的层次

一个简单的校验码,为什么也要抽象成这样?

1
2
3
4
5
6
7
8
9
10
11
public abstract class CheckCode {

protected String checkCode;

public CheckCode(String checkCode) {
this.checkCode = checkCode;
}

public CheckCode(int length, String checkCode, boolean isNumeric) {
}
}

校验码有几种形式

  1. 标准的20位长度的完整检验码
  2. 后6位简短的检验码,发票做验真业务时使用
  3. 区块链发票的检验码,5位长度,字母数字组合

因为有三种形态,不同的长度就是不同的类型;验真时只需要简短检验码;查看完整信息时需要完整的检验码

在发票接口中,也有相应的获取行为,也正因为有这种形为,需要在创建发票对象时,把VerifyCheckCode转换成NormalCheckCode

1
2
3
4
5
public interface Invoice {

public CheckCode getCheckCode();

public String getVerifyCheckCode();

这儿有个疑问,为什么不在构建发票前,把verifyCheckCode转成normalCheckCode,而不是到Invoice的构建内部再转化,那也就没有instanceof的事


但再细想,对于发票来讲,其实只有一个checkCode属性,没有VerifyCheckCode,那只是在请求验真时的一种形态,所以在发票对象中,不应该有verifyCheckCode属性

所以Invoice对象应该是这样

1
2
3
4
5
6
7
8
9
10
11
12
public interface Invoice {

public CheckCode getCheckCode();
}

public AbstractInvoice(InvoiceCode invoiceCode, InvoiceNo invoiceNo, PaperDrewDate paperDrewDate, CheckCode checkCode) {
this.invoiceCode = invoiceCode;
this.invoiceNo = invoiceNo;
this.paperDrewDate = paperDrewDate;
this.checkCode = checkCode;
}

对于primitive domain,CheckCode自包含中应该带有verifyCheckCode

每一种CheckCode都有各自不同的行为


一般通过instanceof判断子类型时,都有不满足LSP的嫌疑;在这个场景中也差不多,但抓住了这一点,重新思考一下,类层次与结构行为可以设计得更合理

接口隔离原则,ISP,Interface Segregation Principle

用于处理胖接口(fat interface)所带来的问题。如果类的接口定义暴露了过多的行为,则说明这个类的接口定义内聚程度不够好

第一种定义: Clients should not beforced to depend upon interfaces that they don’t use.

客户端不应该依赖它不需用的接口

第二种定义:The dependency of oneclass to another one should depend on the smallest possible interface。

类间的依赖关系应该建立在最小的接口上


ISP还是比较简单的,通过行为分离,达到高内聚效果

不遵循ISP

类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。

对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法

显然接口I是个胖接口,客户端依赖了他不需要用的接口方法

遵循ISP

将原有的接口I拆分为三个接口,类A不需要用到“方法4”和“方法5”,就可以选择不依赖接口I3

实例

设计一个门接口,它包含了一款自动门所需要的功能,开关,自动关闭等。

但是并不是每个门都有自动关闭的功能,所以timeOut超时这个方法放在该接口中,就会导致所有实现这个接口的子类都会默认的继承了这个方法,哪怕子类中不对这个方法做处理。

把接口拆分成2个,拆分成Door和TimeClient,2个接口,这样Door就只保持原有的基本功能,而timeOut超时的方法则放到TimeClient接口中,这样就可以解决上述中接口臃肿的问题

VS SRP

很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然

其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。

其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建


在单一职责原则中,一个接口可能有多个方法,提供给多种不同的调用者所调用,但是它们始终完成同一种功能,因此它们符合单一职责原则,却不符合接口隔离原则,因为这个接口存在着多种角色,因此可以拆分成更多的子接口,以供不同的调用者所调用。

比如说,项目中我们通常有一个Web服务管理的类,接口定义中,我们可能会将所有模块的数据调用方法都在接口中进行定义,因为它们都完成的是同一种功能:和服务器进行数据交互;但是对于具体的业务功能模块来说,其他模块的数据调用方法它们从来不会使用,因此不符合接口隔离原则

架构

在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害。

从源代码层次来说,这亲的依赖关系会导致不必要的重新编译和重新部署

对更高层次的软件架构设计来说,问题也类似

如果D中包含了F不需要的功能,那么这些功能同样也会是S不需要的。对D中这些功能的修改会导致F需要被重新部署,后者又会导致S的重新部署。

更糟糕的是,D中一个无关功能的错误也可能会导致F和S运行出错

TIPS

采用接口隔离原则对接口进行约束时,要注意以下几点:

  • 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
  • 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
  • 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则

Reference

《整洁架构之道》

Interface Segregation Principle(ISP)–接口隔离原则

最近连续做了两个新项目,借着新项目的机会,重新审视一下之前一些实践方法,进而寻求一下背后的理论支撑

新项目开始,首先一个就是会新建一个project,那么这个project怎么分层,怎么创建module,怎么分包呢?

经典分层

以传统方式,经典的MVC分层,就controller,service,model

找来一张servlet时代的经典处理流程,虽然技术手段日益更新,但处理流程是一样的

抽象一下,经典的分层就是:

现在大多数系统都是这种分层结构。JavaBean里面只有简单的get和set方法,被很多人鄙视的“贫血模型”;业务逻辑变得复杂时,service会变得很臃肿,出现很多的“上帝类”

回想一下,我们的所有的代码都写在ServiceImpl里面,前几行代码是做validation的事,接下来几行是做convert的事,然后是几行业务处理逻辑的代码,穿插着,我们需要通过RPC或者DAO获取更多的数据,拿到数据后,又是几行convert的代码,在接上一段业务逻辑代码,然后还要落库,发消息…等等

这也是事务脚本开发方式,横行于世。数据与行为被分离。

简单业务这样开发是合适的,但业务一复杂起来,需求频繁变更后,就没人能理解任何一段代码,业务逻辑和技术细节糅杂在一起,业务领域模型没法清晰表达出来,开发人员只知道怎么处理了数据,但背后的业务语义完全不知道

其实呢?还有很多的包,如config,common,core等等,
如果使用了一些中间件,如rabbitmq,还会相应创建上对应的包,简单点可能就被放在了service包下

从有了maven之,module概念更加显现化

1
2
3
4
5
6
<modules>
<module>service</module>
<module>common</module>
<module>core</module>
<module>test</module>
</modules>

我们的那么多包有了更加明确的地方放置,不再是直接放置在工程目录下

由于上面的这些问题 ,我们似乎可以指出经典的三层架构的弱点:

  • 架构被过分简化,如果解决方案中包含发送邮件通知,代码应该放置在哪些层?
  • 它虽然提出了业务逻辑隔离,但没有明确的架构元素指导我们如何隔离

DDD

虽然技术日新月异,但大多仅仅是技术,带了实现的便利性,但对于业务层次,更多的还是经验。随着业务的复杂性提升,系统的腐化速度和复杂性呈指数上升。

DDD带了很多的认知的改变,最大的好处是将业务语义显现化,不再是分离数据与行为,而是通过领域对象将领域概念清晰的显性化表达出来

当然这世间并没有银弹,但至少能给我们带来一种改进经典分层的理论支撑

DDD中带来了Repository概念,以及基础设施层,再结合【DIP原则】,可以把三层结构变成


再细看一下Controller,这一层,做些什么呢?

轻业务逻辑,参数校验,异常兜底。通常这种接口可以轻易更换接口类型,所以业务逻辑必须要轻,甚至不做具体逻辑

但在现实中,有些更极端,在servlet时代,还做下HttpRequest转换成DTO,传入service,现在有了springmvc,struts2框架的转换,不需要转换了,那么controller成了一个透传层,直接调用service

这儿有两个问题,既然controller是透传,那有必要存在吗? controller调用的service,这个service指的服务是什么呢?

第一controller显然有必要存在,不再于业务,而在于技术实现。不管是http,rpc都得有个请求入口。像thrift可能会比其他的一些rpc框架例如dubbo会多出一层,作用和controller层类似

Tservice与controller的作用是一样的

第二service服务指的是什么?领域服务吗?如果一个复杂的业务,那么会跨越多个领域,自然需要多个领域服务。如果放在controller里面,也就是在controller里面去编排领域服务,如果切换到thrift,那Tservice就得重复

因此,此时需要另一个service,在DDD中就是应用服务

应用服务算是领域服务的facade,应用层是高层抽象的概念,但表达的是业务的含义,领域层是底层实现的概念,表达的是业务的细节


1
2
3
4
5
6
7
<modules>
<module>controller</module>
<module>application</module>
<module>domain</module>
<module>infrastructure</module>
<module>start</module>
</modules>

controller模块:restfull风格的少不了这层

application模块:类似以前的service包

domain模块:如果是事务脚本方式,domain模块就没有了

infrastructure模块:基础模块,类似之前的dao包,但这里面都是实现类,而像repository接口则在domain模块,还需要对应的convertor

模块里面各个包,可能需要按实践情况而定了,后面再从项目中抽取个archetype,使用maven直接生成

里氏代换原则 LSP,Liskov Substitution Principle

子类型必须能够替换掉它们的基类型

若对每个类型S的对象O1,都存在一个类型T的对象O2,使得在所有针对T编写的程序P中,用O1替换O2后,程序P行为功能不变,则S是T的子类型

LSP是继承关系设计基本原则,也是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展,对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型辨别(RTTI),这种方式常常是使用一个显式的if语句或才if/else链去确定一个对象的类型

假设一个函数f,它的参数为指向某个基类B的指针或者引用。同样假设B的某个派生类D,如果把D对象作为B类型传递给f,会导致f出现错误的行为。那么D就违反了LSP。显然,D对于f来说是脆弱的。

f的编写者会想去对D进行一些测试,以便于在把D的对象传递给f时,可以使f具有正确的行为。这个测试违反了OCP,因为此时f对于B的所有派生类都不再是封闭的

IS-A

“IS-A”是严格的分类学意义上的定义,意思是一个类是另一个类的“一种”

我们经常说继承是IS-A关系,也就是如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新对象的类应该从这个已用对象的类派生

从一般意义上讲,一个正方形就是一个矩形。因此,把Sequare类视为从Rectangle类派生是合乎逻辑的

1
2
3
4
5
void f(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert(r.Area() == 20)
}

此时,如果传入的是Sequare对象,那这个函数f不能正确处理,也就是Squauare不能替换Rectangle,也就违反了LSP,意味着LSP与通常的数学法则和生活常识有不可混淆的区别

在OOD中IS-A关系是就行为方式而言,而不是属性,这也就是面向接口编程;派生类的行为方式和输出不能违反基类已经确立的任何限制。基类的用户不应该被派生类的输出扰乱

简单判断就是“可替换性”,子类是否能替换父类并保持原有行为不变

LSP与架构

LSP从诞生开始,也就差不多这些内容,主要是指导如何使用继承关系的一种方法。随着时间推移,在更宏观上,LSP逐渐演变成了一种更广泛的、指导接口与其实现方式的设计原则

可以是java风格的接口,具有多个实现类:甚至可以是几个服务响应同一个rest接口,用户都依赖于一种接口,并且都期待实现该接口的类之间能具有可替换性

一旦违背了可替换性,该系统架构就不得不为此增添大量复杂的应对机制

开闭原则 OCP Open-Closed Principle

设计良好的计算机软件应该易于扩展,同时抗拒修改

换句话说,一个良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展

遵循开闭原则设计出的模块具有两个特征:

  1. “对于扩展是开放的”,当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为
  2. “对于更改是封装的”,对模块进行扩展时,不必改动原有的代码

其实这也是研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显示是失败的

一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为0

这原则看着很矛盾,需要扩展,但却又不要修改;那么如何实现这个原则呢?

抽象,面向接口编程

模块可以操作一个抽象体。由于模块依赖于一个固定的抽象体,所以它对于更改可以是关闭的。同时,通过从这个抽象体派生,也可以扩展此模块的行为

client,server都是具体类,client使用server

如果client想使用另一个server对象,那么需要修改client中使用server的地方

显然这样违反了OCP

在新的设计中,添加了ClientInterface接口,此接口是一个拥有抽象成员函数的抽象类。Client类使用这个抽象类。如果我们希望client对象使用不同的server,只需要从clientinterface类派生一个新类,client无需任何修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface ClientInterface
{
public void Message();
//Other functions
}

class Server:ClientInterface
{
public void Message();
}

class Client
{
ClientInterface ci;
public void GetMessage()
{
ci.Message();
}
public void Client(ClientInterface paramCi)
{
ci=paramCi;
}
}

//那么在主函数(或主控端)则
public static void Main()
{
ClientInterface ci = new Server();
//在上面如果有新的Server类只要替换Server()就行了.
Client client = new Client(ci);
client.GetMessage();
}

OCP设计类与模块时的重要原则,但是在架构层面,这项原则意义更重大。

在设计时,可以先将满足不同需求的代码分组(SRP),然后再来调整这些分组之间的依赖关系(DIP)

IOC是不是也有OCP的味道

OCP算是面向对象设计的核心所在。遵循这个原则可以带来面向对象技术的巨大好处(灵活性,可重用性以及可维护性)

然而,并不是说只要使用一种面向对象语言就得遵循这个原则。对于应用程序中每个部分都肆意地进行抽象同样不是一个好主意。正确的做法是,开发人员应该仅仅对程序中呈现出频繁变化的那些部分进行抽象,拒绝不成熟的抽象和抽象本身一样重要

在一个复杂的软件中为什么会建议“尽量”不要违背OCP?

最核心的原因就是一个现有逻辑的变更可能会影响一些原有的代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障单测的覆盖率。OCP的原则能尽可能的规避这种风险,当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变


Common Closure Principle(CCP)共同封闭原则

CCP延伸了开闭原则(OCP)的“关闭”概念,当因为某个原因需要修改时,把需要修改的范围限制在一个最小范围内的包里

一个包中所有的类应该对同一种类型的变化关闭。一个变化影响一个包,便影响了包中所有的类。一个更简短的说法是:一起修改的类,应该组合在一起(同一个包里)。如果必须修改应用程序里的代码,我们希望所有的修改都发生在一个包里(修改关闭),而不是遍布在很多包里。CCP原则就是把因为某个同样的原因而需要修改的所有类组合进一个包里。如果2个类从物理上或者从概念上联系得非常紧密,它们通常一起发生改变,那么它们应该属于同一个包。

CCP还是解决分布式单体可怕的反模式的法宝

在现流行的微服务架构中,按业务能力和子域以及SRP和CCP进行分解是将应用程序分解为服务的好方法

单一职责原则 SRP,single responsibility principle

SRP是所有原则中最简单的之一,也是最难正确运用的之一,也是我们日常中最常用的一个

不管是编码,重构,甚至当下流行的微服务中

在很多团队的规范中,都会听到一条编码规范:一个方法不要超过x行代码

作为一群自命不凡的程序员,为什么在规范中却有如此一条格调不对称规范

主要问题就在于思维对SRP的缺失


微服务这个术语的一个问题是会将你的关注点错误地聚集在“微”上。它暗示服务应该非常小。很多团队在实施时,也是往小了去考虑,偏移了核心目标

微服务的目标是将精心设计的服务定义为能够由小团队开发的服务,并且交付时间最短,与其它团队协作最小。

理论上,团队可能只负责单一服务,因此服务绝对不是微小的

单一

从个人理解可以分为狭义与广义

狭义:

狭义只是从面向底层实现细节的设计原则

一个类而言,应该仅有一个引起它变化的原因

在日常中,在编写方法或者重构方法,也是以这个为原则,即确保一个函数只完成一个功能。

在将大方法重构成小方法时经常会用到这个原则

广义:

相对狭义,适用的范围相对大些,不再是一个类,一个方法,亦或是一个原因

任何一个软件模块都应该只对某一类行为者负责

“软件模块”不再只是一个类,可以是一组紧密相关的函数和数据结构,甚至一个独立应用服务

职责

什么是职责?如果一个类承担多于一个职责,那么引起它变化的原因就会有多个

在SRP中,职责定义为“变化的原因”,如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责

因此对于职责的定义需要结合具体业务,有时从感性上理解一个类的多个方法应该拆分,但如果应用程序的变化方式总是导致这几个职责同时变化,那么就不需要分离

这是近期在公司做的一次分享,这几年的互联网开发,算比较幸运,团队一直践行完善这套规范,没有太多的阻碍,得益于公司整体氛围,以及团队对规范和写文档的不排斥,形成了良好的开发习惯

在这次分享后,发现好些大V也在谈规范,写文档,估计是前段时间阿里又发布了开发手册(华山版),借鉴于一下,对一些细节做些补充,整理出来

整体流程

这个流程整体分为三个大阶段:需求阶段,开发阶段,上线阶段

需求阶段

需求分析

这个阶段主要是产品主导,收集痛点,归集需求,制定目标,与架构师讨论架构方案,与安全评估业务安全性

这儿可根据需求大小,具体行事,比如有些需求涉及方比较多,可能需要多次分组开会,不管是可行性分析,还是讨论方案,不是一次就能完成的

需求评审

当产品已经确定好这些方面,开始输出PRD,与研发、测试一起评审需求

研发与测试需要理解需求,更要挑战需求;

挑战需求的目的不是砍需求,而是确认产品在需求分析阶段的工作完备程度,比如遗留数据处理,对当前系统的影响层面,是否与当前系统重复度过高,业务价值

只有经过充分讨论,才能理解需求,完善需求,防止后期需求返工,某个细节没有考虑,影响整体设计实现

这儿各个团队实践方式各不相同,比如有些是所有团队成员都要参与,而有些只有具体参与开发测试参与

个人推崇所有成员都参与,这样一是讨论可以更充分,二是信息共享,防止因某成员个人原因,推迟需求进度

需求排期

对需求大家都达成了共识,此时就需要产品去需求确定优先级,排期开发

确定开发周期,这儿也有很多具体做法,有些团队是TL根据需求难易程序,变动大小,结合具体开发人员直接给出时间;有些团队是具体开发自行评估时间

一般都是由开发人员自行评估,只要在合理范围内,都给予认同

当然在确定开发周期时,必须给出依据,依据来源于详细设计,对于详细设计文档具体形式后面再谈,至少开发人员知道需要增加多少接口,修改多少接口,大概逻辑;不然评估时长就是一个空谈,造成整个项目的失控

开发阶段

需求阶段,从产品需求分析到开发人员评估出开发周期就已经结束了;下一个阶段进入开发阶段

开发阶段的进度,可以说八成是依赖第一阶段的成果。编码速度,实现手段只要是正常业务需求,一般都不会拖延时长

第一阶段成果,对于开发人员来讲,就是详细设计文档,文档中有了相应流程图,伪代码,具体涉及接口也有了,此时就是一个代码翻译过程

此阶段测试,需要输出测试用例,进行冒烟,回归测试;

自动化脚本完善

上线阶段

测试完成之后,就准备上线了。

根据确定的上线日期,提前核对checklist

对一些需要提前的变更开始申请审批流程

配合监控系统,需要对一些业务监控项进行配置

产品开始对预发布环境进行验收,验收成功后;发布正式环境

上线收尾

收尾工作,这个阶段,还有大量工作需要去做

  1. 产品对需求进行总结,收集数据,分析效果,为下一期需求做准备
  2. 开发需要对代码进行整理,比如有些是为了灰度而生的无用代码可以删除

一个完整的需求开发流程到此结束,当然这只是理想状态,还有很多不可预测问题,当然你也会吐槽,这是典型的瀑布开发模型,在敏捷大行其道时,是不是太守旧,太迟钝,都2091年了,为什么还在玩这一套

理想是丰满的,现实是骨感的。好比敏捷开发的参与者是一群开发经验丰富和才华横溢的开发人员,而这样的团队有多少?强硬为了敏捷而敏捷会不会造成项目的不可控呢?

当然瀑布模型也有天生的缺点:每个阶段的严格性,缺乏灵活性,而现实需求却是经常变化的

所以单纯地选择哪个模型是不可取的,只能根据实际情况出发,为业务提供最大化服务


细则规范

很多人都在要规范,但好像从没思考过为什么需要规范?

《阿里巴巴java开发手册》:手册的愿景是码出高效,码出质量

现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶?对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。 代码的字里行间流淌的是软件系统的血液,质量的提
升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量

从上段可以看出几个目的

  1. 高效协同,降低沟通成本;书同文,车同轨
  2. 码出质量,降低故障率;
  3. 工匠精神,追求卓越

评审会议

很多开发人员最怕开会,更要命的是很多会议是效率低下的。主要表现:

  • 在没有基本的认知共识就被拉去开会:这可能是主持者没有提前知会,同步资料;以及没有在线下达到一定共识就开会,结果会上各种讨论;也可能是参会人员本身也没有提前准备
  • 会后没有结论,或者结论不明确

所以在参与评审后,需要有一份输出,文档或者邮件,主要包括以下内容

  1. 评审日期与轮次
  2. 业务需求的目的及价值描述
  3. 参与人员及角色
  4. 评审附件(PRD或邮件)
  5. 评审结论
  6. 评审遗留问题及跟进人

需求PRD

开发人员,最烦就是口头需求,一句话需求,需要明文禁止

产品写PRD,其实是个基本职业素养,结果现在还要明文规定,也算是个悲哀。

为什么要出PRD,其实不是开发人员故意为难产品,而是让产品深刻理解需求,看这又是个怪事,产品还有不理解业务需求的,但就是常有产品自己都不理解业务,还一本正经给研发提开发需求。

写PRD的过程,就是梳理思考的过程,让需求更明确,流程更完整,细节更透彻,这样就不会出现提交给开发时,被开发一堆问题阻塞住。也可以防止在开发过程中,却发现整个业务没有形成闭环,造成返工,延时

那么开发如何拒绝口头需求,一句话需求呢?

有时产品比较强势,开发人员不好沟通,此时TL就应该对团队明确规定,禁止产品此类行为,也要禁止开发接受此类需求,不能为了一时快,而整个工期慢

在接受到此类需求时,也需要一定的沟通技巧:

  1. 多问几个为什么:这比如你这个需求背后的目的和价值是什么?做了之后有什么预期的收益,为什么这么做就可以达到这个收益,你可以不直接问业务方,但是你也需要问自己,业务方的这个目标和做这个需求的路径是否可以匹配得上,如果实现路径存在逻辑漏洞或者不是最佳的则这个需求也就没有做的必要性

  2. 给出替代方案:经过上面的步骤,其实你会发现你已经过滤了一批无效的一句话需求,而有些需求可能是有一定的存在价值,但是可能业务方提到的点并不是有效的方案或者说成本太大的方案,这时你就需要思考替代方案,尽量通过现有方案或者小成本的方式来满足业务方,间接的达到“拒绝”的效果

  3. 不能直接说不,但可以有条件的说是:当你确定这个需求是ok的,但你确实暂时抽不出时间来搞定这个事情的时候,这时关键在于我们不能直接拒绝业务方,长此以往会影响到后续的合作关系,这种情况你可以说,这个需求我接受,但是我可能需要较长一些的缓冲时间或者砍一些需求(部分满足),又或者必须要按时上的话,不能保证项目的上线后的效果、质量等,让业务方来做部分的取舍

详细设计文档

首先禁止没有文档,直接修改代码;这跟需求PRD类似,强迫开发人员思考需求,完善需求,胸中有丘壑,下笔自有神;不能在编码过程,边写边思考,不仅慢,还会漏洞百出

其实让团队写文档,也是个难事,推动很难,尤其管理者没有引起重视,就更难。这是个怪事,不喜欢写文档,却喜欢被返工,在开发过程因需求逻辑不完备而加班赶点,从上到下默认了这种怪事的正常化

为什么要写设计文档?

  • 对自己,让自己在动手写代码之前,帮助自己想得更清楚;
  • 对项目,保证信息的一致性,保证项目的可控性,减少项目风险;
  • 对团队,确保知识的沉淀与传承;

项目涉及多少个子系统,每个子系统涉及多少个模块,模块间的依赖关系如何,彼此要提供多少个接口,每个接口的参数如何,接口实现过程中上下游交互如何,核心逻辑用什么技术方案实现…

难道相关技术人都一清二楚么?很多自信的技术大神,“以为”懂了,但却讲不明白,其实就是不懂。很多“讲得明白”,却“写不清楚”,其实就是没懂透。把一个项目,一个技术问题,按照逻辑,用文字来一遍,才表示真正的想清楚了

这也是现在流行的,一解释都懂,一问都不知,一讨论就吵架


不再写过多为什么要写文档了,一个习惯的改变不可能一下子就改变,这需要一个过程,也需要自我大胆尝试

这儿给出一些实践,详细设计文档写些什么

其实一份详细设计文档,整体分为两个部分:功能需求与非功能需求

1、需求信息

这儿包括需求背景,业务价值,预计上线时间,架构设计wiki,产品及开发负责人,涉及到的服务,上下游服务

2、系统流程图

阐述整体设计思路,涉及算法时,还需要详细算法思路

包含上下游系统交互和数据流向,建议viso或者astash图,要保存原图文件以防后期维护修改

当然最好还要把设计思路背景说明一下,有多种方案时也罗列一下,因为系统现有状况,进度安排,历史数据等等原因,而选择了当前方案,这样自我分析完整,也免将来别人接手时可以追溯

3、接口列表

这儿列出所有涉及到的接口列表

标明新增加或者修改,以及接口详细入参,返回值

一般会有api doc,或者类似swagger工具,接口变化时,也可以相应变化;如果没有,那只能在文档中详细输出

4、定时任务

有些任务不需要,有些任务可能有很多,需要指出任务功能,频率

5、存储变更

比如缓存,数据结构,过期时间,预计数据增长

DB表设计,表修改,索引信息,数据增长量;有新的业务场景,一定要请DBA帮忙评估索引或者其他信息

6、配置组件

配置:比如配置中心,增加修改配置项,常为了灰度增加一些开关之类

组件:第三方依赖jar,不管是公司自研,还是外部开源;关注新特性给系统带来的变化;这个对开发工作量很小,只需要修改版本号,但测试可能需要一些回归量,尤其常出现的包冲突,造成日志不能正常输出

7、非功能需求

接口在日常和大促时的调用量评估,是否有降级方案,灰度方案,能不能重试,需不需要压测,这些都是围绕服务治理做预案

这其实是个很大的模块,很多已经深入骨髓,变成常态,比如灰度,现在都是7*24小时在线服务的,前后版本的兼容必须考虑到

总结

当然这些并不是必须的,可以根据实际情况变通,有增有减;当然你也可能从不写文档,很多人喜欢看源码,而不看文档;其实这有些本末倒置,源码只是告诉你了how,而文档才解释了what,why

架构师是很多码农的追求,架构师如何设计系统,如何让开发人员去实施呢?当然是文档,总不能直接给代码吧