Статьи

Микросервисы для разработчиков Java: внедрение микросервисов (синхронных, асинхронных, реактивных, неблокирующих)

1. Введение

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

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

2. Синхронный

Синхронное программирование является наиболее широко используемой парадигмой в наши дни из-за своей простоты и легкости рассуждений. В типичном приложении это обычно проявляется как последовательность вызовов функций, где каждый из них выполняется за другим. Чтобы проиллюстрировать это в действии, давайте взглянем на реализацию конечной точки ресурса JAX-RS из службы поддержки клиентов .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response register(@Context UriInfo uriInfo, @Valid CreateCustomer payload) {
    final CustomerInfo info = conversionService.convertTo(payload, CustomerInfo.class);
    final Customer customer = customerService.register(info);
         
    return Response
         .created(
             uriInfo
                 .getRequestUriBuilder()
                 .path(customer.getUuid())
                 .build())
         .build();   
}

Когда вы читаете этот код, на этом пути не будет никаких сюрпризов (кроме возможности получить исключения). Сначала мы преобразуем информацию о клиенте из полезной нагрузки веб-API RESTful в объект службы, после чего вызываем службу для регистрации нового клиента и, наконец, возвращаем ответ обратно вызывающей стороне. Когда выполнение завершается, его результат известен и полностью оценен.

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

3. Асинхронный

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

Чтобы понять, как это работает, нам нужно немного поговорить о параллелизме и параллелизме в Java (и в JVM в целом), основанном на потоках . Любое выполнение в Java происходит в контексте потока . Таким образом, типичными способами асинхронного выполнения конкретной операции являются заимствование потока из пула потоков (или создание нового потока вручную) и выполнение вызова в его контексте.

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

Ветеран Java-разработчиков определенно помнит предшественника CompletableFuture , интерфейса Future . Мы не будем говорить о будущем и не рекомендуем его использовать, поскольку его возможности очень ограничены.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Transactional
public Customer register(CustomerInfo info) {
    final CustomerEntity entity = conversionService.convertTo(info, CustomerEntity.class);
    repository.saveOrUpdate(entity);
 
    customerRegisteredEvent
        .fireAsync(new CustomerRegistered(entity.getUuid()))
        .whenComplete((r, ex) -> {
            if (ex != null) {
                LOG.error("Customer registration post-processing failed", ex);
            }
        });
         
    return conversionService.convertTo(entity, Customer.class);
}

Событие CustomerRegistered запускается асинхронно, и процесс регистрации продолжает выполнение, не ожидая обработки всеми наблюдателями. Реализация несколько наивна (поскольку транзакция может потерпеть неудачу или приложение может аварийно завершить работу при обработке события), но это достаточно хорошо, чтобы проиллюстрировать это: асинхронность усложняет понимание и анализ потоков выполнения. Не говоря уже о скрытых затратах на это: нити — драгоценный и дорогой ресурс

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

4. Блокировка

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

Действительно, хотя с точки зрения потока выполнения нет большой разницы (каждая операция должна ждать завершения предыдущей), механизм выполнения операций ввода-вывода довольно контрастен, скажем, с чисто вычислительной работой. Каковы типичные примеры такой операции блокировки в большинстве приложений Java? Просто подумайте о реляционных базах данных и драйверах JDBC .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@Inject @CustomersDb
private EntityManager em;
 
@Override
public Optional findById(String uuid) {
    final CriteriaBuilder cb = em.getCriteriaBuilder();
     
    final CriteriaQuery query = cb.createQuery(CustomerEntity.class);
    final Root root = query.from(CustomerEntity.class);
    query.where(cb.equal(root.get(CustomerEntity_.uuid), uuid));
         
    try {
        final CustomerEntity customer = em.createQuery(query).getSingleResult();
        return Optional.of(customer);
    } catch (final NoResultException ex) {
        return Optional.empty();
    }
}

Наша реализация Службы поддержки клиентов не использует API-интерфейсы JDBC напрямую, полагаясь на высокоуровневую спецификацию JPA ( JSR-317 , JSR-338 ) и ее провайдеров. Тем не менее, легко определить, где происходит обращение к базе данных:

1
final CustomerEntity customer = em.createQuery(query).getSingleResult();

Поток выполнения собирается ударить стену здесь. В зависимости от возможностей драйверов JDBC вы можете иметь некоторый контроль над транзакцией или запросом, например, отменить ее или установить время ожидания. Но по большому счету это блокирующий вызов: выполнение возобновляется только после завершения запроса и получения результатов.

5. Неблокирующая

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

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

К счастью, существует ряд методов для решения этой проблемы, известных как неблокирующий ввод-вывод (или асинхронный ввод-вывод ). Одна из наиболее широко используемых реализаций неблокирующих асинхронных операций ввода-вывода основана на шаблоне Reactor . На рисунке ниже изображен упрощенный вид.

Реализация микросервисов - Reactor Pattern

Образец реактора

В основе шаблона Reactor лежит однопоточный цикл обработки событий. После получения запроса на операцию ввода-вывода он делегируется пулу обработчиков (или более эффективной реализации, специфичной для операционной системы, в которой выполняется приложение). Результаты операции ввода / вывода могут быть введены обратно (как события) в цикл событий и, в конце концов, после завершения результат отправляется приложению.

В JVM платформа Netty является де-факто выбором для реализации асинхронных сетевых серверов и клиентов, управляемых событиями. Давайте посмотрим, как служба бронирования может вызывать службу поддержки клиентов для поиска клиента по его идентификатору по-настоящему неблокирующим образом, используя библиотеку AsyncHttpClient , построенную поверх Netty (обработка ошибок опущена, чтобы сохранить короткий фрагмент) ,

01
02
03
04
05
06
07
08
09
10
11
12
13
final AsyncHttpClient client = new DefaultAsyncHttpClient();
 
final CompletableFuture customer = client
    .prepareGet("http://localhost:8080/api/customers/" + uuid)
    .setRequestTimeout(500)
    .setReadTimeout(100)
    .execute()
    .toCompletableFuture()
    .thenApply(response -> fromJson(response.getResponseBodyAsStream()));
 
// ...
                 
client.close();

Интересно, что для вызывающей стороны неблокирующий вызов ничем не отличается от асинхронного , но внутренняя часть того, как это делается, имеет большое значение.

6. Реактивный

Реактивное программирование поднимает асинхронные и неблокирующие парадигмы на совершенно новый уровень. Существует довольно много определений того, чем на самом деле является реактивное программирование , но наиболее убедительным является именно это.

Реактивное программирование — это программирование с асинхронными потоками данных. https://gist.github.com/staltz/868e7e9bc2a7b8c1f754

Это довольно короткое определение стоит книги . Чтобы дискуссия была достаточно короткой, мы сосредоточимся на практической стороне вещей — реактивных потоках .

Реактивные потоки — это инициатива по обеспечению стандарта для асинхронной обработки потоков с неблокирующим обратным давлением. http://www.reactive-streams.org/

Что такого особенного в этом? Реактивные потоки объединяют способ работы с данными в наших приложениях, подчеркивая несколько ключевых моментов:

  • (в основном) все это поток
  • потоки асинхронны по своей природе
  • потоки поддерживают неблокирующее обратное давление для управления потоком данных

Код стоит тысячи слов. Поскольку Spring WebFlux поставляется с реактивным, неблокирующим HTTP-клиентом , давайте посмотрим, как Служба бронирования может вызывать Службу поддержки клиентов для поиска клиента по его идентификатору реактивным способом (для простоты обработка ошибок исключена).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
final HttpClient httpClient = HttpClient
    .create()
    .tcpConfiguration(client ->
        client
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 500)
            .doOnConnected(conn ->
                conn
                    .addHandlerLast(new ReadTimeoutHandler(100))
                    .addHandlerLast(new WriteTimeoutHandler(100))
    ));
 
final WebClient client = WebClient
    .builder()
    .clientConnector(new ReactorClientHttpConnector(httpClient))
    .build();
 
final Mono customer = client
    .get()
    .uri("/{uuid}", uuid)
    .retrieve()
    .bodyToMono(Customer.class);

Концептуально это выглядит как пример AsyncHttpClient , просто немного церемония. Тем не менее, использование реактивных типов (таких как Mono<Customer> ) раскрывает полную мощность реактивных потоков .

Дискуссии вокруг реактивного программирования не могут быть полными, если не упомянуть The Reactive Manifesto и его огромное влияние на дизайн и архитектуру современных приложений.

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

Системы, построенные как Reactive Systems, являются более гибкими, слабосвязанными и масштабируемыми . Это облегчает их разработку и поддается изменениям. Они значительно более терпимы к сбоям, и когда сбой происходит, они встречают его с элегантностью, а не с катастрофой. Реактивные системы очень отзывчивы, предоставляя пользователям эффективную интерактивную обратную связь. https://www.reactivemanifesto.org/

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

7. Будущее ярко

За последние пару лет темпы инноваций в Java резко возросли. С новой версией релиза новые функции становятся доступными для разработчиков Java каждые 6 месяцев. Тем не менее, существует много текущих проектов, которые могут оказать существенное влияние на будущее JVM и Java в частности.

Одним из них является Project Loom . Целью этого проекта является изучение реализации легких потоков пользовательского режима ( волокон ), продолжений с разделителями (некоторой формы) и связанных с ними функций. На данный момент волокна не поддерживаются JVM изначально, хотя есть некоторые библиотеки, такие как Quasar из Parallel Universe, которые пытаются заполнить этот пробел.

Кроме того, введение волокон в качестве альтернативы нитям позволило бы эффективно поддерживать сопрограммы в JVM.

8. Реализация микросервисов — Выводы

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

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

9. Что дальше

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