เริ่มต้นกับ Akka Streams ได้อย่างไร? [ปิด]


222

ห้องสมุด Akka Streams มาพร้อมกับเอกสารมากมาย อย่างไรก็ตามปัญหาหลักสำหรับฉันก็คือมันมีเนื้อหามากเกินไป - ฉันรู้สึกว่าค่อนข้างหนักใจกับจำนวนแนวคิดที่ฉันต้องเรียนรู้ ตัวอย่างจำนวนมากที่แสดงให้เห็นว่ามีน้ำหนักมากและไม่สามารถแปลไปยังกรณีการใช้งานจริงได้อย่างง่ายดายดังนั้นจึงเป็นเรื่องที่ค่อนข้างลึกลับ ฉันคิดว่ามันให้รายละเอียดมากเกินไปโดยไม่ต้องอธิบายวิธีการสร้างบล็อคทั้งหมดเข้าด้วยกันและช่วยในการแก้ปัญหาเฉพาะอย่างแน่นอน

มีแหล่งที่มา, อ่าง, กระแส, ขั้นตอนกราฟ, กราฟบางส่วน, เป็นรูปเป็นร่าง, DSL กราฟิกและมากขึ้นและฉันก็ไม่รู้จะเริ่มต้นที่ไหน คู่มือเริ่มต้นอย่างรวดเร็วมีขึ้นเพื่อเป็นสถานที่เริ่มต้น แต่ผมไม่เข้าใจมัน มันแค่โยนในแนวคิดดังกล่าวข้างต้นโดยไม่ต้องอธิบายพวกเขา นอกจากนี้ตัวอย่างโค้ดไม่สามารถดำเนินการได้ - มีชิ้นส่วนที่ขาดหายไปซึ่งทำให้ฉันไม่สามารถติดตามข้อความได้มากหรือน้อย

ทุกคนสามารถอธิบายแหล่งที่มาของแนวคิดอ่างล้างมือโฟลว์ช่วงกราฟกราฟบางส่วนวัสดุเป็นรูปเป็นร่างและสิ่งอื่น ๆ ที่ฉันพลาดด้วยคำง่าย ๆ และมีตัวอย่างง่าย ๆ ที่ไม่สามารถอธิบายรายละเอียดทุกอย่างได้ การเริ่มต้น)?


2
สำหรับข้อมูลนี้กำลังมีการหารือในmeta
DavidG

10
ในฐานะที่เป็นคนแรกที่ลงคะแนนให้ปิดนี้ (ติดตามกระทู้ Meta) ให้ฉันก่อนพูดว่าคำตอบของคุณที่นี่ดีมาก เป็นเชิงลึกจริงๆและเป็นทรัพยากรที่มีประโยชน์มากอย่างแน่นอน อย่างไรก็ตามน่าเสียดายที่คำถามที่คุณถามนั้นกว้างเกินไปสำหรับ Stack Overflow ถ้าอย่างใดคำตอบของคุณสามารถโพสต์คำถามที่แตกต่างกันแล้วน่ากลัว แต่ฉันไม่คิดว่ามันจะเป็นไปได้ ฉันขอแนะนำอย่างยิ่งให้ทำการส่งต่อนี้เป็นโพสต์บล็อกหรือสิ่งที่คล้ายกันซึ่งคุณและผู้อื่นสามารถใช้เป็นแหล่งข้อมูลอ้างอิงในคำตอบในอนาคต
James Donnelly

2
ฉันคิดว่าการเขียนคำถามนี้เนื่องจากการโพสต์บล็อกจะไม่มีประสิทธิภาพ ใช่นี่เป็นคำถามที่กว้างขวาง - และเป็นคำถามที่ดีจริงๆ การ จำกัด ขอบเขตให้แคบจะไม่ทำให้ดีขึ้น คำตอบที่ให้นั้นยอดเยี่ยม ฉันแน่ใจว่า Quora ยินดีที่จะทำธุรกิจให้พ้นจากปัญหาใหญ่
Mike Slinn

11
@MikeSlinn อย่าพยายามพูดคุยกับคนที่เกี่ยวข้องกับคำถามที่เหมาะสมพวกเขาสุ่มสี่สุ่มห้าปฏิบัติตามกฎ ตราบใดที่คำถามไม่ได้ถูกลบออกฉันมีความสุขและไม่รู้สึกอยากย้ายไปที่แพลตฟอร์มอื่น
kiritsuku

2
@sschaef วิธีอวดความรู้ แน่นอนว่ากฎไม่มีค่าอะไรเลยตัวตนที่ยิ่งใหญ่ของคุณรู้ดีกว่ามากและทุกคนที่พยายามใช้กฎนั้นเป็นเพียงการติดตาม hype / พูดจาโผงผาง นี่เป็นส่วนเสริมที่ดีในเอกสารคู่มือเบต้าหากคุณอยู่ในนั้น คุณยังสามารถนำไปใช้และวางไว้ที่นั่น แต่อย่างน้อยคุณควรเห็นว่ามันไม่เหมาะสำหรับเว็บไซต์หลัก
Félix Gagnon-Grenier

คำตอบ:


506

คำตอบนี้จะขึ้นอยู่กับรุ่นakka-stream 2.4.2API อาจแตกต่างกันเล็กน้อยในเวอร์ชันอื่น การพึ่งพาสามารถใช้ได้โดยsbt :

libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.4.2"

เอาล่ะเริ่มกันเลย API ของ Akka Streams ประกอบด้วยสามประเภทหลัก ตรงกันข้ามกับReactive Streamsประเภทนี้มีประสิทธิภาพมากกว่าและซับซ้อนกว่ามาก มันจะสันนิษฐานว่าสำหรับตัวอย่างโค้ดทั้งหมดมีคำจำกัดความต่อไปนี้อยู่แล้ว:

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แสดงถึงบริบทการประเมินของสตรีม ในกรณีของเราเราใช้ a ActorMaterializerซึ่งหมายความว่ามีการประเมินสตรีมด้านบนของนักแสดง ค่าทั้งสองถูกทำเครื่องหมายเป็นimplicitซึ่งทำให้คอมไพเลอร์ Scala มีความเป็นไปได้ที่จะฉีดการพึ่งพาทั้งสองนี้โดยอัตโนมัติเมื่อใดก็ตามที่มีความจำเป็น เรายังนำเข้าซึ่งเป็นบริบทการดำเนินการสำหรับsystem.dispatcherFutures

API ใหม่

Akka Streams มีคุณสมบัติที่สำคัญเหล่านี้:

  • พวกเขาใช้ข้อมูลจำเพาะของReactive Streamsซึ่งมีสามเป้าหมายหลักคือ backpressure, async และ non-blocking boundary และการทำงานร่วมกันระหว่างการใช้งานที่แตกต่างกัน
  • Materializerพวกเขาให้เป็นนามธรรมสำหรับเครื่องมือการประเมินผลสำหรับลำธารซึ่งเรียกว่า
  • โปรแกรมมีสูตรเป็นกลุ่มอาคารที่นำมาใช้ใหม่ซึ่งจะแสดงเป็นสามประเภทหลักSource, และSink FlowBuilding Block สร้างกราฟซึ่งการประเมินจะยึดตาม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วิธีการที่เราสามารถสร้างจุดหยุดเทียมที่ป้องกันเราจากการประเมินอย่างไม่มีกำหนด เนื่องจากการสนับสนุนนักแสดงนั้นติดตั้งอยู่ภายในเราจึงสามารถป้อนสตรีมด้วยข้อความที่ส่งถึงนักแสดงได้อย่างง่ายดาย:

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เราจึงสามารถกำหนดให้กระแสควรล้มเหลวในบัฟเฟอร์ล้น โดยเฉพาะอย่างยิ่งผ่านอินเทอร์เฟซนักแสดงนี้เราสามารถป้อนกระแสข้อมูลผ่านแหล่งข้อมูลใด ๆ ไม่สำคัญว่าข้อมูลจะถูกสร้างโดยเธรดเดียวกันหรืออีกอันโดยกระบวนการอื่นหรือว่ามาจากระบบระยะไกลผ่านอินเทอร์เน็ต

จม

เป็นพื้นตรงข้ามของที่Sink 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

การเชื่อมต่อ a Sourceกับ a Sinkสามารถทำได้ด้วยtoวิธีการ มันจะคืนค่าการเรียกดังนั้นRunnableFlowซึ่งเป็นที่เราจะเห็นในภายหลังในรูปแบบพิเศษของFlow- กระแสที่สามารถดำเนินการได้โดยเพียงแค่เรียกrun()วิธีการของมัน

Flow ที่ไหลได้

ภาพที่นำมาจาก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

ไหล

แหล่งข้อมูลและ sinks นั้นยอดเยี่ยมถ้าคุณต้องการการเชื่อมต่อระหว่าง Akka stream และระบบที่มีอยู่ แต่ก็ไม่สามารถทำอะไรกับมันได้ โฟลว์เป็นชิ้นสุดท้ายที่ขาดหายไปในสิ่งที่เป็นนามธรรมของ Akka Streams พวกเขาทำหน้าที่เป็นตัวเชื่อมต่อระหว่างกระแสต่าง ๆ และสามารถใช้เพื่อแปลงองค์ประกอบ

ไหล

ภาพที่นำมาจากboldradius.com

หากมีการFlowเชื่อมต่อกับSourceใหม่Sourceเป็นผล ในทำนองเดียวกันFlowการเชื่อมต่อกับสร้างใหม่Sink SinkและFlowเชื่อมต่อกับทั้งสองSourceและผลลัพธ์ในSink RunnableFlowดังนั้นพวกเขานั่งอยู่ระหว่างอินพุทและเอาท์พุทช่อง แต่ด้วยตัวเองไม่ตรงกับหนึ่งในรสชาติตราบเท่าที่พวกเขาไม่ได้เชื่อมต่อกับทั้งหรือSourceSink

สตรีมแบบเต็ม

ภาพที่นำมาจาก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วิธีการที่เราสามารถเชื่อมต่อกับSource 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เป็นตัวแทนสตรีมใหม่ทั้งหมด - พวกเขาจะไม่แบ่งปันข้อมูลใด ๆ ผ่านทางแบบเอกสารสำเร็จรูป

สตรีมข้อมูลที่ไม่ จำกัด

ก่อนที่เราจะก้าวต่อไปเราควรทบทวนประเด็นสำคัญบางประการของปฏิกิริยาโต้ตอบ องค์ประกอบจำนวนมากมายสามารถมาถึงที่จุดใดก็ได้และสามารถทำให้กระแสในรัฐที่แตกต่างกัน นอกเหนือจากสตรีมที่รันได้ซึ่งเป็นสถานะปกติสตรีมอาจหยุดทำงานไม่ว่าจะเกิดข้อผิดพลาดหรือผ่านสัญญาณที่ระบุว่าจะไม่มีข้อมูลเพิ่มเติมมาถึง สตรีมสามารถสร้างแบบจำลองในลักษณะกราฟิกโดยทำเครื่องหมายกิจกรรมบนไทม์ไลน์ตามที่เป็นจริงที่นี่:

แสดงให้เห็นว่ากระแสเป็นลำดับของเหตุการณ์ต่อเนื่องที่สั่งในเวลา

ภาพที่นำมาจากการแนะนำในการเกิดปฏิกิริยาการเขียนโปรแกรมที่คุณได้รับหายไป

เราได้เห็นกระแสที่ไหลได้แล้วในตัวอย่างของส่วนก่อนหน้า เราได้รับRunnableGraphเมื่อใดก็ตามที่กระแสสามารถจริงจะปรากฏซึ่งหมายความว่ามีการเชื่อมต่อไปยังSink Sourceจนถึงตอนนี้เรามักจะปรากฏให้เห็นถึงคุณค่า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เนื่องจากค่านี้สามารถแสดงได้เมื่อกระแสที่เชื่อมต่อกับอ่างล้างมือได้สิ้นสุดลงแล้ว จนถึงตอนนี้ตัวอย่างโค้ดก่อนหน้านี้เป็นสิ่งที่ดีที่จะอธิบายแนวคิด แต่พวกเขาก็น่าเบื่อเพราะเราจัดการเฉพาะกับสตรีมที่ จำกัด หรือกับอินฟินิทที่ง่ายมาก เพื่อให้น่าสนใจยิ่งขึ้นในการอธิบายการสตรีมแบบอะซิงโครนัสและแบบไม่ จำกัด ที่เต็ม

คลิกสตรีมตัวอย่าง

ตัวอย่างเช่นเราต้องการให้สตรีมที่จับภาพเหตุการณ์การคลิก เพื่อให้ท้าทายยิ่งขึ้นสมมติว่าเราต้องการจัดกลุ่มการคลิกเหตุการณ์ที่เกิดขึ้นในระยะเวลาอันสั้นหลังจากกัน วิธีนี้เราสามารถค้นหาการคลิกสองครั้งสามครั้งหรือสิบเท่าได้อย่างง่ายดาย นอกจากนี้เราต้องการกรองการคลิกทั้งหมดทั้งหมด หายใจเข้าลึก ๆ และจินตนาการว่าคุณจะแก้ปัญหาอย่างไรในลักษณะที่จำเป็น ฉันเดิมพันว่าจะไม่มีใครสามารถใช้โซลูชันที่ทำงานอย่างถูกต้องในการลองครั้งแรก ในรูปแบบที่ตอบโต้ปัญหานี้เป็นเรื่องที่แก้ไม่ได้ ในความเป็นจริงการแก้ปัญหานั้นง่ายและตรงไปตรงมาเพื่อให้เราสามารถแสดงได้ในแผนภาพที่อธิบายพฤติกรรมของรหัสโดยตรง:

ตรรกะของตัวอย่างสตรีมการคลิก

ภาพที่นำมาจากการแนะนำในการเกิดปฏิกิริยาการเขียนโปรแกรมที่คุณได้รับหายไป

กล่องสีเทาเป็นฟังก์ชั่นที่อธิบายถึงวิธีการที่กระแสหนึ่งถูกเปลี่ยนเป็นอื่น ด้วยthrottleฟังก์ชั่นที่เราได้รับคลิกภายใน 250 มิลลิวินาทีmapและ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 ซึ่งไม่สามารถจับภาพเหตุการณ์การคลิกได้อย่างง่ายดาย ภาวะแทรกซ้อนอื่นคือ Akka โดยค่าเริ่มต้นไม่มีthrottleฟังก์ชั่น แต่เราต้องเขียนด้วยตัวเอง เนื่องจากฟังก์ชั่นนี้เป็น (เพราะเป็นกรณีของmapหรือfilterฟังก์ชั่น) นำมาใช้ซ้ำในกรณีการใช้งานที่แตกต่างกันฉันไม่นับบรรทัดเหล่านี้เป็นจำนวนบรรทัดที่เราจำเป็นต้องใช้ตรรกะ อย่างไรก็ตามในภาษาที่จำเป็นนั้นเป็นเรื่องปกติที่ตรรกะไม่สามารถนำกลับมาใช้ใหม่ได้อย่างง่ายดายและขั้นตอนทางตรรกะที่แตกต่างกันเกิดขึ้นในที่เดียวแทนที่จะใช้ตามลำดับซึ่งหมายความว่าเราอาจจะพลาดโค้ดของเราด้วยตรรกะการควบคุมปริมาณ ตัวอย่างโค้ดแบบเต็มมีให้ในแบบส่วนสำคัญและจะไม่ถูกกล่าวถึงที่นี่อีกต่อไป

ตัวอย่าง SimpleWebServer

สิ่งที่ควรพิจารณาแทนเป็นอีกตัวอย่างหนึ่ง ในขณะที่กระแสการคลิกเป็นตัวอย่างที่ดีที่ให้ Akka Streams จัดการตัวอย่างในโลกแห่งความเป็นจริง แต่ขาดพลังในการแสดงการดำเนินการแบบขนาน ตัวอย่างถัดไปจะแสดงถึงเว็บเซิร์ฟเวอร์ขนาดเล็กที่สามารถจัดการคำขอได้หลายคำขอพร้อมกัน เว็บเซิร์ฟเวอร์จะสามารถยอมรับการเชื่อมต่อขาเข้าและรับลำดับไบต์จากสิ่งเหล่านั้นที่เป็นสัญญาณ ASCII ที่พิมพ์ได้ ลำดับไบต์หรือสตริงเหล่านี้ควรแยกอักขระบรรทัดใหม่ทั้งหมดออกเป็นส่วนย่อย ๆ หลังจากนั้นเซิร์ฟเวอร์จะตอบกลับไปยังไคลเอนต์ด้วยแต่ละบรรทัดการแยก หรืออาจทำอย่างอื่นกับเส้นและให้โทเค็นคำตอบพิเศษ แต่เราต้องการทำให้มันง่ายในตัวอย่างนี้และดังนั้นจึงไม่แนะนำคุณสมบัติแฟนซีใด ๆ โปรดจำไว้ว่า เซิร์ฟเวอร์จะต้องสามารถจัดการคำขอหลายรายการได้ในเวลาเดียวกันซึ่งโดยทั่วไปหมายความว่าไม่มีการอนุญาตให้บล็อกคำขออื่นใดจากการดำเนินการต่อไป การแก้ไขข้อกำหนดเหล่านี้ทั้งหมดอาจทำได้ยากด้วยวิธีที่จำเป็น - ด้วย Akka Streams อย่างไรก็ตามเราไม่ควรต้องมีมากกว่าสองสามบรรทัดเพื่อแก้ปัญหาเหล่านี้ อันดับแรกเรามาดูภาพรวมของเซิร์ฟเวอร์กัน:

เซิร์ฟเวอร์

โดยพื้นฐานมีหน่วยการสร้างหลักเพียงสามรายการเท่านั้น คนแรกต้องยอมรับการเชื่อมต่อเข้ามา คนที่สองต้องการจัดการคำขอที่เข้ามาและคนที่สามต้องส่งคำตอบ การใช้ Building Block ทั้งสามนี้มีความซับซ้อนเพียงเล็กน้อยกว่าการติดตั้งสตรีมคลิก:

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ใช้เวลา (นอกเหนือจากที่อยู่และพอร์ตของเซิร์ฟเวอร์) ยังเป็นระบบนักแสดงและ materializer เป็นพารามิเตอร์โดยนัย โฟลว์การควบคุมของเซิร์ฟเวอร์ถูกแสดงโดย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การไหลที่เกิดได้มีการผลิตByteString 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(_))

การทดสอบเว็บเซิร์ฟเวอร์อาจมีลักษณะเช่นนี้:

$ # 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 นี้ดูยุ่งยากกว่า Flow API มาก ไม่น่าแปลกใจที่เราต้องทำตามขั้นตอนที่จำเป็นมากมายที่นี่ ในการแลกเปลี่ยนเราสามารถควบคุมพฤติกรรมของสตรีมของเราได้มากขึ้น ในตัวอย่างข้างต้นเราระบุเพียงหนึ่งอินพุตและหนึ่งพอร์ตออกและทำให้พร้อมใช้งานสำหรับระบบโดยการแทนที่shapeค่า นอกจากนี้เรายังได้นิยามสิ่งที่เรียกว่าInHandlerและ a 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ส่วนประกอบ สำหรับการแนะนำในเชิงลึกกับ DSL กราฟของ Akka ลำธารเยี่ยมชมส่วนที่สอดคล้องกันในเอกสารอย่างเป็นทางการ ตัวอย่างรหัสเต็มรูปแบบของเซิร์ฟเวอร์ TCP ซับซ้อนและของไคลเอนต์ที่สามารถสื่อสารกับมันสามารถพบได้ที่นี่ เมื่อใดก็ตามที่คุณเปิดการเชื่อมต่อใหม่จากไคลเอนต์คุณจะเห็นข้อความต้อนรับและโดยการพิมพ์"q"บนไคลเอนต์คุณควรเห็นข้อความที่บอกคุณว่าการเชื่อมต่อถูกยกเลิก

ยังมีบางหัวข้อที่ไม่ได้ครอบคลุมโดยคำตอบนี้ โดยเฉพาะอย่างยิ่งการทำให้เป็นรูปเป็นร่างอาจทำให้ผู้อ่านคนหนึ่งหรืออีกคนกลัว แต่ฉันแน่ใจว่าเนื้อหาที่กล่าวมานี้ทุกคนควรจะสามารถไปขั้นตอนต่อไปได้ด้วยตนเอง ดังที่ได้กล่าวไปแล้วเอกสารอย่างเป็นทางการเป็นสถานที่ที่ดีในการเรียนรู้เกี่ยวกับ Akka Streams ต่อไป


4
@monksy ฉันไม่ได้วางแผนที่จะเผยแพร่สิ่งนี้ที่อื่น อย่าลังเลที่จะเผยแพร่สิ่งนี้บนบล็อกของคุณหากคุณต้องการ ทุกวันนี้ API มีความเสถียรในทุกส่วนซึ่งหมายความว่าคุณอาจไม่ต้องสนใจเรื่องการบำรุงรักษา (บทความในบล็อกส่วนใหญ่เกี่ยวกับ Akka Streams นั้นล้าสมัยไปแล้วเนื่องจากมันแสดง API ที่ไม่มีอยู่อีกต่อไป)
kiritsuku

3
มันจะไม่หายไป ทำไมจึงเป็นเช่นนั้น?
kiritsuku

2
@sschaef มันอาจหายไปเพราะคำถามอยู่นอกหัวข้อและถูกปิดเช่นนี้
DavidG

7
@Magisch โปรดจำไว้เสมอ: "เราไม่ได้ลบเนื้อหาที่ดี" ฉันไม่แน่ใจ แต่ฉันคิดว่าคำตอบนี้อาจมีคุณสมบัติแม้ว่าจะมีทุกอย่าง
Deduplicator

9
โพสต์นี้อาจดีสำหรับคุณสมบัติเอกสารใหม่ของ Stack Overflow - ทันทีที่เปิดสำหรับ Scala
SL Barth - Reinstate Monica
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.