|
你写的代码,是别人的噩梦吗?
# l2 q# j& E+ ]' r, I
5 b" R- h% t E5 d阿里妹导读:Frank,是来自阿里国际技术事业部的高级技术专家,从业十年,也是一位英语说到飞起的型男。今天他将与大家聊聊关于企业应用架构实践的话题。 $ R: K7 r8 i% \% e
![]()
# A% K0 z8 a& x+ V9 i+ l$ o7 s" D阿里高级技术专家 Frank
; h1 F! e' w* r1 U
% z \$ H" Y, ?3 u, f- n: O从业这么多年,接触过银行的应用,Apple的应用,eBay的应用和现在阿里的应用,虽然分属于不同的公司,使用了不同的架构,但有一个共同点就是都很复杂。导致复杂性的原因有很多,如果从架构的层面看,主要有两点,一个是架构设计过于复杂,层次太多能把人绕晕。另一个是根本就没架构,ServiceImpl作为上帝类包揽一切,一杆捅到DAO(就简单场景而言,这种Transaction Script也还凑合,至少实现上手都快),这种人为的复杂性导致系统越来越臃肿,越来越难维护,酱缸的老代码发出一阵阵恶臭,新来的同学,往往要捂着鼻子抠几天甚至几个月,才能理清系统和业务脉络,然后又一头扎进各种bug fix,业务修补的恶性循环中,暗无天日!* ^7 I9 L# _9 O! k6 o7 ?
/ @8 n! [: B! U4 K0 h: ^ d* @5 ]
8 c6 n* q6 F$ \& d7 q; U
8 p6 }$ a; v5 J7 Z+ }. f# s/ ~CRM作为阿里最老的应用系统,自然也逃不过这样的宿命。不甘如此的我们开始反思到底是什么造成了系统复杂性?我们到底能不能通过架构来治理这种复杂性?基于这个出发点,我们团队开始了一段非常有意义的架构重构之旅(Redefine theArch),期间我们参考了SalesForce,TMF2.0,汇金和盒马的架构,从他们那里汲取了很多有价值的输入,再结合我们自己的思考最终形成了我们自己现在的基于扩展点+元数据+CQRS+DDD的应用架构。该架构的特点是可扩展性好,很好的贯彻了OO思想,有一套完整的规范标准,并采用了CQRS和领域建模技术,在很大程度上可以降低应用的复杂度。本文主要阐述了我们的思考过程和架构实现,希望能对在路上的你有所帮助。
3 Z- M% W* b0 r9 g v$ b. X g
复杂性来自哪里? 4 @' H+ v3 l- y/ K7 o* k1 _! h
经过我们分析、讨论,发现造成现在系统异常复杂的罪魁祸首主要来自以下四个方面:
7 T/ P% _/ w4 L( Y; J
可扩展性差
8 h, i% N2 I- M5 l; ^: R% X. _
对于只有一个业务的简单场景,并不需要扩展,问题也不突出,这也是为什么这个点经常被忽略的原因,因为我们大部分的系统都是从单一业务开始的。但是随着支持的业务越来越多,代码里面开始出现大量的if-else逻辑,这个时候代码开始有坏味道,没闻到的同学就这么继续往上堆,闻到的同学会重构一下,但因为系统没有统一的可扩展架构,重构的技法也各不相同,这种代码的不一致性也是一种理解上的复杂度。久而久之,系统就变得复杂难维护。 6 D$ [. G8 j: }! ]* W4 a) R
像我们CRM应用,有N个业务方,每个业务方又有N个租户,如果都要用if-else判断业务差异,那简直就是惨绝人寰。其实这种扩展点(ExtensionPoint),或者叫插件(Plug-in)的设计在架构设计中是非常普遍的。比较成功的案例有eclipse的plug-in机制,集团的TMF2.0架构。还有一个扩展性需求就是字段扩展,这一点对SaaS应用尤为重要,因为有很多客户定制化需求,但是我们很多系统也没有统一的字段扩展方案。
/ ^5 R7 s$ w* c$ n( y
面向过程
# X9 S; @9 U5 T$ O* h
是的,不管你承认与否,很多时候,我们都是操着面向对象的语言干着面向过程的勾当。面向对象不仅是一个语言,更是一种思维方式。在我们追逐云计算、深度学习、区块链这些技术热点的时候,静下心来问问自己我们是不是真的掌握了OOD;在我们强调工程师要具备业务Sense,产品Sense,数据Sense,算法Sense,XXSense的时候,是不是忽略了对工程能力的要求。
5 r. t3 r( R7 V' I% {" D
据我观察大部分工程师(包括我自己)的OO能力还远没有达到精通的程度,这种OO思想的缺乏主要体现在两个方面,一个是很多同学不了解SOLID原则,不懂设计模式,不会画UML图,或者只是知道,但从来不会运用到实践中;另一个是不会进行领域建模,关于领域建模争论已经很多了,我的观点是DDD很好,但不是银弹,用和不用取决于场景。但不管怎样,请你抛开偏见,好好的研读一下EricEvans的《领域驱动设计》,如果有认知升级的感悟,恭喜你,你进阶了。 8 r9 R* i$ ]! Y8 w2 i; Z, W8 G
我个人认为DDD最大的好处是将业务语义显现化,把原先晦涩难懂的业务算法逻辑,通过领域对象(Domain Object),统一语言(Ubiquitous Language)将领域概念清晰的显性化表达出来。相信我,这种表达带来的代码可读性的提升,会让接手你代码的人对你心怀感恩的。借用Abelson的一句话是
( Y4 I$ W" n4 X
Programs must be written for people to read, and only incidentally for machines to execute.
* ?* d' e2 o/ l' m" j7 k7 o
所以强烈谴责那些不顾他人感受的编码行为。 - Y% C2 P) }( u4 Q4 ~* h
7 k& D8 X6 `7 F- d6 o; t( g
分层不合理
! m3 z- X7 p; t6 w
俗话说的好,All problemsin computer science can be solved by another level of indirection(计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决),怎样?是不是感受到间接层的强大了。分层最大的好处就是分离关注点,让每一层只解决该层关注的问题,从而将复杂的问题简化,起到分而治之的作用。我们平时看到的MVC,pipeline,以及各种valve的模式,都是这个道理。
, z% K1 H7 |3 q
好吧,那是不是层次越多越好,越灵活呢。当然不是,就像我开篇说的,过多的层次不仅不能带来好处,反而会增加系统的复杂性和降低系统性能。就拿ISO的网络七层协议来说,你这个七层分的很清楚,很好,但也很繁琐,四层就够了嘛。再比如我前面提到的过度设计的例子,如果没记错的话应该是Apple的Directory Service应用,整个系统有7层之多,把什么validator,assembler都当成一个层次来对待,能不复杂么。所以分层太多和没有分层都会导致系统复杂度的上升,因此我们的原则是不可以没有分层,但是只分有必要的层。
- j( _5 s5 [- S% L' @& H4 p% M
随心所欲
( a) [2 ^5 k n2 {
随心所欲是因为缺少规范和约束。这个规范非常非常非常的重要(重要事情说三遍),但也是最容易被无视的点,其结果就是架构的consistency被严重破坏,代码的可维护性将急剧下降,国将不国,架构将形同虚设。有同学会说不就是个naming的问题么,不就是个分包的问题么,不就是2个module还是3个module的问题么,只要功能能跑起来,这些问题都是小问题。是的,对于这些同学,我再丢给你一句名言“Just because you can, doesn't mean you should"。就拿package来说,它不仅仅是一个放一堆类的地方,更是一种表达机制,当你将一些类放到Package中时,相当于告诉下一位看到你设计的开发人员要把这些类放在一起考虑。
% y6 L! ]6 A0 g* }# }5 e$ {
理想很丰满,现实很骨感,规范的执行是个大问题,最好能在架构层面进行约束,例如在我们架构中,扩展点必须以ExtPt结尾,扩展实现必须以Ext结尾,你不这么写就会给你抛异常。但是架构的约束毕竟有限,更多的还是要靠Code Review,暂时没想到什么更好的办法。这种对架构约束的近似严苛follow,确保了系统的consistency,最终形成了一个规整的收纳箱(如下图所示),就像我和团队说的,我们在评估代码改动点时,应该可以像Hash查找一样,直接定位到对应的module,对应的package里面对应的class。而不是到“一锅粥”里去慢慢抠。 + B& z" y: H7 V' h
4 M/ [5 d" t) p+ x' h, F
本章节最后,上一张我们老系统中比较典型的代码,也许你可以从中看到你自己应用的影子。
+ ?+ N1 i: ]1 S0 X, t7 f8 q _
s9 U# o8 ?9 i" E! Z' ]5 {2 ^复杂性应对之道 5 v6 O' h; k% o; z
知道了问题所在,接下来看下我们是如何一个个解决这些问题的。回头站在山顶再看这些解决方案时,每个都不足为奇,但当你还“身在此山中”的时候,这个拨开层层迷雾,看到山的全貌的过程,并不是想象的那么容易。庆幸的是我团队在艰难跋涉之后,终有所收获。
1 Q+ f7 k* D* |0 C& z: |* y
1、扩展点设计 ( K# I, [" W4 f0 C9 d6 m% x1 j
扩展点的设计思想主要得益于TMF2.0的启发,其实这种设计思想也一直在用,但都是在局部的代码重构和优化,比如基于Strategy Pattern的扩展,但是一直没有找到一个很好的固化到框架中的方法。直到毗卢到团队分享,给了我们两个关键的提示,一个是业务身份识别,用他的话说,如果当时TMF1.0如果有身份识别的话,就没有TMF2.0什么事了;另一个是抽象的扩展点机制。
\8 s( S* X% i( C) b
身份识别 * K) j6 X& f- i
业务身份识别在我们的应用中非常重要,因为我们的CRM系统要服务不同的业务方,而且每个业务方又有多个租户。比如中供销售,中供拍档,中供商家都是不同的业务方,而拍档下的每个公司,中供商家下的每个供应商又是不同的租户。所以传统的基于多租户(TenantId)的业务身份识别还不能满足我们的要求,于是在此基础上我们又引入了业务码(BizCode)来标识业务。
. s. k+ G) t6 H, y- H" Y; ~
所以我们的业务身份实际上是(BizCode,TenantId)二元组。在每一个业务身份下面,又可以有多个扩展点(ExtensionPoint),所以一个扩展点实现(Extension)实际上是一个三维空间中的向量。借鉴Maven Coordinate的概念我给它起了个名字叫扩展坐标(Extension Coordinate),这个坐标可以用(ExtensionPoint,BizCode,TenantId)来唯一标识。 ! ~+ J. t: O: j4 p2 B- G
|. x+ y3 \' m/ [3 y# ~# m
扩展点 - s7 B7 D' u+ m8 v! ~
扩展点的设计是这样的,所有的扩展点(ExtensionPoint)必须通过接口申明,扩展实现(Extension)是通过Annotation的方式标注的,Extension里面使用BizCode和TenantId两个属性用来标识身份,框架的Bootstrap类会在Spring启动的时候做类扫描,进行Extension注册,在Runtime的时候,通过TenantContext来选择要使用的Extension。TenantContext是通过Interceptor在调用业务逻辑之前进行初始化的。整个过程如下图所示: $ s q/ k1 ]) M7 y
( k6 g8 K7 {* }8 ^* f
3 q6 T' I& `; q- S& e: p; m2、面向对象
; @7 `: A8 w5 |: V' S0 u
领域建模 % H# b! r# u# y
准确的说DDD不是一个架构,而是思想和方法论。所以在架构层面我们并没有强制约束要使用DDD,但对于像我们这样的复杂业务场景,我们强烈建议使用DDD代替事务脚本(TS: Transaction Script)。因为TS的贫血模式,里面只有数据结构,完全没有对象(数据+行为)的概念,这也是为什么我们叫它是面向过程的原因。然而DDD是面向对象的,是一种知识丰富的设计(Knowledge Rich Design),怎么理解?,就是通过领域对象(Domain Object),领域语言(Ubiquitous Language)将核心的领域概念通过代码的形式表达出来,从而增加代码的可理解性。这里的领域核心不仅仅是业务里的“名词”,所有的业务活动和规则如同实体一样,都需要明确的表达出来。 * R+ N5 ]+ s& _3 C" o+ t4 v
例如前面典型代码图中所展示的,分配策略(DistributionPolicy)你把它隐藏在一堆业务逻辑中,没有人知道它是干什么的,也不会把它当成一个重要的领域概念去重视。但是你把它抽出来,凸显出来,给它一个合理的命名叫DistributionPolicy,后面的人一看就明白了,哦,这是一个分配策略,这样理解和使用起来就容易的多了,添加新的策略也更方便,不需要改原来的代码了。 0 t N* l% X4 C) i8 O
所以说好的代码不仅要让程序员能读懂,还要能让领域专家也能读懂。再比如在CRM领域中,公海和私海是非常重要领域概念,是用来做领地(Territory)划分的,每个销售人员只能销售私海(自己领地)内的客户,不能越界。但是在我们的代码中却没有这两个实体(Entity),也没有相应的语言和其对应,这就导致了领域专家描述的,和我们日常沟通的,以及我们模型和代码呈现的都是相互割裂的,没有关联性。这就给后面系统维护的同学造成了极大的困扰,因为所有关于公海私海的操作,都是散落着各处的repeat itself的逻辑代码,导致看不懂也没办法维护。
2 _5 m+ ?; }5 D& X
所以当尚学把这两个领域概念抽象成实体之后,整个模型和代码都一下子变清晰很多。在加上上面介绍的把业务规则显现化,极大的提升了代码的可读性和可扩展性。用尚学的话说,用DDD写代码,他找到了创作的感觉,而不仅仅是码农式Coding。下图是销售域的简要领域模型,但基本上能表达出销售域的核心领域概念。 . j7 `0 M6 a% ^5 ]4 Q
& C# B' D, E& v% x K
2 y9 [$ |6 j3 H5 Z: }7 Y8 r' C5 Q+ {关于CQRS简要说一下,我们只使用了Command,Query分离的概念,并没有使用Event Sourcing,原因很简单---不需要。关于Command的实现我们使用了命令模式,因此以前的ServiceImpl的职责就只是一个Facade,所有的处理逻辑都在CommandExecutor里面。 * M- M. W4 n( _& N D6 L
SOLID
6 W h) n. H0 W8 b+ o8 A" B) e/ v/ b
SOLID是单一职责原则(SRP),开闭原则(OCP),里氏替换原则(LSP),接口隔离原则(ISP)和依赖倒置原则(DIP)的缩写,原则是要比模式(Design Pattern)更基础更重要的指导准则,是面向对象设计的Bible。深入理解后,会极大的提升我们的OOD能力和代码质量。比如我在开篇提到的ServiceImpl上帝类的例子,很明显就是违背了单一职责,你一个类把所有事情都做了,把不是你的功能也往自己身上揽,所以你的内聚性就会很差,内聚性差将导致代码很难被复用,不能复用,只能复制(Repeat Yourself),其结果就是一团乱麻。 0 j% j2 Z- @3 l5 B7 p. t
! M: p5 {4 @2 `# d- `
再比如在java应用中使用logger框架有很多选择,什么log4j,logback,common logging等,每个logger的API和用法都稍有不同,有的需要用isLoggable()来进行预判断以便提高性能,有的则不需要。对于要切换不同的logger框架的情形,就更是头疼了,有可能要改动很多地方。产生这些不便的原因是我们直接依赖了logger框架,应用和框架的耦合性很高。
1 j% m0 F: s D! m
怎么破? 遵循下依赖倒置原则就能很容易解决,依赖倒置就是你不要直接依赖我,你和我都同时依赖一个接口(所以有时候也叫面向接口的编程),这样我们之间就解耦了,依赖和被依赖方都可以自由改动了。
6 p. A8 p* Y8 x' J4 k
" d& r; b/ n$ Y/ y6 U) r2 R7 {+ t& g9 z) {3 r
在我们的框架设计中,这种对SOLID的遵循也是随处可见,Service Facade设计思想来自于单一职责SRP;扩展点设计符合关闭原则OCP;日志设计,以及Repository和Tunnel的交互就用到了依赖倒置DIP原则,这样的点还有很多,就不一一枚举了。当然了,SOLID不是OO的全部。抽象能力,设计模式,架构模式,UML,以及阅读优秀框架源码(我们的Command设计就是参考了Activiti的Command)也都很重要。只是SOLID更基础,更重要,所以我在这里重点拿出来讲一下,希望能得到大家的重视。 8 D3 z5 r# ^8 M4 x" h
3、分层设计
9 R0 @5 \% D1 ^
这一块的设计比较直观,整个应用层划分为三个大的层次,分别是App层,Domain层和Repostiory层。 : u, N' r8 q9 P
1.App层主要负责获取输入,组装context,做输入校验,发送消息给领域层做业务处理,监听确认消息,如果需要的话使用MetaQ进行消息通知; ( H8 y3 p- X! A3 ~
2.Domain层主要是通过领域服务(Domain Service),领域对象(Domain Object)的交互,对上层提供业务逻辑的处理,然后调用下层Repository做持久化处理; & H( T; k m3 T
3.Repository层主要负责数据的CRUD操作,这里我们借用了盒马的数据通道(Tunnel)的概念,通过Tunnel的抽象概念来屏蔽具体的数据来源,来源可以是MySQL,NoSql,Search,甚至是HSF等。
, O% d4 X8 G7 ]4 D! V. O h! t0 i7 j' @
这里需要注意的是从其他系统获取的数据是有界上下文(Bounded Context)下的数据,为了弥合Bounded Context下的语义Gap,通常有两种方式,一个是用大领域(Big Domain)把两边的差异都合起来,另一个是增加防腐层(Anticorruption Layer)做转换。什么是Bounded Context? 简单阐述一下,就是我们的领域概念是有作用范围的(Context)的,例如摇头这个动作,在中国的Context下表示NO,但是在印度的Context下却是YES。
0 P( t3 X H; D0 ^+ ] U/ \9 H {( e( Y8 z
4、规范设计
. f+ ^; _( G: {; x) I8 }8 ^
我们规范设计主要是要满足收纳原则的两个约束:放对位置东西不要乱放,我们的每一个组件(Module),每一个包(Package)都有明确的职责定义和范围,不可以放错,例如extension包就只是用来放扩展实现的,不允许放其他东西,而Interceptor包就只是放拦截器的,validator包就只是放校验器的。我们的主要构件如下图所示: ' I1 U* N) S8 {9 @% p
. w- @6 p0 D1 j% }& {1 h. E" Q
贴好标签
+ n% H# B8 Z; `8 j+ y# \
东西放在合适位置后还要贴上合适的标签,也就是要按照规范合理命名,例如我们架构里面和数据有关的Object,主要有Client Object,Domain Object和Data Object,Client Object是放在二方库中和外部交互使用的DTO,其命名必须以CO结尾,相应的Data Object主要是持久层使用的,命名必须以DO结尾。这个类名应该是自明的(self-evident),也就是看到类名就知道里面是干了什么事,这也就反向要求我们的类也必须是单一职责的(Single Responsibility)的,如果你做的事情不单纯,自然也就很难自明了。如果我们Class Name是自明的,Package Name是自明的,Module Name也是自明的,那么我们整个应用系统就会很容易被理解,看起来就会很舒服,维护效率会提高很多。我们的命名规则如下图所示: ; L# `" m E5 ^7 I! b8 f' k
6 W0 ^$ J8 v9 K+ k
% U. V5 D6 D. X' \/ }1 m, iGTS应用架构
+ A! j. Z) |2 C4 s# b
经过上面的长篇大论,我希望我把我们的架构理念阐述清楚了,最后再从整体上看下我们的架构吧。如果觉得不错,也可以把framework code拉下来自己玩一下。 * m$ E) h, Y& n% `
整体架构 ' R) q2 m; z0 X8 g& O# {. k
我们的架构原则很简单,即在高内聚,低耦合,可扩展,易理解大的指导思想下,尽可能的贯彻OO的设计思想和原则。我们最终形成的架构是集成了扩展点+元数据+CQRS+DDD的思想,关于元数据前面没怎么提到,这里稍微说一下,对于字段扩展,简单一点的解决方案就是预留扩展字段,复杂一点的就是使用元数据引擎。
8 \3 F, a3 h4 j8 k9 c
使用元数据的好处是不仅能支持字段扩展,还提供了丰富的字段描述,等于是为以后的SaaS化配置提供了可能性,所以我们选择了使用元数据引擎。和DDD一样,元数据也是可选的,如果对没有字段扩展的需求,就不要用。最后的整体架构图如下:
' ~3 l1 ~8 Q) C1 n: |1 Q# V( f9 A2 n- K; k! S f5 ?( v7 F
! x5 K* W; h$ N0 _9 O; p9 i5 m' _8 k. X+ p% O
8 u( ], G/ W% b: n) P0 M$ `4 ]% z& ~/ v9 Q
* a9 `, \- a' m" A, o, V, f4 h3 {4 y. }+ A3 G# P
|