此答案基于akka-stream
版本2.4.2
。该API在其他版本中可能会略有不同。依赖可以被sbt消耗:
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.4.2"
好吧,让我们开始吧。Akka Streams的API包括三种主要类型。与反应式流相反,这些类型功能强大得多,因此更加复杂。假定对于所有代码示例,已经存在以下定义:
import scala.concurrent._
import akka._
import akka.actor._
import akka.stream._
import akka.stream.scaladsl._
import akka.util._
implicit val system = ActorSystem("TestSystem")
implicit val materializer = ActorMaterializer()
import system.dispatcher
import
类型声明需要这些语句。system
表示Akka的参与者系统,并materializer
表示流的评估上下文。在我们的例子中,我们使用ActorMaterializer
,这意味着在actor之上对流进行评估。这两个值都标记为implicit
,这使得Scala编译器可以在需要时自动注入这两个依赖项。我们还导入system.dispatcher
,这是的执行上下文Futures
。
新的API
Akka流具有以下关键属性:
- 他们实现了Reactive Streams规范,该规范的三个主要目标:背压,异步和非阻塞边界以及不同实现之间的互操作性也完全适用于Akka Streams。
- 它们为流的评估引擎提供了一种抽象,称为
Materializer
。
- 程序被表述为可重用的构建块,分别表示为三种主要类型
Source
,Sink
和Flow
。这些构建块构成了一个图表,其评估基于Materializer
,需要明确触发。
在下文中,将给出关于如何使用三种主要类型的更深入的介绍。
资源
A Source
是数据创建者,它充当流的输入源。每个Source
都有一个输出通道,没有输入通道。所有数据都通过输出通道流到连接到的任何通道Source
。
图片取自boldradius.com。
一个Source
能够以多种方式创建:
scala> val s = Source.empty
s: akka.stream.scaladsl.Source[Nothing,akka.NotUsed] = ...
scala> val s = Source.single("single element")
s: akka.stream.scaladsl.Source[String,akka.NotUsed] = ...
scala> val s = Source(1 to 3)
s: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...
scala> val s = Source(Future("single value from a Future"))
s: akka.stream.scaladsl.Source[String,akka.NotUsed] = ...
scala> s runForeach println
res0: scala.concurrent.Future[akka.Done] = ...
single value from a Future
在上述情况下,我们Source
使用有限数据填充了,这意味着它们最终将终止。不应忘记,默认情况下,响应流是惰性的和异步的。这意味着必须明确要求对流进行评估。在Akka Streams中,可以通过这些run*
方法来完成。这runForeach
与众所周知的foreach
函数没有什么不同-通过run
添加,它明确表明我们要求对流进行评估。由于有限的数据很无聊,因此我们继续无限的一个:
scala> val s = Source.repeat(5)
s: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...
scala> s take 3 runForeach println
res1: scala.concurrent.Future[akka.Done] = ...
5
5
5
使用该take
方法,我们可以创建一个人为的停止点,以防止我们无限期地进行评估。由于actor支持是内置的,因此我们还可以轻松地向流提供发送给actor的消息:
def run(actor: ActorRef) = {
Future { Thread.sleep(300); actor ! 1 }
Future { Thread.sleep(200); actor ! 2 }
Future { Thread.sleep(100); actor ! 3 }
}
val s = Source
.actorRef[Int](bufferSize = 0, OverflowStrategy.fail)
.mapMaterializedValue(run)
scala> s runForeach println
res1: scala.concurrent.Future[akka.Done] = ...
3
2
1
我们可以看到,Futures
它们在不同的线程上异步执行,这说明了结果。在上面的示例中,传入元素的缓冲区不是必需的,因此OverflowStrategy.fail
我们可以配置流在缓冲区溢出时失败。尤其是通过此actor接口,我们可以通过任何数据源提供流。数据是由同一个线程,另一个线程,另一个进程创建还是由Internet上的远程系统创建都没有关系。
水槽
A Sink
基本上与A 相反Source
。它是流的端点,因此消耗数据。A Sink
有一个输入通道,没有输出通道。Sinks
当我们想以可重用的方式指定数据收集器的行为而无需评估流时,则特别需要。已知的run*
方法不允许我们使用这些属性,因此最好Sink
改用。
图片取自boldradius.com。
运行中的简短示例Sink
:
scala> val source = Source(1 to 3)
source: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...
scala> val sink = Sink.foreach[Int](elem => println(s"sink received: $elem"))
sink: akka.stream.scaladsl.Sink[Int,scala.concurrent.Future[akka.Done]] = ...
scala> val flow = source to sink
flow: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...
scala> flow.run()
res3: akka.NotUsed = NotUsed
sink received: 1
sink received: 2
sink received: 3
可以使用该方法Source
将a 连接到a 。它返回一个所谓的,这是我们稍后将看到的一种特殊形式-流,只需调用其方法即可执行。Sink
to
RunnableFlow
Flow
run()
图片取自boldradius.com。
当然可以将到达接收器的所有值转发给参与者:
val actor = system.actorOf(Props(new Actor {
override def receive = {
case msg => println(s"actor received: $msg")
}
}))
scala> val sink = Sink.actorRef[Int](actor, onCompleteMessage = "stream completed")
sink: akka.stream.scaladsl.Sink[Int,akka.NotUsed] = ...
scala> val runnable = Source(1 to 3) to sink
runnable: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...
scala> runnable.run()
res3: akka.NotUsed = NotUsed
actor received: 1
actor received: 2
actor received: 3
actor received: stream completed
流
如果您需要在Akka流和现有系统之间建立连接,但是数据源和接收器非常有用,但实际上它们不能做任何事情。流是Akka Streams基础抽象中最后缺少的部分。它们充当不同流之间的连接器,并可用于转换其元素。
图片取自boldradius.com。
如果将a Flow
连接到Source
新的,Source
则结果为。同样,Flow
连接到Sink
会创建一个new Sink
。与a和a Flow
相连接Source
的Sink
结果为a RunnableFlow
。因此,它们位于输入通道和输出通道之间,但只要它们未连接到a Source
或a ,它们本身就不对应其中一种Sink
。
图片取自boldradius.com。
为了更好地理解Flows
,我们将看一些示例:
scala> val source = Source(1 to 3)
source: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...
scala> val sink = Sink.foreach[Int](println)
sink: akka.stream.scaladsl.Sink[Int,scala.concurrent.Future[akka.Done]] = ...
scala> val invert = Flow[Int].map(elem => elem * -1)
invert: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...
scala> val doubler = Flow[Int].map(elem => elem * 2)
doubler: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...
scala> val runnable = source via invert via doubler to sink
runnable: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...
scala> runnable.run()
res10: akka.NotUsed = NotUsed
-2
-4
-6
通过该via
方法,我们可以将a Source
与a 连接起来Flow
。我们需要指定输入类型,因为编译器无法为我们推断出它。正如我们在这个简单的例子已经看到,流动invert
和double
从任何数据生产者和消费者完全独立的。它们仅转换数据并将其转发到输出通道。这意味着我们可以在多个流之间重用流:
scala> val s1 = Source(1 to 3) via invert to sink
s1: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...
scala> val s2 = Source(-3 to -1) via invert to sink
s2: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...
scala> s1.run()
res10: akka.NotUsed = NotUsed
-1
-2
-3
scala> s2.run()
res11: akka.NotUsed = NotUsed
3
2
1
s1
并s2
代表全新的流-它们不会通过其构造块共享任何数据。
无限数据流
在继续之前,我们应该首先回顾一下Reactive Streams的一些关键方面。无限数量的元素可以到达任意点,并且可以将流置于不同的状态。除了可运行流(这是通常的状态)之外,流可能通过错误或通过表示没有其他数据将到达的信号而停止。可以通过在时间线上标记事件来以图形方式对流进行建模,如此处所示:
图片取自“反应式编程入门”中所缺少的。
在上一节的示例中,我们已经看到了可运行的流程。RunnableGraph
只要可以实际实现流,就得到a ,这意味着a Sink
连接到a Source
。到目前为止,我们始终实现为value Unit
,可以在类型中看到:
val source: Source[Int, NotUsed] = Source(1 to 3)
val sink: Sink[Int, Future[Done]] = Sink.foreach[Int](println)
val flow: Flow[Int, Int, NotUsed] = Flow[Int].map(x => x)
对于Source
和Sink
第二类型参数和对于Flow
第三类型参数表示物化值。在整个答案中,将不解释实现的全部含义。但是,有关实现的更多详细信息可以在官方文档中找到。现在,我们唯一需要知道的是,实现价值是运行流时获得的价值。到目前为止,由于我们仅对副作用感兴趣,因此获得Unit
了物化价值。唯一的例外是汇的实现,导致产生Future
。它给了我们一个Future
,因为此值可以表示连接到接收器的流何时结束。到目前为止,前面的代码示例很好地解释了这个概念,但是它们也很无聊,因为我们只处理有限的流或非常简单的无限流。为了使其更加有趣,下面将说明完整的异步和无界流。
ClickStream示例
举例来说,我们想要一个流来捕获点击事件。为了使其更具挑战性,我们还希望将彼此之间在短时间内发生的点击事件分组。这样,我们可以轻松发现两次,三次或十次点击。此外,我们要过滤掉所有的单击。深吸一口气,想象一下您将如何以迫切的方式解决该问题。我敢打赌,没有人能够实施在第一次尝试中就可以正常工作的解决方案。以反应方式,这个问题很难解决。实际上,该解决方案实现起来是如此简单明了,我们甚至可以在直接描述代码行为的图表中表达它:
图片取自“反应式编程入门”中所缺少的。
灰色框是描述一个流如何转换为另一流的功能。使用此throttle
功能,我们可以在250毫秒内累积点击次数,map
and filter
函数应该是不言自明的。颜色球表示事件,箭头表示它们如何流经我们的功能。在随后的处理步骤中,由于我们将它们组合在一起并过滤掉,因此越来越少的元素流过流。该图像的代码如下所示:
val multiClickStream = clickStream
.throttle(250.millis)
.map(clickEvents => clickEvents.length)
.filter(numberOfClicks => numberOfClicks >= 2)
整个逻辑只能用四行代码表示!在Scala中,我们可以写得更短:
val multiClickStream = clickStream.throttle(250.millis).map(_.length).filter(_ >= 2)
的定义clickStream
稍微复杂一点,但是只有这种情况,因为示例程序在JVM上运行,因此不容易捕获click事件。另一个复杂之处在于,默认情况下Akka不提供该throttle
功能。相反,我们必须自己编写它。由于此函数(在map
or filter
函数中就是这种情况)可以在不同的用例中重用,因此我不将这些行计入实现逻辑所需的行数。但是,在命令式语言中,通常不能轻易重用逻辑,并且不同的逻辑步骤全部发生在一个地方而不是顺序应用,这是正常的,这意味着我们可能会通过节流逻辑对代码进行变形。完整的代码示例可作为要点,此处不再赘述。
SimpleWebServer示例
相反,应该讨论的是另一个示例。虽然点击流是让Akka Streams处理真实示例的好例子,但它缺乏显示并行执行的功能。下一个示例将代表一个小型Web服务器,该服务器可以并行处理多个请求。网络服务器应能够接受传入的连接并从中接收表示可打印ASCII符号的字节序列。这些字节序列或字符串应在所有换行符处拆分为较小的部分。此后,服务器应使用每个分割线响应客户端。另外,它可以对行进行其他操作并给出特殊的答案令牌,但在此示例中我们希望保持简单,因此不引入任何奇特的功能。记得,服务器需要能够同时处理多个请求,这基本上意味着不允许任何请求阻止其他任何请求的进一步执行。解决所有这些要求可能非常困难-使用Akka Streams,我们不需要太多行就能解决所有这些问题。首先,让我们对服务器本身进行概述:
基本上,只有三个主要构建块。第一个需要接受传入的连接。第二个需要处理传入的请求,第三个需要发送响应。实施这三个构建块仅比实施点击流稍微复杂一点:
def mkServer(address: String, port: Int)(implicit system: ActorSystem, materializer: Materializer): Unit = {
import system.dispatcher
val connectionHandler: Sink[Tcp.IncomingConnection, Future[Unit]] =
Sink.foreach[Tcp.IncomingConnection] { conn =>
println(s"Incoming connection from: ${conn.remoteAddress}")
conn.handleWith(serverLogic)
}
val incomingCnnections: Source[Tcp.IncomingConnection, Future[Tcp.ServerBinding]] =
Tcp().bind(address, port)
val binding: Future[Tcp.ServerBinding] =
incomingCnnections.to(connectionHandler).run()
binding onComplete {
case Success(b) =>
println(s"Server started, listening on: ${b.localAddress}")
case Failure(e) =>
println(s"Server could not be bound to $address:$port: ${e.getMessage}")
}
}
该函数mkServer
(除了服务器的地址和端口之外)还使用参与者系统和实现器作为隐式参数。服务器的控制流由表示,该服务器binding
获取传入连接的源并将其转发到传入连接的接收器。在connectionHandler
,这是我们的接收器内部,我们通过流处理每个连接serverLogic
,这将在后面描述。binding
返回一个Future
,这在服务器启动或启动失败时完成,当端口已被另一个进程占用时可能会发生这种情况。但是,由于我们看不到处理响应的构件,因此代码无法完全反映图形。这样做的原因是,连接本身已经提供了此逻辑。正如我们在前面的示例中看到的那样,它是双向流,而不仅仅是单向流。就像实现的情况一样,这里不解释这种复杂的流程。该官方文档有足够的材料来覆盖更复杂的流图。现在,仅知道Tcp.IncomingConnection
表示一个知道如何接收请求和如何发送响应的连接就足够了 。仍然缺少的部分是serverLogic
积木。它看起来可能像这样:
再一次,我们能够将逻辑拆分为几个简单的构建块,它们一起构成了程序的流程。首先,我们想将字节序列分成几行,每当找到换行符时就必须这样做。之后,由于处理原始字节比较麻烦,因此每行的字节都需要转换为字符串。总的来说,我们可以收到一个复杂协议的二进制流,这将使处理传入的原始数据极具挑战性。一旦有了可读的字符串,就可以创建答案。为了简单起见,在我们的情况下,答案可以是任何东西。最后,我们必须将答案转换回可以通过网络发送的字节序列。整个逻辑的代码可能如下所示:
val serverLogic: Flow[ByteString, ByteString, Unit] = {
val delimiter = Framing.delimiter(
ByteString("\n"),
maximumFrameLength = 256,
allowTruncation = true)
val receiver = Flow[ByteString].map { bytes =>
val message = bytes.utf8String
println(s"Server received: $message")
message
}
val responder = Flow[String].map { message =>
val answer = s"Server hereby responds to message: $message\n"
ByteString(answer)
}
Flow[ByteString]
.via(delimiter)
.via(receiver)
.via(responder)
}
我们已经知道这serverLogic
是一个需要a ByteString
且必须产生a的流ByteString
。有了delimiter
我们,我们就可以分割成ByteString
较小的部分-在我们的情况下,只要换行符出现,就需要将其拆分。receiver
是采用所有拆分字节序列并将其转换为字符串的流。这当然是危险的转换,因为只有可打印的ASCII字符应转换为字符串,但对于我们的需求来说已经足够了。responder
是最后一个组件,负责创建答案并将答案转换回字节序列。与图形相反,我们没有将最后一个组件一分为二,因为逻辑很简单。最后,我们通过via
功能。此时,您可能会问我们是否照顾起初提到的多用户属性。确实,即使不是立即发现,我们也做到了。通过查看此图,它应该变得更加清晰:
该serverLogic
组件不过是包含较小流的流。该组件接受输入(即请求),并产生输出(即响应)。由于流可以构造多次,并且它们彼此独立工作,因此我们通过嵌套多用户属性来实现。每个请求都在其自己的请求中进行处理,因此,短暂运行的请求可能会覆盖先前启动的长期运行的请求。如果您想知道,serverLogic
当然可以通过内联大多数内部定义将前面显示的定义写得短很多:
val serverLogic = Flow[ByteString]
.via(Framing.delimiter(
ByteString("\n"),
maximumFrameLength = 256,
allowTruncation = true))
.map(_.utf8String)
.map(msg => s"Server hereby responds to message: $msg\n")
.map(ByteString(_))
Web服务器的测试可能如下所示:
$ # Client
$ echo "Hello World\nHow are you?" | netcat 127.0.0.1 6666
Server hereby responds to message: Hello World
Server hereby responds to message: How are you?
为了使上述代码示例正常运行,我们首先需要启动服务器,该startServer
脚本由以下脚本描述:
$ # Server
$ ./startServer 127.0.0.1 6666
[DEBUG] Server started, listening on: /127.0.0.1:6666
[DEBUG] Incoming connection from: /127.0.0.1:37972
[DEBUG] Server received: Hello World
[DEBUG] Server received: How are you?
可以在此处找到此简单TCP服务器的完整代码示例。我们不仅可以使用Akka Streams编写服务器,而且还可以编写客户端。它可能看起来像这样:
val connection = Tcp().outgoingConnection(address, port)
val flow = Flow[ByteString]
.via(Framing.delimiter(
ByteString("\n"),
maximumFrameLength = 256,
allowTruncation = true))
.map(_.utf8String)
.map(println)
.map(_ ⇒ StdIn.readLine("> "))
.map(_+"\n")
.map(ByteString(_))
connection.join(flow).run()
完整的代码TCP客户端可以在这里找到。该代码看起来非常相似,但是与服务器相比,我们不再需要管理传入的连接。
复杂图
在前面的部分中,我们已经看到了如何从流程中构造简单的程序。但是,实际上仅依靠已经内置的功能来构造更复杂的流通常是不够的。如果我们希望能够将Akka Streams用于任意程序,我们需要知道如何构建自己的自定义控件结构和可组合流,从而使我们能够解决应用程序的复杂性。好消息是,Akka Streams旨在根据用户需求进行扩展,并且为了向您简要介绍Akka Streams的更复杂部分,我们在客户端/服务器示例中添加了更多功能。
我们还不能做的一件事就是关闭连接。此时,它开始变得有点复杂,因为到目前为止我们所看到的流API不允许我们在任意点停止流。但是,这里有GraphStage
抽象,可用于创建具有任意数量的输入或输出端口的任意图形处理阶段。首先让我们看一下服务器端,在这里我们引入了一个名为的新组件closeConnection
:
val closeConnection = new GraphStage[FlowShape[String, String]] {
val in = Inlet[String]("closeConnection.in")
val out = Outlet[String]("closeConnection.out")
override val shape = FlowShape(in, out)
override def createLogic(inheritedAttributes: Attributes) = new GraphStageLogic(shape) {
setHandler(in, new InHandler {
override def onPush() = grab(in) match {
case "q" ⇒
push(out, "BYE")
completeStage()
case msg ⇒
push(out, s"Server hereby responds to message: $msg\n")
}
})
setHandler(out, new OutHandler {
override def onPull() = pull(in)
})
}
}
这个API看起来比流程API麻烦得多。难怪,我们必须在这里执行许多必要的步骤。作为交换,我们可以更好地控制流的行为。在上面的示例中,我们仅指定一个输入和一个输出端口,并通过覆盖该shape
值使它们对系统可用。此外,我们定义了所谓的InHandler
和OutHandler
,它们按此顺序负责接收和发射元素。如果您仔细查看完整的点击流示例,则应该已经认识到这些组件。在中,InHandler
我们抓取一个元素,如果它是一个具有单个字符的字符串'q'
,我们想关闭流。为了使客户有机会发现流将很快关闭,我们发出了字符串"BYE"
然后我们立即关闭舞台。该closeConnection
组件可以通过该via
方法与流合并,该方法在有关流的部分中介绍。
除了能够关闭连接之外,如果我们可以向新创建的连接显示欢迎消息,那也很好。为了做到这一点,我们再次必须走得更远:
def serverLogic
(conn: Tcp.IncomingConnection)
(implicit system: ActorSystem)
: Flow[ByteString, ByteString, NotUsed]
= Flow.fromGraph(GraphDSL.create() { implicit b ⇒
import GraphDSL.Implicits._
val welcome = Source.single(ByteString(s"Welcome port ${conn.remoteAddress}!\n"))
val logic = b.add(internalLogic)
val concat = b.add(Concat[ByteString]())
welcome ~> concat.in(0)
logic.outlet ~> concat.in(1)
FlowShape(logic.in, concat.out)
})
该函数serverLogic
现在将传入的连接作为参数。在其主体内部,我们使用DSL来描述复杂的流行为。使用welcome
我们创建的流只能发出一个元素-欢迎消息。logic
如上serverLogic
一节所述。唯一值得注意的区别是我们添加closeConnection
了它。现在实际上是DSL有趣的部分。该GraphDSL.create
函数使生成器b
可用,该生成器用于将流表示为图形。通过此~>
功能,可以将输入和输出端口相互连接。Concat
在示例中使用的组件可以连接元素,并在此处将欢迎消息放在其他出现的元素之前internalLogic
。在最后一行中,我们仅使服务器逻辑的输入端口和级联流的输出端口可用,因为所有其他端口仍将保留serverLogic
组件的实现细节。有关Akka Streams图形DSL的深入介绍,请访问官方文档中的相应部分。可以在此处找到复杂的TCP服务器和可以与之通信的客户端的完整代码示例。每当您从客户端打开新连接时,您都应该看到一条欢迎消息,并且通过"q"
在客户端上键入您应该看到一条消息,告诉您连接已被取消。
仍有一些主题未包含在此答案中。尤其是物化可能会吓到一个读者或另一个读者,但我敢肯定,这里介绍的材料中的每个人都应该能够自行进行下一步。如前所述,官方文档是继续学习Akka Streams的好地方。