你的项目是否曾遇到过有jar包冲突,而这些冲突的jar包又必须同时存在的情况?一般来说,jar 冲突都是因不同的上层依赖项,自身又依赖了相同 jar 包的不同版本所致,解决办法也都是去除其中一个即可。需要同时保留冲突jar包的情况,实属罕见。
在与第三访系统集成通信时,有一种方式是由被集成方提供Jar包,业务代码调用Jar包里提供的相关Java类或接口,并且很多都同时附带一份集成开发文档。
如果第三方在不同时期提供的jar包,相互存在冲突,而工程中又必须同时使用这两个 jar 包,该怎么办呢?
冲突场景
如下图所示,有两个 jar 包,分别是:third-provider-1.0.0.jar 和 third-provider-1.3.13.jar。两个包的异同点如下:
- 相同的 package : guzb.cnblogs.classloader.third
- 相同的类名 : SampleApi.java
- 相同的方法签名(含返回值):public SampleDevice checkDevice(String deviceNo), 返回值类型 SampleDevice 也是包名与类名完全相同
- 返回值类型的结构不同:两个Jar 包的 checkDevice 方法都返回 SampleDevice,但各自 SampleDevice 下的字段不完全一致(图中未体现出这一点)
- 入口类既包含相同的签名方法,也包含不同签名的方法
而这两个 jar 包都必须保留,并且需要在工程中同时使用。因为冲突的那些类中所包含的方法,不完全一样,都要保留和使用。
根据类加载规则,同名的类,只会加载一次,因此,如果 1.0.0 包中的 SampleApi 被加载,则 1.3.13 包中的 SampleApi 不会被加载。可我们要在业务代码时同时使用这两个版本的 SampleApi,要如何完成呢?
解决方案1:微服务隔离
最简单的办法就是将两个版本的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)微服务非常流行,也不在此啰嗦该怎么做了。重点阐述一下第二种实现方式。
解决方案2:类加载器隔离
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 中,该模块做了两件事:
- 提供一个自定义的类加载器,用于从磁盘指定目录中加载 Class
- 在主程中加载 book-sample 中的类,并调用 BookApi 中的方法,进一步访问方法返回值中的属性
自定义类载器(点击查看代码)
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 [email protected]
* @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和实例,在代码书写上存在以下不便:
- 不能直接 new 或调用静态代码的方式创建类的实例(需要通过反射,如第3步)
- 不能通过 obj.xxx() 的方式调用实例的方法(需要通过反射,如第4步)
- 不能直接获取对象的方法或属性(需要通过反射,如第5步)
总之, 一切都需要以反射的方式来编码,这太糟糕了,不仅代码很冗长(如第5步),而且非常不易阅读。
中间层接口方案
方案原理
看来起完美的类加载器隔离方案,却为业务代码的书写带来了麻烦,难道就没有好的解决办法了吗?办法还真有,只是需要提前付出一些额外工作,但这是值得的。这个方案为:定义一套中间层API,为两个Jar包中冲突的方法分别定义不同的上层接口,业务代码直接引入和使用这个中间层API中的方法即可。该方案要能执行起来,还需要为两个 jar 包分别单独编写中间层API的实现代码。总结起来,需要完成以下几步:
-
定义中间层接口
-
分别为两个有冲突的JAR包编写中间层接口的实现代码
-
在业务代码中直接使用中间层接口类来编写代码
这听上去像是废话,这里解释一下,所谓直接使用中间层接口编写代码,隐去了以下细节:
-
中间层接口类位于业务代码的 classpath 中,它与业务代码使用相同的类加载器,因此业务代码中使用到中间层接口的地方,不需要通过反射来调用,而是最自然最朴实的书写形式。
-
两个有冲突 Jar 包的中间层实现代码,需要通过独立的类加载器加载,与
类加载器编程体验
章节中的处理方式一致。 -
同理,最底层的两个原始 jar 包,也需要通过独立的类加载器加载
-
至此为止,大概你也没明白这是个啥方案
标签:包名,String,third,Jar,代码,jar,provider,类名,加载 From: https://www.cnblogs.com/guzb/p/18379639/load-contains-same-name-classes-jars-by-classloade