Рубрики
Без рубрики

Как спроектировать уровень сервиса в Java с использованием CRF

Как спроектировать уровень сервиса/приложения на Java с использованием CRF –Команд, реакций и воронок. С тегами java, архитектура, многоуровневость, crf.

Вступление

В рамках моего курса Эффективная Java я показывал, как разработать простой, но гибкий уровень обслуживания для приложений Java с помощью подхода, который я называю CRF . Большинство участников находят эту идею свежей и захватывающей, поэтому я решил поделиться ею с более широкой аудиторией.

Уровень обслуживания?!

Давайте вкратце обсудим, что должен делать уровень обслуживания. Цель уровня обслуживания состоит в том, чтобы:

1) Определите вариант использования . Другими словами, когда вы смотрите на уровень сервиса, он должен отвечать на вопрос – на что способна система /.

2) Определите контракт . Другими словами, когда вы смотрите на конкретный вариант использования/услугу, должно быть ясно, какие входные данные требуются, каковы возможные выходные данные и какие исключения возникают.

3) Определите границу транзакции . Вариант использования либо завершается успешно, либо завершается неудачно. Если это удастся, связанные данные будут сохранены. Если это не удается – изменения откатываются. Это важно, потому что разные пользователи могут запускать множество вариантов использования параллельно. Скажем, если я (Эдуардс) и вы (читатель) пытаетесь закрепить одно и то же сиденье в самолете, есть 2 случая использования (secureaseat), работающих параллельно. Во избежание двойного бронирования ( привет, United Airlines ), если вы сначала обеспечите место, моя попытка бронирования должна завершиться неудачей, и все изменения, связанные с моей попыткой бронирования (если таковые имеются), должны быть отменены.

О сервисном уровне нужно знать гораздо больше, но этого достаточно, чтобы установить общую основу.

Теперь давайте рассмотрим наиболее распространенный способ построения сервисного уровня.

Процедурный уровень обслуживания (стиль бабушки)

Наиболее распространенным стилем уровня обслуживания является использование процедур.

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

interface MemberPermissionService {
  void grantPermission(String memberId, String permissionId);
  void revokePermission(String memberId, String permissionId);
  PermissionsDto listPermissions(String memberId);
  ...
}

Поскольку мы программируем на интерфейс , существует также реализация:

@Service
class MemberPermissionServiceImpl implements MemberPermissionService {
  @Override
  public void grantPermission(String memberId, String permissionId) { ... }

  @Override
  void revokePermission(String memberId, String permissionId) { ... }

  @Override
  public PermissionsDto listPermissions(String memberId) { ... }
}

Иногда интерфейс вообще не используется. Независимо от того, так это или нет, это не меняет идеи.

Вышеупомянутый стиль верен, но, как и любое дизайнерское решение, он также имеет ряд недостатков.

Непрозрачность

Представьте, что мы смотрим на пакет, в котором расположены услуги:

net.blog.sizovs.services.MemberPermissionService
net.blog.sizovs.services.ModerationService
net.blog.sizovs.services.MemberService

Заглядывая в пакет, мы не можем получить четкого понимания того, что делает система. Мы видим, что система может делать что-то с разрешениями участников, что-то с модерацией и что-то с участниками.

Чтобы понять, что именно может делать система, мы должны открыть сервисы и посмотреть, какие процедуры они содержат. Другими словами, уровень обслуживания не кричит о том, на что способна система.

Ожирение

Все варианты использования, связанные с разрешениями участников, становятся методами Службы разрешений участников интерфейс. Чем больше вариантов использования – тем больше методов.

Чем больше методов публикует интерфейс, тем выше афферентная связь.

Чем больше интерфейс обещает сделать, тем быстрее будет реализация ( Разрешение участника ServiceImpl ).

Очень вероятно, Разрешение участника ServiceImpl также будет страдать от высокой эфферентной связи – у него будет много исходящих зависимостей.

Большое количество зависимостей снижает тестируемость.

Это как снежный ком, катящийся с холма. Люди просто продолжают добавлять варианты использования в один и тот же интерфейс, потому что интерфейс стал контейнером для подходящих вариантов использования. Звучит знакомо?

Всегда блокирующий

В нашем примере вызовы службы блокируются. Технически мы можем вернуть некоторые услуги Завершаемое будущее или Наблюдаемый .

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

interface MemberPermissionService {
  PermissionsDto listPermissions(String memberId);
  CompletableFuture listPermissionAsync(String memberId);
  ...
}

Некоторые команды предпочитают создавать отдельный интерфейс:

interface MemberPermissionServiceAsync {
  CompletableFuture listPermissionAsync(String memberId);
  ...
}

Это скорее вопрос вкуса, а не концептуальное различие.

Помните правило – инкапсулировать то, что меняется ? Услуга по сути одна и та же, но модель выполнения отличается. Можем ли мы сделать работу лучше? Конечно, мы можем. Читайте дальше.

Примечание : что, если мы вернем все услуги CompletableFuture с 1-го дня для поддержки как блокирующего, так и неблокирующего выполнения? Да, мы можем! Тем не менее, существуют и другие модели выполнения, такие как “выстрелить и забыть”, когда вы помещаете работу в очередь сообщений, а работа выполняется в фоновом режиме. CompletableFuture здесь не поможет.

Нет единой точки входа

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

Традиционное решение проблемы заключается в создании набора аннотаций и инструментировании кода с помощью AOP:

interface MemberPermissionService {
  @WrapInTransaction
  @Log
  @Validate
  @Throttle(max = 100)
  void grantPermission(String memberId, String permissionId);
  ...
}

Решение служит поставленной цели, но имеет существенные ограничения, хотя:

  • Что, если мы хотим ограничить вызовы на основе динамического параметра, а не жестко заданного значения 100 ? С аннотациями вы не сможете этого сделать.
  • Как мы можем гарантировать, что @Дроссельная заслонка процессор аннотаций запустится до @Wrapintransaction чтобы избежать создания ненужных транзакций? Очень вероятно, нам придется ввести невидимую зависимость между @Wrapinтранзакция и @Дроссельная заслонка процессоры. Нехорошо.

Должен быть лучший способ. Читайте дальше.

Встречайте команды/Реакции/Воронки (CRF)

CRF – это ориентированный на команды подход к проектированию уровня обслуживания, состоящий из 3 основных компонентов – Команды , Реакции и Воронки .

Команда

Команда определяет один вариант использования, действие, которое может выполнить пользователь. Например – Разрешение на предоставление , Отзыв разрешения и Список разрешений будут нашими командами.

Технически команды реализуют интерфейс маркера. Поскольку команды могут возвращать Ответ , R generic определяет тип ответа для команды:

interface Command {
  interface R {
    class Void implements R {
    }
  }

Реакция

Реакция определяет, как система реагирует на определенную команду. Реакция – это когда мы пишем сервисный код. На каждую команду должна быть реакция. Реакция на предоставление разрешения , Реакция отзыва разрешения и Реакция на список разрешений это наши реакции.

Технически реакции происходят из одного интерфейса. Интерфейс является универсальным и определяет тип команды и тип ответа.

Мы также добавляем метод по умолчанию CommandType() , который разрешает соответствующий тип команды с помощью универсальных. Токен типа взят из библиотеки Guava:

interface Reaction, R extends Command.R> {
  R react(C $);

  default TypeToken commandType() {
    return new TypeToken(getClass()) {};
  }    
}

Воронка

Все команды проходят через воронку. Воронка определяет как будет выполнена команда – блокирующая, неблокирующая или запускающая или забывающая. Естественно, он также содержит код, который выполняется для всех команд (например, ведение журнала).

Простейшие системы имеют единую синхронную воронку.

Ниже приведены примеры интерфейса различных воронок. Реализации последуют позже:

Синхронная воронка

interface Now {
  public > R execute(C command);
}

Асинхронная воронка

interface Future {
  public > CompletableFuture schedule(C command);
}

Реактивная воронка

interface Rx {
  public >  Observable observe(C command);
}

Прочная воронка “огонь и забвение”

interface Durable {
   void enqueue(C command);
}

Собирая все это вместе

Давайте начнем с команды Предоставить разрешение , которая не возвращает результата:

class GrantPermission implements Command {

  private final String memberId, permissionId;

  public GrantPermission(String memberId, String permissionId) { ... }

  public String memberId() {
    return memberId;
  }

  public String permissionId() {
    return permissionId;
  }
}

Затем мы реализуем реакцию. Участники и Разрешения являются частями нашей модели домена. Реакция является компонентом, управляемым пружиной, поэтому Вводятся разрешения и Члены зависимости.

@Component
class GrantPermissionReaction implements Reaction {

  private final Members members;
  private final Permissions permissions;

  public GrantPermissionReaction(Permissions permissions, Members members) { 
    ... 
  }

  @Override
  public Command.R.Void react(GrantPermissionCommand $) {
    Member member = members.byId($.memberId());
    Permission permission = permissions.byId($.permissionId());
    member.grantPermission(permission);
    return new Command.R.Void();
  }
}

Мы также должны реализовать синхронную воронку. Когда команда передается через воронку (называется Теперь ), воронка направляет команду на соответствующую реакцию. Реакции разрешаются из контекста приложения Spring:

interface Now {
  public > R execute(C command);
}

@Component
class SimpleNow implements Now {

  private final ListableBeanFactory beanFactory;

  public Now(ListableBeanFactory beanFactory) {
    this.beanFactory = beanFactory;
  }

  public > R execute(C command) {
    Class commandType = command.getClass();
    Reaction reaction = reactions()
                .stream()
                .filter(r -> r.commandType().isSupertypeOf(commandType))
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("No reaction found for " + commandType));

    return reaction.react(command);
  }

  private Collection reactions() {
    return beanFactory.getBeansOfType(Reaction.class).values();
  }
}

Однако в вышеупомянутом примере Spring просит предоставлять все реакции каждый раз, когда команда проходит через воронку. Это может ударить по производительности. Давайте добавим Кофеин кэширование, чтобы убедиться, что воронка запоминает, какая реакция соответствует команде –

@Component
class SimpleNow implements Now {

  private final LoadingCache reactions;

  public Now(ListableBeanFactory beanFactory) {
    this.reactions = Caffeine.newBuilder()
            .build(commandType -> reactions(beanFactory)
             .stream()
             .filter(reaction -> reaction.commandType().isSupertypeOf(commandType))
             .findFirst()
             .orElseThrow(() -> new IllegalStateException("No reaction found for " + commandType);
  }

  public > R execute(C command) {
    Reaction reaction = reactions.get(command.getClass());
    return (R) reaction.react(command);
  }

  private Collection reactions(ListableBeanFactory beanFactory) {
    return beanFactory.getBeansOfType(Reaction.class).values();
  }

}

Бинго! Поскольку мы склеили все компоненты вместе, теперь мы можем взглянуть на клиентский код:

class Application {

  @Autowired
  Now now;

  public void run(String memberId, String permissionId) {
    GrantPermission grantPermission = new GrantPermission(memberId, permissionId);
    now.execute(grantPermission);
  }
}

…если используется асинхронная воронка, она будет выглядеть так –

class Application {

  @Autowired
  Future future;

  public void run(String memberId, String permissionId) {
    GrantPermission grantPermission = new GrantPermission(memberId, permissionId);
    future.schedule(grantPermission)
      .thenAccept(System.out::println)
      .thenAccept(...);
  }
}

…если используется воронка RxJava, она будет выглядеть так –

class Application {

  @Autowired
  Rx rx;

  public void run(String memberId, String permissionId) {
    GrantPermission grantPermission = new GrantPermission(memberId, permissionId);   
    rx.observe(grantPermission).subscribe(System.out::println);
  }
}

Удивительно, не правда ли? , Клиент может выбрать подходящую воронку в зависимости от своих требований.

Команда на стероидах

Давайте добавим пару методов по умолчанию в Command интерфейс, чтобы сделать клиентский код более сексуальным:

interface Command {

  default T execute(Now now) {
    return now.execute(this);
  }

  default CompletableFuture schedule(Future future) {
    return future.schedule(this);
  }

  default Observable observe(Rx rx) {
    return rx.observe(this);
  }
}

Теперь вы можете вызывать команды естественным способом:

grantPermission.execute(now);

grantPermission.schedule(future);

grantPermission.observe(rx);

Состав командования

Команды хороши тем, что их можно составлять (оформлять). Чтобы продемонстрировать эту идею, давайте реализуем повтор механизм. Если команды завершаются ошибкой с исключением, система повторит попытку несколько раз, прежде чем выдаст исключение.

Мы создаем специальную команду Try , которая может обернуть любую команду:

class Try, R extends Command.R> implements Command {

  private final C origin;
  private long times = 3;

  public Try(C origin) {
    this.origin = origin;
  }

  public Command origin() {
    return origin;
  }

  public Try times(long times) {
    this.times = times;
    return this;
  }

  public long times() {
    return times;
  }
}

Затем мы создаем реакцию. Для реализации повторной попытки мы используем класс SimpleRetryPolicy , который является частью проекта Sprint Retry .

class TryReaction, R extends Command.R>
                                          implements Reaction, R> {
  private final Router router;

  @Override
  public R react(Try $) {
    SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
    retryPolicy.setMaxAttempts($.times());
    RetryTemplate template = new RetryTemplate();
    template.setRetryPolicy(retryPolicy);
        return template.execute(context -> {
            Command origin = $.origin();
            Reaction, R> reaction = router.route(origin);
            return reaction.react(origin);
        });
  }
}

TryReaction зависит от нового класса, называемого Маршрутизатор – класс, который инкапсулирует логику маршрутизации команд в реакции, которая ранее была частью Simplenow воронки:

@Component
class Router {

  private final LoadingCache reactions;

  public Rounter(ListableBeanFactory beanFactory) {
    reactions = Caffeine.newBuilder()
                  .build(commandType -> reactions(beanFactory)
                      .stream()
                      .filter(reaction -> reaction.commandType().isSupertypeOf(commandType))
                      .findFirst()
                      .orElseThrow(() -> new IllegalStateException("No reaction found for " + commandType)));
    }

  private Collection reactions(ListableBeanFactory beanFactory) {
    return beanFactory.getBeansOfType(Reaction.class).values();
  }

  public , R extends Command.R> Reaction route(C command) {
    return reactions.get(command.getClass());
  }
}

Вот как будет выглядеть клиентский код:

new Try<>(
  new GrantPermission(...))
      .times(5)
      .execute(now);

Я знаю, что вы поражены. Я тоже.

Воронки на стероидах

Давайте обогатим нашу воронку, чтобы все команды регистрировались и выполнялись в транзакции.

Давайте изменим нашу Простую сейчас воронку. Мы будем называть это Передано сейчас в честь *труб unix:

interface Now {
  public > R execute(C command);
}

@Component
class PipedNow implements Now {
  public > R execute(C command) {
    Now pipe = new Loggable(
                     new Transactional(
                            new Reacting()));
    return pipe.execute(command);
  }
}

Регистрируемый , Транзакционные просто старые декораторы. Никакой магии:

class Loggable implements Now {

  private final Logger log = LoggerFactory.getLogger(Loggable.class);

  private final Now origin;

  public Loggable(Now origin) {
    this.origin = origin;
  }

  @Override
  public > R execute(C command) {
    log.info(">>> {}", command.toString());
    R response = origin.execute(command);
    log.info("<<< {}", response.toString());
    return response;
  }
}
class Transactional implements Now {

  private final Now origin;
  private final TransactionTemplate tx;

  public Transactional(Now origin) {
    this.origin = origin;
    this.tx = new TransactionTemplate(txManager);
  }

  @Override
  public > R execute(C command) {
    return tx.execute(txStatus -> origin.execute(command));
  }
}
class Reacting implements Now {

  @Override
  public > R execute(C command) {
    Reaction reaction = router.route(command);
    return reaction.react(command);
  }
}

Добавить новых декораторов очень легко. Самое приятное в этом то, что порядок выполнения декораторов явно виден в коде. Никакой магии.

Хорошо, какие преимущества мы получили?

  • Каждый вариант использования записан в отдельном классе – меньше зависимостей, улучшена тестируемость.
  • Каждый вариант использования проходит через одну точку входа – воронку . Это очень удобно для централизованной проверки, ведения журнала, управления транзакциями, регулирования, предоставления идентификатора корреляции . Никакой магии АОП.
  • Клиент может выбрать модель выполнения команд, переключив воронку – синхронизацию, асинхронную, реактивную, запланированную. Вы даже можете создать макет воронки для тестирования.
  • Уровень обслуживания кричит о том, на что способно приложение:
net.blog.sizovs.services.GrantPermission

net.blog.sizovs.services.RevokePermission

net.blog.sizovs.services.ListPermissions
  • Вы можете добавлять новые параметры к существующим командам и типам ответов без изменения сигнатур служб.
  • Мы получили гибкость в командовании, реакции и сторонах воронки почти бесплатно. По мере развития системы мы можем добавлять больше функций на наш уровень обслуживания без значительных изменений.

Заключительные слова

CRF – хорошая альтернатива процедурному уровню обслуживания. Я использую его в большинстве корпоративных приложений, которые я создаю, и он оказался чрезвычайно эффективным.

С помощью CRF вы можете сделать гораздо больше. Например, ответы на команды можно кэшировать, удерживая результаты по ключу, синтезированному из имени команды и аргументов. Или у вас может быть несколько версий одной и той же реакции, и вы можете выбрать, какую из них выполнить, в зависимости от контекста. Удивительно!

Пример рабочего кода можно найти на моем GitHub . Если вам интересно узнать больше об этом подходе – присоединяйтесь к моему Эффективный Java класс.

Оригинал: “https://dev.to/eduardsi/how-to-design-a-service-layer-in-java-using-crf”