JVMベースのMicroservices用フルスタックフレームワークMicronautを試してみる


はじめに

この記事は NTTテクノクロス Advent Calendar 2018の9日目です。

こんにちは、NTTテクノクロスの中野です。

弊社の会社ブログ的なところでGroovyやGrailsの話を書いたりしてます。

今回は、Grails開発チームがお贈りする新しいフレームワーク、Micronautについて紹介します。


Micronautとは

みなさんご存知の通り、GrailsはGroovyベースのフルスタックなWebアプリケーションフレームワークですが、そのGrailsのコア開発チームがMicroservices用に一からフルスクラッチした新しいJVMベースのフルスタックフレームワークが Micronautです。宇宙飛行士の Astronaut (アストロノート)と同じように「マイクロノート」と発音します。現在の最新バージョンは1.0.1です。


  • とにかくMicroservicesに特化している

  • 実装言語としてJava/Groovy/Kotlinが使える

  • 実行可能JARのファイルサイズが小さい

  • メモリフットプリントが小さい

  • (コンパイル時に色々解決しておくため)アプリの起動が速い

  • 宣言的にREST APIを扱える(=他のサービスをつつきやすい)

  • フィーチャ(Feature)として様々なオプション機能が提供されている(Kafka, Zipkin, Consul, etc.)

  • Spring Frameworkに依存していない(DI機構は自前)

  • ....

などなど、色々特徴があるのですが、ここではあまり深く突っ込まずに手を動かしながらみていきます。


インストールする

JVM系ツールのパッケージマネージャであるSDKMANを使うと簡単にインストールできます。


$ sdk install micronaut

Downloading: micronaut 1.0.1

In progress...

######################################################################## 100.0%

Installing: micronaut 1.0.1
Done installing!

Setting micronaut 1.0.1 as default.

MicronautではmnというCLIコマンドが提供されていて、これを使って色々やります。

何ができるのかhelpコマンドでみてみると...

$ mn help

Usage: mn [-hnvVx] [COMMAND]
Micronaut CLI command line interface for generating projects and services.
Commonly used commands are:
create-app NAME
create-cli-app NAME
create-federation NAME --services SERVICE_NAME[,SERVICE_NAME]...
create-function NAME

Options:
-h, --help Show this help message and exit.
-n, --plain-output Use plain text instead of ANSI colors and styles.
-v, --verbose Create verbose output.
-V, --version Print version information and exit.
-x, --stacktrace Show full stack trace when exceptions occur.

Commands:
help Prints help information for a specific command
create-bean Creates a singleton bean
create-job Creates a job with scheduled method
create-client Creates a client interface
create-controller Creates a controller and associated test
create-websocket-client Creates a Websocket client
create-websocket-server Creates a Websocket server

Web API(create-app)以外にも、CLIアプリやFunctionも作れるみたいですね。


アプリケーションプロジェクトを生成してみる

とりあえず、一番普通っぽいアプリケーション形式でプロジェクトを生成してみます。

$ mn create-app micronaut-sample

| Generating Java project...
| Application created at /tmp/micronaut-sample

↓こんなファイル群が生成されます。

$ tree

micronaut-sample
├── Dockerfile
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── micronaut-cli.yml
└── src
├── main
│ ├── java
│ │ └── micronaut
│ │ └── sample
│ │ └── Application.java
│ └── resources
│ ├── application.yml
│ └── logback.xml
└── test
└── java
└── micronaut
└── sample

デフォルトなので、言語はJavaになっています。みんな大好きGradleのGradle Wrapperももちろん標準装備です。Microservices用ということでDockerfileも標準装備です。いたれりつくせりです。

build.gradleで依存ライブラリをみてみると、http系やバリデータ系のライブラリが入ってますね。

//...

dependencies {
annotationProcessor "io.micronaut:micronaut-inject-java"
annotationProcessor "io.micronaut:micronaut-validation"
compile "io.micronaut:micronaut-inject"
compile "io.micronaut:micronaut-validation"
compile "io.micronaut:micronaut-runtime"
compile "io.micronaut:micronaut-http-client"
compile "io.micronaut:micronaut-http-server-netty"
compileOnly "io.micronaut:micronaut-inject-java"
runtime "ch.qos.logback:logback-classic:1.2.3"
testCompile "junit:junit:4.12"
testCompile "io.micronaut:micronaut-inject-java"
testCompile "org.hamcrest:hamcrest-all:1.3"
}
//...


素のアプリケーションを起動してみる

まだ何ひとつ実装してませんが、とりあえずアプリケーションを起動してみましょう。

Grailsとは違って、Micronautのmnコマンドはテンプレートからの生成系のコマンドしかサポートしていません。実行/ビルド系はGradleに一本化されているようです。

というわけで、Gradle Wrapperのgradlewスクリプトを使ってrunタスクを実行してみます。初回はGradle Wrapperで必要なファイルや、Micronautの依存ライブラリが自動的にダウンロードされるので、しばらく待ちましょう。

$ ./gradlew run

....(snipped).....
> Task :run
14:56:48.895 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 735ms. Server Running: http://localhost:8080

という感じでログが表示されたら起動成功です。runタスクの場合はプロンプトが返って来ません。CTRL+Cすれば止まります。

別のターミナルを開いて、HTTPieで接続してみましょう。

$ http :8080

HTTP/1.1 404 Not Found
Date: Mon, 3 Dec 2018 15:00:16 GMT
connection: close
content-length: 77
content-type: application/json

{
"_links": {
"self": {
"href": "/",
"templated": false
}
},
"message": "Page Not Found"
}

JSONでなんかそれっぽい404レスポンスが返ってきました。


REST APIでHello Worldしてみる

さて、200レスポンスが返るようにコントローラを生成します。

$ mn create-controller hello

| Rendered template Controller.java to destination src/main/java/micronaut/sample/HelloController.java
| Rendered template ControllerTest.java to destination src/test/java/micronaut/sample/HelloControllerTest.java

コントローラのデフォルト実装はこんな感じ。

//...

@Controller("/hello")
public class HelloController {
@Get("/")
public HttpStatus index() {
return HttpStatus.OK;
}
}

アプリを起動し直して、/helloにアクセスしてみると...

$ http :8080/hello

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 15:35:59 GMT
connection: keep-alive
transfer-encoding: chunked

200なレスポンスが返ってきました。

Hello Worldとしてはテキストでボディも返しておきたいので、コントローラを次のように修正します。

//...

@Controller("/hello")
public class HelloController {
@Get(produces = MediaType.TEXT_PLAIN) // producesを指定しない場合、デフォルトはJSON
public String index() {
return "Hello, Micronaut!";
}
}

アプリを起動し直して、/helloにアクセスしてみると...

$ http :8080/hello

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 15:46:10 GMT
connection: keep-alive
content-length: 17
content-type: text/plain

Hello, Micronaut!

できました。

ちなみに起動時のログはこう↓でしたが(再掲)、

$ ./gradlew run

....(snipped).....
> Task :run
14:56:48.895 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 735ms. Server Running: http://localhost:8080

...お分かりになっただろうか。

Startup completed in 735ms

1秒かからずに起動してますね。サクッと起動するので、この程度であれば自動リロード機能がなくてもあまり辛くなさそうです。ただコード量が増えたときにどのぐらい遅くなるのかは気になります。

なお、Spring Loadedによる自動リロード機能はフィーチャとしては存在しますが、


Spring-Loaded is not actively maintained and currently only supports Java versions < 9.


ということで、Java 8で開発する分には使えますが、それ以降の場合はまだ使えないようです。

さて、メモリフットプリントはどうなってるでしょうか。ちょっといい加減ですが、jvisualvmでモニタリングしつつ手動でGCを発動して底を打ったときのメモリ使用量を見てみると、

memoryfootprint-micronaut.png

と、瞬間最低で6MB台を記録しています。これは確かに相当小さいですね。比較対象として、Grails 3.3.8でREST API向けプロジェクトの素の状態で生成したアプリを起動してみると、

memoryfootprint-grails.png

と約88MBでした。Micronautでは相当カリカリに削りこんできたなぁという感じがしますね。


テストを書いてみる

さて、create-controllerしたときにHelloControllerTestテストクラスも一緒に生成されていました。Javaベースのプロジェクトの場合は、テスティングフレームワークとしてJUnit 4.xが使われます。

//...

public class HelloControllerTest {
@Test
public void testIndex() throws Exception {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class)) {
try (RxHttpClient client = server.getApplicationContext().createBean(RxHttpClient.class, server.getURL())) {
assertEquals(HttpStatus.OK, client.toBlocking().exchange("/hello").status());
}
}
}
}

これはモックを使ったユニットテストではなく、実際にアプリを起動してそのポートにアクセスするEnd-to-Endテストになっています。

テストの実行は、Gradleのtestタスク(またはcheckタスク)を使います。

$ ./gradlew test

....
BUILD SUCCESSFUL in 4s
5 actionable tasks: 5 executed

自動生成されたテストコードでは、「レスポンスのHTTPステータスが200かどうか」をチェックしてますが、コントローラを改造してボディも返すようにしたので、それに対応してみます。

public class HelloControllerTest {

@Test
public void testIndex() throws Exception {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class)) {
try (RxHttpClient client = server.getApplicationContext().createBean(RxHttpClient.class, server.getURL())) {
assertEquals("Hello, Micronaut!", client.toBlocking().retrieve("/hello"));
}
}
}
}

テストを実行すると、期待通りパスします。

$ ./gradlew test

....
BUILD SUCCESSFUL in 4s
5 actionable tasks: 5 executed

ちなみにわざと変な期待値を書いてテストを失敗させると、

assertEquals("Bye!", client.toBlocking().retrieve("/hello"));

$ ./gradlew test

> Task :test

micronaut.sample.HelloControllerTest > testIndex FAILED
org.junit.ComparisonFailure at HelloControllerTest.java:16

1 test completed, 1 failed

> Task :test FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///tmp/micronaut-sample/build/reports/tests/test/index.html

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
Use '--warning-mode all' to show the individual deprecation warnings.
See https://docs.gradle.org/4.10/userguide/command_line_interface.html#sec:command_line_warnings

BUILD FAILED in 4s
4 actionable tasks: 2 executed, 2 up-to-date

こんな感じでエラーになります。

Gradleのテストレポートであるmicronaut-sample/build/reports/tests/test/index.htmlをブラウザで開いてみると、

micronaut-gradle-test-report.png

こんな感じで何がまずかったのかを確認できます。


実行可能JARをビルドしてみる

さて、次にデプロイ用のJARファイルを生成してみます。

Gradleのassembleタスクを実行するとbuild/libs配下に2つのJARファイルが生成されます。

$ ./gradlew assemble

...
$ tree -sh build/libs
build/libs
├── [ 11M] micronaut-sample-0.1-all.jar
└── [7.3K] micronaut-sample-0.1.jar

このうちの-allが付いているほうが、全部入りの実行可能なFat JARになっています。

参考までに、Grails 3.3.8でREST API向けプロジェクトの素の状態で生成したFat JARのサイズは38MB程度です。この11MBというサイズは、1/3程度でかなり小さくなった感じです。

試しに起動してみましょう。

$ java -jar build/libs/micronaut-sample-0.1-all.jar

16:21:37.404 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 971ms. Server Running: http://localhost:8080

$ http :8080/hello

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 16:21:59 GMT
connection: keep-alive
content-length: 17
content-type: text/plain

Hello, Micronaut!

動きますね。


Dockerイメージにしてみる

前述のようにDockerfileが標準装備されています。

FROM openjdk:8u171-alpine3.7

RUN apk --no-cache add curl
COPY build/libs/*-all.jar micronaut-sample.jar
CMD java ${JAVA_OPTS} -jar micronaut-sample.jar

まだopenjdk:8u171-alpine3.7になっていますが、そこはスルーしておきます。

dockerコマンドでイメージをビルドします。

$ docker build . -t micronaut-sample

イメージサイズを確認してみます。

$ docker images

REPOSITORY TAG IMAGE ID CREATED SIZE
micronaut-sample latest 5242b7291de8 2 minutes ago 114MB
openjdk 8u171-alpine3.7 1caad94162ef 4 months ago 102MB

micronaut-sampleは114MBでした。ベースのopenjdk:8u171-alpine3.7が102MBなので、アプリとしては114-102=12MBでほぼJARファイルの分ですね。

コンテナを起動してみます。

$ docker run -it --rm -p 8080:8080 micronaut-sample

07:44:58.298 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 2369ms. Server Running: http://79df3644f4e4:8080

起動に2秒以上かかっています。素で起動するよりもオーバヘッドが大きいようです。

$ http :8080/hello

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 07:44:59 GMT
connection: keep-alive
content-length: 17
content-type: text/plain

Hello, Micronaut!

アクセスできますね。


(実験的機能) GraalVMで起動してみる

Micronautは、実験的機能(厳密には"Incubating Phase")としてGraalVMでの実行に対応しています。

GraalVMサポートはフィーチャ(Feature)のひとつとして実装されています。create-appするときにフィーチャを指定すると、build.gradleに必要な設定が追加されていたり、便利なスクリプトなどが追加されたりします。ただ、既存のプロジェクトに後からフィーチャを追加する方法は(少なくともまだ)提供されていないので、ここでは別のダミープロジェクトを生成して、そこから必要そうなファイルをピックアップすることで試してみます。

$ mkdir /tmp/graalvm

$ cd /tmp/graalvm
$ mn create-app micronaut-sample --features=graal-native-image

# ↓この3つのファイルがポイント
$ cp /tmp/graalvm/micronaut-sample/build.gradle /tmp/micronaut-sample/
$ cp /tmp/graalvm/micronaut-sample/Dockerfile /tmp/micronaut-sample/DockerfileForGraalVM
$ cp /tmp/graalvm/micronaut-sample/src/main/java/micronaut/sample/MicronautSubstitutions.java /tmp/micronaut-sample/src/main/java/micronaut/sample/

$ cd /tmp/micronaut-sample
$ ./gradlew clean assemble
$ docker build . -f DockerfileForGraalVM -t micronaut-sample-graalvm
....
Successfully built 5dc364049043
Successfully tagged micronaut-sample-graalvm:latest

このイメージのビルドは結構時間がかかるので、辛抱強く待ちます。

さて、コンテナを起動してみましょう。

$ docker run -it --rm -p 8080:8080  micronaut-sample-graalvm

09:10:59.735 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 26ms. Server Running: http://66cb51a477b7:8080

驚異の起動時間26msを達成。何これすごい。

$ http :8080/hello

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 09:12:13 GMT
connection: keep-alive
content-length: 17
content-type: text/plain

Hello, Micronaut!

普通に動いてますね。

ちなみに、なぜかGraalVM版のコンテナはCTRL+Cで終わらないので、docker killで落としましょう。

Dockerのイメージサイズはというと、

$ docker images

REPOSITORY TAG IMAGE ID CREATED SIZE
micronaut-sample-graalvm latest 5dc364049043 4 minutes ago 1.67GB
oracle/graalvm-ce 1.0.0-rc8 88858be11628 6 weeks ago 1.56GB

アホみたいにデカいです。Oracleのoracle/graalvm-ce自体がデカいですね。あまりにもデカすぎな気がしますが、そのうち小さくなるんでしょうか。

なお、現在のGraalVMサポートとしてはGroovyで実装したアプリは非対応です。GraalVMで生成するネイティブイメージは、現時点ではリフレクションを部分的にしかサポートしていないのですが、Groovyはそのリフレクションに強く依存しているためです。残念。


(おまけ) Groovy/Spock/GORMでGrailsっぽくアプリを実装してみる

GORM (Grails' Object Relational Mapping)は、Groovy版Active Record的なORマッパであり、Grailsの主要な便利機能のひとつなのですが、最近はSpring BootなどGrails以外のフレームワークと組み合わせて使えるようにStandalone GORMというのが提供されています。

MicronautでGORMを使うには、hibernate-gormフィーチャを指定してプロジェクトを生成します。

hibernate-gormフィーチャを指定すると自動的にgroovyフィーチャもONになり、プログラミング言語がGroovyになります。また、groovyフィーチャが追加されると自動的にspockフィーチャもONになり、テスティングフレームワークもSpockになります。

$ mn create-app micronaut-sample-gorm --features=hibernate-gorm

| Generating Groovy project...
| Application created at /tmp/micronaut-sample-gorm

生成されるファイル構成はこんな感じ。なぜかsrc/test/javaディレクトリが無駄に生成されています。まあ、バグですかね。

$ tree

micronaut-sample-gorm
├── Dockerfile
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── micronaut-cli.yml
└── src
├── main
│ ├── groovy
│ │ └── micronaut
│ │ └── sample
│ │ └── gorm
│ │ └── Application.groovy
│ └── resources
│ ├── application.yml
│ └── logback.xml
└── test
├── groovy
│ └── micronaut
│ └── sample
│ └── gorm
└── java
└── micronaut
└── sample
└── gorm

依存関係はこんな感じ。

//.....

dependencies {
compile "io.micronaut.configuration:micronaut-hibernate-validator"
compile "io.micronaut.configuration:micronaut-hibernate-gorm"
compile "io.micronaut:micronaut-http-client"
compile "io.micronaut:micronaut-http-server-netty"
compile "javax.annotation:javax.annotation-api"
compile "io.micronaut:micronaut-runtime-groovy"
compile "io.micronaut:micronaut-validation"
compileOnly "io.micronaut:micronaut-inject-groovy"
runtime "ch.qos.logback:logback-classic:1.2.3"
runtime "com.h2database:h2"
runtime "org.apache.tomcat:tomcat-jdbc"
testCompile "io.micronaut:micronaut-inject-groovy"
testCompile "junit:junit:4.12"
testCompile "io.micronaut:micronaut-inject-java"
testCompile "org.hamcrest:hamcrest-all:1.3"
testCompile("org.spockframework:spock-core") {
exclude group: "org.codehaus.groovy", module: "groovy-all"
}
}
//.....

設定ファイルであるapplication.ymlの中身はこんな感じ。

---

micronaut:
application:
name: micronaut-sample-gorm

---
hibernate:
hbm2ddl:
auto: update
cache:
queries: false
use_second_level_cache: true
use_query_cache: false
region.factory_class: org.hibernate.cache.ehcache.SingletonEhCacheRegionFactory
dataSource:
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
pooled: true
jmxExport: true
driverClassName: org.h2.Driver
username: sa
password: ''

なんとなく雰囲気がわかったので、TODO管理するREST APIをエイヤっと実装してみたプロジェクトがこちらです。

起動して適当にアクセスしてみます。

$ http :8080/todo

HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:48:32 GMT
connection: keep-alive
content-length: 2
content-type: application/json

[]

$ http post :8080/todo title=牛乳 deadline=明日
HTTP/1.1 201 Created
Date: Mon, 3 Dec 2018 22:48:42 GMT
connection: keep-alive
content-length: 45
content-type: application/json

{
"deadline": "明日",
"id": 1,
"title": "牛乳"
}

$ http post :8080/todo title=パン deadline=今日
HTTP/1.1 201 Created
Date: Mon, 3 Dec 2018 22:49:04 GMT
connection: keep-alive
content-length: 45
content-type: application/json

{
"deadline": "今日",
"id": 2,
"title": "パン"
}

$ http :8080/todo
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:16 GMT
connection: keep-alive
content-length: 93
content-type: application/json

[
{
"deadline": "明日",
"id": 1,
"title": "牛乳"
},
{
"deadline": "今日",
"id": 2,
"title": "パン"
}
]

$ http :8080/todo/1
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:21 GMT
connection: keep-alive
content-length: 45
content-type: application/json

{
"deadline": "明日",
"id": 1,
"title": "牛乳"
}

$ http put :8080/todo/1 title=豆乳 deadline=明日
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:25 GMT
connection: keep-alive
content-length: 45
content-type: application/json

{
"deadline": "明日",
"id": 1,
"title": "豆乳"
}

$ http :8080/todo
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:51 GMT
connection: keep-alive
content-length: 93
content-type: application/json

[
{
"deadline": "明日",
"id": 1,
"title": "豆乳"
},
{
"deadline": "今日",
"id": 2,
"title": "パン"
}
]

$ http delete :8080/todo/2
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:54 GMT
connection: keep-alive
transfer-encoding: chunked

$ http :8080/todo
HTTP/1.1 200 OK
Date: Mon, 3 Dec 2018 22:49:56 GMT
connection: keep-alive
content-length: 47
content-type: application/json

[
{
"deadline": "明日",
"id": 1,
"title": "豆乳"
}
]

この程度ならもっと簡単に瞬殺できるかと思いきや、2〜3時間かかってしまった...。言い訳というか感想は↓こんな感じ。


  • Standalone GORMは、GrailsのGORMと比較して微妙に挙動(設定?)が異なる部分があってハマった。

  • コントローラのテストがGrailsのようなモック式ではなく、実際のEnd-to-Endなのは良い。


    • が、低レベルAPIでコントローラのテストを書くのはつらい。あとで、宣言的RESTクライアントというやつで書き直してみたい。



  • productionレベルで使うにはもっと突っ込んだプロトタイプで試さないと不安。


    • まだバージョンは1.0.1なので、Grailsチームのいつものペースとも言える。もうちょっと版を重ねると安定してくるはず。たぶん。




おわりに

というわけで、触り程度ですがお試しがてらMicronautについて紹介してきました。

他にも


  • コンパイル時DIというのは具体的にどういうこと?

  • Springファミリーのライブラリを使える?

  • CLIアプリだとどんな感じになる?

  • Functionってどんな感じ?

  • Federationとはなんぞ?

などなど色々興味はつきませんが、だいぶ長くなったのでこのエントリとしてはひとまずここまでとします。

それでは、引き続き明日以降もNTTテクノクロス Advent Calendar 2018をお楽しみください。


リソース