第五部分:查询设备组
akka版本2.5.8
版权声明:本文为博主原创文章,未经博主允许不得转载。
我们目前看到的会话模式都很简单,它们要求actor保持很少甚至没有内部状态,特别地:
1、设备actor返回读数时不需要改变状态
2、记录温度只更新了一个字段
3、设备组actor通过简单地增删map中的元素来维持组关系
在本节中,我们使用一些更加复杂的例子,由于家的主人会对整个家里的温度感兴趣,因此我们的目标是可以请求整个组里的设备actor。让我们从研究这个请求API应该具备什么样的功能开始我们的学习。
处理可能出现的情况
我们面临的第一个问题就是,组里的成员是动态的,每个被actor代表的传感器设备可能在任何时间被关闭。在请求开始的时候,我们可以让所有存在的设备上报当前温度,然而在请求的生命周期内:
1、设备actor可能会停止,这种情况下它就不会回应温度读取请求了
2、一个新的设备actor可能在这时候启动了,它就可能会错过这次温度收集,因为我们请求之前没有检测到它
这些问题可以用很多方式来解决,但是关键的一点是要决定我们所需的行为。以下工作在我们的用例里执行的很好:
1、当请求到达时,设备组actor对当前所拥有的设备actor执行一次快照,紧接着我们只会去请求这些设备上报温度信息。
2、对于在我们请求到达之后才接入的设备actor,我们简单地在这次请求里把它们忽略。
3、如果快照里的设备actor没有回应请求,我们会向查询者报告它已经停止运行。
除了设备actor会动态地添加去除,一些actor的回应也可能有很长的时间延迟。例如它们可能会因为偶然的死循环、bug导致的失败等原因把我们的请求给丢弃了。我们不希望我们的请求在不确定中继续,因此我们认为在以下情况中请求是完整的:
1、它返回了一个可用的温度:
Temperature(value)
2、它有回应,但是还没有可用的温度:TemperatureNotAvailable
3、它可能在回答前停止工作了:DeviceNotAvailable
4、它在超时前没有回复:DeviceTimedOut
通过总结这些信息的可能性,我们可以把以下代码添加到DeviceTimedOut
里:
final case class RequestAllTemperatures(requestId: Long)
final case class RespondAllTemperatures(requestId: Long, temperatures: Map[String, TemperatureReading])
sealed trait TemperatureReading
final case class Temperature(value: Double) extends TemperatureReading
case object TemperatureNotAvailable extends TemperatureReading
case object DeviceNotAvailable extends TemperatureReading
case object DeviceTimedOut extends TemperatureReading
实现查询功能
其中一种实现查询的方式是把代码添加到设备组actor里,然而这操作起来是很麻烦的,并且容易出错。请记住当我们查询的时候,我们需要对现有设备做一个快照,并且启动一个定时器来提供超时期限。另一个请求可能在这时到达,当然,我们要跟踪和之前同样的信息,只不过和刚才是隔离的。这可能要求我们在请求者和设备之间保持单独映射。
相反,我们将通过一种更简单,更好的方式来实现。我们会创建一个actor,这个actor代表一次查询,并代替组actor来完成这次查询工作。到目前为止,我们已经创建了一个属于经典域对象的actor。但是现在,我们会创建一个代表查询任务的actor,而不是创建一个实体actor。保持设备组actor简洁和提供可测试能力这两点给我们提供了极大的便利。
定义查询actor
首先我们需要设计查询actor的生命周期。其中包括其初始状态,首先会采取的行动,在有必要时要进行清理。查询actor需要以下信息:
1、所有在活动状态的设备actor的快照
2、这次请求的ID(以便我们能在回复中体现)
3、发送请求的actor的引用,我们会直接回复这个actor
4、请求超时时间,把它作为参数会让我们更便于测试
实现查询超时
由于我们需要使用一种方式来指出我们需要等待回复多长时间,现在是时候来介绍一和Akka内置在调度代码新特性了,虽然我们现在还用不到。它使用起来特别简单:
1、我们从
ActorSystem
中获取调度器,它又可以从actor的context里获得:context.system.scheduler
。这需要一个被隐式提供的ExecutionContext
,它以线程池为基础,并且会自己执行定时任务。在我们的例子中,我们通过添加import context.dispatcher
来使用这个调度器。
2、方法scheduler.scheduleOnce(time, actorRef, message)
会使用time
把信息message
调度到future里,并在时间到之后发送给actorRef
所引用的actor
我们需要创建一个代码查询超时的信息。我们创建了一个没有参数的简单消息CollectionTimeout
。scheduleOnce
方法的返回值是一个Cancellable
实例,如果请求在超时前成功了,我们可以使用它来取消这个定时器。在请求开始的时候,我们需要请求每个设备actor的当前温度。为了可以快速检测到哪些设备在收到ReadTemperature
消息前就已经停止了,我们会watch每一个actor。通过这种方式,我们获得了Terminated
消息,它能表明哪些设备在我们请求中停机了。这样我们就不必等它们超时才能将其标为不可用。
把它们放在一起,我们就得到了actorDeviceGroupQuery
的大致轮廓:
object DeviceGroupQuery {
case object CollectionTimeout
def props(
actorToDeviceId: Map[ActorRef, String],
requestId: Long,
requester: ActorRef,
timeout: FiniteDuration
): Props = {
Props(new DeviceGroupQuery(actorToDeviceId, requestId, requester, timeout))
}
}
class DeviceGroupQuery(
actorToDeviceId: Map[ActorRef, String],
requestId: Long,
requester: ActorRef,
timeout: FiniteDuration
) extends Actor with ActorLogging {
import DeviceGroupQuery._
import context.dispatcher
val queryTimeoutTimer = context.system.scheduler.scheduleOnce(timeout, self, CollectionTimeout)
override def preStart(): Unit = {
actorToDeviceId.keysIterator.foreach { deviceActor ⇒
context.watch(deviceActor)
deviceActor ! Device.ReadTemperature(0)
}
}
override def postStop(): Unit = {
queryTimeoutTimer.cancel()
}
}
跟踪actor状态
在查询actor里不仅存在一个超时定时器,还需要有一个状态来跟踪所有actor哪些回复了,那些停止了,哪些暂时还没消息。一种跟踪这些状态的方式是在actor里创建一个可变的(var)字段。另一种方式使用了更换actor如何回复消息的能力。Receive
是一个函数(或者一个对象),它可以被另一个函数返回。默认情况下,receive
代码块定义了actor的行为方式,但是actor生命周期内,我们可以改变actor的行为很多次。我们可以通过调用context.become(newBehavior)
来实现上述功能,其中newBehavior
是Receive
类型的(实际上就是一个偏函数PartialFunction[Any, Unit]
的助记符),我们会利用这个特性来跟踪我们的actor状态。
对于我们的用例:
1、我们委托
waitingForReplies
方法来创建Receive
而不是直接定义receive
。
2、waitingForReplies
方法会持续跟踪两个变量
Map
中存储已经返回消息的actor
Set
中存储还没返回消息的actor
因此我们需要做三件事:
1、我们可以从其他设备接受到
RespondTemperature
消息
2、在设备actor停止时我们可以收到Terminated
消息
3、我们可以在定时器触发后获得CollectionTimeout
消息
在前两种情况下,我们需要持续跟踪回复,我们简单把它委托给receivedResponse
方法,我们之后会讨论到它。在超时的情况下,我们仅仅需要把所有还没有回复的actor取出(在stillWaiting
集合里的所有成员),并将其标为DeviceTimedOut
状态。之后我们就可以向查询者回复,并在之后关闭这个请求actor了。
为了做到这一点,我们在DeviceGroupQuery
源文件里添加如下代码:
override def receive: Receive =
waitingForReplies(
Map.empty,
actorToDeviceId.keySet
)
def waitingForReplies(
repliesSoFar: Map[String, DeviceGroup.TemperatureReading],
stillWaiting: Set[ActorRef]
): Receive = {
case Device.RespondTemperature(0, valueOption) ⇒
val deviceActor = sender()
val reading = valueOption match {
case Some(value) ⇒ DeviceGroup.Temperature(value)
case None ⇒ DeviceGroup.TemperatureNotAvailable
}
receivedResponse(deviceActor, reading, stillWaiting, repliesSoFar)
case Terminated(deviceActor) ⇒
receivedResponse(deviceActor, DeviceGroup.DeviceNotAvailable, stillWaiting, repliesSoFar)
case CollectionTimeout ⇒
val timedOutReplies =
stillWaiting.map { deviceActor ⇒
val deviceId = actorToDeviceId(deviceActor)
deviceId -> DeviceGroup.DeviceTimedOut
}
requester ! DeviceGroup.RespondAllTemperatures(requestId, repliesSoFar ++ timedOutReplies)
context.stop(self)
}
目前我们还不知道还如何去改变repliesSoFar
和stillWaiting
的数据结构。一个很重要的事情要注意的是,waitingForReplies
函数不会直接处理消息,它仅仅返回一个将要处理消息的Receive
函数。这意味着如果我们使用不同的参数再次调用waitingForReplies
,它会返回一个全新的Receive
函数,并且使用新的参数来处理消息。
我们已经看到我们可以通过重写receive
方法在actor里设置一个初始的Receive
。我们需要一些机制来置一个新的消息处理方式,那就是context.become(newReceive)
方法。它会将actor的消息处理函数变更为你所提供的newReceive
函数。你可以想象在启动之前,你的actor自动地调用了context.become(receive)
方法来将receive
方法返回的Receive
函数设置默认的消息处理方式。这是很重要的一点,不是receive
处理了actor的消息,它仅仅返回了一个Receive
函数,这个函数才会真正处理消息。
我们现在必须搞清楚receivedResponse
里到底做了什么。首先,我们需要在repliesSoFar
map里记录这个新的返回结果,并且从stillWaiting
里删除它。接下来我们需要检查是否还存在我们需要等待的actor。如果没有了,我们就发送请求结果给原始请求者并关闭本查询actor,否则我们需要更新repliesSoFar
和stillWaiting
结构,并继续等待回复消息。
在之前的代码里,我们隐含地把Terminated
当作DeviceNotAvailable
回应,所以receivedResponse
就不需要做其他特别的操作了。然而还有一个很小的任务需要我们去做,我们来看这种情况:如果一个设备actor已经回复了温度消息,但在此之后它停机了,我们不希望那停机的消息会影响到我们之前收到的来自它的温度消息。换句话说,我们不希望在收到温度消息后再收到Terminated
事件,我们可以通过调用context.unwatch(ref)
很容易做到这一点。这个方法也保证了我们之后不会收到这个actor的Terminated
消息了,即便它已经在邮箱里了。并且多次调用这个方法并没有什么问题,只有第一次调用会起效,剩下的都会被直接忽略。
有了这些知识,我们可以创建receivedResponse
方法如下:
def receivedResponse(
deviceActor: ActorRef,
reading: DeviceGroup.TemperatureReading,
stillWaiting: Set[ActorRef],
repliesSoFar: Map[String, DeviceGroup.TemperatureReading]
): Unit = {
context.unwatch(deviceActor)
val deviceId = actorToDeviceId(deviceActor)
val newStillWaiting = stillWaiting - deviceActor
val newRepliesSoFar = repliesSoFar + (deviceId -> reading)
if (newStillWaiting.isEmpty) {
requester ! DeviceGroup.RespondAllTemperatures(requestId, newRepliesSoFar)
context.stop(self)
} else {
context.become(waitingForReplies(newRepliesSoFar, newStillWaiting))
}
}
在这种情况下我们自然会问,我们为什么要使用context.become()
这个把戏而不是直接把repliesSoFar
和stillWaiting
结构给改掉呢,这到底给我们带来了什么好处呢?在这个简单的例子里,并没有多大好处。当你突然有更多的状态后,像这种风格的状态变量会明显地增多。由于每个状态都有其相关的临时数据,把它们作为类字段会污染整个actor的状态。例如,我们会搞不清楚那个字段改在那个状态下被改变。把查询actor用var
来代替context.become()
其实也是一种好的方式,然而,我们还是推荐使用我们已经使用的这种方式,因为它可以帮助我们结构化更复杂的actor代码,并提高可维护性。
我们的查询actor已经写完了:
object DeviceGroupQuery {
case object CollectionTimeout
def props(
actorToDeviceId: Map[ActorRef, String],
requestId: Long,
requester: ActorRef,
timeout: FiniteDuration
): Props = {
Props(new DeviceGroupQuery(actorToDeviceId, requestId, requester, timeout))
}
}
class DeviceGroupQuery(
actorToDeviceId: Map[ActorRef, String],
requestId: Long,
requester: ActorRef,
timeout: FiniteDuration
) extends Actor with ActorLogging {
import DeviceGroupQuery._
import context.dispatcher
val queryTimeoutTimer = context.system.scheduler.scheduleOnce(timeout, self, CollectionTimeout)
override def preStart(): Unit = {
actorToDeviceId.keysIterator.foreach { deviceActor ⇒
context.watch(deviceActor)
deviceActor ! Device.ReadTemperature(0)
}
}
override def postStop(): Unit = {
queryTimeoutTimer.cancel()
}
override def receive: Receive =
waitingForReplies(
Map.empty,
actorToDeviceId.keySet
)
def waitingForReplies(
repliesSoFar: Map[String, DeviceGroup.TemperatureReading],
stillWaiting: Set[ActorRef]
): Receive = {
case Device.RespondTemperature(0, valueOption) ⇒
val deviceActor = sender()
val reading = valueOption match {
case Some(value) ⇒ DeviceGroup.Temperature(value)
case None ⇒ DeviceGroup.TemperatureNotAvailable
}
receivedResponse(deviceActor, reading, stillWaiting, repliesSoFar)
case Terminated(deviceActor) ⇒
receivedResponse(deviceActor, DeviceGroup.DeviceNotAvailable, stillWaiting, repliesSoFar)
case CollectionTimeout ⇒
val timedOutReplies =
stillWaiting.map { deviceActor ⇒
val deviceId = actorToDeviceId(deviceActor)
deviceId -> DeviceGroup.DeviceTimedOut
}
requester ! DeviceGroup.RespondAllTemperatures(requestId, repliesSoFar ++ timedOutReplies)
context.stop(self)
}
def receivedResponse(
deviceActor: ActorRef,
reading: DeviceGroup.TemperatureReading,
stillWaiting: Set[ActorRef],
repliesSoFar: Map[String, DeviceGroup.TemperatureReading]
): Unit = {
context.unwatch(deviceActor)
val deviceId = actorToDeviceId(deviceActor)
val newStillWaiting = stillWaiting - deviceActor
val newRepliesSoFar = repliesSoFar + (deviceId -> reading)
if (newStillWaiting.isEmpty) {
requester ! DeviceGroup.RespondAllTemperatures(requestId, newRepliesSoFar)
context.stop(self)
} else {
context.become(waitingForReplies(newRepliesSoFar, newStillWaiting))
}
}
}
测试查询actor
现在让我们来验证测试actor实现的正确性吧。为了保证所有工作符合我们预期,我们需要对各种场景去单独测试。为了做到这一点,我们需要模拟出一些设备actor,用来测试正常和失败的情况。多亏我们的查询actor有一个设备actor的列表(实际上是一个Map
),所以我们可以很简单地把TestProbe
传进去。在我们第一个测试用例里,我们测试当我们拥有两个设备actor,并且它们都返回温度报告的情景:
"return temperature value for working devices" in {
val requester = TestProbe()
val device1 = TestProbe()
val device2 = TestProbe()
val queryActor = system.actorOf(DeviceGroupQuery.props(
actorToDeviceId = Map(device1.ref -> "device1", device2.ref -> "device2"),
requestId = 1,
requester = requester.ref,
timeout = 3.seconds
))
device1.expectMsg(Device.ReadTemperature(requestId = 0))
device2.expectMsg(Device.ReadTemperature(requestId = 0))
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(1.0)), device1.ref)
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(2.0)), device2.ref)
requester.expectMsg(DeviceGroup.RespondAllTemperatures(
requestId = 1,
temperatures = Map(
"device1" -> DeviceGroup.Temperature(1.0),
"device2" -> DeviceGroup.Temperature(2.0)
)
))
}
这是我们一个成功的例子,但是我们知道设备有时会不能提供温度测量结果,这种场景和之前有略微不同:
"return TemperatureNotAvailable for devices with no readings" in {
val requester = TestProbe()
val device1 = TestProbe()
val device2 = TestProbe()
val queryActor = system.actorOf(DeviceGroupQuery.props(
actorToDeviceId = Map(device1.ref -> "device1", device2.ref -> "device2"),
requestId = 1,
requester = requester.ref,
timeout = 3.seconds
))
device1.expectMsg(Device.ReadTemperature(requestId = 0))
device2.expectMsg(Device.ReadTemperature(requestId = 0))
queryActor.tell(Device.RespondTemperature(requestId = 0, None), device1.ref)
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(2.0)), device2.ref)
requester.expectMsg(DeviceGroup.RespondAllTemperatures(
requestId = 1,
temperatures = Map(
"device1" -> DeviceGroup.TemperatureNotAvailable,
"device2" -> DeviceGroup.Temperature(2.0)
)
))
}
我们还知道有时候设备actor会在回复前停机:
"return DeviceNotAvailable if device stops before answering" in {
val requester = TestProbe()
val device1 = TestProbe()
val device2 = TestProbe()
val queryActor = system.actorOf(DeviceGroupQuery.props(
actorToDeviceId = Map(device1.ref -> "device1", device2.ref -> "device2"),
requestId = 1,
requester = requester.ref,
timeout = 3.seconds
))
device1.expectMsg(Device.ReadTemperature(requestId = 0))
device2.expectMsg(Device.ReadTemperature(requestId = 0))
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(1.0)), device1.ref)
device2.ref ! PoisonPill
requester.expectMsg(DeviceGroup.RespondAllTemperatures(
requestId = 1,
temperatures = Map(
"device1" -> DeviceGroup.Temperature(1.0),
"device2" -> DeviceGroup.DeviceNotAvailable
)
))
}
不知道你是否记得还有另一种依赖设备actor停机的场景。我们在收到温度消息后就接收到actor结束的消息了。在这种场景下,我们需要保持第一次返回的温度数据而不是把actor标记为DeviceNotAvailable
,让我们测试下:
"return temperature reading even if device stops after answering" in {
val requester = TestProbe()
val device1 = TestProbe()
val device2 = TestProbe()
val queryActor = system.actorOf(DeviceGroupQuery.props(
actorToDeviceId = Map(device1.ref -> "device1", device2.ref -> "device2"),
requestId = 1,
requester = requester.ref,
timeout = 3.seconds
))
device1.expectMsg(Device.ReadTemperature(requestId = 0))
device2.expectMsg(Device.ReadTemperature(requestId = 0))
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(1.0)), device1.ref)
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(2.0)), device2.ref)
device2.ref ! PoisonPill
requester.expectMsg(DeviceGroup.RespondAllTemperatures(
requestId = 1,
temperatures = Map(
"device1" -> DeviceGroup.Temperature(1.0),
"device2" -> DeviceGroup.Temperature(2.0)
)
))
}
最后一个场景就是当存在一些设备在规定时间内没有给出应答,为了让我们的测试用例跑的快一点,我们在构建DeviceGroupQuery
actor时传入一个较小的超时时间:
"return DeviceTimedOut if device does not answer in time" in {
val requester = TestProbe()
val device1 = TestProbe()
val device2 = TestProbe()
val queryActor = system.actorOf(DeviceGroupQuery.props(
actorToDeviceId = Map(device1.ref -> "device1", device2.ref -> "device2"),
requestId = 1,
requester = requester.ref,
timeout = 1.second
))
device1.expectMsg(Device.ReadTemperature(requestId = 0))
device2.expectMsg(Device.ReadTemperature(requestId = 0))
queryActor.tell(Device.RespondTemperature(requestId = 0, Some(1.0)), device1.ref)
requester.expectMsg(DeviceGroup.RespondAllTemperatures(
requestId = 1,
temperatures = Map(
"device1" -> DeviceGroup.Temperature(1.0),
"device2" -> DeviceGroup.DeviceTimedOut
)
))
}
现在我们的查询工作和预期一致了,是时候把这个新方法放到DeviceGroup
里了。
给组添加查询能力
现在给组actor添加查询能力已经相当简单了,我们把所有的重活都留给了查询actor。组actor只需要使用正确的参数创建它就行了。
class DeviceGroup(groupId: String) extends Actor with ActorLogging {
var deviceIdToActor = Map.empty[String, ActorRef]
var actorToDeviceId = Map.empty[ActorRef, String]
var nextCollectionId = 0L
override def preStart(): Unit = log.info("DeviceGroup {} started", groupId)
override def postStop(): Unit = log.info("DeviceGroup {} stopped", groupId)
override def receive: Receive = {
// ... other cases omitted
case RequestAllTemperatures(requestId) ⇒
context.actorOf(DeviceGroupQuery.props(
actorToDeviceId = actorToDeviceId,
requestId = requestId,
requester = sender(),
3.seconds
))
}
}
我们在章节开始的时候提到的一点这里需要重复一下,我们把温度状态有关的请求保存在了另一个独立的actor里,并使组actor特别精简。他代理了子actor的所有事件,但是并不需要持有与它核心业务无关的状态。另外,多个请求可以并行地运行,实际上,你想同时查多少个都行。在我们的场景下,查询一个单独的actor是很快的操作,但是如果不是这样呢?例如,在远程查询的情况下,远程传感器需要通过网络来交流,在这种情况下,使用我们这种方式会就显著提高吞吐率。
我们以一个全局测试的例子来结束我们的教程。这个测试用例只是之前的一个变体:
"be able to collect temperatures from all active devices" in {
val probe = TestProbe()
val groupActor = system.actorOf(DeviceGroup.props("group"))
groupActor.tell(DeviceManager.RequestTrackDevice("group", "device1"), probe.ref)
probe.expectMsg(DeviceManager.DeviceRegistered)
val deviceActor1 = probe.lastSender
groupActor.tell(DeviceManager.RequestTrackDevice("group", "device2"), probe.ref)
probe.expectMsg(DeviceManager.DeviceRegistered)
val deviceActor2 = probe.lastSender
groupActor.tell(DeviceManager.RequestTrackDevice("group", "device3"), probe.ref)
probe.expectMsg(DeviceManager.DeviceRegistered)
val deviceActor3 = probe.lastSender
// Check that the device actors are working
deviceActor1.tell(Device.RecordTemperature(requestId = 0, 1.0), probe.ref)
probe.expectMsg(Device.TemperatureRecorded(requestId = 0))
deviceActor2.tell(Device.RecordTemperature(requestId = 1, 2.0), probe.ref)
probe.expectMsg(Device.TemperatureRecorded(requestId = 1))
// No temperature for device3
groupActor.tell(DeviceGroup.RequestAllTemperatures(requestId = 0), probe.ref)
probe.expectMsg(
DeviceGroup.RespondAllTemperatures(
requestId = 0,
temperatures = Map(
"device1" -> DeviceGroup.Temperature(1.0),
"device2" -> DeviceGroup.Temperature(2.0),
"device3" -> DeviceGroup.TemperatureNotAvailable)))
}
总结
在物联网系统环境中,这个教程介绍了以下概念:
1、actor的层级结构和生命周期
2、把消息设计灵活的重要性
3、如果有必要,怎样观察一个停止的actor
接下来
为了继续你的Akka旅程,我们推荐:
1、开始建立你自己的Akka应用程序,你通过可以加入社区,当你遇到困难可以在那得到帮助。
2、如果你想要更多的知识,阅读剩下的参考文献、视频。