JHipster によるエンタープライズアプリケーションの構築(2) Spring Nativeで高速起動化しAWS Lambdaで動かす
[!] この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
前回は JHipster ⧉ とは何かについて紹介しました。今回は JHipster で自動生成した Gradle プロジェクトから AWS Lambda の関数を作成する方法を紹介します。
「使い慣れた Java 言語で Lambda 関数を作りたい」と考えており、以下のような課題を解決したい方の一助となれば幸いです。
- AWS Lambda の関数を Java で作成したいが、JVM の起動に時間がかかるため Java を採用しにくい
- AWS Lambda のアーキテクチャーに関わる実装や DB アクセス部品の実装、Gradle プロジェクトの作成などの定型的な作業を効率化したい
上述の課題を以下の方針で解決していきます。
Native イメージ化による高速起動
JVM の起動時間を短縮したり、コールドスタートを抑制する方法はいくつかありますが、この記事では JVM を使用せずに Native イメージ化することによって起動時間を高速化する方法を採用しています。
Lambda SnapStart ⧉ の発表により JVM の起動時間の問題は解決しそうですが、Native イメージ化する方法には AWS Lambda 以外の環境でも使えるというメリットがあります。
Gradle プロジェクトのボイラープレート化
JHipster では JHipster Domain Language (JDL) ⧉ という形式でデータモデルを定義することで、CRUD 操作用のソースコードや Liquibase の ChangeLog ファイルを自動生成することができます。
また、Blueprint ⧉ を作成することで、自動生成する成果物をカスタマイズすることができます。
この記事では Blueprint の作成までは行っていませんが、自動生成可能なレベルまで成果物を明確にしています。
この記事の内容をもとに Blueprint を作成することで、CRUD 操作用の Lambda 関数をサンプルとしてビジネスロジックの実装に集中することができるようになります。
使用している主要なプロダクト
GraalVM
Javaコードを実行可能な Native バイナリファイルとして出力する機能を持った JVMです。
(製品ページ ⧉)
Spring Native
Spring Boot アプリケーションを Native イメージ化するためのプロジェクト1です。
(GitHub ⧉)
Spring Cloud Function と AWS Adapter
Spring Boot で Lambda 関数を作成するためのプロジェクトです。
(Spring Cloud Function ⧉|AWS Adapter ⧉)
Spring Native for JHipster
JHipster で Spring Native を使用するための Blueprintです。 (ブログ記事 ⧉|GitHub ⧉)
この Blueprint は JHipster が自動生成するプロジェクトを Spring Native で動かすためのものです。プロジェクトを自動生成する際に、必要となる Maven モジュールの依存関係、ソースコード、設定ファイル等を出力してくれます。
これを使用することで Spring Native の知識がほぼ無くても Entity を CRUD 操作する Web アプリケーションを Native イメージで動かすことができます2。
加えて JHipster の機能により、Lambda 関数を作るための部品として以下のコードを自動生成することができます。
- Spring Data JPA を使用した Entity クラス、Repository クラス
- JPA Criteria API を使用した検索用の Query Service クラス
- hibernate-jpamodelgen を使用して Entity クラスに対する Metamodel クラスを生成するための Gradle プロジェクト構成
アーキテクチャー
この記事で作るアーキテクチャーのゴールのイメージは以下のとおりです。
改修の概要
Spring Native for JHipster で自動生成したプロジェクトに対し、以下の改修をします。
- Lambda 関数では不要となる Web(REST API)系の機能(ソースコードや依存関係)を除外する
- Spring Cloud Function と AWS Adapter を組み込む
- Spring Cloud Function で公開するための関数型 Bean(Java のソースコード)を作成する
その他、Native イメージを動かすために必要となる改修があります。
詳細はこの後記載していきますが、この記事で採用した方式のポイントを 2 点説明します。
Native イメージは static にリンクにする
AWS Lambda のカスタムランタイムで使用されている Amazon Linux 2 の glibc のバージョンが古かったため、static にリンクすることにしました。
実行環境の OS やライブラリのバージョンに依存したくないという意図もあります。
glibc を static にリンクしてしまうと LGPL の制約が気になりますので、libc には GraalVM の公式ドキュメントでも紹介されている musl libc を使っています。
リフレクション情報を GraalVM に渡す方法
Native イメージを作成する場合、ビルド時に動作に必要となる全てのクラス・リソースファイルを特定する必要がありますが、リフレクションを使用する場合はクラスやリソースファイルの情報を GraalVM に渡す必要があります。
Spring Native を使用した場合、GraalVM にリクレクションの情報を渡す方法が 2 つあります。
- GraalVM の JSON ファイル
- Spring Native の Native Hints アノテーション
どちらの方法でも同じことができますが、後に Blueprint を作成することを考慮して以下の方針としています。
- プロジェクトに変わらず共通のもの : Native Hints アノテーション
- プロジェクトごとに可変のもの(具体的には JDL によって変わるもの) : GraalVM の JSON ファイル
開発環境
開発用OS : Ubuntu 22.04 LTS
apt で upgrade / update できる範囲で最新化し、build-essential パッケージを追加します。
必須ではありませんが、日本語化しました。
SDKMAN : 構築時の最新(バージョン不問)
GraalVM のインストール用です。(リンク ⧉)
nodebrew : 構築時の最新(バージョン不問)
Node.js のインストール用です。(リンク ⧉ )
GraalVM Community : 22.3(Java17)
SDKMAN でインストールします。
Spring Native のドキュメント3には「GraalVM version 22.1.0 is supported」と書かれています。ですが動作に問題がないかぎり LTS の最新版を使うのが良いと思いますで、構築時の最新である 22.3 を使いました。
musl.cc : 10.2.1
構築時の最新である 11 系ではビルド時のエラーを解決できなかったため、10 系を使いました。
追加で zlib ⧉ をインストールする必要があります。 GraalVM のドキュメント ⧉ を参照。
Node.js : v16.18.1
nodebrew でインストールします。
構築時の最新 LTS である v18 系では JHipster が動作しないため、1 つ前の LTS である v16 系を使いました。
Spring Boot Native blueprint for JHipster : v1.3.2
先に記載しているため、ここでは説明を省きます。(リンク ⧉)
AWS Lambda Runtime Interface Emulator (RIE): 構築時の最新(バージョン不問)
Native イメージの動作確認に使用します。Docker は使用せず、開発環境に直接インストールして使っています。(リンク ⧉)
Java 関連の主な OSS バージョンは以下です。
Spring Native : 0.12.1
Spring Boot : 2.7.3
Spring Native のドキュメント3には「Spring Native 0.12.1 only supports Spring Boot 2.7.1, so change the version if necessary.」と書かれていますが、JHipster が使っているバージョンを使いました。
Spring Cloud Function (+ AWS Adapter) :3.2.8
Spring Native のドキュメント3には「Spring Native 0.12.1 has been tested against Spring Cloud 2021.0.3.」と書かれており、対応する Spring Cloud Function のバージョンは 3.2.5 ⧉ なのですが、構築時の最新を使いました。
ビルドツールは Gradle です。
JHipster が生成した Gradle プロジェクトに Gradle Wrapper として含まれており、バージョンは 7.4.2 です。
データベースは PostgreSQL です。
バージョンは気にしませんが、AWS では Amazon RDS(PostgreSQL)を使うことを想定していますので、近いバージョンのものを使いました。
改修の手順
それでは以降、以下の手順に従って改修内容を説明していきます。
- JHipster でプロジェクトを作成する
- Entity と Spring Data JPA Repository の生成
- Native イメージのビルドと起動の確認
- Spring Cloud Function の組み込み
- Spring Cloud Function で公開する関数を作成する
- Spring Cloud Function / Spring Native で動かすための修正
- ローカルでの動作確認
- AWS Lambda へのデプロイ
JHipster でプロジェクトを作成する
Spring Native for JHipster で プロジェクトを自動生成します。
適当なディレクトリ(ここでは jhipster-native-lambda )を作成し、 JHipster でプロジェクトを作成します。
$ mkdir jhipster-native-lambda
$ cd jhipster-native-lambda/
$ jhipster-native
自動生成時のオプション(クリックして開く/閉じる)
? Which *type* of application would you like to create? Monolithic application (recommended for simple projects)
? What is the base name of your application? jhipster
info Using blueprint generator-jhipster-native for server subgenerator
? Do you want to make it reactive with Spring WebFlux? No
? What is your default Java package name? com.mycompany.myapp
? Which *type* of authentication would you like to use? HTTP Session Authentication (stateful, default Spring Security mechanism)
? Which *type* of database would you like to use? SQL (H2, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL)
? Which *production* database would you like to use? PostgreSQL
? Which *development* database would you like to use? PostgreSQL
? Which cache do you want to use? (Spring cache abstraction) No cache - Warning, when using an SQL database, this will disable the Hibernate 2nd level cache!
? Would you like to use Maven or Gradle for building the backend? Gradle
? Do you want to use the JHipster Registry to configure, monitor and scale your application? No
? Which other technologies would you like to use?
? Which *Framework* would you like to use for the client? No client
? Would you like to enable internationalization support? No
? Please choose the native language of the application Japanese
? Besides JUnit and Jest, which testing frameworks would you like to use?
? Would you like to install other generators from the JHipster Marketplace? No
これで開発のベースとなる Gradle プロジェクトが生成されました。
Entity と Spring Data JPA Repository の生成
今回はサンプルとして以下のような JDL を作成します。
ファイルはプロジェクト直下に jhipster.jdl として保存します。
enum Gender {
MASCULINE ("男性"),
FEMININE ("女性"),
NEUTER ("中性")
}
entity Organization {
name String required minlength(1),
description String
}
entity Employee {
name String required minlength(1),
gender Gender,
dateOfBirth LocalDate
}
paginate Employee with pagination
filter Employee
relationship ManyToOne {
Employee{organization} to Organization{employee}
}
自動生成を実行します。
$ jhipster-native jdl ./jhipster.jdl
途中でファイルがコンフリクトしているというメッセージが出ますが上書きで問題ありません。
conflict src/main/resources/config/liquibase/master.xml
これでプロジェクトに Entity を操作する以下の CRUD 機能が追加されました。
- データベースのテーブルやシーケンスを作成するための Liquibase ChangeLog
- Spring Data JPA を使用した Entity、Repository、及び、検索機能を実装した Service クラス
Native イメージのビルドと起動の確認
確認のために、この段階で Native イメージを作成します。
ビルドではテスト系が動かないところがあるのでいくつかのタスクをスキップします。
$ ./gradlew clean build -xgenerateTestAot -xintegrationTest
ビルドが正常終了したところで Native イメージを作るのですが、デフォルトでは dynamic リンクになっているため static リンクに修正します。
build.gradle の修正
自動生成されたものでは複数のパラメータを受け取れるようになっていなかったので、以下のように修正しました。
graalvmNative {
binaries {
main {
(略)
buildArgs.add("${findProperty('nativeBuildArgs') ?: ''}")
↓
"${findProperty('nativeBuildArgs') ?: ''}".split(" ").each {arg ->
buildArgs.add(arg)
}
gradle.properties の修正
static にリンクするようにプロパティを追加します。
nativeBuildArgs = --static --libc=musl
以上で static にリンクするための修正は完了です。
ビルドと実行
Native イメージをビルドします。
$ ./gradlew nativeCompile
ビルド時に以下のような Stack Trace が出力されますが無視して問題ありません。
ERROR io.netty.handler.ssl.BouncyCastleAlpnSslUtils - Unable to initialize BouncyCastleAlpnSslUtils.
java.lang.ClassNotFoundException: org.bouncycastle.jsse.BCSSLEngine
at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:445)
ビルドが成功したら以下のコマンドで Native イメージを実行できます。
$ ./build/native/nativeCompile/native-executable
ですが、この状態では実行時にいくつかエラーが発生しますので対応します。
Type cannot be found エラーの対応
実行時に以下のようなエラーが発生します。
Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1005E: Type cannot be found 'org.springdoc.core.Constants'
SpringDoc の Native 用依存関係を追加する必要があります。build.gradle の dependencies に以下を追加します。
dependencies {
implementation 'org.springdoc:springdoc-openapi-native'
(略)
Resource not found エラーの対応
実行時に以下のようなエラーが発生します。
java.io.IOException: Resource not found: "org/joda/time/tz/data/ZoneInfoMap" ClassLoader: jdk.internal.loader.ClassLoaders$AppClassLoader@f5f2bb7
Joda-Time の リソースを Native イメージに含める必要があります。
com.mycompany.myapp.JhipsterApp4 クラスに、クラスのアノテーションとして以下を追記します。
@ResourceHint(patterns = {"org/joda/time/tz/data/.*"})
再度 Gradle の nativeCompile タスクを実行し、 native-executable 実行すると正常に起動するはずです。
ここまでで、JHipster の CRUD アプリケーションを Spring Native で起動することができました。
Spring Cloud Function の組み込み
プロジェクトに Spring Cloud Function を組み込んでいきます。
Maven モジュールのバージョン定義
gradle.properties に以下を追記し、各種バージョン情報を定義します。
springDependencyManagementVersion = 1.0.15.RELEASE
springCloudFunctionVersion = 3.2.8
awsLambdaEventsVersion = 3.11.0
awsLambdaCoreVersion = 1.2.2
liquibaseCoreVersion = 4.15.0
DependencyManagement プラグインの定義
settings.gradle のpluginManagement → plugins に以下を追加し、DependencyManagement プラグインを定義します。
pluginManagement {
plugins {
id 'io.spring.dependency-management' version "${springDependencyManagementVersion}"
(略)
build.gradle の修正
DependencyManagement プラグインを有効化するために、plugins に以下を追加します。
plugins {
id 'io.spring.dependency-management'
(略)
AWS Lambda では HTTP のポートを使用しないため、Web系の依存関係を dependencies から削除(コメントアウト)します。
dependencies {
// implementation "org.springdoc:springdoc-openapi-webmvc-core"
// implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
// implementation "org.springframework.boot:spring-boot-starter-web"
// implementation "org.springframework.boot:spring-boot-starter-tomcat"
(略)
dependencies に必要な Maven モジュールを追加します。
dependencies {
implementation 'org.springframework.cloud:spring-cloud-function-adapter-aws'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8'
compileOnly 'com.amazonaws:aws-lambda-java-events'
compileOnly 'com.amazonaws:aws-lambda-java-core'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
(略)
dependencyManagement で、バージョンを固定するものを指定します。
dependencyManagement {
dependencies {
dependency "org.liquibase:liquibase-core:${liquibaseCoreVersion}"
dependency "com.amazonaws:aws-lambda-java-events:${awsLambdaEventsVersion}"
dependency "com.amazonaws:aws-lambda-java-core:${awsLambdaCoreVersion}"
imports {
mavenBom "org.springframework.cloud:spring-cloud-function-dependencies:${springCloudFunctionVersion}"
}
}
}
依存関係の削除でコンパイルが通らなくなったソースコードをビルド対象外にします。
sourceSets {
main {
java {
exclude 'com/mycompany/myapp/config/DateTimeFormatConfiguration.java'
exclude 'com/mycompany/myapp/config/LocaleConfiguration.java'
exclude 'com/mycompany/myapp/config/SecurityConfiguration.java'
exclude 'com/mycompany/myapp/config/WebConfigurer.java'
exclude 'com/mycompany/myapp/security/PersistentTokenRememberMeServices.java'
exclude 'com/mycompany/myapp/service/MailService.java'
exclude 'com/mycompany/myapp/service/UserService.java'
exclude 'com/mycompany/myapp/web/rest/**'
exclude 'com/mycompany/myapp/ApplicationWebXml.java'
}
}
test {
java {
exclude 'com/mycompany/myapp/config/WebConfigurerTest.java'
exclude 'com/mycompany/myapp/service/UserServiceIT.java'
exclude 'com/mycompany/myapp/web/rest/**'
}
}
}
以上で Spring Cloud Function の組み込みは完了です。
Spring Cloud Function で公開する関数を作成する
ここまでで、JHipster により、Service クラスとして、検索(ページネーションあり/なし)・更新・削除が作成されています。
ここからは、Service クラスを関数化していきます。一例として「ページネーションありの検索」の実装例を紹介します。
Spring Cloud Function で関数を公開する方法は大きく 2 種類あります。
- Bean Factory 形式で Function (主に Java の Lambda 式)をリターンするようにする
- Function 型クラスを Bean として定義する
前者のほうが記載量が少なく書くのは楽なのですが、今回は Lambda 関数 1 つに対して 1 トランザクションとするために後者を採用しています5。
返却用のコンテナクラスを作成
ページネーションの処理は org.springframework.data.domain.Page というインターフェースを使用しています。
このインターフェースは java.util.Iterable を継承しています。
Spring Cloud Function の中に返却用のオブジェクトが Iterable の場合に Collection にキャストする処理があり、ClassCastException を回避できなかったので返却用にラッパークラスを作ることにしました。
これは今後のバージョンアップ等で不要になるかもしれません。
com.mycompany.myapp.data.domain.PagedContents インターフェース(クリックして開く/閉じる)
package com.mycompany.myapp.data.domain;
import java.util.List;
public interface PagedContents<T> {
int getNumber();
int getSize();
int getTotalPages();
long getTotalElements();
List<T> getContent();
}
com.mycompany.myapp.data.domain.PageWrappedPagedContents クラス(クリックして開く/閉じる)
package com.mycompany.myapp.data.domain;
import java.util.List;
import org.springframework.data.domain.Page;
public class PageWrappedPagedContents<T> implements PagedContents<T> {
private Page<T> wrapTargetPage;
protected PageWrappedPagedContents(Page<T> wrapTargetPage) {
this.wrapTargetPage = wrapTargetPage;
}
public static <T> PageWrappedPagedContents<T> wrap(Page<T> wrapTargetPage) {
return new PageWrappedPagedContents<T>(wrapTargetPage);
}
@Override
public int getTotalPages() {
return this.wrapTargetPage.getTotalPages();
}
@Override
public long getTotalElements() {
return this.wrapTargetPage.getTotalElements();
}
@Override
public List<T> getContent() {
return this.wrapTargetPage.getContent();
}
@Override
public int getNumber() {
return this.wrapTargetPage.getNumber();
}
@Override
public int getSize() {
return this.wrapTargetPage.getSize();
}
}
検索用の関数クラスを作成
Spring Cloud Function(AWS Adapter)では、AWS Lambda の「ハンドラー名」と一致する名前の関数 Bean が実行されます(以下では searchEmployees)。
com.mycompany.myapp.function.SearchEmployees クラス(クリックして開く/閉じる)
package com.mycompany.myapp.function;
import java.io.Serializable;
import java.util.function.Function;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.mycompany.myapp.data.domain.PageWrappedPagedContents;
import com.mycompany.myapp.data.domain.PagedContents;
import com.mycompany.myapp.domain.Employee;
import com.mycompany.myapp.function.SearchEmployees.SearchParam;
import com.mycompany.myapp.service.EmployeeQueryService;
import com.mycompany.myapp.service.criteria.EmployeeCriteria;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Component("searchEmployees")
@Transactional
@Slf4j
@RequiredArgsConstructor
public class SearchEmployees implements Function<SearchParam, PagedContents<Employee>> {
private final EmployeeQueryService employeeQueryService;
@Override
public PagedContents<Employee> apply(SearchParam param) {
log.debug("Lambda request to get Employees by criteria: {}", param);
return PageWrappedPagedContents.wrap(
employeeQueryService.findByCriteria(param.getCriteria(),
param.getPageable() != null ? param.getPageable() : Pageable.unpaged()));
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SearchParam implements Serializable {
private static final long serialVersionUID = 1L;
private EmployeeCriteria criteria;
private Pageable pageable;
}
}
Service クラスを Spring Cloud Function の関数で公開するための修正は以上です。
Spring Cloud Function / Spring Native で動かすための修正
追加で JSON のデシリアライザーの作成や Native イメージで動かすための修正が必要になります。
JhipsterApp クラスの修正
JSON のデシリアライズ時にリフレクションでアクセスされるクラスに @TypeHint が必要になります。
JhipsterApp4 クラスにクラスアノテーションとして以下を追加します。
JHipster には、下記の 4 つ以外にも Filter クラスが用意されていますが、今回は使うものだけを記載しています。
@TypeHint(types = {
DateFormatter.class,
LongFilter.class,
StringFilter.class,
LocalDateFilter.class
}, access = {
TypeAccess.PUBLIC_METHODS,
TypeAccess.PUBLIC_CONSTRUCTORS
})
PageableDeserializer クラスの作成
Spring Data JPA の Pageable インターフェースはデフォルトでは JSON にデシリアライズができなかったため、簡単なデシリアライザーを作成しました。
com.mycompany.myapp.jackson.databind.deser.PageableDeserializer クラス(クリックして開く/閉じる)
package com.mycompany.myapp.jackson.databind.deser;
import java.io.IOException;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.NullHandling;
import org.springframework.data.domain.Sort.Order;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class PageableDeserializer extends StdDeserializer<Pageable> {
private int defaultPageSize = 20;
public PageableDeserializer() {
this(Pageable.class);
}
protected PageableDeserializer(Class<?> vc) {
super(vc);
}
public void setDefaultPageSize(int defaultPageSize) {
this.defaultPageSize = defaultPageSize;
}
@Override
public Pageable deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
log.debug("default-pagesize:" + defaultPageSize);
try {
JsonNode node = p.getCodec().readTree(p);
int pageSize = defaultPageSize;
if (node.has("pageSize")) {
pageSize = node.get("pageSize").asInt();
}
int pageNumber = 0;
if (node.has("pageNumber")) {
pageNumber = node.get("pageNumber").asInt();
}
Sort sort = null;
if (node.has("sort")) {
JsonNode sortNode = node.get("sort");
if (sortNode.has("orders")) {
JsonNode ordersNode = sortNode.get("orders");
sort = Sort.by(
StreamSupport
.stream(ordersNode.spliterator(), false)
.map(e -> {
Order o = null;
if (e.has("property")) {
o = Order.by(e.get("property").asText());
o = e.has("direction")
? o.with(Direction.valueOf(e.get("direction").asText()))
: o;
o = e.has("ignoreCase") && e.get("ignoreCase").asBoolean() ? o.ignoreCase()
: o;
o = e.has("nullHandling")
? o.with(NullHandling.valueOf(e.get("nullHandling").asText()))
: o;
}
return o;
})
.filter(Objects::nonNull)
.collect(Collectors.toList()));
}
}
Pageable ret = sort != null ? PageRequest.of(pageNumber, pageSize, sort)
: PageRequest.of(pageNumber, pageSize);
return ret;
} catch (Exception e) {
log.error("Pageable deserialize error.", e);
}
return Pageable.unpaged();
}
}
FunctionConfiguration クラスの作成
作成したデシリアライザーを Spring で管理している Jackson に追加する設定です。
com.mycompany.myapp.config.FunctionConfiguration クラス(クリックして開く/閉じる)
package com.mycompany.myapp.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Pageable;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.mycompany.myapp.jackson.databind.deser.PageableDeserializer;
@Configuration
public class FunctionConfiguration {
@Value("${spring.data.web.pageable.default-page-size:20}")
private int defaultPageSize;
@Bean
public SimpleModule pageableDeserializerModule() {
SimpleModule module = new SimpleModule() {
{
PageableDeserializer pageableDeserializer = new PageableDeserializer();
pageableDeserializer.setDefaultPageSize(defaultPageSize);
this.addDeserializer(Pageable.class, pageableDeserializer);
}
@Override
public void setupModule(SetupContext context) {
super.setupModule(context);
}
};
return module;
}
}
reflect-config.json の作成
JPA の Criteria API で使用するクラスはリフレクションでアクセスされるため、コンパイラへ情報を渡す必要があります。
ここでは
- src/main/resources/META-INF/native-image/com.mycompany.myapp/reflect-config.json
を作成し、リフレクションの情報を記載します。
下記でクラス名の最後に「_」が付与されるものは hibernate-jpamodelgen によって自動生成されるクラスです。
[
{
"name": "com.mycompany.myapp.domain.Employee_",
"allPublicFields": true
},
{
"name": "com.mycompany.myapp.domain.Organization_",
"allPublicFields": true
},
{
"name": "com.mycompany.myapp.service.criteria.EmployeeCriteria",
"allPublicMethods": true,
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"name": "com.mycompany.myapp.service.criteria.EmployeeCriteria$GenderFilter",
"allPublicMethods": true,
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
}
]
Spring Native のプロファイルに関する注意点とソースコードの修正
Native イメージを作成する場合、プロファイルはコンパル時に固定になってしまいます。
Spring Native のドキュメント3には「使うのは避けるべき」とありますが、JHipster はプロファイルを使用しているため、ビルド時に「-Pprod」「-Pdev」等のオプションを付けることで対応します。
1 つだけ修正が必要なクラスがあります。
Spring Boot 起動時に実行される Liquibase のデータベース更新処理です。
- com.mycompany.myapp.config.LiquibaseConfiguration
というクラスが JHipster によって自動生成されますが、その中に
// If you don't want Liquibase to start asynchronously, substitute by this:
// SpringLiquibase liquibase = SpringLiquibaseUtil.createSpringLiquibase(liquibaseDataSource.getIfAvailable(), liquibaseProperties, dataSource.getIfUnique(), dataSourceProperties);
SpringLiquibase liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase(
this.env,
executor,
liquibaseDataSource.getIfAvailable(),
liquibaseProperties,
dataSource.getIfUnique(),
dataSourceProperties
);
という箇所があります。
この先にプロファイルによって Liquiabse の処理を同期・非同期で実行する分岐があり、非同期で実行されると DDL の更新と AWS Lambda のイベント処理が並行で動いてしまうことがあります。
プロダクションでも開発でも Liquibase の処理を非同期で動かす必要はないと思いますので、LiquibaseConfiguration のソースコードをコメントに従って以下のように修正します。
// If you don't want Liquibase to start asynchronously, substitute by this:
SpringLiquibase liquibase = SpringLiquibaseUtil.createSpringLiquibase(liquibaseDataSource.getIfAvailable(), liquibaseProperties, dataSource.getIfUnique(), dataSourceProperties);
/* SpringLiquibase liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase(
this.env,
executor,
liquibaseDataSource.getIfAvailable(),
liquibaseProperties,
dataSource.getIfUnique(),
dataSourceProperties
); */
Spring Native で動かすための修正は以上になります。
Native イメージのビルドは例えば dev プロファイルにしたい場合は以下のようにします。
$ ./gradlew -Pdev nativeCompile
ローカルでの動作確認
Native イメージのビルドが完了したら、RIE を使用して動作確認します。
以下のコマンドで RIE を起動します。
$ export SPRING_DATASOURCE_URL={Spring Data Source の JDBC URL}
$ export SPRING_DATASOURCE_USERNAME={データベースのユーザID}
$ export SPRING_DATASOURCE_PASSWORD={データベースのパスワード}
$ export _HANDLER=searchEmployees
$ $HOME/.aws-lambda-rie/aws-lambda-rie {プロジェクトの絶対パス}/build/native/nativeCompile/native-executable
別のターミナルから curl 等で Lambda 関数へのメッセージを送信します。
$ curl -XPOST "http://localhost:8080/2015-03-31/functions/function/invocations" -d @- <<EOF
{
"criteria": {
"id": {
"greaterThan": 0
}
},
"pageable": {
"pageNumber": 0,
"sort": {
"orders": [
{
"property": "id",
"direction": "DESC"
}
]
}
}
}
EOF
正常にビルドできていれば検索結果の JSON が返信されます。
AWS Lambda へのデプロイ
AWS Lambda へのデプロイ方法はこの記事の本題ではないので詳細は省きますが、ポイントを記載します。
AWS Lambda ランタイムの選択
生成された Native イメージは AWS Lambda サービスへのイベントループを自身で行うアプリケーションです。
そのため、カスタムランタイムを想定していますが、コンテナイメージでも実行可能です。
JHipster で生成した Gradle プロジェクトには Jib でコンテナイメージを作成する機能が含まれていますので、軽微な修正でコンテナイメージを Amazon Elastic Container Registry(ECR)に登録することができます。
データベース接続
Spring Boot の環境変数は Native イメージでも利用可能です。
データベースにあわせて接続先の環境変数を設定します。
SPRING_DATASOURCE_URL
SPRING_DATASOURCE_USERNAME
SPRING_DATASOURCE_PASSWORD
自動生成された設定では、データソースに HikariDataSource が設定されています。
ユースケースによって異なりますが、プールサイズを 1 にするか、コネクションプールを使用せずに DriverManagerDataSource 等を使用するかのどちらかになると思います。
ハンドラー名
Spring Cloud Function(AWS Adapter)では、AWS Lambda の「ハンドラー名」と一致する名前の関数 Bean を実行します。
今回のサンプルの場合はハンドラー名を「searchEmployees」とします。
メモリ
メモリが少ないと動作が重くなり、場合によってはエラー(リトライ)になります。検証時はメモリを 512 MB に設定しました。
JVM で動かした場合と Native イメージで動かした場合の比較
手作業で数回実施の平均値ですが、以下の結果となりました。
( JVM → Native )
- 初期化 : 8 秒 → 2 秒
- 実行(コールド) : 1180 ミリ秒 → 80 ミリ秒
- 実行(ウォーム) : 70 ミリ秒 → 20 ミリ秒
- メモリ使用量 : 295 MB → 275 MB
メモリ使用量は大差ありませんでしたが、実行時間は全体的に改善されました。
Lambda 関数の実行時間は最大 15 分までの間で指定できますが、初期化時間は最大 10 秒固定であり、初期化処理で 10 秒を超えるとリトライされてしまいます。
今回は特にチューニングをしていない状態ではありますが、JVM の初期化にかかる 8 秒という時間は制限の 10 秒を考慮すると特に厳しい値ですので、Native イメージ化しない場合はなんらかの改善が必要になると思われます。
最後に
GraalVM が登場してからも Java の Native 化は(主に精神的に)ハードルが高かったと思うのですが、Spring Framework が正式に取り込んだことでハードルは下がったと思います。
Spring Framework 6 / Spring Boot 3 での Native 化対応は Spring Native からの変更点もあるはずですので、新しいバージョンでも試していきたいと思います。
JHipster 関連記事
- JHipster によるエンタープライズアプリケーションの構築(1) 紹介編
- JHipster によるエンタープライズアプリケーションの構築(2) Spring Nativeで高速起動化しAWS Lambdaで動かす
Footnotes
-
Spring Native は Spring Projects Experimental として実験的に開発されてきましたが、2022/11 に正式公開された Spring Framework 6 / Spring Boot 3 に統合されました。
その後 Spring Native プロジェクトは停止されましたが、Spring Framework の Native イメージ化が継続して公式にサポートされる見通しとなりました。JHipster はまだ Spring Framework 6 / Spring Boot 3 には対応していないため、この記事で使用している Spring Framework のバージョンは若干古いものになります。 ↩ -
Spring Native for JHipster では自動生成された画面機能を Native イメージで動かすことができますが、使う機能によっては不足しているものもあります。そのため、この記事で紹介するような修正が必要になることがあります。 ↩
-
Spring Native のドキュメントは現在 HTML ページが Spring Boot のページにリダイレクトされてしまっており、PDF ⧉のみが参照可能です。 ↩ ↩2 ↩3 ↩4
-
com.mycompany.myapp.JhipsterApp クラスは自動生成された Spring Boot のメインクラスです。JHipster のプロジェクト名からパッケージ名とクラス名が決まります。 ↩ ↩2
-
今回作成する関数にはビジネスロジックが無い(自動生成された Query Service クラスのメソッドを 1 つ実行するだけ)ため、関数のアノテーションとして @Transactional を付与したいのですが、Java の Lambda 式にはアノテーションを付与できないためです。
ビジネスロジックが入る関数を作成する場合は Service 層のメソッドでトランザクション制御を行うように作成し、それを Java の Lambda 式で公開するほうが楽になると思います。 ↩