在本章,笔者会介绍如何在 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 的核心角色 —— Actor 模型
处理并发问题的关键是:保证可共享数据的一致性和正确性,因为程序在多线程环境下运行时,多个线程对同一个数据进行修改,如果不加同步锁,则会造成读脏现象。
或许我们会尝试着在关键代码中加入 synchronized
锁,但是这样的代码在高并发环境下就会长时间处于阻塞状态,对程序效率产生很大的影响。若选择串行处理,虽然保证了数据的一致性,但是牺牲了系统的性能。
而Actor模型的出现则解决了这个问题:它简化了并发编程,又提升了程序的性能。下面是 Actor 系统的示意图:
在这个基于 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 脚本来渲染页面。这就导致浏览器端有很长一段时间什么内容都不会显示。
这对于当时的用户来说是很糟糕的体验:因为在页面完全加载完毕之前,他们在浏览器端得不到任何的反馈。
直到 Ajax 技术的诞生,用户体验得到了极大的提升:一般向服务器端的数据请求是需要耗费时间的,那么则使用 Ajax 来发送一个异步请求,并允许浏览器继续执行 JavaScript 脚本来渲染页面的其它部分。直到得到服务器端的响应之后(XMLHttpRequest 的状态为 4),再通过准备好的回调函数(callback function)对数据进行处理(比如进行 dom 操作)。
Actor 模型工作机制的简要说明
当 Actor A要给Actor B ( 下文简称为 A 和 B )发送消息时,会首先找到B的代理B ActorRef (而不是直接发送到B)。另外,这个消息也并没有直接发送到B的“邮箱”mailbox当中,而是由一个消息中转站 Message Dispatcher进行了处理。
Message Dispatcher 根据这条消息所标注的发送方,转发给 B 的 mailbox 当中。注意,这个 mailbox 相当于是一个消息队列,遵守 FIFO 原则。
而 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
中,然后再根据消息接收方的身份发送到对应 Actor
的 mailbox
队列当中等待消息处理。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
的两个类 Boy
和 Girl
,并设计一个简短的 dialog。我们首先希望男孩子会主动地率先向女孩儿推送消息,但是如何获取这个女孩的 ActorRef
呢?
在这个 Boy
被构建之前,我们首先就要将 Girl
的 ActorRef
实例绑定进去。因此,我们需要如此构造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来实现不同计算机节点之间的通讯。网络编程其实分为两种:
- TCP socket 编程:比如 QQ,MSN 等聊天工具,都是基于
socket
进行编程的。 - b/s 结构的 Http 编程:即目前的 Web 开发范畴。当使用浏览器访问服务器的时候,使用的都是 HTTP 协议,而 HTTP 底层仍旧是基于 TCP
socket
来实现的。
向另一个微信好友发送的消息都经历了什么?
为什么只要我和好友都安装微信,我们就可以利用微信进行通讯了呢?比如说我发送了一个“Hello”,这条消息是如何发送到对方微信的呢?首先我们来回顾一个简单的计算机网络的基础知识:OSI参考模型,以及我们实际采纳的TCP/IP参考模型。
运行在应用层的微信首先会将这条消息转换成ASCii码发送到运输层,然后被装入到TCP报文向下传递到网络层。网络层将本机的IP地址连同TCP报文装入到IP报文中传递到数据链路层。
数据链路层会将IP报文加上帧头与帧尾(FCS),使其组成一个MAC帧并沿着物理层网络设备发送到对方机器的对应端口中。
端口(Port)
这里的端口特指TCP/IP协议的逻辑端口。一个计算机一般只需要一个IP地址,但是却可以拥有65535个端口!而逻辑端口又被分为以下三类:
- 0号作为保留端口。
- 1-1024是固定端口。比如:端口22用于SSH远程登录协议,端口20,21用于FTP传输服务等。
- 1025-65535是动态端口。这些允许用户程序来使用,比如Play2服务的默认端口为9000;Tomcat服务器的默认端口是8080;MySQL服务器的默认端口为3306。
❗使用端口的注意事项
- 在计算机(服务器)应尽可能地少开端口。
- 一个端口只能被一个程序监听,但是允许多个客户端去访问(客户端也会启动一个端口去访问它,一般情况下都是随机分配)。
- Windows系统下,可以使用
netstat -an
可以查看本机有那些端口处在监听状态。 - Windows系统下,可以使用
netstat -anb
可以查看监听端口的pid
(进程标识号id)。
利用 Akka 编写一个小冰客服
本节主要介绍如何编写一个简单的的c/s程式。全局代码会在文章末尾给出笔者的 github 仓库链接。
需求分析
- 服务器端开启9999端口进行监听,允许其它客户端连接。
- 客户端通过键盘输入问题,通过本机的9990端口发送给服务器端的程序。
- 服务端根据问题回答问题。
代码分析
服务器端程序
- 创建
ActorSystem
。 - 创建一个
XiaoBingActor
作为服务器端的客服。
XiaoBingActor
应该包含的功能:
- 接收客户端发送的消息。
- 利用偏函数,根据客户端的问题,给定回复。
客服端程序
- 创建
ActorSystem
。 - 创建一个
ClientActor
作为客户。
ClientActor
应该包含的功能:
- 绑定服务器端的
XiaoBingActor
(需要通过 akka-remote 实现)。 - 向服务器端的
XiaoBingActor
发送文本。 - 能够接收
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 程序监听
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
的构造器当中,然后创建出 XiaoBingActor
的 Ref
:
//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")
在之前的单机环境中,我们都没有直接使用 ActorSystem
和 Actor
的 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 查看完整示例
至此,代码的关键部分就介绍完毕了,剩下的工作为业务逻辑的补充,即 ClientActor
的 receive
方法和 XiaoBingActor
的 receive
方法。在相互通信时,应当将 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
目录下。