Руководство по потокам Акка

Путеводитель по ручьям Акка

1. обзор

В этой статье мы рассмотрим библиотекуakka-streams, которая построена на основе структуры акторов Akka, которая придерживаетсяreactive streams manifesto. The Akka Streams API allows us to easily compose data transformation flows from independent steps.

Более того, вся обработка выполняется реактивным, неблокирующим и асинхронным способом.

2. Maven Зависимости

Для начала нам нужно добавить библиотекиakka-stream иakka-stream-testkit в нашpom.xml:


    com.typesafe.akka
    akka-stream_2.11
    2.5.2


    com.typesafe.akka
    akka-stream-testkit_2.11
    2.5.2

3. API Akka Streams

Для работы с Akka Streams нам необходимо знать основные концепции API:

  • Source the entry point to processing in the akka-stream library - мы можем создать экземпляр этого класса из нескольких источников; например, мы можем использовать методsingle(), если мы хотим создатьSource из одногоString, или мы можем создатьSource изIterable элементов

  • Flow – the main processing building block - каждый экземплярFlow имеет одно входное и одно выходное значение

  • Materializer – we can use one if we want our Flow to have some side effects like logging or saving results; чаще всего мы будем передавать псевдонимNotUsed какMaterializer, чтобы обозначить, что нашFlow не должен иметь побочных эффектов

  • Sink operation – when we are building a Flow, it is not executed until we will register a Sink operation на нем - это терминальная операция, которая запускает все вычисления во всемFlow

4. СозданиеFlows в Akka Streams

Давайте начнем с построения простого примера, в котором мы покажем, какcreate and combine multiple Flows - обрабатывать поток целых чисел и вычислять среднее скользящее окно пар целых чисел из потока.

Мы проанализируем разделенные точкой с запятой целые числаStringв качестве входных данных, чтобы создать нашakka-stream Source для примера.

4.1. ИспользованиеFlow для синтаксического анализа ввода

Во-первых, давайте создадим классDataImporter, который примет экземплярActorSystem, который мы будем использовать позже для создания нашегоFlow:

public class DataImporter {
    private ActorSystem actorSystem;

    // standard constructors, getters...
}

Затем давайте создадим методparseLine, который будет генерироватьList изInteger из нашего ограниченного вводаString.. Имейте в виду, что мы используем Java Stream API здесь только для синтаксического анализа:

private List parseLine(String line) {
    String[] fields = line.split(";");
    return Arrays.stream(fields)
      .map(Integer::parseInt)
      .collect(Collectors.toList());
}

Наш начальныйFlow применитparseLine к нашему вводу, чтобы создатьFlow с типом вводаString и типом выводаInteger:

private Flow parseContent() {
    return Flow.of(String.class)
      .mapConcat(this::parseLine);
}

Когда мы вызываем методparseLine(), компилятор знает, что аргументом этой лямбда-функции будетString - такой же, как тип ввода для нашегоFlow.

Обратите внимание, что мы используем методmapConcat() - эквивалент метода Java 8flatMap() - потому что мы хотим сгладитьList дляInteger, возвращаемогоparseLine(), в aFlow изInteger, чтобы на последующих этапах нашей обработки не было необходимости иметь дело сList.

4.2. ИспользованиеFlow для выполнения вычислений

На данный момент у нас естьFlow проанализированных целых чисел. Теперь нам нужноimplement logic that will group all input elements into pairs and calculate an average of those pairs.

Теперь давайтеcreate a Flow of Integers and group them using the grouped() method.

Далее мы хотим рассчитать среднее.

Поскольку нас не интересует порядок, в котором будут обрабатываться эти средние значения, мы можемhave averages calculated in parallel using multiple threads by using the mapAsyncUnordered() method, передав количество потоков в качестве аргумента этому методу.

Действие, которое будет передано как лямбда вFlow, должно вернутьCompletableFuture, потому что это действие будет вычисляться асинхронно в отдельном потоке:

private Flow computeAverage() {
    return Flow.of(Integer.class)
      .grouped(2)
      .mapAsyncUnordered(8, integers ->
        CompletableFuture.supplyAsync(() -> integers.stream()
          .mapToDouble(v -> v)
          .average()
          .orElse(-1.0)));
}

Мы рассчитываем средние значения в восьми параллельных потоках. Обратите внимание, что мы используем Java 8 Stream API для вычисления среднего значения.

4.3. Составление несколькихFlows в одинFlow

APIFlow - это плавная абстракция, которая позволяет намcompose multiple Flow instances to achieve our final processing goal. У нас могут быть гранулярные потоки, где один, например, анализируетJSON,, другой выполняет какое-то преобразование, а другой собирает некоторую статистику.

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

Мы создали два потока выше, которые могут работать независимо друг от друга. Теперь мы хотим составить их вместе.

Сначала мы хотим проанализировать наш вводString, а затем мы хотим вычислить среднее значение для потока элементов.

Мы можем составлять наши потоки с помощью методаvia():

Flow calculateAverage() {
    return Flow.of(String.class)
      .via(parseContent())
      .via(computeAverage());
}

Мы создалиFlow с типом вводаString и двумя другими потоками после него. parseContent()Flow принимает входString и возвращаетInteger в качестве выхода. computeAverage() Flow берет этотInteger и вычисляет среднее значениеDouble в качестве типа вывода.

5. ДобавлениеSink кFlow

Как мы уже упоминали, на данный момент весьFlow еще не выполнен, потому что он ленив. To start execution of the Flow we need to define a Sink. ОперацияSink может, например, сохранять данные в базе данных или отправлять результаты в какую-либо внешнюю веб-службу.

Предположим, у нас есть классAverageRepository со следующим методомsave(), который записывает результаты в нашу базу данных:

CompletionStage save(Double average) {
    return CompletableFuture.supplyAsync(() -> {
        // write to database
        return average;
    });
}

Теперь мы хотим создать операциюSink, которая использует этот метод для сохранения результатов обработки нашегоFlow. Чтобы создать нашSink,, нам сначала нужноcreate a Flow that takes a result of our processing as the input type. Далее мы хотим сохранить все наши результаты в базе данных.

Опять же, нас не волнует порядок элементов, поэтому мы можемperform the save() operations in parallel, используя методmapAsyncUnordered().

Чтобы создатьSink изFlow, нам нужно вызватьtoMat() сSink.ignore() в качестве первого аргумента иKeep.right() в качестве второго, потому что мы хотим вернуть статус обработки:

private Sink> storeAverages() {
    return Flow.of(Double.class)
      .mapAsyncUnordered(4, averageRepository::save)
      .toMat(Sink.ignore(), Keep.right());
}

6. Определение источника дляFlow

Последнее, что нам нужно сделать, этоcreate a Source from the input*String*.. Мы можем применитьcalculateAverage()Flow к этому источнику, используя методvia().

Затем, чтобы добавитьSink к обработке, нам нужно вызвать методrunWith() и передать только что созданныйstoreAverages() Sink:

CompletionStage calculateAverageForContent(String content) {
    return Source.single(content)
      .via(calculateAverage())
      .runWith(storeAverages(), ActorMaterializer.create(actorSystem))
      .whenComplete((d, e) -> {
          if (d != null) {
              System.out.println("Import finished ");
          } else {
              e.printStackTrace();
          }
      });
}

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

7. ТестированиеAkka Streams

Мы можем протестировать нашу обработку, используяakka-stream-testkit.

Лучший способ проверить фактическую логику обработки - проверить всю логикуFlow и использоватьTestSink для запуска вычисления и утверждения результатов.

В нашем тесте мы создаемFlow, которые хотим протестировать, а затем мы создаемSource из входного содержимого теста:

@Test
public void givenStreamOfIntegers_whenCalculateAverageOfPairs_thenShouldReturnProperResults() {
    // given
    Flow tested = new DataImporter(actorSystem).calculateAverage();
    String input = "1;9;11;0";

    // when
    Source flow = Source.single(input).via(tested);

    // then
    flow
      .runWith(TestSink.probe(actorSystem), ActorMaterializer.create(actorSystem))
      .request(4)
      .expectNextUnordered(5d, 5.5);
}

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

8. Заключение

В этой статье мы рассматривали библиотекуakka-stream.

Мы определили процесс, который объединяет несколькоFlows для вычисления скользящего среднего значений элементов. Затем мы определилиSource, который является точкой входа в обработку потока, иSink, который запускает фактическую обработку.

Наконец, мы написали тест для нашей обработки, используяakka-stream-testkit.

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