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

Обычно потокам требуется от 128 до 256 КБ памяти на поток, по крайней мере, на JVM. Независимо от возможностей масштабирования, с увеличением количества запросов предел разумного быстро достигается. Циклы сборщика мусора не обязательно будут короче с большей кучей.

На практике существуют разные подходы для более эффективного использования ресурсов. В этой статье я хотел бы сравнить две концепции и выяснить их преимущества и недостатки на небольшом примере программирования. Эти два понятия - Реактивное программирование и Сопрограммы.

Некоторая предыстория

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

Реактивное программирование пытается минимизировать необходимое количество одновременных потоков с помощью интеллектуального планирования. Реактивное программирование работает с асинхронными функциональными цепочками, которые распространяют входные данные от производителей по этим функциональным цепочкам к получателям. Реактивные фреймворки полностью абстрагируются от разработчика всего базового потока и модели планирования. Это приведет к более эффективному использованию ресурсов по ряду факторов. Однако любой, кто когда-либо работал с реактивными потоками, быстро поймет, что работать с ними нелегко из-за такого уровня абстракции. Для реактивных потоков база кода организована в виде функциональных цепочек вызовов.

Альтернативный подход к эффективному использованию ресурсов был разработан в форме сопрограмм. Эта концепция асинхронного программирования восходит к 60-м годам и сейчас находит свое применение во все большем количестве языков программирования. Например, Kotlin изначально поддерживает сопрограммы начиная с версии 1.1. Эта концепция также допускает выполнение асинхронного кода. Однако асинхронный код по-прежнему выглядит так, как будто он выполняется последовательно.

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

Настройка вещей

Перво-наперво, весь код, использованный в примере, можно найти в нашем репозитории Github: https://github.com/dxfrontiers/coroutines-vs-reactive

Код написан на Котлине. Если вы не знаете языка, я рекомендую прочитать мою запись в блоге коллеги о том, почему Kotlin просто лучше (и веселее!), Чем Java.

Для реактивной части я использую реактивную триаду Project Reactor, Spring WebFlux и R2DBC, чтобы каждая часть приложения была неблокирующей, от веб-уровня до персистентности. Чтобы узнать об основах Project Reactor и Spring WebFlux, прочтите вкратце одну из моих предыдущих публикаций в блоге Реактивное программирование в двух словах. R2DBC (Reactive Relational Database Connectivity) предоставляет API реактивного программирования для реляционных баз данных. В нашем случае мы используем интеграцию Spring Boot Data R2DBC для встроенной базы данных H2.

Для части сопрограмм используется библиотека Kotlinx Coroutines, которая переносит сопрограммы в Kotlin в качестве сторонней библиотеки. Поскольку сопрограммы можно беспрепятственно использовать вместе с Spring WebFlux и Spring Data R2DBC, никаких дополнительных фреймворков не требуется.

Подробно используемые фреймворки и их соответствующие версии:

  • Котлин 1.4.32
  • Kotlinx Coroutines 1.4.2
  • Весенняя загрузка + WebFlux 2.4.2
  • Проект Реактор 3.4.2
  • R2DBC 0.8.4.РЕЛИЗ
  • Весенние данные R2DBC 1.2.3

Приведи меня к коду!

Весь пример состоит из одного микросервиса с конечной точкой REST, минималистичного уровня обслуживания и уровня сохраняемости для наших двух сущностей Character и House. Мы строим своего рода дом для персонажей Игры престолов. Очень креативно, я знаю :)

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

Путь для реактивного API: /reactive/characters

Путь для API сопрограмм: /coroutines/characters

Методы выставления для обоих:

  • Найти по имени: GET /?lastName=<lastName>
  • Найти по id: GET /{id}
  • Добавить: PUT /?firstName=<firstName>&lastName=<lastName>
  • Удалить по имени: DELETE /?firstName=<firstName>&lastName=<lastName>

Пример вызова:

curl -s "http://localhost:8080/reactive/characters?lastName=Stark"
[{"id":1,"firstName":"Eddard","lastName":"Stark","house":1},{"id":6,"firstName":"Arya","lastName":"Stark","house":1}]

Приложение было заполнено только несколькими образцами данных.

Итак, давайте начнем со стороны и постепенно спустимся к упорству.

Контроллер (Реактивный)

По большей части контроллер довольно просто реализовать:

Контроллеры определяют четыре метода для четырех методов, которые мы хотим предоставить. Метод findByName может возвращать несколько символов, поэтому здесь мы возвращаем Flux символов. Надеемся, что findById найдет только совпадающий символ, поэтому мы возвращаем объект Mono, содержащий ноль или один символ.

Реализация метода addCharacter уже немного сложнее, поскольку у нас есть три разных результата:

  • 201 (СОЗДАНО): персонажа раньше не существовало, и его можно было добавить.
  • 200 (ОК): символ уже существует, и ничего не изменилось.
  • 400 (BAD_REQUEST): произошла ошибка проверки ввода, и запрос был отклонен.

Ошибки нельзя обрабатывать @ExceptionHandler аннотированными методами, так как они нарушили бы реактивный рабочий процесс. Итак, мы должны использовать один из многих OnErrorX методов, которые предоставляет нам Reactor API.

Наконец, метод deleteByName снова прост. Только тип возврата Mono<Void> сначала выглядит незнакомым, потому что на самом деле мы не хотим ничего возвращать. Однако структура ожидает, что мы вернем реактивный тип, поэтому мы не инкапсулируем ничего в тип Mono. Могло быть хуже.

Контроллер (сопрограммы)

Для сопрограмм Kotlin реализация могла бы выглядеть следующим образом:

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

  • Моно ‹T› → T?
  • Флюс ‹T› → Поток ‹T›
  • Mono ‹Void› → Void?

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

Следующее, на что следует обратить внимание, это, очевидно, модификатор suspend для каждого метода (ожидайте, что он возвращает Flow, потому что он внутренне вызывает только приостановленные функции). Это помечает эти функции как приостанавливаемые. Это означает, что основной поток не заблокирован. На самом деле функции приостановки могут быть вызваны только из сопрограммы или другой функции приостановки.

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

Сопрограммы позволяют нам выполнять асинхронные операции с использованием императивного кода.

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

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

Теперь реактивная часть уровня сервиса становится немного острее:

Методы findByLastName и findById не требуют пояснений. Удаление символа в методе deleteByName требует немного больше усилий, но по большей части выглядит довольно легко. Здесь действительно важно выбрать правильного оператора для операции удаления. Хотя мы можем подумать, что удаление записи в базе данных является побочным эффектом, потому что оно изменяет внешний мир, и нас не волнует обратная связь от метода, мы не можем не использовать здесь Mono.doOnNext. Метод удаления из ReactiveCrudRepository сам возвращает подписываемое значение, поэтому требуется, чтобы кто-либо ™ подписался на него:

Mono<Void> deleteById(ID id);

В противном случае ничего бы не произошло по принципу: Ничего не происходит, пока вы не подпишетесь.

Мы могли либо подписаться на возвращаемое значение вручную, либо передать все по уровням, пока Spring WebFlux не обработает подписку за нас в контроллерах. Я выбрал последнее и связал вызов с оператором flatMap.

Для метода addCharacter реализация становится немного более сложной, потому что необходимо обработать три случая:

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

Фильтр в строке 19 обрабатывает второй случай, возвращая пустой Mono, если применяется предикат. В других случаях мы выполняем плоское сопоставление результата разрешения House и снова плоское сопоставление для связанного метода save. В случае, если разрешение House не вернет результата, мы предоставляем ошибку-Mono, которая инкапсулирует наше исключение. Думайте о методе switchIfEmpty как о методе Optional.orElseGet.

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

Уровень обслуживания (сопрограммы)

С сопрограммами сервис может быть реализован следующим образом:

Как и раньше, findByLastName и findById не представляют опасности. Метод deleteByName на самом деле довольно прост. С хорошо известной конструкцией expr1?.let { expr2 } мы можем выполнять некоторые операции expr2, только если expr1 возвращает какое-то значение. Итак, мы запускаем удаление только в том случае, если какой-то символ был найден. Сопрограммы позволяют нам придерживаться общих шаблонов программирования (по крайней мере, общих для разработчиков Kotlin).

Для метода addCharacter мы можем воспользоваться выражением if из Котлина, чтобы немного упростить структуру. Если символ был найден, мы возвращаем null и больше ничего не делаем. В противном случае мы сначала ищем дом и снова используем выражение let, чтобы сохранить персонажа, если дом был найден. Если дом не найден, срабатывает оператор elvis Котлина ?:, и мы просто генерируем какое-то специальное исключение.

Постоянство (реактивные / сопрограммы)

Наконец, давайте перейдем к самой простой части решения - настойчивости. Начиная с серии релизов Spring Data Neumann, деривация запросов, наконец, также работает для реактивных репозиториев и репозиториев сопрограмм. Ура!

Это делает реализацию очень простой для обоих.

Реактивный репозиторий:

Репозиторий сопрограмм:

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

Пробовать вещи

Получить всех персонажей:

curl -s -XGET "http://localhost:8080/reactive/characters?lastName=Stark"
[{"id":1,"firstName":"Eddard","lastName":"Stark","house":1},{"id":6,"firstName":"Arya","lastName":"Stark","house":1},{"id":10,"firstName":"Sansa","lastName":"Stark","house":1}]

Удалить персонажа:

curl -s -XDELETE "http://localhost:8080/coroutines/characters?firstName=Sansa&lastName=Stark"
curl -s -XGET "http://localhost:8080/coroutines/characters?lastName=Stark"
[{"id":1,"firstName":"Eddard","lastName":"Stark","house":1},{"id":6,"firstName":"Arya","lastName":"Stark","house":1}]

Добавить персонажа:

curl -s -XPUT "http://localhost:8080/coroutines/characters?firstName=Ellaria&lastName=Sand"
No valid house found for the character Ellaria Sand!

Все равно Эллария никому не нравится ¯ \ _ (ツ) _ / ¯

Вывод

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

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

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

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

Спасибо за чтение! Не стесняйтесь комментировать или писать мне, если у вас есть вопросы или предложения. Возможно, вас заинтересуют другие сообщения, опубликованные в блоге Digital Frontiers, анонсированные в нашей учетной записи Twitter.