--- title: 'SpringBoot3 AOT 開發紀錄' disqus: hackmd --- # 1. 準備 * SpringBoot3 * Java17 * **gradle 插件**: * AOT:`id "org.graalvm.buildtools.native" version '0.9.28'` ```groovy= plugins { id 'org.graalvm.buildtools.native' version '0.9.28' } ``` * **編譯環境** * Docker * docker image:gradle:8.8.0-jdk17-graal-jammy * Linux * 需要安裝:`build-essential` \ `zlib1g-dev` * 指令sudo apt-get install build-essential zlib1g-dev * 其他環境:https://www.graalvm.org/latest/reference-manual/native-image/#build-a-native-executable-using-maven-or-gradle * **編譯指令**:gradle clean nativeCompile # 2. 需要調整或檢查的項目 * **參考網址** * https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-with-GraalVM * https://www.graalvm.org/latest/reference-manual/native-image/#build-a-native-executable-using-maven-or-gradle * https://www.graalvm.org/latest/reference-manual/native-image/metadata/#reflection ## 2.1 Log包的選擇 * **Logfj2**(`Springboot3不支持AOT`) 在**編譯**(`AOT`)時會出現Class NotFound,目前改用SpringBoot 默認的logback * pom.xml:引入 logback-db的核心 * `implementation 'ch.qos.logback.db:logback-core-db:1.2.11.1'` * github:https://github.com/qos-ch/logback-db ```groovy configurations { compileOnly { extendsFrom annotationProcessor } //排除所有 configureEach { exclude group: 'org.apache.logging.log4j', module: 'log4j-api' } } ``` ### 2.1.1 Log-To-DB * 自定義一個繼承 DBAppenderBase\<E>的類 * **建議**:重寫DBAppenderBase抽象類的append方法,**避免被默認的PK綁架** ```java= public class LogDBAppender extends DBAppenderBase<ILoggingEvent> { static final int LOG_TIME_INDEX = 1; static final int LEVEL_STRING_INDEX = 2; static final int THREAD_NAME_INDEX = 3; static final int CLASS_NAME_INDEX = 4; static final int MESSAGE_INDEX = 5; static final int EXCEPTION_MESSAGE_INDEX = 6; static final int BACKEND_NAME_INDEX = 7; static final String BACKEND_NAME = "API-SERVER"; private final String insertSQL = "INSERT INTO app_log" + "(log_time, \"level\", thread, \"class\", message, \"exception\", backend_name) " + "VALUES(?, ?, ?, ?, ?, ?, ?);"; @Override public void start() { if (connectionSource == null) { throw new IllegalStateException("DBAppender cannot function without a connection source"); } // all nice and dandy on the eastern front started = true; } @Override protected Method getGeneratedKeysMethod() { return null; } @Override protected String getInsertSQL() { return this.insertSQL; } //重寫DBAppenderBase抽象類的append方法,避免被默認的PK綁架 @Override public void append(ILoggingEvent eventObject) { Connection connection = null; PreparedStatement insertStatement = null; try { connection = connectionSource.getConnection(); connection.setAutoCommit(false); insertStatement = connection.prepareStatement(getInsertSQL()); // inserting an event and getting the result must be exclusive synchronized (this) { subAppend(eventObject, connection, insertStatement); } connection.commit(); } catch (Throwable sqle) { addError("problem appending event", sqle); } finally { DBHelper.closeStatement(insertStatement); DBHelper.closeConnection(connection); } } @Override protected void subAppend(ILoggingEvent eventObject, Connection connection, PreparedStatement statement) throws Throwable { statement.setTimestamp(LOG_TIME_INDEX, new Timestamp(eventObject.getTimeStamp())); statement.setString(LEVEL_STRING_INDEX, eventObject.getLevel().toString()); statement.setString(THREAD_NAME_INDEX, eventObject.getThreadName()); statement.setString(CLASS_NAME_INDEX, eventObject.getLoggerName()); statement.setString(MESSAGE_INDEX, eventObject.getFormattedMessage()); statement.setString(BACKEND_NAME_INDEX, BACKEND_NAME); if (eventObject.getThrowableProxy() != null) { IThrowableProxy throwableProxy = eventObject.getThrowableProxy(); StringBuilder errorMsg = new StringBuilder(1000); errorMsg.append(throwableProxy.getClassName()).append(": ").append(throwableProxy.getMessage()).append( "\n"); Arrays.stream(throwableProxy.getStackTraceElementProxyArray()).collect(Collectors.toList()).forEach(stackTraceElementProxy -> { System.out.println(stackTraceElementProxy.getSTEAsString()); }); for (StackTraceElementProxy stackTraceElementProxy : throwableProxy.getStackTraceElementProxyArray()) { errorMsg.append("\t").append(stackTraceElementProxy.getSTEAsString()).append("\n"); } errorMsg.replace(errorMsg.lastIndexOf("\n"), errorMsg.length(), ""); statement.setString(EXCEPTION_MESSAGE_INDEX, errorMsg.toString()); } else { statement.setString(EXCEPTION_MESSAGE_INDEX, ""); } int updateCount = statement.executeUpdate(); if (updateCount != 1) { addWarn("Failed to insert loggingEvent"); } } @Override protected void secondarySubAppend(ILoggingEvent eventObject, Connection connection, long eventId) throws Throwable { } } ``` * logback-spring.xml ```xml= <configuration> <property name="LOGS" value="logs"/> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <layout class="ch.qos.logback.classic.PatternLayout"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight([%level]) %logger - %msg%n</pattern> </layout> </appender> <appender name="DB" class="com.nicolas.logback_db_test.log.NDBAppender"> <connectionSource class="ch.qos.logback.core.db.DataSourceConnectionSource"> <dataSource class="com.zaxxer.hikari.HikariDataSource"> <jdbcUrl>jdbc:postgresql://localhost:5432/postgres</jdbcUrl> <driverClassName>org.postgresql.Driver</driverClassName> <poolName>log-pool</poolName> </dataSource> </connectionSource> </appender> <root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="DB"/> </root> </configuration> ``` ## 2.2 JPA + DB ### 2.2.1 線程池配置 * DB線程池 需要**設定反射配置**(路徑 `classpath:META-INT/native-image/reflect-config.json`) ```json= [ { "name": "com.zaxxer.hikari.HikariDataSource", "methods": [ { "name": "<init>", "parameterTypes": [] } ] }, { "name": "org.hibernate.dialect.PostgreSQLDialect", "allDeclaredMethods": true, "allDeclaredFields": true, "allDeclaredConstructors": true }, { "name": "com.zaxxer.hikari.HikariConfig", "allDeclaredClasses": true, "allDeclaredMethods": true, "allDeclaredFields": true, "allDeclaredConstructors": true, "allPublicClasses": true, "allPublicMethods": true, "allPublicFields": true, "allPublicConstructors": true, "allRecordComponents": true, "allNestMembers": true, "allSigners": true, "allPermittedSubclasses": true, "queryAllDeclaredMethods": true, "queryAllDeclaredConstructors": true, "queryAllPublicMethods": true, "queryAllPublicConstructors": true, "unsafeAllocated": true }, { "name": "java.sql.Statement[]" } ] ``` ### 2.2.2 JAP * 需要配置 **PersistenceManagedTypes** * 未配置所導致的問題:無法找到Entity * 訊息:Not a managed type * 範例: ```java= @Bean("PersistenceManagedTypes") @Primary PersistenceManagedTypes managedTypes(ResourceLoader resourceLoader) { return new PersistenceManagedTypesScanner(resourceLoader) .scan("com.example.aotdemo.apdater.repository"); } ``` * 參考網址:https://github.com/DigitalMediageek/aot-database-issue/blob/main/src/main/java/com/example/database/hikari/Graal1DbConfig.java * 完整配置 ```java= package com.example.aotdemo.config; import com.zaxxer.hikari.HikariDataSource; import jakarta.persistence.EntityManagerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.core.io.ResourceLoader; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypesScanner; import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; import java.util.HashMap; import java.util.Map; @Configuration @EnableJpaRepositories(entityManagerFactoryRef = "CoreEntityManagerFactory", transactionManagerRef = "CoreTransactionManager", basePackages = {"com.example.aotdemo.adapter.repository"}) public class CoreDataSourceConfig { @Value("${MAXIMUM_POOL_SIZE:2}") private int maximumPoolSize; @Primary @Bean(name = "CoreDatasource") @ConfigurationProperties(prefix = "spring.aotdemo.datasource") public DataSource dataSource() { HikariDataSource dataSource = DataSourceBuilder.create().type(HikariDataSource.class).build(); dataSource.setMaximumPoolSize(maximumPoolSize); dataSource.setPoolName("CorePool"); return dataSource; } @Primary @Bean(name = "CoreEntityManagerFactory") public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(EntityManagerFactoryBuilder builder, @Qualifier("CoreDatasource") DataSource dataSource, @Qualifier("PersistenceManagedTypes") PersistenceManagedTypes persistenceManagedTypes) { Map<String, Object> properties = new HashMap<>(); properties.put("hibernate.hbm2ddl.auto", "none"); return builder.dataSource(dataSource) .properties(properties) .packages("com.example.aotdemo.adapter.repository") .persistenceUnit("core_db") .managedTypes(persistenceManagedTypes) .build(); } @Primary @Bean(name = "CoreTransactionManager") public PlatformTransactionManager transactionManager( @Qualifier("CoreEntityManagerFactory") EntityManagerFactory entityManagerFactory) { return new JpaTransactionManager(entityManagerFactory); } @Bean("PersistenceManagedTypes") @Primary PersistenceManagedTypes managedTypes(ResourceLoader resourceLoader) { return new PersistenceManagedTypesScanner(resourceLoader) .scan("com.example.aotdemo.adapter.repository"); } } ``` ## 2.3 Spring Validation * 使用沒有改變,不需要做額外配置 * **@Vaild**/**@Validated**/**@NotEmpty**/..:不需要額外處理 * 使用 **@RequestMapping**下使用 **@RequestBody**的**參數**會觸發 Spring Boot 的 AOT(Ahead-of-Time)的處理。(`配置該類的信息`) * 當使用到**反射**時就可以從`reflect-config.json`中**獲取類的信息** * **源碼**:**ControllerMappingReflectiveProcessor**.java 中的 `registerParameterTypeHints()` ## 2.4 GlobalExceptionHandler * 使用沒有改變,不需要做額外配置 * **@ControllerAdvice** 不需要額外處理 * 當中有使用到annotation:不需要額外處理 ## 2.5 SpringBoot Security * Spring Boot3下的Security配置不需要額外處理 * **其他**:權限驗證採用 `@PreAuthorize + 自訂權限驗證器`:需要配置該`自訂權限驗證器`的信息(`權限驗證是使用反射的方式來處理`) * 另外**Spel表達式**有使用到**Enum**,需要添加該Enum的類信息 * @PreAuthorize("@**EunoAISecurityExpression**.hasAuthority(T(com.example.aotdemo.entity.PrivilegeActionEnum).CREATE_AND_EDIT_SOURCE)") * **EunoAISecurityExpression**:**為自訂權限驗證器的BeanName** * **配置**:**自訂權限驗證器**、**Enum** ```json= { "name": "com.example.aotdemo.entity.PrivilegeActionEnum", "allDeclaredClasses": true, "allDeclaredMethods": true, "allDeclaredFields": true, "allDeclaredConstructors": true, "allPublicClasses": true, "allPublicMethods": true, "allPublicFields": true, "allPublicConstructors": true, "allRecordComponents": true, "allNestMembers": true, "allSigners": true, "allPermittedSubclasses": true, "queryAllDeclaredMethods": true, "queryAllDeclaredConstructors": true, "queryAllPublicMethods": true, "queryAllPublicConstructors": true, "unsafeAllocated": true }, { "name": "com.example.aotdemo.service.security.EunoAISecurityExpression", "allDeclaredClasses": true, "allDeclaredMethods": true, "allDeclaredFields": true, "allDeclaredConstructors": true, "allPublicClasses": true, "allPublicMethods": true, "allPublicFields": true, "allPublicConstructors": true, "allRecordComponents": true, "allNestMembers": true, "allSigners": true, "allPermittedSubclasses": true, "queryAllDeclaredMethods": true, "queryAllDeclaredConstructors": true, "queryAllPublicMethods": true, "queryAllPublicConstructors": true, "unsafeAllocated": true } ``` * 目前是添加**該類的所有信息配置**,`後續可以在調整類信息的配置` ## 2.6 OpenAPI * SpringBoot3下的OpenAPI可以直上 ## 2.7 JDBC版本 確認 * Orcale * **ojbc參考表**:https://www.oracle.com/database/technologies/appdev/jdbc-downloads.html * **ojbc8** 支援 `JDK8\JDK11` * 導致Log出現無法解析問題 * **解**:上升至 ojbc10 * Access * **ucanaccess** * 使用 hsqldb 當記憶體DB,**SpringBoot3不支援 hsqldb** * 解: 直接使用 com.healthmarketscience.jackcess ## 2.8 Bean注入方式:默認禁止方法注入(factory-method)-重點 * **禁止原因**: * 為了原生映像(native image)和 AOT 最佳化,**默認關閉代理機制**(proxyBeanMethods = false) * 這避免了 CGLIB 動態代理在編譯時產生問題,提升啟動速度並減少原生映像大小 * 錯誤訊息:UnsatisfiedDependencyException * Unexpected AOP exception * **建議手動標注**明確表明不使用代理 ```java @Configuration(proxyBeanMethods = false) ``` ## 2.9 自訂國際化資源失效 * **原因**:**國際化資源為動態載入** * 當需要進行國際化處理的時候,才會載入國際化資源 * **參考**:**ResourceBundleMessageSource類**中的**getResourceBundle()** * **解法**:參考**MessageSourceAutoConfiguration**中的**MessageSourceRuntimeHints** * 確保了資源文件模式在運行時可以加載 * **另一個方法**:**將資源的路徑**設定到`resource-config.json` * **代碼**:**添加三個資源** ```java= @Configuration() @ImportRuntimeHints({XxxConfig.XxxMessageSourceRuntimeHints.class}) public class XxxConfig { @Bean("MessageSource") public MessageSource messageSource() { ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource(); source.setBasenames("classpath:i18n/messages", "classpath:i18n/ValidationMessages", "classpath:i18n/postgresqlErrorCodes"); source.setDefaultEncoding(StandardCharsets.UTF_8.name()); return source; } @Bean("LocalValidatorFactoryBean") public LocalValidatorFactoryBean getValidator() { LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); bean.setValidationMessageSource(messageSource()); return bean; } static class XxxMessageSourceRuntimeHints implements RuntimeHintsRegistrar { XxxMessageSourceRuntimeHints() { } @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.resources().registerPattern("i18n/messages.properties") .registerPattern("i18n/ValidationMessages.properties") .registerPattern("i18n/postgresqlErrorCodes.properties"); } } } ``` * **registerHints**中的路徑不需要添加 classpath,並以檔案名開頭(`相對路徑`) ## 2.10 使用gson或ObjectMapper轉換 * **狀況**:如果使用**gson**或**ObjectMapper**在`Java 對象`和`JSON字串`之間進行轉換,該對象**未設定AOT配置**,會有如下問題: * gson:將Entity轉換一個`{}`字串 * ObjectMapper:出現無法轉換的異常 * **原因**:**gson**或**ObjectMapper**是使用**反射**(`在運行中動態加載類的信息`)的方式,但**編譯後無法動態加載類的訊息**,所以需要**先定義**該**類**的`字段`、`方法`、`信息` * **可能使用gson或ObjectMapper轉換的對象的情況**: * **Call API**: * **建議**:改用SpringBoot3的**HttpInterface**,該參數`如果是一個RequestBody`的話會貼上 **@RequestBody**,SpringBoot自動將此**對象**的**添加**到`META-INF/native-image/reflect-config.json` * **上述的觸發機制**:**@RequestBody**觸發SpringBoot的AOT的自動配置 * RestFulAPI與HttpInterface一至的作法 * **建議**:需要將轉換的物件設定在`META-INF/native-image/reflect-config.json` * 使用**RabbitMQ**來**發Queue**或**聽Queue** * **建議**:需要將轉換的物件設定在`META-INF/native-image/reflect-config.json` * **配置範例**:**Teacher類** * **對象** ```java= @Data public Class Teacher{ private String id; private String name; private String sex; } ``` * **reflect-config.json** ```json= [ { "name": "com.nicolas.mq_test.entity.Teacher", "allDeclaredFields": true, "allDeclaredConstructors": true, "fields": [ { "name": "id" }, { "name": "name" }, { "name": "sex" } ], "methods": [ { "name": "getId", "parameterTypes": [] }, { "name": "getName", "parameterTypes": [] }, { "name": "getSex", "parameterTypes": [] }, { "name": "setId", "parameterTypes": [ "java.lang.String" ] }, { "name": "setName", "parameterTypes": [ "java.lang.String" ] }, { "name": "setSex", "parameterTypes": [ "java.lang.String" ] } ] } ] ``` * **參考網址**:https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/Reflection/#configuration-with-features * 使用 SpringBoot註解: **@RegisterReflectionForBinding**(`Spring 6.1.17`) \ **@ReflectiveScan** (`Spring 6.2.3`) * **RegisterReflectionForBinding使用方式**:https://docs.spring.io/spring-framework/docs/6.1.20-SNAPSHOT/javadoc-api/org/springframework/aot/hint/annotation/RegisterReflectionForBinding.html ```JAVA= @Configuration @RegisterReflectionForBinding({Foo.class, Bar.class}) public class MyConfig { // ... } ``` * **參考**:`@RequestMapping` * **網址**:https://docs.spring.io/spring-framework/reference/6.1-SNAPSHOT/core/aot.html#aot.hints.reflective # 3. 開發經歷 ## 3.1 AotInitializerNotFoundException * **錯誤訊息** ```txt= org.springframework.boot.AotInitializerNotFoundException: Startup with AOT mode enabled failed: AOT initializer com.eunodata.eunoai.EunoAiBackendApplication__ApplicationContextInitializer could not be found at org.springframework.boot.SpringApplication.addAotGeneratedInitializerIfNecessary(SpringApplication.java:443) at org.springframework.boot.SpringApplication.prepareContext(SpringApplication.java:400) at org.springframework.boot.SpringApplication.run(SpringApplication.java:334) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352) at com.eunodata.eunoai.EunoAiBackendApplication.main(EunoAiBackendApplication.java:24) ``` * **ClassLoader**中無法找到`com.eunodata.eunoai.EunoAiBackendApplication` * **當前原因**:JavaCompile的Task中設定**關閉processAot** (`processAot.enabled(false)`) ```gradle= tasks.withType(JavaCompile) { options.encoding = 'UTF-8' options.compilerArgs << '-Xlint:all' processAot.enabled(false) } ``` * **解決**:**開啟processAot**或**移除此設定**(`默認開啟AOT`) * 另外關閉JavaCompile中的AOT,會使**編譯速度加速很多**(`判斷因為該部分沒有被編譯,所以加速`) * 參考: * https://www.xuanyuanli.cn/pages/dd8447/#_1-2-%E9%85%8D%E7%BD%AE-maven-%E6%88%96-gradle ###### tags: `Spring` `SpringBoot` `Java17` `AOT` `GraalVM` `Ahead of Time Compilation`