甲方扔给两个存在包名与类名均相同的Jar包,要在工程中同时使用怎么办?

你的项目是否曾遇到过有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 下的字段不完全一致(图中未体现出这一点)
  • 入口类既包含相同的签名方法,也包含不同签名的方法

conflicting-jar-illustration

而这两个 jar 包都必须保留,并且需要在工程中同时使用。因为冲突的那些类中所包含的方法,不完全一样,都要保留和使用。

根据类加载规则,同名的类,只会加载一次,因此,如果 1.0.0 包中的 SampleApi 被加载,则 1.3.13 包中的 SampleApi 不会被加载。可我们要在业务代码时同时使用这两个版本的 SampleApi,要如何完成呢?

解决方案1:微服务隔离

最简单的办法就是将两个版本的Jar包,做成两个独立的微服务,然后再将使用到的接口或方法,包装成 Http 服务,业务代码调用这些服务即可。

以 1.0.0 包中的 SampleApi 为例,整个包装过程要做的事项如下(以 SpringWeb 为例):

  1. 新建一个Web工程
    它依赖 1.0.0 这个jar包。并且最终将部署为一个Web服务,这样业务工程便可以调用它所提供的 http 服务

  2. 编写 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 中,该模块做了两件事:

  1. 提供一个自定义的类加载器,用于从磁盘指定目录中加载 Class
  2. 在主程中加载 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个主要步骤:

  1. 自定义类加载器继承java.lang.ClassLoader, 或其子类

  2. 读取所加载类的字节码 <重点>

    根据类的包名和类名找到编译后的 class 字节码所在的地方,可能是在磁盘上,也可能就位于网络上,还可能位于内存的其它位置。把这些字节码读取到一个 byte[] 数组中

  3. 调用父类的 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和实例,在代码书写上存在以下不便:

  • 不能直接 new 或调用静态代码的方式创建类的实例(需要通过反射,如第3步)
  • 不能通过 obj.xxx() 的方式调用实例的方法(需要通过反射,如第4步)
  • 不能直接获取对象的方法或属性(需要通过反射,如第5步)

总之, 一切都需要以反射的方式来编码,这太糟糕了,不仅代码很冗长(如第5步),而且非常不易阅读。

无法忍受反射式编程代码

中间层接口方案

方案原理

看来起完美的类加载器隔离方案,却为业务代码的书写带来了麻烦,难道就没有好的解决办法了吗?办法还真有,只是需要提前付出一些额外工作,但这是值得的。这个方案为:定义一套中间层API,为两个Jar包中冲突的方法分别定义不同的上层接口,业务代码直接引入和使用这个中间层API中的方法即可。该方案要能执行起来,还需要为两个 jar 包分别单独编写中间层API的实现代码。总结起来,需要完成以下几步:

  1. 定义中间层接口

  2. 分别为两个有冲突的JAR包编写中间层接口的实现代码

  3. 在业务代码中直接使用中间层接口类来编写代码

    这听上去像是废话,这里解释一下,所谓直接使用中间层接口编写代码,隐去了以下细节:

    • 中间层接口类位于业务代码的 classpath 中,它与业务代码使用相同的类加载器,因此业务代码中使用到中间层接口的地方,不需要通过反射来调用,而是最自然最朴实的书写形式。

    • 两个有冲突 Jar 包的中间层实现代码,需要通过独立的类加载器加载,与 类加载器编程体验 章节中的处理方式一致。

    • 同理,最底层的两个原始 jar 包,也需要通过独立的类加载器加载

至此为止,大概你也没明白这是个啥方案 😁,它到底是个什么原理?在进一步解释之前,我们先明确一件事,即该方案要解决什么问题。该方案要解决以下两个问题:

  1. 同时加载两个相互冲突的Jar中的Class issue-α

  2. 能够像书写普通代码那样,在业务代码中访问两个 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 程序中也会使用到该工程的定义的接口,如下工程的结构如下:

load-classes-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 包中的代码

third-provider-jar1 的 SampleApi
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;
    }
}
third-provider-jar1 的 SampleDevice
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
}
third-provider-jar1 的 InterfaceConfigurstion
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 包中的代码

third-provider-jar2 的 SampleApi
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;
    }
}
third-provider-jar2 的 SampleDevice
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
}
third-provider-jar2 的 SocketSlot
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);
    }
}

third-provider-jar2 的 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 , 可理解设备升级了,将之前的接口分成了多个接口槽,一个槽内包含多个接口(插口)。因此,SampleApi#checkDevice 的内部实现代码也完全不同。

为解决 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包下的两个类

DeviceFactoryV1
public interface DeviceFactoryV1 {

    /**
     * v1 版本的获取设备信息
     * @param deviceNo 设备编号
     */
    DeviceBasicInfoV1 getDeviceInfo(String deviceNo);
    
}
DeviceBasicInfoV1
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,都支持插件化特性,并且还支持热插拔,他们也面临不同插件中类名冲突的问题,解决方案依然是使用类加载器进行隔离。

小结

  1. 类加载器可以解决类名冲突的问题

  2. 类加载器带来的代码书写问题,可以通过引入中间层的方式解决

  3. 类加载器在业务开发几乎不会用到,若遇到了,也应该通过引入中间层的方式,在业务代码中隐藏这一细节

  4. 类加载器广泛应用在容器、框架和IDE中

  5. 示例工程源码

posted @ 2024-08-26 09:13  顾志兵  阅读(4431)  评论(51编辑  收藏  举报