曹工說面試:當應用依賴jar包的A版本,中間件jar包依賴B版本,兩個版本不兼容,這還怎麼玩?
- 2020 年 7 月 6 日
- 筆記
- Java虛擬機, spring/boot源碼解析
背景
大一點的公司,可能有一些組,專門做中間件的;假設,某中間件小組,給你提供了一個jar包,你需要集成到你的應用里。假設,它依賴了一個日期類,版本是v1;我們應用也依賴了同名的一個日期類,版本是v2.
兩個版本的日期類,方法邏輯的實現,有一些差異。
舉個例子,中間件提供的jar包中,依賴如下工具包:
<dependency>
<groupId>com.example</groupId>
<artifactId>common-v1</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
該版本中,包含了com.example.date.util.CommonDateUtil這個類。
package com.example.date.util;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CommonDateUtil {
public static String format(String date) {
// 1
String s = date + "- v1";
log.info("v1 result:{}", s);
return s;
}
}
應用中,依賴如下jar包:
<dependency>
<groupId>com.example</groupId>
<artifactId>common-v2</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
該jar包中,包含同名的class,但裡面的方法實現不一樣:
@Slf4j
public class CommonDateUtil {
public static String format(String date) {
String s = date + "- v2";
log.info("v2 result:{}", s);
return s;
}
}
ok,那假設我們是一個spring boot應用,當中間件小組的哥們找到你,讓你集成,你可能就愉快地弄進去了;但是,有個問題時,你的jar包、和中間件小哥的jar包,都是放在fatjar的lib目錄的(我這裡解壓了,方便查看):
請問,最終載入的CommonDateUtil類,到底是common-v1的,還是commonv2中的呢?因為spring boot載入BOOT-INF/lib時,肯定都是同一個類載入器,同一個類載入器,對於一個包名和類名都相同的類,只會載入一次;那麼,載入了v1,就不可能再載入V2;反之亦然。
那這就出問題了。我們應用要用V2;中間件要用V1,水火不容啊,這可怎麼辦?
分析
首先,我們要重寫spring boot的啟動類,這是毋庸置疑的,啟動類是哪個呢?
為什麼要重寫這個?因為,我們問題分析里說了,當打成fat jar運行時,其結構如下:
[root@mini2 temp]# tree
.
├── BOOT-INF
│ ├── classes
│ │ ├── application.properties
│ │ ├── application.yml
│ │ └── com
│ │ └── example
│ │ └── demo
│ │ ├── CustomMiddleWareClassloader.class
│ │ └── OrderServiceApplication.class
│ └── lib
│ ├── classmate-1.4.0.jar
│ ├── common-v1-0.0.1-SNAPSHOT.jar
│ ├── common-v2-0.0.1-SNAPSHOT.jar
│ ├── hibernate-validator-6.0.17.Final.jar
│ ├── jackson-annotations-2.9.0.jar
│ ├── jackson-core-2.9.9.jar
│ ├── jackson-databind-2.9.9.jar
│ ├── jackson-datatype-jdk8-2.9.9.jar
│ ├── jackson-datatype-jsr310-2.9.9.jar
│ ├── jackson-module-parameter-names-2.9.9.jar
│ ├── javax.annotation-api-1.3.2.jar
│ ├── jboss-logging-3.3.2.Final.jar
│ ├── jul-to-slf4j-1.7.26.jar
│ ├── log4j-api-2.11.2.jar
│ ├── log4j-to-slf4j-2.11.2.jar
│ ├── logback-classic-1.2.3.jar
│ ├── logback-core-1.2.3.jar
│ ├── lombok-1.18.10.jar
│ ├── middle-ware-0.0.1-SNAPSHOT.jar
│ ├── middle-ware-api-0.0.1-SNAPSHOT.jar
│ ├── slf4j-api-1.7.26.jar
│ ├── snakeyaml-1.23.jar
│ ├── spring-aop-5.1.9.RELEASE.jar
│ ├── spring-beans-5.1.9.RELEASE.jar
│ ├── spring-boot-2.1.7.RELEASE.jar
│ ├── spring-boot-autoconfigure-2.1.7.RELEASE.jar
│ ├── spring-boot-loader-2.1.7.RELEASE.jar
│ ├── spring-boot-starter-2.1.7.RELEASE.jar
│ ├── spring-boot-starter-json-2.1.7.RELEASE.jar
│ ├── spring-boot-starter-logging-2.1.7.RELEASE.jar
│ ├── spring-boot-starter-tomcat-2.1.7.RELEASE.jar
│ ├── spring-boot-starter-web-2.1.7.RELEASE.jar
│ ├── spring-context-5.1.9.RELEASE.jar
│ ├── spring-core-5.1.9.RELEASE.jar
│ ├── spring-expression-5.1.9.RELEASE.jar
│ ├── spring-jcl-5.1.9.RELEASE.jar
│ ├── spring-web-5.1.9.RELEASE.jar
│ ├── spring-webmvc-5.1.9.RELEASE.jar
│ ├── tomcat-embed-core-9.0.22.jar
│ ├── tomcat-embed-el-9.0.22.jar
│ ├── tomcat-embed-websocket-9.0.22.jar
│ └── validation-api-2.0.1.Final.jar
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
│ └── com.example
│ └── web-application
│ ├── pom.properties
│ └── pom.xml
└── org
└── springframework
└── boot
└── loader
├── archive
│ ├── Archive.class
│ ├── Archive$Entry.class
│ ├── Archive$EntryFilter.class
│ ├── ExplodedArchive$1.class
│ ├── ExplodedArchive.class
│ ├── ExplodedArchive$FileEntry.class
│ ├── ExplodedArchive$FileEntryIterator.class
│ ├── ExplodedArchive$FileEntryIterator$EntryComparator.class
│ ├── JarFileArchive.class
│ ├── JarFileArchive$EntryIterator.class
│ └── JarFileArchive$JarFileEntry.class
├── data
│ ├── RandomAccessData.class
│ ├── RandomAccessDataFile$1.class
│ ├── RandomAccessDataFile.class
│ ├── RandomAccessDataFile$DataInputStream.class
│ └── RandomAccessDataFile$FileAccess.class
├── ExecutableArchiveLauncher.class
├── jar
│ ├── AsciiBytes.class
│ ├── Bytes.class
│ ├── CentralDirectoryEndRecord.class
│ ├── CentralDirectoryFileHeader.class
│ ├── CentralDirectoryParser.class
│ ├── CentralDirectoryVisitor.class
│ ├── FileHeader.class
│ ├── Handler.class
│ ├── JarEntry.class
│ ├── JarEntryFilter.class
│ ├── JarFile$1.class
│ ├── JarFile$2.class
│ ├── JarFile.class
│ ├── JarFileEntries$1.class
│ ├── JarFileEntries.class
│ ├── JarFileEntries$EntryIterator.class
│ ├── JarFile$JarFileType.class
│ ├── JarURLConnection$1.class
│ ├── JarURLConnection.class
│ ├── JarURLConnection$JarEntryName.class
│ ├── StringSequence.class
│ └── ZipInflaterInputStream.class
├── JarLauncher.class
├── LaunchedURLClassLoader.class
├── LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
├── Launcher.class
├── MainMethodRunner.class
├── PropertiesLauncher$1.class
├── PropertiesLauncher$ArchiveEntryFilter.class
├── PropertiesLauncher.class
├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
├── util
│ └── SystemPropertyUtils.class
└── WarLauncher.class
BOOT-INF/lib下,是由同一個類載入器去載入的,而我們的V1和V2的jar包,全部混在這個目錄下。
我們要想同時載入V1和V2的jar包,必須用兩個類載入器來做隔離。
即,應用類載入器,不能載入V1;而中間件類載入器,只管載入V2,其他一概不能載入。
spring boot 的啟動類
前面我們提到,啟動類是org.springframework.boot.loader.JarLauncher,這個是在BOOT-INF/MANIFEST中指定了的。
這個類在哪裡呢,一般在如下這個依賴中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>
該依賴,會在打包階段,由maven插件,打到我們的fat jar中:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
這個jar包,打到哪裡去了呢?實際是解壓後,放到fat jar的如下路徑了,可以再去上面看看那個fat jar結構:
上面那個啟動類,就是在這個裡面。
啟動類簡單解析
先來看看uml:
先看看JarLauncher:
public class JarLauncher extends ExecutableArchiveLauncher {
static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";
static final String BOOT_INF_LIB = "BOOT-INF/lib/";
public JarLauncher() {
}
protected JarLauncher(Archive archive) {
super(archive);
}
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
return entry.getName().startsWith(BOOT_INF_LIB);
}
public static void main(String[] args) throws Exception {
// 1
new JarLauncher().launch(args);
}
}
這裡1處,new了自身,然後調用launch。
public abstract class Launcher {
/**
* Launch the application. This method is the initial entry point that should be
* called by a subclass {@code public static void main(String[] args)} method.
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
// 1
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 2
launch(args, getMainClass(), classLoader);
}
1處這裡,就是創建類載入器了,期間,先調用了getClassPathArchives。
我們看看:
org.springframework.boot.loader.Launcher#getClassPathArchives
protected abstract List<Archive> getClassPathArchives() throws Exception;
這是個抽象方法,此處使用了模板方法設計模式,在如下類中實現了:
org.springframework.boot.loader.ExecutableArchiveLauncher#getClassPathArchives
@Override
protected List<Archive> getClassPathArchives() throws Exception {
// 1
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
postProcessClassPathArchives(archives);
return archives;
}
此處的1處,不用深究,就是獲取類載入器的classpath集合。我們這裡打個斷點,直接看下:
這裡面細節就先不看了,主要就是拿到BOOT-INF/lib下的每個jar包。
然後我們繼續之前的:
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
// 1
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 2
launch(args, getMainClass(), classLoader);
}
現在getClassPathArchives已經ok了,接著就調用createClassLoader來創建類載入器了。
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
List<URL> urls = new ArrayList<>(archives.size());
for (Archive archive : archives) {
urls.add(archive.getUrl());
}
// 1
return createClassLoader(urls.toArray(new URL[0]));
}
1處,繼續調用內層函數,傳入了url數組。
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}
這裡new了一個LaunchedURLClassLoader,參數就是url數組。我們看看這個類:
public class LaunchedURLClassLoader extends URLClassLoader {
static {
ClassLoader.registerAsParallelCapable();
}
/**
* Create a new {@link LaunchedURLClassLoader} instance.
* @param urls the URLs from which to load classes and resources
* @param parent the parent class loader for delegation
*/
public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
這個類,繼承了URLClassLoader,所以,大家如果對類載入器有一定了解,就知道,URLClassLoader就是接收一堆的url,然後loadClass的時候,遵從雙親委派,雙親載入不了,就交給它,它就去url數組裡,去載入class。
思路分析
我的打算是,修改fat jar中的啟動類,為我們自定義的啟動類。
Manifest-Version: 1.0
Implementation-Title: web-application
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: com.example.demo.OrderServiceApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.1.7.RELEASE
Created-By: Maven Archiver 3.4.0
// 1
Main-Class: com.example.demo.CustomJarLauncher
1處,指定我們自定義的class,這個class,我們會在打好fat jar後,手動拷貝進去。
然後,我們自定義啟動類裡面要幹啥呢?
public class CustomJarLauncher extends JarLauncher {
public static void main(String[] args) throws Exception {
new CustomJarLauncher().launch(args);
}
@Override
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
// 1
List<Archive> classPathArchives = getClassPathArchives();
/**
* 2
*/
List<URL> allURLs = classPathArchives.stream().map(entries -> {
try {
return entries.getUrl();
} catch (MalformedURLException e) {
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
// 3
List<URL> middleWareClassPathArchives = new ArrayList<>();
for (URL url : allURLs) {
String urlPath = url.getPath();
if (urlPath == null) {
continue;
}
boolean isMiddleWareJar = urlPath.contains("common-v1")
|| urlPath.contains("middle-ware");
if (isMiddleWareJar) {
if (urlPath.contains("middle-ware-api")) {
continue;
}
middleWareClassPathArchives.add(url);
}
}
/**
* 4 從全部的應用lib目錄,移除中間件需要的jar包,但是,中間件的api不能移除
*/
allURLs.removeAll(middleWareClassPathArchives);
// 5
CustomLaunchedURLClassLoader loader =
new CustomLaunchedURLClassLoader(allURLs.toArray(new URL[0]),
getClass().getClassLoader());
loader.setMiddleWareClassPathArchives(middleWareClassPathArchives);
launch(args, getMainClass(), loader);
}
}
- 1處,獲取fat jar的lib目錄下的全部包
- 2處,將1處得到的集合,轉為url集合
- 3處,篩選出中間件的包,複製到單獨的集合中,我這邊有2個,直接寫死了(畢竟是demo)
- 4處,將原集合中,移除中間件的jar包
- 5處,創建一個自定義的classloader,主要是方便我們存放中間件相關jar包集合
5處自定義的classloader,這裡可以看下:
public class CustomLaunchedURLClassLoader extends LaunchedURLClassLoader {
// 中間件jar包
List<URL> middleWareClassPathArchives;
/**
* Create a new {@link LaunchedURLClassLoader} instance.
*
* @param urls the URLs from which to load classes and resources
* @param parent the parent class loader for delegation
*/
public CustomLaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
public List<URL> getMiddleWareClassPathArchives() {
return middleWareClassPathArchives;
}
public void setMiddleWareClassPathArchives(List<URL> middleWareClassPathArchives) {
this.middleWareClassPathArchives = middleWareClassPathArchives;
}
}
最終,在我們的業務程式碼,要怎麼去寫呢?
我們現在自定義了一個類載入器,那麼,後續業務程式碼都會由這個類載入器去載入。
我們再想想標題說的問題,我們是需要:載入中間件程式碼時,不能用這個類載入器去載入,因為這個類載入器中,已經排除了中間件相關jar包,是載入不到的。
此時,我們需要自定義一個classloader,去如下類中的middleWareClassPathArchives這個地方載入:
public class CustomLaunchedURLClassLoader extends LaunchedURLClassLoader {
// 中間件jar包
List<URL> middleWareClassPathArchives;
...
}
只有當它載入不到之後,才丟給應用類載入器去載入。
程式碼如下:
@SpringBootApplication
@RestController
@Slf4j
public class OrderServiceApplication {
/**
* 中間件使用的classloader
*/
static ClassLoader delegatingClassloader;
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
// 1
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Method method = loader.getClass().getMethod("getMiddleWareClassPathArchives");
List<URL> middleWareUrls = (List<URL>) method.invoke(loader);
// 2
delegatingClassloader = new CustomMiddleWareClassloader(middleWareUrls.toArray(new URL[0]), loader);
// 3
SpringApplication.run(OrderServiceApplication.class, args);
}
-
1,這裡,我們要通過當前執行緒,拿到我們的類載入器,此時拿到的,肯定就是我們的自定義類載入器;然後通過反射方法,拿到中間件url集合,其實這裡自己去拼這個url也可以,我們這裡為了省事,所以就這麼寫;
-
2處,創建一個類載入器,主要就是給中間件程式碼使用,進行類載入器隔離。
注意,這裡,我們把當前應用的類載入器,傳給了這個中間件類載入器。
@Data
@Slf4j
public class CustomMiddleWareClassloader extends URLClassLoader {
ClassLoader classLoader;
public CustomMiddleWareClassloader(URL[] urls, ClassLoader parent) {
super(urls);
classLoader = parent;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
/**
* 先自己來載入中間件相關jar包,這裡調用findClass,就會去中間件那幾個jar包載入class
*/
try {
Class<?> clazz = findClass(name);
if (clazz != null) {
return clazz;
}
throw new ClassNotFoundException(name);
} catch (Exception e) {
/**
* 在中間件自己的jar包里找不到,就交給自己的parent,此處即應用類載入器
*/
return classLoader.loadClass(name);
}
}
}
程式碼結構
中間件整體模組概覽
在繼續之前,有必要說下程式碼結構。
中間件總共三個jar包:
common-v1,middle-ware,middle-ware-api。
其中,middle-ware的pom如下:
中間件api模組
該模組無任何依賴,就是個介面
public interface IGisUtilInterface {
String getFormattedDate(String date);
}
該模組是很有必要的,該api模組必須由應用的類載入器載入,沒錯,是應用類載入器。
類似於servlet-api吧。
大家可以暫時這麼記著,至於原因,那就有點長了。
可以參考:
不吹不黑,關於 Java 類載入器的這一點,市面上沒有任何一本圖書講到
中間件實現模組
實現模組的pom:
<groupId>com.example</groupId>
<artifactId>middle-ware</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>middle-ware</name>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>middle-ware-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>common-v1</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
裡面只有一個類,就是實現api模組的介面。
@Slf4j
public class GisUtilImpl implements IGisUtilInterface{
@Override
public String getFormattedDate(String date) {
String v1 = CommonDateUtil.format(date);
log.info("invoke common v1,get result:{}", v1);
return v1;
}
}
spring boot 的自定義loader模組
這部分和業務關係不大,主要就是自定義我們前面的那個fat jar啟動類。
<groupId>com.example</groupId>
<artifactId>custom-jar-launch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>custom-jar-launch</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>
</dependencies>
這裡有個特別的依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>
該模組,主要包含:
com.example.demo.CustomJarLauncher
com.example.demo.CustomLaunchedURLClassLoader
應用程式
<groupId>com.example</groupId>
<artifactId>web-application</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>web-application</name>
<description>Demo project for Spring Boot</description>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>common-v2</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>middle-ware-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>middle-ware</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
模擬jar包衝突場景,此時,我們已經同時依賴了v1和v2了。
其測試程式碼如下:
public class OrderServiceApplication {
/**
* 中間件使用的classloader
*/
static ClassLoader delegatingClassloader;
@RequestMapping("/")
public void test() throws ClassNotFoundException, IllegalAccessException {
// 1
Class<?> middleWareImplClass = delegatingClassloader.loadClass("com.example.demo.GisUtilImpl");
// 2
IGisUtilInterface iGisUtilInterface = (IGisUtilInterface) middleWareImplClass.newInstance();
// 3
String middleWareResult = iGisUtilInterface.getFormattedDate("version:");
log.info("middle ware result:{}",middleWareResult);
// 4
String result = CommonDateUtil.format("version:");
log.info("application result:{}",result);
}
-
1處,類似於servlet,也是把servlet實現類寫死在web.xml的,我們這裡也一樣,把中間件的實現類寫死了。
可能有更好的方式,暫時先這樣。
-
2處,將實現類(中間件類載入器載入),轉換為介面類(應用類載入器載入)。之所以要定義介面,這裡很關鍵。
可以再仔細看看:
-
3處,調用中間件程式碼
-
4處,調用應用程式碼
效果展示
2020-05-22 06:37:13.481 INFO 6676 --- [nio-8082-exec-1] com.example.demo.GisUtilImpl : invoke common v1,get result:version:- v1
2020-05-22 06:37:13.481 INFO 6676 --- [nio-8082-exec-1] c.example.demo.OrderServiceApplication : middle ware result:version:- v1
2020-05-22 06:37:13.482 INFO 6676 --- [nio-8082-exec-1] com.example.date.util.CommonDateUtil : v2 result:version:- v2
2020-05-22 06:37:13.482 INFO 6676 --- [nio-8082-exec-1] c.example.demo.OrderServiceApplication : application result:version:- v2
可以發現,中間件那裡,是v1;而調用應用的方法,則是v2。
說明我們成功了。
我這裡用arthas分析了下這個類:
這個類,還在下面出現:
這個是中間件載入的。
所以,大家平安無事地繼續生活在了一起。
源碼
//gitee.com/ckl111/all-simple-demo-in-work-1/tree/master/jar-conflict
該源碼怎麼使用?
先正常打包應用為fat jar,然後將custom-jar-launch中的class,拷進fat jar,然後修改META-INF/MANIFEST文件的啟動類。
然後調用介面:
curl localhost:8082
總結
希望對大家有所啟發,謝謝。