第五部分:查詢設備組
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、如果你想要更多的知識,閱讀剩下的參考文獻、視頻。