Ку, Хабр!

Немного откровения: каждый раз написав 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

Комментарии (4)


  1. usharik
    02.08.2019 14:52
    +1

    Хм… А причем тут Spring Boot?


  1. a_belyaev
    02.08.2019 17:07
    +1

    Не очень понятно. Для каждого типа команд есть собственный listener? Чем не устроил стандартный механизм обработки событий, который есть в Spring? Там диспетчеризация по типу события автоматом происходит.


  1. sickfar
    02.08.2019 18:33

    И вот вы реализовали паттерн "команда" с использованием прелестной рефлексии. Думаю, вам стоит ознакомиться с паттернами проектирования, все велосипеды уже изобретены. Возможно вас заинтересует ещё AOP с неявным навешиванием оберток.


  1. 3draven
    02.08.2019 19:26

    Чувак не умеет в spring, сразу видно, чукча писатель