第03课:开发架构松耦合

第03课:开发架构松耦合

概述

“开发架构”这个称谓对于有些人来说,可能使用“开发视图”更容易理解。总之,根据前文的讲解,应用架构包含了我们通常理解的架构视图的绝大部分,除了进程、部署等视图。

无论称谓是什么,这里专指的应用系统在开发环境中的静态组织结构,也是项目开发人员具体的工作环境。因此这部分的松耦合与项目开发人员密切相关。

谈到开发架构的松耦合,主要包含两方面的内容:

  • API 依赖的松耦合。
  • 项目模块(工程目录)的松耦合。

API 依赖的松耦合

实际上,在开发阶段,绝大多数人接触到的松耦合基本属于这一类。无论我们读过的代码设计相关的书,还是实际工作经验,又或是来自一些支持 AOP 的第三方框架的约束,这些都会促使我们按照一种良好的松耦合的方法来编写代码。如面向接口、继承、多态以及各种相关的设计模式等等。对于如何编写松耦合的代码,本文不再展开过多论述,相信阅读过诸如设计原则、设计模式之类书籍的人并不难做到。本文主要侧重于探讨针对我们编写的模块,如何处理模块之间松耦合的问题。

首先,我们开发的绝大多数应用是分层的,如常见的 Web 应用分为展现层、服务层、持久层。应用分层便会存在层与层之间依赖的问题。诸如 Spring 等框架,通过依赖注入,使得层与层之间的依赖实现了松耦合。

层与层之间的依赖注入,可以有两种形式。

  • 面向接口:也就是说层与层之间通过接口实现松耦合。上层模块根据配置在容器中查找接口的实现,下层模块需要实现接口并注册到容器中。这种方式,接口成了层与层之间的耦合点,接口的变化会同时影响上下层。
  • 面向代理:也就是说层与层之间不再有接口上的耦合。上层根据需要,定义一个接口代理,这个代理会自动查找下层模块的实现。下层模块不必实现相关接口(注意:这并不代表它不需要实现任何接口,而是不需要实现与上层耦合的接口),只需要在容器中注册即可。这种方式的好处是不存在接口变化的影响(尤其对于 Java 这种编译型语言)。但是它会产生更细粒度的依赖,如方法,因为至少需要在上层的代理中指定下层的组件名、方法、参数等信息。当然,如果下层模块方法实现的足够健壮(如充分考虑方法版本的兼容性),这种问题会减少很多。除此之外,面向代理的好处还有就是透明的切换下层组件的访问方式(本地、远程)。当然,这种方式与面向接口相比实现起来会复杂一些。

其次,即便位于同一层中的各个模块(如服务层),也存在相互依赖的问题,比如订单服务需要访问客户服务获取客户资料。这种情况的解决方式应该与层与层之间的依赖类似。

同一层各个模块之间的依赖(尤其是服务层)相对比较复杂的地方就是,对于传输对象的处理。举个例子:订单服务需要调用客户服务获取客户资料,积分服务也需要调用客户服务获取客户资料。那么对于客户服务返回的客户资料传输对象,便会形成一种模块间的耦合关系。

对于这个问题,有不同的解决方式,并没有绝对的对与错,随着项目的不断迭代以及新出现的依赖方面的挑战可以不断修改处理的方法。总体来讲,可以有三种。

  • 将每个模块发布服务的传输对象单独打包,依赖该服务的模块只需要依赖该传输对象的发布包即可。
  • 将项目中所有模块的传输对象合并打包,各模块都依赖这个传输对象包。这是第一种方案的“懒惰”版,毕竟如果模块数量非常大时,管理工作量会比较大。当然这种方式的缺陷也很明显,是与模块化方向背离的。
  • 每个模块使用自己的传输对象。这种方式只适用于那种弱依赖的远程调用(像本地调用、Spring Http Invoker 这种强依赖调用是不可行的)。也就是说,当模块调用外部服务时,按照自己使用的数据,定义传输对象。这种方式是耦合性最小的方式(部分讲解微服务的书也提到了这种处理方式),因为我们不需要关注服务发布方的全部数据,而是按需获取。当然这是一种很理想的服务调用方式,但是现实却是很多数据在多个模块之间是重复的。对于上面的例子,也许无论订单还是积分,都需要获取客户的名称、地址、联系方式等信息。结果就是,在这些模块的传输对象中,你都需要重复包含这些信息。

如果认为传输对象重复定义是不可忍受的,那么可以选择方案一,如果项目这种情况并不多,那么可以选择方案三,如果认为合并到一起不是什么大问题的话,方案二也可以使用,即便后期需要拆分,这也并不难做到。

模块的松耦合

首先承认,这个标题并不合适,因为模块的松耦合也包括“API 依赖的松耦合”,但是我始终没有想到一个合适的称谓来描述它。

通过讲解一个实际的工作场景,可能会更好的理解这个问题。设想我们的系统相对比较庞大,从前端到后端被拆分成了许多模块,如果在开发过程中我们想测试某个模块的功能(从前端到后端)。你喜欢仅仅启动测试模块及产生 API 依赖的模块,还是说你原意将整个系统启动起来?很明显,相信绝大多数人会选择前者。如果每次测试都要启动整个系统,那么开发测试的效率将会非常低,不仅仅是因为系统启动更加耗时,还因为其他模块可能存在缺陷(因为同时处于开发过程中,出现致命缺陷的概率非常大)导致系统无法正常运行。

再考虑一个工作场景,实际上无论是 B/S 还是 C/S 结构的系统,无论我们最终将应用系统部署到服务器还是将服务器作为一个组件嵌入到应用当中,本质上来说,它还是遵从了 Servlet 规范(当然,此处指绝大多数,而不是所有)。虽然 Servlet 规范提供了多种模块化机制,但是它的入口却只有一个,即 web.xml 描述文件。如何将 web.xml 中的配置,以注解或者 web-fragment.xml 的形式分解到各模块中,也是实现松耦合的关键。

我们也可以将上面的两个场景作为模块松耦合目标的一部分。而且这个层面的松耦合更有助于我们将系统向更细粒度的部署架构方向演进。可以说,这种方式已经距离微服务架构一步之遥,而且由清晰的模块化架构到微服务,这种循序渐进的架构重构更易成功实现微服务化治理。不仅如此,你还会发现,这种架构极易回退,如果你认为微服务并不适合你们。

我们至少有两种方案可以实现将模块独立运行。第一种是采用 Servlet 规范的模块化机制,第二种是采用诸如 Spring Boot 的方式。

我们先来看第一种。

Servlet 规范

Servlet 规范支持应用配置的模块化和可插拔,主要分为三种方式:

  • 注解
  • SCI
  • web-fragment.xml

这三种方式都可以用于实现模块之间配置的松耦合,尽管它们的实现方式有所区别。

对于注解的方式,我们需要在每个模块中定义自己的 Servlet、Filter 并添加相应的注解,用于分发处理当前模块的请求,以代替原有 web.xml 中的配置。理想情况下,web.xml 中不保存任何配置(由于应用服务器都会提供默认的 web.xml,因此项目中甚至可以不需要该文件)。

这样,每个模块都变为一个可部署的 Web 应用(暂时不考虑静态文件,接下来会单独讨论)。模块与模块之间,除了必要的 API 层面的依赖,不会存在任何配置依赖。

当然,实际情况可能要稍微复杂一些。例如设置请求/响应编码、安全认证,这些通用 Filter 我们更希望统一配置,而不是每个模块都要配置一次。此时,可以单独保留一个通用的“门户”模块,用于保存系统的这些基础配置。这个“门户”模块与其他模块并没有任何依赖关系,只是提供了请求映射层面的基础功能,因此它是可以轻易替换的。

如果你使用的是一个来自第三方框架的 Servlet 实现,此时使用注解并不是一个好的选择(除非你愿意实现它的一个子类或者装饰类,以便添加注解)。

此时,可以使用 @WebListener 注解,以编码的方式添加 Servlet,或者采用 SCI。

SCI(ServletContainerInitializer)基于 SPI 机制,以编码的方式添加 Servlet、Filter。与注解相比,它扩展性更好。

这两种方式都能在脱离 XML 的情况下,实现 Web 应用配置的模块化。如果你不希望 Servlet API 侵入每个模块,那么可以考虑 SCI 的方式。以通用模块的方式提供 SCI 实现,并自定义扩展机制,每个模块根据自定义的扩展机制声明自己的配置。

最后是 web-fragment.xml,作为 web.xml 的片段,它与 web.xml 格式完全相同,完全是对 web.xml 内容的拆解。尽管在规范中,它支持加载顺序,我们还是建议不要使用该特性,而是要合理的划分各模块的请求地址,因为这种加载顺序的定义,也会造成模块间的依赖。

这三种方案,你可以进行灵活选择,甚至混合使用(尽管我们建议只采用其中一种),这都不是关键问题。这一部分内容的一个关键点是你如何合理拆分各模块的请求,最好是通过开发规范的方式进行明确,以确保各模块之间独立开发而又不会重复。尤其是原有系统请求采用集中式处理的情况下(使用一个 Servlet 作为所有请求的入口)。

Spring Boot

对于基于 Spring Framework 的 Web 应用来说,Spring Boot 提供了一种很好的模块化方案,或者换句话说,它本身就是高度模块化的。

Spring Boot 通过自动构造嵌入式 Web 服务器和 Spring 容器,使得应用可以独立运行,而不需要部署到 Web 服务器中。也就是说,对于采用 Spring Boot 开发的程序,它的启动方式与你通过 main 方法启动任何 Java 程序是一样的。因此,我们可以为每个模块添加一个 main 方法,即可实现模块的独立配置。

如果所有模块的配置都是一样的,我们甚至可以只在一个通用组件里加一个 main 类,无论是独立启动一个模块,还是启动整个系统,我们都使用这个通用组件作为启动入口。

当然,你可能会疑惑,既然使用 Spring Boot,为什么不直接按照微服务这种架构设计系统,还要考虑统一启动的场景?我们说,对于微服务这类架构,更多是一种业务层面的驱动,从技术架构上我们则要考虑系统架构的适应性和灵活性。还有就是我们的系统除了服务还有 UI,如果它们也各自以独立的方式运行,这将会使系统复杂度大大增加。

Spring Boot 这种方式使得你可以像管理普通的 Java 程序一样管理 Web 应用。但是从本质上来说,它仍是通过前面所述的 Servlet 规范的机制实现的。只是 Spring Boot 屏蔽了 Web 容器的复杂性,使你可以像开发普通 Java 程序一样开发 Web 应用,并且在此基础上定义了自己的扩展机制以及默认配置,而使你不再需要再关注与 Web 容器相关的配置。

我们之所以将 Spring Boot 单独说明,是因为它与前面讲的基于 Servlet 规范的方案稍有不同,主要体现在:

  • 前者以部署包的形式部署到 Web 服务器中,而后者是一个独立启动的程序。
  • 前者存在对 Servlet 配置的分解,而后者是由 Spring Boot 自动装配(每个进程一个)。

除此之外,Spring Boot 还很好的解决了静态文件的问题,下面我们会进行说明。

资源文件处理

首先,我们此处所说的资源文件,是指除 Web 应用中除 Java 代码以外的所有文件。它通常包含 JSP、各种模板文件(如 FreeMarker 等)以及静态文件(如 HTML、JS、图片、CSS 等)。

如果你开发的是一个 SOA 应用或者 C/S 系统的服务端,应该不需要考虑资源文件在模块化过程中所造成的影响。但是,如果你开发的是一个 Web 应用,这却是需要加以考虑的一个问题。

既然我们的代码按照业务耦合程度拆分成不同的模块,那么我们自然希望与之相关的资源文件也应该被拆分到相应的模块,而不是被放置到一起。

当我们基于 Servlet 规范进行模块分解时,每个模块是一个可以独立发布到 Web 服务器的应用,因此它自然可以维护只与自己相关的资源文件。只不过这里需要考虑两个问题:

(1)共用的文件,如 CSS 以及第三方 JS 库如何处理。

(2)多个模块一起启动的问题。

对于问题(1),建议将共用文件放置在一个单独的服务器上,各模块直接引用远程链接,而不是放置在本地。系统最终构建时,将这些文件包含进来即可。

对于问题(2),在集成测试阶段还是比较常见,毕竟独立启动多个模块要更复杂一些,效率相对较低。

但是部分 IDE(如 Eclipse)只支持将依赖项目以 JAR 的形式部署到当前的应用,而不支持对资源文件的处理。例如,有两个模块 A 和 B,当启动 B 时将 A 添加为依赖项目,此时,A 会作为 JAR 添加到应用 B,但是 A 中包含的资源文件却无法一并包含到应用 B 中,因此你是无法同时测试两个模块的页面的。

当然,笔者并未尝试过其他 IDE,如果你正在使用的 IDE 支持对资源文件的归集,那么恭喜你。

对于这个问题,解决起来也并不复杂,可以尝试以下方案:

  • 将资源文件改由通过类路径加载,而不是存储目录。这是 Spring Boot 采用的方式。由于应用服务器(如 Tomcat)默认通过存储目录查找资源文件,因此需要实现自己的 Servlet 用于处理 JSP 以及静态文件。在 Spring Boot 中,所有请求均通过 DispatcherServlet 处理,通过为资源文件注册单独的 HandlerMapping,以支持通过类路径加载资源文件。在这种方式下,资源文件与 Java 类文件都会被包含到 JAR 包中,并无二致。

  • 不使用 IDE 的自动部署功能,通过 Maven 等工具进行构建测试。在这种方式下,对于资源文件的修改,不能做到实时刷新,便利性大打折扣。

  • 如果你熟悉 JDT(仅针对基于 Eclipse 的 IDE),可以扩展 IDE 的自动部署插件,使其支持合并部署。该方案具有一定的技术门槛,实践起来并不划算。

比较之下,方案一实现起来最简单,同时也更便捷。当然,出于优化系统访问性能的考虑,静态文件更倾向于单独部署到前置 Web 服务器(如 Nginx)上。此时,只需考虑 JSP 以及各种模板文件的处理即可。

小结

对于开发架构的松耦合,主要体现在如何解决 API 依赖以及模块产出物(代码、配置、资源文件)的分解上。这种分解便于模块以更轻量级的方式运行,有利于系统整体架构向轻量级架构转型。

如果你正在尝试将当前系统重构为微服务架构,不妨先尝试如何做类似拆分,这种拆分一定是由业务进行驱动。当你的系统以松耦合的模块化架构运行无碍后,微服务架构便已是一步之遥。

上一篇
下一篇
目录