当前位置: 首页 > 科技观察

关于Java模块系统,看这篇文章就够了

时间:2023-03-19 19:54:05 科技观察

继2014年3月Java8发布,时隔4年,2018年9月,Java11如期发布,Java9间隔两个月和Java10。非LTS(长期支持)版本。作为最新的LTS版本,与Java8相比,Java11包含了模块系统、使用G1作为默认GC算法、响应式流Flow、新版HttpClient等诸多特性。作为JDK11升级系列的第一篇文章,本文将介绍本次升级最重要的特性——模块系统。1、引入模块系统如果把Java8比作一个单体应用,那么引入模块系统之后,从Java9开始,Java华丽丽的变成了微服务。代号为Jigsaw的模块系统于2008年8月首次提出(比MartinFowler提出微服务早了6年)。2014年跟随Java9正式进入开发阶段,最终跟随Java9于2017年9月发布。那么什么是模块系统呢?官方定义是一组唯一命名的、可重用的相关包,以及资源(如图像和XML文件)和模块描述符。如图-1,模块的载体是一个jar文件,一个模块就是一个jar文件,但是与传统的jar文件相比,模块的根目录下有一个module-info.class文件,它是模块描述符。模块描述符包含以下信息:模块名称取决于哪些模块导出模块中的哪些包(允许直接导入使用)打开模块中的哪些包(允许通过Java反射访问)提供哪些服务取决于哪些服务图-1:Java9Modulealso也就是说任何jar文件只要加入合法的模块描述符就可以升级为模块。这个看似很小的改变能带来什么好处呢?在我看来,它至少带来了四个好处。首先,本机依赖管理。有了模块系统,Java可以根据模块描述符计算模块之间的依赖关系。一旦发现循环依赖,就会终止启动。同时,由于模块系统不允许不同的模块导出同一个包(即分包,分包),Java在查找包时可以准确定位模块,从而获得更好的性能。第二,精简JRE。引入模块系统后,JDK本身被划分为94个模块(见图2)。通过Java9中新增的jlink工具,开发者可以根据实际应用场景自由组合这些模块,去掉不需要的模块,生成自定义的JRE,从而有效降低JRE的体积。得益于此,JRE11的大小仅为JRE8的53%,从218.4MB减少到116.3MB,并且JRE中广受诟病的巨型jar文件rt.jar也被移除。更小的JRE意味着更少的内存占用,这使得Java对于嵌入式应用程序开发更加友好。Figure-2:TheModularJDK第三,更好的兼容性。Java自诞生以来,包的可见性只有4种,这大大降低了Java对面向对象三大特性之一的封装性的支持。类库的维护者对此怨声载道,只能翻来覆去各种文档。或者奇怪的命名以强调这些或那些类仅供内部使用,使用风险自负。Java9之后,在模块描述符中使用exports关键字,模块维护者可以精确控制哪些类可以对外开放,哪些类只能在内部使用。也就是说,它们不再依赖于文档,而是由编译器来保证。类可见性的细化不仅带来了更好的兼容性,也带来了更好的安全性。Figure-3:JavaAccessibility四、提高Java语言开发效率。Java9之后,Java好像挂了钩,一改原来一拖再拖的作风,严格按照每六个月一个大版本的发布策略,从2017年9月到2020年3月,从Java9到Java14,六个版本已在三年内发布,没有任何延迟,请参见图4。这无疑与模块系统的引入有很大关系。前面提到,在Java9之后,JDK被拆分成94个模块,每个模块都有明确的边界(模块描述符)和独立的单元测试,对于每个Java语言开发者来说,大家只需要关注由此带来的开发效率它所负责的模块得到了很大的改进。区别就像从单体应用架构升级到微服务架构,版本迭代速度不快也不难。Figure-4:JavaSELifecycle2,Basics2.1模块描述符如前所述,模块的核心在于模块描述符,对应根目录下的module-info.class文件,这个class文件是由模块创建的-info.class文件在源代码的根目录下。info.java编译生成。Java为module-info.java设计了专门的语法,包括module、requires、exports等多个关键字(见图5)。Figure-5:Module-info.javaGrammar语法解释:[open]module:声明一个模块,模块名要全局唯一,不能重复。添加open关键字意味着允许通过Java反射访问模块中的所有包,模块声明体中不再允许有opens语句。requires[transitive]:声明模块依赖,一次只能声明一个依赖,如果依赖多个模块,需要多次声明。添加传递关键字表示传递依赖。比如模块A依赖模块B,模块B传递依赖模块C,那么模块A就会自动依赖模块C,类似于Maven。exports[to[,...]]:导出模块中的包(允许直接导入),一次导出一个包,如果需要导出多个包,需要多次声明。如果需要定向导出,请使用to关键字后跟模块列表(以逗号分隔)。opens[to[,...]]:打开模块中的包(允许通过Java反射访问),一次打开一个包,如果需要打开多个包,需要声明多次。如果需要定向打开,可以使用to关键字后跟模块列表(逗号分隔)。provideswith[,...]:声明模块提供的JavaSPI服务,一次可以声明多个服务实现类(以逗号分隔)。uses:声明模块依赖的JavaSPI服务,然后模块中的代码可以通过ServiceLoader.load(Class)一次性加载声明的SPI服务的所有实现类。2.2-p&-m参数Java9引入了一系列新的模块编译运行参数,其中最重要的两个参数是-p和-m。-p参数指定模块路径,多个模块之间用“:”(Mac、Linux)或“;”分隔(视窗)。它适用于javac命令和java命令。用法和Java8中的-cp非常相似,-m参数指定要运行的模块的main函数。输入格式为main函数的模块名/类名,仅适用于java命令。两个参数的基本用法如下:javac-pjava-p-m/2.3Demoexample为了帮助大家理解模块描述符的语法和新Java参数,我专门设计了一个样例工程,包含5个模块:mod1模块:主模块,展示了服务实现类的两种使用方式。mod2a模块:导出并打开一个包,声明两个服务实现类。mod2b模块:声明一个未记录的服务实现类。mod3模块:定义了SPI服务(IEventListener),声明了一个未记录的服务实现类。mod4模块:导出公共模型类。图6:包含5个模块的示例项目让我们先看一下main函数。方法一展示了直接使用mod2导出打开的两个IEventListener实现类。方法二展示了通过JavaSPI机制使用所有IEventListener实现类。无论是否导出/打开。与方法一相比,方法二多了两行输出,分别是mod2b和mod3通过provides关键字提供的服务实现类。publicclassEventCenter{publicstaticvoidmain(String[]args)throwsReflectiveOperationException{//方法一:通过exports和opensSystem.out.println("Demo:DirectMode");varlisteners=newArrayList();//使用导出类listeners.add(newEchoListener());//使用open类//compileerror:listeners.add(newReflectEchoListener());listeners.add((IEventListener)Class.forName("mod2a.opens.ReflectEchoListener").getDeclaredConstructor().newInstance());varevent=Events.newEvent();listeners.forEach(l->l.onEvent(event));System.out.println();//方法二:通过SPISystem.out.println("Demo:SPIMode");//加载所有的IEventListener实现类,不管是否导出/打开varlisteners2=ServiceLoader.load(IEventListener.class).stream().map(ServiceLoader.Provider::get).collect(Collectors.toList());//compileerror:listeners.add(newInternalEchoListener());//compileerror:listeners.add(newSpiEchoListener());varevent2=Events.newEvent();listeners2.forEach(l->l.onEvent(event2));}}代码1:mod1.Event在Center.java命令行下执行./build_mods.sh,输出如下,结果与预期一致-c31f5e3f72d2Demo:SPIMode[spiecho]Eventreceived:678d239a-77ef-4b7f-b7aa-e76041fcdf47[echo]Eventreceived:678d239a-77ef-4b7f-b7aa-e76041fcdf47[reflectecho]Eventreceived:678d239a-77ef-4b7f-b7aa-e76041fcdf47[internalecho]Eventreceived:678d239a-77ef-4b7f-b7aa-e76041fcdf47Code-2:EventCenterResultOutput3.AdvancedarticlesSeehere,我相信创建和运行新的模块应用程序对你来说不再是问题,但问题是旧的Java8应用程序呢?别着急,我们先来了解两个高级概念,未命名模块和自动模块。图7:未命名模块vs自动模块未模块化的jar文件是转换为未命名模块还是自动模块取决于jar文件出现的路径。如果它是一个类路径,它将被转换成一个未命名的模块。module,如果是模块路径,会转为自动模块。注意自动模块也属于命名模块的范畴,它们的名字是模块系统根据jar文件名自动派生的。例如,从com.foo.bar-1.0.0.jar文件派生的自动模块名称是com.foo.bar。图7列出了未命名模块和自动模块的行为差异。此外,两者之间还有另一个关键区别。分包规则适用于自动模块,但对未命名模块无效,即多个未命名模块。模块可以导出相同的包,但自动模块不能。未命名模块和自动模块存在的意义在于,无论传入的jar文件是否为合法模块(包括模块描述符),Java都可以统一以模块的形式进行处理,这也是Java9兼容旧版本应用架构原则。当运行旧版本的应用程序时,所有jar文件都出现在类路径中,即它们被转换为未命名的模块。对于未命名的模块,默认导出所有包,所有模块都是依赖的,所以应用程序可以正常运行。进一步解读请参考官方白皮书相关章节。基于未命名模块和自动模块,对应的有两种针对旧版本应用的迁移策略,即模块化策略。3.1自底向上策略第一种策略称为自底向上策略,即根据jar包依赖关系(如果依赖关系比较复杂,可以使用jdeps工具进行分析),沿着依赖树对jar进行模块化自下而上打包(在jar包源码根目录下添加一个合法的模块描述文件module-info.java)。最初,所有的jar包都是非模块化的,都放在类路径下(转化为无名模块),应用程序以传统方式启动。然后,开始自下而上的对jar包进行模块化,将改造后的jar包移动到模块路径下,期间还是按照传统方式启动应用。最后,在所有jar包模块化之后,以-m方式启动应用,这也标志着应用已经迁移到真正的Java9应用。以上面的示例工程为例,图8:自下而上的模块化策略1.假设一开始所有的jar包都是非模块化的,此时应用程序运行命令为:java-cpmod1.jar:mod2a.jar:mod2b.jar:mod3.jar:mod4.jarmod1.EventCentermod3和mod4模块化改造。完成后,此时mod1、mod2a、mod2b还是普通的jar文件,新的运行命令为:java-cpmod1.jar:mod2a.jar:mod2b.jar-pmod3.jar:mod4.jar--add-modulesmod3,mod4mod1.EventCenter与上一步的命令相比,首先将mod3.jar和mod4.jar从类路径移到了模块路径下,这样很好理解,因为这两个jar包已经转化为真正的模块了。其次,多了一个参数--add-modulesmod3,mod4,这是为什么呢?这是关于模块系统的模块发现机制。不管是编译时还是运行时,模块系统首先要确定一个或多个根模块(rootmodule),然后从这些根模块开始,根据模块依赖关系找到模块路径中的所有可观察模块(observablemodule)),这些可观察模块加上类路径下的jar文件,最终构成了编译时环境和运行时环境。那么根模块是如何确定的呢?对于运行时,如果应用程序是由-m启动的,那么根模块就是-m指定的主模块;如果应用程序是通过传统方法启动的,那么根模块就是所有的java.*模块就是JRE(见图2)。回到前面的例子,如果不加--add-modules参数,那么运行环境除了JRE就只有mod1.jar、mod2a.jar、mod2b.jar,不会有mod3和mod4模块,以及java.lang将被报告。ClassNotFoundException异常。可以想象,--add-modules参数用于手动指定额外的根模块,以便应用程序可以正常运行。3.然后完成mod2a和mod2b的模块化改造。此时运行命令为:java-cpmod1.jar-pmod2a.jar:mod2b.jar:mod3.jar:mod4.jar--add-modulesmod2a,mod2b,mod4mod1.EventCenter由于mod2a和mod2b都是依赖于mod3,mod3不需要添加到--add-modules参数中。4、最后完成mod1的模块化改造,最终运行命令简化为:java-pmod1.jar:mod2a.jar:mod2b.jar:mod3.jar:mod4.jar-mmod1/mod1.EventCenter注意此时的应用程序是以-m方式启动的,并且指定mod1为主模块(也是根模块),所以其他所有模块都会根据依赖被识别为可观察模块,加入到运行环境中,应用程序可以正常运行。3.2自上而下的策略自下而上的策略很容易理解,实现路径也很清晰,但是它有一个隐含的假设,就是所有的jar包都可以模块化,那么如果有不能进行模块化改造的jar包(对于比如jar包是第三方类库)?别慌,我们来看第二种策略,叫做自上而下(top-down)策略。它的基本思想是沿着依赖树从上到下分析各个jar包进行模块化改造的可能性,从主应用开始,根据jar包的依赖关系,将jar包分为两类,一类是可以修改的,一类不能改革。对于第一类,我们仍然采用自下而上的策略进行改造,直到主应用完全改造完毕。对于第二类,我们需要从一开始就把它放到模块路径中,也就是把它变成一个自动模块。下面我们就来说说自动化模块设计的巧妙之处。首先automatic模块会导出所有的包,这样第一类jar包就可以正常访问automatic模块了。其次,自动模块依赖于所有命名模块并允许访问所有未命名模块。命名模块的类(这很重要,因为除了自动模块之外的命名模块不允许访问未命名模块的类),这样自动模块本身就可以像往常一样访问其他类。主应用完成模块化改造后,可以将应用的启动模式改为-m模式。还是以示例工程为例,假设mod4是第三方jar包,不能模块化,那么经过最后改造后,虽然应用运行命令和之前一样,java-pmod1.jar:mod2a。jar:mod2b.jar:mod3.jar:mod4.jar-mmod1/mod1.EventCenter,但是只有mod1、mod2a、mod2b、mod3是真正的模块,mod4没有任何修改,转成通过模块系统的自动模块。图9:自上而下的模块化策略看起来很完美,但是等一下,如果有多个自动模块,并且它们之间有拆分包怎么办?如前所述,自动模块与其他命名模块一样,需要遵循splitPackage规则。这种情况下,如果模块化改造势在必行,要么舍不得其中一个自动模块,只保留其中一个自动模块,要么自己做一个版本的Hack。当然,你也可以尝试找到这些自动模块的维护者,让他们PK来决定谁才是这个拆分包的主人。4.番外章节模块系统介绍基本结束。简要回顾。首先介绍了什么是模块以及模块化的好处。然后我给出了定义模块的语法,以及编译和运行模块的命令。辅以示例工程进行说明,最后阐述了老版本应用模块化改造的思路。下面我们来看一些类似于模块系统的框架和工具,进一步加深大家对模块系统的理解。4.1vsOSGi说到模块化,尤其是在Java界,当属OSGi这个模块系统的鼻祖了。OSGi中的束与模块系统中的模块非常相似。它们都以jar文件的形式存在。每个bundle都有自己的名称,还定义了依赖bundle、导出的包和发布的服务。不同的是,一个OSGibundle可以定义一个版本和一个生命周期的概念,包括六种状态:installed、resolved、uninstalled、starting、active和stopping。所有的bundle都由OSGi容器管理,都在同一个OSGi容器中,允许同一个bundle的多个版本同时运行,甚至每个bundle都有自己独立的类加载器。以上种种特点,使得OSGi框架变得非常沉重,在微服务盛行的情况下,它也越来越边缘化。4.2vsMavenMaven的依赖管理和模块系统有一些相似之处。Maven中artifacts对应的modules以jar文件的形式存在,有名字,可以声明传递依赖。不同之处在于Maven工件支持版本,但缺少包级别的信息并且没有服务的概念。如果Java天生就是模块系统,那么Maven的依赖管理很可能直接基于模块系统来设计。4.3vsArchUnit与模块系统相比,ArchUnit在包可见性方面的控制能力不亚于模块系统,可以细化到类、方法、属性的层面。然而,ArchUnit缺乏模块级控制。模块系统的出现正好弥补了ArchUnit的不足。