如何开始使用Akka Streams?[关闭]


222

Akka Streams库已经提供了很多文档。但是,对我来说主要的问题是它提供了太多的材料-我对必须学习的概念数量感到不知所措。那里显示的许多示例都很繁重,无法轻松转换为现实的用例,因此非常神秘。我认为它给出了太多的细节,却没有说明如何一起构建所有构建块以及它如何帮助解决特定问题。

有源,汇,流,图阶段,部分图,物化,图DSL等,我只是不知道从哪里开始。该快速入门指南,就是要一个首发位置,但我不明白。它只是抛出了上述概念,而没有对其进行解释。此外,这些代码示例无法执行-缺少某些部分,这使我或多或少无法遵循本文。

谁能用简单的词和简单的示例来解释这些概念的来源,汇,流,图阶段,局部图,物化以及也许我错过的其他一些问题,这些示例并不能解释每个细节(并且可能在任何时候都不需要开始)?


2
有关信息,将在meta
DavidG '16

10
作为第一个投票赞成关闭此操作的人(在Meta主题之后),首先让我说您的回答很好。它确实很深入,并且肯定是非常有用的资源。但是不幸的是,您提出的问题对于Stack Overflow来说太宽泛了。如果您能以某种方式将您的答案发布到措辞不同的问题上,那就太好了,但我认为不可能。我强烈建议您将其重新提交为博客文章或类似内容,您自己和其他人可以将其用作将来的答案中的参考资源。
James Donnelly

2
我认为将这个问题写为博客文章是无效的。是的,这是一个广泛的问题-这是一个非常好的问题。缩小范围不会改善它。提供的答案非常好。我相信Quora会很乐意将业务从SO转移到大问题上。
Mike Slinn '16

11
@MikeSlinn不会尝试与SO人员讨论适当的问题,他们会盲目遵循规则。只要问题不被解决,我都会很高兴,也不会感到转移到其他平台。
kiritsuku 2016年

2
@sschaef多么学究。是的,当然,规则是一文不值的,您的伟大自我知道得更好,每个尝试应用规则的人都只是盲目地遵循炒作。/ rant。更重要的是,如果您在文档Beta中,这将是一个很好的补充。您仍然可以申请并放在那儿,但是至少应该看到它不太适合主站点。
费利克斯·加侬-格雷尼尔

Answers:


506

此答案基于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
  • 程序被表述为可重用的构建块,分别表示为三种主要类型SourceSinkFlow。这些构建块构成了一个图表,其评估基于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 。它返回一个所谓的,这是我们稍后将看到的一种特殊形式-流,只需调用其方法即可执行。SinktoRunnableFlowFlowrun()

可运行流

图片取自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相连接SourceSink结果为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。我们需要指定输入类型,因为编译器无法为我们推断出它。正如我们在这个简单的例子已经看到,流动invertdouble从任何数据生产者和消费者完全独立的。它们仅转换数据并将其转发到输出通道。这意味着我们可以在多个流之间重用流:

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

s1s2代表全新的流-它们不会通过其构造块共享任何数据。

无限数据流

在继续之前,我们应该首先回顾一下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)

对于SourceSink第二类型参数和对于Flow第三类型参数表示物化值。在整个答案中,将不解释实现的全部含义。但是,有关实现的更多详细信息可以在官方文档中找到。现在,我们唯一需要知道的是,实现价值是运行流时获得的价值。到目前为止,由于我们仅对副作用感兴趣,因此获得Unit了物化价值。唯一的例外是汇的实现,导致产生Future。它给了我们一个Future,因为此值可以表示连接到接收器的流何时结束。到目前为止,前面的代码示例很好地解释了这个概念,但是它们也很无聊,因为我们只处理有限的流或非常简单的无限流。为了使其更加有趣,下面将说明完整的异步和无界流。

ClickStream示例

举例来说,我们想要一个流来捕获点击事件。为了使其更具挑战性,我们还希望将彼此之间在短时间内发生的点击事件分组。这样,我们可以轻松发现两次,三次或十次点击。此外,我们要过滤掉所有的单击。深吸一口气,想象一下您将如何以迫切的方式解决该问题。我敢打赌,没有人能够实施在第一次尝试中就可以正常工作的解决方案。以反应方式,这个问题很难解决。实际上,该解决方案实现起来是如此简单明了,我们甚至可以在直接描述代码行为的图表中表达它:

点击流示例的逻辑

图片取自“反应式编程入门”中所缺少的

灰色框是描述一个流如何转换为另一流的功能。使用此throttle功能,我们可以在250毫秒内累积点击次数,mapand 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功能。相反,我们必须自己编写它。由于此函数(在mapor 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值使它们对系统可用。此外,我们定义了所谓的InHandlerOutHandler,它们按此顺序负责接收和发射元素。如果您仔细查看完整的点击流示例,则应该已经认识到这些组件。在中,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的好地方。


4
@monksy我不打算在其他任何地方发布。如果需要,可以随时在您的博客上重新发布。如今,API在大多数地方都是稳定的,这意味着您甚至不必在乎维护(关于Akka Streams的大多数博客文章已经过时,因为它们显示了不再存在的API)。
kiritsuku 2016年

3
它不会消失。为什么要这样
kiritsuku 2016年

2
@sschaef它很可能会消失,因为问题不在主题范围内,因此已经关闭。
DavidG '16

7
@Magisch永远记住:“我们不会删除优质内容。” 我不太确定,但我猜想,尽管有什么建议,但这个答案实际上可能是合格的。
Deduplicator

9
这篇文章可能对Stack Overflow的新文档功能很有用-一旦打开Scala。
SL Barth-恢复莫妮卡
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.