Подтвердить что ты не робот

Spring Загрузка: обертка ответа JSON в динамических родительских объектах

У меня есть спецификация REST API, которая говорит с фоновыми микросервисами, которые возвращают следующие значения:

В ответах "коллекций" (например, GET/users):

{
    users: [
        {
            ... // single user object data
        }
    ],
    links: [
        {
            ... // single HATEOAS link object
        }
    ]
}

В ответах "один объект" (например, GET /users/{userUuid}):

{
    user: {
        ... // {userUuid} user object}
    }
}

Этот подход был выбран таким образом, чтобы отдельные ответы были бы расширяемыми (например, возможно, если GET /users/{userUuid} получает дополнительный параметр запроса по строке, такой как ?detailedView=true, у нас будет дополнительная информация запроса).

В сущности, я считаю, что это подход OK для минимизации изменений разрыва между обновлениями API. Однако преобразование этой модели в код очень сложно.

Скажем, что для отдельных ответов у меня есть следующий объект модели API для одного пользователя:

public class SingleUserResource {
    private MicroserviceUserModel user;

    public SingleUserResource(MicroserviceUserModel user) {
        this.user = user;
    }

    public String getName() {
        return user.getName();
    }

    // other getters for fields we wish to expose
}

Преимущество этого метода заключается в том, что мы можем выставлять только поля из внутренних моделей, для которых у нас есть публичные геттеры, но не другие. Тогда для ответов коллекций у меня будет следующий класс оболочки:

public class UsersResource extends ResourceSupport {

    @JsonProperty("users")
    public final List<SingleUserResource> users;

    public UsersResource(List<MicroserviceUserModel> users) {
        // add each user as a SingleUserResource
    }
}

Для однопоточных ответов у нас будет следующее:

public class UserResource {

    @JsonProperty("user")
    public final SingleUserResource user;

    public UserResource(SingleUserResource user) {
        this.user = user;
    }
}

Это дает ответы JSON, которые отформатированы в соответствии со спецификацией API в верхней части этой публикации. Поверхность этого подхода заключается в том, что мы раскрываем только те поля, которые мы хотим разоблачить. Тяжелый недостаток заключается в том, что у меня есть тонна классов-оболочек, которые летают вокруг, которые не выполняют никакой заметной логической задачи, кроме чтения Джексоном, чтобы получить корректно отформатированный ответ.

Мои вопросы таковы:

  • Как я могу обобщить этот подход? В идеале я хотел бы иметь один класс BaseSingularResponse (и, возможно, класс BaseCollectionsResponse extends ResourceSupport), который все мои модели могут расширять, но, видя, как Джексон выводит ключи JSON из определений объектов, мне нужно было бы что-то использовать пользователю например, Javaassist, чтобы добавить поля к базовым классам ответов в Runtime - грязный хак, который я хотел бы оставить как можно дальше от человека.

  • Есть ли более простой способ сделать это? К сожалению, я могу иметь переменное количество объектов JSON верхнего уровня в ответе через год, поэтому я не могу использовать что-то вроде Jackson SerializationConfig.Feature.WRAP_ROOT_VALUE, потому что это обертывает все в один объект на уровне корня (насколько мне известно).

  • Возможно, что-то вроде @JsonProperty для уровня класса (в отличие от только метода и уровня поля)?

4b9b3361

Ответ 1

Существует несколько возможностей.

Вы можете использовать java.util.Map:

List<UserResource> userResources = new ArrayList<>();
userResources.add(new UserResource("John"));
userResources.add(new UserResource("Jane"));
userResources.add(new UserResource("Martin"));
Map<String, List<UserResource>> usersMap = new HashMap<String, List<UserResource>>();
usersMap.put("users", userResources);
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(usersMap));

Вы можете использовать ObjectWriter, чтобы обернуть ответ, который вы можете использовать, как показано ниже:

ObjectMapper mapper = new ObjectMapper();
ObjectWriter writer = mapper.writer().withRootName(root);
result = writer.writeValueAsString(object);

Вот предложение для обобщения этой сериализации.

Класс для обработки простого объекта:

public abstract class BaseSingularResponse {

    private String root;

    protected BaseSingularResponse(String rootName) {
        this.root = rootName;
    }

    public String serialize() {
        ObjectMapper mapper = new ObjectMapper();
        ObjectWriter writer = mapper.writer().withRootName(root);
        String result = null;
        try {
            result = writer.writeValueAsString(this);
        } catch (JsonProcessingException e) {
            result = e.getMessage();
        }
        return result;
    }
}

Класс для обработки коллекции:

public abstract class BaseCollectionsResponse<T extends Collection<?>> {
    private String root;
    private T collection;

    protected BaseCollectionsResponse(String rootName, T aCollection) {
        this.root = rootName;
        this.collection = aCollection;
    }

    public T getCollection() {
        return collection;
    }

    public String serialize() {
        ObjectMapper mapper = new ObjectMapper();
        ObjectWriter writer = mapper.writer().withRootName(root);
        String result = null;
        try {
            result = writer.writeValueAsString(collection);
        } catch (JsonProcessingException e) {
            result = e.getMessage();
        }
        return result;
    }
}

И пример приложения:

public class Main {

    private static class UsersResource extends BaseCollectionsResponse<ArrayList<UserResource>> {
        public UsersResource() {
            super("users", new ArrayList<UserResource>());
        }
    }

    private static class UserResource extends BaseSingularResponse {

        private String name;
        private String id = UUID.randomUUID().toString();

        public UserResource(String userName) {
            super("user");
            this.name = userName;
        }

        public String getUserName() {
            return this.name;
        }

        public String getUserId() {
            return this.id;
        }
    }

    public static void main(String[] args) throws JsonProcessingException {
        UsersResource userCollection = new UsersResource();
        UserResource user1 = new UserResource("John");
        UserResource user2 = new UserResource("Jane");
        UserResource user3 = new UserResource("Martin");

        System.out.println(user1.serialize());

        userCollection.getCollection().add(user1);
        userCollection.getCollection().add(user2);
        userCollection.getCollection().add(user3);

        System.out.println(userCollection.serialize());
    }
}

Вы также можете использовать аннотацию Jackson @JsonTypeInfo на уровне класса

@JsonTypeInfo(include=As.WRAPPER_OBJECT, use=JsonTypeInfo.Id.NAME)

Ответ 2

Лично я не возражаю против дополнительных классов Dto, вам нужно только создать их один раз, и стоимость обслуживания невелика. И если вам нужно выполнить тесты MockMVC, вам, скорее всего, потребуются классы для десериализации ваших ответов JSON для проверки результатов.

Как вы, вероятно, знаете, что структура Spring обрабатывает сериализацию/десериализацию объектов в слое HttpMessageConverter, так что это правильное место для изменения способа сериализации объектов.

Если вам не нужно десериализовать ответы, можно создать общую оболочку и настраиваемый HttpMessageConverter (и поместить его перед MappingJackson2HttpMessageConverter в список конвертеров сообщений). Вот так:

public class JSONWrapper {

    public final String name;
    public final Object object;

    public JSONWrapper(String name, Object object) {
        this.name = name;
        this.object = object;
    }
}


public class JSONWrapperHttpMessageConverter extends MappingJackson2HttpMessageConverter {

    @Override
    protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        // cast is safe because this is only called when supports return true.
        JSONWrapper wrapper = (JSONWrapper) object;
        Map<String, Object> map = new HashMap<>();
        map.put(wrapper.name, wrapper.object);
        super.writeInternal(map, type, outputMessage);
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        return clazz.equals(JSONWrapper.class);
    }
}

Затем вам необходимо зарегистрировать пользовательский HttpMessageConverter в конфигурации Spring, который расширяет WebMvcConfigurerAdapter, переопределяя configureMessageConverters(). Имейте в виду, что при этом отключается автоматическое обнаружение конвертеров по умолчанию, поэтому вам, вероятно, придется добавить сам по умолчанию (проверьте исходный код Spring для WebMvcConfigurationSupport#addDefaultHttpMessageConverters(), чтобы увидеть значения по умолчанию. Если вы расширяете WebMvcConfigurationSupport вместо WebMvcConfigurerAdapter, вы может напрямую вызвать addDefaultHttpMessageConverters (Лично я предпочитаю использовать WebMvcConfigurationSupport над WebMvcConfigurerAdapter, если мне нужно что-то настроить, но для этого есть некоторые незначительные последствия, о которых вы, вероятно, можете прочитать в других статьях.

Ответ 3

У Jackson нет большой поддержки динамических/переменных структур JSON, поэтому любое решение, которое выполняет что-то подобное, будет довольно храбрым, как вы упомянули. Насколько я знаю и из того, что я видел, стандартный и наиболее распространенный метод использует классы-оболочки, как вы сейчас. Классы-обертки складываются, но если вы проявляете творческий подход к своей нечувствительности, вы можете найти некоторые общие черты между классами и тем самым уменьшить количество классов-оболочек. В противном случае вы можете посмотреть на пользовательскую структуру.