Kotlin
JAX-RS
docker
payara

KotlinでJAX-RSによるAPIを作ってDocker上のPayara Microで動かしてみる

Kotlinの練習のため、JAX-RSによる簡単なAPIを作ってみたので、その際にやったことを記録しておきます。
サーバーサイドKotlinだとSpring Bootの記事はいっぱい出てきますが、Java EE(JAX-RS)の記事となると、結構古いものが多かったので、たいしたことはやってませんがアップしておくことにしました。
なお、ビルドはgradleで行い、Docker上のPayara Microで動かします。

はじめに

環境

今回の作業環境は以下の通りです。ちょっとバージョンが違うくらいでは問題ないとは思いますが。
- Java8 + Kotlin 1.2
- Payara Micro 4.1.2.174(Payara Micro公式Dockerイメージを使用)
- Gradle 4.5
- Docker for Mac(Docker CE 17.09.0)
- macOS Sierra

下記GitHubリポジトリにあるコードを使う場合、
- Java8
- Docker for Mac
が入っていればOKのはずです(それ以外はGradleの依存関係で持ってこれる)。

GitHubリポジトリ

今回作成したファイルは、GitHubリポジトリにアップしてありますので、適宜参照してください。

APIの仕様

GETとPOSTのAPIを1つずつ作ります。APIの中身は何でもよいのですが、製品情報を検索するAPIとしています。
いずれも、JAX-RSのAPIを作るのが目的なので、データベース等は使用せず、プログラム内で応答データを生成しています。

GET

GETのAPIは、URLでパラメータ(製品ID)を受け取りJSONデータ(当該製品IDの製品データ1件)を返却します。データが存在しなかった場合は返却データは空とします。

GET APIの使用例
[Request URL]
http://localhost:8080/jaxrs-kotlin-example/api/product/1
(最後の「1」がパラメータの製品ID)
[Response]
{
"category": "tablet",
"id": 1,
"name": "Tablet-A"
}

POST

POSTのAPIは、JSONで検索条件(製品カテゴリー)を受け取り、検索結果のJSONデータを返します。指定したカテゴリーに合致するデータが存在しない場合は、応答データ中の製品リストが空になります(GETの場合と異なり、JSON自体は返却されます)。

POST APIの使用例
[Request]
{
"category": "tablet"
}
[Response]
{
"category": "tablet",
"description": "jaxrs-kotlin-example product list",
"productList": [
{
"category": "tablet",
"id": 1,
"name": "Tablet-A"
},
{
"category": "tablet",
"id": 2,
"name": "Tablet-B"
}
]
}

準備

build.gradle

Spring Bootだと、Spring Initializrが使えるので、build.gradleを生成してもらえばよいのですが、今回はそうもいかないので、Kotlinのマニュアルも参考にしつつSpring Initializrが生成してくれたbuild.gradleも参考にしつつ、作成しました。

build.gradle
buildscript {
    ext.kotlin_version = '1.2.21'
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
    repositories {
        mavenCentral()
    }
}

apply plugin: 'kotlin'
apply plugin: 'war'

ext.kotlin_version = '1.2.21'

sourceCompatibility = 1.8
compileKotlin {
  kotlinOptions.jvmTarget = '1.8'
}
compileTestKotlin {
  kotlinOptions.jvmTarget = '1.8'
}

repositories {
    mavenCentral()
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    compileOnly 'javax.ws.rs:javax.ws.rs-api:2.0.1'
}

task wrapper(type: Wrapper) {
    gradleVersion = '4.5'
}

ソースディレクトリ

デフォルトの構成であるsrc/main/kotlinにソースを配置します。今回はweb.xml等は作成しないので、src/main/webappは作成していません。

APIコードの作成

ExampleApplication.kt

JAX-RSのApplicationクラスを作ります。javax.ws.rs.Applicationクラスを継承したクラスを作成し、@ApplicationPathアノテーションを付与します。Kotlinのクラス記法になっている以外、通常のJAX-RSアプリと変わりません。

ExampleApplication.kt
package kazusato.example.jaxrskotlin

import javax.ws.rs.ApplicationPath
import javax.ws.rs.core.Application

@ApplicationPath("/api")
class ExampleApplication : Application() {

}

Product.kt

製品データを保持するクラスです。Kotlinのデータクラスとして作成しています。GET APIの応答、POST APIの応答の一部として使用します。

Product.kt
package kazusato.example.jaxrskotlin.data

data class Product(
        var id: Long = 0,
        var name: String = "",
        var category: String = ""
)

ProductSearchCriteria.kt

POST APIで検索条件を指定するのに使用します。JAXBでJSONデータから変換するため、引数なしのコンストラクターが存在しないとエラーになります。そのため、クラス定義での引数デフォルト値の設定は必須です。

ProductSearchCriteria.kt
package kazusato.example.jaxrskotlin.data

data class ProductSearchCriteria(
        var category: String = ""
)

ProductSearchResult.kt

POST APIの応答で使用します。検索結果のデータを保持するためのリストを持ち、当該リストにデータを登録するための関数を定義しています。

ProductSearchResult.kt
package kazusato.example.jaxrskotlin.data

data class ProductSearchResult(
        var category: String = "",
        var description: String = "jaxrs-kotlin-example product list",
        var productList: MutableList<Product> = mutableListOf<Product>()
) {
    fun addAll(products: Collection<Product>) {
        productList.addAll(products)
    }
}

ProductResource.kt

APIの処理を記述するリソースクラスです。GET用関数、POST用関数をそれぞれ定義しています。応答用のデータもこのクラスの中で生成しています。

ProductResource.kt
package kazusato.example.jaxrskotlin.api

import kazusato.example.jaxrskotlin.data.Product
import kazusato.example.jaxrskotlin.data.ProductSearchCriteria
import kazusato.example.jaxrskotlin.data.ProductSearchResult
import javax.ws.rs.Consumes
import javax.ws.rs.GET
import javax.ws.rs.POST
import javax.ws.rs.Path
import javax.ws.rs.PathParam
import javax.ws.rs.Produces
import javax.ws.rs.core.MediaType

@Path("/product")
class ProductResource {

    private val allProductList = listOf(
            Product(1L, "Tablet-A", "tablet"),
            Product(2L, "Tablet-B", "tablet"),
            Product(3L, "Phone-A", "phone")
    )

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    fun getProduct(@PathParam("id") id: Long): Product? = findProductById(id)

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    fun searchProduct(criteria: ProductSearchCriteria): ProductSearchResult {
        val result = ProductSearchResult(category=criteria.category)
        val products = findProductsByCategory(criteria.category)
        result.addAll(products)

        return result
    }

    private fun findProductById(id: Long): Product? =
            allProductList.filter { p -> p.id == id }.firstOrNull()

    private fun findProductsByCategory(category: String): List<Product> =
            allProductList.filter { p -> p.category == category}

}

getProduct関数

HTTP GETメソッド用の関数です。ポイントとしては、URL中に指定するパラメータを、関数に対するアノテーション

@Path("/{id}")

で指定する点、関数の引数に対するアノテーション

@PathParam("id")

で指定する点と、返却される応答データをJSONとするため、関数に対するアノテーション

@Produces(MediaType.APPLICATION_JSON)

を指定する点です。

searchProduct関数

HTTP POSTメソッド用の関数です。API呼び出し時のデータもJSONで受け取るので、関数に対するアノテーションとして

@Consumes(MediaType.APPLICATION_JSON)

を付与しています。

findProductById関数、findProductsByCategory関数

検索条件に合致したデータを抽出して返却します。findProductByIdの方は、データがあれば一意に決まるのと、存在しない場合はnullを返したい(API的には空の応答としたい)ので、最後にfirstOrNull()を呼んでいます。

ビルド

build.gradleでwarプラグインを設定しているので、build.gradleが置かれているディレクトリで

$ ./gradlew war

を実行すれば、コンパイルを実行してbuild/libs以下にwarファイルが生成されます(Gradleラッパーを使用しています)。

warファイルの中身

せっかくなので

$ jar tvf build/libs/jaxrs-kotlin-example.war

を実行して、生成されたwarファイルの中身を開いて見てみます。

jaxrs-kotlin-example.war
     0 Fri Jan 26 16:54:04 JST 2018 META-INF/
    25 Fri Jan 26 12:45:48 JST 2018 META-INF/MANIFEST.MF
     0 Fri Jan 26 16:54:04 JST 2018 WEB-INF/
     0 Fri Jan 26 16:54:04 JST 2018 WEB-INF/classes/
     0 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/
     0 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/example/
     0 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/
     0 Fri Jan 26 16:54:04 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/api/
  4708 Fri Jan 26 16:54:04 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/api/ProductResource.class
     0 Fri Jan 26 16:54:00 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/data/
  3666 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/data/Product.class
  2547 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/data/ProductSearchCriteria.class
  5152 Fri Jan 26 16:54:00 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/data/ProductSearchResult.class
   664 Fri Jan 26 15:28:56 JST 2018 WEB-INF/classes/kazusato/example/jaxrskotlin/ExampleApplication.class
     0 Fri Jan 26 12:54:08 JST 2018 WEB-INF/classes/.gitkeep
     0 Fri Jan 26 16:54:04 JST 2018 WEB-INF/lib/
 12366 Fri Jan 26 12:53:26 JST 2018 WEB-INF/lib/kotlin-stdlib-jdk8-1.2.21.jar
  3135 Fri Jan 26 12:53:26 JST 2018 WEB-INF/lib/kotlin-stdlib-jdk7-1.2.21.jar
932477 Fri Jan 26 12:32:02 JST 2018 WEB-INF/lib/kotlin-stdlib-1.2.21.jar
 17536 Sun Jan 07 15:03:54 JST 2018 WEB-INF/lib/annotations-13.0.jar

kotlin-stdlib-1.2.21.jarが900KB超で、結構大きいですね。API自体がシンプルなので、warファイルのサイズ(891376バイト)の大部分はこのJARが占めているようです。

DockerでのAPI実行

最初に記載した通り、今回はPayara MicroをDocker上で動かしてAPIを動作させます。Payara Microは、Glassfishから派生したJava EEアプリケーションサーバPayaraの軽量版で、70MB以下のJAR1個で動くというコンパクトさながら、Eclipse MicroProfileに対応し、JAX-RS、CDI、JTA、JPAといったJava EEの主要APIが使用可能です。

Dockerイメージの作成

Payara Microは公式Dockerイメージが公開されていますので、これを使用します。
以下のDockerfileを使用して、アプリ(warファイル)をデプロイ、起動します。

FROM payara/micro:174
LABEL maintainer="kazusato"
COPY files/jaxrs-kotlin-example.war /opt/payara/deployments
USER root
RUN mkdir /var/log/payara && \
  chown payara:payara /var/log/payara
USER payara
ENTRYPOINT [""]

なお、ENTRYPOINTを設定しなくても、アプリを動かすことはできるのですが、リモートデバッグしようとしたときにjavaコマンドにオプションを渡せなくて困ったのでこのようにしています(詳細は別記事に書きます)。

ちなみに、Payara Micro公式イメージのDockerfileでは、ENTRYPOINTとは以下の通り設定されています。

ENTRYPOINT ["java", "-jar", "/opt/payara/payara-micro.jar"]

docker build実行

docker buildに使用するシェルスクリプトは以下の通りです。

docker/bin/build.sh
#!/bin/sh
cp ../build/libs/jaxrs-kotlin-example.war files
docker build -t kazusato/jaxrs-kotlin-example:0.1 \
    --force-rm --no-cache .

スクリプトの作り上、warファイル作成後、dockerディレクトリにcdした上で、

bin/build.sh
を実行するとDockerイメージのビルドができます。

正常にビルドできると、以下のように表示されるはずです。

$ bin/build.sh 
Sending build context to Docker daemon  901.6kB
Step 1/7 : FROM payara/micro:174
 ---> 873991a18378
Step 2/7 : LABEL maintainer "kazusato"
 ---> Running in 381771f34c9d
 ---> 3bbf65e78e0a
Removing intermediate container 381771f34c9d
Step 3/7 : COPY files/jaxrs-kotlin-example.war /opt/payara/deployments
 ---> 2342affa227c
Step 4/7 : USER root
 ---> Running in d65be685fb5b
 ---> fea8e6825437
Removing intermediate container d65be685fb5b
Step 5/7 : RUN mkdir /var/log/payara &&   chown payara:payara /var/log/payara
 ---> Running in 9640a01d51cb
 ---> 0442c264dde0
Removing intermediate container 9640a01d51cb
Step 6/7 : USER payara
 ---> Running in c56c0addfd97
 ---> e3d719b76980
Removing intermediate container c56c0addfd97
Step 7/7 : ENTRYPOINT 
 ---> Running in f95c7642c7d9
 ---> 4b905f318386
Removing intermediate container f95c7642c7d9
Successfully built 4b905f318386
Successfully tagged kazusato/jaxrs-kotlin-example:0.1

APIを動かしてみる

docker run用スクリプト

docker run用のスクリプトはこちらです。アプリにアクセスするためのポート8080を公開しています。また、Payara Microのログ出力先の指定もしています。

docker/bin/run.sh
#!/bin/sh
docker run \
  -d \
  --name jaxrs-kotlin-example \
  -p 8080:8080 \
  kazusato/jaxrs-kotlin-example:0.1 \
  java \
  -jar /opt/payara/payara-micro.jar \
    --deploymentDir /opt/payara/deployments \
  --logToFile /var/log/payara/payara-micro.log

docker runしてからAPIを呼ぶまで

Dockerイメージ作成時と同様、dockerディレクトリにcdした状態で

bin/run.sh

を実行し、正しくコンテナが起動されると、

$ bin/run.sh 
fa0c05d5715eb4055952a86992b6a6273c76e4083be2eb829ab9558a9616a0d2

のようにIDが出力されます(IDの値は異なるでしょうが)。

この状態では、まだアプリのデプロイまで終わっていません。

bin/tail_log.sh

を使うとコンテナ内に出力されているPayara Microのログを参照(tail -F)することができます。ログに以下のように出力されたら、デプロイ完了です。

(略)
http://fa0c05d5715e:8080/jaxrs-kotlin-example

'jaxrs-kotlin-example' REST Endpoints
POST    /jaxrs-kotlin-example/api/product
GET /jaxrs-kotlin-example/api/product/{id}

この通り出力されたら、Ctrl+Cで抜けてください。

curlでのAPI呼出

work/call_get.sh
work/call_post.sh

を使うと、curlコマンドを使ってAPIを呼び出すことができます。

GETの呼び出しは、

curl http://localhost:8080/jaxrs-kotlin-example/api/product/1

のような感じです。

一方、POSTは、

curl -X POST -H "Content-type: application/json" \
-d '{"category": "tablet"}' \
http://localhost:8080/jaxrs-kotlin-example/api/product

となります。

いずれも、呼び出しに成功すると、API仕様の項に記載したようなJSONが返却されます。

Dockerコンテナの停止

動作確認が終わったら、dockerディレクトリにcdした状態で、

bin/stop.sh

としてDockerコンテナを止めてください。また、

bin/rm.sh

とすることでコンテナを削除することもできます。

まとめ

以上が、JAX-RSによるAPIをKotlinで作成し、Docker上のPayara Microで動かす手順となります。
実は、ここまで来る間に、何箇所かはまりどころがあったのですが、それはまた別記事で書きます。