[JAVA] Lorsque j'ai effectué un test de l'API Jersey, j'ai rencontré un phénomène selon lequel l'API JSR310 ne pouvait pas être désérialisée.

Aperçu

Lorsque j'ai créé une API avec la classe DTO décrite plus tard comme valeur de retour et effectué un test d'API, l'exception suivante s'est produite et le test ne s'est pas terminé normalement.

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)

environnement

Il se composait de Spring Boot.

Détails

Puisque Dto a été écrit dans la classe de données de Kotlin, je pensais que c'était une exception qu'une nouvelle instance ne pouvait pas être créée par la méthode newInstance car les propriétés du constructeur ne sont pas autorisées à être nulles, mais même si je la réécris en java comme indiqué ci-dessous, c'est la même chose. Une exception s'est produite.

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;

    /**
     *Constructeur sans argument pour que Jackson fasse 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();
    }

}

La classe de ressources qui prend ce DTO comme argument et renvoie une valeur est la suivante.

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)
        );
    }
}

De plus, lorsqu'elle est lancée comme une application Spring Boot comme d'habitude, lorsqu'elle est exécutée avec Post Man, etc., les demandes et les réponses peuvent être envoyées et reçues normalement.

Oublier

Screen Shot 2019-06-16 at 7.49.37.png

Pour POST

Screen Shot 2019-06-16 at 7.49.29.png

Classe d'essai

J'ai créé une classe de test en définissant cette page. Le test get s'est terminé avec le journal ci-dessus, mais le post-test s'est terminé avec un statut de 400. Les résultats seront différents, mais la cause de l'échec du test est la même que celle décrite ci-dessous.

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
Tester le message public nul() {
        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
Tester 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()));
    }
}

Solution

La cause semble être que l'ObjectMapper utilisé par __Jersey ne prend pas en charge JSR310 __. Le comportement par défaut de Jersey est peut-être de choisir l'ObjectMapper de Jackson comme sérialiseur / désérialiseur de Json. La configuration elle-même est la même que Spring MVC, mais contrairement au test de __Spring MVC, ObjectMapper ne prend pas en charge JSR310 __, il semble donc qu'une telle erreur se produise. Pour résoudre ce problème, nous avons dû définir des paramètres supplémentaires __ qui ne s'appliquent que lors de l'exécution de __tests.

Définir ObjectMapper qui peut désérialiser JSR310

L'ObjectMapper enregistré dans le conteneur avec Spring Boot prend en charge JSR310, mais JerseyTest ne semble pas l'utiliser. Sérialiseur et désérialiseur JSR310 [JavaTimeModule](https://github.com/FasterXML/jackson-datatype-jsr310/blob/master/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule Inscrivez-vous via .java). Le sérialiseur et le désérialiseur correspondant au groupe de classes JSR310 sont enregistrés dans le constructeur de cette classe. Une chose à noter est l'enregistrement du désérialiseur. Je ne pouvais tout simplement pas résoudre le problème de désérialisation JSR310 décrit ci-dessous. Par conséquent, il était nécessaire de préparer un désérialiseur personnalisé. Cette fois, je n'ai préparé que LocalDateTime, mais je prédis que si vous utilisez d'autres API telles que ZonedDateTime, vous devez également préparer un désérialiseur.

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)
                //Si vous ne le désactivez pas, il sera formaté à l'heure Unix.
                .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .featuresToEnable(SerializationFeature.INDENT_OUTPUT)
                .build();

    }
}

Préparez-vous à utiliser l'ObjectMapper beanized

J'ai pu convertir l'ObjectMapper en bean, mais tel quel, cet ObjectMapper beanisé ne sera pas utilisé par Jersey. Il semble y avoir plusieurs façons d'utiliser cet ObjectMapper, mais ContextResolver Pour le rendre disponible.

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;
    }
}

Je ne suis pas sûr car il n'y a pas d'explication, mais d'après la situation, si le type spécifié dans le paramètre type du ContextResolver à implémenter est utilisé à Jersey, ce contexte sera obtenu via la méthode getContext de cette classe. est. Puisque l'ObjectMapper Beanized est injecté dans le constructeur de cette classe, vous pouvez utiliser l'ObjectMapper correspondant à JSR310.

Traitez la demande avec un ObjectMapper beanisé

J'ai défini un contexte, mais ce contexte n'est pas enregistré avec Jersey. Si vous souhaitez enregistrer certaines ressources pour le traitement côté serveur de Jersey, vous devez vous inscrire auprès de ResourceConfig et restaurer les paramètres avec la méthode de configuration JerseyTest. En enregistrant le ContextResolver ci-dessus avec la classe de ressources Jersey, l'ObjectMapper enregistré peut être utilisé lors du traitement de la demande côté serveur.

MyJerseyTest.java



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

Traitez la réponse avec un ObjectMapper bean-ized

Jersey semble faire une distinction stricte entre les paramètres côté serveur et côté client. Les paramètres côté serveur sont le processus de traitement des demandes. Le traitement côté client est le traitement de traitement de la réponse. Le désérialiseur côté serveur peut être enregistré, mais le désérialiseur côté client ne peut pas être enregistré. Cela élimine l'erreur 400 lors de la publication du code de test. Cependant, lorsque j'essaie d'exécuter Response.readEntity, il semble qu'un ObjectMapper qui ne prend pas en charge JSR310 est utilisé, et l'erreur citée au début se produit. Pour éliminer cette erreur, enregistrez un ContextResolver avec le client utilisé par Jersey (dans ce cas, l'instance qui effectue la demande et reçoit la réponse lors de l'exécution du test).

MyJerseyTest.java


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

Vous avez maintenant enregistré un ObjectMapper pour JSR310. La classe dans laquelle JerseyTest est défini est la suivante.

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();
            }
        };

    }
}

Créer un désérialiseur pour LocalDateTime

Comme mentionné ci-dessus, j'ai enregistré un désérialiseur LocalDateTime avec ObjectMapper, mais cela devait être personnalisé. Cela est dû au fait que le désérialiseur par défaut ne peut pas désérialiser la chaîne générée par Jersey. Le traitement peut être effectué avec le désérialiseur spécifié Il y a peut-être un moyen, mais je n'ai tout simplement pas pu trouver d'alternative. Ce qui suit est adopté pour cette solution.

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()
            );
        }
    }
}

La raison en est que lorsque la méthode Response.readEntity est exécutée dans la classe de test, la chaîne de la valeur LocalDateTime qui peut être obtenue par le désérialiseur Jackson LocalDateTime par défaut qui est exécuté dans la partie la plus interne est Ce n'est pas le format attendu par Jackson. Au moment où l'objet de réponse a été créé, le format de la chaîne localDateTime a été changé au format suivant quoi qu'il arrive.

.json


{
	"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"
		}
	}
}

Que voulez-vous dire? Il a soigneusement sérialisé chaque objet membre de LocalDateTime au format JSON. Au lieu de faire cela, je devrais au moins faire docilement LocalDateTime.toString (). Vous devez vous désérialiser pour faire cette chose supplémentaire. Lors de la désérialisation, Jackson stocke les informations de chaîne de caractères à désérialiser sous forme de chaîne de caractères dans JsonParser, le premier argument de la méthode de désérialisation, mais lors de la désérialisation vers LocalDateTime, ce format de la chaîne de caractères est stocké dans LocalDateTime. N'a pas la capacité de désérialiser vers. La première branche dans le code ci-dessus,

Si un format autre que l'un des deux ci-dessus vient, une exception au devis au début se produira. Dans ce cas, la chaîne de caractères commence apparemment par "{", donc le caractère à désérialiser est jugé comme un objet et ne peut pas être désérialisé. [^ 1] Si vous obtenez un format que Jackson ne prend pas en charge, vous devrez le désérialiser vous-même, donc je n'avais pas d'autre choix que d'utiliser cette implémentation. Je me demandais pourquoi il était venu dans ce format en premier lieu, alors j'ai débogué le code de Jersey, mais j'ai décidé que c'était trop compliqué et que je perdais du temps pour le déchiffrer, donc je ne suis pas allé plus loin. .. ..

Impressions

Spring Boot fait beaucoup de travail, mais il semble que certaines parties de Jersey soient hors de portée. Avec Spring MVC, vous pouvez tester de manière transparente avec Spring Boot, ce problème ne se produit donc pas. Vous pouvez tester sans penser à rien. Et JAX-RS semble vous obliger à comprendre les détails de la spécification avant de l'utiliser. Le temps dont nous disposons est limité. Si vous passez votre temps à vous souvenir de quelque chose qui ne peut pas être fait, vous voulez passer votre temps à faire d'autres choses. À l'origine, le cadre doit faire de son mieux pour réduire ce délai, mais c'est aussi un bon point à tomber. Après tout, lorsque j'ai choisi Spring Boot, j'ai réalisé qu'il n'y avait aucun mérite à choisir Jersey (JAX-RS). ~~ En premier lieu, les avantages de créer une application Web autre que Spring Boot avec Java ne sont pas clairs à ce stade, il semble donc qu'il n'est pas nécessaire d'utiliser JAX-RS. ~~

Supplément

J'ai trouvé deux méthodes autres que l'utilisation de l'ObjectMapper Beanized avec ContextResolver, je vais donc les écrire. Dans les deux cas, il est nécessaire d'enregistrer l'instance côté serveur et côté client.

Utilisez JacksonJaxbJsonProvider

Par défaut, ObjectMapper est utilisé pour sérialiser et désérialiser le Json de Jersey, mais il semble que ce comportement soit obtenu à l'aide d'une classe appelée JacksonJaxbJsonProvider. Par conséquent, remplacez cette classe.

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);
    }
}

Implémenter MessageBodyReader et MessageBodyWrite

Le contenu du corps qui entre dans la réponse de Jersey semble entrer dans la mise en œuvre de la classe de titre. Il semble lire et écrire le corps en résolvant cette classe, et vous pouvez relier la sérialisation et la désérialisation ici. Cependant, cette méthode n'est pas du tout recommandée car elle nécessite une mise en œuvre supplémentaire, est compliquée et on ne sait pas si elle est vraiment pratique en premier lieu. Pour référence seulement.

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 {
        //Contenu à sérialiser
        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 {
        //Contenu à désérialiser

    }
}

[^ 1]: En passant, JsonParser a les informations pour se désérialiser sous forme de chaîne. Si la chaîne de caractères de JsonParse ne commence pas par "{", "[", c'est une chaîne de caractères (ID_STRING), si elle commence par "{", c'est un objet (START_OBJECT), si elle commence par "[", c'est un tableau ( Il semble être jugé comme START_ARRAY).

Recommended Posts

Lorsque j'ai effectué un test de l'API Jersey, j'ai rencontré un phénomène selon lequel l'API JSR310 ne pouvait pas être désérialisée.
L'histoire de l'arrêt parce que le test des rails n'a pas pu être fait
Après avoir vérifié le problème de Montyhall avec Ruby, c'était une histoire que je pouvais bien comprendre et que je ne comprenais pas bien
L'histoire que je n'ai pas pu construire après l'installation de plusieurs Java sur Windows