对模块化设计的一些看法

去年我在团队内部做过一次关于模块化设计、微服务与容器技术的技术分享,PPT 上传至了 SlideShare 链接在此(需要科学上网)。后两者是目前大热的话题,但其实对于服务器端应用,良好的模块设计是应用是否能够借住容器技术的助力而走向微服务结构的基础。所以,在这篇文章里,我就想谈谈我对模块化设计的看法。因为我主要是做服务器端开发,语言主要是 Java,所以一些技术细节难免会有一定的局限性,请大家见谅。

最近 Docker 等技术的出现,极大推动了云计算的发展。但这些技术解决方案不能提到架构设计。在架构设计中,如何将整个应用分成一些独立的模块,这些模块以何种关系相互依赖、相互调用,以及如何将好的设计通过工程实践贯彻于实际的工作中,这些问题值得每个架构师和有志的工程师去深入思考。下面我就说一下我对模块化设计实践的几点看法:

  • 模块之间单向依赖
  • 分散放置模块的门面接口
  • 系统中的模块应呈现层级化
  • 从业务角度考虑模块的内聚性

1. 模块之间单向依赖

依赖指什么

软件模块之间的依赖源自代码之间的依赖。代码之间的依赖关系有两种,一种是引用关系,一种是调用关系,调用既可以是本地调用,也可以是远程消息。故模块之间的依赖关系也有两种,一种是引用关系,一种是调用关系。

这两种依赖的方向有时是一致的,有时却相反。前者容易理解,后者一个常见的例子就是回调。回调最常见的例子是观察者模式。对于观察者模式我就不做介绍了。如果要将观察者模式中的几个接口和类划分一下模块的话,SubjectObserver 接口通常放在一个模块中,我们称其为 Subject 模块。Observer 接口的实现通常放在另一个或几个模块中,我们可称其 Observer 模块。显然,从引用关系上看,Observer 模块依赖 Subject 模块,但这两个模块却存在双向的调用关系。所以引用和调用这两个关系有时是相反的。

在“模块之间单向依赖”这个观点中,依赖指的是引用依赖。

为什么模块之间要单向依赖

其实,在使用 Maven 之类的工具构建的 Java 项目中,模块之间是不会存在双向和循环依赖的(我想其它静态语言也是类似的。但我对于 Javascript 这样的语言不甚了解)。但前面提到的模块是狭义上的,比如一个 Jar 包。当我们进入一个 Java 项目,一个包,或者几个包,也可以被看做是一个模块,或者它们随着项目的演进,会发展为一个独立部署的模块。正因为如此,我们在一开始就应当关注包之间的依赖关系。在 Java 中,包之间的双向或循环依赖并不会导致编译失败。但如果我们不对包之间的双向和循环依赖加以控制,便会给项目的发展带来很多障碍。

如何确保模块(包括“包”)之间的单向依赖

对于 Java 应用,有一些工具可以被用来对代码做静态检查,其中一项检查便是包之间的依赖。最常用的工具是 Sonar。下图是 Spring Data 这个项目的包设计图

spring-data-sonar-package-analysis

图中的表格,左侧是包明,安照依赖层次从高到低排列,越靠下的包被依赖的越多。当有循环依赖产生时,Sonar 就会以红色显示,提醒代码已经出现了问题。想看到上图的内容可以访问这个链接

多说一点,Sonar 的功能不只提醒循环依赖的出现。当点击左侧的一个包名时,它所依赖的和依赖于它的包都会被高亮,依赖的次数也有显示。

2. 分散放置模块的门面接口

门面(Facade)模式是大家所熟知的。对于模块,尤其是业务模块,其也应当遵从这个模式(底层模块,考虑到其技术复杂性,往往很难到这一点。比如 JDBC)。

与分散放置相反的是集中放置,下面就来解释一下集中放置门面接口的设计是处于何种考量。

门面(Facade)接口集中放置的由来

可能有的设计者,希望通过分离接口和实现已达到良好的扩展性的目的。新的实现模块不用依赖另一个重量级的、包含了业务实现的模块,仅依赖于一个接口模块即可。这个想法固然好,但却忽视了系统中并不是处处都要求扩展的。这本质上其实是一种过度设计的体现。

由于每个业务模块都将其门面接口分离出来,如果将这些接口分别独立为一个模块,那系统总模块就显得太多。于是乎设计者又将这些门面接口集中放置在一个专门的模块中。这就是门面接口集中放置的由来。

但如果每个接口都单独地放置于一个模块中,那模块的数量将大幅增加。所以,为了简化设计,这些接口就被集中放置于一个或几个模块中。这就是门面接口集中放置设计的由来。

如下图所示,购物车(shopping-cart)、订单(order)、用户管理(user)几个业务模块的接口被放置在了一个统一的模块(service-interfaces)

facade-centralized-1

集中放置的隐患

似乎这种轻微的过度设计并没有什么严重的问题。而且,它还带来了一个额外的好处:模块之间的引用依赖关系极大简化了。

在业务中,购物车模块会调用订单模块和用户管理模块,订单模块会调用用户管理模块。如下图实线所示。如果门面接口不集中放置,模块之间就会形成因相互调用而形成的引用依赖。在本例中,因为相互调用而形成的依赖关系尚且很简单,但是在真实系统中,十几、几十个模块很常见,甚至更多,它们之间相互调用而形成的关系,其复杂度可想而知。

如果将门面接口集中放置,那每个业务模块只需引用接口模块即可,如下图的虚线所示。在需要调用另一个模块的功能时,只需要通过另一个模块放置在接口模块中的门面接口查找到服务即可。这种查找在单机应用中可能是在 Spring context 中查找,在分布式应用中可能是在服务注册中心中查找。不管是那种形式的查找,模块之间在编译过程的引用依赖都被大幅度简化。

facade-centralized-2

但其实,这种对模块依赖关系的简化增加了系统堕化的风险。将门面接口放回原本服务提供方模块中,调用方模块就能通过引用服务提供方模块来得到这个门面接口,进而用这个接口查找服务。虽然这么做使得模块之间的引用关系变得复杂,但这让我们可以通过分析模块之间引用关系(例如通过 mvn dependency:tree 或一些 GUI 工具)来得到近似于调用依赖的关系,从而帮助开发人员更好地了解其所开发的系统。

上面之所以说是这两个关系是近似的,是因为在调用关系中存在着向之前提到的回调关系。以及通过如消息中间件调用这样的间接调用关系。这些关系使得模块之间引用关系和调用关系不完全一致。但即便如此,引用关系对我们理解系统仍十分有帮助。而且需要知道的是,引用关系所不能反映的回调和间接调用关系通常是一种弱关系,即这种调用如果发生问题,通常只会影响依赖关系中上面的模块,而对下面的模块影响不大。通过引用关系来得到强调用关系,在通过文档描述回调和间接调用关系,就能得到一个系统比较完整的、真实的模块调用关系。

▲ 模块实际的依赖关系

▲ 门面接口集中放置之后模块的依赖关系

从上面两图我们看到,如果门面接口集中放置,那模块之间的依赖关系就完全无法反映实际的调用关系。

为什么不将模块结构文档化

文档在软件开发中非常重要,但是好的文档不如好的软件本身。如果模块之间的依赖关系就可以体现出实际的模块结构,那自然要好过用文档描述。因为文档是会过时的,而软件本身不会。这和好的代码胜过代码注释是一个道理。当然,文档也不是完全不需要的。好的实践应该是软件本身自描述,同时辅以文档。

小结

简单的设计不一定就是简洁清晰的好设计。在软件的模块化设计中,将接口集中放置表面上简化了模块之间的依赖关系,实则隐藏了模块之间复杂的调用关系,使本可以清晰的层级化的模块架构变得模糊起来。这不仅对软件的演化带来了困难,也为日常的维护带来了障碍。

3. 系统中的模块应呈现层级化

如果能很好地实践前两点,那通过静态分析模块之间的引用依赖,就可以进一步得到整个系统中模块的依赖关系图。这个依赖关系图应当是可以层级化的,没有依赖任何其它模块的模块应当是第一层,只依赖于第一层模块的模块是第二层……以此类推,我们就能得知每个模块应处于的层次。这个层次图可以帮助我们划分出正确的子系统,以及这些子系统之间的依赖关系,而不至于子系统的相互依赖违反模块之间的依赖关系。

4. 从业务角度考虑模块的内聚性

高内聚是软件开发成提到的一个词,但是在模块设计领域我们如何实现高内聚呢?实现高内聚,我们应该统一从何种角度考虑模块的内聚性。

一个真实的例子

在我的项目中,我们的服务器产品需要和另一个服务器产品(我们直接简称其为 Server)通信。通信的主要内容是消息的订阅和通知,这些消息分别与两类业务相关,另外我们的产品还会发心跳消息,检查对方服务器产品的状态。我们产品的模块设计是这样的。负责向 Server 发送心跳消息和订阅消息的是一个模块,我们称其为 Subscriber。负责两类业务的模块分别是 ServiceA 和 ServiceB。负责接收从 Server 发送过来的通知的是模块 Consumer。Subscriber 模块会发送和两个业务模块相关的订阅消息。同样,Consumer 模块也会接收与这两个业务模块相关的消息,然后再由 ServiceA 和 ServiceB 这两个业务模块处理消息(对如此设计的订阅通知功能的吐槽不在本文范畴,况且这样设计有一定合理性)。

这样设计模块是否合理呢?发送消息放在一个模块,接收消息放在另一个模块,而两种不同的业务功能也分别由两个不同的模块实现。似乎从内聚性的角度讲是完全说的过去的。但稍微仔细一想便会发现这种设计所存在的问题。如果这两个业务模块要独立部署,即产品架构从单体应用向微服务架构转变。这是我们便会发现,只将 ServiceA 和 ServiceB 这两个模块独立成服务是无法实现全部功能的,因为它们无法向 Server 订阅消息,也无法接收从 Server 发送过来的通知。在这种情况下,简单地改变部署方式已经无法实现产品从单体向微服务的转变。我们需要进一步地对 Subscriber 和 Consumer 这两个模块进行重构,将这两个模块中与 ServiceA 和 ServiceB 相关的功能分离出来,或者与 ServiceA 和 ServiceB 打包在一起,或者部署在一起,从而使 ServiceA 和 ServiceB 实现完整的功能。

从哪个角度考虑内聚性

从上面的例子我们可以看到,看似是内聚的模块设计却对产品架构的演进造成了一定的困难。虽然这种困难看上去不大,但是当如此“内聚”的模块设计变成普遍现象之后,系统的演进便变得困难起来。

所以,当我们谈到模块内聚性的时候,一定要从一个合适的角度来考虑,对内聚性不同角度的理解会产生不同结果。我们可以说,从业务角度考虑模块的内聚性,而不是从技术实现的角度考虑,通常会对我们的设计带来更合理的结果。还是用上面那个例子,Subsriber 和 Consumer 这两个模块中与 SerivceA 和 ServiceB 相关的功能的实现部分,应该与 ServiceA 和 ServiceB 这两个模块有更为紧密的关系。这种更紧密的关系可能表现为这些功能实现与 ServiceA 和 SerivceB 作为同一个构件(例如 Java 的 Jar 包或 War 包)组合在一起;或者可能编译打包到不同的构件,但是作为同一个进程或容器一起部署。

这种以业务为出发点对内聚性的考虑在多数情况下都是适用的。而对于实现底层基础架构的模块,技术实现其实就是它们所要实现的业务。比如还是上面的例子,如果 Subscriber 模块所实现的功能是一个通用的消息队列,那发送消息的逻辑就不应该放在 ServiceA 和 ServiceB 模块中实现,而是应该放在 Subscriber 模块中。

5. 总结

上面提到的这些是我对模块化设计的一些看法。就像一开始提到的那样,在如今这个微服务成为主流设计思想的年代,出色的模块化设计对应用的发展至关重要。模块化设计的好坏就如同优良的基因,只有当我们的应用优良出色的基因,这时在更出色的技术的帮助下,我们的应用才会进化的更加出色。

被广泛使用的开源软件总是有着不错的模块化设计,比如 Spring 平台的众多产品、Hadoop、Netty 等等,我就不一一列举。在我们研究开源软件的实现袭击的同时,我们也应该去学习它们的模块设计。

转载于:https://my.oschina.net/lifany/blog/527481

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值