[翻译]akka in action之akka-stream (1 基本流处理)

1 基本流处理

让我们首先看看使用akka-stream处理流的真正含义。图1展示了在某个处理节点上,元素是一个个如何被处理的。一次处理一个元素是防止内存溢出的关键。还可以看到,有限内存可用于处理链上的某些位置。

图1

与actor的相似性是显而易见的。如图1所示,不同点在,生产者和消费者之间的信号,该信号描述了在有限内存中可以处理什么。如果直接使用actor来实现,这部分你要自己来实现。图2展示了用于日志流处理的线性处理链例子,包括了过滤、转换和帧化日志事件。

图2

日志流处理器需要做的不仅仅是从一个生产者那里读取, 而是写到一个消费者, 这就是处理图的来源。处理图使得可以从现有的处理节点中构建出更高级的处理逻辑。例如,如图3,一个图合并了两个流并过滤元素。本质上,任何处理节点都是一个图;图是一个处理元素,并含有一定数目的输入和输出。

图3

日志流处理器服务的最终版本将使用HTTP从网络上接收许多服务的应用程序日志,并将合并不同类型的流。它将过滤、分析、变换,最终将结果发送到其他服务。图4展示了一个假想的服务的使用用例。

图4

图中展示了日志流处理器从一个票据应用程序的不同部分接收日志事件。日志事件立即或有些延迟后发送到日志流处理器。票据的应用程序 和 HTTP 服务在事件发生时发送事件, 而日志转发器服务在从第三方服务日志聚合后发送事件。

在图4 所示的用例中, 日志流处理器将标识的日志事件发送到存档服务, 以便用户以后可以执行查询。日志流服务还识别应用程序服务中出现的特定问题, 并使用通知服务在需要人工干预时通知团队。一些事件被转换为度量值,这些度量值送入提供更多分析图表的服务中。

将日志事件转换为归档事件、通知、度量值和审计跟踪,将在不同的处理流中进行,每个流程需要一个单独的处理逻辑,所有的处理都是对传入日志事件进行的。

这个日志流处理器示例将突出几个目标,其解决方案将在本章的下一节中讨论:

  • 有限内存的使用——因为日志数据不会整个都装入内存中,所以日志流处理器不会耗尽内存。它应该一个接一个地处理事件,可能收集事件到临时缓冲区中,但从不试图将所有日志事件读取到内存中。
  • 异步,非阻塞的I/O——应该有效地使用资源,并且阻塞线程应该尽可能地受到限制。例如,日志流处理器不能按顺序向每个服务发送数据,并等待所有用户依次响应。
  • 不同的速度——生产者和消费者应该能够以不同的速度运行。

日志流处理器的最后一个化身是HTTP流服务,我们将从一个更简单的版本开始展示,从一个文件中处理事件,然后将结果写入另一个文件。正如你将看到的,akka-stream相当灵活。将处理逻辑与读写流的类型解耦是相对容易的。在下一节中, 我们将逐步构建日志流处理应用程序, 从一个简单的流复制应用程序开始。在前进的路上,我们将探讨akka-stream API。

1.1 使用source和sink拷贝文件

作为构建日志流处理应用程序的第一步,我们将看一个流拷贝示例。从源流读取的每个字节都将写入到目标流中。

一如既往,我们将在构建文件中增加依赖,如下所示。
“com.typesafe.akka”%%"akka-stream"%version,

使用akka-stream通常包含两步:

  1. 定义一个蓝图——一个流处理组件图。这个图定义了流应当如何被处理。
  2. 执行这个蓝图——在一个ActorSystem中运行这个图。图转换为actor,以执行实际流数据所需的所有工作。

这个图 (蓝图) 可以在整个程序中共享。创建后,它是不可变的。图可以多次运行,并且每次运行都由一组新的actor执行。一个运行图,可以从流处理的组件中返回结果。在本章的后面, 我们将详细讨论所有这些工作的细节。 如果现在还不完全清楚,不要担心。

我们将从一个非常简单的日志流问题的前身开始,创建一个只复制日志的应用程序。这个例子的蓝图是一个非常简单的管道。从一个流中接收到的所有数据,写入到另外一个流。下面展示了最相关的代码,前者定义一个蓝图,后者执行这个蓝图。

引入
定义一个RunnableGraph

首先,通过FileIO.fromPath 和 FileIO.toPath定义了一个source和一个sink。
Source和Sink都是流端点。Source有一个开放的输出,而Sink有一个开放的输入。
Source和Sink是强类型的,此例中两者的流元素类型都是ByteString。

将Source和Sink连接在一起而形成一个RunnableGraph,如图5所示:

图5

此例中我们使用FileIO,因为很容易在文件中验证输入和输出,而且由FileIO创建source和sink很简单。

source和sink的类型转换相对容易,也就是说,将文件转换为其它介质。

注意:由FileIO创建的Source和Sink内部使用了阻塞文件I/O。FileIO的source和sink创建的actor运行在不同的调度器(dispatcher)上,可以在akka.stream.blocking-io-dispatcher全局定义。也可以通过withAttributes (使用一个ActorAttributes)为图元素自定义调度器(dispatcher)。1.2节会展示使用ActorAttributes来设置supervisorStrategy (监管策略)。

文件I/O的阻塞并不像你想象的那么糟糕。例如,磁盘的延迟远远低于网络的传输。FileIO的异步版本将来可能提供,如果它能够为许多并发文件流提供更好的性能。

物化值
当图运行时,source和sink能够提供一个辅助值,称为物化值(materialized value)。此例中,是一个Future[IOResult],包含了读或者写了多少字节。在1.2节中,我们将讨论更多的物化细节。

这个流复制应用程序创建一个最简单的图——我们通过source.to(sink)定义而来——它创建了一个RunnableGraph,这个RunnableGraph从source获取数据,并直接输送到sink。

创建source和sink的语句是声明式的。它们并没有创建文件或者打开文件的句柄,而是简单的捕捉所有信息,用于稍后RunnableGraph的运行。

还要注意到,创建RunnableGraph 并没有执行任何事情。它只是简单的定义了一个蓝图。

下面代码展示了RunnableGraph 是如何执行的:

执行RunnableGraph

运行这个runnableGraph ,将导致字节从source复制到sink——此例中,从一个文件到另一个文件。一旦图开始运行称为该图被物化

此例中,一旦所有的数据复制完毕,此图就会停止运行。我们将在下一节中详细讨论。

FileIO对象是akka-stream的一部分,它提供了创建文件source和sink的简便方法。连接source和sink,一旦RunnableGraph被物化,导致从文件source读取的每一个ByteString传递到文件sink,一次一个。

运行本例
本例代码在https://github.com/RayRoestenburg/akka-in-action的chapter-stream文件夹下
通常,你可以从sbt控制台运行本例。你可以将程序所需要的参数传递给run命令。
有一个很方便运行程序的插件是sbt-revolver,可以用来启动一个应用程序,或者重启,或者停止(使用re-start和re-stop),而不需要退出sbt控制台。可以从https://github.com/spray/sbt-revolver获取。
在GitHub项目的chapter-stream文件夹下,还包含一个GenerateLogFile 应用程序,它可以创建大体积的测试日志文件。
复制一个比JVM最大内存设置(通过-Xmx parameter设置)更大的文件,来验证应用程序没有偷偷的将整个文件加载到内存中,这是一个你可以尝试的练习。

1.2 物化可运行图

RunnableGraph的run方法需要一个Materalizer在隐式范围内(可以通过scala的隐式参数来了解这里所说的隐式范围)。一个ActorMaterializer 将RunnableGraph转换为actor,这些actor来执行图的要求。

我们来看看文件复制例子中具体细节。其中一些细节可能会发生变化, 因为它们是akka的私有内部, 但是跟踪代码并了解工作原理是非常有用的。图6展示了StreamingCopy 图如何物化的简化版本。

图6
  • ActorMaterializer 检查图中的Source和Sink是否完美连接,要求Source和Sink内部建立资源。在内部,fromPath从一个FileSource创建Source(FileSource是一个SourceShape的内部实现)。
  • FileSource被要求创建它的资源和创建一个FilePublisher,这是一个打开FileChannel的actor。
  • toPath方法从一个FileSinkSinkModule创建一个Sink。FileSink创建了一个FileSubscriber actor,它打开了一个FileChannel。
  • to方法用于连接source和sink,内部会将source模块和sink模块结合为一个模块。
  • ActorMaterializer 根据模块是如何连接的,将订阅者订阅到发布者,本例中是将FileSubscriber 订阅到FilePublisher。
  • FilePublisher 从文件读取ByteStrings直到尾部,一旦它停止关闭文件。
  • FileSubscriber 将从FilePublisher 收到的ByteStrings 写入到输出文件中。一旦FileSubscriber 停止就会关闭FileChannel 。
  • 一旦FilePublisher 从文件中读取了所有文件,将完成流。FileSubscriber 将会收到OnComplete 消息,并关闭写入的文件。

可以通过taketakeWhiletakeWithin来取消流,它们分别代表处理元素的最大数目时取消流,谓词函数返回true时取消流,设定的时间已经过去时取消流。在内部,这些操作符用类似方式完成流。

在那刻,执行此工作而创建的所有actor将停止。再次运行这个RunnableGraph 将重新创建一组actor,整个执行将重头开始。

如果FilePublisher从文件读取的所有数据加载到内存中(它并不会这么做),将会导致内存溢出异常,那它是如何做的呢?答案在于Publisher 和Subscriber 相互是如何交互的,如图7所示:

图7

FilePublisher 能够根据FileSubscriber的请求只发布一定数目的元素。

此例中,如果sink一边的FileSubscriber 请求更多的数据,source一边的FilePublisher 可以从文件中读取更多的数据。这意味着,从source中读取数据的速度与sink写入数据的速度相同。这个简单的例子中,图里只有两个组件;在更复杂的图中,需求从图的末尾一直移动到图的开头, 确保没有任何发布者比订阅者的需求更快地发布。

akka-stream所有图组件都用类似的方式工作。最终,每个部分转换为响应式流发布者或订阅者。正是这个API让 akka-stream能够在有限内存中处理无限的流数据,并且设置发布者和订阅者间交互的规则,例如绝不发送比请求的元素更多的内容。

我们在这里大大简化了发布者和订阅者之间的协议。最重要的是, 订阅者和发布者异步发送有关供求关系的消息。他们不会以任何方式阻塞对方。需求和供给被指定为固定数量的元素。订阅者可以向发布者发出信号, 表明它只能处理较少的数据, 或者它可以向发布者发出信号, 表明它可以处理更多数据。订阅者执行此功能的能力称为非阻塞背压

响应式流倡议
响应式流是一个倡议,它提供了一个使用非阻塞背压进行异步流处理的标准。有些库已经实现了响应式流API,这些库能够相互集成。Akka-stream实现了响应式流API,并在此上提供了高级别API。可以从www.reactive-streams.org了解更多信息。

内部缓冲
Akka-stream内部使用缓冲来优化吞吐量。不是请求和发布单一元素,而是内部批量请求和发布。

FileSubscriber 可以每次请求固定数目的元素。akka-stream库确保读取和写入文件时有足够的内存。这不是你必须担心的,但是你可能好奇,你可能想知道任何时间FileSubscriber可以请求的最大数目。

如果你深入到代码中,你会看到FileSubscriber通过WatermarkRequestStrategy 策略使用了一个高水位线来设置最大输入缓冲区大小。FileSubscriber 不能请求比该设置更多的元素。

然后是元素本身的大小。此例中,是读取文件块的大小,我们可以在fromPath 方法中设置,默认是8KB。

最大输入缓冲大小设置了元素的最大数目,可以在配置文件中定义akka.stream.materializer.max-input-buffer-size来设置。默认的设置是16,所以此例中大概128KB数据可以在处理中。

最大输入缓冲也可以通过ActorMaterializerSettings设置,它可以将设置传递给materializer 或者具体的图组件。ActorMaterializerSettings 可以配置的内容包括,执行图的actor们所使用的dispatcher和图组件如何被监管。

操作符融合
我们会在source和sink间使用更多的节点,akka-stream使用了一种叫做操作符融合的优化技术,在线性链图中尽可能删除不必要的异步边界。
默认情况下,图的各个阶段应当尽可能多的在单一actor中运行,以便消除线程间传递元素、需求和供给信号的开销。async 方法可以用于明确在图中创建一个异步边界,以便通过async调用分别处理元素,保证后面运行在不同的actor上。
操作符融合在物化时发生。可以通过设置akka.stream.materializer.auto-fusing=off关闭。也可以通过Fusing.aggressive(graph)预融合一个图(在它被物化之前)。

组合物化值
正如前面提到的,source和sink能够提供一个辅助值当图物化的时候。文件source和sink提供了一个Future[IOResult],一旦它们完成的时候,包含了读取和写入的字节数。

RunnableGraph 当它运行时,返回了一个物化值,那么它是如何决定哪个值通过图传递?

to方法是toMat的简化版,toMat是一个采用附加函数参数来组合物化值的方法。为此Keep对象定义了几个标准函数。

默认情况下,to方法使用Keep.left保持左边的物化值,这解释了为什么StreamingCopy 例子中图返回的是读取文件的物化值Future[IOResult]。如图8所示:

图8

如下所示,你可以选择保留左边、右边、空或者两个值,通过toMat 方法。

保留物化值

Keep.left, Keep.right, Keep.both, 和 Keep.none是简单的函数分别用于返回左边,右边,两个或者空参数。 Keep.left是一个好的默认值;在一个长的图中图的起始物化值得以保留。如果Keep.right是物化值,你就不得不在每一步中指定Keep.left 保留起始物化值。

到目前为止,你已经看到了一个source和一个sink如何连接。下一节,我们将回到日志事件例子,并且介绍一个Flow组件。在处理和过滤事件的上下文中,我们将更紧密地看到流操作。

1.3 使用flow处理事件

现在你知道了定义和物化图的基础,现在来看一个比复制字节更复杂的例子。我们将开始日志处理的第一个版本。

EventFilter 应用,是一个简单的命令行应用程序,包含三个参数:一个包含日志事件的输入文件,一个写有JSON格式化事件的输出文件,和过滤的事件状态值(拥有该状态的事件将被写到输出文件中)。

我们讨论下日志事件格式。日志事件以文本行的形式编写,日志事件的每一个元素使用管道符(|)与下一个元素分离开。如下所示:

my-host-1 | web-app | ok    | 2015-08-12T12:12:00.127Z | 5 tickets sold.||
my-host-2 | web-app | ok    | 2015-08-12T12:12:01.127Z | 3 tickets sold.||
my-host-1 | web-app | ok    | 2015-08-12T12:12:02.127Z | 1 tickets sold.||
my-host-2 | web-app | error | 2015-08-12T12:12:03.127Z | exception!!||

我们第一个例子中,一个日志事件行由主机名,服务名,状态,时间和描述字段组成。状态值可以是'ok', 'warning', 'error', 或者 'critical'。每行的终点有个换行符(\n)。

文件中每一个文本行将被分析,并转化为一个Event样例类。

case class Event(
  host: String,
  service: String,
  state: State,
  time: ZonedDateTime,
  description: String,
  tag: Option[String] = None,
  metric: Option[Double] = None
)

spray-json库用于将一个Event 转化为JSON。此处省略的EventMarshalling 特质,包含了Event样例类的JSON格式。EventMarshalling 特征可以在 GitHub 仓库中找到, 以及本章中所示的所有代码, 在chapter-stream目录中。

我们将在Source和Sink中使用Flow,如图9所示:

图9

这个flow将捕捉所有的流处理逻辑,我们将在后面本例的HTTP版本中再次使用。Source和Flow都提供方法来操作流。图10展示了概念上在事件过滤流中的操作:

图10

我们遇到的第一个问题是事实上这个flow将从source接受到任意大小的ByteStrings 元素。我们不能假设接收到的一个ByteStrings恰好包含一个日志事件行。

Akka-stream有几个预定义的流用于帧化,来在一个流中识别数据帧。在本例中,我们可以使用Framing.delimiter flow,它在流中检测特定的ByteString 作为分隔符。它缓冲最大值的maxLine字节来根据分隔符查找帧,需要确保破坏性输入不会导致内存溢出异常。

下面展示了帧flow将任意大小的ByteStrings转换为ByteStrings帧(由换行符来分隔)。在我们的格式中,这表示一个完整的日志事件行。

帧化ByteStrings

我们现在准备分析日志行到一个Event样例类中。我们省略了解析日志行的实际逻辑(可以在GitHub项目中找到)。我们将简单的重新映射元素,将字符串转变为一个Event,如下所示:

流不是集合
你可能注意到许多流操作看起来像集合操作,例如map,filter和collect。这些可能会让你认为流仅仅是另外一种标准集合,但事实不是这样的。最大的不同是流的大小是不知道的,然而几乎所有的集合类像List,Set和Map大小是知道的。由于无法遍历流的所有元素, 因此你可能在流上所期望的某些方法 (基于你对集合API 的经验) 是不可用的。

分析行

Flow[String]创建了一个将String元素作为输入和输出的Flow。

在本例中,物化值的类型是不重要的。在创建Flow[String]的时候,没有合理的类型可供选择。NotUsed用于表示物化值不重要并且不使用。分析flow输入字符串并输出Event。

下一步是过滤。

val filter: Flow[Event, Event, NotUsed] =  Flow[Event].filter(_.state == filterState)

某特定filterState事件将通过过滤flow,而其它的将被抛弃。

下面展示的是序列化flow。

序列化事件

Flow能够使用via方法来组合。下面的代码展示了一个完整的事件过滤flow以及它是如何物化的:

val composedFlow: Flow[ByteString, ByteString, NotUsed] =
  frame.via(parse)
    .via(filter)
    .via(serialize)

val runnableGraph: RunnableGraph[Future[IOResult]] =
  source.via(composedFlow).toMat(sink)(Keep.right)

runnableGraph.run().foreach { result =>
  println(s"Wrote ${result.count} bytes to '$outputFile'.")
  system.terminate()
}

这里我们使用了toMat来保留右边的物化值,即Sink的物化值,所以我们能够打印出写入到输出文件中字节数。当然,也可以如下一次性定义flow:

val flow: Flow[ByteString, ByteString, NotUsed] =
  Framing.delimiter(ByteString("\n"), maxLine)
    .map(_.decodeString("UTF8"))
    .map(LogStreamProcessor.parseLineEx)
    .collect { case Some(e) => e }
    .filter(_.state == filterState)
    .map(event => ByteString(event.toJson.compactPrint))

下一节,我们将看一看当错误发生时会发生什么,例如在日志文件中有一个破坏性的行时。

1.4 在流中处理错误

EventFilter 应用程序遇到错误时,有些稚嫩。LogStreamProcessor.parseLineEx方法遇到无法解析的行时,会抛出异常,但这仅仅是可能遇到错误中的一种。你可能传入一个根本不存在的文件路径。

默认情况下,当异常发生时,流处理会停止。RunnableGraph的物化值将是一个保护异常的失败Future。在这种情况下不太方便。忽略无法解析的日志行会更有意义。

首先,我们来看看忽略无法解析的日志行。你可以定义一个监管策略,类似于为actor定义的监管策略。下面的代码展示了如何使用恢复来删除引起异常的元素,从而导致流处理继续进行。

恢复flow

监管策略使用withAttributes传递,它可以对所有图组件有用。你也可以使用ActorMaterializerSettings对整个图的监管策略进行设置,如下所示:

监管图

流的监管策略支持Resume, Stop, 和 Restart。一些流操作建有状态,当使用Restart时,这些状态会被抛弃;而Resume则不会。

将错误作为流的元素
另外一个错误处理选项是捕获异常并使用一种错误类型,将它和其它元素一样在流中传递。例如,你可以引入一个UnparsableEvent 样例类,而Event和UnparsableEvent 都继承于一个普通的sealed特质Result,这样可以模式匹配。完整的流将是一个Flow[ByteString, Result, NotUsed]。另外一个选项是使用Either 类型并编码错误为left、事件为right,结果像这样Flow[ByteString, Either[Error, Result], NotUsed]。在社区里,有比Either更好的选择,例如Scalaz的Disjunction, Cats的 Xor类型,或者 Scalactic的 Or 类型。

现在我们简要地讨论了如何处理流错误, 我们将研究如何将序列化协议与筛选事件的逻辑分开。EventFilter 是一个非常简单的应用——主要的逻辑就是过滤有特殊状态的事件。如果我们能够更好地重用解析、过滤和序列化步骤, 那就太好了。此外, 我们开始相当武断地只支持日志格式作为输入和 JSON 输出。例如, 如果我们还能支持 JSON 输入和文本日志格式输出, 那就太好了。在下一节中, 我们将查看双向flow, 以定义可重用的序列化协议, 我们可以叠加在过滤flow之上。

1.5 使用BidiFlow来创建协议

BidiFlow是一个拥有两个开放输入和两个开放输出的图组件。使用BidiFlow的一个方法是作为适配器叠加在一个flow之上。

我们将使用BidiFlow作为两个flow合并使用,但需要注意的是有许多创建BidiFlow的方法,不仅仅是通过两个flow。

让我们重写EventFilter应用以便它基本上只处理过滤方法,一个Flow[Event, Event, NotUsed],从事件到事件。从输入字节如何读取事件和事件如何重写应当作为协议适配重用。图11展示了BidiFlow的结构。

图11

BidiEventFilter 应用将序列化协议从过滤事件的逻辑中分离,如图12所示。在本例中,“out”flow只包含一个序列化的flow,因为本例中帧化元素(换行符)是自动添加到序列化上的。

图12

下面代码展示了从命令行参数中如何创建一个特定的BidiFlow。除 "json" 之外的任何内容都将被解释为日志文件格式。

通过命令行创建BidiFlow

JsonFraming 帧化输入的字节到JSON对象中。我们使用spray-json解析字节包含的JSON对象,并转换它到Event。JsonFraming 包括在GitHub上的项目,这是抄袭Konrad Malawski的初步工作编组JSON流(预计在Akka的未来版本中包含)。

fromFlows方法从两个flow创建BidiFlow,用于反序列化和序列化。BidiFlow可以使用join方法加在过滤flow之上,如下所示:

未标题-1.png

另一种方式来思考,可以认为BidiFlow提供了两个flow,你可以一个连接在现有的flow之前,一个连接在之后,适配输入侧和输出侧遇到的问题。本例中,用于读取和写入一致性的格式。

在下一节中, 我们将构建一个流式 HTTP 服务, 并向日志流处理器添加更多功能, 以接近实际的应用程序。到目前为止, 我们只使用了流操作的直线管道。我们还将关注广播和合并流。

推荐阅读更多精彩内容