Jdk9模块化实战入门
本章我们来到了Jdk9新增的最重要的一块内容——模块化。有过编程经验的人都不应该对模块化陌生, 无论是项目代码的组织、还是应用的拆分、架构的设计都渗透着模块化的思想,如今jdk9 不但本身已经模块化,且对我们创建模块化的应用程序提供了“本地原生”支持。模块化的程序提供了更高级的封装性与服务化特性。本章我们将学习:
什么是模块化
jdk9 的模块化特性
如何构建模块化程序
面向服务的模块化设计
一、模块化思想进阶
模块化的目标
比如说一个电商系统根据业务可以拆解为登录、购物车、商品详情、订单、秒杀等等子系统, 比如说一个app端商品列表页可以拆分成header bar 、footer bar 、category tab 、搜索框、商品列表等一系列UI 组件。再比如我们总是把一些通用的逻辑抽取到一块, 比如我们的工具类。大到应用系统设计,小到功能编码。从现在很火的前端vue 、 react 编程模型, 到后端兴起的Spring-cloud 微服务架构等等,全都在深入贯彻着模块化思想: 把一个大的系统拆分成细粒度的组织单元,进而降低系统复杂度、减少耦合性,增加复用性,提高团队协作能力,所以模块化的目标:
拆分思想
当我们生活中遇到一个很大的困难时, 我们肯定是把它分解成一个个的小问题, 分而治之,各个击破。同理,模块化的原则驱动我们去把一个大的代码逻辑拆分成一个个小的功能函数, 如果能有机的组织好这些小的功能单元, 那对外同样可以提供更大服务能力,并且每个功能单元的可复用性大大增加、复杂度大大减少,有利于专门的开发人员去维护!
封装性&服务化
在构建模块时,所谓封装, 意味着对外要隐藏一些属性以及实现细节, 模块之间通过接口通信, 作为消费者的模块只需要关注服务提供者模块暴露的api, 而隐藏掉接口的实现细节,进而阻止意外的模块间耦合(如果另一模块不小心使用了一个模块应该封装的东西),减少了访问模块的复杂性, 提高了安全性。
jdk9之前
问题一
在jdk9之前, 如果我们做这样一个事情: 设计一个通用数据加密工具jar 来让开发团队各个小组使用,我们可能会设计一个通用的加密接口来对外让大家使用,而不需要去关心加密方式。
└─com.dockerx
└─api
└─internal
└─ DataSecurityServiceImpl
└─ DataSecurityService
public static String encrypt(String info, KeyType type) {
return DataSecurityServiceImpl.sysInnerEncryptBySHA(info, type);
}
DataSecurityService 里包含一个静态加密方法,加密过程委托给internal 包下的实现类,现在你可能将代码打成jar 包, 让大家去引用你的jar,然后在代码中这样去调用:DataSecurityService.encrypt("password", KeyType.RSA) , 一切看起来很美好。一个月后你发现一个安全性更高的加密算法, 并决定更新曾经的jar, 心想只需要删掉之前的实现类,换用新的实现不就行了?而客户端对加密接口的调用方式不需要做任何改动!
public static String encrypt(String info, KeyType type) {
return RSADataSecurityServiceImpl.sysInnerEncryptByRSA(info, type);
}
好了, 改动完毕,升级完jar 版本,大家都引用了最新的加密jar 包,结果各个项目中大面积代码报错!因为并不是所有人都像期望的那样使用了DataSecurityService.encrypt("password",KeyType.PASSWD) ,他们是直接使用具体实现类做的加密(DataSecurityServiceImpl.sysInnerEncryptBySHA(info, type))! 虽然是在internal 包下, 但仍然阻止不了程序员们探索的热情, 都怪实现类是public, 如果把实现类 改成protect呢? 那加密接口就无法访问实现类了!看来实现类只能是public 的, 只能赤裸裸的暴露给所有不该使用的人使用?
问题二
//重写
我们知道classPath 对于Java 应用程序的意义,无论是在累的编译时还是运行时, 对类的加载都需要去classPath 遍历查找。在运行时一个Java 应用程序就JVM 来说,本质上就是来自各个包中的一堆在classPath 中平铺的class 文件集合而已。即使所谓的jar 对JVM 也是透明的。
逻辑上就像这样:
Java.lang.Integer; Java.lang.String; java.util.Map;javax.annotation.Resource;.........
我们知道JVM 运行时所需的类库rt.jar 在经过二十多年的丰富以后,已经包含了两万多个类,如果算上我们大型应用程序所依赖的其它类, 全都毫无结构化的平铺在我们的classPath 中,维护起来越来越像噩梦一样,例如重复依赖某个类,而两者版本不一致,反映在classPath 中,那就看哪一个版本的类先被找到, 进而造成运行时异常的隐患,抑或在
/*需要补充/
jdk9 模块化
尽管我们在应用层面已经随处可见模块化设计的理念, 但是直到jdk9 发布之后, 才有了“编程语言”级别的构建模块化应用程序的支持, 开发者们现在可以利用jdk9 对模块化的原生支持,构建模块化应用程序。就连java runtime environment(jre) 、Java Development Kit (JDK) 也已被重写为模块化,我们可以在控制台使用java --list-modules 来列出Java 平台提供的可用模块:
C:\Users\cbam>java --list-moudles
java.activation@9.0.1
java.base@9.0.1
java.compiler@9.0.1
java.corba@9.0.1
java.datatransfer@9.0.1
java.desktop@9.0.1
java.instrument@9.0.1
java.jnlp@9.0.1
java.logging@9.0.1
java.management@9.0.1
java.management.rmi@9.0.1
..............................
..............................
/*需要补充/
二、构建模块化实例
创建单个模块
略
创建多个模块
有了单模块创建的基础, 我们来尝试把第一节加密jar 以模块化的方式重写, 看看能否其存在的问题。按如下目录结创建两个模块, 加密模块:com.dockerx.encrypt,客户端模块: com.dockerx.cli ,客户端模块依赖加密模块来调用加密服务。
src
└─com.dockerx.encrypt
└─ module-info.java
└─ security
└─api
└─ DataSecurityService.java
└─internal
└─ DataSecurityServiceImpl.java
└─com.dockerx.cli
└─ module-info.java
└─ security
└─ cli
└─ Main.java
com.dockerx.encrypt 模块的module-info:
module com.dockerx.encrypt {
exports security.api;
}
com.dockerx.cli 模块的module-info:
module com.dockerx.cli {
requires com.dockerx.encrypt;
}
exports 意味着导出该模块下的某个包,意味着其它模块可以使用,这个包下的内容。requires 意味着此模块依赖com.dockerx.encrypt 模块。
Main 类中代码:
import security.api.DataSecurityService;
import security.internal.DataSecurityServiceImpl;
public class Main {
public static void main(String[] args) {
System.out.println(DataSecurityService.encrypt("Hello"));
}
}
使用以下指令编译通过:
javac --module-source-path src -d out -m com.dockerx.encrypt,com.dockerx.cli
我们假设有某个“不老实” 的程序员故意要使用实现类来加密, 我们在main 方法中做一下尝试
System.out.println( DataSecurityServiceImpl.sysInnerEncryptBySHA("Hello"));
再次编译发现很不幸!现在我们无法访问具体实现了!明明我们的实现类是public 的!看来只能老老实实根据提供的接口来加密了。。。
D:\project\Demo\jdk9\src\main\java>javac --module-source-path src2 -d out -
m com.dockerx.encrypt,com.dockerx.cli
src2\com.dockerx.cli\security\cli\Main.java:4: 错误: 程序包security.internal不存
在
import security.internal.DataSecurityServiceImpl;
^
src2\com.dockerx.cli\security\cli\Main.java:10: 错误: 找不到符号
System.out.println( DataSecurityServiceImpl.sysInnerEncryptBySHA("Hello"
));
^
符号: 变量 DataSecurityServiceImpl
位置: 类 Main
2 个错误
我们看到模块化的封装特性阻止了外部模块对此模块某些包下的某些类型访问能力, 即使某些类型是public 的,除非在module-info.java 里显示导出,否则就是不可见的
我们再来看另外一个问题, jdk9 之前,我们编译好的class 运行的时候有时会出现NoClassDefFoundError 运行时异常。现在我们尝试把我们编译好的com.dockerx.encrypt 模块目录删掉,然后再次运行, 发现抛出了如下异常
Error occurred during initialization of boot layer
java.lang.module.FindException: Module com.dockerx.encrypt not found, required b
y com.dockerx.cli
异常显示com.dockerx.encrypt 模块被依赖, 却未找到,这并不意外, 但是我们最应该关注的是异常并不是发生在Java 运行时找不到某个类!意味着在虚拟机初始化的时候就对所有module-info 所构建的依赖图做了检查。(就好比我们这本书包含第4章节,但现在第四章节在目录里面消失了,当然要发生异常)
问题到这里似乎都解决了? 如果哪天我们要扩展一些新的加密算法实现, 我们肯定要重新修改这个模块化的jar , 在internal 包里新增一个实现, 然后需要修改api 包中的DataSecurityService 的加密实现:
public static String encrypt(String info, KeyType type) {
switch (type)
case KeyType.RSA:
return DataSecurityServiceImpl.sysInnerEncryptByRSA(info, type);
break;
case KeyType.SHA:
return DataSecurityServiceImpl.sysInnerEncryptBySHA(info, type);
break;
。。。。。。。
}
别忘了还要在使用加密模块的客户端模块的module-info.java 中requires 我们新的实现!看来真是一团糟。
虽然模块化的封装性对客户端屏蔽了实现细节,但从扩展性上,我们发现对外加密的api 和加密算法实现, 不仅物理上耦合在一个包里, 就连代码逻辑都有着千丝万缕的联系。api 对外应该是一种稳定的存在, 如果在修改实现的时候还要改动api 的逻辑,系统稳定性将受到极大的威胁!“对扩展开放, 对修改关闭”的开闭原则激励我们去思考如何更优雅的扩展一种实现,而不需要做任何多余的修改。同时客户端要想使用加密功能, 还得知道存在哪些可用的实现,“最少知识原则”激励我们去思考如何才能使api 模块完全不需要知道实现类的存在与否甚至实现细节。 api 与实现类之间,实现类与实现类之间最好应该尽可能少的通信, 因为越解耦的系统,将来出错的机率越低!可维护性、可扩展性越强!带着这个问题我们在下一节通过持续优化来学习如何使模块间完全解耦的服务化特性~
Tips:
public is not public ! 如果读者的应用程序是基于Spring 的,可能直接用DI 来做这个工具类
三、面向服务的模块化设计
本章我们将共同学习jdk9 中模块间的“服务化” 支持,上一章中我们尝试抽取出通用加密模块来让各个客户端模块使用, 本身就是一种服务支持,只不过模块间通过一种直接相互依赖的方式,这种方式, 我们已经在上一章末尾讨论了存在的问题。现在我们继续重构上一章的例子一步步尝试着把模块间的依赖变成松耦合。
简单重构
首先,要表达一种服务,使服务具有扩展性,我们自然而然的想到定义一个接口,然后让不同的实现类实现这个接口, 虽然用接口来表达不是必须的,但却是一种极佳的方式, 那么我们把我们之前的类DataSecurityService 声明成接口类型,所有internal 包下的加密类现在都需要实现该接口。其次,为了使我们的接口与具体实现松耦合,更弹性的去扩展一种实现,我们现在把接口以及接口的所有实现都分别模块化,加上客户端,我们现在有四个模块要创建,就像这样:
让我们来分析一下上面的改造,现在我们可以轻松的扩展一种新的加密实现类,只要实现DataSecurityService 接口就好了嘛, 这时无须改动接口模块,既然我们的服务约定(接口定义)稳定,就确保了客户端的服务调用的稳定性。同时对于我们新的加密实现单独作为一个模块,对其它已稳定“跑着”的实现模块不会产生任何影响!看来我们的这次重构 带来的好处不少。好,我们来写一写DataSecurityClient 客户端模块的代码:
DataSecurityService encryptService = new RsaEncryptImpl();
encryptService.encrypt("identityCode");
看到new, 是否回顾起来上章末尾遗留的另一个问题?客户端还是要拥有足够的“知识”去知道到底有哪些实现模块!毕竟客户端只拿着接口至少要找个具体的实现类的实例去进行加密吧。我们新扩展了一个加密实现模块还要记得去告诉好多好多的客户端:“哦~ 有新实现了!快添加新依赖~”,并且每次扩展都需要在module-info 里exports 新的实现,甚至一旦服务实现类修改,客户端代码可能也要修改。所以说,现在又来问题了:客户端强耦合具体实现模块。反映在客户端module-info.java 里,会是这样
//TODO 图上下次会标上 模块名称 文章内容就可以 直接称呼其模块名
module com.dockerx.client {
requires com.dockerx.encrypt.api;
requires com.dockerx.encrypt.HmacImpl;
requires com.dockerx.encrypt.RsaImpl;
}
意味着每次有新实现,客户端都要requires 一下。那么我们现在把目标转向如何如何使com.dockerx.client 模块和各种具体实现模块松耦合?
工厂模式引入
在面向接口编程中,直接在客户端new 接口实现类, 总是会让程序员非常敏感——这意味着强耦合。先明确的告诉大家,如果使用jdk9 提供的服务化会是一个极佳的解决方案,在我们登上“优雅之巅”之前, 先来自己动手去做解耦尝试, 要知道程序员哥哥们的饭碗是“思维能力”,而不是“搬砖”的!任何计算机科学问题都可以通过加一个中间层来解决,ok, 此时应该从大脑的module-path 中迅速定位到一种已经存在的经典的设计模式——工厂模式,工厂模式的目标就是解耦客户端与具体的服务实现,它不就解决恰好解决我们现在的问题了么!? 我们来实现这个工厂:
it looks like this:
public class EncryptServiceFactory {
public static List getSupportedTypes() {
return List.of(KeyType.RSA.getValue(), KeyType.AES.getValue());
}
public static DataSecurityService getDataSecurityService(KeyType type) {
switch (type) {
case KeyType.RSA:
return new RsaEncryptImpl();
case KeyType.HMAC:
return new HmacEncryptImpl();
default:
return new DefaultEncryptImpl();
}
}
}
客户端能够通过工厂方法getSupportedTypes拿到所有支持的加密实现, 并通过工厂方法getDataSecurityService 来获取某一种实现的实例,工厂屏蔽掉了任何客户端对具体实现的感知必要性,服务提供者可以任意修改、扩展,而无需担心服务调用者们。但是我们应该把这个工厂类放到哪里呢?如果把它放到com.dockerx.encrypt.api 模块, 因为工厂类本身是对实现类模块有依赖的,那么将导致com.dockerx.encrypt.api 模块在编译时就需要依赖所有的DataSecurityService 接口实现模块,要知道我们不能再让api 与其具体实现再次耦合! 那我们试试把他独立成一个模块
看起来就像这样:
如图所示,工厂模块对接口模块的依赖是requires transitive,所以客户端们现在要在module-info里这样描述:
module com.dockerx.client {
requires com.dockerx.encrypt.EncryptServiceFactory;
}
对于各种加密实现模块,在module-info中由单纯的exports , 修改为exports xxImpl to com.dockerx.encrypt.factory ,尽可能的向其它模块隐藏实现细节,保持强封装性。
在客户端会这样调用:
EncryptServiceFactory.getDataSecurityService("your keyType");
呀~,看起来怎么变得有点复杂了。。。不过还好,客户端可以开开心心的调用服务了,客户端的module-info 也不再需要requires 任何具体实现类了,可以做到编译时独立于接口实现类模块。不过辩证的看待这个工厂,虽然我们成功借助中间层——工厂解耦了客户端与实现模块,但会发现工厂模块与实现模块还是会存在与之前类似的问题——在编译时需要知道所有实现类。看起来没完没了了。并且每扩展一种实现我们还是要自行去exports ...to ... 实现,至少对工厂模块是可见的,因为工厂还是需要new 实现类。
jdk9模块间的服务化
看来是时候拿出我们的终极解决方案了!同样是对一个复杂的问题添加中间层的思想,jdk9 所提供的服务化支持是基于服务注册与服务发现, Service Registry ——服务注册中心,扮演的就是上节工厂的角色,但这个工厂角色由jdk 模块系统底层实现, 所有服务接口类型的各种实现都需要在这里注册,所以说Service Registry 维护了所有可用服务类型的实现信息,就像下面这样:
现在客户端想要获取一个服务实例,不再需要依赖各种加密具体实现类模块,并且我们砍掉了上节自定义的工厂实现模块,替而代之的是Service Registry,而我们并不需要关注它任何细节,因为整个服务注册与服务发现过程大部分工作都由模块化系统底层来完成。
服务发布
回顾我们服务解耦的历程, 我们发现之所以没有搞定具体实现模块与其它模块间的解耦,是不是我们总是会在实现类模块的module-info中exports 自身? 不过看起来好像也没办法,如果不exports模块,其它模块如何消费(requires)此模块? 这也就导致至少存在模块对我们想要隐藏的实现模块是强耦合的。
不过jdk9 的服务化提供了特殊的支持,它允许服务模块在不需要exports 自身的同时而被其它模块所消费,客户端只需要根据服务接口和Service Registry 通信, 来获取所需的具体服务实现实例,这样完全隔离了服务提供者与消费者(客户端)的联系——没有exports,意味着更强封装性,同时客户端在编译时不需要知道所要消费的服务的任何实现。
接下来我们尝试继续重构来实现上述特性, 其实我们只需要改动一些模块的module-info 即可。我们上面已经定义了服务接口:
module com.dockerx.encrypt.api {
exports security.api;
}
它是无论客户端还是服务端都需要共同依赖的模块。
接下来是我们的服务具体实现类, 仅仅需要修改实现类的module-info :
module com.dockerx.encrypt.RsaImpl{
requires com.dockerx.encrypt.api;
provides security.api.EncryptService with security.impl.RsaImpl;
我们来解释一下provides Awith B 句法, 句法意味着:在此模块,我要为 EncryptService 服务接口(A)提供一种具体实现(B),并将我注册在Service Registry 。没有了exports ,任何其它模块都对其没有可见性,也就实现了隐藏实现,解耦模块的终极目的!至此,简简单单,我们完成了服务注册所要做的全部工作。
服务发现
服务发布的 意义在于为消费者提供服务,我们来看如何消费发布在Service Registry 的服务。
第一步我们需要修改com.dockerx.client 模块的module-info :
module com.dockerx.client{
requires com.dockerx.encrypt.api;
uses security.api.EncryptService;
现在没有了对之前自定义工厂的requires, 使用uses 关键字。 uses 后面跟着服务接口的 全限定包名,表示client会使用EncryptService 服务接口所提供的服务实例, 注意到客户端和服务端(具体实现类) 共同依赖com.dockerx.encrypt.api 模块,而两者之间不存在任何直接或间接依赖,所以uses 语句并不强制在编译时就存在可用服务具体实现,甚至在运行时也不需要任何服务已注册。
我们已经用uses 声明了对服务的使用,第二步,在客户端代码中使用服务:
class Client {
public static void main(String[] args) {
Iterable encryptors = ServiceLoader.load(EncryptService.class);
for (EncryptService encryptor: encryptors) {
System.out.println(encryptor.encrypt("test str"));
}
}
}
main 方法内做了两件事:
首先使用ServiceLoader.load() 创建了一个ServiceLoader 实例
遍历打印EncryptService 服务接口所有实现
ServiceLoader 的迭代器,每当迭代到哪一个服务时,对应的服务实现才会实例化, 如果重复来回遍历,那么每一个服务实例会是同一个,意味着服务实例在第一次创建后会被缓存。除非客户端重新调用load 方法后,再次遍历出来的服务才是新的实例。基于这种方式的服务发现,压根就不知道当前遍历到的服务是哪一个模块提供的,而对每一个encryptor实例,效果等价于
EncryptService encryptor=new RsaImpl();
我们可以利用jdk8 中提供的,在接口中定义静态方法的特性,来把对ServiceLoader API 的使用“隐藏” 在通用模块com.dockerx.encrypt.api 中。
interface EncryptService {
String encrypt(String info);
static Iterable getEncryptServices() {
return ServiceLoader.load(EncryptService.class);
}
}
在客户端main 方法就可以这样来获取服务
Iterable encryptors = EncryptService.getEncryptServices(EncryptService.class)
Tips:
DI vs jdk9 服务化
有些同学可能接触过一些企业级框架,比如Spring , Spring 内置了一种强大的特性——依赖注入(DI),同样是用来解耦服务的很好实践。/*一种是依赖注入 一种是依赖查找 而非注入/
另一种服务发布的方式
发布服务有两种方式:
使用存在无参构造方法的服务接口实现类
使用一个public static 且无参数的provide 方法,其中provide 方法返回一个服务接口类型或实现类
我们之前的服务发布方式为第一种,而第二种发布方式更为灵活,因为基于无参构造方法,我们没有办法通过传递某些参数来定制化的创建我们想要的服务实例。相反,在第二种方式中, 如何创建服务实例,完全依靠于provide 方法体的具体实现。
class RsaImpl implements EncryptService {
private String extInfo;
RsaImpl() {
}
RsaImpl(String extInfo) {
this.extInfo = extInfo;
}
public String encrypt(String info) {
return innerEncrypt(info);
}
public static RsaImpl provide() {
return new RsaImpl("created by provide method!")
}
}
provide方法也可以放在其它类里,provide .... with 后面只需要跟上包含provide 方法的全限定名类即可。
服务选择
在上面的服务化模型中, 服务端对客户端完全隐藏了具体的实现模块细节, 从客户端的维度,很可能不会用到所有的服务,怎么才能过滤并选择出自己想要使用的服务?我们可以设计一个自描述的服务, 比如我们设计服务接口时新增一个getName() 方法, 用来标志每一个不同的服务实现,客户端就可以在迭代到某个服务时,调用getName() 来识别服务,这样虽然解决了服务识别问题, 但是别忘了, 我们用ServiceLoader API 采用遍历手法时, 在正确找到我们想要的服务实现时,已经在之前初始化了好多我们并不需要的服务实例,万一某个实例的初始化有着很高的性能损耗,譬如需要开辟大量内存,或服务实例不容易GC ,那么这种服务识别方式还是存在潜在问题的,不过jdk9 中的ServiceLoader 已经能够做到在服务实例未创建时做类型检查。ServiceLoader 实例的.stream() 方法返回包含ServiceLoader.Provider 的流。ServiceLoader.Provider 在创建实例之前会检查实现类型。
所以现在介绍一种新的利用注解来进行服务识别的方法,首先定义一个注解:
@Retention(RetentionPolicy.RUNTIME)
public @interface Recommend {
public boolean value() default true;
}
然后把注解放在我们加密实现类上:
@Recommend("true")
class RsaImpl implements EncryptService {
}
现在我们在客户端可以这样来进行服务的选择:
public class Main {
public static void main(String args[]) {
ServiceLoader encryptors=
ServiceLoader.load(EncryptService.class);
encryptors.stream()
.filter(provider -> isRecommend(provider.type()))
.map(ServiceLoader.Provider::get)
.forEach(encryptor-> System.out.println(encryptor.getName()));
}
private static boolean isRecommend(Class> clazz) {
return clazz.isAnnotationPresent(Recommend.class)
&& clazz.getAnnotation(Recommend.class).value() == true;
}
}
我们现在已经完成了利用注解进行反射来进行服务实现类的识别。
Tips
Java.lang.Class vs 访问性
上面我们是通过反射拿到实现类 的Class 对象,别忘了我们的服务实现类发布服务用的是provider...with..., 并没有导出任何包,看起来是不是破坏了模块化的可访问性?答案是否定的, 既然我们拿到类的class 对象,但如果我们尝试去newInstance() ,但是结果会抛出IllegalAccessError 异常。所以在访问控制上是没有问题的
四、模块化设计最佳实践