你的项目是否曾遇到过有jar包冲突,而这些冲突的jar包又必须同时存在的情况?一般来说,jar 冲突都是因不同的上层依赖项,自身又依赖了相同 jar 包的不同版本所致,解决办法也都是去除其中一个即可。需要同时保留冲突jar包的情况,实属罕见。
在与第三访系统集成通信时,有一种方式是由被集成方提供Jar包,业务代码调用Jar包里提供的相关Java类或接口,并且很多都同时附带一份集成开发文档。
如果第三方在不同时期提供的jar包,相互存在冲突,而工程中又必须同时使用这两个 jar 包,该怎么办呢?
如下图所示,有两个 jar 包,分别是:third-provider-1.0.0.jar 和 third-provider-1.3.13.jar。两个包的异同点如下:
而这两个 jar 包都必须保留,并且需要在工程中同时使用。因为冲突的那些类中所包含的方法,不完全一样,都要保留和使用。
根据类加载规则,同名的类,只会加载一次,因此,如果 1.0.0 包中的 SampleApi 被加载,则 1.3.13 包中的 SampleApi 不会被加载。可我们要在业务代码时同时使用这两个版本的 SampleApi,要如何完成呢?
最简单的办法就是将两个版本的Jar包,做成两个独立的微服务,然后再将使用到的接口或方法,包装成 Http 服务,业务代码调用这些服务即可。
以 1.0.0 包中的 SampleApi 为例,整个包装过程要做的事项如下(以 SpringWeb 为例):
新建一个Web工程
它依赖 1.0.0 这个jar包。并且最终将部署为一个Web服务,这样业务工程便可以调用它所提供的 http 服务
编写 Web Controller
将 SampleApi 中相应的方法,通过 WebController 包装为 Http服务
根据需要,还可以将 SampleDevice 这个返回类也包装成一个新类。不过这一步可以省略,由调用方法代码自己去编写也可以。因为业务工程与此 Web 服务是通过 http 交换信息的(通常都是 json 串)。
由于当前(2024-06-11)微服务非常流行,也不在此啰嗦该怎么做了。重点阐述一下第二种实现方式。
jar 包冲突是因同名的类只会被加载一次,但还有一个重要的细节:何为同名的类?一般而言,当两个类的 package name (包名) 与 class name (类名) 都相同时,即为同名类。不过还有一个隐藏的区分项,就是类加载器。
在程序运行期间,Java 的类是由类加载器载入到运行环境的。对于同一个类加载器来说,包名与类名相同的类只会被加载一个。但不同类加载,可以各自单独加载包名与类名均相同的类。
以前面图片中的冲突场景为例:假定业务代码中有两个类加载器,分别是 loaderA 和 loaderB,若 loaderA 加载了 1.0.0 包中的 guzb.cnblogs.classloader.third.SampleApi,则无法加载 1.3.13 中的 guzb.cnblogs.classloader.third.SampleApi,因为该类加载器已经加载过这个类了。但无论 loaderA 加载了什么,都不影响 loaderB 再去加载一次。也就是说,此时 loaderB 既可以加载 1.0.0 中 SampleApi,也可以加载 1.3.13 包中的 SampleApi,但不能同时加载。不难发现,我们可以通过 loaderA 加载 1.0.0 包的 SampleApi, 和 loaderB 加载 1.3.13 包的中 SampleApi 这种组合方案,实现同一工程中,同时加载这两个存在冲突的 jar 包中的所有类。
类加载器就像一个是沙盒,将两套代码予以隔离,它的这个特性正好用来解决本文的冲突场景。实事上,它在 Java 容器中应用得最多(如tomcat隔离不同的Web项目)。为了保证一些公共基础类(如jdk里rt.jar中的类)不要重复加载,类加载器还引入了双亲委托式的加载机制。关于类加载器本身的基础知识不是本文本的重点,读者请参阅其它相关网文。本文接下来将重点介绍如何应用类加载器,实现一个工程级的方案来更友好地解决前面提到的冲突场景。
如上图所示,或许不少读者觉得这篇文章到此可以结束了。因为类加载器隔离两个同包同名的类,原理上非常清晰,它一定是可行的。后续讲解不就是显得既多余又啰嗦了么。
OK,尽管通过不同类加载器确实可以解决类名冲突的问题,但同时却又引来了另外一个问题:编写代码时,无法像普通编程那样书写。这是什么意思呢,为了说清楚这个东西,我们先通过类加载器的方式来获取一个类,体验一下其编码的不便。
这里单独创建一个 maven 工程来简单来体验类加载器编程与普通编程在代码书写上的差异, 工程源码:classloader-experience,其结构如下:
┌─ classloader-experience
│ ├─ book-sample # 一个业务模块样例,该 module 下的代码将由单独的类加载器加载
│ │ └─ vip.guzb.clrdemo
│ │ ├─ BookApi # book 样例模块的使用入口类,独立类加载器也直接加载它
│ │ ├─ Book # 书籍类
│ │ └─ Press # 出版社类
│ │
│ └─ main # 主程序模块,book-sample 下的类将在 main 模块中加载
│ └─ vip.guzb.clrmain
│ ├─ MyClassLoader # 一个简单的自定义类加载器,用于从指定目录加载 Class
│ └─ ClassLoaderExperienceMain # 整个类加载器体验程序的主类(入口类)
└─ pom.xml
main 模块是主程序,而 book-sample 下的 class 将由 main 模块使用独立类加载器加载,因此,book-sample 模块下的 class 不能位于 main 模块启动时的 classpath 下,否则,根据 ClassLoader 的双亲委派模型,book-sample 的类加载器将会与 main 模块的类加载器是同一个,而不是我们单独编写的 MyClassLoader,也就达不到目的了。这里将将它们都写在同一个 maven 工程中,是为了方便在博客中展示所有代码。
下面是 book-sample 模块的代码
package vip.guzb.clrdemo;
public class BookApi{
public String description() {
return "Hi,你好,很高兴见到你。本内容是来自 BookApi 的 description 方法";
}
public Collection<Book> getBooksOfAuthor(String authorName) {
List<Book> books = new ArrayList<Book>();
books.add(new Book("TeaHouse", authorName, 135.0, new Press("四川人民出版社", "四川省成都市的一个犄角旮旯处")));
books.add(new Book("The Life of Mine", authorName, 211.0, new Press("长江文艺出版社", "大陆一个神秘的地方")));
return books;
}
}
public class Press {
private String name;
private String address;
public Press(String name, String address) {
this.name = name;
this.address = address;
}
// omit the getter and setter methods
}
public class Book {
private String name;
private String author;
private Double price;
private Press press;
public Book(String name, String author, Double price, Press press) {
this.name = name;
this.author = author;
this.price = price;
this.press = press;
}
// omit the getter and setter methods
......
}
主程序将会使用单独的类加载器加载 BookApi,并创建一个该类的实例,调用其 descritpion() 和 getBooksOfAuthor(String name) 方法,然后进一步操作方法的返回值。前者简单地返回一个 java.lang.String 对象, 后者则返回一个集合,集合元素类型为 vip.guzb.clrdemo.Book,Book 类还有一个 vip.guzb.clrdemo.Press 类型的成员字段,因此整个结构是比较复杂的。
OK,现在回到主模块 main 中,该模块做了两件事:
package vip.guzb.clrmain;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
public class MyClassloader extends ClassLoader {
// 要读取的编译后的 Class 在磁盘上的根目录
private String classRootDir;
public MyClassloader(String classRootDir) {
this.classRootDir = classRootDir;
}
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
// 读取Class的二进制数据
byte[] classBytes = getClassBytes (className);
return super.defineClass(className, classBytes, 0, classBytes.length);
}
private byte[] getClassBytes(String className) {
// 解析出class文件在磁盘上的绝对路径
String classFilePath = resolveClassFilePath(className);
// 将Class文件读取为二进制数组
ByteArrayOutputStream bytesReader;
try (InputStream is = new FileInputStream(classFilePath)) {
bytesReader = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int readSize = 0;
while ((readSize = is.read(buffer)) != -1) {
bytesReader.write(buffer, 0, readSize);
}
return bytesReader.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return new byte[0];
}
private String resolveClassFilePath(String className) {
return this.classRootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
}
}
如何编写自定义类加载不是本文的重点,这里简单说明一下,编写自定义类加载器的3个主要步骤:
自定义类加载器继承java.lang.ClassLoader, 或其子类
读取所加载类的字节码 <重点>
根据类的包名和类名找到编译后的 class 字节码所在的地方,可能是在磁盘上,也可能就位于网络上,还可能位于内存的其它位置。把这些字节码读取到一个 byte[] 数组中
调用父类的 defineClass 方法,完成类的加载
接下来就是整个 main 模块的重点了:加载前面提及的 vip.guzb.clrdmo 包下的 BookApi(加载它的过程,附带会把 Book 和 Press 也加载了),并调用 BookApi 类的 description 和 getBooksOfAuthor 方法,如下所示:
package vip.guzb.clrmain;
import java.lang.reflect.Method;
import java.util.Collection;
/**
* 类加载器体验主类
*
* @author 顾志兵
* @mail ipiger@163.com
* @since 2024-05-18
*/
public class ClassLoaderExperienceMain {
public static void main(String[] args) throws Exception {
// 1. 实例化一个自定义的类加载器
// book-sample 模块上的类所在根目录,请根据自己电脑的实际情况更改
MyClassloader myClassloader = new MyClassloader("D:\\tmp\\DemoClass");
// 2. 加载 BookApi 这个Class
Class bookApiClass = myClassloader.loadClass("vip.guzb.clrdemo.BookApi");
// 3. 创建 BookApiClass 的实例,
// 这里不直接写成 DemoA demoA = new DemoA(); 因为 DemoA 在类路径下不存在。
// 即使存在,根据本文本一开始的场景,也因为同时要加载同名的类,而不允许存在
Object bookApiObj = bookApiClass.newInstance();
// 4. 调用 BookApi 的 description() 方法
// 该方法很简单,返回类型为标准库中的 java.lang.String, 因此代码书写也相对容易
Method testAMethod = bookApiClass.getMethod("description");
String resultOfDescription = (String)testAMethod.invoke(bookApiObj);
System.out.printf("description()方法的调用结果: %s\n\n", resultOfDescription);
// 5. 调用 BookApi 的 getBooksOfAuthor 方法
// 该方法的返回值是一个集合,而集合中的对象在 Classpath 中不存在,
// 获取集合元素的属性和方法的代码将会显示很冗长
Method getBooksOfAuthorMethod = bookApiClass.getMethod("getBooksOfAuthor", String.class);
Collection<?> books = (Collection<?>) getBooksOfAuthorMethod.invoke(bookApiObj, "老舍");
System.out.println("老舍的作品列表: ");
for (Object book : books) {
// books 集合中的对象类型为 vip.guzb.clrdemo.Book,
// 但由于是使用单独的类加载器加载的,不能像平常编码那样直接在源码中书写,依然要通过反射来获取
Method bookNameMethod = book.getClass().getMethod("getName");
Method bookPriceMethod = book.getClass().getMethod("getPrice");
String bookName = (String)bookNameMethod.invoke(book);
Double price = (Double) bookPriceMethod.invoke(book);
// 同理, vip.guzb.clrdemo.Press 对象的访问也需要通过反射
Method pressMethod = book.getClass().getMethod("getPress");
Object pressObj = pressMethod.invoke(book);
Method pressNameMethod = pressObj.getClass().getMethod("getName");
Method pressAddressMethod = pressObj.getClass().getMethod("getAddress");
String pressName = (String)pressNameMethod.invoke(pressObj);
String pressAddress = (String)pressAddressMethod.invoke(pressObj);
System.out.printf(" · 书名: 《%s》, 价格: %.2f, 出版社: %s, 地址: %s\n", bookName, price, pressName, pressAddress);
}
}
}
测试代码的第1步就创建了一个 MyClassLoader,该类加载器将会从 D:\tmp\DemoClass 中加载 BookApi。因此,需要将 book-sample 模块下编译后的所有 Class 按 package 的层次复制到 D:\tmp\DemoClass 目录中。
输出结果为:
description()方法的调用结果: Hi,你好,很高兴见到你。本内容是来自 BookApi 的 description 方法
老舍的作品列表:
· 书名: 《TeaHouse》, 价格: 135.00, 出版社: 四川人民出版社, 地址: 四川省成都市的一个犄角旮旯处
· 书名: 《The Life of Mine》, 价格: 211.00, 出版社: 长江文艺出版社, 地址: 大陆一个神秘的地方
从上面的代码可以看出,要访问通过独立类加载器加载的Class和实例,在代码书写上存在以下不便:
总之, 一切都需要以反射的方式来编码,这太糟糕了,不仅代码很冗长(如第5步),而且非常不易阅读。
看来起完美的类加载器隔离方案,却为业务代码的书写带来了麻烦,难道就没有好的解决办法了吗?办法还真有,只是需要提前付出一些额外工作,但这是值得的。这个方案为:定义一套中间层API,为两个Jar包中冲突的方法分别定义不同的上层接口,业务代码直接引入和使用这个中间层API中的方法即可。该方案要能执行起来,还需要为两个 jar 包分别单独编写中间层API的实现代码。总结起来,需要完成以下几步:
定义中间层接口
分别为两个有冲突的JAR包编写中间层接口的实现代码
在业务代码中直接使用中间层接口类来编写代码
这听上去像是废话,这里解释一下,所谓直接使用中间层接口编写代码,隐去了以下细节:
中间层接口类位于业务代码的 classpath 中,它与业务代码使用相同的类加载器,因此业务代码中使用到中间层接口的地方,不需要通过反射来调用,而是最自然最朴实的书写形式。
两个有冲突 Jar 包的中间层实现代码,需要通过独立的类加载器加载,与 类加载器编程体验
章节中的处理方式一致。
同理,最底层的两个原始 jar 包,也需要通过独立的类加载器加载
至此为止,大概你也没明白这是个啥方案 😁,它到底是个什么原理?在进一步解释之前,我们先明确一件事,即该方案要解决什么问题。该方案要解决以下两个问题:
同时加载两个相互冲突的Jar中的Class issue-α
能够像书写普通代码那样,在业务代码中访问两个 jar 包中提供的功能, 而不是通过反射的方式来访问 issue-β
现在我们回到原点:看看最初项目是什么样子的。
如上图所示,最初的问题是业务代码需要同时使用两个第3方 jar 包中完全同名但内部功能又不一样的类。通过类加载器隔离后,可以在同一工程中同时加载这两个名字冲突的类,但在业务代码书写上又非常不便。
也就是说,issue-α 已经解决了,现在聚焦如何解决 issue-β。类加载器就是一个沙箱,在同一个沙箱里的Class,相互访问对方的方法和属性时,其代码书写就是最简单最自然的方式。但如果 α 沙箱里 A 类的 a() 方法,要访问 β 沙箱里 B 类的 b() 方法,则无法以 B.b() 的方式直接调用,因为 classpath 中没有 B 这个类,如果强行将 β 沙箱中的 Class(假定都位于 β.jar 中)加入到 classpth, 则会导致编译失败。因为在我们的场景里,存在 β1.jar 和 β2.jar 两个沙箱,编译器加载了 β1.jar 中的 B 类,就不会再加载 β2.jar 中的 B 类。因为此时 β1.jar 和 β2.jar 中的class 都位于 classpath 中,会被同一个类加载器加载。
要在业务代码中以普通代码书写的方式,同时使用 β1.jar 中的 B 类和 β2.jar 中的 B 类,我们可以引入一个中间层(假定叫 δ.jar),在这个中间层里,对 β1.jar 和 β2.jar 中有冲突的部分,都单独编写一套名称上不再冲突的接口即可。比如:
β1.jar 中 B 类的 b() 方法定义为:
public int b(){ return 9929; }
β2.jar 中 B 类的 b() 方法定义为:
public String b(String title){return "Hello " + title; }
则可以在 δ.jar 中定义如下接口类:
public interface Api {
// 映射到 β1.jar 中的 b() 方法
public int b1();
// 映射到 β2.jar 中的 b(String title) 方法
public String b2();
}
这样一来,业务代码 α.jar 不用直接访问 β1.jar 和 β2.jar 中的方法了,而是通过调用 δ.jar 中的 API 类中的相应方法即可。因为 δ.jar 没有类名冲突,通过不同的方法名区分开了,因此将 δ.jar 加入到业务代码工程的 classpath 中,就可以像 int xxx = Api.b1();
和 String yyy = Api.b2();
这样书写代码了。
现在还剩下最后一个细节:δ.jar 中的 API 这个接口在哪里实现的呢?要完成 Api#b1() 最终调用 β1.jar.B#b() 方法,Api#b2() 最终调用 β2.jar.B#b() 方法这一目标,还需要再引入两个 jar 包: β1-impl.jar 和 β2-impl.jar。β1-impl.jar 用于实现 δ.jar.Api#b1() 方法,实现方式就是再转调 β1.jar.B#b() 方法。同理,β2-impl.jar 用于实现 δ.jar.Api.b2() 方法,实现方式为转调 β2.jar.B#b() 方法。
这里有些绕,看上去似乎是脱裤子放屁多此一举。关键是要搞清楚一点:为什么不将实现 Api#b1() 和 Api#b2() 这两个接口方法的Class,直接放置到 δ.jar 中呢?原因是 δ.jar 中的 Class 是要与业务代码 α.jar 中的 Class 使用同一个类加载器加载的,实现 Api#b1() 最终会调用 β1.jar.B#b(),实现 Api#b2() 最终会调用 β2.jar.B#b(),β1 和 β2 本身就是冲突的,因此他们不能出现在同一个类加载器中。
至此,整个方案涉及的组件项均已介绍完,如下图所示:
OK,现在我们换个视角,从类加载器的角度来观察上面提到的这些类,分别被哪些类加载器所加载。要启用的业务程序为 α.jar, 假定它的类加载器为 app-main-loader, 另外,在 α.jar 的代码中,会创建两个类加载器,third-jar1-loader 和 third-jar2-loader,分别用于加载 β1.jar 和 β2.jar(这是最原始的需求,用于解决 issue-α :隔离冲突的类)。以上图中涉及的程序包为例,类加载器与它所加载的程序包间的关系如下表所示:
程序包 | 类加载器 | 用途 |
---|---|---|
α.jar | app-main-loader | 业务主程序 |
δ.jar | app-main-loader | 中间层接口,胜于定义 β1.jar 与 β2.jar 中功能抽象,解决类名冲突 |
β1-impl.jar | third-jar1-loader | 实现 δ.jar 中与 β1 相关的接口方法 |
β1.jar | third-jar1-loader | 第三方提供的原始程序包1 |
β2-impl.jar | third-jar2-loader | 实现 δ.jar 中与 β2 相关的接口方法 |
β2.jar | third-jar2-loader | 第三方提供的原始程序包2 |
是时候写一个较真实的样例,完整地验证一下这个方案了(点击下载源码)。与上面的原理阐述中所提到的程序包一样,这个方案实战的代码,也分成6个部分,每个部分都是一个单独的 maven 工程,如下所示:
工程名 | 用途 | 对应「方案原理」中的程序包 |
---|---|---|
load-classes-main | 业务主程序 | α.jar |
third-provider-api | 中间层接口,封装两个第三方程序包提供的能力 | δ.jar |
third-provider-jar1 | 第1个三方程序包 | β1.jar |
third-provider-jar1-wrapper | 第1个三方程序包的功能包装器,它将实现 third-provider-api 中与第1个三方程序包相关的接口 | β1-impl.jar |
third-provider-jar2 | 第2个三方程序包 | β2.jar |
third-provider-jar2-wrapper | 第2个三方程序包的功能包装器,它将实现 third-provider-api 中与第2个三方程序包相关的接口 | β2-impl.jar |
loader-classes-main 工程为整个实战项目的入口,它仅包含两个Class,分别是加载 jar 包的类加载器和 Main 程序。但工程的会引入对 third-provider-api 工程的依赖,Main 程序中也会使用到该工程的定义的接口,如下工程的结构如下:
可以看到,两个三方Jar包以及它们的包装器被放置在了 resources/third-lib 目录下,该目录下的 Jar 包是不会被主程序的启动类加载器所加载的,它们会由 Main 程序的代码手动加载。主程序代码如下:
package guzb.cnblogs.classloader;
import guzb.cnblogs.classloader.thirdapi.v1.DeviceBasicInfoV1;
import guzb.cnblogs.classloader.thirdapi.v1.DeviceFactoryV1;
import guzb.cnblogs.classloader.thirdapi.v2.DeviceBasicInfoV2;
import guzb.cnblogs.classloader.thirdapi.v2.DeviceFactoryV2;
public class LoadNameConflictClassesAppMain {
public static void main(String[] args) throws Exception {
// 调用第三方接口的第一版本(jar1, 被包装成了 jar1wrapper)
String third1WrapperJarPath = "/third-lib/third-provider-jar1-wrapper-1.0.0.jar";
ClasspathJarLoader third1Classloader = new ClasspathJarLoader(third1WrapperJarPath);
Class third1DeviceFactoryClass =
third1Classloader.loadClass("guzb.cnblogs.classloader.third1wrapper.DeviceFactoryV1Impl");
DeviceFactoryV1 deviceFactoryV1 = (DeviceFactoryV1)third1DeviceFactoryClass.newInstance(); ⑴
 ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
DeviceBasicInfoV1 device1 = deviceFactoryV1.getDeviceInfo("BN8964");
System.out.println("第三方接口v1版本的调用结果:");
System.out.println(device1.toString());
System.out.println();
// 调用第三方接口的第二版本(jar2, 被包装成了 jar2wrapper)
String third2WrapperJarPath = "/third-lib/third-provider-jar2-wrapper-1.0.0.jar";
ClasspathJarLoader third2Classloader = new ClasspathJarLoader(third2WrapperJarPath);
Class third2DeviceFactoryClass =
third2Classloader.loadClass("guzb.cnblogs.classloader.third2wrapper.DeviceFactoryV2Impl");
DeviceFactoryV2 deviceFactoryV2 = (DeviceFactoryV2)third2DeviceFactoryClass.newInstance(); ⑵
 ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
DeviceBasicInfoV2 device2 = deviceFactoryV2.getDeviceInfo("BN8633");
System.out.println("第三方接口v2版本的调用结果:");
System.out.println(device2.toString());
}
}
请注意代码中的 ⑴ 和 ⑵ 处,在它们的上一行,分别创建了一个类加载器,用于加载 third-provider-api 接口包的实现类,DeviceFactoryV1 和 DeviceFactoryV2 就是在 third-provider-api 中定义的,这两个接口分别用于对 third-provider-jar1 和 third-provider-jar2 的二次定义(解决名称冲突),⑴ ⑵ 处获得了它们的实现类实例,之后的代码书写,就不用再通过冗长难懂的反射API来完成了,也就解决了 issue-β 。
上述代理已经隐藏了原始的类名冲突,符合实际场景(不应该在业务代理里来处理此类纯技术问题)。本实战代码模拟的是一个设备检查服务,在调用 checkDevice 方法时,通过指定设备编号,便可检查该设备的状态。这个服务在 third-provider-jar1 和 third-provider-jar2 中,均位于 SampleApi 类中,具体的服务方法签名均为:SampleDevice checkDevice(String deviceNo)。但两个Jar包中,SampleDevice 的内部字段不尽相同,返回的信息量差异较大,这便是我们模拟的冲突场景。
下面是第一个 Jar 包中的代码
package guzb.cnblogs.classloader.third;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
// SampleApi 为整个 jar1 对处暴露的服务类
public class SampleApi {
/**
* 模块的一个对外服务方法:检查设备信息
* @param deviceNo 设备编号
* @rerun 将返回设备信息,设备信息是一个复合对象,拥有众多的字段
*/
public SampleDevice checkDevice(String deviceNo) {
SampleDevice device = new SampleDevice();
device.setSid("GUWD5320P001");
device.setManager("张三");
device.setStatus(DeviceStatus.RENT_OUT);
InterfaceConfiguration interfaceConfig = new InterfaceConfiguration();
interfaceConfig.setPowerPortCount(2);
interfaceConfig.setMainWaveformPortCount(2);
interfaceConfig.setMonitorPortCount(8);
device.setInterfaceConfig(interfaceConfig);
LocalDateTime localDateTime = LocalDateTime.of(2019, 3, 3, 15, 32, 28);
device.setInboundDate(Date.from(localDateTime.atZone(ZoneId.of("Asia/Shanghai")).toInstant()));
return device;
}
}
package guzb.cnblogs.classloader.third;
import java.util.Date;
public class SampleDevice {
/** 设备串号(唯一) Serial Identify Number */
private String sid;
/** 设备状态 */
private DeviceStatus status;
/** 设备接口配置 */
private InterfaceConfiguration interfaceConfig;
/** 设备入库日期 */
private Date inboundDate;
/** 负责人 */
private String manager;
// Omit getter and setter methods
}
public class InterfaceConfiguration {
//电源接口数量
private int powerPortCount = 1;
// 状态指示灯接口数量
private int stateSignalPortCount = 1;
// 主波形输出接口数量
private int mainWaveformPortCount = 1;
// 基准输出频率
private int baseFrequency = 1600;
// 监视输入接口数量
private int monitorPortCount = 4;
// Omit getter and setter methods
可以看到,SampleApi#checkDevice(String deviceNo) 返回的 SampleDevice 包含多个字段,其中还有一个复杂对象字段 interfaceConfig,表示检查设备上的接口配置,该对象本身又包含多个字段。
下面是第二个 Jar 包中的代码
package guzb.cnblogs.classloader.third;
import java.util.ArrayList;
import java.util.List;
public class SampleApi {
public SampleDevice checkDevice(String deviceNo) {
SampleDevice device = new SampleDevice();
device.setSid("GUWD5320P001");
device.setRegion("西南");
device.setStatus(DeviceStatus.RUNNING);
device.setUsage("市排水给水流量监测");
List<SocketSlot> slots = new ArrayList<>();
SocketSlot slot = new SocketSlot(1, 4);
slot.connect(new Socket(1, SocketType.POWER, "EBS-9527"));
slots.add(slot);
slot = new SocketSlot(2, 6);
slot.connect(new Socket(1, SocketType.CONTROL, "CTR-0709"));
slot.connect(new Socket(2, SocketType.CONTROL, "CTR-0310"));
slot.connect(new Socket(3, SocketType.MAIN_WAVEFORM, "WVE-15218"));
slots.add(slot);
slot = new SocketSlot(3, 12);
slot.connect(new Socket(1,SocketType.MONITOR, "MTR-709817"));
slot.connect(new Socket(2,SocketType.MONITOR, "MTR-3572"));
slot.connect(new Socket(3,SocketType.MONITOR, "MTR-709817"));
slot.connect(new Socket(4,SocketType.MONITOR, "MTR-709817"));
slot.connect(new Socket(5,SocketType.MONITOR, "MTR-709817"));
slot.connect(new Socket(6,SocketType.MONITOR, "MTR-709817"));
slots.add(slot);
device.setSocketSlots(slots);
return device;
}
}
package guzb.cnblogs.classloader.third;
import java.util.List;
public class SampleDevice {
/** 设备串号(唯一) Serial Identify Number */
private String sid;
/** 设备状态 */
private DeviceStatus status;
/** 接口槽位列表 */
private List<SocketSlot> socketSlots;
/**
* 所属区域,如: 深圳
*/
private String region;
// omit getter and setter mthods
}
package guzb.cnblogs.classloader.third;
import java.util.ArrayList;
import java.util.List;
/**
* 物理接口槽,一个接口槽中安装有多个接口
*/
public class SocketSlot {
/** 接口槽的编号 */
private int number;
/** 接口支持的接口数量 */
private int socketCount;
/** 已连接的接口列表 */
private List<Socket> connectedSockets = new ArrayList<>();
public SocketSlot(int number, int socketCount) {
this.number = number;
this.socketCount = socketCount;
}
public void connect(Socket socket) {
if (this.connectedSockets.size() >= socketCount) {
System.out.println("接口已全部占用");
return;
}
connectedSockets.add(socket);
}
}
package guzb.cnblogs.classloader.third;
public class Socket {
/** 插口编号 */
private int number;
/** 插口类型 */
private SocketType type;
/** 接口规格 */
private String specification;
public Socket(int number, SocketType type, String specification) {
this.number = number;
this.type = type;
this.specification = specification;
}
// omit getter and setter methods
}
jar2 的 SampleApi#checkDevice(String deviceNo) 返回的 SampleDevice 与 jar1 包中的 SampleDevice 字段差异巨大,字段完全不同,返回的设备接口信息是一个 List
为解决 jar1 和 jar2 中的冲突,引入了 third-provider-api, 解决办法为: 分别提供两个不同的接口,每个接口各自返回自己的 SampleDevice。这样在业务代码中就可以使用这些没有名称冲突的类了。 下面是 thrid-provider-api 工程的结构:
third-provider-api
├─ src/main/java/guzb/cnblogs/classloader/thirdapi
│ ├─ v1
│ │ ├─ DeviceFactoryV1 # 对 third-provider-jar1 中 SampleService#checkDevice 方法的封装
│ │ └─ DeviceBasicInfoV1 # 对 third-provider-jar1 中 SampeDevice 类的封装
│ └─ v2
│ ├─ DeviceFactoryV1 # 对 third-provider-jar2 中 SampleService#checkDevice 方法的封装
│ └─ DeviceBasicInfoV1 # 对 third-provider-jar2 中 SampeDevice 类的封装
└─ pom.xml
其实 third-provider-api 只是将两个jar包中,冲突类的内容复制了一份,换成了两个名称不同的类,然后就可以在业务代码中使用了。由于代码十分简单,下面仅列出v1包下的两个类
public interface DeviceFactoryV1 {
/**
* v1 版本的获取设备信息
* @param deviceNo 设备编号
*/
DeviceBasicInfoV1 getDeviceInfo(String deviceNo);
}
public class DeviceBasicInfoV1 {
/** 设备编号 */
private String deviceNo;
/** 设备状态 */
private String status;
/** 设备接头数量 */
private int socketCount;
/** 设备入库日期 */
private Date inboundDate;
/** 负责人 */
private String manager;
@Override
public String toString() {
return "DeviceBasicInfoV1 {\n" +
" deviceNo='" + deviceNo + "',\n" +
" status='" + status + "',\n" +
" socketCount=" + socketCount + ",\n" +
" inboundDate=" + inboundDate + ",\n" +
" manager='" + manager + "'\n" +
'}';
}
}
third-provider-jar1-wrapper 和 third-provider-jar1-wrapper 的代码也非常简单了,它们分别实现 third-provider-api 中的 DeviceFactoryV1 接口和 DeviceFactoryV2 接口,实现方式就是先调用原始的 third-provider-jar1 和 third-provider-jar2 中的相应服务方法,然后将返回值对象转化为 DeviceBasicInfoV1 和 DeviceBasicInfoV2。这里就不再帖出代码了,请下载 class-loader-in-action 的完整源码查看。
经过上面一番实战,相信你已经对类加载器有了真直观的感受。一般而言,日常开发是不会涉及到类加载器的。但如果真涉及到了的话,仅仅做到上面的程度,还是不够的,因为业务代码不应该涉及到类加载器的任何内容,即: 业务代码应该完全感知不到类加载器的存在。上述代码中 LoadNameConflictClassesAppMain 的 ⑴ 和 ⑵ 处的前两行,均明确使用了类加载器来加载 DeviceFactoryV1 和 DeviceFactoryV2 的实现类。如何让业务代码中完全不出现类加载器呢?如果我们把 LoadNameConflictClassesAppMain 中初始化 DeviceFactoryV1 和 DeviceFactoryV2 的实现类的过程包装起来,做成一个单独的Jar包,再在这个 Jar 包中提供直接获得这些实现类的快捷方法,供上层业务代码使用,就可以达到目的了。
其实上面这个方案,就是把整个 load-classes-main 当作是处理类冲突的解决方案包,去掉了其中的业务测试代码,再添加了一个获取 DeviceFactoryV1 和 DeviceFactoryV2 实例的工具类。此时,load-classes-main 就摇身一变,由业务测试程序,变成解决方案 jar 包了。这里就不再帖源码了,需要注意的是:作为一个工程级解决方案,还需要处理好 maven 的打包和私服管理,不要像示例程序中那样,手动将 third-provider-jar1、third-provider-jar1-wrapper、third-provider-jar12、third-provider-jar2-wrapper 复制到 load-classes-main 工程的src/resources/third-lib 目录下(这一步应该由 maven 打包来完成)。
上面的一翻实战,代码量不大,所涉及的业务(设备检测)也是模拟的,看似一个平常的类加载器运用案例,实则揭示出了类加载器的强大能力。运用类加载器,可以实现许多重要的隔离构架,最经典的莫过于 Servlet 这套标准了。
Servlet 标准只定义了一组丰富的 web 操作接口,以及接口方法所返回的数据对象,具体实现由相应的容器(如 tomcat)完成,使用者只需要按照 Servlet 的标准接口书写业务代码即可,实际应用时,根据相应容器的要求,将业务代码总署到容器中就能运行了。我们已经这样做很长时间了,却从来没有思考过,如果在 tomcat 里部署的多个war包中,存在包名与类名都相同的类时,会不会导致这些应用启动失败,或是启动后行为异常。或许曾经想到过这个问题,只是觉得 tomcat 一定有办法解决,至于如何解决的,就没有再深入思考了。很显然,Servlet 容器就是通过类加载器来解决这个问题的。
同理,Eclipse 和 ItelliJ Idea 这两款著名的IDE,都支持插件化特性,并且还支持热插拔,他们也面临不同插件中类名冲突的问题,解决方案依然是使用类加载器进行隔离。
类加载器可以解决类名冲突的问题
类加载器带来的代码书写问题,可以通过引入中间层的方式解决
类加载器在业务开发几乎不会用到,若遇到了,也应该通过引入中间层的方式,在业务代码中隐藏这一细节
类加载器广泛应用在容器、框架和IDE中
示例工程源码