LoginSignup
3
3

More than 3 years have passed since last update.

Jersey の API テストを実施したところ、JSR310 の API をデシリアライズできない現象に遭遇した

Last updated at Posted at 2019-06-15

概要

後述の DTO クラスを戻り値とした API を作成して、API テストを実施したところ、下記例外が発生してテストが正常終了しませんでした。

javax.ws.rs.ProcessingException: Error reading entity from input stream.

    at org.glassfish.jersey.message.internal.InboundMessageContext.readEntity(InboundMessageContext.java:889)
    at org.glassfish.jersey.message.internal.InboundMessageContext.readEntity(InboundMessageContext.java:808)


Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.time.LocalDateTime` (no Creators, like default construct, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (org.glassfish.jersey.message.internal.ReaderInterceptorExecutor$UnCloseableInputStream); line: 1, column: 38] (through reference chain: my.controller.MyDto["localDateTime"])
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1452)

環境

Spring Boot で構成しました。

  • Spring Boot 2.1.X
  • Java 1.8 -> Kotlin でも再現します。

詳細

Dto は Kotlin の data class で書いていたので、コンストラクタのプロパティが null 許容されていないため、newInstance メソッドで新規インスタンスが生成できない例外かと思いましたが、下記のように java で書き直しても同じように例外が発生しました。

MyDto.java
package my.controller;

import com.fasterxml.jackson.annotation.JsonAutoDetect;

import java.time.LocalDateTime;

@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class MyDto {

    public final String word;
    public final LocalDateTime localDateTime;

    /**
     * Jackson が newInstance するための引数なしコンストラクタ
     */
    private MyDto() {
        this(null, null);
    }

    public MyDto(String word, LocalDateTime localDateTime) {
        this.word = word;
        this.localDateTime = localDateTime;
    }

    public String value() {
        return word + localDateTime.toString();
    }

}

この DTO を引数にとり、値を返すリソースクラスは下記です。

MyJerseyResource.java
package my.controller;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.time.LocalDateTime;

@Path("/my")
public class MyJerseyResource {

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public MyDto post(MyDto myDto) {
        return new MyDto(
                myDto.value() + " finishes!",
                LocalDateTime.of(2019, 1, 1, 12, 0, 0)
        );
    }

    @GET
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public MyDto get() {
        return new MyDto(
                "finish!",
                LocalDateTime.of(2019, 1, 1, 12, 0, 0)
        );
    }
}

また、通常通り Spring Boot アプリケーションとして起動した場合、Post Man などで実行すると正常にリクエストとレスポンスを送受信できました。

GET の場合

Screen Shot 2019-06-16 at 7.49.37.png

POST の場合

Screen Shot 2019-06-16 at 7.49.29.png

テストクラス

このページ の設定をして、テストクラスを作りました。
get のテストは上述のログが、post のテストでは、ステータスが 400 で終了していました。
結果は異なりますが、テストが失敗した原因は後述の通り同じです。

MyJerseyResourceTest.java
package my.controller;

import my.ConfigureTest;
import my.jersey.MyJerseyTest;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.time.LocalDateTime;

import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.core.Is.*;


@RunWith(SpringRunner.class)
@SpringBootTest(classes = {ConfigureTest.class, MyJerseyTest.class})
public class MyJerseyResourceTest {

    @Autowired
    MyJerseyTest jerseyTest;

    @Before
    public void setUp() throws Exception {
        this.jerseyTest.setUp();
    }

    @Test
    public void postをテストする() {
        Response response = jerseyTest.webTarget("/my").request()
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .post(
                        Entity.json(new MyDto(
                                        "start!",
                                        LocalDateTime.of(2018, 1, 1, 12, 1, 1)
                                )
                        )
                );

        assertThat(response.getStatus(), is(200));
        MyDto content = response.readEntity(MyDto.class);
        assertThat(content.word, is("start!2018-01-01T12:01:01 finishes!"));
        assertThat(content.localDateTime.toString(), is(LocalDateTime.of(2019, 1, 1, 12, 0, 0).toString()));
    }

    @Test
    public void getをテストする() {
        Response response = jerseyTest.webTarget("/my").request()
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .get();
        assertThat(response.getStatus(), is(200));

        MyDto content = response.readEntity(MyDto.class);
        assertThat(content.word, is("finish!"));
        assertThat(content.localDateTime.toString(), is(LocalDateTime.of(2019, 1, 1, 12, 0, 0).toString()));
    }
}

解決策

原因は Jersey で使用されている ObjectMapper が JSR310 に対応していないことのようです。おそらく Jersey の規定の動作として、Json のシリアライザー/デシリアライザーは Jackson の ObjectMapper が選択されるようです。
構成自体は Spring MVC と同じなのですが、Spring MVC のテストとは異なり ObjectMapper が JSR310 に対応していないため、このようなエラーがでてしまうようです。
この問題を解決するため、テスト実施時のみに適用されるいくつかの設定を別途行う必要がありました。

JSR310 をデシリアライズできる ObjectMapper をセット

Spring Boot でコンテナに登録される ObjectMapper は JSR310 に対応していますが、JerseyTest はこれを使わないようです。JSR310 のシリアライザー、デシリアライザーを JavaTimeModule 経由で登録します。このクラスのコンストラクタで JSR310 のクラス群に対応したシリアライザー、デシリアライザーが登録されます。注意が必要なのは、デシリアライザーの登録です。後述の JSR310 のデシリアライズの問題をどうしても解決できませんでした。そのため、カスタムのデシリアライザを用意する必要がありました。今回は、LocalDateTime のみ用意しましたが、ZonedDateTime など、他の API を使用する場合は同じようにデシリアライザーを用意する必要があると予測します。

ConfigureTest.java
package my;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Configuration
public class ConfigureTest {

    @Bean
    public ObjectMapper customObjectMapper(Environment environment) {
        JavaTimeModule m = new JavaTimeModule();
        m.addDeserializer(LocalDateTime.class, new CustomLocalDateTimeDeserializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        return Jackson2ObjectMapperBuilder.json()
                .modulesToInstall(m)
                // ここを無効化しないと Unix Time でフォーマットされてしまう。
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .featuresToEnable(SerializationFeature.INDENT_OUTPUT)
                .build();

    }
}

Bean 化した ObjectMapper を使う準備をする

ObjectMapper をBean 化できましたが、このままではこの Bean 化した ObjectMapper は Jersey で使用されません。
この ObjectMapper が使用されるためには、いくつかの方法があるようですが、ContextResolverを使って使用できるようにします。

MyJacksonConfigurer.java
package my.jersey;

import com.fasterxml.jackson.databind.ObjectMapper;

import javax.ws.rs.Consumes;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.ext.ContextResolver;
import javax.ws.rs.ext.Provider;

@Provider
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class MyJacksonConfigurator implements ContextResolver<ObjectMapper> {

    private final ObjectMapper objectMapper;
    public MyJacksonConfigurator(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public ObjectMapper getContext(Class<?> type) {
        return this.objectMapper;
    }
}

説明がないのでよくわかりませんが、状況から察するに、実装先の ContextResolver の型パラメーターで指定している型が Jersey で使用される場合、そのコンテキストがこのクラスの getContext メソッド経由で取得されるようです。このクラスのコンストラクタで Bean 化された ObjectMapper が Inject されているので、JSR310 に対応した ObjectMapper を使用できます。

リクエストを Bean 化した ObjectMapper で処理する

コンテキストを定義しましたが、このコンテキストは Jersey に登録されていません。Jersey のサーバー側の処理について、なんらかのリソースを登録したいときは、ResourceConfig に登録して、JerseyTest の configure メソッドで設定を戻す必要があります。Jersey のリソースクラスとともに、上述の ContextResolver を登録して、サーバー側でリクエストを処理する際、登録した ObjectMapper を利用できるようになります。

MyJerseyTest.java

            @Override
            protected ResourceConfig configure() {
                return new ResourceConfig(MyJerseyResource.class)
                        .register(new MyJacksonConfigurator(objectMapper))
                        .property("contextConfig", applicationContext);
            }

レスポンスを Bean 化した ObjectMapper で処理する

Jersey は、サーバー側の設定とクライアント側の設定が厳密に区別されるようです。サーバー側の設定とは、リクエストをさばくための処理となります。クライアント側の処理は、レスポンスをさばくための処理となります。
サーバー側でのデシリアライザーは登録できましたが、クライアント側のデシリアライザーは登録できていません。
そのため、テストコードの post 時の 400 エラーは解消されます。しかし、Response.readEntity を実施しようとすると、JSR310 未対応の ObjectMapper が使用されるらしく、最初に引用したエラーが発生します。
このエラーを解消できるように、Jersey で使用するクライアント (この場合、テストを実施する際にリクエストを投げて、レスポンスを受け取るインスタンス) に ContextResolver を登録します。

MyJerseyTest.java
            @Override
            public Client getClient() {
                return JerseyClientBuilder.createClient()
                        .register(new MyJacksonConfigurator(objectMapper));
            }

これで、JSR310 対応の ObjectMapper を登録できました。
JerseyTest を設定したクラスは下記のようになりました。

MyJerseyTest.java
package my.jersey;

import com.fasterxml.jackson.databind.ObjectMapper;
import my.controller.MyJerseyResource;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.servlet.ServletContainer;
import org.glassfish.jersey.test.JerseyTest;
import org.glassfish.jersey.test.ServletDeploymentContext;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.glassfish.jersey.test.spi.TestContainerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.FormContentFilter;
import org.springframework.web.filter.HiddenHttpMethodFilter;
import org.springframework.web.filter.RequestContextFilter;

import javax.ws.rs.client.Client;
import javax.ws.rs.client.WebTarget;

@Component
public class MyJerseyTest {

    final JerseyTest jerseyTest;

    boolean start = false;

    public void setUp() throws Exception{
        if (!start) {
            this.jerseyTest.setUp();
        }
        start = true;
    }

    public WebTarget webTarget(String url) {
        return this.jerseyTest.target(url);
    }

    public MyJerseyTest(ApplicationContext applicationContext, ObjectMapper objectMapper) {
        this.jerseyTest = new JerseyTest() {

            @Override
            public Client getClient() {
                return JerseyClientBuilder.createClient()
                        .register(new MyJacksonConfigurator(objectMapper));
            }

            @Override
            protected ResourceConfig configure() {
                return new ResourceConfig(MyJerseyResource.class)
                        .register(new MyJacksonConfigurator(objectMapper))
                        .property("contextConfig", applicationContext);
            }

            @Override
            protected ServletDeploymentContext configureDeployment() {
                return ServletDeploymentContext
                        .forServlet(new ServletContainer(configure()))
                        .addFilter(HiddenHttpMethodFilter.class, HiddenHttpMethodFilter.class.getSimpleName())
                        .addFilter(FormContentFilter.class, FormContentFilter.class.getSimpleName())
                        .addFilter(RequestContextFilter.class, RequestContextFilter.class.getSimpleName())
                        .build();
            }

            @Override
            public TestContainerFactory getTestContainerFactory() {
                return new GrizzlyWebTestContainerFactory();
            }
        };

    }
}

LocalDateTime のデシリアライザーを作成する

上述のとおり、ObjectMapper に LocalDateTime のデシリアライザーを登録していましたが、これはカスタマイズされたものである必要がありました。というのも、規定のデシリアライザーでは、Jersey が生成した文字列をデシリアライズできないからです。規定のデシリアライザーで処理が行えるような
方法があるのかもしれませんが、どうしても代替案を見つけることができませんでした。今回のソリューションには下記を採用しました。

CustomLocalDateDeserializer.java
package my;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonTokenId;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class CustomLocalDateTimeDeserializer extends LocalDateTimeDeserializer {

    public CustomLocalDateTimeDeserializer(DateTimeFormatter pattern) {
        super(pattern);
    }

    @Override
    public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {

        if (
                p.hasTokenId(JsonTokenId.ID_STRING) ||
                        p.isExpectedStartArrayToken()) {
            return super.deserialize(p, ctxt);
        } else {
            ObjectNode node = p.getCodec().readTree(p);
            return LocalDateTime.of(
                    node.get("year").asInt(),
                    node.get("monthValue").asInt(),
                    node.get("dayOfMonth").asInt(),
                    node.get("hour").asInt(),
                    node.get("minute").asInt(),
                    node.get("second").asInt()
            );
        }
    }
}

なぜこのような形になるかというと、Response.readEntity メソッドをテストクラスにおいて実行する際、最も奥の部分で実行される規定の Jackson の LocalDateTime の デシリアライザーにおいて取得できる LocalDateTime の値の文字列が Jackson が期待している形式ではないためです。レスポンスオブジェクトが生成されたタイミングで、なにをどうやっても LocalDateTime の文字列の形式は、下記の形式になってしまいました。

{
    "localDateTime": {
        "dayOfYear": 1,
        "dayOfWeek": "WEDNESDAY",
        "month": "JANUARY",
        "dayOfMonth": 1,
        "year": 2018,
        "monthValue": 1,
        "hour": 1,
        "minute": 0,
        "second": 0,
        "nano": 0,
        "chronology": {
            "id": "ISO",
            "calendarType": "iso8601"
        }
    }
}

なんということでしょう。LocalDateTime の各メンバーオブジェクトをご丁寧に JSON 形式にシリアライズしてきてくれているのです。こんなことをしないで、せめて素直に、LocalDateTime.toString() してくれるだけでよいのですが。
こんな余計なことをするため、自力でデシリアライズする必要があります。
Jackson は、デシリアライズするとき、deserialize メソッドの第一引数の JsonParser に文字列としてデシリアライズ対象の文字列情報が格納されているのですが、LocalDateTime にデシリアライズするとき、この形式の文字列を LocalDateTime にデシリアライズする機能を有していません。上記のコードで最初の分岐となっている、

  • p.hasTokenId(JsonTokenId.ID_STRING) -> 2019-01-01T12:00:00 の形式
  • p.isExpectedStartArrayToken() -> [2019, 1, 1, 12, 0, 0] 形式

のどちらか以外の形式がくると、冒頭の引用の例外が発生します。
この場合、どうやら文字列が "{" で始まっているので、デシリアライズ対象の文字は、オブジェクトとして判断されてデシリアライズ不可能と判断されます。1
Jackson が対応していない形式がくる場合、自力でデシリアライズする必要があるので、仕方なくこの実装を使いました。
そもそもなぜこんな形式で入ってくるか、という話になるかと思いますので、Jersey のコードをデバッグしましたが、あまりにも込み入っており解読する時間が無駄であると判断したので深追いしませんでした。。。

所感

Spring Boot がかなり頑張ってくれていますが、Jersey だとどうしても手が届いていないところが出てしまうようです。
Spring MVC であれば Spring Boot とシームレスにテストまで行うことができるのでこんな問題は発生しません。なにも考えずにテストができます。
そして、JAX-RS は、仕様の詳細を理解した上で使うことを強制しているように思われます。我々にとって有効な時間は限りがあります。こんなしようのないことを覚えるような時間を使うなら、他のことに時間を使いたいものです。本来こんな時間を減らすため、フレームワークががんばるべきであるのに、本末転倒もいいところです。
やはり、Spring Boot を選んだ段階で Jersey (JAX-RS) を選ぶメリットはないなと痛感した次第です。
そもそも Java で Spring Boot 以外で Web アプリケーションを構築するメリットが今段階ではよくわからないので、JAX-RS を使う必然性はないように思われます。

補足

ContextResolver で Bean 化した ObjectMapper を使う方法以外に 2 つ方法を発見したので、書いておきます。
いずれも、サーバー側、クライアント側でのインスタンスの登録が必要です。

JacksonJaxbJsonProvider を使う

規定では、Jersey の Json のシリアライズ、デシリアライズには ObjectMapper が使用されますが、どうやらこの動作は、JacksonJaxbJsonProvider というクラスが使用されて実現されているようです。そのため、このクラスをオーバーライドします。

CustomJacksonJsonProvider.java
package my;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.glassfish.jersey.jackson.internal.jackson.jaxrs.json.JacksonJaxbJsonProvider;

import javax.annotation.Priority;
import javax.ws.rs.ext.Provider;


@Provider
@Priority(0)
public class CustomJacksonJsonProvider extends JacksonJaxbJsonProvider {
    public CustomJacksonJsonProvider(ObjectMapper objectMapper) {
        super();
        super.setMapper(objectMapper);
    }
}

MessageBodyReader と MessageBodyWrite を実装する

Jersey のレスポンスに入ってくるボディの内容は表題のクラスの実装に入るようです。このクラスを解して、ボディの読み書きをするようで、ここでシリアライズ、デシリアライズをフック可能です。
しかし、余計な実装も必要ですし、複雑さがますだけで、そもそも本当に実用可能なのか不明なので、まったくお勧めできない手法です。
参考程度に。

MyMessageBody.java
package my;

import javax.ws.rs.Consumes;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.ext.MessageBodyReader;
import javax.ws.rs.ext.MessageBodyWriter;
import javax.ws.rs.ext.Provider;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;

@Provider
@Produces(value ={MediaType.APPLICATION_JSON})
@Consumes(value ={MediaType.APPLICATION_JSON})
public class MyMessageBody<T extends Serializable> implements MessageBodyReader<T>, MessageBodyWriter<T> {

    @Override
    public boolean isReadable(Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {
        return true;
    }

    @Override
    public T readFrom(Class<T> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, String> httpHeaders, InputStream entityStream) throws IOException, WebApplicationException {
        //シリアライズする内容
        return null;
    }

    @Override
    public boolean isWriteable(Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {
        return true;
    }

    @Override
    public void writeTo(T t, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException {
        //デシリアライズする内容

    }
}

  1. 余談ですが、JsonParser は自分がデシリアライズするための情報を文字列として持っています。JsonParse が持っている文字列の先頭が "{", "[" でなければ文字列( ID_STRING)、"{" で始まっていればオブジェクト (START_OBJECT)、"[" で始まっていれば、配列 (START_ARRAY) と判断されるようです。 

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3