上篇我介紹了CQRS模式存寫部分的具體實現和akka-persistence一些函數和消息的用法。在這篇本來是準備直接用一個具體的例子來示範CQRS模式編程,主要是寫端,或者是數據採集端。想着模擬收銀機的後端操作,可以使用集羣分片(cluster-sharding),每個分片shard代表一部POS機控制系統。在寫這段程序之前首先把示例功能實現、cluster-sharding, persistence-actor,actor-passivation, backoff-supervisor, ClusterSharding.start和ClusterSharding.startProxy等技術細節搞清楚:
1、構建幾個測試銷售的產品信息
2、設計一套簡單但功能完整的操作指令command
3、設計運算狀態,即一單未結算銷售單據的狀態。相關的指令-事件command-event轉換和狀態更新機制
4、單據狀態初始化
5、業務邏輯部分,從接到各項指令、指令-事件轉換、處理副作用、存寫事件、更新單據狀態
6、結束單據處理
以一單支付金額大於等於應付金額作爲整單結束狀態。此時應進行下面的處理:
1)增加單號 2)清除所有交易項目 3)saveSnapshot (重啓就不用恢復前面的事件persistent-events)
7、資源釋放策略及處理 passivation
如果一個shard-entity暫不使用時最好先停掉stop以釋放它佔用的資源。但用常規的方式停止entity會造成mailbox裏未處理的消息丟失,所以cluster-sharding有一套特別的機制ClusterSharding.Passivate(actorRef)來實現shard-entity的安全停用,即:目標entity向ShardRegion發送Passivate(stopMessage)消息、ShardRegion向目標entity發送包嵌在消息裏的stopMessage。目標entity在收到消息後可以自行停止。ShardRegion會保留收到Passivate消息到目標entity停止之間收到的消息,還給再啓動的entity。在本例子裏passivation的應用場景如下:每單支付後如果一段時間沒有收到新的開單指令,這個shard-entity可以通過向ShardRegion發送Passivate消息或按空轉時間段設定自動passivate自己,這時ShardRegion在entity空轉超出時間後自動發送ClusterSharding.start(...)裏定義的handOffStopMessage(PoisonPill),如下:
def passivate(entity: ActorRef, stopMessage: Any): Unit = {
idByRef.get(entity) match {
case Some(id) ⇒ if (!messageBuffers.contains(id)) {
passivating = passivating + entity
messageBuffers.add(id)
entity ! stopMessage
} else {
log.debug("Passivation already in progress for {}. Not sending stopMessage back to entity.", entity)
}
case None ⇒ log.debug("Unknown entity {}. Not sending stopMessage back to entity.", entity)
}
}
def passivateIdleEntities(): Unit = {
val deadline = System.nanoTime() - settings.passivateIdleEntityAfter.toNanos
val refsToPassivate = lastMessageTimestamp.collect {
case (entityId, lastMessageTimestamp) if lastMessageTimestamp < deadline ⇒ refById(entityId)
}
if (refsToPassivate.nonEmpty) {
log.debug("Passivating [{}] idle entities", refsToPassivate.size)
refsToPassivate.foreach(passivate(_, handOffStopMessage))
}
}
啓動passivation的時間長度可以通過配置文件或者直接在代碼裏設置:在配置文件中設置 akka.cluster.sharding.passivate-idle-entity-after = 2m,代表兩分鐘內沒有接收從ShardRegion發來的POS指令即啓動passivation(經entity自身actor或actorRef收發的消息不算)。可以設置off關閉自動passivation。其它設置值參考如下:
ns, nano, nanos, nanosecond, nanoseconds
us, micro, micros, microsecond, microseconds
ms, milli, millis, millisecond, milliseconds
s, second, seconds
m, minute, minutes
h, hour, hours
d, day, days
也可以直接在代碼裏設定ClusterShardingSettings.passivateIdleEntityAfter=2 minutes。不過我們還是選擇配置文件方式,比較靈活。下面是一個包括了passivation, backoffSupervisor的示範代碼:
import akka.cluster.sharding.ShardRegion.Passivate
import scala.concurrent.duration._
object SupervisionSpec {
val config =
ConfigFactory.parseString(
"""
akka.actor.provider = "cluster"
akka.loglevel = INFO
""")
case class Msg(id: Long, msg: Any)
case class Response(self: ActorRef)
case object StopMessage
val idExtractor: ShardRegion.ExtractEntityId = {
case Msg(id, msg) ⇒ (id.toString, msg)
}
val shardResolver: ShardRegion.ExtractShardId = {
case Msg(id, msg) ⇒ (id % 2).toString
}
class PassivatingActor extends Actor with ActorLogging {
override def preStart(): Unit = {
log.info("Starting")
}
override def postStop(): Unit = {
log.info("Stopping")
}
override def receive: Receive = {
case "passivate" ⇒
log.info("Passivating")
context.parent ! Passivate(StopMessage)
// simulate another message causing a stop before the region sends the stop message
// e.g. a persistent actor having a persist failure while processing the next message
context.stop(self)
case "hello" ⇒
sender() ! Response(self)
case StopMessage ⇒
log.info("Received stop from region")
context.parent ! PoisonPill
}
}
}
class SupervisionSpec extends AkkaSpec(SupervisionSpec.config) with ImplicitSender {
import SupervisionSpec._
"Supervision for a sharded actor" must {
"allow passivation" in {
val supervisedProps = BackoffSupervisor.props(Backoff.onStop(
Props(new PassivatingActor()),
childName = "child",
minBackoff = 1.seconds,
maxBackoff = 30.seconds,
randomFactor = 0.2,
maxNrOfRetries = -1
).withFinalStopMessage(_ == StopMessage))
Cluster(system).join(Cluster(system).selfAddress)
val region = ClusterSharding(system).start(
"passy",
supervisedProps,
ClusterShardingSettings(system),
idExtractor,
shardResolver
)
region ! Msg(10, "hello")
val response = expectMsgType[Response](5.seconds)
watch(response.self)
region ! Msg(10, "passivate")
expectTerminated(response.self)
// This would fail before as sharded actor would be stuck passivating
region ! Msg(10, "hello")
expectMsgType[Response](20.seconds)
}
}
}
8、異常處理、重試策略 backoffsupervisor 實現,如下:
val supervisedProps = BackoffSupervisor.props(Backoff.onStop(
Props(new EventWriter()),
childName = "child",
minBackoff = 1.seconds,
maxBackoff = 30.seconds,
randomFactor = 0.2,
maxNrOfRetries = -1
))
//自動passivate時設定 .withFinalStopMessage(_ == StopMessage))
9、分片sharding部署
一般來說可以通過ClusterSharding(system).start(...)在每個節點上部署分片,如:
ClusterSharding(system).start(
typeName = shardName,
entityProps = POSProps,
settings = mySettings,
extractEntityId = getPOSId,
extractShardId = getShopId,
allocationStrategy = ClusterSharding(system).defaultShardAllocationStrategy(mySettings),
handOffStopMessage = PassivatePOS
)
但如果分片的調用客戶端所在節點因某種原因不能部署分片時可以用ClusterSharding(system).startProxy(...)部署一個分片代理:
ClusterSharding(system).startProxy(
typeName = shardName,
role = Some(role),
extractEntityId = getPOSId,
extractShardId = getShopId
)
實際上當所在節點的role不等於startProxy參數role時才能啓動這個分片代理。下面是一個成功部署分片代理的例子:
def create(port: Int): ActorSystem = {
var config: Config = ConfigFactory.load()
if (port != 2554)
config = ConfigFactory.parseString(s"akka.remote.netty.tcp.port=$port")
.withFallback(ConfigFactory.parseString("akka.cluster.roles = [shard]"))
.withFallback(ConfigFactory.load())
else
config = ConfigFactory.parseString(s"akka.remote.netty.tcp.port=$port")
.withFallback(ConfigFactory.load())
val system = ActorSystem("posSystem",config)
val role = "shard"
val mySettings = ClusterShardingSettings(system) //.withPassivateIdleAfter(10 seconds)
.withRole(role)
/*
val allocationStrategy = new ShardCoordinator.LeastShardAllocationStrategy(rebalanceThreshold = 2, maxSimultaneousRebalance = 1)
val region = ClusterSharding(system).start(
"myType",
InactiveEntityPassivationSpec.Entity.props(probe.ref),
settings,
extractEntityId,
extractShardId,
ClusterSharding(system).defaultShardAllocationStrategy(settings),
Passivate
) */
if (port != 2554) {
ClusterSharding(system).start(
typeName = shardName,
entityProps = POSProps,
settings = mySettings,
extractEntityId = getPOSId,
extractShardId = getShopId,
allocationStrategy = ClusterSharding(system).defaultShardAllocationStrategy(mySettings),
handOffStopMessage = PassivatePOS
)
println(s"************** cluster-shard created at port $port **************")
}
else {
ClusterSharding(system).startProxy(
typeName = shardName,
role = Some(role),
extractEntityId = getPOSId,
extractShardId = getShopId
)
println(s"************** cluster-shard-proxy created at port $port **************")
}
val eventListener = system.actorOf(Props[EventLisener],"eventListener")
system
}
配置文件指定分片部署role例子:
cluster {
seed-nodes = [
"akka.tcp://[email protected]:2551"]
log-info = off
sharding {
role = "shard"
passivate-idle-entity-after = 10 s
}
}
10、設計後端執行命令後返回的結果類型
11、設計一套POS前端的命名規則:因爲有關POS過程的事件持久化是以persistenceId辨別的,所以一個POS編號應該有一個對應的persistenceId,所有這個POS編號的事件都以對應的persistenceId來存儲。我們先跳到ClusterSharding是如何動態地構建和部署ShardRegion和entity的:ClusterSharding是通過兩個函數extractShardId,extractEntityId來對應ShardRegion和Entity實例的。用一個shardId去調用ShardRegion,如果不存在就用這個Id構建一個。ShardRegion是個actor,那麼這個Id應該就是它的ActorPath.name。同樣ShardRegion也會用一個entityId去構建Entity。這個entityId也就是Entity的ActorPath.name了。而從ActorPath結構來看:ShardRegion是Entity的父輩。最終,我們可以用父子關係的ActorPath.name來代表persistenceId,如:
// self.path.parent.name is the type name (utf-8 URL-encoded)
// self.path.name is the entry identifier (utf-8 URL-encoded) but entity has a supervisor
override def persistenceId: String = self.path.parent.parent.name + "-" + self.path.parent.name
如果考慮的全面些,我們可以把區域zone,門店shop,部門dpt,POS機合併成一個唯一的Id:
1位zoneId+3位shopId+2位dptId+4位POSId => 10位POSUID 如1001019365
12、用actor來模擬POS前端。店號與shardId, 機號與entityId對應。暫時用以顯示後端執行指令結果。
以上這12個關注點算是我編程前的一些思路和備註。然後就開始寫示範代碼了。經歷了好幾遍周折,這段CQRS的C部分是越寫越細、越複雜。主要是想把這個例子做成一個將來可以落地的項目(剛好有朋友公司他們提供零售IT解決方案,需要一個平臺化android前端POS解決方案),自不然又不斷考慮前端移動客戶和後端CQRS的Q部分如何實現的問題,這時在一個局部功能的實現裏需要照顧到全局的功能需求,往往把應該在其它部分實現的功能都放到這個C部分代碼中來了。所以還是應該先從整體系統考慮的更具體、全面些纔行。
一開始,我的主要注意力是放在persistenceActor的狀態變化,也就是收款機開單操作過程的維護方面。我犯的第一個錯誤就是老是擔心在後面Q端(讀端)能不能實現客單項目內容管理,所以複雜化了event數據結構,總是希望爲Q端提供完整的信息來支持對客單項目內容的管理。實際上C端和Q端各自的功能應該是:C端主要負責把所有的操作動作都記錄下來,Q端把這些動作恢復成交易項目,形成客單內容,然後管理整個客單狀態。C端只維護客單的開始、結束狀態。至於這張單項目內容的修改、調整則應該是放在Q端的。這樣一來,正如本篇標題所述:還是需要多想想,有全局思路。下面是我重新整理的一些想法:
1、整體考慮前端POS機客戶端、C端、Q端:前端接收收款員操作動作及應對動作所產生的結果如顯示、打印等。C端負責動作的數據採集。Q端負責客單交易內容的構建和管理
2、從C端角度考慮:需要向前端返回每個動作產生的結果,使前端有足夠的信息進行顯示、打印小票等。如實向Q端反應具體操作動作,提供客單狀態如新單、結束、單號等Q端管理客單狀態必要的信息。
3、C端POSHandler是個cluster-sharding-entity persistenceActor with backoffSupervisor。對應的前端POSRouter是客戶端請求入口,是個cluster-singleton,能實現熱插拔、熱轉換。POSRouter可以通過cluster-load-balancing在routees上運行Q端。
4、C端有以下幾種狀態:登陸前、開單中、單結束。C端程序主要是處理這幾種狀態裏的操作
5、整體POS系統是一個雲平臺應用。客戶端通過POSRouter向POS系統請求POS服務。POSRouter是部署在集羣所有節點上的cluster-singleton, 系統通過一個公網IP連接任何一個在線節點的POSRouter,任何一個節點出現異常不會影響系統運行,這是一種高可用的設計。
6、POSHandler是集羣分片,每個分片代表一部物理POS機。POS機號編碼規則爲:客戶號+店號+序號,客戶代表雲POS用戶
7、每客單結束時POSHandler向POSRouter發送消息請求啓動執行一次Q端讀取動作,這樣可以避免持久數據流佔用資源
8、系統應該作爲一種雲服務提供給各種的客戶端設備。客戶端用gRPC連接雲服務端。調用那項服務,用戶有否使用權限由客戶端決定。
原文出處:https://www.cnblogs.com/tiger-xc/p/10564790.html