En

JazzTeam Software Development Company

Agile Java Development

Реактивное программирование на примере Akka

Концепция реактивного программирования

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

Характеристики, которыми должна обладать реактивная система, описаны в манифесте реактивного программирования, который был опубликован в 2014 году.

Характеристики реактивной системы:

  1. Отзывчивость.
  2. Устойчивость.
  3. Эластичность.
  4. Ориентированность на обмен сообщениями.

Отзывчивость

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

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

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

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

Следствием проблем с отзывчивостью систем является потеря клиентов. Пользователи интенсивнее взаимодействуют с “отзывчивыми” приложениями, что приводит к большим объёмам покупок. В некоторых сферах, например: медицина, вооружение, неспособность обрабатывать запросы в рамках временных ограничений равносильно полному отказу системы. Многие приложения быстро становятся бесполезными, если они перестают отвечать временным требованиям. Например, приложение, осуществляющее торговые операции, может потерять текущую сделку, если не успеет ответить вовремя.

Устойчивость

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

Основными решениями проблемы отказа системы являются:

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

Эластичность

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

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

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

Ориентированность на обмен сообщениями

В реактивном приложении компоненты взаимодействуют друг с другом путём отправки и получения сообщений — дискретных частей информации, описывающих факты. Эти сообщения отправляются и принимаются в асинхронном и неблокирующем режиме. Событийно-ориентированные системы более склонны к push-модели, нежели чем к pull или poll. Т.е. они проталкивают данные к своим потребителям, когда данные становятся доступными, вместо того, чтобы впустую тратить ресурсы, постоянно запрашивая или ожидая данные.

Инструменты для реактивного программирования

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

Зеленые потоки

Зеленые потоки — это, по сути, имитация потоков. Виртуальная машина берёт на себя заботу о переключении между разными зелеными потоками, а сама машина работает как один поток ОС. Это даёт несколько преимуществ. Потоки ОС относительно дороги. Кроме того, переключение между native threads гораздо медленнее, чем между зелеными. Это всё означает, что в некоторых ситуациях зеленые гораздо выгоднее, чем native threads. Система может поддерживать гораздо большее количество зеленых, чем потоков OС.

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

CSP

Взаимодействие последовательных процессов (Communicating Sequential Processes - CSP) - математическая инкапсуляция в одном процессе нескольких процессов или потоков, взаимодействующих посредством передачи сообщения. Уникальность CSP заключается в том, что два процесса или потока не должны ничего знать друг о друге, они прекрасно разделены в плане отправки и получения сообщений, но все еще связаны с точки зрения передаваемого значения. Вместо накопления сообщений в очереди применяется принцип рандеву: для обмена сообщением одна сторона должна быть готова к его отправке, а вторая - к получению. Таким образом, обмен сообщением всегда синхронизирован.

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

Future и Promise

Future - ссылка на значение или код ошибки, которая может стать доступной (только для чтения) в какой-то момент времени.

Promise - соответствующий дескриптор с возможностью одиночной записи, который обеспечивает доступ к значению. Аналоги объектов Future и Promise реализованы в большинстве популярных языков: Java, C#, Ruby, Python и т.д.

Функция, которая возвращает результат асинхронно, создаёт объект Promise, инициализирует асинхронную обработку, устанавливает итоговую функцию обратного вызова, которая в какой-то момент наполнит Promise данными, и возвращает вызывающему компоненту объект Future, связанный с Promise. Затем вызывающий компонент может прикрепить к Future некий код, который будет выполнен при появлении значений в этом объекте.

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

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

Reactive Extensions

Reactive Extensions или Rx или “реактивные расширения” – это библиотека, точнее набор библиотек, которые позволяют работать с событиями и асинхронными вызовами в композиционном стиле. Библиотека пришла из мира .NET. Затем она была перенесена на популярные платформы: Java, Ruby, JS, Python и т.д. Эта библиотека объединяет в себе два шаблона управления потоками: наблюдатель и итератор. Оба связаны с обработкой потенциально неизвестного количества элементов или событий.

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

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

Последним инструментом для реактивного программирования, является модель акторов (ударение на ‘а’), которая будет описана в следующей главе на примере Akka.

Акторная модель

Акторная модель была изобретена в далеком 1973 г. Карлом Хьюитом. Основной концепцией модели акторов является то, что вычислительной единицей в системе является “актор”. При использовании этой модели все проектирование и реализация системы строится вокруг сущности актора. Поэтому концепцию акторной модели лаконично можно выразить во фразе: “Все есть актор”.

Язык программирования, построенный на основе акторной модели - Erlang. Наиболее популярные фреймворки, реализующие акторную модель - Akka, Orbit, Quasar. Далее в статье будем рассматривать акторную модель на примере фреймворка Akka.

Чтобы стало понятно, что за сущностью является актор, обратимся к частям из которых он состоит:

  1. State.
  2. Behavior.
  3. Mailbox.
  4. Child actors.
  5. Supervisor strategy.

State

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

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

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

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

Однако существует возможность сконфигурировать актор так, что он сможет автоматически восстанавливаться в состояние до перезапуска.

Behavior

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

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

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

Mailbox

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

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

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

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

Child Actors

Каждый актор потенциально является супервайзером: если он создает дочерние акторы для делегирования подзадач, он автоматически контролирует их. Список дочерних акторов поддерживается в контексте актора (стоит отметить, что потомки во втором поколении - “внуки”,  не доступны напрямую) и доступен ему. Изменения в списке выполняются путем создания (context.actorOf (...)) или остановки (context.stop (child)) дочерних акторов. Фактически, действия по созданию и завершению дочерних акторов происходят асинхронным образом, поэтому они не блокируют свой супервизор.

Supervisor strategy

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

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

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

Akka является асинхронной, неблокирующей и поддерживает обмен сообщениями. Она масштабируется как вертикально, так и горизонтально (посредством Akka Distributes Data). Для поддержки отказоустойчивости в них предусмотрены механизмы слежения. Она соответствует всем требованиям, предъявляемым к созданию реактивных систем.

Работа с акторами в Akka

Для последующих примеров нужно подключить библиотеку Akka-actor. Сделаем это при помощи maven:

  
<dependencies>

  <dependency>

      <groupId>com.typesafe.akka</groupId>

      <artifactId>akka-actor_2.12</artifactId>

      <version>2.5.16</version>

  </dependency>

</dependencies>

Стандартным способом создания актора в Akka является реализация интерфейса AbstractActor. Также имеются другие типы интерфейсов, для создания актора, например TypedActor или UntypedActor.

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

Для описания поведения актора нужно реализовать метод receiveBuilder().

Пример актора:

import akka.actor.AbstractActor;

import akka.actor.ActorRef;

import akka.actor.Props;

import com.lightbend.akka.sample.Printer.Greeting;


public class Greeter extends AbstractActor {

static public Props props(String message, ActorRef printerActor) {

  return Props.create(Greeter.class, () -> new Greeter(message, printerActor));

}

static public class WhoToGreet {

  public final String who;



  public WhoToGreet(String who) {

      this.who = who;

  }

}

static public class Greet {

  public Greet() {

  }

}

private final String message;

private final ActorRef printerActor;

private String greeting = "";

public Greeter(String message, ActorRef printerActor) {

  this.message = message;

  this.printerActor = printerActor;

}

@Override

public Receive createReceive() {

  return receiveBuilder()

      .match(WhoToGreet.class, wtg -> {

        this.greeting = message + ", " + wtg.who;

      })

      .match(Greet.class, x -> {

        printerActor.tell(new Greeting(greeting), getSelf());

      })

      .build();

}

}


Метод props является фабричным методом и отвечает за создание экземпляра актора.

Props - это класс конфигурации, который указывает параметры для создания акторов. Рекомендуется использовать этот класс для построения актора.

Этот пример передает параметры, которые требуются актору при построении. Первый параметр - сообщение, которое будет использоваться при создании приветственных сообщений. Второй параметр - объект ActorRef, который является ссылкой на актора, обрабатывающего вывод приветствия.

Метод createReceiver определяет поведение: как актор должен реагировать на различные сообщения, которые он получает. Доступ или изменение внутреннего состояния актора полностью защищено потоком. Метод createReceive должен обрабатывать сообщения, которые ожидает пользователь. В случае с Greeter он ожидает два типа сообщений: WhoToGreet и Greet. Первый тип сообщений обновит состояние актора, последний же отправит сообщение greeting актору printerActor.

В Akka невозможно создать объект актора, используя new. Вместо этого при помощи фабричного метода system.actorOf создается объект ActorRef, который используется для взаимодействия с созданным актором. Этот метод принимает 2 аргумента: объект конфигурации Props и имя актора.

Пример создания актора:


final ActorRef printerActor =

  system.actorOf(Printer.props(), "printerActor");

final ActorRef howdyGreeter =

  system.actorOf(Greeter.props("Howdy", printerActor), "howdyGreeter");

final ActorRef helloGreeter =

  system.actorOf(Greeter.props("Hello", printerActor),"helloGreeter");

final ActorRef goodDayGreeter =

  system.actorOf(Greeter.props("hi", printerActor),"hiGreeter");

Акторы реагируют и управляются сообщениями и ничего не делают, пока не получат сообщение. Это гарантирует, что отправитель не будет ожидать, пока его сообщение не будет обработано получателем. Вместо этого отправитель помещает сообщение в почтовый ящик получателя и может свободно выполнять другую работу.

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

Чтобы отправить сообщение актору используется метод tell в ActorRef.

Пример отправки актору сообщения: первый аргумент - отправляемый объект, второй аргумент - ActorRef отправителя (ссылка на отправителя, по которой при необходимости можно будет ответить).

howdyGreeter.tell(new WhoToGreet("Akka"), ActorRef.noSender());

howdyGreeter.tell(new Greet(), ActorRef.noSender());

Заключение

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

, , , , , , , , , , ,

Leave a Reply

Your email address will not be published. Required fields are marked *