netty

Akka Stream之使用流式IO

2017-10-26  本文已影响968人  乐言笔记

Akka Sream提供了一种使用流处理文件IO和TCP连接的方法。虽然一般方法与使用Akka IO 的基于Actor的 TCP 处理非常相似, 但通过使用Akka Stream, 您将不必手动对背压信号做出反应, 因为该库对您来说是透明的。

流式TCP

为了实现一个简单的EchoServer,我们绑定到一个给定的地址,它返回一个Source[IncomingConnection, Future[ServerBinding]],该Source为服务器要处理的每一个新连接射入一个IncomingConnection元素:

val binding: Future[ServerBinding] =
  Tcp().bind("127.0.0.1", 8888).to(Sink.ignore).run()

binding.map { b =>
  b.unbind() onComplete {
    case _ => // ...
  }
}
tcp-stream-bind.png

The last boolean argument indicates that we require an explicit line ending even for the last message before the connection is closed. In this example we simply add exclamation marks to each incoming text message and push it through the flow:
接下来,我们简单地使用一个Flow处理每个传入连接,该Flow将用作处理阶段,以从和到TCP Socket处理和发送ByteString s。由于一个ByteString不一定对应于一行文本(客户端可能会以分块的形式发送行),因此我们使用Framing.delimiter帮助Flow 将输入块组合到实际文本行中。最后一个布尔参数表示,我们需要一个明确的行结束,即使是连接关闭之前的最后一个消息。 在本例中, 我们只需将感叹号添加到每个传入的文本消息中, 并将其推送到流中:

import akka.stream.scaladsl.Framing

val connections: Source[IncomingConnection, Future[ServerBinding]] =
  Tcp().bind(host, port)
connections runForeach { connection =>
  println(s"New connection from: ${connection.remoteAddress}")

  val echo = Flow[ByteString]
    .via(Framing.delimiter(
      ByteString("\n"),
      maximumFrameLength = 256,
      allowTruncation = true))
    .map(_.utf8String)
    .map(_ + "!!!\n")
    .map(ByteString(_))

  connection.handleWith(echo)
}
tcp-stream-run.png

请注意,虽然Akka Streams中的大部分构建块都是可重用且可自由共享的,但传入连接Flow并不是这样,因为它直接对应于现有的已经接受的连接,因此它的处理只能物化一次。

通过从服务器逻辑中取消传入的连接流 (例如,将其下游连接到Sink.cancelled并将其上游连接到Source.empty)可以关闭连接。 也可以通过取消IncomingConnection source 连接来关闭服务器的套接字。

然后,我们可以通过使用netcat向TCP Socket发送数据来测试TCP服务器:

$ echo -n "Hello World" | netcat 127.0.0.1 8888
Hello World!!!

连接:REPL客户端

在这个例子中,我们通过TCP实现了一个相当朴素的Read Evaluate Print Loop客户端。 假设我们知道一台服务器已经在 TCP上公开了一个简单的命令行接口, 并希望通过 TCP上的Akka Stream与它进行交互。要打开一个传出连接套接字,我们使用outgoingConnection方法:

val connection = Tcp().outgoingConnection("127.0.0.1", 8888)

val replParser =
  Flow[String].takeWhile(_ != "q")
    .concat(Source.single("BYE"))
    .map(elem => ByteString(s"$elem\n"))

val repl = Flow[ByteString]
  .via(Framing.delimiter(
    ByteString("\n"),
    maximumFrameLength = 256,
    allowTruncation = true))
  .map(_.utf8String)
  .map(text => println("Server: " + text))
  .map(_ => readLine("> "))
  .via(replParser)

connection.join(repl).run()

我们用来处理服务器交互的 repl 流首先打印服务器响应,然后等待命令行的输入(这个阻塞调用在这里只是为了简单起见),并将其转换为ByteString,然后将其发送到服务器。

一个有弹性的REPL客户端将比这更复杂,例如它应该将输入读取分解成一个单独的mapAsync步骤,并且有一种方法让服务器在任何给定时刻写入比一个ByteString块更多的数据,但这些改进仍然存在 作为读者的锻炼。

避免背压循环中的死锁和活动问题

当编写这样的端到端背压系统时, 有时可能会出现循环的情况, 其中任何一方都在等待另一方开始对话。在前面所示的两个例子中, 我们总是假设我们所连接的一方会开始对话, 这实际上意味着双方都在承受背压, 无法开始对话。有多种处理方式,在图形循环,活动和死锁章节中有深入的解释,但是在客户端 - 服务器场景中,通常最简单的方法是让任何一方发送一个初始消息。

注意
在背压循环(即使在不同的系统之间也可能出现)的情况下,有时您必须确定哪一方开始对话才能将其解除。这通常可以通过从一方中注入一个初始消息(对话启动器)来实现。

为了破解背压循环,我们需要注入某个初始消息,一个“对话启动器”。首先,我们需要确定连接的哪一边应该是被动的,哪个方面是主动的。幸运的是,在大多数情况下, 找到开始对话的正确位置是相当简单的, 因为它经常是我们试图使用流实现的协议所固有的。在类似于聊天的应用程序中,如我们的示例,通过发出“hello”消息使服务器启动会话是有意义的:

connections.runForeach { connection =>

  // server logic, parses incoming commands
  val commandParser = Flow[String].takeWhile(_ != "BYE").map(_ + "!")

  import connection._
  val welcomeMsg = s"Welcome to: $localAddress, you are: $remoteAddress!"
  val welcome = Source.single(welcomeMsg)

  val serverLogic = Flow[ByteString]
    .via(Framing.delimiter(
      ByteString("\n"),
      maximumFrameLength = 256,
      allowTruncation = true))
    .map(_.utf8String)
    .via(commandParser)
    // merge in the initial banner after parser
    .merge(welcome)
    .map(_ + "\n")
    .map(ByteString(_))

  connection.handleWith(serverLogic)
}

为了发出初始消息,我们将带有一个元素的Source合并,在命令处理之后,但在进行帧化和转换成ByteString之前,这样我们不必重复这样的逻辑。

本例中,服务器端基于分析出命令- BYE 关闭流,而客户端情况下是q。这是通过以下方式实现:从流中提取直到q,连接带有单一Bye元素的Source,在原source完成后发送它。

在协议中使用帧化

诸如TCP之类的流传输协议只是传递字节流,并且从应用程序的角度来看不知道逻辑块的大小是多少。通常在实现网络协议时,你想要引入自己的帧化。这可以有两种方式:帧结束标记,例如, 结束行\ n,可以通过Framing.delimiter进行帧化。或者可以使用长度字段来构建成帧协议。有一个Framing.simpleFramingProtocol提供的bidi协议实现,在ScalaDoc查看更多信息。

流化文件IO

Akka Streams提供简单的Source和Sink,可以使用ByteString实例来对文件执行IO操作。

给一个目标路径,使用FileIO.fromPath从文件创建数据流,还有一个可选的chunkSize,它决定了在这样的流中确定为一个 "元素" 的缓冲区大小:

import akka.stream.scaladsl._
val file = Paths.get("example.csv")

val foreach: Future[IOResult] = FileIO.fromPath(file)
  .to(Sink.ignore)
  .run()

请注意, 这些处理阶段由Actor支持, 默认情况下预先配置的线程池(配置为在专门用于文件 IO )支持的调度器上运行。这一点非常重要, 因为它将阻塞的文件 IO 操作与 ActorSystem 的其余部分隔离开来, 从而使每个调度器能够以最有效的方式进行利用。如果要在全局范围内为文件 IO 操作配置自定义调度器, 可以更改akka.stream.blocking-io-dispatcher,或者在代码中为某个具体的阶段指定一个自定义Dispatcher,像这样:

FileIO.fromPath(file)
  .withAttributes(ActorAttributes.dispatcher("custom-blocking-io-dispatcher"))
上一篇下一篇

猜你喜欢

热点阅读