模块化不仅仅是一个实现问题,也是一个设计和架构的问题。通过模块化,可以应对需求、环境、团队以及其他不可预见事件所带来的变化。
本章将讨论模块化开发通用设计指南,以提高使用模块所构建系统的可维护性、灵活性和可重用性,这些模式和设计实践中的大部分与技术无关。
1. 模块边界
长久以来,将系统划分为小型的、可管理的模块已被认为是一项成功的策略。
根据D.L.Parnas在1972年的一篇论文中所述,他设计了一种称为Parnas分区(Parnaspartitioning)的模块化方法。通常,在设计模块时可以划分几个参考标准:
可理解性
可变性
可重用性
团队合作
当设计重用时,可以考虑以下两点:
坚持UNIX哲学理念,只做一件事情,并且将其做好。
将模块本身的依赖关系数量减到最少。否则,所有重用的使用者都要承担可传递依赖关系所带来的负担。
2. 精益化模块(大小)
需要考虑两个指标:模块公共区域面积(surfacearea)的大小以及内部实现的大小。
对于公共区域面积,应该使其最小化。而对于内部实现,精益化模块要尽可能独立,在可能的情况下避免依赖于其他模块。
开发精益化模块与围绕可重用微服务的最佳实践之间存在许多相似的地方:尽可能的小,与外界有定义良好的协议,同时尽可能保持独立。
精益化模块和微服务
模块和微服务是互补的概念,微服务很可能在内部使用Java模块来实现。
两者之间的一个重要区别是,使用模块及其描述符不仅可以明确描述所提供(导出)的内容,还可以明确地描述所需要的内容,因此可以非常安全可靠地解析和链接Java模块系统中的模块,但在大多数微服务环境中却不是这样的。
3. API模块
接口是实现API提供者和消费者之间解耦的主要手段,因此尽可能的自包含,不依赖多余的内容。
4. 聚合器模块
使用隐式可读性来构建聚合器模块,其本质上是通过现有的库模块构建一个外观(facade)。聚合器模块不包含代码,它只有一个模块描述符,为所有其他模块设置了隐式可读性。JDK中有两个很好的示例:
5. 避免循环依赖
一个显而易见的解决方案是将这些JAR合并成一个模块。当两个组件之间存在如此紧密(循环)的关系时,就可以断定它们是一个模块的。当然,该解决方案的前提是循环关系是良性的。当循环是间接的并且涉及多个组件(而不仅仅是两个组件)时,该方案就行不通了,除非想要合并参与循环的所有组件,但这不太可能。
通常,存在循环说明设计是有问题的,这意味着需要进行一些重新设计来打破这个循环。常言道,计算机科学的一切问题都可以通过引入另一个间接层来解决(当然更多的间接层也会出现问题)。
该示例中使用了一个中间层Named接口,解决了Author和Book的循环依赖问题。
当循环是间接的,并且通过很多步骤产生时,它们可能很难被发现,此时可以使用工具(如SonarQube)帮助检测现有代码中的循环依赖。
6. 可选的依赖关系
模块化应用程序的标志之一是其显式的依赖关系图。但如果一个模块在运行时不是绝对需要的,那么又该怎么办呢?
一种方式是使用static关键字,指定该模块是编译时依赖关系,即该模块只能在编译时使用,无法在运行时使用;应用程序可以通过try catch和Class.forName的方式,对依赖的模块进行检测,以决定应该使用哪个模块。
这种依赖关系检测往往意味着大量的检测,使用起来比较痛苦,并不是解耦的最佳方式。
module framework{
requires static fastjsonlib;
}
另外一种方式是使用服务(Service),为所有可选的实现制定一套通过的API,可以实现完美的解耦;缺点是必须要对现有代码改造。
7. 版本化
模块完全是由名称进行解析的,模块解析期间忽略版本,这是Java模块系统中一个慎重的设计选择。
版本配置一直以来是一个复杂的问题,不管采用什么策略,都会在模块系统中变得根深蒂固,因此也会深入到编译器、语言规范和JVM中。模块版本的选择权,仍然由构建工具决定,如Maven和Gradle。
在应用程序开发中,强烈建议找到一种统一依赖于单个模块版本的方法,避免同时使用多个版本。通常,运行同一模块的多个并发版本的原因是因为懒惰,而不是迫切需要。
8. 资源封装
模块中除了Java代码,还有很多资源文件,比如包含翻译(本地化资源包)的文件、配置文件等;模块封装了这些资源的访问,限制了跨模块资源访问。
访问本模块资源的方式有如下几种,其中以Module的方式获取是新增的。
Class clazz = ResourcesInModule.class;
InputStream cz_pkg = clazz.getResourceAsStream("resource_in_package.txt"); //<1>
URL cz_tl = clazz.getResource("/top_level_resource.txt"); //<2>
Module m = clazz.getModule(); //<3>
InputStream m_pkg = m.getResourceAsStream(
"javamodularity/firstresourcemodule/resource_in_package.txt"); //<4>
InputStream m_tl = m.getResourceAsStream("top_level_resource.txt"); //<5>
跨模块强行访问资源,一些场景下虽然是允许的,但是并不推荐;如果确实需要来自其他模块的资源,可以考虑通过使用导出类中的方法或使用服务来公开资源的内容。
//跨模块访问获得Module
Optional otherModule = ModuleLayer.boot().findModule("secondresourcemodule"); //<1>
otherModule.ifPresent(other -> {
try {
//下面三种方式,可以正常获得资源,它分为三类:模块顶级目录中的资源、非包目录下的资源、所有的class文件
InputStream m_tl = other.getResourceAsStream("top_level_resource2.txt"); //<2>
InputStream m_class = other.getResourceAsStream(
"javamodularity/secondresourcemodule/A.class"); //<4>
InputStream m_meta = other.getResourceAsStream("META-INF/resource_in_metainf.txt"); //<5>
//下面两种方式,都无法获得资源。模块对包路径中的资源时强封装的。
InputStream m_pkg = other.getResourceAsStream(
"javamodularity/secondresourcemodule/resource_in_package2.txt"); //<3>
InputStream cz_pkg =
Class.forName("javamodularity.secondresourcemodule.A")
.getResourceAsStream("resource_in_package2.txt"); //<6>
assert Stream.of(m_tl, m_class, m_meta)
.noneMatch(Objects::isNull);
assert Stream.of(m_pkg, cz_pkg)
.allMatch(Objects::isNull);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
也可以通过使用开放式模块(open module)或开放式包(open package),向其他模块公开包中封装的资源。
9. 小结
模块化可能是Java有史以来最大的Feature,它将自己长期依赖All-in-one/Environment的结构,转变为以Module基础的组件。
其影响范围不仅仅是代码层面,它将影响设计、编译、打包、部署等过程;本文讨论的这些通用模式,是以模块为基础的系统在设计时常常要考虑的。
参考的文章