使用Scala开发现代应用程序:响应式应用程序

本文是我们名为“ 使用Scala开发现代应用程序 ”的学院课程的一部分。

在本课程中,我们提供一个框架和工具集,以便您可以开发现代的Scala应用程序。 我们涵盖了广泛的主题,从SBT构建和响应式应用程序到测试和数据库访问。 通过我们简单易懂的教程,您将能够在最短的时间内启动并运行自己的项目。 在这里查看

1.简介

在过去的几年中,每天有数百万人甚至数十亿人使用的许多软件系统已经开始面临前所未有的可伸缩性要求。 在许多方面,传统的软件体系结构被推到了极限,这表明迫切需要提出其他更适合现代世界需求的体系结构样式。

这是另一个范式, 反应式编程 ,开始出现并Swift普及的时刻。 与函数式编程一起反应式编程已经震动了整个行业,并发现了全新的一类应用程序,这些天我们称之为反应式应用程序

2.积极主动

根据反应式编程范式构建应用程序意味着遵循不同的建筑风格和设计原则,这在JonasBonér于2013年左右出版的The Reactive Manifesto中得到了最好的描述。

  • 响应:系统尽可能及时响应。
  • 弹性:面对故障时,系统保持响应能力
  • 弹性:系统在变化的工作负载下保持响应能力。
  • 消息驱动:响应系统依靠异步消息传递在组件之间建立边界,以确保松散耦合,隔离和位置透明。

不用说,这些原则中的每一个都具有很大的意义,它们一起代表了构建现代软件应用程序的完美秘诀。 但是可以肯定的是,那里没有银弹。 没有魔术会突然使任何应用程序或系统反应。 它是异步编程和非阻塞编程的组合,消息传递并发性辅以不变性和反压力,仅举几个关键的例子。

在本教程中,我们将学习使用Scala编程语言和生态系统开发真正的反应式应用程序的所有必要构造块,在本节中重点介绍第一个反应式流

3.反应流规范

反应性流的目的是为无阻塞背压的异步流处理打下坚实的基础。 在这方面, 反应流规范是提供可互操作标准的持续努力,该标准将遵循遵循的实现。

反应流规范严格按照The Reactive Manifesto概述的原则编写,并且已经发布了JVM平台的第一个正式版本1.0.0

4.野外的反应流

面向JVM平台的官方反应式流 API公开后,许多非常受欢迎的开源项目宣布立即提供兼容的实现。 尽管完整列表中包括一打,但这里仅是一些最知名的:

值得一提的是, RxJava是最早的也是最先进的JVM库之一,它向Java开发人员介绍了反应式编程范例的强大原理。 尽管它也具有Scala语言的端口RxScala ,但我们将专注于Akka Streams ,这是基于纯Scala反应流规范的纯实现。

Akka流:反应式流实现

正如我们已经简要提到的那样, Akka Streams只是更全面的Akka Toolkit发行版的一部分。 在撰写本文时, Akka Toolkit的最新发行版本是2.4.8 ,这是本节其余部分中将使用的版本。

但是,如果您的Akka Toolkit版本不是最新版本,请不要惊慌,因为相当长的时间, Akka Streams提供了反应流规范的实现。

基本概念

Akka Streams建立在几个基本部分的基础之上,这些基本部分可以互操作,并且可以描述非常复杂的流处理管道。

  • :仅有一个输出流的对象(从概念上讲,代表发布者
  • 接收器 :仅有一个输入流的对象(从概念上讲,它代表一个Subscriber
  • :仅具有一个输入流和一个输出流的对象(概念上代表一个Processor
  • BidiFlow :恰好具有两个输入流和两个输出流的东西
  • :接受某些输入并公开某些输出的流处理拓扑

对于好奇的读者, Akka Streams完全实现了反应流规范,但将此事实隐藏在更简洁的面向用户的API抽象之后,引入了自己的基本原语。 这就是为什么如果查看JVM的反应式流 API的原因,尽管支持相应的转换,却可能找不到与Akka Streams的直接匹配。

Akka Streams的主要设计目标之一是可重用性。 我们很快就会看到,上述所有构建模块都可以共享或组成更复杂的流处理拓扑。

物化

Akka Streams在流定义和实际流执行之间进行了非常清晰的区分。 在Akka Streams术语中,采用任意流定义并提供运行所需的所有必要资源的机制称为实现。

本质上,实现器实现可以是任何东西,但默认情况下, Akka Streams提供了ActorMaterializer ,它基本上使用Actor映射了不同的处理阶段。 这也意味着一般来说,流处理将完全是异步的并且由消息驱动。

在本教程的后续部分“并发和并行性:Akka”中 ,我们将详细讨论ActorActor模型 。 幸运的是, Akka Streams通过向我们隐藏不必要的抽象而做得非常好,这样演员就不会在ActorMaterializer之外的其他地方弹出。

源和汇

输入数据是任何流处理的起点。 它可以是任何东西:文件,集合,网络套接字,流,将来,只要您命名。 在Akka Streams API中,此输入由参数化的Source [+ Out,+ Mat]类表示,其中:

  • 输出是源输出元素的类型
  • Mat是来源可能产生的一些附加值的类型(通常设置为NotUsed,但稍后会更多)

为了方便起见, Source对象具有许多工厂方法,这些方法简化了将典型输入包装到相应Source类实例中的过程,例如:

val source: Source[Int, NotUsed] = Source(1 to 10)

val source: Source[Int, NotUsed] = Source(Set(1, 2, 3, 4, 5))

val source: Source[String, NotUsed] = Source.single("Reactive Streams")

val source: Source[ByteString, _] = FileIO.fromPath(file)

val source: Source[Int, _] = Source.tick(1 second, 10 seconds, Random.nextInt())

具有输入已经足以开始简单的流处理。 但是,正如我们已经知道的那样,定义Source直到实际实现时才真正做任何事情。 Source类(以及其他一些类似Flow fe的类)具有一系列所谓的终端函数: run()runWith() 。 对该函数中任何一个的调用都会触发实现过程,要求隐式或显式提供实现器。 例如:

implicit val system = ActorSystem("reactive-streams")
implicit val materializer = ActorMaterializer()
  
val numbers = List(100, 200, 300, 400, 500)
val source: Source[Int, NotUsed] = Source(numbers)
  
source
  .runForeach { println _ }
  .onComplete { _ => system.terminate() }

一旦流处理的执行终止,每个数字都将在控制台上打印出来:

100
200
300
400
500

有趣的是,在runForeach调用runForeach的代码段中,使用了另一个Akka Streams抽象Sink ,它实际上是不同阶段中流输入的使用者。 因此我们的示例可以这样重写:

source
  .runWith { Sink.foreach { println _ } }
  .onComplete { _ => system.terminate() }

正如您肯定希望的那样, Source类支持广泛的函数来转换或处理流元素,这些函数在Akka Streams中称为处理阶段,例如:

source
  .map { _ * 2 }
  .runForeach { println _ }
  .onComplete { _ => system.terminate() }

请注意,处理阶段从不修改当前流定义,而是返回新的处理阶段。 最后但并非最不重要的一点: Akka Streams不允许null作为元素在流中流过,来自Java背景的人们应该特别注意这一点。

流量

如果SourceSink只是对输出和输入的抽象,那么Flow是这种胶水,本质上将它们连接在一起。 让我们回到带有数字的示例中,但是这次我们将从文件中读取它们。

val file: Path = Paths.get(getClass.getResource("/numbers.txt").toURI())
val source: Source[ByteString, _] = FileIO.fromPath(file)

Numbers.txt只是一个普通的旧文本文件,其中每行包含一些任意数字,例如:

100
200
300
400
500

这听起来很琐碎,但让我们看一下SourceOut类型:它实际上是ByteString (更确切地说,将以ByteString块形式读取文件)。 这不是我们真正想要的,我们想逐行,逐个读取文件,但是我们该怎么做呢? 幸运的是, Akka StreamsFraming的形式提供了开箱即用的支持,我们只需要定义从ByteString流到常规整数流的转换即可:

val flow: Flow[ByteString, Int, _] = Flow[ByteString]
  .via(Framing.delimiter(ByteString("\r\n"), 10, true))
  .map { _.utf8String.toInt }

在这里,我们刚刚定义了Flow ! 它不附加,也不附加,也不附加。 但是,任何流处理定义都可以重用它,例如:

source
  .via(flow)
  .filter { _ > 200 }
  .runForeach { println _ }

或者,我们可以定义另一个流处理管道,只是为了计算总体上已经处理了多少行。 这是Mat类型的方便之处,因为我们将使用Sink之一的值作为最终结果,例如:

val future: Future[Int] = source
  .via(flow)
  .toMat(Sink.fold[Int, Int](0){(acc, _) => acc + 1 })(Keep.right)
  .run
  
future.onSuccess { case count => 
  println(s"Lines processed: $count") 
}

很简单,不是吗? 值得一提的是有关元素顺序保证的重要说明: Akka Streams在大多数情况下会保留元素的输入顺序(但某些处理阶段可能不会这样做)。

图和BidiFlows

虽然Flow是真正强大的抽象,但它仅限于一个输入和一个输出。 对于许多用例来说可能就足够了,但是复杂的流处理方案需要更大的灵活性。 让我们开始介绍GraphBidiFlow

可以使用任意数量的输入和输出,并形成真正复杂的拓扑,但是总的来说,它们是由简单的Flow组成的。 为了说明Graph的作用,让我们考虑这个示例。 该组织已准备好向其员工发放年度奖金,但是所有管理职位将获得1万美元的基本工资奖金,而其他职位仅获得5000美元的奖金。 下图显示了完整的流处理。

图形

通过具有表现力的Graph DSL, Akka Streams使构建非常复杂的场景变得非常简单,尽管我们还很幼稚。

val employees = List(
  Employee("Tom", "manager", 50000),
  Employee("Bob", "employee", 20000),
  Employee("Mark", "employee", 20000),
  Employee("John", "manager", 55000),
  Employee("Dilan", "employee", 35000)      
)

val graph = GraphDSL.create() { implicit builder => 
  import GraphDSL.Implicits._
   
  val in = Source(employees)
  val out = Sink.foreach { println _ }

  val broadcast = builder.add(Broadcast[Employee](2))
  val merge = builder.add(Merge[Employee](2))

  val manager = Flow[Employee]
    .filter { _.position == "manager" }
    .map { e => e.copy(salary = e.salary + 10000) } 
    
  val employee = Flow[Employee]
    .filter { _.position != "manager" }
    .map { e => e.copy(salary = e.salary + 5000) }

  in ~> broadcast ~> manager  ~> merge ~> out
        broadcast ~> employee ~> merge
          
  ClosedShape
}

Graph末尾的ClosedShape意味着我们已经定义了一个完全连接的图,所有输入和输出均已插入。完全连接的图可以转换为RunnableGraph并实际执行,例如:

RunnableGraph
  .fromGraph(graph)
  .run

图形执行完成后,我们将在控制台中看到预期的输出。 每个人的工资都有各自的奖金:

Employee(Tom,manager,60000)
Employee(Bob,employee,25000)
Employee(Mark,employee,25000)
Employee(John,manager,65000)
Employee(Dilan,employee,40000)

与大多数其他基本部分一样, GraphRunnableGraph是可自由共享的。 其结果之一是能够构造局部图并将不同的SourceSinkFlow组合在一起的能力。

在示例中,到目前为止,我们仅看到数据在一个方向上流过流。 BidiFlow是该图的特例,其中有两个方向相反的流。 BidiFlow的最佳说明是典型的请求/响应通信,例如:

case class Request(payload: ByteString)
case class Response(payload: ByteString)
  
val server = GraphDSL.create() { implicit builder => 
  import GraphDSL.Implicits._
   
  val out = builder.add(Flow[Request].map { _.payload.utf8String })
  val in = builder.add(Flow[String].map {s => Response(ByteString(s.reverse))})

  BidiShape.fromFlows(out, in)
}

在这个简单的示例中,请求的有效负载仅被反转并包装为响应有效负载。 server实际上是一个图,要创建BidiFlow实例,我们必须使用fromGraph工厂方法,如下所示:

val bidiFlow = BidiFlow.fromGraph(server)

现在,我们可以通过提供一个简单的请求并将两个bidiFlow流直接连接在一起,来实现并运行BidiFlow实例。

Source
  .single(Request(ByteString("BidiFlow Example")))
  .via(bidiFlow.join(Flow[String]))
  .map(_.payload.utf8String)
  .runWith(Sink.foreach { println _ })
  .onComplete { _ => system.terminate() }

毫不奇怪,但是“ BidiFlow Example”字符串的反向版本将在控制台中打印出来:

elpmaxE wolFidiB

背压

背压的概念是反应流理论的基础之一。 Akka Streams尽最大努力通过确保发布者永远不会发布超出订阅者能力的元素来保持流处理的健康。 除了控制需求并使背压从下游流向上游传播之外, Akka Streams还支持使用缓冲和节流功能来实现更细粒度和高级的背压管理。

处理错误

在或多或少的现实场景中,使用Akka Streams构建的流处理管道将引用特定于应用程序的逻辑,包括数据库访问或外部服务调用。 错误可能并且将发生,导致流处理过早终止。

幸运的是, Akka Streams提供了至少三种策略来处理在应用程序代码执行过程中引发的异常:

  • 停止:流失败完成(默认策略)
  • 恢复:元素被删除,流继续
  • 重新启动:元素被删除,并且流在重新启动阶段后继续

这些因素在很大程度上受到Akka Streams用于实现流处理流程的Actor模型的影响,也称为监督策略。 为了说明它是如何工作的,让我们考虑一个简单的流,该流使用数字作为源,并在每次遇到偶数时引发异常。

val source = Source
  .unfold(0) { e => Some(e + 1, e + 1) }
  .map { e => if (e % 2 != 0) e else throw new IllegalArgumentException("Only odd numbers are allowed") }
  .withAttributes(ActorAttributes.supervisionStrategy(_ => Supervision.Resume))
  .take(10)
  .runForeach { println _ }
  .onComplete { _ => system.terminate() }

在没有监督的情况下,流将在发出数字2时结束。 但是,通过恢复策略,流从其离开的位置继续执行,跳过了有问题的元素。 如预期的那样,我们应该在控制台中仅看到奇数:

1
3
5
7
9
11
13
15
17
19

测试中

正如我们将要看到的那样, Scala社区开发的大多数框架和库都是在高级测试支持下构建的。 Akka Streams当然也不例外,它提供了专用的测试套件来开发综合测试方案。 为了展示这一点,让我们回到在“ 流程”部分中定义的样本流程。

val flow: Flow[ByteString, Int, _] = Flow[ByteString]
  .via(Framing.delimiter(ByteString("\r\n"), 10, true))
  .map { _.utf8String.toInt }

拥有一个测试用例来验证框架是否完全符合我们的预期听起来不错,因此让我们创建一个。

class FlowsSpec extends TestKit(ActorSystem("reactive-streams-test")) 
    with SpecificationLike with FutureMatchers with AfterAll {
  
  implicit val materializer = ActorMaterializer()
  def afterAll = system.terminate()

  "Stream" >> {
    "should return an expected value" >> {   
      val (publisher, subscriber) = TestSource.probe[ByteString]
        .via(flow)
        .toMat(TestSink.probe[Int])(Keep.both)
        .run()
        
      subscriber.request(2)
      publisher.sendNext(ByteString("20"))  
      publisher.sendNext(ByteString("0\r\n"))
      publisher.sendComplete()
      
      subscriber.expectNext() must be_==(200)
    }
  }
}

诸如TestSourceTestSink (以及更多)之类的类可以完全控制流处理,因此可以测试非常复杂的管道。 另外, Akka Streams并没有要求使用测试框架,因此我们提出的示例是一个典型的Specs2规范。

6。结论

本节只是对反应式编程 (特别是反应式流)的世界的简要介绍。 尽管我们已经谈论了很多Akka Streams ,但我们只涉及了其中的一小部分,只是触及了冰山一角:一长串的功能和内在细节未被发现。 Akka Streams官方文档是有关该主题的全面知识的绝佳来源,其中包含许多示例。 请不要犹豫,通过它。

7.接下来

在本教程下一部分中,我们将讨论从您的Scala应用程序访问关系数据库。 本节的主题将很有用,因为我们将看到反应式流在支持某些数据库访问模式中的重要性。

完整的项目可下载

翻译自: https://www.javacodegeeks.com/2016/07/developing-modern-applications-scala-reactive-applications.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值