13.Spring 6.0及SpringBoot 3.0新特性解析

https://github.com/spring-projects/spring-framework/wiki/What%27s-New-in-Spring-Framework-6.x

最核心的就是Spring AOT。

GraalVM文章推薦:https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzI3MDI5MjI1Nw==&action=getalbum&album_id=2761361634840969217&scene=173&from_msgid=2247484273&from_itemidx=1&count=3&nolastread=1#wechat_redirect

GraalVM體驗

下載壓縮包

打開https://github.com/graalvm/graalvm-ce-builds/releases,按JDK版本下載GraalVM對應的壓縮包,請下載Java 17對應的版本,不然後面運行SpringBoot3可能會有問題。

windows的直接給大家:📎graalvm-ce-java17-windows-amd64-22.3.0.zip

下載完後,就解壓,

配置環境變量

新開一個cmd測試:

安裝Visual Studio Build Tools

因爲需要C語言環境,所以需要安裝Visual Studio Build Tools。

打開visualstudio.microsoft.com,下載Visual Studio Installer。

選擇C++桌面開發,和Windows 11 SDK,然後進行下載和安裝,安裝後重啓操作系統。

要使用GraalVM,不能使用普通的windows自帶的命令行窗口,得使用VS提供的 x64 Native Tools Command Prompt for VS 2019,如果沒有可以執行C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Auxiliary\Build\vcvars64.bat腳本來安裝。

安裝完之後其實就可以在 x64 Native Tools Command Prompt for VS 2019中去使用native-image命令去進行編譯了。

但是,如果後續在編譯過程中編譯失敗了,出現以下錯誤:

那麼可以執行cl.exe,如果是中文,那就得修改爲英文。

通過Visual Studio Installer來修改,比如:

可能一開始只選擇了中文,手動選擇英文,去掉中文,然後安裝即可。

再次檢查

這樣就可以正常的編譯了。

Hello World實戰

新建一個簡單的Java工程:

我們可以直接把graalvm當作普通的jdk的使用

我們也可以利用native-image命令來將字節碼編譯爲二進制可執行文件。

打開x64 Native Tools Command Prompt for VS 2019,進入工程目錄下,並利用javac將java文件編譯爲class文件:javac -d . src/com/zhouyu/App.java

此時的class文件因爲有main方法,所以用java命令可以運行

我們也可以利用native-image來編譯:

編譯需要一些些。。。。。。。時間。

編譯完了之後就會在當前目錄生成一個exe文件:

我們可以直接運行這個exe文件:

並且運行這個exe文件是不需要操作系統上安裝了JDK環境的。

我們可以使用-o參數來指定exe文件的名字:

native-image com.zhouyu.App -o app

GraalVM的限制

GraalVM在編譯成二進制可執行文件時,需要確定該應用到底用到了哪些類、哪些方法、哪些屬性,從而把這些代碼編譯爲機器指令(也就是exe文件)。但是我們一個應用中某些類可能是動態生成的,也就是應用運行後才生成的,爲了解決這個問題,GraalVM提供了配置的方式,比如我們可以在編譯時告訴GraalVM哪些方法會被反射調用,比如我們可以通過reflect-config.json來進行配置。

SpringBoot 3.0實戰

然後新建一個Maven工程,添加SpringBoot依賴

<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>3.0.0</version>
</parent>

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
</dependencies>

以及SpringBoot的插件

<build>
	<plugins>
		<plugin>
			<groupId>org.graalvm.buildtools</groupId>
			<artifactId>native-maven-plugin</artifactId>
		</plugin>
		<plugin>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-maven-plugin</artifactId>
		</plugin>
	</plugins>
</build>

以及一些代碼

@RestController
public class ZhouyuController {

    @Autowired
    private UserService userService;

    @GetMapping("/demo")
    public String test() {
        return userService.test();
    }

} 
package com.zhouyu;

import org.springframework.stereotype.Component;

@Component
public class UserService {

    public String test(){
        return "hello zhouyu";
    }
}
package com.zhouyu;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

這本身就是一個普通的SpringBoot工程,所以可以使用我們之前的方式使用,同時也支持利用native-image命令把整個SpringBoot工程編譯成爲一個exe文件。

同樣在 x64 Native Tools Command Prompt for VS 2019中,進入到工程目錄下,執行mvn -Pnative native:compile進行編譯就可以了,就能在target下生成對應的exe文件,後續只要運行exe文件就能啓動應用了。

在執行命令之前,請確保環境變量中設置的時graalvm的路徑。

編譯完成截圖:

這樣,我們就能夠直接運行這個exe來啓動我們的SpringBoot項目了。

Docker SpringBoot3.0 實戰

我們可以直接把SpringBoot應用對應的本地可執行文件構建爲一個Docker鏡像,這樣就能跨操作系統運行了。

Buildpacks,類似Dockerfile的鏡像構建技術

注意要安裝docker,並啓動docker

注意這種方式並不要求你機器上安裝了GraalVM,會由SpringBoot插件利用/paketo-buildpacks/native-image來生成本地可執行文件,然後打入到容器中

Docker鏡像名字中不能有大寫字母,我們可以配置鏡像的名字:

<properties>
  <maven.compiler.source>17</maven.compiler.source>
  <maven.compiler.target>17</maven.compiler.target>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <spring-boot.build-image.imageName>springboot3demo</spring-boot.build-image.imageName>
</properties>

然後執行:

mvn -Pnative spring-boot:build-image

來生成Docker鏡像,成功截圖:

執行完之後,就能看到docker鏡像了:

然後就可以運行容器了:

docker run --rm -p 8080:8080 springboot3demo

如果要傳參數,可以通過-e

docker run --rm -p 8080:8080 -e methodName=test springboot3demo

不過代碼中,得通過以下代碼獲取:

String methodName = System.getenv("methodName")

建議工作中直接使用Environment來獲取參數:

RuntimeHints

假如應用中有如下代碼:

public class ZhouyuService {

    public String test(){
        return "zhouyu";
    }
} 
@Component
public class UserService {

    public String test(){

        String result = "";
        try {
            Method test = ZhouyuService.class.getMethod("test", null);
            result = (String) test.invoke(ZhouyuService.class.newInstance(), null);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        } catch (InstantiationException e) {
            throw new RuntimeException(e);
        }

        return result;
    }

}

在UserService中,通過反射的方式使用到了ZhouyuService的無參構造方法(ZhouyuService.class.newInstance()),如果我們不做任何處理,那麼打成二進制可執行文件後是運行不了的,可執行文件中是沒有ZhouyuService的無參構造方法的,會報如下錯誤:

我們可以通過Spring提供的Runtime Hints機制來間接的配置reflect-config.json。

方式一:RuntimeHintsRegistrar

提供一個RuntimeHintsRegistrar接口的實現類,並導入到Spring容器中就可以了:

@Component
@ImportRuntimeHints(UserService.ZhouyuServiceRuntimeHints.class)
public class UserService {

    public String test(){

        String result = "";
        try {
            Method test = ZhouyuService.class.getMethod("test", null);
            result = (String) test.invoke(ZhouyuService.class.newInstance(), null);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        } catch (InstantiationException e) {
            throw new RuntimeException(e);
        }


        return result;
    }

    static class ZhouyuServiceRuntimeHints implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            try {
                hints.reflection().registerConstructor(ZhouyuService.class.getConstructor(), ExecutableMode.INVOKE);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

方式二:@RegisterReflectionForBinding

@RegisterReflectionForBinding(ZhouyuService.class)
public String test(){

    String result = "";
    try {
        Method test = ZhouyuService.class.getMethod("test", null);
        result = (String) test.invoke(ZhouyuService.class.newInstance(), null);
    } catch (NoSuchMethodException e) {
        throw new RuntimeException(e);
    } catch (InvocationTargetException e) {
        throw new RuntimeException(e);
    } catch (IllegalAccessException e) {
        throw new RuntimeException(e);
    } catch (InstantiationException e) {
        throw new RuntimeException(e);
    }


    return result;
}

注意

如果代碼中的methodName是通過參數獲取的,那麼GraalVM在編譯時就不能知道到底會使用到哪個方法,那麼test方法也要利用RuntimeHints來進行配置。

@Component
@ImportRuntimeHints(UserService.ZhouyuServiceRuntimeHints.class)
public class UserService {

    public String test(){

        String methodName = System.getProperty("methodName");

        String result = "";
        try {
            Method test = ZhouyuService.class.getMethod(methodName, null);
            result = (String) test.invoke(ZhouyuService.class.newInstance(), null);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        } catch (InstantiationException e) {
            throw new RuntimeException(e);
        }


        return result;
    }

    static class ZhouyuServiceRuntimeHints implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            try {
                hints.reflection().registerConstructor(ZhouyuService.class.getConstructor(), ExecutableMode.INVOKE);
                hints.reflection().registerMethod(ZhouyuService.class.getMethod("test"), ExecutableMode.INVOKE);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

或者使用了JDK動態代理:

public String test() throws ClassNotFoundException {

    String className = System.getProperty("className");
	Class<?> aClass = Class.forName(className);

	Object o = Proxy.newProxyInstance(UserService.class.getClassLoader(), new Class[]{aClass}, new InvocationHandler() {
    	@Override
    	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        	return method.getName();
    	}
	});

	return o.toString();
}

那麼也可以利用RuntimeHints來進行配置要代理的接口:

public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
    hints.proxies().registerJdkProxy(UserInterface.class);
}

方式三:@Reflective

對於反射用到的地方,我們可以直接加一個@Reflective,前提是ZhouyuService得是一個Bean:

@Component
public class ZhouyuService {

    @Reflective
    public ZhouyuService() {
    }

    @Reflective
    public String test(){
        return "zhouyu";
    }
}

以上Spring6提供的RuntimeHints機制,我們可以使用該機制更方便的告訴GraalVM我們額外用到了哪些類、接口、方法等信息,最終Spring會生成對應的reflect-config.json、proxy-config.json中的內容,GraalVM就知道了。

Spring AOT的源碼實現

流程圖:https://www.processon.com/view/link/63edeea8440e433d3d6a88b2

SpringBoot 3.0插件實現原理

上面的SpringBoot3.0實戰過程中,我們在利用image-native編譯的時候,target目錄下會生成一個spring-aot文件夾:

這個spring-aot文件夾是編譯的時候spring boot3.0的插件生成的,resources/META-INF/native-image文件夾中的存放的就是graalvm的配置文件。

當我們執行mvn -Pnative native:compile時,實際上執行的是插件native-maven-plugin的邏輯。

我們可以執行mvn help:describe -Dplugin=org.graalvm.buildtools:native-maven-plugin -Ddetail

來查看這個插件的詳細信息。

發現native:compile命令對應的實現類爲NativeCompileMojo,並且會先執行package這個命令,從而會執行process-aot命令,因爲spring-boot-maven-plugin插件中有如下配置:

我們可以執行mvn help:describe -Dplugin=org.springframework.boot:spring-boot-maven-plugin -Ddetail

發現對應的phase爲:prepare-package,所以會在打包之前執行ProcessAotMojo。

所以,我們在運行mvn -Pnative native:compile時,會先編譯我們自己的java代碼,然後執行executeAot()方法(會生成一些Java文件並編譯成class文件,以及GraalVM的配置文件),然後才執行利用GraalVM打包出二進制可執行文件。

對應的源碼實現:

maven插件在編譯的時候,就會調用到executeAot()這個方法,這個方法會:

  1. 先執行org.springframework.boot.SpringApplicationAotProcessor的main方法
  2. 從而執行SpringApplicationAotProcessor的process()
  3. 從而執行ContextAotProcessor的doProcess(),從而會生成一些Java類並放在spring-aot/main/sources目錄下,詳情看後文
  4. 然後把生成在spring-aot/main/sources目錄下的Java類進行編譯,並把對應class文件放在項目的編譯目錄下target/classes
  5. 然後把spring-aot/main/resources目錄下的graalvm配置文件複製到target/classes
  6. 然後把spring-aot/main/classes目錄下生成的class文件複製到target/classes

Spring AOT核心原理

以下只是一些關鍵源碼

prepareApplicationContext會直接啓動我們的SpringBoot,並在觸發contextLoaded事件後,返回所創建的Spring對象,注意此時還沒有掃描Bean。

protected ClassName performAotProcessing(GenericApplicationContext applicationContext) {
	FileSystemGeneratedFiles generatedFiles = createFileSystemGeneratedFiles();

	DefaultGenerationContext generationContext = new DefaultGenerationContext(createClassNameGenerator(), generatedFiles);
	
	ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator();

	// 會進行掃描,並且根據掃描得到的BeanDefinition生成對應的Xx_BeanDefinitions.java文件
	// 並返回com.zhouyu.MyApplication__ApplicationContextInitializer
	ClassName generatedInitializerClassName = generator.processAheadOfTime(applicationContext, generationContext);

	// 因爲後續要通過反射調用com.zhouyu.MyApplication__ApplicationContextInitializer的構造方法
    // 所以將相關信息添加到reflect-config.json對應的RuntimeHints中去
	registerEntryPointHint(generationContext, generatedInitializerClassName);

	// 生成source目錄下的Java文件
	generationContext.writeGeneratedContent();

	// 將RuntimeHints中的內容寫入resource目錄下的Graalvm的各個配置文件中
	writeHints(generationContext.getRuntimeHints());
	writeNativeImageProperties(getDefaultNativeImageArguments(getApplicationClass().getName()));

	return generatedInitializerClassName;
}
public ClassName processAheadOfTime(GenericApplicationContext applicationContext,
									GenerationContext generationContext) {
	return withCglibClassHandler(new CglibClassHandler(generationContext), () -> {
		
		// 會進行掃描,並找到beanType是代理類的請求,把代理類信息設置到RuntimeHints中
		applicationContext.refreshForAotProcessing(generationContext.getRuntimeHints());

		// 拿出Bean工廠,掃描得到的BeanDefinition對象在裏面
		DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
		
		ApplicationContextInitializationCodeGenerator codeGenerator =
			new ApplicationContextInitializationCodeGenerator(generationContext);
		
		// 核心
		new BeanFactoryInitializationAotContributions(beanFactory).applyTo(generationContext, codeGenerator);
		
		return codeGenerator.getGeneratedClass().getName();
	});
}
BeanFactoryInitializationAotContributions(DefaultListableBeanFactory beanFactory) {
	// 把aot.factories文件的加載器以及BeanFactory,封裝成爲一個Loader對象,然後傳入
	this(beanFactory, AotServices.factoriesAndBeans(beanFactory));
}
BeanFactoryInitializationAotContributions(DefaultListableBeanFactory beanFactory,
										  AotServices.Loader loader) {

	// getProcessors()中會從aot.factories以及beanfactory中拿出BeanFactoryInitializationAotProcessor類型的Bean對象
	// 同時還會添加一個RuntimeHintsBeanFactoryInitializationAotProcessor
	this.contributions = getContributions(beanFactory, getProcessors(loader));
}
private List<BeanFactoryInitializationAotContribution> getContributions(
	DefaultListableBeanFactory beanFactory,
	List<BeanFactoryInitializationAotProcessor> processors) {
	
	List<BeanFactoryInitializationAotContribution> contributions = new ArrayList<>();

	// 逐個調用BeanFactoryInitializationAotProcessor的processAheadOfTime()開始處理
	for (BeanFactoryInitializationAotProcessor processor : processors) {
		BeanFactoryInitializationAotContribution contribution = processor.processAheadOfTime(beanFactory);
		if (contribution != null) {
			contributions.add(contribution);
		}
	}
	return Collections.unmodifiableList(contributions);
}

總結一下,在SpringBoot項目編譯時,最終會通過BeanFactoryInitializationAotProcessor來生成Java文件,或者設置RuntimeHints,後續會把寫入Java文件到磁盤,將RuntimeHints中的內容寫入GraalVM的配置文件,再後面會編譯Java文件,再後面就會基於生成出來的GraalVM配置文件打包出二進制可執行文件了。

所以我們要看Java文件怎麼生成的,RuntimeHints如何收集的就看具體的BeanFactoryInitializationAotProcessor就行了。

比如:

  1. 有一個BeanRegistrationsAotProcessor,它就會負責生成Xx_BeanDefinition.java以及Xx__ApplicationContextInitializer.java、Xx__BeanFactoryRegistrations.java中的內容
  2. 還有一個RuntimeHintsBeanFactoryInitializationAotProcessor,它負責從aot.factories文件以及BeanFactory中獲取RuntimeHintsRegistrar類型的對象,以及會找到@ImportRuntimeHints所導入的RuntimeHintsRegistrar對象,最終就是從這些RuntimeHintsRegistrar中設置RuntimeHints。

Spring Boot3.0啓動流程

在run()方法中,SpringBoot會創建一個Spring容器,但是SpringBoot3.0中創建容器邏輯爲:

private ConfigurableApplicationContext createContext() {
	if (!AotDetector.useGeneratedArtifacts()) {
		return new AnnotationConfigServletWebServerApplicationContext();
	}
	return new ServletWebServerApplicationContext();
}

如果沒有使用AOT,那麼就會創建AnnotationConfigServletWebServerApplicationContext,它裏面會添加ConfigurationClassPostProcessor,從而會解析配置類,從而會掃描。

而如果使用了AOT,則會創建ServletWebServerApplicationContext,它就是一個空容器,它裏面沒有ConfigurationClassPostProcessor,所以後續不會觸發掃描了。

創建完容器後,就會找到MyApplication__ApplicationContextInitializer,開始向容器中註冊BeanDefinition。

後續就是創建Bean對象了。

 

本系列文章來自圖靈學院周瑜老師分享,本博客整理學習並搬運

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章