Akka 实战:分布式 App 构建与测试

1,192 阅读12分钟

本章起将介绍如何水平拓展我们的 Akka 应用程序。本章使用节点 ( node ) 表示通过网络进行通讯的程序,它将是网络拓扑的一个连接点,是分布式系统的一部分。这些节点将可能运行在同一个服务器并监听不同的端口,也有可能完全运行在不同的服务器上。

常见的网络拓扑大致可分为:星型 ( C/S ),环形,对等 / 网格形,树形。

all_nodes.png

节点在分布式系统中各自扮演一个角色 ( role ),每个角色有其特有的职责,最常见的是 Master/Worker 类型的主从角色。和单机应用不同,节点之间需要使用 TCP/IP 和 UDP 这样的网络协议进行相互通讯。数据包装载字节数组形式的消息,因此这需要进行序列化 ( Serialization ) 和反序列化 ( Deserialization )。

同一个分布式应用的所有节点之间共享组员关系 ( Group membership )。这种关系可以是静态配置的,也可以是动态配置的。动态的成员关系允许节点扮演不同的角色,同时允许节点随时加入 / 离开网络。显然,动态的成员关系是可伸缩的,节点数量可以根据实际的业务量动态增加 / 减少,但是实现难度显然也要更高。

论文《 A Node on Distributed Computing 》概括了从单机编程迁移到分布式编程时,不可忽略的四个方面:

  1. 延迟:使用网络意味着对消息的处理需要更多的时间。
  2. 部分故障:当系统的某些重要组件不总是可见,消失,或重新出现时,确定分布式系统是否工作正常,是一件困难的事。
  3. 内存访问:在本地系统获取内存对象的引用不会发生间歇性失败,但此问题在分布式编程经常出现。
  4. 并发:没有一个全局的 ”监管者",再考虑到之前的因素,多个节点的交织操作有可能会失败。

好在 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 ,该版本在传输协议上有较大的变化:

  1. TCP 协议默认将基于 Akka Streams TCP ( 在此之前都是 Netty )。
  2. 由 Akka Streams TLS 提供 SSL 加密服务。
  3. 由 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.confbackend.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 的样例类默认就是序列化的,因此编写简单程序时可以直接使用样例类作为消息交换的载体。

通过状态机实现自动重连

考虑以下情况:

  1. backend 节点可能突然宕机,或者重启。
  2. BackendActor Actor 崩溃或已经关闭。

理想情况下,frontend 节点的 FrontendActor 应当能从容处理这些情形,并在 backend 节点重新可用时自动重连。

  1. 当与 BackendActor Actor 成功保持连接时,切换为 活动状态
  2. 当与 BackendActor Actor 断开连接时,进入 识别状态

现在,FrontendActor 将作为一个有限状态机:在不同的状态下,它将表现出不同的行为。在活动状态下,它将和 BackendActor 保持正常通信;在识别状态下,它将不断尝试发起连接,并拒绝在此期间的其它消息。在连接成功后,主动切换为活动状态。

这里的 "行为" 指 Actor 的 Receive 类型,它本质上是一个偏函数。这里将活动状态命名为 active,将识别状态命名为 identify,状态切换意味着 FrontendActorreceive 方法会在 activeidentify 之间切换,这里通过 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.conffrontend-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,它仍然是一个有限状态机,会在 deployingmaybeActive 两个状态当中切换。

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-testakka-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 用于启动和关闭多节点测试,为此需要混入 MultiNodeSpecCallbacksBeforeAndAfterAll 特质。除此之外再将能用到的 scalatest 的特质也集成进来,如 WordSpecLikeMatchers 等。

这是官方提供的一套标准代码,下面的 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,分别命名为 frontendbackend

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}TestNameNodeName 可以自拟。

我们无需知悉底层的测试子类实际负责哪个角色,只管为角色分配测试工作就可以了。在源代码中,通过 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 角色才能继续推进测试代码。

实际的测试要比这个更加稍稍复杂一些,这里使用一个流程图来表示:

multi-jvm-node.png

下面是完整的代码,这里复用了前文介绍的非远程部署的前端和后端系统进行测试:首先在后端节点中创建 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 应用而言,我们仍然发现了不变的因素:

  1. 无论 Actor 是本地还是远程的,ActorRef 的行为仍然不变。
  2. 分布式系统的死亡监视 API 和本地监视 API 相同。
  3. 尽管整个系统被分为了不同的节点,但是通过路径查找仍然可以让节点之间进行透明的交互。
  4. multi-node-testkit 模块让我们有办法实现分布式单元测试。