Ку, Хабр!
Немного откровения: каждый раз написав 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 классов
Базовый класс
Команда без аргументов
Имплементация для команды с аргументами
Enum для определения принадлежности команды к классу, посредством метода getByClass()
@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)
a_belyaev
02.08.2019 17:07+1Не очень понятно. Для каждого типа команд есть собственный listener? Чем не устроил стандартный механизм обработки событий, который есть в Spring? Там диспетчеризация по типу события автоматом происходит.
sickfar
02.08.2019 18:33И вот вы реализовали паттерн "команда" с использованием прелестной рефлексии. Думаю, вам стоит ознакомиться с паттернами проектирования, все велосипеды уже изобретены. Возможно вас заинтересует ещё AOP с неявным навешиванием оберток.
usharik
Хм… А причем тут Spring Boot?