Spring Boot: Рефлексия c аннотациями

от автора

Ку, Хабр!

Немного откровения: каждый раз написав if с несколькими условиями или switch с полотном кейсов, чувствую, что где-то плачут котики. Чем больше условий, тем больше котиков. Рука тянется оптимизировать, переписать, переделать и накормить котиков.

Собственно, статья — индульгенция. Решение одного из кейсов при помощи рефлективной парадигмы программирования.

Работаю я в Beeline Кыргызстан, и у телекома часто на повестке дня кейсы с условием высокой нагрузки и масштабирования. Задача — реализовать application, принимающего неограниченное количество команд с обработкой в многопоточном режиме. Каждый раз писать еще один условный блок — решение так себе, как и вариант с регулярными выражениями. И оба варианта лишь добавят мотивации склонному к насилию психопату, который знает, где я живу, если этих команд 100 или 1000 например.
Если вы привыкли сразу читать код, то ссылка на репозиторий тут.

Идея следующая: двигаемся по пути паттерна Mediator (или Intermediary, ­Controller), создадим диспетчера, который будет определять назначение той или иной команды, и вызывать аннотированный метод с логикой.

Например, приходит команда /command/observeAllUserActions. По имени команды observeAllUserActions, определяем класс-обработчик и выполняем логику одного конкретного метода, который слушает например таблицу логов, фильтрует и возвращает все действия юзеров в один определенный топик. На фронте подписываемся на этот топик и видим данные в реалтайме.

Итак, начнем.

Пара слов об аннотациях для понимания. Аннотация — это по сути метка в коде, метаданные для определенного типа элементов (пакет, класс, метод, конструктор и т.д). Пометив код, в дальнейшем используем его в рантайме, определив по этой самой аннотации.

Наши две аннотации CommandHandler и HandlerMethod:

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Component public @interface CommandHandler { } 

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface HandlerMethod {     AppEventsEnum value(); } 

RetentionPolicy — это жизненный цикл аннотации и у нас указан RUNTIME, еще есть CLASS и SOURCE.

CLASS указываем, если метаданные нужны только в исходном коде, SOURCE если в скомпилированном файле и RUNTIME если нужен в процессе выполнения кода.

Target — тип элемента, для которого создается аннотация, это может быть класс, метод, конструктор, поле и т.д.

Также добавим две зависимости

<dependency>  <groupId>org.reflections</groupId>  <artifactId>reflections</artifactId>  <version>0.9.10</version> </dependency>  <dependency>  <groupId>com.fasterxml.jackson.core</groupId>  <artifactId>jackson-databind</artifactId> </dependency> 

Библиотека Reflections работает как сканер CLASSPATH. Индексирует отсканированные метаданные и позволяет запрашивать их во время выполнения(в рантайме). Также умеет сохранять эту информацию, и можно использовать ее в любой момент проекта без необходимости повторного сканирования CLASSPATH.

Jackson Project в представлении не нуждается. Библиотека для парсинга/генерации JSON.

Описываем типы для будущих event handle классов:

Enum для наших event handle классов

Базовый класс

@Data @NoArgsConstructor @AllArgsConstructor public class AppCommand {     protected Map<String, String> arguments; } 

Команда без аргументов

public class Command1 extends AppCommand {     public Command1() {         super();     } } 

Имплементация для команды с аргументами

@Getter @Setter public class Command2 extends AppCommand {      private String someParams;      public Command2() {         super();     }      public Command2(String someParams) {         this.someParams = someParams;     }      public Command2(Map<String, String> arguments, String someParams) {         super(arguments);         this.someParams = someParams;     } } 

Enum для определения принадлежности команды к классу, посредством метода getByClass()

public enum AppCommandsEnum {      COMMAND1(Command1.class),     COMMAND2(Command2.class);      private Class<? extends AppCommand> appCommandClass;      AppCommandsEnum(Class<? extends AppCommand> appCommandClass) {         this.appCommandClass = appCommandClass;     }      public static AppCommandsEnum getByClass(Class<? extends AppCommand> appCommandClass) {         for (AppCommandsEnum commandsEnum : AppCommandsEnum.values()) {             if (commandsEnum.appCommandClass.isAssignableFrom(appCommandClass))                 return commandsEnum;         }         return null;     }      public Class getAppCommandClass() {         return appCommandClass;     } } 

Диспетчеру нужен инвокер (класс, вызывающий методы).

Класс-обертка для java.lang.reflect.Method.invoke()

class CommandInvoker {      private final Method method;     private final Object instance;      CommandInvoker(Method method, Object instance) {         this.method = method;         this.instance = instance;     }      void invoke(AppCommand command) {         try {              method.invoke(instance, command);          } catch (IllegalAccessException | InvocationTargetException e) {             throw new DispatchException(e);         } catch (IllegalArgumentException e) {             throw new MethodArgumentMismatchException(                     "Невалидные аргументы для метода: "                             + this.method.getName()                             + " :: "                             + this.instance.getClass().getName(),                     e);         }     } } 

Диспетчер

код под спойлером, чтобы не было ощущения, что статья вся из кода

@Component public class CommandDispatcher {    private static Map<AppCommandsEnum, CommandInvoker> warehouse = new ConcurrentHashMap<>();    private final ApplicationContext context;    @Autowired  public CommandDispatcher(ApplicationContext context) {   this.context = context;   loadDispatchers();  }    private void loadDispatchers() {   Reflections reflections = new Reflections("com.xeofus.reflectiveapp.handler");   Set<Class<?>> annotated = reflections.getTypesAnnotatedWith(CommandHandler.class);     for (Class clazz : annotated) {    for (Method method : clazz.getDeclaredMethods()) { 	HandlerMethod handlerMethod; 	if ((handlerMethod = method.getAnnotation(HandlerMethod.class)) != null) { 	 Object beanObject = null; 	 try { 	 	beanObject = context.getBean(clazz); 	 } catch (Exception e) { 	 	System.out.println("Bean not found " + clazz); 	 } 	 if (beanObject != null) 	 	warehouse.put( 		 handlerMethod.value(),  		 new CommandInvoker(method, beanObject) 		); 	 }    }   }  }    public void dispatch(AppCommand command) {   try {    warehouse.get(AppCommandsEnum.getByClass(command.getClass())).invoke(command);   } catch (NullPointerException e) {    throw new MethodNotImplementedException("Для команды "  	+ AppCommandsEnum.getByClass(command.getClass())  	+ " не нашлось аннотированного метода",  	e    );   }  } } 

Немного заострю внимание, разберем чем занят диспетчер.

private static Map<AppCommandsEnum, CommandInvoker> warehouse = new ConcurrentHashMap<>(); 

Здесь используется статичный ConcurrentHashMap, о котором можно прочесть тут. Если коротко то, у HashMap synсhronized блоки, а у ConcurrentHashMap данные сегментированы и разбиты по hash’у ключа. Это дает доступ к данным с локом по сегментам, а не по объекту. В итоге есть мапа, который будем использовать как склад с бинами после скана CLASSPATH.

Далее идет DI и вызов из конструктора метода loadDispatchers()

@Autowired public CommandDispatcher(ApplicationContext context) { 	this.context = context; 	loadDispatchers(); } 

Инициализируем диспетчера.

Определяем пакет для скана

Reflections reflections = new Reflections("com.xeofus.reflectiveapp.handler");

Получаем все классы аннотированные кастомной аннотацией @CommandHandler

Set<Class<?>> annotated = reflections.getTypesAnnotatedWith(CommandHandler.class);

Дракарис

//Проходимся по каждому классу for (Class clazz : annotated) {  //ищем задекларированные методы  for (Method method : clazz.getDeclaredMethods()) {   HandlerMethod handlerMethod;   //если метод аннотирован нашей @HandlerMethod   if ((handlerMethod = method.getAnnotation(HandlerMethod.class)) != null) {   	Object beanObject = null;   	// создаем бин вытащив из ApplicationContext   	// аннотированный класс   	try {          //так делать не самая лучшая идея          //нарушает философию IoC   	 beanObject = context.getBean(clazz);   	} catch (Exception e) {   	 System.out.println("Bean not found " + clazz);   	}   	//Далее, добавляем в ConcurrentHashMap   	if (beanObject != null)   	 warehouse.put(   	  handlerMethod.value(),   	  new CommandInvoker(method, beanObject)   	 );   }  } } 

Обработчик

public void dispatch(AppCommand command) {  try {   warehouse.get(AppCommandsEnum.getByClass(command.getClass())).invoke(command);  } catch (NullPointerException e) {  	throw new MethodNotImplementedException("Для команды "  	 + AppCommandsEnum.getByClass(command.getClass())  	 + " не нашлось аннотированного метода",  	 e  	);  } } 

Контроллер для WebSocket сообщений

Код класса

@Controller public class WsController {   private final CommandDispatcher dispatcher;   private final ObjectMapper mapper;      @Autowired   public WsController(CommandDispatcher dispatcher, ObjectMapper mapper) {       this.dispatcher = dispatcher;       this.mapper = mapper;   }      @MessageMapping("/command/{commandName}")   public void commandHandler(           @DestinationVariable String commandName,           Message message   ) {       try {        @SuppressWarnings("unchecked")        AppCommand appCommand = (AppCommand) mapper.readValue(         new String((byte[]) message.getPayload()),         AppCommandsEnum.valueOf(commandName.toUpperCase()).getAppCommandClass()        );         dispatcher.dispatch(appCommand);       } catch (IOException e) {        e.printStackTrace();       }   } } 

Получаем команду вида /command/command1, переводим command1 в верхний регистр, вытаскиваем из AppCommandsEnum значение класса

AppCommandsEnum.valueOf(commandName.toUpperCase())

Код выше вернет enum COMMAND1, геттером getAppCommandClass() получаем класс handler Command1.class.

Плюс аргументы из message.getPayload().

new String((byte[]) message.getPayload())

Кастим все с это с помощью ObjectMapper.readValue() и отправляем обработчику диспетчера.

Итог: можем создавать бесконечное количество handler классов, именуя их как хотим, тем самым обеспечив контролируемое масштабирование. После каждого добавления handler класса, нужно объявить его в AppCommandsEnum

MYNEWCOMMAND(MyNewCommand.class),

теперь у нас готов новый endpoint /command/mynewcommand
Как вы уже поняли, endpoint должен совпадать с enum, это единственное ограничение.

Далее, нужны хэндлер классы с минимум одним методом, для выполнения той самой логики, ради которой пишется app. Классы аннотируем нашей аннотацией CommandHandler, методы аннотацией HandlerMethod

@CommandHandler public class MyCommand1 {      private final WsCallbackDispatcher dispatcher;     private final ObjectMapper mapper;      @Autowired     private MyCommand1(WsCallbackDispatcher dispatcher, ObjectMapper mapper) {         this.dispatcher = dispatcher;         this.mapper = mapper;     }      @HandlerMethod(AppCommandsEnum.COMMAND1)     public void runExecution(Command1 command1) {         //здесь блок выполнения какой-то бизнес логики         Runnable run = () -> {             try {                 dispatcher.dispatch(mapper.writeValueAsString(command1));             } catch (JsonProcessingException e) {                 e.printStackTrace();             }         };          new Thread(run).start();     } } 

Здесь облегченный вариант с интерфейсом Runnable, с минимумом трудозатрат можем использовать ExecutorService.

Мне кажется, труд выше получился немного громоздким и не самым простым для понимания. Если у вас есть альтернативное решение подобных задач, ну или просто критика, добро пожаловать в комментарии. В комментах рождается истина 🙂

Спасибо за внимание!

Ссылка на Github


ссылка на оригинал статьи https://habr.com/ru/post/460871/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *