本章起将介绍如何水平拓展我们的 Akka 应用程序。本章使用节点 ( node ) 表示通过网络进行通讯的程序,它将是网络拓扑的一个连接点,是分布式系统的一部分。这些节点将可能运行在同一个服务器并监听不同的端口,也有可能完全运行在不同的服务器上。
常见的网络拓扑大致可分为:星型 ( C/S ),环形,对等 / 网格形,树形。
节点在分布式系统中各自扮演一个角色 ( role ),每个角色有其特有的职责,最常见的是 Master/Worker 类型的主从角色。和单机应用不同,节点之间需要使用 TCP/IP 和 UDP 这样的网络协议进行相互通讯。数据包装载字节数组形式的消息,因此这需要进行序列化 ( Serialization ) 和反序列化 ( Deserialization )。
同一个分布式应用的所有节点之间共享组员关系 ( Group membership )。这种关系可以是静态配置的,也可以是动态配置的。动态的成员关系允许节点扮演不同的角色,同时允许节点随时加入 / 离开网络。显然,动态的成员关系是可伸缩的,节点数量可以根据实际的业务量动态增加 / 减少,但是实现难度显然也要更高。
论文《 A Node on Distributed Computing 》概括了从单机编程迁移到分布式编程时,不可忽略的四个方面:
- 延迟:使用网络意味着对消息的处理需要更多的时间。
- 部分故障:当系统的某些重要组件不总是可见,消失,或重新出现时,确定分布式系统是否工作正常,是一件困难的事。
- 内存访问:在本地系统获取内存对象的引用不会发生间歇性失败,但此问题在分布式编程经常出现。
- 并发:没有一个全局的 ”监管者",再考虑到之前的因素,多个节点的交织操作有可能会失败。
好在 Akka 天然地遵循分布式模型,因此将我们原本的单机 Akka 程序进行水平拓展是一件容易的事情,本章将使用 Akka Remote 工具做一些改变。除此之外,笔者会着重介绍用于多虚拟机测试的 multi-jvm
插件。它对于未来的 Akka 分布式开发很有用。
Akka Remote 版本声明
Akka Remote 在后续版本发生了较大的变动。建议先阅读一遍官方文档:Artery Remoting • Akka Documentation
本章的项目将在 Scala 2.12.4 / JDK 11 的环境下运行。JDK 9 版本中 String
的底层实现发生了变动,因此在 2.4.x
版本的 Akka 框架中,内部调用 Unsafe
方法会出现一些 Bug。为了规避这个问题,我们需要将 Akka 组件整体升级到 2.5.X
版本。尽管还有一种方式是将版本回退到 JDK 8,但笔者不再建议基于此版本继续开发了。
libraryDependencies ++= {
val akkaVersion = "2.5.32"
val akkaHttpVersion = "10.0.9"
Seq(
"com.typesafe.akka" %% "akka-actor" % akkaVersion,
"com.typesafe.akka" %% "akka-slf4j" % akkaVersion,
"com.typesafe.akka" %% "akka-remote" % akkaVersion,
"com.typesafe.akka" %% "akka-multi-node-testkit" % akkaVersion % "test",
"com.typesafe.akka" %% "akka-testkit" % akkaVersion % "test",
"org.scalatest" %% "scalatest" % "3.0.0" % "test",
"ch.qos.logback" % "logback-classic" % "1.1.6",
)
}
选择 2.5.X
版本的另一个原因是和原书 《Akka in Action》 的代码保持高度兼容。官网的最新版本号是 2.6.X
,该版本在传输协议上有较大的变化:
- TCP 协议默认将基于 Akka Streams TCP ( 在此之前都是 Netty )。
- 由 Akka Streams TLS 提供 SSL 加密服务。
- 由 Aeron 服务器提供 UDP 服务。
在 2.7.0
版本后,传统的 Akka Remote 将会被 Akka Artery 替代。同时,在使用 Akka 进行应用开发时,官网鼓励使用 Akka Cluster,Akka HTTP,gRPC 等更加高级的工具。
编写第一个分布式 App
本章的重点是将单机程序拓展成 C/S 结构的,仅由两个节点 ( 前端节点和后端节点 ) 静态构成的分布式应用。后文称前端节点为 frontend
,称后端节点为 backend
。
配置文件现在有了更多内容,这里的重点是 actor {...}
块和 remote {...}
块的配置。特地强调,这是 2.5.X
(含) 之前版本的配置,因为 2.6.x
版本后不再默认用 Netty 作内置服务器了。
akka {
# 日志相关
loglevel = DEBUG
stdout-loglevel = WARNING
loggers = ["akka.event.slf4j.Slf4jLogger"]
actor {
provider = "akka.remote.RemoteActorRefProvider"
}
remote {
enabled-transports = ["akka.remote.netty.tcp"]
netty.tcp {
hostname = "0.0.0.0"
port = 2551
}
}
}
前后端节点将作为两个进程分别运行,每个进程都需要创建一个 ActorSystem 系统。因此要准备创建两份配置文件:frontend.conf
和 backend.conf
。在前端的配置文件中,将端口设置为 2552
( 或其他 );在后端的配置文件中,将 port
设置为 2551
。
在启动 ActorSystem 时分别将两个配置文件导入到内部。其中,配置文件的 akka.actor.provider
项会引导 ActorSystem 加载 akka-remote
模块。有别于之前的简单示例,这里必须显式地为 ActorSystem 赋予有意义的名称,以便其它节点能够通过 uri
找到它。
在下面的代码中,backend
节点 ( 它是一个 ActorSystem 系统 ) 创建了一个名为 backendActor
的用户空间下的顶级 Actor ( 由 ActorSystem 直接创建 ),它监听外界的消息并总是回显 "hello" 内容。
object BackendMain extends App {
val config = ConfigFactory.load("backend")
val system = ActorSystem("backend",config)
system.actorOf(Props[BackendActor],"backendActor")
}
class BackendActor extends Actor{
override def receive: Receive = {
case _ => sender() ! "hello"
}
}
对于前端的 frontend
节点而言,想要找到此 Actor,其 uri
路径要包括:具体的协议名称,对端的 Actor 系统名称,对端 Ip 地址,端口号,对端 Actor 名称 ( 即 backendActor
)。
val protocol = "akka.tcp"
val systemName = "backend"
val host = "0.0.0.0"
val port = "2551"
val actorName = "user/backendActor"
val uri = s"$protocol://$systemName@$host:$port/$actorName"
由于我们创建的所有 Actor 都在用户空间,因此路径总是在 /user
下。下面是创建于 frontend
节点的 Actor,它依赖于这条 uri
向对端的 Actor 发送消息。
// 将主程序的 uri 赋值给 path,
// FrontendActor 基于这个 path 和另一个 ActorSystem 系统下的
class FrontendActor(path : String) extends Actor{
override def receive: Receive = {
case "start" =>
val backendRef: ActorSelection = context.actorSelection(path)
backendRef ! "hi"
case msg : String => println(msg)
}
}
此 Actor 调用了 context.actorSelection(path)
去寻找路径匹配的所有 ActorRef 对象,并返回 ActorSelection 类型的引用。ActorSelection 还可以用于向指定路径下的所有 ActorRef 群发消息,我们现在只知道使用它可以找到对端的 backendActor
就可以了。
object FrontendMain extends App {
val config = ConfigFactory.load("frontend")
val system = ActorSystem("frontend",config)
val protocol = "akka.tcp"
val systemName = "backend"
val host = "0.0.0.0"
val port = "2551"
val actorName = "user/backendActor"
val uri = s"$protocol://$systemName@$host:$port/$actorName"
val rt = system.actorOf(Props(new FrontendActor(path = uri)),"lookup")
// 主函数向 actor 发送消息。
rt ! "start"
}
相继启动 backend
节点和 frontend
节点。如果整个系统正常运行,那么 FrontendActor
将会收到 BackendActor
回显的 Hello 信息。值得一提,Hello 消息经过了序列化,发送 TCP 套接字,解包,反序列化的过程。
我们没有在任何一处代码编写序列化 / 反序列化代码,因为 Akka 默认使用 Java 反序列化替我们完成了这部分工作。在以后,我们会学习如何自定义序列化工具以达到更快,更安全的性能。Scala 的样例类默认就是序列化的,因此编写简单程序时可以直接使用样例类作为消息交换的载体。
通过状态机实现自动重连
考虑以下情况:
backend
节点可能突然宕机,或者重启。BackendActor
Actor 崩溃或已经关闭。
理想情况下,frontend
节点的 FrontendActor
应当能从容处理这些情形,并在 backend
节点重新可用时自动重连。
- 当与
BackendActor
Actor 成功保持连接时,切换为 活动状态。 - 当与
BackendActor
Actor 断开连接时,进入 识别状态。
现在,FrontendActor
将作为一个有限状态机:在不同的状态下,它将表现出不同的行为。在活动状态下,它将和 BackendActor
保持正常通信;在识别状态下,它将不断尝试发起连接,并拒绝在此期间的其它消息。在连接成功后,主动切换为活动状态。
这里的 "行为" 指 Actor 的 Receive
类型,它本质上是一个偏函数。这里将活动状态命名为 active
,将识别状态命名为 identify
,状态切换意味着 FrontendActor
的 receive
方法会在 active
和 identify
之间切换,这里通过 context.become()
方法来实现。
第二个问题:FrontendActor
需要一种有效的机制监控连接状态。考虑之前学过的监视 context.watch()
方法:在成功连接时,FrontendActor
会立刻将 BackendActor
的 ActorRef 纳入监视的范围。这样,当 BackendActor
断连时,FrontendActor
能够收到一条 Terminated 消息。见:Akka 实战:解析 Akka 容错机制 - 掘金 (juejin.cn)
第三个问题:利用已有的方法设计一个简单的重连功能。
一方面,Akka 设计了这样的机制:请求方 Actor 可以向 ActorSelection 发送 Identify
消息,并得到一条 ActorIdentity 类型的回复。这条回复包含了两个内容,一个是回显匹配的路径,另一个是可能为空的 Option[ActorRef]
引用。显然,当消息请求失败时,返回的第二个参数将为 None
。此时应当不断尝试重连,直到获得了非 None
的 ActorRef 为止。
另一方面,Actor 可以通过混入 Timer 特质获得创建定时计划的能力。在识别状态时,Actor 将利用定时计划不断尝试连接,直到它获取到正确的远程连接为止。
注,《 Akka in Action 》原书中使用
context.setReceiveTimeout()
实现了定时检查功能。见:Classic Actors • Akka Documentation 中,Receive Timeout 和 Timers,scheduled messages 这两节的内容。
下面给出代码实现,留意代码的注释部分。
// Actor 需要混入 Timers 特质。
class AutoFrontedActor(path : String) extends Actor with ActorLogging with Timers {
// 在开始时,AutoFrontedActor 为识别模式。
// 在首次被创建时发起连接
override def receive: Receive = identify
sendIdentify()
case object Retry
// 用于识别远程 BackendActor 的方法,会接收到 ActorIdentity 消息。
def sendIdentify(): Unit = {
val selection = context.actorSelection(path)
selection ! Identify(path)
}
// 识别模式。
val identify: Receive = {
case Retry => {
log.info("尝试重连 ...")
sendIdentify()
}
// 这里需要检查 ActorIdentity 返回的路径是否为我们寻找的路径。
// 如果返回 Some,则说明找到了对应的 ActorRef。
// 通过 context.become(active) 切换到活动状态。
case ActorIdentity(p,Some(actor)) if p == path => {
context.watch(actor)
context.become(active)
}
// 如果返回 None,则说明远端的 BackendActor 没有准备好。
case ActorIdentity(p,None) if p == path => {
log.error("重连失败,3 秒后重试")
timers.startSingleTimer("RetryTimer",Retry,3 seconds)
}
// 在识别模式下不处理外界消息。
case _ => log.error(s"backend 节点还没准备好,消息未发送")
}
// 活动模式。
val active : Receive = {
// 如果活动状态下断开,那么重新开始识别。
case Terminated(actorRef) => {
log.info(s"$actorRef 断开,重新进入识别模式")
context.become(identify)
sendIdentify()
}
case msg => println(s"接收到了消息 ${msg}")
}
}
当 FrontendActor
成功连接 BackendActor
时,从日志中可以观察到双方传递的心跳检测的消息。
22:57:04.816 [frontend-akka.actor.default-dispatcher-2] DEBUG akka.remote.RemoteWatcher - Sending Heartbeat to [akka.tcp://backend@0.0.0.0:2551]
22:57:04.820 [frontend-akka.actor.default-dispatcher-2] DEBUG akka.remote.RemoteWatcher - Received heartbeat rsp from [akka.tcp://backend@0.0.0.0:2551]
远程部署
本节的远程部署,指在前端 frontend-sys
ActorSystem 节点下创建 Actor,然后将其发送到后端的 backend-sys
ActorSystem 部署。远程部署可以通过配置实现,也可以通过编程实现部署。
配置实现
首先介绍配置实现,这只需要对配置文件做一些简要更改,而无需改动项目代码。拷贝一份 frontend.conf
到 frontend-remote-dep.conf
,额外添加这一条配置。
akka.actor.deployment {
/backend {remote = "akka.tcp://backend-sys@0.0.0.0:2551"}
}
这条配置表示:当 frontend-sys
节点创建路径为 /user/backend
的 Actor 时,按照 remote
配置的路径发送到 backend-sys
端的 ActorSystem 中,而其它的 Actor 仍然在本地端进行创建。
现在,backend-sys
端的 ActorSystem 只需要简单地启动并等待。当后端收到创建 Actor 的请求之后,控制台会打印:
[backend-sys-akka.actor.default-dispatcher-16] DEBUG akka.actor.LocalActorRefProvider(akka://backend-sys) - Received command [DaemonMsgCreate(Props(Deploy(,Config .... to RemoteSystemDaemon on [akka://backend-sys]
注意,通过远程部署的 Actor,其路径要更加复杂。
// ${protocol}://${remoteActorSysName}@${remoteIp}:${remotePort}/remote/${protocol}/${localActorSysName}@${localIp}:${localPort}/user/${actorName}
val remoteUri = "akka.tcp://backend-sys@0.0.0.0:2551/remote/akka.tcp/frontend-sys@0.0.0.0:2552/user/backend"
因此像之前那样,使用这条 path 是无法找到部署到远程的 BackendActor 的。
"akka.tcp://backend-sys@0.0.0.0:2551/user/backend"
代码实现
下面是代码形式的远程部署。
// 这里我们仍然可以使用之前的 frontend 节点配置。
val conf: Config = ConfigFactory.load("frontend")
val sys: ActorSystem = ActorSystem("frontend-sys",conf)
val remoteUri = "akka.tcp://backend-sys@0.0.0.0:2551"
val backendAddress: Address = AddressFromURIString(remoteUri)
val props: Props = Props[BackendActor].withDeploy(
Deploy(scope = remote.RemoteScope(backendAddress))
)
sys.actorOf(props,"backend")
代码实现显然要比配置的方式麻烦不少,并且步骤繁杂,不便于记忆。在大部分情况下,通过配置文件的形式就足够了。但在动态远程部署的情形下必须使用代码才能完成。对于完全的动态部署,最好使用 Akka Cluster 模块,因为它专门用于维护动态的成员关系。
基于远程部署的自动重连
注意,当前端的 frontend-sys
进行远程部署时 backend-sys
后端系统还未启动,那么这个远程部署就会失败。换句话说,即使前端执行了远程部署,获取的后端 ActorRef 也不一定就是有效的。
不过,无论是 ActorRef 因为后端突然宕机而停止,还是因后端未启动而实质上部署失败,负责部署的前端 Actor 都能收到一条 Terminated 消息。我们根据这个特性创建 AutoFrontendActor
Actor,它仍然是一个有限状态机,会在 deploying
和 maybeActive
两个状态当中切换。
class AutoFrontendActor extends Actor with ActorLogging with Timers{
deployAndWatch()
case object Retry
def deployAndWatch(): Unit ={
// /proxy/backend
val ref: ActorRef = context.actorOf(Props[BackendActor],"backend")
log.info("开始部署 ...")
context.watch(ref)
context.become(maybeActive(ref))
}
def deploying : Receive = {
case Retry => deployAndWatch()
case _ => log.error("部署未完成")
}
// 由于远程部署的不确定性,不能保证部署完的 Actor 立刻可用。
// 一旦发现远程 Actor 不可用,就设置定时器准备重新部署。
def maybeActive(backendRef : ActorRef) : Receive = {
// 等价于 Terminated(backendRef$) if backendRef$ == backendRef
case Terminated(`backendRef`) =>
log.info(s"断开或部署失败,尝试重新部署:${`backendRef`.path}")
context.become(deploying)
timers.startSingleTimer("retry",Retry,3 seconds)
case m => println(s"收到了后端的消息:${m}")
}
override def receive: Receive = deploying
}
在这个例子中,被部署到后端的 BackendActor
Actor 由前端 backend-sys
节点下的 AutoFrontendActor
Actor 管理,而非后端的 backend-sys
节点管理,因此不妨将这个 AutoFrontendActor
Actor 命名为 proxy
。同时将 BackendActor
Actor 命名为 backend
,它现在的远程部署路径将是 /proxy/backend
。
akka.actor.deployment {
/proxy/backend {remote = "akka.tcp://backend-sys@0.0.0.0:2551"}
}
基于目前的实现,前后端节点现在能以任意的顺序进行启动。前端节点在发现后端节点重新可用时,便会自动在后端部署一个 Actor。
多 JVM 测试
Akka 官网提供了关于 multi-jvm 插件的使用帮助。见:
分布式系统的单元测试变得更加复杂:首先要准备两个 JVM 分别运行前端节点和后端节点,并且要保证前端节点在后端节点启动之后再运行。尽管在 IDE 的控制台中人工启动两个主程序然后观察结果也没什么问题,但我们仍有必要去了解并使用高效的自动化测试工具以解放生产力。
除了 scala-test
和 akka-testkit
这两个测试框架之外,还需要其它的工具。有关 akka-testkit
的内容,见:Akka 实战:测试驱动开发 TDD - 掘金 (juejin.cn)
首先需要在 /project/plugins.sbt
中引入 sbt-multi-jvm
插件:
// 1.1.0 适合 2.12 版本的 Scala。
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")
// 这是 akka 官网推荐的最新版 multi-jvm 插件版本
addSbtPlugin("com.typesafe.sbt" % "sbt-multi-jvm" % "0.4.0")
不仅如此。还需要在 build.sbt
中做一些配置,通过 enablePlugins
在项目中开启此插件:
lazy val root = (project in file("."))
.settings(
name := "akka-remote"
).enablePlugins(MultiJvmPlugin).configs(MultiJvm)
导入多虚拟机节点测试框架 akka-multi-node
:
libraryDependencies ++= {
val akkaVersion = "2.5.32"
val akkaHttpVersion = "10.0.9"
Seq(
// 忽略其它依赖
"com.typesafe.akka" %% "akka-multi-node-testkit" % akkaVersion % "test",
"com.typesafe.akka" %% "akka-testkit" % akkaVersion % "test",
"org.scalatest" %% "scalatest" % "3.0.0" % "test",
)
}
默认的测试路径为 /src/multi-jvm/test
,我们需要手动创建路径,然后将后文创建的所有类文件都放到该路径下。
使用 multi-jvm
/ akka-multi-node-testkit
的好处是:我们可以在多虚拟机测试中方便地测试某个 Akka Remote API 的功能 ( 或者仅是验证我们的某些想法 ),而不用像之前那样手动创建两个进程,并为此编写额外的代码。
在正式编写测试类之前,还需要额外编写一些基础代码。首先创建一个增强特质 STMultiNodeSpec
用于启动和关闭多节点测试,为此需要混入 MultiNodeSpecCallbacks
和 BeforeAndAfterAll
特质。除此之外再将能用到的 scalatest
的特质也集成进来,如 WordSpecLike
,Matchers
等。
这是官方提供的一套标准代码,下面的 STMultiNodeSpec
特质可以直接拷贝到项目中使用。
/**
* Hooks up MultiNodeSpec with ScalaTest
*/
trait STMultiNodeSpec extends MultiNodeSpecCallbacks with WordSpecLike with Matchers with BeforeAndAfterAll {
override def beforeAll() = multiNodeSpecBeforeAll()
override def afterAll() = multiNodeSpecAfterAll()
}
随后定义一个 MultiNodeConfig
,它用于描述节点的角色。在这个分布式项目测试中,前端,后端节点运行在两个 JVM 之上,因此配置两个 role
,分别命名为 frontend
和 backend
。
object ClientServerConfig extends MultiNodeConfig {
val frontend = role("frontend")
val backend = role("backend")
}
前置准备就绪后,创建一个测试类,不妨命名为 TwoNodeTest
,见下方的模板代码。它需要继承 MultiNodeSepc
抽象类 ( 它用于加载节点角色配置 ) 以及创建好的增强测试特质 STMultiNodeSpec
( 它已经集成了 scalatest
的功能 )。
ImplicitSender
则是由 akka-testkit
提供的特质。在测试代码中,该特质可以指定消息的发送者为 testActor
。这样就可以直接调用 expectMsg
或者是其它的断言方法验证功能,而没必要再让 testActor
回传消息。
class TwoNodeTestMultiJvmNode1 extends TwoNodeTest
class TwoNodeTestMultiJvmNode2 extends TwoNodeTest
// 这份测试代码会分别被上述的两个 MultiJvmFrontendNode, MultiJvmBackendNode 继承并在两个 jvm 环境中运行
class TwoNodeTest extends MultiNodeSpec(ClientServerConfig) with STMultiNodeSpec with ImplicitSender {
import ClientServerConfig._
import priv.gotickets.starter._
// 强制要求描述测试的节点数目。
override def initialParticipants: Int = roles.size
// TODO: 测试代码
}
我们还注意到了有两个类继承了这个测试类。这是因为在整个测试中,我们需要加载两个主程序,在两个 JVM 环境下运行。不过,它们仅会共享测试类的 TwoNodeTest
的部分代码,同时各自执行负责的任务:其中一个节点专门用于启动 backend
系统,另一个则需要稍后启动 frontend
系统并尝试在 backend
系统寻找远程 Actor。
multi-jvm
插件约定了测试节点类的命名为:{TestName}MultiJvm{NodeName}
,TestName
和 NodeName
可以自拟。
我们无需知悉底层的测试子类实际负责哪个角色,只管为角色分配测试工作就可以了。在源代码中,通过 runOn(nodeName){}
闭包指定某段代码块仅由哪些节点负责。
"create multi-jvm test" in {
runOn(backend) {/*TODO, only for backend node*/}
runOn(frontend) {/*TODO, only for backend ndoe*/}
// TODO, all the nodes will work
}
这还不够。为了确保 backend
先于 frontend
节点启动,我们还需要在多节点测试中引入类似 检查点屏障 的概念 ( 类比 GC 回收中的安全点机制,见笔者的 JVM 笔记 JVM:垃圾收集器 - 掘金 (juejin.cn) ),下面是一个简单的例子:
runOn(backend) {
// doing deploy
println("doing deploy")
enterBarrier("deploy")
}
runOn(frontend) {
enterBarrier("deploy")
println("start test")
}
检查点屏障通过 enterBarrier(name)
设置。比如,在上述的代码块中,frontend
角色会首先到达 deploy 检查点并开始等待。等到 backend
角色完成部署工作并到达 deploy 之后,frontend
角色才能继续推进测试代码。
实际的测试要比这个更加稍稍复杂一些,这里使用一个流程图来表示:
下面是完整的代码,这里复用了前文介绍的非远程部署的前端和后端系统进行测试:首先在后端节点中创建 ActorSystem,然后创建一个顶级 Actor。我们的测试目标是在另一个前端节点中能够执行远程访问。
class TwoNodeTestMultiJvmNode1 extends TwoNodeTest
class TwoNodeTestMultiJvmNode2 extends TwoNodeTest
// 这份测试代码会分别被上述的两个 Node1, Node2 继承并在两个 jvm 环境中运行
class TwoNodeTest extends MultiNodeSpec(ClientServerConfig) with STMultiNodeSpec with ImplicitSender {
// 导入我们配置的两个 frontend 和 backend 节点。
import ClientServerConfig._
// 强制要求描述测试的节点数目。
override def initialParticipants: Int = roles.size
"waiting all nodes" in {
// 等待所有节点开始测试
println(s"starting..")
enterBarrier("startup")
}
"create and send msg" in {
//只有 backend 执行此代码块
runOn(backend) {
// 这个 system 指 backend 端的 System.
system.actorOf(Props[BackendActor],"backend")
enterBarrier("deployed")
}
// 只有 frontend 执行此代码块
runOn(frontend) {
enterBarrier("deployed")
// 通过 node(backend) 可以方便地获取到后端地址
val path: ActorPath = node(backend) / "user" / "backend"
// 首先获取 ActorSelection, 然后提取指定的 ActorRef
val selection: ActorSelection = system.actorSelection(path)
selection.tell(Identify(path),testActor)
// 断言1: 在后端已经部署此 Actor 的情况下,前端一定能够访问。
val ref = expectMsgPF(){
case ActorIdentity(`path`,Some(ref)) => ref
}
ref ! "ping"
// 断言2: 后端的 Actor 能够响应信息。
expectMsgPF(){
case msg : String => {
println(s"get msg: ${msg}")
}
}
}
println("done.")
enterBarrier("finished")
}
}
❗最后,检查自己的版本。笔者已经发现在最新版的 sbt 1.6.2 / scalaVersion 2.12.4 环境下执行多节点测试时,sbt 在编译期会出现类似于此篇 issue 中:Display error message regarding Scala 2.12.4 not working with Zinc · Issue #6838 · sbt/sbt · GitHub 提交的错误 Bug。笔者的解决方法是将 sbt 降低到 1.5.8 版本。
注意,sbt shell 控制台有可能无法正确地显示中文字符,这不会影响到程序的正确性。进入 sbt 控制台,使用 mutlti-jvm:test
执行 task
。控制台将会打印类似这样的消息:
[info] * TwoNodeTest
[JVM-1] Run starting. Expected test count is: 2
[JVM-1] TwoNodeTestMultiJvmBackendNode:
[JVM-1] starting..
[JVM-2] Run starting. Expected test count is: 2
[JVM-2] TwoNodeTestMultiJvmFrontendNode:
[JVM-2] starting..
[JVM-1] - waiting all nodes
[JVM-2] - waiting all nodes
[JVM-2] done.
[JVM-2] get msg
[JVM-1] get msg: hello
[JVM-1] done.
[JVM-1] - create and send msg
[JVM-2] - create and send msg
[JVM-2] Run completed in 5 seconds, 314 milliseconds.
[JVM-2] Total number of tests run: 2
[JVM-2] Suites: completed 1, aborted 0
[JVM-2] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[JVM-2] All tests passed.
[JVM-1] Run completed in 5 seconds, 401 milliseconds.
[JVM-1] Total number of tests run: 2
[JVM-1] Suites: completed 1, aborted 0
[JVM-1] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[JVM-1] All tests passed.
[info] TwoNodeTest
[info] No tests to run for MultiJvm
[success] Total time: 15 s, completed 2022-5-9 17:10:43
[IJ]
只要最终打印了 [success]
信息,则说明测试成功。
附:在基于 Netty 的 Akka Remote 多 JVM 测试中,偶尔会抛出 WARNING,堆栈信息见 Github 的此篇 issue:[Docker] java.util.concurrent.RejectedExecutionException: Worker has already been shutdown · Issue #62 · ProjectZetta/RemoteFutures · GitHub,但是这不会影响到测试结果。
《Akka in Action》原书中有意关闭了 sbt 的并行测试,但是并没有交代这么做的原因。
在后续的章节,我们还会基于 multi-node-testkit
在多个服务器之间进行测试。
小结
单节点和 C/S 架构之间的巨大差异之一就体现在对 Actor 的寻找。本章的案例给了一个启发:使用 ActorSelection 和消息识别机制 Identity,ActorIdentity 等待或寻找远程 的 Actor 可用。
对于通常的单节点应用来说,我们无法通过简单的修改将其水平拓展。因为分布式需要网络环境,但本地应用经常会忽略这一点。Akka 使其变得更加简单。相比单节点 Akka 应用而言,我们仍然发现了不变的因素:
- 无论 Actor 是本地还是远程的,ActorRef 的行为仍然不变。
- 分布式系统的死亡监视 API 和本地监视 API 相同。
- 尽管整个系统被分为了不同的节点,但是通过路径查找仍然可以让节点之间进行透明的交互。
multi-node-testkit
模块让我们有办法实现分布式单元测试。