Введение
Всем привет, друзья! Сегодня хочу рассказать про способ использования ControllerAdvice для оборачивания объекта, возвращаемого контроллерами, в новый класс на уровне DispatcherServlet.
Пример:
Допустим, некоторый метод отдавал информацию о пользователе
{
"name": "Ivan",
"surname": "Ivanov"
}
И есть еще десяток методов, которые отдают некоторую информацию о пользователе
Но теперь мы хотим, чтобы каждый метод отдавал дополнительно еще несколько общих полей (например, серию и номер паспорта)
{
"name": "Ivan",
"surname": "Ivanov",
"passport": "1111 111111"
}
Я расскажу про интересное применение ControllerAdvice и покажу один из способов, которым можно решить такую задачу
UDP: про то, как еще можно использовать такой подход
UDP: представленный в статье механизм возможно использовать и для иных задач, связанных с обработкой ответов контроллеров. Подобный подход используется для обработки исключений в контроллерах. (Хорошая статья об обработки исключений https://habr.com/ru/post/528116/)
С помощью использования ControllerAdvice+ResponseBodyAdvice и аннотаций вы можете более гибко настроить обработку любых ответов контроллеров с использованием большого количества информации о методе, контроллере, запросе и ответе контроллера
P.S. В жизни такой подход был удобен, когда в проекте существовала отдельная библиотека, импортирующая в микросервисы модель данных API, а по бизнес требованиям стало необходимо добавить в некоторых микросервисах к моделям дополнительные данные. Возможны и иные кейсы
Решение задачи
Решение удобно при его многократном использовании. В одном сервисе будет удобнее, скорее всего, использовать иной подход. Поэтому будем писать стартер
Наши задачи:
Создать удобный способ использования стартера в коде - через аннотации
Создать возможность гибкой настройки добавляемых данных в класс-обертку
Создать класс с @ControllerAdvicе, обрабатывающий методы контроллеров
Собрать все в стартер Spring Boot
Аннотации
Для начала создадим 2 аннотации, который будут включать оборачивание для контроллера и выключать его для конкретного метода
Аннотация, включающая обработку методов
@RestController
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnableResponseWrapper {
Class<? extends IWrapperModel> wrapperClass();
}
Так как наша аннотация всегда висит над контроллером, а у аннотаций в java не существует понятия наследования - то вешаем над нашей аннотацией @RestController - это позволит использовать @EnableResponseWrapper вместо @RestController
@Target(ElementType.TYPE) - указывает на то, что наша аннотация может висеть над классом, интерфейсом или enum-ом
@Retention(RetentionPolicy.RUNTIME) - указывает область видимости аннотации - во время выполнения кода
Аннотация, в качестве аргумента, принимает класс, который описывает оболочку Class<? extends IWrapperModel> wrapperClass();
Аннотация, отключающая обработку метода
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DisableResponseWrapper {
}
Сами по себе аннотации, безусловно, никакой функциональности не добавляют. Обработкой аннотаций займемся позднее
Интерфейсы
Для работы нам понадобится сущность, возвращающая дополнительные данные и сущность, описывающая обертку. Создадим интерфейсы
Интерфейс сервиса, через который будем получать данные для наполнения обертки
@Service
public interface IWrapperService {
Object getData(Object body);
}
Через метод getData(Object body) будем получать данные, затем кладем в класс-обертку.
Интерфейс класса-обертки
public interface IWrapperModel {
void setData(Object object);
void setBody(Object object);
}
Через метод setData(Object object) устанавливаем те данные, которые получили в методе getData(Object object).
Через setBody(Object object) устанавливаем объект-ответ, который вернул обрабатываемый метод
Эдвайс
Создадим основной класс стартера, обрабатывающий методы контроллеров
UPD: Инжект бина сервиса происходит через внутренние функции спринга. В случае, если интерфейс реализуют несколько классов - при инжекте произойдет ошибка. В проекте возможно решить эту проблему с помощью @Primary . Если вам необходима различная реализация сервиса - возможна доработка проекта с добавлением передачи сервиса вместе с классом-обертки, инжектом мапы сервисов либо иными более простыми способами решения.
Данный pet проект я не стал дорабатывать из-за нежелания усложнять код - в первую очередь цели проекта и статьи - рассказать об интересном решении, а не создание библиотеки. Выражаю благодарность пользователю, нашедшему данное неудобство @maxzh83
@AllArgsConstructor
@ControllerAdvice(annotations = EnableResponseWrapper.class)
public class ResponseWrapperAdvice implements ResponseBodyAdvice<Object> {
...
}
Аннотация
@ControllerAdvice(annotations = EnableResponseWrapper.class) указывает, что методы данного компонента будут использоваться сразу несколькими контроллерами. Также указываем, что наши методы будут обрабатывать только те контроллеры, которые помечены @EnableResponseWrapper
Класс реализует интерфейс ResponseBodyAdvice<>, который позволяет настраивать ответ, после его возвращения методом @ResponseBody
или контроллером ResponseEntity, но до того, как тело будет записано с помощью HttpMessageConverter
В классе необходимо реализовать 2 метода
Первый позволяет отсеить методы на обрабатываемые и не обрабатываемые
Именно в нем мы проверим, не аннотирован ли случайно наш метод @DisableResponseWrapper. Получим все аннотации, которые висят над методом, и поищем среди них нужную нам аннотацию.
...
@Override
public boolean supports(MethodParameter returnType, @NonNull Class converterType) {
for (Annotation a : returnType.getMethodAnnotations()) {
if (a.annotationType() == DisableResponseWrapper.class) {
return false;
}
}
return true;
}
...
Второй метод класса вызывается только для тех методов, для которых метод supports возвращает true
...
@SneakyThrows
@Override
public Object beforeBodyWrite(
@Nullable Object body,
@NonNull MethodParameter returnType,
@NonNull MediaType selectedContentType,
@NonNull Class selectedConverterType,
@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response
) {
if (body == null) {
return null;
}
// получаем wrapperClass из аннотации
Class<? extends IWrapperModel> wrapperClass = null;
for (Annotation annotation : returnType.getContainingClass().getAnnotations()) {
if (annotation.annotationType() == EnableResponseWrapper.class) {
wrapperClass = ((EnableResponseWrapper) annotation).wrapperClass();
break;
}
}
if (wrapperClass == null) {
return body;
}
...
Достаем класс-обертку из аннотации
Далее будем работать с объектом, который возвращает наш метод (в методе beforeBodyWrite()
он передается первым параметром Object body
)
Рассмотрим две ситуации: когда метод возвращает коллекцию и когда возвращает единичный объект. В случае коллекции мы хотим, чтобы был обернут каждый объект коллекции:
...
// проверяем, был ли передан Collection или наследник Collection
if (Collection.class.isAssignableFrom(body.getClass())) {
try {
Collection<?> bodyCollection = (Collection<?>) body;
// проверяем, что collection не пустой
if (bodyCollection.isEmpty()) {
return body;
}
// оборачиваем каждый элемент коллекции
return generateListOfResponseWrapper(bodyCollection, wrapperClass);
} catch (Exception e) {
return body;
}
}
...
И если обрабатываемый метод отдает не коллекцию:
...
return generateResponseWrapper(body, wrapperClass);
...
Функции generateListOfResponseWrapper и generateResponseWrapper генерируют обертку для коллекции и для единичного элемента:
...
private List<IWrapperModel> generateListOfResponseWrapper(Collection<?> bodyCollection, Class<? extends IWrapperModel> wrapperClass) {
return bodyCollection.stream()
.map((t) -> t == null ?
null :
generateResponseWrapper(t, wrapperClass)
)
.collect(Collectors.toList());
}
...
...
@SneakyThrows
private IWrapperModel generateResponseWrapper(Object body, Class<? extends IWrapperModel> wrapperClass) {
// wrapperClass должен иметь конструктор без параметров - получаем объект класса, реализующего IWrapperModel
IWrapperModel wrapper = wrapperClass.getDeclaredConstructor().newInstance();
wrapper.setBody(body);
wrapper.setData(wrapperService.getData(body));
return wrapper;
}
...
Обратим внимание, что из класса нам необходимо получить объектIWrapperModel wrapper = wrapperClass.getDeclaredConstructor().newInstance();
, но такой подход требует наличия в классе конструктора без параметров. Используем @SneakyThrows библиотеки Lombok для того, чтобы обработать это исключение
Полный код
@AllArgsConstructor
@ControllerAdvice(annotations = EnableResponseWrapper.class)
public class ResponseWrapperAdvice implements ResponseBodyAdvice<Object> {
private final IWrapperService wrapperService;
/**
* Метод не будет обработан, если помечен аннотацией {@link DisableResponseWrapper} <br/> <br/>
*
* @param returnType the return type
* @param converterType the selected converter type
* @return {@code true} if {@link #beforeBodyWrite} should be invoked;
* {@code false} otherwise
*/
@Override
public boolean supports(MethodParameter returnType, @NonNull Class converterType) {
for (Annotation a : returnType.getMethodAnnotations()) {
if (a.annotationType() == DisableResponseWrapper.class) {
return false;
}
}
return true;
}
/**
* Оборачиваем ответ
*
* @param body the body to be written
* @param returnType the return type of the controller method
* @param selectedContentType the content type selected through content negotiation
* @param selectedConverterType the converter type selected to write to the response
* @param request the current request
* @param response the current response
* @return the body that was passed in or a modified (possibly new) instance
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(
@Nullable Object body,
@NonNull MethodParameter returnType,
@NonNull MediaType selectedContentType,
@NonNull Class selectedConverterType,
@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response
) {
if (body == null) {
return null;
}
// получаем wrapperClass из аннотации
Class<? extends IWrapperModel> wrapperClass = null;
for (Annotation annotation : returnType.getContainingClass().getAnnotations()) {
if (annotation.annotationType() == EnableResponseWrapper.class) {
wrapperClass = ((EnableResponseWrapper) annotation).wrapperClass();
break;
}
}
if (wrapperClass == null) {
return body;
}
// проверяем, был ли передан Collection или наследник Collection
if (Collection.class.isAssignableFrom(body.getClass())) {
try {
Collection<?> bodyCollection = (Collection<?>) body;
// проверяем, что collection не пустой
if (bodyCollection.isEmpty()) {
return body;
}
// оборачиваем каждый элемент коллекции
return generateListOfResponseWrapper(bodyCollection, wrapperClass);
} catch (Exception e) {
return body;
}
}
// если не collection
return generateResponseWrapper(body, wrapperClass);
}
/**
* Генерируем список оберток для коллекции (те информация добавляется внутрь списка)
*
* @param bodyCollection список объектов, которые необходимо обернуть
* @param wrapperClass объект обертки
* @return список оберток
*/
private List<IWrapperModel> generateListOfResponseWrapper(Collection<?> bodyCollection, Class<? extends IWrapperModel> wrapperClass) {
return bodyCollection.stream()
.map((t) -> t == null ?
null :
generateResponseWrapper(t, wrapperClass)
)
.collect(Collectors.toList());
}
/**
* Генерируем обертку вокруг объекта
*
* @param body объект который необходимо поместить в обертку
* @param wrapperClass объект обертки
* @return обертка
*/
@SneakyThrows
private IWrapperModel generateResponseWrapper(Object body, Class<? extends IWrapperModel> wrapperClass) {
// wrapperClass должен иметь конструктор без параметров - получаем объект IWrapperModel
IWrapperModel wrapper = wrapperClass.getDeclaredConstructor().newInstance();
wrapper.setBody(body);
wrapper.setData(wrapperService.getData(body));
return wrapper;
}
}
Стартер
Теперь нам необходимо превратить наш проект в стартер. Для этого создадим класс автоконфигурации, в котором будем создавать бин из класса ResponseWrapperAdvice
@Configuration
@AutoConfigureAfter(WebMvcAutoConfiguration.class)
@AllArgsConstructor
public class ResponseWrapperAutoConfiguration {
private final IWrapperService wrapperService;
@Bean
@ConditionalOnMissingBean
public ResponseWrapperAdvice responseWrapperAdvice() {
return new ResponseWrapperAdvice(wrapperService);
}
}
@AutoConfigureAfter(WebMvcAutoConfiguration.class)
- говорит о том, что наши бины подключатся после того, как сконфигурируются и подключатся бины web mvc
А также в resources/META-INF/ создадим файл spring.factories, в котором укажем, где Spring Boot-у искать наши настроенные бины для добавления в контекст
org.springframework.boot.autoconfigure.EnableAutoConfiguration=ru.emilnasyrov.lib.response.wrapper.config.ResponseWrapperAutoConfiguration
Сборка
Соберем стартер в jar файл с помощью команды
gradle jar
Наш jar-ник появится в build/libs/response-wrapper-starter-0.0.1-SNAPSHOT-plain.jar
- для удобства обрезаем -plain. Стартер готов и нам остается только подключить его к проекту
Демо
Для демо создадим отдельный проект, в котором будем использовать стартер
В проекте создадим папку libs, в которую положим jar стартера
В build.gradle
стартер подключаем следующим образом:
repositories {
...
flatDir {
dirs 'libs'
}
}
dependencies {
...
implementation 'ru.emilnasyrov.lib:response-wrapper-starter:0.0.1-SNAPSHOT'
...
}
Модели данных
MainModel - изначальные данные
@Data
@AllArgsConstructor
public class MainModel {
private String name;
private String surname;
// переопределяем toString, hashCode, equals
}
И Wrapper - класс-обертка
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Wrapper implements IWrapperModel {
@JsonUnwrapped
Object main;
String someInfo;
@Override
public void setData(Object object) {
someInfo = object.toString();
}
@Override
public void setBody(Object object) {
main = object;
}
// переопределяем toString, hashCode, equals
}
Сервис, ответственный за получение данных извне
@Service
public class WrapperServiceImpl implements IWrapperService {
@Override
public Object getData(Object body) {
return "Additional Information";
}
}
Контроллер
Controller
Создадим контроллер, на который повесим аннотацию @EnableResponseWrapper(wrapperClass = Wrapper.class) с указанием класса-обертки
@EnableResponseWrapper(wrapperClass = Wrapper.class)
@RequestMapping("/test")
public class Controller {
@GetMapping
public MainModel test() {
return new MainModel("Name", "Surname");
}
@GetMapping("/collection")
public Collection<MainModel> testList() {
Collection<MainModel> mainModels = new ArrayList<>();
mainModels.add(new MainModel("Name1", "Surname1"));
mainModels.add(new MainModel("Name2", "Surname2"));
return mainModels;
}
@DisableResponseWrapper
@GetMapping("/unwrapped")
public MainModel unwrapped() {
return new MainModel("Name", "Surname");
}
}
Точки входа с оберткой одного объекта /test
, с оберткой коллекции объектов /test/collection
и /test/unwrapped
- отключение обработки для конкретного метода
Запустим проект и проверим запросы
Postman
Резюме
Мы рассмотрели интересный способ использования ControllerAdvice для работы с ответом точек входа контроллера. Также создали pet-библиотеку с реализацией в виде стартера Spring Boot
Почему не будет работать обычное AOP? Потому что AOP создает прокси класса с помощью CGLib или JDK Dynamic Proxy. Но когда DispatcherServlet, сканируя наши контроллеры, увидит, что контроллер должен возвращать один класс, а возвращает в итоге иной (обертку), то он отдаст ошибку. Однако подход, описанный в статье, позволяет провернуть такую интересную штуку
Ссылка на на полный код проекта: GitHub