Scala 之 Akka 框架实战

4,029 阅读18分钟

在本章,笔者会介绍如何在 IntelliJ IDEA 中利用 SBT 来构建一个完整的 Scala 项目(就像使用 Maven 构建一个 Java 项目那样),并且了解一个重要的并发程序框架 —— Akka。Spark 在底层通过 Akka 来调度 Master 和 Worker 之间的工作,轻量级Web 框架 Play! 的异步工作同样使用了 Akka 去完成。

有关 SBT 工具的下载和搭建参考另一篇掘金文章:Play2 / sbt 操作指南

Akka

Akka 是 Java 虚拟机平台用于构建高并发,分布式和容错应用的工具包,可以将 Akka 理解成用于编写并发程序的框架。

Akka 使用 Scala 语言编写而成,同时也面向 Java 提供相关的 API 。Akka 在分布式系统中应用地很广泛,譬如,Spark 的 driver 和 worker 之间的底层通讯就是通过 Akka 完成的。

Akka 主要的解决问题:轻松地写出高效稳定的并发程序,程序员不需要过多地考虑线程,锁和资源竞争的细节。

📖Akka中文社区(基于2.3.6版本)

这里的并发是更加广义的概念:即集群并发,而非是单机内的多线程并发。

Akka 的核心角色 —— Actor 模型

处理并发问题的关键是:保证可共享数据的一致性正确性,因为程序在多线程环境下运行时,多个线程对同一个数据进行修改,如果不加同步锁,则会造成读脏现象。

或许我们会尝试着在关键代码中加入 synchronized 锁,但是这样的代码在高并发环境下就会长时间处于阻塞状态,对程序效率产生很大的影响。若选择串行处理,虽然保证了数据的一致性,但是牺牲了系统的性能。

而Actor模型的出现则解决了这个问题:它简化了并发编程,又提升了程序的性能。下面是 Actor 系统的示意图:

Actor_System.png

在这个基于 Actor 的系统当中,所有的事物都是 Actor,就好像 OOP 中所有的实物都是一个 Object。Actor 之间通过消息来进行通信(即示意图中的"信封")。

每个 Actor 的"邮箱":mailbox 按照队列的有序形式来接收其它 Actor 发送给它的消息。如何处理消息由接收方来决定。而消息发送方可以选择等待回复,也可以选择异步处理其它任务(比如前端使用的Ajax)。

所有的 Actor 由 ActorSystem 创建并管理。ActorSystem 是单例的,一个主线程只会有一个。而 Actor 会有多个(可以将 ActorSystem 理解成是一个生产 Actor 的工厂)。

Actor 对并发模型提供了更高的抽象,是异步,非阻塞,高性能的时间驱动编程模型。它是一个轻量级的时间处理模型(1 GB 内存可以容纳百万个 Actor),因此处理大并发情况下的性能高。(总体而言,这很像 Go 语言当中的缓冲通道通信 + 轻量级 goroutine 的并发方案)

使用 Ajax 来说明异步编程

在 Ajax 技术诞生之前(2005 年之前),当浏览器向服务器中请求一个内容(数据/图片等)时,在服务器将它们返回之前,浏览器都处于等待(阻塞)状态,而不会向下继续执行 JavaScript 脚本来渲染页面。这就导致浏览器端有很长一段时间什么内容都不会显示

这对于当时的用户来说是很糟糕的体验:因为在页面完全加载完毕之前,他们在浏览器端得不到任何的反馈

without_ajax.png

直到 Ajax 技术的诞生,用户体验得到了极大的提升:一般向服务器端的数据请求是需要耗费时间的,那么则使用 Ajax 来发送一个异步请求,并允许浏览器继续执行 JavaScript 脚本来渲染页面的其它部分。直到得到服务器端的响应之后(XMLHttpRequest 的状态为 4),再通过准备好的回调函数(callback function)对数据进行处理(比如进行 dom 操作)。

with_ajax.png

Actor 模型工作机制的简要说明

当 Actor A要给Actor B ( 下文简称为 A 和 B )发送消息时,会首先找到B的代理B ActorRef (而不是直接发送到B)。另外,这个消息也并没有直接发送到B的“邮箱”mailbox当中,而是由一个消息中转站 Message Dispatcher进行了处理。

Message Dispatcher 根据这条消息所标注的发送方,转发给 B 的 mailbox 当中。注意,这个 mailbox 相当于是一个消息队列,遵守 FIFO 原则。

actora2b.png

而 B 通过 receive 方法来处理从自己的 mailbox 中接收到的消息,并且可以通过 sender 方法直接对消息的发送方发出回应。

然而对于程序开发者来说,ActorSystem 系统的 Message Dispatcher 和各个 Actor 的 Mailbox 是隐藏的。因此程序员只需要知道一件事:想要给哪个 Actor 发送消息,就找到哪个 Actor 的代理 ActorRef 就可以了

*注意,这些 Actor 之间未必全部在本机运行。如果是跨机器的通讯,则会使用到 ActorPath 工具去转发消息。

使用 SBT 启动一个Akka项目

该 SBT 项目所使用的 Scala 版本是2.12.4(这个版本不一定对应本机的 SDK 版本,IntelliJ IDEA 会根据 SBT 实际配置的 Scala 版本去下载对应 SDK),对应 Akka 的最低版本是 2.4.12,最新版是 2.6.x。在这里我们选择最保守的 2.4.12

scalaVersion := "2.12.4"

resolvers += "Typesafe Repository" at "http://repo.typesafe.com/typesafe/releases/"

libraryDependencies += "com.typesafe.akka" %% "akka-actor" % "2.4.12"

当 SBT 报错显示 not found 时,很大可能是该依赖包的版本和当前配置的 scalaVersion 版本不兼容。当遇到这种情况时,可以在浏览器端打开 repo1 仓库repo1,查看与当前 scalaVersion 兼容的版本;并修改为兼容的版本即可。

从第一个 Actor 开始

首先创建一个类 class,并使其继承 akka.actir.Actor

import akka.actor.Actor
class PlainActor extends Actor

重写 receive 方法,并返回一个 Receive 对象,而它实际上是一个偏函数的别称。因此,我们使用 case 语句来匹配接收到不同消息时采取的动作。

override def receive: Receive = {
  case "hello" => println("nice to meet you too~")
  case "ok" => println("Okay.")
  case "exit" =>
    println("接收到exit指令,系统结束。")
    context.stop(self) //self 指代停用自身的ActorRef.
    context.system.terminate() //主程序关闭.
  case _ => println("Not matched.")
}

而实际上,接收到的消息可以是属于 Any 类型,只要该类型是允许进行序列化的。随后在主函数中创建这个 Actor,并向它打个招呼。在创建 Actor 之前,我们首先要构造出一个 ActorSystem 单例出来。

//1.创建一个Actor Factory工厂。
private val actorFactory = ActorSystem("actorFactory")

然后,我们就可以通过这个 ActorSystem 实例生产出 PlainActor 的代理出来了。注意,所有的 Actor 类都是通过 ActorRef 进行代理的。

//2.创建一个Actor的同时,返回此Actor的ActorRef.
//  2.1.Props[PlainActor] 相当于创建了一个PlanActor的实例,利用反射去完成。
//  2.2.后面的参数为该ActorRef的name,在一个环境下应当避免重复的name。
private val plainActorRef: ActorRef = actorFactory.actorOf(Props[PlainActor],"plainActor")

我们尝试着在主函数中,向PlainActor代理发送一条消息。

实际上这条消息会首先发送到 Message Dispatcher 中,然后再根据消息接收方的身份发送到对应 Actormailbox 队列当中等待消息处理。mailbox 实际上是一个 Runnable 接口,因此它会持续处于监听状态,并按照 Actor 中定义的 receive 方法来采取相应的处理动作。

然而,上述这段复杂的操作已经被 ActorSystem 隐藏起来了。

//3.向这个plainActor的Ref(代理人)发送消息:"hello"
//4.这条消息会发送给Message Dispatcher.
//5.Message Dispatcher会将后面的消息发送给MailBox[plainActor](它是一个Runnable线程)
//6.这个MailBox一直处于监听状态,当它接收到主程序发送的"hello"消息之后,便会去调用plainActor所对应的receive方法。
//7.注意,由于MailBox处于监听状态,所以目前为止主函数一直都没有退出。
plainActorRef ! "hello"

Actor 发送消息的两种形式

实际上向 Actor(Ref)发送消息有两种形式:一种方式是我们平时熟悉的!方式,而另一种方式是?。这两种消息发送方式有什么区别呢?

! 方法

actorRef ! "msg"

这种写法是真正的非阻塞异步方式,即发送消息后,不等待 actor 的任何回应,继续执行本线程的程序。(就像发号施令一样)所以这种情况下,!发送的没有返回值。

? 方法

actorRef ? "msg"

首先,想要使用此方法,首先需要引入 akka.pattern.ask 包,scala.concurrent.duration 包。这种写法是阻塞异步方式。多用于以下(或类似)的情景:A想要将一个job的某个部分委托给B去执行,且A依赖B处理完并返回的结果,才能顺利地执行将整个job执行完成。

也就是说,我们此时是需要等待对方 actor 的回应的。这时我们就要在对方的 receive 方法当中设置好回复的消息,这个消息将作为 ? 动作的返回值。

?动作的返回值是一个 Future[Any] 对象。因为这个结果不是立刻得到的,而是在未来的某一个时刻,由对方的 ActorRef 返回的结果。

val futureMsg : Future[Any] = actorRef ? "msg"

因此我们需要借助 Await.result 方法,预约一个时间,让此线程在该时间内阻塞,等待对方的回复消息。我们需要在合适的地方声明一个隐式变量 timeout,表示最长等待时间。

//最多等待10秒.seconds实际上是一个后置运算符
implicit val timeout: timeout = Timeout(10 seconds)

如果对方没有及时回复消息,则最终得到的值为空值。timeout.duration表示刚才设定好的预约时间。如果你很清楚对方会回复何种消息类型,一般还会和asInstanceOf方法组合使用。

//这段代码节选自笔者自己的个人代码,主要是观察? 如何配合Await 来实现ask形式的通讯的。
val future: Future[Any] = keyGenerator ? msg._1
val K: Array[Array[Byte]] = Await.result(future,timeout.duration).asInstanceOf[Array[Array[Byte]]]

注意,这种方式只能用在远程 Actor 返回结果格式确定且本地调用发起方没有高并发的情况下,否则该方法效率非常低,也就失去了使用 Actor 的意义。异步方式调用 actor 时,在 receive 方法中,一定要谨慎处理消息类型,尤其是要区分远程 Actor 返回的消息与本地发起的消息,否则就会出现两个 Actor 之间死循环。

参考链接:Akka Actor的异步与阻塞用法

实现两个 Actor 的相互通信

我们创建两个继承自 akka.actor.Actor 的两个类 BoyGirl,并设计一个简短的 dialog。我们首先希望男孩子会主动地率先向女孩儿推送消息,但是如何获取这个女孩的 ActorRef 呢?

在这个 Boy 被构建之前,我们首先就要将 GirlActorRef 实例绑定进去。因此,我们需要如此构造Boy类:

//在它构造的时候就为其绑定一个ActorRef.
class Boy(private val actorRef: ActorRef) extends Actor{

  override def receive: Receive = {
    case "greet" =>
      println("boy:hi!")
      actorRef ! "hi"
    case "Bye!" =>
      println("boy:bye!")
      context.stop(self)
      context.system.terminate()
    case _=>
      context.stop(self)
      context.system.terminate()
  }
}

而女孩如何知道消息的发送者是谁呢?Akka提供了sender()方法,它能够自动捕获到消息的发送者身份,并通过sender() ! msg的方式,将回复原路返回

  override def receive: Receive = {
    case "hi" =>
      //sender()指将消息原路返回出去。
      println("girl:Bye!")
      sender() ! "Bye!"
    case _ =>
      context.stop(self)
      context.system.terminate()
  }
}

最后,在主函数中完善逻辑,注意 Props 的两种构造方式

//在创建任何Actor之前都要先构造出actorSystem。
val actorSystemFactory = ActorSystem("actorSystemFactory")

//Girl的构造方法不需要传参,因此可以使用类型反射的方式令Props构造出一个Girl的代理。
val girl: ActorRef = actorSystemFactory.actorOf(Props[Girl],"girl")

//注意:由于Boy的构造方法需要传参,因此在这里不能简单地利用类型反射令Props构造一个Boy的代理出来。
val boy: ActorRef = actorSystemFactory.actorOf(Props(new Boy(girl)),"boy")

//给boy发送消息。
boy ! "greet"

Akka + 网络编程

在10年之前,如果需要使用Java实现一个聊天室功能,则需要基于socket(基于TCP/IP协议)网络通信接口进行编程。

在实际环境中,所有的任务是不会只会一台单机环境去运行的。因此在这一节,我们要介绍如何利用Akka来实现不同计算机节点之间的通讯。网络编程其实分为两种:

  1. TCP socket 编程:比如 QQ,MSN 等聊天工具,都是基于socket进行编程的。
  2. b/s 结构的 Http 编程:即目前的 Web 开发范畴。当使用浏览器访问服务器的时候,使用的都是 HTTP 协议,而 HTTP 底层仍旧是基于 TCP socket 来实现的。

向另一个微信好友发送的消息都经历了什么?

为什么只要我和好友都安装微信,我们就可以利用微信进行通讯了呢?比如说我发送了一个“Hello”,这条消息是如何发送到对方微信的呢?首先我们来回顾一个简单的计算机网络的基础知识:OSI参考模型,以及我们实际采纳的TCP/IP参考模型。

tcpip.png

运行在应用层的微信首先会将这条消息转换成ASCii码发送到运输层,然后被装入到TCP报文向下传递到网络层。网络层将本机的IP地址连同TCP报文装入到IP报文中传递到数据链路层。

数据链路层会将IP报文加上帧头与帧尾(FCS),使其组成一个MAC帧并沿着物理层网络设备发送到对方机器的对应端口中。

mac.png

端口(Port)

这里的端口特指TCP/IP协议的逻辑端口。一个计算机一般只需要一个IP地址,但是却可以拥有65535个端口!而逻辑端口又被分为以下三类:

  • 0号作为保留端口
  • 1-1024是固定端口。比如:端口22用于SSH远程登录协议,端口20,21用于FTP传输服务等。
  • 1025-65535是动态端口。这些允许用户程序来使用,比如Play2服务的默认端口为9000;Tomcat服务器的默认端口是8080;MySQL服务器的默认端口为3306。

❗使用端口的注意事项

  1. 在计算机(服务器)应尽可能地少开端口
  2. 一个端口只能被一个程序监听,但是允许多个客户端去访问(客户端也会启动一个端口去访问它,一般情况下都是随机分配)。
  3. Windows系统下,可以使用netstat -an可以查看本机有那些端口处在监听状态。
  4. Windows系统下,可以使用netstat -anb可以查看监听端口的pid(进程标识号id)。

利用 Akka 编写一个小冰客服

本节主要介绍如何编写一个简单的的c/s程式。全局代码会在文章末尾给出笔者的 github 仓库链接。

需求分析

  1. 服务器端开启9999端口进行监听,允许其它客户端连接。
  2. 客户端通过键盘输入问题,通过本机的9990端口发送给服务器端的程序。
  3. 服务端根据问题回答问题。

代码分析

服务器端程序

  1. 创建 ActorSystem
  2. 创建一个 XiaoBingActor 作为服务器端的客服。

XiaoBingActor 应该包含的功能:

  1. 接收客户端发送的消息。
  2. 利用偏函数,根据客户端的问题,给定回复。

客服端程序

  1. 创建 ActorSystem
  2. 创建一个 ClientActor 作为客户。

ClientActor 应该包含的功能:

  1. 绑定服务器端的 XiaoBingActor需要通过 akka-remote 实现)。
  2. 向服务器端的 XiaoBingActor 发送文本。
  3. 能够接收 XiaoBingActor 回复的消息。

同时还要注意,在网络之间直接传递String类型的数据是不可行的。因此客户端和服务器端需要建立一个协议。这个协议一般使用样例类来实现,因为样例类本身提供了很多便捷功能,包括serializable序列化接口。

项目目录

Xiaobing
 └client
 |	└ClientActor.scala
 └common
 |  └MessageProtocal.scala
 └server
    └XiaoBingActor.scala

导入 akka-remote 依赖

如果要利用 Akka 编写网络间通讯的程序,则需要导入 akka-remote 依赖包。在 build.sbt 中添加下面一行:

libraryDependencies += "com.typesafe.akka" %% "akka-remote" % "2.4.12"

注意,该依赖的版本需要和 "akka-actor" 依赖版本保持一致。经过实践,2.6.x 版和旧版的 akka 工具相比,在使用方式会存在一些差异和问题。

通讯过程流程图

网络通信是两个机器之间的通讯(本例使用同一个机器的不同端口模拟不同机器的情况,因此服务器端和客户端都取回环地址127.0.0.1),因此双方都需要启动 Akka 程序,并构建 ActorSystem

akka-remote.png

  • 服务器端的 Akka 程序监听 9999 端口,并创建一个 XiaoBingActor 的引用 Ref

  • 客户端的 Akka 程序监听 9990 端口,并创建一个 ClientActor 的引用 Ref

  • 网络之间传输的数据必须是被序列化的。

  • 默认服务器端和客户端的主程序由 Actor 的伴生对象运行。

  • 使用样例类包装传输的信息。

编写服务器端 Actor

XiaoBingActor 的大体框架如下。我们首先在伴生对象中完善主程序的内容:

class XiaoBingActor extends Actor {
	override def receive: Receive = ???
}

//继承App可以将伴生对象内的语句块直接作为主函数运行。
object XiaoBingActor extends App {
//TODO 主函数逻辑:创建ActorSystem->创建XiaoBingActor的Ref
}

在网络环境下,我们在构建 ActorSystem 时需要加入额外的配置 config,来告诉程序监听的端口和 ip 地址(在集群环境中,需要配置为实际的内网地址);同时,我们还要通过配置 provide = "akka.remote.RemoteActorRefProvider" 依赖。

因此,我们在 object XiaoBingActor 中创建如下变量和配置:

  //1.绑定本机地址和启动的端口号
  val host = "127.0.0.1" //绑定为本机地址
  val port = 9999 //绑定本机启动的端口号

  //2.绑定配置文件
  val config = ConfigFactory.parseString(
    s"""
        akka{
          actor{
            provider = "akka.remote.RemoteActorRefProvider"
          }

          remote{
            enabled-transports=["akka.remote.netty.tcp"]
            netty.tcp{
              hostname="$host"
              port=$port
            }
          }
        }
     """)

将该配置放入到 ActorSystem 的构造器当中,然后创建出 XiaoBingActorRef

//3.客户端通过 ActorSystem 的名字匹配到服务器端的 Actor.
//这里和之前的构造方法相比,增加了一个配置文件的传入。
//"server" 的作用相当于 uri(统一资源定位).
//因此服务启动时,akka监听的端口为:akka.tcp://server@127.0.0.1:9999。
val server = ActorSystem("server", config)

//4.创建小冰客服的Actor。
//注意,name很重要。其它网络要通过 ../user/${name}来查找到这个Actor。
val xiaobingRef: ActorRef = server.actorOf(Props[XiaoBingActor], "xiaobing")

在之前的单机环境中,我们都没有直接使用 ActorSystemActor 的 name 属性。而在网络环境中,它们的作用就相当于是 url (统一资源定位符)。

编写客户端 Actor

客户端 Actor 的框架如下:

class ClientActor(serverHost: String, serverPort: Int) extends Actor {
  
  var serviceActorRef: ActorSelection = _

  //1.在这个ClientActor被构建出来之前,应该先向服务器中申请XiaoBingActor并绑定。
  //2.这个工作需要在preStart()方法内完成。
  override def preStart(): Unit = ???

  override def receive: Receive = ???
}

//继承App可以将伴生对象内的语句块直接作为主函数运行。
object ClientActor extends App {
//TODO 主函数逻辑:创建ActorSystem->创建XiaoBingActor的Ref
}

和服务器端的 Actor 相比,客户端多了一个 preStart 方法。原因在于,我们并不能直接获取 XiaoBingActor 的引用 Ref,因为实际上这个 Actor 保存在其它计算机中的 ActorSystem 当中(假定情景是这样)。所以在 ClientActor 被构建之前,首先需要联网找到服务器端的 XiaoBingActor 引用并绑定给 serviceActorRef 变量,注意,它的类型属于 ActorSelection

我们给出完整的 preStart 方法,其中,服务器的 ip 地址 serverHost 和端口号 serverPort 通过构造器传入:

override def preStart(): Unit = {
  //在目标akka服务的 /user "目录"下请求对应的Actor的name。
  serviceActorRef = context.actorSelection(s"akka.tcp://server@$serverHost:$serverPort/user/xiaobing")
  println("[successful] 获取远端Actor成功:"  + serviceActorRef)
}

server 是对方 ActorSystem 的 name;xiaobing 是对方 ActorSystem 中指定的 XiaoBingActor。因此我们实际上就像是在浏览器中输入url一样获取了服务器端 Actor 的 Ref

消息传输协议

在网络环境中,我们是无法直接发送未经序列化的 String 类型,并且在实际环境中,通讯内容要远远复杂地多。因此我们需要用样例类来包装 C->B,以及 B->C的消息内容。(双方通讯的消息格式未必相同,比如 HTTP 请求报文和响应报文的 Header 部分就不完全一样,因此两边都要实现)

因此我们在一个MessageProtocal.scala文件中补充上:

package Xiaobing.common

//1.使用样例类来使客服端在发送消息时遵守某种协议,因为样例类自动实现了序列化,以及apply方法。
case class ClientProtocol(mes :String)

//2.同样的,服务器端发送消息也会遵守一个协议,并且服务器端和客服端返回的消息未必是同一种类型的消息。【这里是特例】
case class ServerProtocol(mes :String)

Scala编译器会在编译时自动为我们补充上apply, unapply等方法,并支持序列化。

移步 Github 查看完整示例

至此,代码的关键部分就介绍完毕了,剩下的工作为业务逻辑的补充,即 ClientActorreceive 方法和 XiaoBingActorreceive 方法。在相互通信时,应当将 String 类型的消息包装到 XXXProtocol 类中发送,对方通过对象提取器 unapply 方法提取出对应的信息。

//---------------ClientActor-------------//
//do not : serviceActorRef ! msg
serviceActorRef ! ClientProtocol(msg)
//---------------ServerActor-------------//
override def receive: Receive = {
	case ClientProtocol(msg) => ...
}

🔗github链接:Akka |相关代码在src/main/scala/XiaoBing目录下。