你好,我是范学雷。今天,我们继续讨论Java平台模块系统(Java Platform Module System,JPMS)。
Java平台模块系统是在JDK 9正式发布的。在上一讲我们也说过,这项重要的技术从萌芽到诞生,花费了十多年的时间,堪称Java出现以来最重要的新软件工程技术。
模块化可以帮助各级开发人员在构建、维护和演进软件系统时提高工作效率。更让人满意的是,它还非常简单、直观。我们不需要太长的学习时间就能快速掌握它。
这一节课,我们就一起来看看应该怎么使用Java平台模块系统。
在前面的课程里,我们多次使用了Digest这个案例来讨论问题。在这些案例里,我们把实现的代码和接口定义的代码放在了同一个文件里。对于一次Java新特性的讨论来说,这样做也许是合适的。我们可以使用简短的代码,快速、直观地展示新特性。
public sealed abstract class Digest {private static final class SHA256 extends Digest {// snipped, implementation code.}private static final class SHA512 extends Digest {// snipped, implementation code.}public static Returned<Digest> of(String algorithm) {// snipped, implementation code.}public abstract byte[] digest(byte[] message);}
但是,如果放到生产环境,这样的示例就不一定是一个好的导向了。因为,Digest的算法可能有数十种。其中有老旧废弃的算法,有即将退役的算法,还有当前推荐的算法。把这些算法的实现都装到一个瓶子里,似乎有点拥挤。
而且,不同的算法,可能有不同的许可证和专利限制;实现的代码也可能是由不同的个人或者公司提供的。同一个算法,可能还会有不同的实现:有的实现需要硬件加速,有的实现需要使用纯Java代码。这些情况下,这些实现代码其实都是没有办法装到同一个瓶子里的。
所以,典型的做法就是分离接口和实现。
首先,我们来看一看接口的设计。下面的代码就是一个接口定义的例子。
package co.ivi.jus.crypto;import java.util.ServiceLoader;public interface Digest {byte[] digest(byte[] message);static Returned<Digest> of(String algorithm) {ServiceLoader<DigestManager> serviceLoader =ServiceLoader.load(DigestManager.class);for (DigestManager cryptoManager : serviceLoader) {Returned<Digest> rt = cryptoManager.create(algorithm);switch (rt) {case Returned.ReturnValue rv -> {return rv;}case Returned.ErrorCode ec -> {continue;}}}return Returned.UNDEFINED;}}
在这个例子里,我们只定义了Digest的公开接口,以及实现获取的方法(使用ServiceLoader),而没有实现具体算法的代码。同时呢,我们希望Digest接口所在的包也是公开的,这样应用程序可以方便地访问这个接口。
有了Digest的公开接口,我们还需要定义连接公开接口和私有实现的桥梁,也就是实现的获取和供给办法。下面这段代码,定义的就是这个公开接口和私有实现之间的桥梁。Digest公开接口的实现代码需要访问这个桥梁接口,所以它也是公开的接口。
package co.ivi.jus.crypto;public interface DigestManager {Returned<Digest> create(String algorithm);}
然后,我们来看看Digest接口实现的部分。有了Digest的公开接口和实现的桥梁接口,Digest的实现代码就可以放置在另外一个Java包里了。比如,下面的例子里,我们把Sha256的实现,放在了co.ivi.jus.impl这个包里。
package co.ivi.jus.impl;import co.ivi.jus.crypto.Digest;import co.ivi.jus.crypto.Returned;final class Sha256 implements Digest {static final Returned.ReturnValue<Digest> returnedSha256;// snippedprivate Sha256() {// snipped}@Overridepublic byte[] digest(byte[] message) {// snipped}}
因为这只是一个算法的实现代码,我们不希望应用程序直接调用实现的子类,也不希望应用程序直接访问这个Java包。所以,Sha256这个子类,使用了缺省的访问修饰符。
同时,在这个Java包里,我们也实现了Sha256的间接获取方式,也就是实现了桥梁接口。
package co.ivi.jus.impl;// snippedpublic final class DigestManagerImpl implements DigestManager {@Overridepublic Returned<Digest> create(String algorithm) {return switch (algorithm) {case "SHA-256" -> Sha256.returnedSha256;case "SHA-512" -> Sha512.returnedSha512;default -> Returned.UNDEFINED;};}}
稍微有点遗憾的是,由于ServiceLoader需要使用public修饰的桥梁接口,所以我们不能使用除了public以外的访问修饰符。也就是说,如果应用程序加载了这个Java包,它就可以直接使用DigestManagerImpl类。这当然不是我们期望的使用办法。
我们并不希望应用程序直接使用DigestManagerImpl类,然而JDK 8之前的Java世界里,我们并没有简单有效的、强制性的封装办法。所以,我们的解决办法通常是对外宣称:“co.ivi.jus.impl”这个包是一个内部Java包,请不要直接使用。这需要应用程序的开发者仔细地阅读文档,分辨内部包和公开包。
但在Java 9之后的Java世界里,我们就可以使用Java模块来限制应用程序使用DigestManagerImpl类了。
下面我们来一起看看,Java模块是怎么实现这样的限制的。
首先呢,我们把公开接口的部分,也就是co.ivi.jus.crypto这个Java包封装到一个Java模块里。我们给这个模块命名为jus.crypto。Java模块的定义,使用的是module-info.java这个文件。这个文件要放在源代码的根目录下。下面的代码,就是我们封装公开接口的部分的module-info.java文件。
module jus.crypto {exports co.ivi.jus.crypto;uses co.ivi.jus.crypto.DigestManager;}
第一行代码里的“module”,就是模块化定义的关键字。紧接着module的就是要定义的模块的名字。在这个例子里,我们定义的是jus.crypto这个Java模块。
第二行代码里的“exports”, 说明了这个模块允许外部访问的API,也就是这个模块的公开接口。“模块的公开接口”,是一个Java模块带来的新概念。
没有Java模块的时候,除了内部接口,我们可以把public访问修饰符修饰的外部接口看作是公开的接口。这样的规则,需要我们去人工分辨内部接口和外部接口。
但有了Java模块之后我们就知道,使用了“exports”模块定义、并且使用了public访问修饰符修饰的接口,就是公开接口。这样,公开接口就有了清晰的定义,我们就不用再去人工分辨内部接口和外部接口了。
而第四行代码里的“uses”呢,则说明这个模块直接使用了DigestManager定义的服务接口。
你看,这么简短的五行代码,就把co.ivi.jus.crypto这个Java模块化了。它定义了公开接口以及要使用的服务接口。
然后呢,我们要把内部接口的部分,也就是co.ivi.jus.impl这个Java包也封装到一个Java模块里。下面的代码,就是我们封装内部接口部分的module-info.java文件。
module jus.crypto.impl {requires jus.crypto;provides co.ivi.jus.crypto.DigestManager with co.ivi.jus.impl.DigestManagerImpl;}
在这里,第一行代码定义了jus.crypto.impl这个Java模块。
第二行代码里的“requires”说明,这个模块需要使用jus.crypto这个模块。也就是说,定义了这个模块的依赖关系。有了这个明确定义的依赖关系,加载这个模块的时候,Java运行时就不再需要地毯式地搜索依赖关系了。
第四行代码里的“provides”说明,这个模块实现了DigestManager定义的服务接口。同样的,有了这个明确的定义,服务接口实现的搜索,也不需要地毯式地排查了。
需要注意的是,这个模块并没有使用“exports”定义模块的公开接口。这也就意味着,虽然在co.ivi.jus.impl这个Java包里,有使用public访问修饰符修饰的接口,它们也不能被模块外部的应用程序访问。这样,我们就不用担心应用程序直接访问DigestManagerImpl类了。取而代之的,应用程序只能通过DigestManager这个公开的接口,间接地访问这个实现类。这是我们想要的封装效果。
有了公开接口和实现,我们再来看看该怎么模块化应用程序。下面的代码,是我们使用了Digest公开接口的一个小应用程序。
package co.ivi.jus.use;import co.ivi.jus.crypto.Digest;import co.ivi.jus.crypto.Returned;public class UseCase {public static void main(String[] args) {Returned<Digest> rt = Digest.of("SHA-256");switch (rt) {case Returned.ReturnValue rv -> {Digest d = (Digest) rv.returnValue();d.digest("Hello, world!".getBytes());}case Returned.ErrorCode ec ->System.getLogger("co.ivi.jus.stack.union").log(System.Logger.Level.INFO,"Failed to get instance of SHA-256");}}}
下面的代码,就是我们封装这个应用程序的module-info.java文件。
module jus.crypto.use {requires jus.crypto;}
在这里,第一行代码定义了jus.crypto.use这个Java模块。
第二行代码里的“requires”, 说明这个模块需要使用jus.crypto这个模块。
需要注意的是,这个模块并没有使用“exports”定义模块的公开接口。那么,我们该怎么运行UseCase这个类的main方法呢?其实,和传统的Java代码相比,模块的编译和运行有着自己的特色。
在javac和java命令行里,我们可以使用“–module-path”指定java模块的搜索路径。在Jar命令行里,我们可以使用“–main-class”指定这个Jar文件的main函数所在的类。在Java命令里,我们可以使用“–module”指定main函数所在的模块。
有了这些选项的配合,在上面的例子里,我们就不需要把UseCase在模块里定义成公开类了。我们来看看这些选项是怎么使用的。
$ cd jus.crypto$ javac --enable-preview --release 17 \-d classes src/main/java/co/ivi/jus/crypto/* \src/main/java/module-info.java$ jar --create --file ../jars/jus.crypto.jar -C classes .$ cd ../jus.crypto.impl$ javac --enable-preview --release 17 \--module-path ../jars -d classes \src/main/java/co/ivi/jus/impl/* \src/main/java/module-info.java$ jar --create --file ../jars/jus.crypto.impl.jar -C classes .$ cd ../jus.crypto.use$ javac --enable-preview --release 17 \--module-path ../jars -d classes \src/main/java/co/ivi/jus/use/* \src/main/java/module-info.java$ jar --create --file ../jars/jus.crypto.use.jar \--main-class co.ivi.jus.use.UseCase \-C classes .$ java --enable-preview --module-path ../jars --module jus.crypto.use
我在专栏里不会讲解这些选项的细节。具体的用法,我更希望你去找第一手的资料。下面的这个备忘单是我看到的一个比较好的总结。你可以打印下来备用,用熟了之后再丢掉。
好,到这里,我来做个小结。前面,我们讨论了怎么使用Java模块封装我们的代码,了解了module-info.java文件以及它的结构和关键字。
总体来看,Java模块的使用是简单、直观的。Java模块的使用,实现了更好的封装,也定义了模块和Java包之间的依赖关系。有了依赖关系,Java语言就能够实现更快的类检索和类加载了。这样的性能提升,通过模块化就能实现,还不需要更改代码。
如果面试的时候,讨论到了Java平台模块系统,你可以聊一聊Java模块封装的关键字,以及这些关键字能够起到的作用。我相信,这是一个有意思、有深度的话题。
在前面的讨论中,我们把DigestManager定义成了公开接口。我们希望Digest的实现可以使用这个桥梁接口,但是我们又不希望应用程序直接使用它。取而代之的,应用程序应该使用Digest.of方法获得算法的实现。从这个意义上说,我们前面的案例,并没有做好封装。
那么,有没有更好的办法,把DigestManager也封装起来,让应用程序无法调用呢?这是我们这一次、也是最后一次的思考题。
欢迎你在留言区留言、讨论,分享你的阅读体验以及你的改进。
注:本文使用的完整代码可以从GitHub下载。