CLOVER🍀

That was when it all began.

QuarkusのRESTEasy Jackson Extensionを試す

これは、なにをしたくて書いたもの?

Quarkus 0.20から、Jackson向けのExtensionが追加されていたようで。

Quarkus 0.20.0 released

こちらをちょっと試してみようかな、と。

Quarkus Jackson Extension

Jacksonについては、多くの質問があったみたいですね。

Jackson extensions

Quarkus loves standards. That’s why we started by supporting JSON-B as our JSON serialization library.

We had a lot of users asking for Jackson support and, while you could use Jackson with Quarkus, it wasn’t as easy as for JSON-B.

Quarkusは標準を好むのでJSON-Bを起点にしたけれど、Jacksomも追加しましたよ、と。

Jackson support · Issue #2578 · quarkusio/quarkus · GitHub

Jacksonをスタンドアロンに使うための「jackson」Extensionと、RESTEasyと合わせて使うための「resteasy-jackson」Extensionの
2つがあります。

Quarkusのガイド上も、JSON-B以外の選択肢として、Jacksonが書かれた内容になっています。

Quarkus - Writing JSON REST Services

今回は、「resteasy-jackson」を見ていこうと思います。

環境

今回の環境は、こちら。

$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (build 1.8.0_222-8u222-b10-1ubuntu1~18.04.1-b10)
OpenJDK 64-Bit Server VM (build 25.222-b10, mixed mode)


$ mvn -version
Apache Maven 3.6.2 (40f52333136460af0dc0d7232c0dc0bcf0d9e117; 2019-08-28T00:06:16+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_222, vendor: Private Build, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-62-generic", arch: "amd64", family: "unix"


$ $GRAALVM_HOME/bin/native-image --version
GraalVM Version 19.1.1 CE

利用するQuarkusのバージョンは、0.22.0です。

準備

プロジェクトの作成。Extensionには、「resteasy-jackson」を指定します。

$ mvn io.quarkus:quarkus-maven-plugin:0.22.0:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=resteasy-jackson \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-jackson"

作成するプロジェクトのアーティファクトIDまで、「resteasy-jackson」にしたのは紛らわしかったですね…。

pom.xmlには、以下の依存関係が追加されます。

    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-jackson</artifactId>
    </dependency>

サンプルコードの作成

書籍を題材にした、簡単なREST APIを書いてみます。

書籍クラスの定義。
src/main/java/org/littlewings/quarkus/resteasyjackson/Book.java

package org.littlewings.quarkus.resteasyjackson;

public class Book {
    private String isbn;
    private String title;
    private int price;

    // getter/setterは省略
}

こちらを扱う、JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/resteasyjackson/BookResource.java

package org.littlewings.quarkus.resteasyjackson;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("book")
public class BookResource {
    Map<String, Book> books = new ConcurrentHashMap<>();

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> books() {
        return books
                .values()
                .stream()
                .sorted(Comparator.comparing(Book::getPrice).reversed())
                .collect(Collectors.toList());
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Book book(@PathParam("isbn") String isbn) {
        return books.get(isbn);
    }

    @PUT
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(Book book) {
        books.put(book.getIsbn(), book);
        return "OK!!";
    }
}

また、REST JSON Serviceのドキュメントを読んでいると、リソースクラスの引数、戻り値に対象となるクラスを使わない場合、
すなわちResponseを使う場合について注意書きがあるので、こちらも含めておきましょう。

Using Response

ちなみにこれは、Jacksonに限った話ではありません。JSON-Bを使った場合も同じです。

もうひとつ、書籍用のクラスを用意。
src/main/java/org/littlewings/quarkus/resteasyjackson/FixedBook.java

package org.littlewings.quarkus.resteasyjackson;

import io.quarkus.runtime.annotations.RegisterForReflection;

@RegisterForReflection
public class FixedBook {
    private String isbn;
    private String title;
    private int price;

    public static FixedBook create(String isbn, String title, int price) {
        FixedBook book = new FixedBook();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        return book;
    }

    // getter/setterは省略
}

ポイントは、@RegisterForReflectionアノテーションが付与されていること。この効果については、また後ほど記述します。

@RegisterForReflection
public class FixedBook {

JAX-RSリソースクラスについては、メソッドの引数や戻り値には書籍クラスを含めない形で作成します。要は、Responseを使います、と。 src/main/java/org/littlewings/quarkus/resteasyjackson/FixedBookResource.java

package org.littlewings.quarkus.resteasyjackson;

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

@Path("fixed-book")
public class FixedBookResource {
    Map<String, FixedBook> books = Arrays
            .asList(
                    FixedBook.create("978-4774183169", "パーフェクト Java EE", 3456),
                    FixedBook.create("978-4798124605", "Beginning Java EE 6", 3891),
                    FixedBook.create("978-4798140926", "Java EE 7徹底入門", 4104)
            )
            .stream()
            .collect(
                    Collectors
                            .toMap(
                                    FixedBook::getIsbn,
                                    book -> book
                            )
            );

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response books() {
        List<FixedBook> sortedBooks = books
                .values()
                .stream()
                .sorted(Comparator.comparing(FixedBook::getPrice).reversed())
                .collect(Collectors.toList());

        return Response.ok(books).build();
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response book(@PathParam("isbn") String isbn) {
        FixedBook book = books.get(isbn);

        return Response.ok(book).build();
    }
}

とりあえず、ここまでで最低限確認したいところまでは作ってみました。

確認してみる

まずは、JARにパッケージングして動かしてみましょう。

$ mvn package && java -jar target/resteasy-jackson-0.0.1-SNAPSHOT-runner.jar

データの登録。

$ curl -XPUT -H 'Content-Type: application/json' http://localhost:8080/book -d '{"isbn": "978-4774183169", "title": "パーフェクト Java EE", "price": 3456}'
OK!!


$ curl -XPUT -H 'Content-Type: application/json' http://localhost:8080/book -d '{"isbn": "978-4798124605", "title": "Beginning Java EE 6", "price": 3891}'
OK!!


$ curl -XPUT -H 'Content-Type: application/json' http://localhost:8080/book -d '{"isbn": "978-4798140926", "title": "Java EE 7徹底入門", "price": 4104}'
OK!!

データの取得。

$ curl localhost:8080/book
[{"isbn":"978-4798140926","title":"Java EE 7徹底入門","price":4104},{"isbn":"978-4798124605","title":"Beginning Java EE 6","price":3891},{"isbn":"978-4774183169","title":"パーフェクト Java EE","price":3456}]


$ curl localhost:8080/book/978-4774183169
{"isbn":"978-4774183169","title":"パーフェクト Java EE","price":3456}

Responseを使ったパターンの確認。

$ curl localhost:8080/fixed-book
{"978-4798124605":{"isbn":"978-4798124605","title":"Beginning Java EE 6","price":3891},"978-4798140926":{"isbn":"978-4798140926","title":"Java EE 7徹底入門","price":4104},"978-4774183169":{"isbn":"978-4774183169","title":"パーフェクト Java EE","price":3456}}


$ curl localhost:8080/book/978-4774183169
{"isbn":"978-4774183169","title":"パーフェクト Java EE","price":3456}

ネイティブイメージにして、実行。

$ mvn package -P native && ./target/resteasy-jackson-0.0.1-SNAPSHOT-runner

結果は同じなので、割愛。

@RegisterForReflectionアノテーションを付けなかったら?

ところで、@RegisterForReflectionアノテーションをクラスの宣言に付与しなかったら、どうなるんでしょう?

@RegisterForReflection
public class FixedBook {

@RegisterForReflectionアノテーションを付与しなかった場合、ネイティブイメージにしてResponseを使うリソースクラスの
エンドポイントを呼び出した時に、Serializerがないと言われてエラーになります。

$ curl localhost:8080/fixed-book
$ curl localhost:8080/book/978-4774183169


Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.littlewings.quarkus.resteasyjackson.FixedBook and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.HashMap["978-4798124605"])

こちらのように、メソッドの引数や戻り値に対象の型を宣言している場合は、問題なく実行できます。

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> books() {
        return books
                .values()
                .stream()
                .sorted(Comparator.comparing(Book::getPrice).reversed())
                .collect(Collectors.toList());
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Book book(@PathParam("isbn") String isbn) {
        return books.get(isbn);
    }

    @PUT
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(Book book) {
        books.put(book.getIsbn(), book);
        return "OK!!";
    }

これは、ドキュメントにも書いてありますが、QuarkusがRESTエンドポイントをビルド時に解析してリフレクションの情報を
登録しているからですね。

As mentioned above, the issue is that Quarkus was not able to determine the Legume class will require some reflection by analyzing the REST endpoints.

このあたりかなぁ、と。

https://github.com/quarkusio/quarkus/blob/0.22.0/extensions/resteasy-server-common/deployment/src/main/java/io/quarkus/resteasy/server/common/deployment/ResteasyServerCommonProcessor.java#L545-L556

Responseを使う場合や、引数に対象の型が現れない場合はこれができないので、代わりに@RegisterForReflectionアノテーション
付与して、ビルド時に解析してリフレクションの情報を登録してもらいます。

こちらですね。

https://github.com/quarkusio/quarkus/blob/0.22.0/core/deployment/src/main/java/io/quarkus/deployment/steps/RegisterForReflectionBuildStep.java

ネイティブイメージにして実行する場合は、このあたりにも気をつけましょう、と。

ObjectMapperをカスタマイズする

ところで、RESTEasy Jackson Extensionが提供するデフォルトのObjectMapperは、デフォルトの状態です。

    @DefaultBean
    @Singleton
    @Produces
    public ObjectMapper objectMapper() {
        // in the future we can do a lot more here
        return new ObjectMapper();
    }

https://github.com/quarkusio/quarkus/blob/0.22.0/extensions/resteasy-jackson/runtime/src/main/java/io/quarkus/resteasy/jackson/runtime/ObjectMapperProducer.java#L19

これをカスタマイズする場合は、ドキュメントに従い@SingletonであるObjectMapperを作成する、Producerを用意します。

More on our Jackson support

例えば、Pretty Printするような場合、こんな感じに。 src/main/java/org/littlewings/quarkus/resteasyjackson/ObjectMapperProducer.java

package org.littlewings.quarkus.resteasyjackson;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Produces;
import javax.inject.Singleton;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

@ApplicationScoped
public class ObjectMapperProducer {
    @Singleton
    @Produces
    public ObjectMapper objectMapper() {
        return new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
    }
}

これで、デフォルトで提供されるObjectMapperを差し替えることができます。

最初、なにも考えずに@ApplicationScopedとかにしていたら、動かなくなってハマりました…。

確認。

$ curl localhost:8080/fixed-book
{
  "978-4798124605" : {
    "isbn" : "978-4798124605",
    "title" : "Beginning Java EE 6",
    "price" : 3891
  },
  "978-4798140926" : {
    "isbn" : "978-4798140926",
    "title" : "Java EE 7徹底入門",
    "price" : 4104
  },
  "978-4774183169" : {
    "isbn" : "978-4774183169",
    "title" : "パーフェクト Java EE",
    "price" : 3456
  }
}

Jackson Extensionは?

ところで、RESTEasyがつかない方の、Jackson Extensionはどのようなものなのでしょう?

ソースコードを見ると、runtimeの方はJacksonへの依存関係が定義されているだけです。

https://github.com/quarkusio/quarkus/tree/0.22.0/extensions/jackson/runtime

deploymentの方では、JaxbAnnotationIntrospectorとSqlDateSerializerのリフレクションの情報として登録しています。

https://github.com/quarkusio/quarkus/blob/0.22.0/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java

このくらいの内容みたいですね。