Akka官方文档之随手记
➡️ 引言
近两年,一直在折腾用FP与OO共存的编程语言Scala,采取以函数式编程为主的方式,结合TDD和BDD的手段,采用Domain Driven Design的方法学,去构造DDDD应用(Domain Driven Design & Distributed)。期间,尝试了大量的框架:业务领域主要适用Akka、Scalaz等框架,UI和基础设施方面主要适用Spring、Kafka等框架,测试则主要适用Spock、ScalaTest、Selenium等框架。
两年的折腾,不能说没有一点收获。在核心的领域模型设计方面,通过尝试用传统Akka的Actor包裹聚合,以自定义的Command和Event进行信息交换,用Free Monad构造表达树实现延迟计算,用Akka Persistence实现Event Store,用Kafka维护命令和事件队列,让我依稀看到了以FP的方式实现ES+CQRS架构应用的曙光。
但如此多的框架不仅让人眼花缭乱,还要学习Scala、Groovy、Java等编程语言以及Gradle等Build工具的DSL,如果再加上Cluster、Cloud和Micro Service,这无疑是一个浩大的工程。所幸,伴随Akka 2.6版本推出的Akka Typed,解决了消息难以管理、框架入侵过甚、节点部署繁复等痛点和难点。特别是在ES和CQRS方面,Akka Typed可以说是提供了一个完整的解决方案。就如同当初LINQ的出现一样,给我打开了一扇新的窗口。
以下,便是我学习Akka Typed官方文档时随看随写留下的笔记(语种Scala,版本2.6.5,在学习过程中已更新至2.6.6),以备查考。内容和路线均以我个人的关注点为主,所以只是节选且难免有失偏颇。
📌 文中提到的RMP是指Vaughn Vernon撰写的Reactive Messaging Patterns with the Actor Model一书。它是我学习传统Actor模型时的重要参考书籍。
📎 在Github上,Paweł Kaczor开发出的akka-ddd框架,是一个非常棒的学习案例。
- akka-ddd:一个基于Akka和EventStore实现的CQRS架构的DDD框架。
- ddd-leaven-akka-v2:使用akka-ddd框架实现的电子商务应用。
目录
- ➡️ Get Started
- ➡️ 术语和概念
- ➡️ Actors
- Actor概貌
- Actor的生命周期
- 交互模式
- 容错能力
- 发现Actor
- 路由 Route
- 暂存 Stash
- Behavior是一台有限状态机
- 协调关机 Coordinated Shutdown
- 消息分发器 Dispatchers
- 邮箱 Mailbox
- 测试
- Akka之Classic与Typed共存
- 函数式与面向对象风格指南
- 从传统Akka过渡
- ➡️ Cluster
- ➡️ Persistence
- ➡️ 其他
➡️ Get Started
Actor模型的优点
- 事件驱动的:Actor相互之间只能用异步的消息进行联系,不会产生直接的耦合。
- 强壮的隔离性: Actor不象普通的对象那样提供可供调用的API,只会暴露它所支持的消息(通信协议),从而避免了状态的共享。
- 位置的透明性: ActorSystem使用工厂创建Actor并返回其引用,所以位置无关紧要,Actor也可以启动、停止、移动或者重启,甚至从故障中恢复。
- 轻量性: 每个Actor通常只需要数百字节的开销,所以一个应用程序完全可能拥有上百万个并发Actor实例。
🔗 Akka库与模块一览
- Actor Library:Akka Typed的核心
- Remoting:使Actor得以分布部署
- Cluster及其Sharding、Singleton:集群支持
- Persistence:使Actor得以将其事件持久化,是实现ES+CQRS的重要组成
- Distributed Data:在Actor之间共享数据
- Stream:使Actor支持流处理
示例 Internet of Things (IoT)
🔗 https://doc.akka.io/docs/akka/current/typed/guide/tutorial.html
这是一个在物联时代,让用户可以借助遍布家中的温度监测仪,随时掌握屋内各个角落温度情况的应用。
📌 所需的准备工作
-
在IDEA的
Editor/General/Auto Import
中排除对*javadsl*
的自动导入。 -
需要的依赖:
implementation 'org.scala-lang:scala-library:2.13.2' implementation 'com.typesafe.akka:akka-actor-typed_2.13:2.6.5' implementation 'ch.qos.logback:logback-classic:1.2.3' testImplementation 'org.scalatest:scalatest_2.13:3.1.2' testImplementation 'com.typesafe.akka:akka-actor-testkit-typed_2.13:2.6.5'
📝 Note
- 打印ActorRef将获得Actor的URL,从中可获悉actor族谱。
- Actor的生命周期始终保持与其父Actor一致,Actor自身停止时推荐返回Behaviors.stopped,父可用context.stop(childRef)停止叶子Actor。
- Actor在其生命周期中可触发PostStop之类的信号(参见RMP-47)。
- 在Actor内部处理消息的是onMessage,处理信号的是onSignal。
- 使用
val child = context.spawn(Behaviors.supervise(child()).onFailure(SupervisorStrategy.restart), name = "child-actor")
改变默认的监督策略Stop。传统Akka采取的方式是override supervisorStrategy,用一个闭包声明Decider函数(参见RMP-52)。 - “协议即API”——在Actor的世界里,协议Protocol取代了传统OOP的接口Interface。利用这些由Command和Event组成的协议,各方的Actor们最终将借助from-replyTo所指向的ActorRef[Command/Event]完成对话。
- ⚡ 传统Akka围绕若干Actor的实例构筑整个系统,而Akka Typed则围绕Behavior的实例构筑系统,这是观念上的巨大差别。
- Command用现在时,可以理解为“我能做什么”,是Actor对外公开的API、是它能提供的服务;而Event则用过去式,表明“我关心什么”,是触发Actor后续动作的事件。
- 传递消息涉及网络、主机、Actor邮箱、Actor消息处理函数等多个环节,所以非常脆弱。主要的模式有三种(参见RMP-164:确保送达机制):
- 最多一次:消息在发出去就不用管,也不用保存消息传送的状态。所以消息可能会丢失。这是Actor默认采用的方式,简单而高效。
- 最少一次:发送后还要保存消息传送状态甚至进行重试,以确保收件人收到消息。所以消息不会丢失,但不能避免重复。
- 正好一次:除了发件人,还要在收件人保存消息传送状态,以确保收件人不会接到重复的消息。所以消息既不会丢失,也不会重复。
- Actor保证直连双方的消息会严格按序传送,但不保证不丢失消息。
- 合理决定Actor的粒度,是Akka设计的核心难点:通常情况下都推荐使用较大的粒度,以降低细粒度引入的复杂度。仅在以下一些情况下方才增加粒度:
- 需要更多的Actor提供更高的并发性。
- 场景本身需要复杂的对话。
- 为减少不同状态之间的耦合度,需要将多个状态分别交由更小的参与者进行独立维护。
- 为隔离失败,减少不同参与者之间相互的干扰与牵扯,确保失败情况造成最小的负面影响。
- 使用Dead Watch实现有关Actor停止时的互动。Dead Watch关系不仅限于父子之间,只要知道对方的ActorRef即可。当被观察的Actor停止时,观察者Actor将会收到一个Terminated(actorRefOfWatchee)信号。由于该信号无法附加其他信息,所以推荐做法是将其再包装成一条消息WatcheeTerminated,并在创建被观察者时就用
context.watchWith(watcheeActor, WatcheeTerminated(watcheeActor,...))
建立观察关系。(💀 WatcheeTerminated会被context自动填充吗?貌似是的。) - 遵循CQRS的原则,在Actor里也推荐读写分离,将Query放入单独的Actor,避免对业务Actor的干扰。在示例中,由业务Actor负责创建Query Actor。
- Query通常都要设置超时,于是引出Actor内建的调度机制,在工厂的Behaviors.setup中使用Behaviors.withTimers定义timers,然后在Actor类里用timers.startSingleTime来调度一条经过给定延时后才发出的消息。
- 对于跨Actor的消息,通常需要使用context.messageAdapter()来提供一个消息转译器。而转译器最简单的方案就是把消息(通常是响应)包裹在本Actor的某个消息里。
➡️ 术语和概念
三种无阻塞设计理念
为防止死锁(大家都抢,结果都吃不上)、饥饿(弱肉强食,弱者老是吃不上)和活锁(大家都谦让,结果都不好意思吃),有三种确保死锁无忧的无阻塞设计,其能力由强到弱如下:
- Wait-Freedom:需要确保每个方法调用都能在有限的步数内完成,能保证不死锁、不饥饿。
- Lock-Freedom:需要确保某些关键方法调用能在有限的步数内完成,即可保证不死锁,但不能避免饥饿。
- Obstruction-Freedom:需要确保某些关键方法在特定的时段或条件下能在有限的步数内完成,即可避免死锁。所有的Lock-Freedom都是Obstruction-Freedom的,反之却不尽然。乐观并发控制(Optimistic Concurrency Control)就是典型的Obstruction-Freedom,因为在特定的时点,当只有一名参与者在尝试时,其共享操作即可完成。
Actor System
关于Actor体系设计的最佳实践
整个Actor System的体系,如同一个组织,任务总是逐级下派,命令总是下级服从上级。这与常见的分层软件设计(Layered Software Design)是不同的,后者总是想方设法把问题隐藏和解决在自己那一层,而不是交给上级去处理或与其他人协商。推荐的做法主要包括:
- 如果一个Actor携带的数据非常重要,那么为了防止自身崩溃,导致数据损失,就应该把危险的任务交给子Actor负责,确保每个Request都由一个独立的子Actor进行处理,并负责好子Actor失败时的善后工作。(这被称作Erlang的“错误内核模式 Error Kernel Pattern”)
- 如果一个Actor依赖另一个Actor来完成自己的工作,那么就要建立Watch关系,确保接受其委托的代理Actor始终处于有效状态。
- 如果一个Actor承担了太多不同的职责,那么就把这些职责分派给不同的子Actor去负责。
关于Actor设计的最佳实践
- Actor应当是位很好的同事,它总是能独立完成自己份内的工作,而且尽可能不打扰别人、不独占资源。即便需要访问某些外部资源,除非是逼不得已,它也不会处于阻塞状态。
- 不要在Actor之间传递可变对象,应尽可能使用不可变的消息。
- Actor被设计成包含了行为与状态的容器,所以不要习惯性地使用闭包等语法糖在消息里夹带行为,这将因分享可变状态而产生各种不可控的意外情况。
- 应用中最顶层的Actor是整个错误内核模式的最核心,它应当只负责启动各个子系统,而不承担其他的业务职责。否则,它会因监督责任过重,影响失败和故障的处理。
协调关机
🔗 https://doc.akka.io/docs/akka/current/coordinated-shutdown.html
当应用的所有工作完成后,可以通知/user监督者停止运行,或者调用ActorSystem.terminate方法,从而通过运行协调关机CoordinatedShutdown来停止所有正在运行的Actor。在此过程中,你还可以执行其他一些清理和扫尾工作。
Actor基础
官方文档有专章讲解Actor的方方面面,本章只是介绍基本概念。
Actor的主要作用包括:向熟识的其他Actor发送消息,创建新的Actor,指定处理下一条消息的行为。它作为一个容器,包括有状态State、行为Behavior、邮箱Mailbox、监督策略Supervisor Strategy以及若干的子Actor等内容物,且该容器只能通过指定消息类型的参数化ActorRef进行引用,以确保最基本的隔离:
- State可以是一台复杂的状态机,也可以只是一个简单的计数值,本质上是由Actor内部维护的一个状态。它将在Actor重启时回复到Actor刚创建时候的样子,或者也可以采用Event Sourcing的方式在重启后恢复到故障发生前的样子。
- Behavior总是和当前Actor要处理的消息相对应,并且在Actor创建之初总会有一个初始化的行为。而在Actor的生命周期内,Actor的Behavior将可能随Actor的状态变化而变化,由上一个Behavior切换至下一个Behavior。
- 由于消息总是发送给ActorRef的,而这背后实际对应的是能响应该消息的Behavior,所以这种对应关系必须在Actor创建之时就声明,且Behavior自身也和ActorRef一样是参数化的,这同时也决定了彼此切换的两个Behavior必须是类型相容的,否则便无法与其ActorRef保持一致。(💀 这便是为什么同一个Actor的Message要从同一个trait派生,以表明它就只处理这一类的消息。)
- 在回应Command的回复消息里,通常都会包括指向应回复Actor的replyTo引用,所以能以这种方式把第三者引入当前的会话当中。
- Mailbox按照消息的发送时间将收到的消息排好队,再交给Actor处理。默认的Mailbox是FIFO队列。从Mailbox中出队的消息,总是交由当前的Behavior进行处理。如果Behavior无法处理,就只能作失败处理。
- Child Actors总是由父Actor监管,在spawn或stop后从context的列表中加入或退出,且这一类异步操作不会造成父Actor的阻塞。
- Supervisor Strategy用于定义异常发生时的应对策略。默认情况下Akka Typed在触发异常时采取停止Actor的策略,而传统的Akka则采取的重启策略。
在Actor终止后,其持有的所有资源将被回收,剩下未处理的消息将转入Actor System的死信邮箱Dead Letter Mailbox,而后续新传来的消息也将悉数转到System的EventStream作为死信处理。
监管与监测
⚠️ Akka Typed的监管已经重新设计,与传统Akka有显著区别
监管 Supervision
监管的对象是意料之外的失败(Unexpected Failure),而不是校验错误或者try-catch能处理的预期异常。所以,监管是Actor的额外装饰,并不属于Actor消息处理的组成部分。而后者,则是属于Actor业务逻辑的一部分。
当失败发生时,监管的策略包括以下三种:
- Resume:恢复Actor及其内部状态。
- Restart:清理Actor内部状态并恢复到Actor刚创建时候的样子。实际上,这是由父Actor使用一个新的Behavior实例替换掉当前失败Child Actor的行为,并用新的Actor接管失败Actor的邮箱,从而实现重启。
- Stop:永久地停止Actor。
⚡ 要注意的是,引发失败的那条消息将不会再被处理,而且期间Actor发生的这些变化,在父Actor以外的范围都是不可知的。
生命周期监测 Lifecycle Monitoring
Lifecycle Monitoring通常是指DeathWatch(💀 之前叫Dead Watch,Watch观察,Monitoring监测,译为观察更为妥帖)。这是除了父子间的监管关系外,Actor之间另一种监测关系。由于Supervision导致的Actor Restart对外是不可知的,所以要用Monitoring在一对Actor之间建立监测关系。但从目的上讲二者是有区别的,Supervision主要为应对失败情形,Monitoring主要为确保另一方知悉本方已终止运行。
使用context.watch(targetActorRef)
及unwatch来建立或撤销监测关系。当被监测Actor终止时,监测方Actor将收到一条Terminated消息(💀不是Signal吗?),而默认的消息处理是抛出一个DeathPactException
。
⚡ 要注意的是,监测关系的建立和目标Actor终止时间无关。这就意味着在建立监测关系时,即使目标Actor已经终止,此时监测Actor仍将收到一条Terminated消息。
在消息处理过程中触发异常时的结果
- 对消息而言:该消息将被丢失,不再退回到邮箱。所以必须自己捕获异常,并建立相应的重试机制,并兼顾到非阻塞的要求。
- 对邮箱而言:没有任何影响,后续的消息即使Actor被重启也将全部保留。
- 对Actor而言:如果Actor将异常抛出,则其将被父Actor挂起(Suspend),并根据父Actor的监管策略决定将被恢复、重启还是终止。
容错能力设计
🔗 https://doc.akka.io/docs/akka/current/typed/fault-tolerance.html
Actor引用、路径和地址
一些基本的Actor Reference
- ActorContext.self:指向自己的引用
- PromiseActorRef:由Ask方式为回调而创建的ActorRef
- DeadLetterActorRef:默认的死信服务提供的ActorRef
- EmptyLocalActorRef:当被查找的Actor不存在时Akka使用的ActorRef。它虽等价于DeadLetterActorRef,但因其保留有path,因此该引用仍可被传送,用以与位于相同路径的Actor引用进行比较,以确定后者是否为Actor死亡前获得的。(💀 有点类似Null Object模式。)
Actor引用与路径之间的区别
- Reference与Actor同生共死,随着Actor生命结束而失效。所以即便是处于同一Path的新旧2个Actor,也不会有同一个Reference,这也意味着发给旧ActorRef的消息永远不会自动转发发新的ActorRef。
- Path只是一个代表族谱关系的名字,不存在生存周期,所以永不会失效。
获取Reference的2个主要渠道
- 直接创建Actor。
- 通过接线员Receptionist从已注册的Actor里查找。
Actor与Java内存模型
为防止Actor相互可见和消息乱序问题,Akka严格遵守以下两条“发生之前(happens before)”守则:
- The actor send rule:发件人发送消息将始终先于收件人收到消息。
- The actor subsequent processing rule:任何一个Actor,在任一时刻,有且只能处理一条消息。处理完成当前消息后,才接着处理下一条消息。
可靠的消息投递
Delivery翻译为“投递”更为妥帖,更好模仿邮政业务的妥投等术语。“送达”侧重结果,“发送"侧重动作本身。
Akka消息投递遵循的两条原则
- 一条消息最多被投递一次。从业务角度讲,相比命令发成功没有,我们实际更关心对方的回复,有回复即印证对方收到命令了,否则重发命令进行催促即可。
- 在一对发件人-收件人之间,消息的发送与接收顺序始终保持一致(仅限于用户自定义消息,不包括父子间的系统消息)
Akka消息传递采用的ACK-RETRY协议内容
- 区分不同的消息及其确认消息的标识机制
- 在超时前仍未收到预期的确认消息时的重试机制
- 收件人甄别重复消息并决定丢弃它的检测机制。实现它的第一种方式,是直接采用Akka的妥投模块,改变消息投递模式为最少投递一次。第二种方式,是从业务逻辑的角度,确保消息处理的设计是幂等的。
保证妥投模块
借助Akka Persistence确保消息妥投。(参见RMP-164)
🔗 https://doc.akka.io/docs/akka/current/typed/reliable-delivery.html
事件溯源
事件溯源的本质,是执行一条Command,衍生出若干条Event,这些Event既是Command产生的副作用,也是改变对象状态的动因,及其生命周期内不可变的历史。
Akka Persistence对事件溯源提供了直接支持。
🔗 https://doc.akka.io/docs/akka/current/typed/persistence.html#event-sourcing-concepts
带确认回执的邮箱
可以通过自定义邮箱,实现消息投递的重试。但这多数仅限于本地通讯的场景,具体原因参见🔗 The Rules for In-JVM (Local) Message Sends
死信
无法妥投的而不是因网络故障等原因被丢失了的消息,将被送往名为/deadLetters的Actor,因此这些消息被称为Dead Letter(参见RMP-161)。产生死信的原因主要是收件人不详或已经死亡,而死信Actor也主要用于系统调试。
由于死信不能通过网络传递,所以要搜集一个集群内的所有死信,则需要一台一台地收集每台主机本地的死信后再进行汇总。通过在系统的Event Stream对象akka.actor.DeadLetter
中注册,普通Actor将可以订阅到本地的所有死信消息。
配置
Akka使用Typesafe Config Library管理配置信息。该库独立于Akka,也可用于其他应用的配置信息管理。
Akka的ActorSystem在启动时,所有的配置信息均会通过解析class path根目录处的application.conf/.json/.properties等文件而加载入Config对象,并通过合并所有的reference.conf形成后备配置。
⚠️ 若正在编写的属于Akka应用程序,则Akka配置信息应写入application.conf;若是基于Akka的库,则配置信息应写入reference.conf。并且,Akka不支持从另一个库中覆写(override)当前库中的config property。
配置信息既可以从外部配置文件加载,也可用代码实现运行时解析,还可以利用ConfigFactory.load()从不同地方加载。
import akka.actor.typed.ActorSystem
import com.typesafe.config.ConfigFactory
val customConf = ConfigFactory.parseString("""
akka.log-config-on-start = on
""")
// ConfigFactory.load sandwiches customConfig between default reference
// config and default overrides, and then resolves it.
val system = ActorSystem(rootBehavior, "MySystem", ConfigFactory.load(customConf))
一个典型的多项目配置示例:
myapp1 {
akka.loglevel = "WARNING"
my.own.setting = 43
}
myapp2 {
akka.loglevel = "ERROR"
app2.setting = "appname"
}
my.own.setting = 42
my.other.setting = "hello"
相应的配置信息加载代码示例:
val config = ConfigFactory.load()
val app1 = ActorSystem(rootBehavior, "MyApp1", config.getConfig("myapp1").withFallback(config))
val app2 = ActorSystem(rootBehavior, "MyApp2", config.getConfig("myapp2").withOnlyPath("akka").withFallback(config))
🔗 Akka的默认配置列表,长达近千行……
📎 Akka Config Checker是用于查找Akka配置冲突的有力工具。
➡️ Actors
🏭 com.typesafe.akka:akka-actor-typed:2.6.5
Actor概貌
Hello World
示例HelloWorld是由HelloWorldMain创建一个HelloWorld(即Greeter),在每次ActorSystem要求HelloWorld SayHello的时候,就创建一个SayHello消息所赋名称对应的HelloWorldBot(所以会有若干个动作相同但名称不同的Bot),然后要求Greeter去向这个Bot问好,最后以Greeter与Bot相互问候数次作为结束。
示例采用了FP风格,Actor的状态和行为均在Singleton对象里定义,采用了类似传统Akka receive()
的函数Behaviors.receive { (context, message) => ... }
,以消息类型作为约束,实现了Actor的互动与组合。在每个Bot里,利用消息的递归重入维持一个Greeting的计数值,届满则用Behaviors.stopped停止响应,否则递归重入。
Behaviors.receive {...}与receiveMessage {...}的区别,在于前者将把context带入闭包。
ChatRoom
这是一个类似聊天室功能的示例,各Actor的职责、定义和联系如下表:
Actor | 职责 | Behavior类型 | Command | Event |
---|---|---|---|---|
Main | 创建聊天室ChatRoom和客户Gabbler,并为二者牵线搭桥 | NotUsed | ||
ChatRoom | 创建并管理一组Session | RoomCommand |
|
|
Session | 负责播发诸如Gabbler这样的Client的发言 | SessionCommand |
|
|
Gabbler | 响应Session | SessionEvent |
|
示例先采用FP风格实现。比如ChatRoom在处理GetSession消息时,最后以chatRoom(ses :: sessions)返回一个新的Behavior实例结束,这里的sessions正是Actor ChatRoom维护的状态。
示例演示了如何限制消息的发件人。比如session及其工厂方法,以及PublishSessionMessage类型均为chatroom私有,外部不可访问;在session Behavior的PostMessage分支中,chatroom的ActorRef通过工厂方法传入session,且类型被限制为ActorRef[PublishSessionMessage]。这样外界只能与ChatRoom通信,然后由ChatRoom在内部将消息转交Session处理。
处理消息的参数来源于工厂方法的传入参数,还是封装在消息的字段里,这个示例也分别给出了样板。💀 在设计通信协议时,消息定义为Command还是Event,消息的主人是谁,处理消息需要的参数如何传入等等,都是需要考虑的问题。
为实现程序安全退出,示例在Main的Behavior里,设置了Dead Watch观察gabbler,并定义了Behaviors.receiveSignal {...},在收到gabbler处理完MessagePosted消息,因返回Behaviors.stopped而发出的Terminated信号后,以Main自身的Behaviors.stopped作为结束。
⚡ Behaviors.setup是一个Behavior的工厂方法,该Behavior的实例将在Actor启动后才创建。而Behaviors.receive虽也是Behavior的工厂方法之一,但Behavior的实例却是在Actor启动的那一刻就同时创建的。
Actor的生命周期
Actor是一个需要显式启停并且自带状态的资源(子Actor与随父Actor虽不共生、但定共死),所以回想在GC出现前需要自己管理内存句柄的时代吧。
Actor System是一个高能耗的系统,所以通常一个应用或者一个JVM里只有一个Actor System。
创建Actor
ActorContext
ActorContext可用作:
- 孵化(Spawn)子Actor和监管关系。
- 观察(Watch)其他Actor,并在被观察Actor停止运行时收到Terminated事件(信号)。
- 记录日志(Logging)。
- 创建消息适配器Message Adapter。
- 以Request-Response方式与其他Actor进行交互。
- 访问Actor自身引用
self
。
ActorContext本身并不是完全线程安全的,主要有以下限制:
- 不能从Future回调函数的线程访问。
- 不能在多个Actor实例之间进行共享。
- 只能在普通的消息处理线程里使用。
孵化子Actor
孵化有两层含义:创建并启动。
孵化协议SpawnProtocol
在使用Behaviors.setup启用SpawnProtocol后,在应用中任何地方都将可以不直接引用context,改用telling或asking方式完成Actor系统的组装。其中,Ask方式的使用类似传统Akka,它将返回Future[ActorRef[XX]]。
⚡ 留意示例代码里的几处泛型约束,由这些Message串起了应用的流程。
// 启用SpawnProtocol的Actor
object HelloWorldMain {
def apply(): Behavior[SpawnProtocol.Command] =
Behaviors.setup { context =>
// Start initial tasks
// context.spawn(...)
SpawnProtocol()
}
}
implicit val system: ActorSystem[SpawnProtocol.Command] =
ActorSystem(HelloWorldMain(), "hello")
val greeter: Future[ActorRef[HelloWorld.Greet]] =
system.ask(SpawnProtocol.Spawn(behavior = HelloWorld(), name = "greeter", props = Props.empty, _))
val greetedBehavior = Behaviors.receive[HelloWorld.Greeted] { (context, message) =>
context.log.info2("Greeting for {} from {}", message.whom, message.from)
Behaviors.stopped
}
val greetedReplyTo: Future[ActorRef[HelloWorld.Greeted]] =
system.ask(SpawnProtocol.Spawn(greetedBehavior, name = "", props = Props.empty, _))
for (greeterRef <- greeter; replyToRef <- greetedReplyTo) {
greeterRef ! HelloWorld.Greet("Akka", replyToRef)
}
停止Actor
Actor可以通过返回Behaviors.stopped作为接替Behavior来停止自身运行。
子Actor可以在处理完当前消息后,被其父Actor使用ActorContext.stop方法强行关停。
所有子Actor都将伴随其父Actor关停而关停。
当Actor停止后将会收到一个PostStop信号,可以用Behaviors.receiveSignal在该信号的处理方法里完成其他的清理扫尾工作,或者提前给Behaviors.stopped传入一个负责扫尾的闭包函数,以实现Actor优雅地关停。(💀 经测试,前者将先于后者执行。)
观察Actor
由于Terminated信号只带有被观察者的ActorRef,所以为了添加额外的信息,在注册观察关系时可以用context.watchWith(watchee, SpecifiedMessageRef)取代context.watch(watchee)。这样在Terminated信号触发时,观察者将收到预定义的这个SpecifiedMessageRef。
⚡ 注册、撤销注册和Terminated事件的到来,在时序上并不一定严格遵守先注册后Terminated这样的规则,因为消息是异步的,且有邮箱的存在。
交互模式
Actor之间的交互,只能通过彼此的ActorRef[Message]来进行。这些ActorRef和Message,构成了Protocol的全部,既表明了通信的双方,也表明了Actor能处理的消息、限制了能发给它的消息类型。
📎 要运行示例代码,需要导入日志和Ask模式的支持:
import akka.actor.typed.scaladsl.LoggerOps
import akka.actor.typed.scaladsl.AskPattern._
并且在test/resources文件夹下的logback-test.xml里配置好日志:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<pattern>[%date{ISO8601}] [%level] [%logger] [%marker] [%thread] - %msg MDC: {%mdc}%n</pattern>
</encoder>
</appender>
<appender name="CapturingAppender" class="akka.actor.testkit.typed.internal.CapturingAppender"/>
<logger name="akka.actor.testkit.typed.internal.CapturingAppenderDelegate">
<appender-ref ref="STDOUT"/>
</logger>
<root level="DEBUG">
<appender-ref ref="CapturingAppender"/>
</root>
</configuration>
⭕ Fire-Forget
使用异步的、线程安全的tell发出消息,但不保证对方收到消息,也不关心该消息是否被对方处理完毕了。
实现要点
recipient ! message
适用场景
- 当消息是否被处理无关紧要时;
- 当对于消息未妥投或未处理的情形不需要处置预案时;
- 当为了提高吞吐量而需要最小化消息数量时(通常为发送一条响应需要创建两倍数量的消息)。
缺点
- 如果来信数量超过处理能力,会把邮箱撑破;
- 发件人不会知晓消息是否丢失了。
⭕ Request & Response
发件人发出Request并附上回信地址,并以获得收件人的Response作为消息妥投并被处理的确信。
实现要点
先定义Request和Response,随后sender在发出Request时把self作为replyTo的ActorRef一并传出,方便recipient收到Request后回复Response。
适用场景
- 当需要订阅对方的Response时。
缺点
- Actor之间通常不会为彼此通信而专门定义一个Response消息(参见Adapted Response);
- 如果未收到Response,很难确定究竟是因为Request未妥投还是未被对方处理(参见ask方式);
- 如果没有Request与Response之间一一对应的甄别机制或上下文,必然毫无用处(参见ask方式,或者每会话子Actor模式)。
⭕ Adapted Response
把收件人的Response进行简单封装,即作为发件人可处理的消息类型,从而减少发件人定义Protocol的负担。
实现要点
定义收件人recipient的Response类型,再在sender里定义适配后的Response类型,然后在其Behavior.setup里用context.messageAdapter(rsp => WrappedResponse(rsp))
注册一个消息适配器,最后在适配后消息的分支里取出原始的Response(当初由收件人回复的),再处理该消息。适配器能匹配预定义的响应类型及其派生类,总以最晚注册的为有效版本,属于sender且与sender同生命周期,所以当适配器触发异常时将导致其宿主停止。
适用场景
- 当需要在2种不同协议间进行转译时;
- 当需要订阅一个Actor返回的多种Response时。
缺点
- 如果未收到Response,很难确定究竟是因为Request未妥投还是未被对方处理(参见ask方式);
- 每种Response类型在任何时候只能有一个有效的适配器,所以若干个Actor只能共用一个适配器。
- 如果没有Request与Response之间一一对应的甄别机制或上下文,必然毫无用处。
⭕ 在Actor之间使用ask方式实现Request-Response
把Request-Response原本使用的tell方式改为ask,从而能限定Response发回的时间,超时则视作ask失败。
实现要点
在提问人中,通过Behaviors.setup定义隐式的超时时限,以context.ask(recipientRef, request) { case Success(Response(msg)) => AdaptedResponse(msg); case Failure(_) => AdaptedResponse(...) }
使用ask方式发出Request,并备妥Response到来时的适配预案(无需再额外象Adapted Response那样注册消息适配器),最后用工厂Behaviors.receiveMessage定义适配后响应消息的处理函数。
适用场景
- 当提问人需要查询单次的Response时;
- 当提问人需要根据上一次Request的回复情况来决定下一步怎么做时;
- 当提问人在指定时限内未收到Response,需要在这种情况下决定重发Request时;
- 当提问人需要主动跟踪Request被处理的情况,而不是一味追问答复人时(参见RMP-93 Back Pressure回压模式,类似有最大容量限制的阻塞队列,超出的请求将被直接拒绝);
- 当Protocol在设计时遗漏了必要的上下文信息,但又需要将信息临时添附到会话中时。(这是指提问人在使用context.ask发出Request前,在ask调用语句前放置的相关信息。💀 这安全吗,如果这些信息被其他代码修改了怎么办?真有必要的话,为什么不放进Request消息的结构里?)
缺点
- ask一次只能得到一条Response消息;
- 提问人给自己提问设置了时限,答复人却未必知晓。所以当ask超时那一刻,答复人可能还在处理Request甚至才刚收到正要处理;
- 很难决策超时设置多长为妥,不当的时限设置可能导致过多的误报。
⭕ 从Actor系统外部使用ask方式实现Request-Response
在Actor System以外直接用ask向某个Actor提问,最终得到用Future包装好的回复。
实现要点
定义隐式的ActorSystem实例和超时时限,用reply: Future[Response] = recipient.ask(ref => Request).flatMap { case response => Future.successful(response); case another => Future.failed(...) }
定义Request-Response的对应关系,再通过system.executionContext
启动执行,最后在reply的回调onComplete { case Success(Response) => ...; case Failure(_) => ...}
里取出Response区别处理。
适用场景
- 当需要从Actor系统之外向某个Actor提问时。
缺点
- 处在不同线程内的Future回调将可能导致各种意外;
- ask一次只能得到一条Response消息;
- 提问人给自己提问设置了时限,答复人却未必知晓。
⭕ 忽略回复
当不关心收件人的回应时,在Request里把回信地址设置为什么也不干的ignoreRef
,使模式从Request-Response变为Fire-Forget。
实现要点
发件人发出Request时,把回复地址从context.self
改为什么消息也不处理的context.system.ignoreRef
。
适用场景
- 当协议里本设定有回复类型,但发件人偶尔不关心Response时。
缺点
由于ignoreRef将忽略所有发给它的消息,所以使用时必须小心。
- 如果使用不当,将会中断两个Actor的已有联系;
- 当有外部ask请求发来时,ignoreRef将必定导致超时。
- Watch ignoreRef将变得没有意义。
⭕ 自提Future结果
在Actor内部有Future类型的调用时,使用pipeToSelf获取回调结果。尽管直接用Future.onComplete也能取出结果,但会因此将Actor的内部状态暴露给外部线程(在onComplete里能直接访问Actor内部状态),所以并不安全。
实现要点
在Actor内部,先定义Future调用futureResult,再使用context.pipeToSelf(futureResult) { case Success(_) => WrappedResult(...); case Failure(_) => WrappedResult(...)}
将回调结果封装入WrappedResult消息,最后在WrappedResult消息分支里再作回应。
适用场景
- 当需要从Actor里使用Future访问诸如数据库之类的外部资源时;
- 当Actor依赖Future返回结果才能完成消息处理时;
- 当需要在Future返回结果时仍保持调用前上下文时。
缺点
- 引入了额外的消息包装。
⭕ 每会话子Actor
当一份响应需要综合多个Actor的回复信息才能作出时,由一个父Actor委托多个子Actor搜集信息,待信息齐备后才由父Actor汇总发回给Request的请求人,请求人除与父Actor之间的协议外,对其间细节一概不知。这些子Actor仅活在每次会话期间,故名为“每会话”的子Actor。
实现要点
由父Actor在Behaviors.setup里构造实际承担工作的一组子Actor,在Request处理过程中构造负责组织协调子Actor的管家Actor(其行为类型为Behavior[AnyRef],以保证类型最大程度地兼容)。随后在管家Actor的Behaviors.setup里向子Actor发出Request,接着在Behaviors.receiveMessage里,使用递归反复尝试从子Actor的Response里取出结果(生产条件下应该设定子Actor响应超时)。当所有结果都取出后,由管家Actor利用父Actor传入的replyTo直接向外发出Response,最后停止管家Actor。
这当中的关键点包括:一是在管家Actor里的几处,使用narrow限定Actor的类型T:<U,这也算是一种妥协,确保消息类型为子类型T而非父类型U,从而实现更严谨的约束;二是利用递归配合Option[T]取出子Actor的响应结果。
// 子Actor
case class Keys()
case class Wallet()
// 父Actor
object Home {
sealed trait Command
case class LeaveHome(who: String, replyTo: ActorRef[ReadyToLeaveHome]) extends Command
case class ReadyToLeaveHome(who: String, keys: Keys, wallet: Wallet)
def apply(): Behavior[Command] = {
Behaviors.setup[Command] { context =>
val keyCabinet: ActorRef[KeyCabinet.GetKeys] = context.spawn(KeyCabinet(), "key-cabinet")
val drawer: ActorRef[Drawer.GetWallet] = context.spawn(Drawer(), "drawer")
Behaviors.receiveMessage[Command] {
case LeaveHome(who, replyTo) =>
context.spawn(prepareToLeaveHome(who, replyTo, keyCabinet, drawer), s"leaving-$who")
Behaviors.same
}
}
}
// 管家Actor
def prepareToLeaveHome(whoIsLeaving: String, replyTo: ActorRef[ReadyToLeaveHome],
keyCabinet: ActorRef[KeyCabinet.GetKeys], drawer: ActorRef[Drawer.GetWallet]): Behavior[NotUsed] = {
Behaviors.setup[AnyRef] { context =>
var wallet: Option[Wallet] = None
var keys: Option[Keys] = None
keyCabinet ! KeyCabinet.GetKeys(whoIsLeaving, context.self.narrow[Keys])
drawer ! Drawer.GetWallet(whoIsLeaving, context.self.narrow[Wallet])
Behaviors.receiveMessage {
case w: Wallet =>
wallet = Some(w)
nextBehavior()
case k: Keys =>
keys = Some(k)
nextBehavior()
case _ =>
Behaviors.unhandled
}
def nextBehavior(): Behavior[AnyRef] = (keys, wallet) match {
case (Some(w), Some(k)) =>
// 已取得所有结果
replyTo ! ReadyToLeaveHome(whoIsLeaving, w, k)
Behaviors.stopped
case _ =>
Behaviors.same
}
}.narrow[NotUsed]
}
}
适用场景
- 当需要的结果来自于数个Actor的响应汇总时;
- 为保证至少送达一次而设计重试功能时(委托子Actor反复重试,直到获取结果)。
缺点
- 由于子Actor是随管家Actor的停止而停止的,因此要切实防止资源泄漏;
- 增加了实现的复杂度。
⭕ 一般意义上的响应聚合器
本模式非常类似每会话子Actor模式,由聚合器负责收集子Actor回应的信息,再反馈给委托人Actor。
实现要点
实现与Per Session Child Actor近似,只是在具体代码上更具通用性而已。其中,context.spawnAnonymous
是起联结作用的重要步骤。它不仅负责孵化聚合器,还要提前准备向子Actor发出Request的闭包,以及将子Actor回复转换为统一的格式的映射闭包。聚合器被启动后,即开始收集子Actor的回复,收集完成时即告终止。
// 允许子Actor有不同的协议,不必向Aggregator妥协
object Hotel1 {
final case class RequestQuote(replyTo: ActorRef[Quote])
final case class Quote(hotel: String, price: BigDecimal)
}
object Hotel2 {
final case class RequestPrice(replyTo: ActorRef[Price])
final case class Price(hotel: String, price: BigDecimal)
}
object HotelCustomer {
sealed trait Command
final case class AggregatedQuotes(quotes: List[Quote]) extends Command
// 将子Actor的回复封装成统一的格式
final case class Quote(hotel: String, price: BigDecimal)
def apply(hotel1: ActorRef[Hotel1.RequestQuote], hotel2: ActorRef[Hotel2.RequestPrice]): Behavior[Command] = {
Behaviors.setup[Command] { context =>
context.spawnAnonymous(
// 这个传递给聚合器工厂的sendRequests是衔接聚合器及其委托人的关键
Aggregator[Reply, AggregatedQuotes](
sendRequests = { replyTo =>
hotel1 ! Hotel1.RequestQuote(replyTo)
hotel2 ! Hotel2.RequestPrice(replyTo)
},
expectedReplies = 2,
context.self,
aggregateReplies = replies =>
AggregatedQuotes(
replies
.map {
case Hotel1.Quote(hotel, price) => Quote(hotel, price)
case Hotel2.Price(hotel, price) => Quote(hotel, price)
}
.sortBy(_.price)
.toList),
timeout = 5.seconds))
Behaviors.receiveMessage {
case AggregatedQuotes(quotes) =>
context.log.info("Best {}", quotes.headOption.getOrElse("Quote N/A"))
Behaviors.same
}
}
}
}
object Aggregator {
// 用来兼容不同子Actor响应而定义的回复类型
type Reply = Any
sealed trait Command
private case object ReceiveTimeout extends Command
private case class WrappedReply[R](reply: R) extends Command
def apply[Reply: ClassTag, Aggregate](
sendRequests: ActorRef[Reply] => Unit,
expectedReplies: Int,
replyTo: ActorRef[Aggregate],
aggregateReplies: immutable.IndexedSeq[Reply] => Aggregate,
timeout: FiniteDuration): Behavior[Command] = {
Behaviors.setup { context =>
context.setReceiveTimeout(timeout, ReceiveTimeout)
val replyAdapter = context.messageAdapter[Reply](WrappedReply(_))
// 向子Actor发出Request并搜集整理回复信息
sendRequests(replyAdapter)
def collecting(replies: immutable.IndexedSeq[Reply]): Behavior[Command] = {
Behaviors.receiveMessage {
case WrappedReply(reply: Reply) =>
val newReplies = replies :+ reply
if (newReplies.size == expectedReplies) {
val result = aggregateReplies(newReplies)
replyTo ! result
Behaviors.stopped
} else
collecting(newReplies)
case ReceiveTimeout =>
val aggregate = aggregateReplies(replies)
replyTo ! aggregate
Behaviors.stopped
}
}
collecting(Vector.empty)
}
}
}
适用场景
- 当需要以相同的方式,从分布多处的多个Actor获取信息,并以统一方式回复时;
- 当需要聚合多个回复结果时;
- 为保证至少送达一次而设计重试功能时。
缺点
- 越是通用的消息类型,在运行时越缺少约束;
- 子Actor可能造成资源泄漏;
- 增加了实现复杂度。
⭕ 延迟掐尾器 (Latency tail chopping)
这是聚合器模式的一种变形。类似于集群条件下,每个Actor承担着同样的工作职责,当其中某个Actor未按期响应时,将工作从这个迟延的Actor手里交给另一个Actor负责。
实现要点
💀 这个例子不够完整,还需要进一步理解,比如为什么sendRequests需要一个Int参数,如果换作OO风格如何实现。
参考文献 🔗 Achieving Rapid Response Times in Large Online Services
- 使用Behaviors.withTimers设置若干个定时器,由定时器负责向子Actor发出Request。
- 设置2个超时,其中请求超时是单个Actor完成工作的时限,到期未完成就交出工作;另一个是最迟交付超时,是整个工作完成的时限,到期则说明无法交付委托人的工作。
- 利用sendRequest函数(类型为
(Int, ActorRef[Reply]) => Boolean
)联结掐尾器和具体承担工作的Actor。如果sendRequest成功,说明请求已经发送给承担工作的子Actor,那么就调度一条由请求超时限定的单个Request的消息,否则就调度一条由最迟交付超时限定的消息。
object TailChopping {
sealed trait Command
private case object RequestTimeout extends Command
private case object FinalTimeout extends Command
private case class WrappedReply[R](reply: R) extends Command
def apply[Reply: ClassTag](
sendRequest: (Int, ActorRef[Reply]) => Boolean,
nextRequestAfter: FiniteDuration,
replyTo: ActorRef[Reply],
finalTimeout: FiniteDuration,
timeoutReply: Reply): Behavior[Command] = {
Behaviors.setup { context =>
Behaviors.withTimers { timers =>
val replyAdapter = context.messageAdapter[Reply](WrappedReply(_))
sendNextRequest(1)
def waiting(requestCount: Int): Behavior[Command] = {
Behaviors.receiveMessage {
case WrappedReply(reply: Reply) =>
replyTo ! reply
Behaviors.stopped
// 单个任务没能按时完成,另外找人
case RequestTimeout =>
sendNextRequest(requestCount + 1)
// 整个工作交付不了,抱歉
case FinalTimeout =>
replyTo ! timeoutReply
Behaviors.stopped
}
}
def sendNextRequest(requestCount: Int): Behavior[Command] = {
if (sendRequest(requestCount, replyAdapter)) {
timers.startSingleTimer(RequestTimeout, nextRequestAfter)
} else {
timers.startSingleTimer(FinalTimeout, finalTimeout)
}
waiting(requestCount)
}
}
}
}
}
适用场景
- 当需要快速响应而必须降低不必要的延迟时;
- 当工作总是一味重复的内容时。
缺点
- 因为引入了更多的消息并且要重复多次同样的工作,所以增加了整个系统的负担;
- 工作的内容必须是幂等和可重复的,否则无法转交;
- 越是通用的消息类型,在运行时越缺少约束;
- 子Actor可能造成资源泄漏。
⭕ 调度消息给自己
使用定时器,在指定时限到期时给自己发送一条指定的消息。
实现要点
- 使用Behaviors.withTimers为Actor绑定TimerScheduler,该调度器将同样适用于Behaviors的setup、receive、receiveMessage等工厂方法创建的行为。
- 在timers.startSingleTimer定义并启动定时器,在startSingleTimer设定的超时到期时将会收到预设的消息。
object Buncher {
sealed trait Command
final case class ExcitingMessage(message: String) extends Command
final case class Batch(messages: Vector[Command])
private case object Timeout extends Command
private case object TimerKey
def apply(target: ActorRef[Batch], after: FiniteDuration, maxSize: Int): Behavior[Command] = {
Behaviors.withTimers(timers => new Buncher(timers, target, after, maxSize).idle())
}
}
class Buncher(
timers: TimerScheduler[Buncher.Command],
target: ActorRef[Buncher.Batch],
after: FiniteDuration,
maxSize: Int) {
private def idle(): Behavior[Command] = {
Behaviors.receiveMessage[Command] { message =>
timers.startSingleTimer(TimerKey, Timeout, after)
active(Vector(message))
}
}
def active(buffer: Vector[Command]): Behavior[Command] = {
Behaviors.receiveMessage[Command] {
// 收到定时器发来的Timeout消息,缓冲区buffer停止接收,将结果回复给target。
case Timeout =>
target ! Batch(buffer)
idle()
// 时限到达前,新建缓冲区并把消息存入,直到缓冲区满
case m =>
val newBuffer = buffer :+ m
if (newBuffer.size == maxSize) {
timers.cancel(TimerKey)
target ! Batch(newBuffer)
idle()
} else
active(newBuffer)
}
}
}
注意事项
- 每个定时器都有一个Key,如果启动了具有相同Key的新定时器,则前一个定时器将被取消cancel,并且保证即便旧定时器的到期消息已经放入Mailbox,也不会再触发(💀 定时器的Key可以自定义吗?旧定时器的到期消息是被框架主动过滤掉的吗?)。
- 定时器有周期性(PeriodicTimer)和一次性(SingleTimer)两种,它们的参数形式都一样:定时器键TimerKey、调度消息Message和时长Duration。区别在于最后一个参数对应周期时长或是超时时长。(⚡ 根据JAPI文档,PeriodicTimer已经作废,取而代之的是指定发送频率的startTimerAtFixedRate或者指定两次消息发送间隔时长的startTimerWithFixedDelay,区别参见下文调度周期的说明。)
- TimerScheduler本身是可变的,因为它要执行和管理诸如注册计划任务等副作用。(💀 所以不是线程安全的?)
- TimerScheduler与其所属的Actor同生命周期。
- Behaviors.withTimers也可以在Behaviors.supervise内部使用。当Actor重启时,它将自动取消旧的定时器,并确保新定时器不会收到旧定时器的预设到期消息。
关于调度周期的特别说明
调度周期有两种:一种是FixedDelay:指定前后两次消息发送的时间间隔;一种是FixedRate:指定两次任务执行的时间间隔。如果实难选择,建议使用FixedDelay。(❗ 此处Task等价于一次消息处理过程,可见对Akka里的各种术语还需进一步规范。)
区别主要在于:Delay不会补偿两次消息间隔之间因各种原因导致的延误,前后两条消息的间隔时间是固定的,而不会关心前一条消息是何时才交付处理的;而Rate会对这之间的延误进行补偿,后一条消息发出的时间会根据前一条消息交付处理的时间而确定。(💀 换句话说,Delay以发出时间计,Rate以开始处理的时间计。)
长远来看,Delay方式下的消息处理的频率通常会略低于指定延迟的倒数,所以更适合短频快的工作;Rate方式下的消息处理频率恰好是指定间隔的倒数,所以适合注重完整执行次数的工作。
⚠️ 在Rate方式下,如果任务延迟超出了预设的时间间隔,则将在前一条消息之后立即发送下一条消息。比如scheduleAtFixedRate的间隔为1秒,而消息处理过程因长时间暂停垃圾回收等原因造成JVM被挂起30秒钟,则ActorSystem将快速地连续发送30条消息进行追赶,从而造成短时间内的消息爆发,所以一般情况下Delay方式更被推崇。
⭕ 响应集群条件下分片后的Actor
在集群条件下,通常采用的在Request中传递本Shard Actor之ActorRef的方法仍旧适用。但如果该Actor在发出Request后被移动或钝化(指Actor暂时地关闭自己以节约内存,需要时再重启),则回复的Response将会全部发至Dead Letters。此时,引入EntityId作为标识,取代ActorRef以解决之(参见RMP-68)。缺点是无法再使用消息适配器。
⚠️ RMP-77:Actor的内部状态不会随Actor对象迁移,所以需要相应持久化机制来恢复Actor对象的状态。
实现要点
把通常设计中的ActorRef换成EntityId,再使用TypeKey和EntityId定位Actor的引用即可。
object CounterConsumer {
sealed trait Command
final case class NewCount(count: Long) extends Command
val TypeKey: EntityTypeKey[Command] = EntityTypeKey[Command]("example-sharded-response")
}
object Counter {
trait Command
case object Increment extends Command
final case class GetValue(replyToEntityId: String) extends Command
val TypeKey: EntityTypeKey[Command] = EntityTypeKey[Command]("example-sharded-counter")
private def apply(): Behavior[Command] = Behaviors.setup { context =>
counter(ClusterSharding(context.system), 0)
}
private def counter(sharding: ClusterSharding, value: Long): Behavior[Command] = Behaviors.receiveMessage {
case Increment =>
counter(sharding, value + 1)
case GetValue(replyToEntityId) =>
val replyToEntityRef = sharding.entityRefFor(CounterConsumer.TypeKey, replyToEntityId)
replyToEntityRef ! CounterConsumer.NewCount(value)
Behaviors.same
}
}
容错能力
默认情况下,当Actor在初始化或处理消息时触发了异常、失败,则该Actor将被停止(⚠️ 传统Akka默认是重启Actor)。
要区别校验错误与失败:校验错误Validate Error意味着发给Actor的Command本身就是无效的,所以将其界定为Protocol规范的内容,由发件人严格遵守,这远甚过收件人发现收到的是无效Command后直接抛出异常。失败Failure则是由于Actor不可控的外因导致的,这通常无法成为双方Protocol的一部分,发件人对此也无能为力。
发生失败时,通常采取“就让它崩”的原则。其思路在于,与其花费心思零敲碎打地在局部进行细粒度的修复和内部状态纠正,不如就让它崩溃停止,然后利用已有的灾备方案,重建一个肯定有效的新Actor重新来过。
监管 Supervise
监管就是一个放置灾备方案的好地方。默认监视策略是在引发异常时停止Actor,如果要自定义此策略,则应在spawn子Actor时,使用Behaviors.supervise进行指定。
策略有许多可选参数,也可以象下面这样进行嵌套,以应对不同的异常类型。
Behaviors.supervise(
Behaviors.supervise(behavior)
.onFailure[IllegalStateException](SupervisorStrategy.restart))
.onFailure[IllegalArgumentException](SupervisorStrategy.stop)
⚠️ 若Actor被重启,则传递给Behaviors.supervise的Behavior内定义的可变状态就需要在类似Behaviors.setup这样的工厂方法中进行初始化。若采用OO风格,则推荐在setup中完成初始化;若采用FP风格,由于通常不存在函数内的可变量,所以无需如此。
🔗 完整列表参见API指南:SupervisorStrategy
子Actor在父Actor重启时停止
第二个放置灾备的地方是Behaviors.setup里。因为当父Actor重启时,其Behaviors.setup会再次执行。同时,子Actor会随父Actor重启而停止运行,以防止资源泄漏等问题发生。
注意区别以下两种方式:
⭕ 方式一:由supervise包裹setup
这种方式下,每当父Actor重启时,就会完全重构一次子Actor,从而总是回到父Actor刚创建时候的样子。
def child(size: Long): Behavior[String] =
Behaviors.receiveMessage(msg => child(size + msg.length))
def parent: Behavior[String] = {
Behaviors
.supervise[String] {
// setup被supervise包裹,意味着每次父Actor重启,该setup必被重新执行
Behaviors.setup { ctx =>
val child1 = ctx.spawn(child(0), "child1")
val child2 = ctx.spawn(child(0), "child2")
Behaviors.receiveMessage[String] { msg =>
val parts = msg.split(" ")
child1 ! parts(0)
child2 ! parts(1)
Behaviors.same
}
}
}
.onFailure(SupervisorStrategy.restart)
}
⭕ 方式二:由setup包裹supervise
这种方式下,子Actor不会受到父Actor的重启影响,它们既不会停止,更不会被重建。
def parent2: Behavior[String] = {
Behaviors.setup { ctx =>
// 此setup只会在父Actor创建时运行一次
val child1 = ctx.spawn(child(0), "child1")
val child2 = ctx.spawn(child(0), "child2")
Behaviors
.supervise {
// 在父Actor重启时,只有这段receiveMessage工厂会被执行
Behaviors.receiveMessage[String] { msg =>
val parts = msg.split(" ")
child1 ! parts(0)
child2 ! parts(1)
Behaviors.same
}
}
// 参数false决定了父Actor重启时不会停止子Actor
.onFailure(SupervisorStrategy.restart.withStopChildren(false))
}
}
PreRestart信号
第三个放置灾备方案的地方是在PreRestart信号处理过程里。和之前提过的PostStop信号一样,Actor因监测而重启前,会收到一个信号PreRestart信号,方便Actor自身在重启前完成清理扫尾工作。
💀 RMP-47的对传统Akka的描述适用于Akka Typed吗?
- PreStart:在Actor启动前触发
- PostStop:在Actor停止后触发
- PreRestart:在重启Actor前触发,完成任务后会触发PostStop
- PostRestart:在Actor重启后触发,完成任务后会触发PreStart
把异常当水泡一样顺着遗传树往上传递
在传统Akka里,子Actor触发的异常将被上交给父Actor,由后者决定如何处置。而在Akka Typed里,提供了更丰富的手段处理这种情况。
方法就是由父Actor观察(watch)子Actor,这样当子Actor因失败而停止时,父Actor将会收到附上原因的ChildFailed信号。特别地,ChildFailed信号派生自Terminated,所以如果业务上不需要刻意区分的话,处理Terminated信号即可。
在子Actor触发异常后,如果它的祖先Actor(不仅仅是父亲)没有处理Terminated信号,那么将会触发akka.actor.typed.DeathPactException异常。
📎 示例里用Boss -> MiddleManagement -> Work这样的层级进行了演示。当Boss发出Fail消息后,MiddleManagement将消息转发给Work,Work收到Fail消息后抛出异常。因MiddleManagement和Boss均未对Terminated信号进行处理,因此相继停止。随后Boss按预定策略重启,并顺次重建MiddleManagement和Work,从而确保测试脚本尝试在等候200毫秒后重新发送消息Hello成功。
发现Actor
除了通过创建Actor获得其引用外,还可以通过接线员Receptionist获取Actor的引用。
Receptionist采用了注册会员制,注册过程仍是基于Akka Protocol。在Receptionist上注册后的会员都持有key,方便集群上的其他Actor通过key找到它。当发出Find请求后,Receptionist会回复一个Listing,其中将包括一个由若干符合条件的Actor组成的集合。(⚠️ 同一个key可以对应多个Actor)
由Receptionist维护的注册表是动态的,其中的Actor可能因其停止运行、手动从表中注销或是节点从集群中删除而从表中消失。如果需要关注这种动态变化,可以使用Receptionist.Subscribe(keyOfActor, replyTo)订阅关注的Actor,Receptionist会在注册表变化时将Listing消息发送给replyTo。
⚠️ 切记:上述操作均是基于异步消息的,所以操作不是即时产生结果的。可能发出注销请求了,但Actor还在注册表里。
要点:
- 用
ServiceKey[Message]("name")
创建Key - 用
context.system.receptionist ! Receptionist.Register(key, replyTo)
注册Actor,用Deregister注销 - 用
context.system.receptionist ! Receptionist.Subscribe(key, replyTo)
订阅注册表变动事件 - 用
context.system.receptionist ! Receptionist.Find(key, messageAdapter)
查找指定key对应的若干Actor
集群的接线员
在集群条件下,一个Actor注册到本地节点的接线员后,其他节点上的接线员也会通过分布式数据广播获悉,从而保证所有节点都能通过ServiceKey找到相同的Actor们。
但需要注意集群条件下与本地环境之间的差别:一是在集群条件下进行的Subscription与Find将只能得到可达Actor的集合。如果需要获得所有的已注册Actor(包括不可达的Actor),则得通过Listing.allServiceInstances获得。二是在集群内各节点之间传递的消息,都需要经过序列化。
接线员的可扩展性
接线员无法扩展到任意数量、也达不到异常高吞吐的接转要求,它通常最多就支持数千至上万的接转量。所以,如果应用确实需要超过Akka框架所能提供的接转服务水平的,就得自己去解决各节点Actor初始化连接的难题。
路由 Route
尽管Actor在任意时刻只能处理一条消息,但这不并妨碍同时有多个Actor处理同一条消息,这便是Akka的路由功能使然。
路由器本身也是一种Actor,但主要职责是转发消息而不是处理消息。与传统Akka一样,Akka Typed的路由也分为两种:池路由池与组路由。
⭕ 池路由
在池路由方式下,由Router负责构建并管理所有的Routee。当这些作为子actor的Routee终止时,Router将会把它从Router中移除。当所有的Routee都移除后,Router本身停止运行。
示例要点
- 使用
val pool = Routers.pool(poolSize = 4)(Behaviors.supervise(Worker()).onFailure[Exception](SupervisorStrategy.restart))
定义池路由,其中监管策略应是必不可少的内容,被监管的Worker()即是Routee,poolSize则是池中最多能创建并管理的Routee数目。 - 接着用
val router = ctx.spawn(pool, "worker-pool")
创建路由器本身。 - 之后便可以向路由器router发送消息了。
- 最终,消息将被路由给所有的routee(此处将有4个Worker的实例负责处理消息)。
- Behaviors.monitor(monitor, behaviorOfMonitee):将被监测的Monitee收到新消息的同时,将该消息抄送给监测者Monitor
由于Router本身也是Actor,Routee是其子Actor,因此可以指定其消息分发器。(💀 Router中以with开头的API还有不少,需要仔细参考API文档。)
// 指定Routee使用默认的Blocking IO消息分发器
val blockingPool = pool.withRouteeProps(routeeProps = DispatcherSelector.blocking())
// 指定Router使用与其父Actor一致的消息分发器
val blockingRouter = ctx.spawn(blockingPool, "blocking-pool", DispatcherSelector.sameAsParent())
// 使用轮循策略分发消息,保证每个Routee都尽量获得同样数量的任务,这是池路由默认策略
// 示例将获得a-b-a-b顺序的日志
val alternativePool = pool.withPoolSize(2).withRoundRobinRouting()
📌 在学习Akka Typed的过程中,应引起重视和警醒的是,不能象传统Akka一样执着于定义Actor的Class或Object本身,而应该紧紧围绕Behavior来思考、认识和设计系统。
在Akka Typed的世界里,包括Behaviors各式工厂在内的许多API均是以Behavior为核心进行设计的。而Behavior又与特定类型的Message绑定,这便意味着Behavior与Protocol进行了绑定,于是消息Message及处理消息的Behavior[Message]便构成了完整的Protocol。
⭕ 组路由
与池路由不同的是,组路由方式下的Routee均由外界其它Actor产生(自行创建、自行管理),Router只是负责将其编组在一起。
组路由基于ServiceKey和Receptionist,管理着属于同一个key的若干个Routee。虽然这种方式下对Routee构建和监控将更灵活和便捷,但也意味着组路由将完全依赖Receptionist维护的注册表才能工作。在Router启动之初,当注册表还是空白时,发来的消息将作为akka.actor.Dropped扔到事件流中。当注册表中注册有Routee后,若其可达,则消息将顺利送达,否则该Routee将被标记为不可达。
路由策略
-
轮循策略 Round Robin
轮循策略将公平调度各Routee,平均分配任务,所以适合于Routee数目不会经常变化的场合,是池路由的默认策略。它有一个可选的参数
preferLocalRoutees
,为true时将强制只使用本地的Routee(默认值为false)。 -
随机策略 Random
随机策略将随机选取Routee分配任务,适合Routee数目可能会变化的场合,是组路由的默认策略。它同样有可靠参数
preferLocalRoutees
。 -
一致的散列策略 Consistent Hashing
散列策略将基于一张以传入消息为键的映射表选择Routee。
🔗 参考文献:Consistent Hashing
💀 该文只展示了如何设计一个ConsistentHash[T]类,并提供add/remove/get等API函数,却没讲怎么使用它,所以需要完整示例!
关于性能
如果把Routee看作CPU的核心,那自然是多多益善。但由于Router本身也是一个Actor,所以其Mailbox的承载能力反而会成为整个路由器的瓶颈,而Akka Typed并未就此提供额外方案,因此遇到需要更高吞吐量的场合则需要自己去解决。
暂存 Stash
Stash(暂存),是指Actor将当前Behavior暂时还不能处理的消息全部或部分缓存起来,等完成初始化等准备工作或是处理完上一条幂等消息后,再切换至匹配的Behavior,从缓冲区取出消息进行处理的过程。
示例要点
trait DB {
def save(id: String, value: String): Future[Done]
def load(id: String): Future[String]
}
object DataAccess {
sealed trait Command
final case class Save(value: String, replyTo: ActorRef[Done]) extends Command
final case class Get(replyTo: ActorRef[String]) extends Command
private final case class InitialState(value: String) extends Command
private case object SaveSuccess extends Command
private final case class DBError(cause: Throwable) extends Command
// 使用Behaviors.withStash(capacity)设置Stash容量
// 随后切换到初始Behavior start()
def apply(id: String, db: DB): Behavior[Command] = {
Behaviors.withStash(100) { buffer =>
Behaviors.setup[Command] { context =>
new DataAccess(context, buffer, id, db).start()
}
}
}
}
// 大量使用context.pipeToSelf进行Future交互
class DataAccess(
context: ActorContext[DataAccess.Command],
buffer: StashBuffer[DataAccess.Command],
id: String,
db: DB) {
import DataAccess._
private def start(): Behavior[Command] = {
context.pipeToSelf(db.load(id)) {
case Success(value) => InitialState(value)
case Failure(cause) => DBError(cause)
}
Behaviors.receiveMessage {
case InitialState(value) =>
// 完成初始化,转至Behavior active()开始处理消息
buffer.unstashAll(active(value))
case DBError(cause) =>
throw cause
case other =>
// 正在处理幂等消息,故暂存后续消息
buffer.stash(other)
Behaviors.same
}
}
// Behaviors.receiveMessagePartial():从部分消息处理程序构造一个Behavior
// 该行为将把未定义的消息视为未处理。
private def active(state: String): Behavior[Command] = {
Behaviors.receiveMessagePartial {
case Get(replyTo) =>
replyTo ! state
Behaviors.same
// 处理幂等的Save消息
case Save(value, replyTo) =>
context.pipeToSelf(db.save(id, value)) {
case Success(_) => SaveSuccess
case Failure(cause) => DBError(cause)
}
// 转至Behavior saving(),反馈幂等消息处理结果
saving(value, replyTo)
}
}
private def saving(state: String, replyTo: ActorRef[Done]): Behavior[Command] = {
Behaviors.receiveMessage {
case SaveSuccess =>
replyTo ! Done
// 幂等消息处理结束并已反馈结果,转至Behavior active()开始处理下一条消息
buffer.unstashAll(active(state))
case DBError(cause) =>
throw cause
case other =>
buffer.stash(other)
Behaviors.same
}
}
}
注意事项
- Stash所使用的缓冲区由Akka提供,其大小一定要在Behavior对象创建前进行设定,否则过多的消息被暂存将导致内存溢出,触发
StashOverflowException
异常。所以在往缓冲区里暂存消息前,应当使用StashBuffer.isFull
提前进行检测。 unstashAll()
将会停止Actor响应新的消息,直到当前暂存的所有消息被处理完毕,但这有可能因长时间占用消息处理线程而导致其他Actor陷入饥饿状态。为此,可改用方法unstash(numberOfMessages)
,确保一次只处理有限数量的暂存消息。
Behavior是一台有限状态机
有限状态机:当前处于状态S,发生E事件后,执行操作A,然后状态将转换为S’。
这部分内容对应传统Akka的FSM:Finite State Machine,可参考RMP及下文
📎 参考示例:哲学家用餐问题,及其解析:🔗 Dining Hakkers
object Buncher {
// 把FSM里驱动状态改变的事件,都用Message代替了
sealed trait Event
final case class SetTarget(ref: ActorRef[Batch]) extends Event
final case class Queue(obj: Any) extends Event
case object Flush extends Event
private case object Timeout extends Event
// 状态
sealed trait Data
case object Uninitialized extends Data
final case class Todo(target: ActorRef[Batch], queue: immutable.Seq[Any]) extends Data
final case class Batch(obj: immutable.Seq[Any])
// 初始状态为Uninitialized,对应初始的Behavior为idle()
def apply(): Behavior[Event] = idle(Uninitialized)
private def idle(data: Data): Behavior[Event] =
Behaviors.receiveMessage[Event] {
message: Event => (message, data) match {
case (SetTarget(ref), Uninitialized) =>
idle(Todo(ref, Vector.empty))
case (Queue(obj), t @ Todo(_, v)) =>
active(t.copy(queue = v :+ obj))
case _ =>
Behaviors.unhandled
}
}
// 处于激活状态时,对应Behavior active()
private def active(data: Todo): Behavior[Event] =
Behaviors.withTimers[Event] { timers =>
// 设置超时条件
timers.startSingleTimer(Timeout, 1.second)
Behaviors.receiveMessagePartial {
case Flush | Timeout =>
data.target ! Batch(data.queue)
idle(data.copy(queue = Vector.empty))
case Queue(obj) =>
active(data.copy(queue = data.queue :+ obj))
}
}
}
在Akka Typed里,由于Protocol和Behavior的出现,简化了传统Akka中有限状态机FSM的实现。不同的状态下,对应不同的Behavior,响应不同的请求,成为Akka Typed的典型作法,这在此前的大量示例里已经有所展示。
协调关机 Coordinated Shutdown
CoordinatedShutdown是一个扩展,通过提前注册好的任务Task,可以在系统关闭前完成一些清理扫尾工作,防止资源泄漏等问题产生。
关闭过程中,默认的各阶段(Phase)都定义在下面这个akka.coordinated-shutdown.phases
里,各Task则后续再添加至相应的阶段中。
在application.conf配置里,可以通过定义不同的depends-on来覆盖缺省的设置。其中,before-service-unbind
、before-cluster-shutdown
和before-actor-system-terminate
是最常被覆盖的。
各Phase原则上按照被依赖者先于依赖者的顺序执行,从而构成一个有向无环图(Directed Acyclic Graph,DAG),最终所有Phase按DAG的拓扑顺序执行。
# CoordinatedShutdown is enabled by default and will run the tasks that
# are added to these phases by individual Akka modules and user logic.
#
# The phases are ordered as a DAG by defining the dependencies between the phases
# to make sure shutdown tasks are run in the right order.
#
# In general user tasks belong in the first few phases, but there may be use
# cases where you would want to hook in new phases or register tasks later in
# the DAG.
#
# Each phase is defined as a named config section with the
# following optional properties:
# - timeout=15s: Override the default-phase-timeout for this phase.
# - recover=off: If the phase fails the shutdown is aborted
# and depending phases will not be executed.
# - enabled=off: Skip all tasks registered in this phase. DO NOT use
# this to disable phases unless you are absolutely sure what the
# consequences are. Many of the built in tasks depend on other tasks
# having been executed in earlier phases and may break if those are disabled.
# depends-on=[]: Run the phase after the given phases
phases {
# The first pre-defined phase that applications can add tasks to.
# Note that more phases can be added in the application's
# configuration by overriding this phase with an additional
# depends-on.
before-service-unbind {
}
# Stop accepting new incoming connections.
# This is where you can register tasks that makes a server stop accepting new connections. Already
# established connections should be allowed to continue and complete if possible.
service-unbind {
depends-on = [before-service-unbind]
}
# Wait for requests that are in progress to be completed.
# This is where you register tasks that will wait for already established connections to complete, potentially
# also first telling them that it is time to close down.
service-requests-done {
depends-on = [service-unbind]
}
# Final shutdown of service endpoints.
# This is where you would add tasks that forcefully kill connections that are still around.
service-stop {
depends-on = [service-requests-done]
}
# Phase for custom application tasks that are to be run
# after service shutdown and before cluster shutdown.
before-cluster-shutdown {
depends-on = [service-stop]
}
# Graceful shutdown of the Cluster Sharding regions.
# This phase is not meant for users to add tasks to.
cluster-sharding-shutdown-region {
timeout = 10 s
depends-on = [before-cluster-shutdown]
}
# Emit the leave command for the node that is shutting down.
# This phase is not meant for users to add tasks to.
cluster-leave {
depends-on = [cluster-sharding-shutdown-region]
}
# Shutdown cluster singletons
# This is done as late as possible to allow the shard region shutdown triggered in
# the "cluster-sharding-shutdown-region" phase to complete before the shard coordinator is shut down.
# This phase is not meant for users to add tasks to.
cluster-exiting {
timeout = 10 s
depends-on = [cluster-leave]
}
# Wait until exiting has been completed
# This phase is not meant for users to add tasks to.
cluster-exiting-done {
depends-on = [cluster-exiting]
}
# Shutdown the cluster extension
# This phase is not meant for users to add tasks to.
cluster-shutdown {
depends-on = [cluster-exiting-done]
}
# Phase for custom application tasks that are to be run
# after cluster shutdown and before ActorSystem termination.
before-actor-system-terminate {
depends-on = [cluster-shutdown]
}
# Last phase. See terminate-actor-system and exit-jvm above.
# Don't add phases that depends on this phase because the
# dispatcher and scheduler of the ActorSystem have been shutdown.
# This phase is not meant for users to add tasks to.
actor-system-terminate {
timeout = 10 s
depends-on = [before-actor-system-terminate]
}
}
-
通常应在系统启动后尽早注册任务,否则添加得太晚的任务将不会被运行。
-
向同一个Phase添加的任务将并行执行,没有先后之分。
-
下一个Phase会通常会等待上一个Phase里的Task都执行完毕或超时后才会启动。可以为Phase配置
recover = off
,从而在Task失败或超时后,中止整个系统的关机过程。 -
通常情况下,使用
CoordinatedShutdown(system).addTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "someTaskName") { ... }
向Phase中添加Task,此处的名称主要用作调试或者日志。 -
使用
CoordinatedShutdown(system).addCancellableTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "cleanup") { () => Future { ... } }
添加可取消的Task,之后可以用c.cancel()取消Task的执行。 -
通常情况下,不需要Actor回复Task已完成的消息,因为这会拖慢关机进程,直接让Actor终止运行即可。如果要关注该Task何时完成,可以使用
CoordinatedShutdown(system).addActorTerminationTask(CoordinatedShutdown.PhaseBeforeServiceUnbind, "someTaskName", someActor, Some("stop"))
添加任务,并且给这个someActor发送一条消息,随后watch该Actor的终止便可知晓Task完成情况。 -
使用
ActorSystem.terminate()
或val done: Future[Done] = CoordinatedShutdown(system).run(CoordinatedShutdown.UnknownReason)
可以启动协调关机过程,且多次调用也只会执行一次。 -
ActorSystem会在最后一个Phase里的Task全部执行完毕后关闭,但JVM不一定会停止,除非所有守护进程均已停止运行。通过配置
akka.coordinated-shutdown.exit-jvm = on
,可以强制一并关闭JVM。 -
在集群条件下,当节点正在从集群中离开或退出时,将会自动触发协调关机。而且系统会自动添加Cluster Singleton和Cluster Sharding等正常退出群集的任务。
-
默认情况下,当通过杀死SIGTERM信号(Ctrl-C对SIGINT不起作用)终止JVM进程时,CoordinatedShutdown也将运行,该默认行为可以通过配置
akka.coordinated-shutdown.run-by-jvm-shutdown-hook=off
禁用之。 -
可以使用
CoordinatedShutdown(system).addJvmShutdownHook { ... }
添加JVM Hook任务,以保证其在Akka关机前得以执行。 -
在测试时,如果不希望启用协调关机,可以采用以下配置禁用之:
# Don't terminate ActorSystem via CoordinatedShutdown in tests akka.coordinated-shutdown.terminate-actor-system = off akka.coordinated-shutdown.run-by-actor-system-terminate = off akka.coordinated-shutdown.run-by-jvm-shutdown-hook = off akka.cluster.run-coordinated-shutdown-when-down = off
消息分发器 Dispatchers
MessageDispatcher是Akka的心脏,是它驱动着整个ActorSystem的正常运转,并且为所有的Actor提供了执行上下文ExecutionContext,方便在其中执行代码、进行Future回调等等。
-
默认Dispatcher
每个ActorSystem都有一个默认的Dispatcher,可以在
akka.actor.default-dispatcher
配置中细调,其默认的执行器Executor类型为 “fork-join-executor”,这在绝大多数情况下都能提供优越的性能,也可以在akka.actor.default-dispatcher.executor
一节中进行设置。 -
内部专用Dispatcher
为保护Akka各模块内部维护的Actor,有一个独立的内部专用Dispatcher。它可以在
akka.actor.internal-dispatcher
配置中细调,也可以设置akka.actor.internal-dispatcher为其他Dispatcher名字(别名)来替换之。 -
查找指定的Dispatcher
Dispatcher均实现了ExecutionContext接口,所以象这样
val executionContext = context.system.dispatchers.lookup(DispatcherSelector.fromConfig("my-dispatcher"))
就可加载不同的Dispatcher。 -
选择指定的Dispatcher
// 为新的Actor使用默认Dispatcher context.spawn(yourBehavior, "DefaultDispatcher") context.spawn(yourBehavior, "ExplicitDefaultDispatcher", DispatcherSelector.default()) // 为不支持Future的阻塞调用(比如访问一些老式的数据库),使用blocking Dispatcher context.spawn(yourBehavior, "BlockingDispatcher", DispatcherSelector.blocking()) // 使用和父Actor一样的Dispatcher context.spawn(yourBehavior, "ParentDispatcher", DispatcherSelector.sameAsParent()) // 从配置加载指定的Dispatcher context.spawn(yourBehavior, "DispatcherFromConfig", DispatcherSelector.fromConfig("your-dispatcher"))
your-dispatcher { type = Dispatcher executor = "thread-pool-executor" thread-pool-executor { fixed-pool-size = 32 } throughput = 1 }
Dispatcher的两种类型
对比 | Dispatcher | PinnedDispatcher |
---|---|---|
线程池 | 事件驱动,一组Actor共用一个线程池。 | 每个Actor都拥有专属的一个线程池,池中只有一个线程。 |
可否被共享 | 没有限制 | 不可共享 |
邮箱 | 每个Actor拥有一个 | 每个Actor拥有一个 |
适用场景 | 是Akka默认的Dispatcher, 支持隔板 | 支持隔板 |
驱动 | 由java.util.concurrent.ExecutorService 驱动。使用fork-join-executor、thread-pool-executor或基于akka.dispatcher.ExecutorServiceConfigurator实现的完全限定类名,可指定其使用的executor。 |
由任意的akka.dispatch.ThreadPoolExecutorConfigurator 驱动,默认执行器为thread-pool-executor 。 |
一个Fork-Join执行器示例:
my-dispatcher {
# Dispatcher is the name of the event-based dispatcher
type = Dispatcher
# What kind of ExecutionService to use
executor = "fork-join-executor"
# Configuration for the fork join pool
fork-join-executor {
# Min number of threads to cap factor-based parallelism number to
parallelism-min = 2
# Parallelism (threads) ... ceil(available processors * factor)
parallelism-factor = 2.0
# Max number of threads to cap factor-based parallelism number to
parallelism-max = 10
}
# Throughput defines the maximum number of messages to be
# processed per actor before the thread jumps to the next actor.
# Set to 1 for as fair as possible.
throughput = 100
}
自定义Dispatcher以尽可能避免阻塞
📎 讲解阻塞危害的参考视频:
Managing Blocking in Akka video,及其示例代码:https://github.com/raboof/akka-blocking-dispatcher
在使用默认Dispatcher的情况下,多个Actor共用一个线程池,所以当其中一些Actor因被阻塞而占用线程后,有可能导致可用线程耗尽,而使其他同组的Actor陷入线程饥饿状态。
监测工具推荐:YourKit,VisualVM,Java Mission Control,Lightbend出品的Thread Starvation Detector等等。
示例使用了两个Actor作对比,在(1 to 100)的循环里,新建的一个Actor在消息处理函数中sleep 5秒,导致同时新建的另一个Actor无法获得线程处理消息而卡住。
针对上述情况,首先可能想到的象下面这样,用Future来封装这样的长时调用,但这样的想法实际上过于简单。因为仍旧使用了由全体Actor共用的ExecutionContext作为Future的执行上下文,所以随着应用程序的负载不断增加,内存和线程都会飞快地被耗光。
object BlockingFutureActor {
def apply(): Behavior[Int] =
Behaviors.setup { context =>
implicit val executionContext: ExecutionContext = context.executionContext
Behaviors.receiveMessage { i =>
triggerFutureBlockingOperation(i)
Behaviors.same
}
}
def triggerFutureBlockingOperation(i: Int)(implicit ec: ExecutionContext): Future[Unit] = {
println(s"Calling blocking Future: $i")
Future {
Thread.sleep(5000) //block for 5 seconds
println(s"Blocking future finished $i")
}
}
}
正确的解决方案,是为所有的阻塞调用提供一个独立的Dispatcher,这种技巧被称作“隔板 bulk-heading”或者“隔离阻塞 isolating blocking”。
在application.conf里对Dispatcher进行如下配置,其中thread-pool-executor.fixed-pool-size
的数值可根据实际负载情况进行微调:
my-blocking-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 16
}
throughput = 1
}
随后,使用该配置替换掉前述代码第4行加载的默认Dispatcher
implicit val executionContext: ExecutionContext = context.system.dispatchers.lookup(DispatcherSelector.fromConfig("my-blocking-dispatcher"))
以上便是处理响应性应用程序中阻塞问题的推荐方法。对有关Akka HTTP中阻塞调用的类似讨论,请参阅🔗 Handling blocking operations in Akka HTTP。
其他一些建议:
- 在Future中进行阻塞调用,但务必要确保任意时刻此类调用的数量上限,否则大量的此类任务将耗尽您的内存或线程。
- 在Future中进行阻塞调用,为线程池提供一个线程数上限,该上限要匹配运行应用程序的硬件平台条件。
- 专门使用一个线程来管理一组阻塞资源,例如用一个NIO选择器来管理多个通道,并在阻塞资源触发特定事件时作为Actor消息进行分发调度。
- 使用路由器来管理进行阻塞调用的Actor,并确保相应配置足够大小的线程池。这种方案特别适用于访问传统数据库这样的单线程资源,使每个Actor对应一个数据库连接,由一个路由器进行集中管理。至于Actor的数量,则由数据库部署平台的硬件条件来决定。
- 使用Akka的任务Task在application.conf中配置线程池,它,再通过ActorSystem进行实例化。
其他一些常见的Dispatcher配置
-
固定的线程池大小
blocking-io-dispatcher { type = Dispatcher executor = "thread-pool-executor" thread-pool-executor { fixed-pool-size = 32 } throughput = 1 }
-
根据CPU核心数设置线程池大小
my-thread-pool-dispatcher { # Dispatcher is the name of the event-based dispatcher type = Dispatcher # What kind of ExecutionService to use executor = "thread-pool-executor" # Configuration for the thread pool thread-pool-executor { # minimum number of threads to cap factor-based core number to core-pool-size-min = 2 # No of core threads ... ceil(available processors * factor) core-pool-size-factor = 2.0 # maximum number of threads to cap factor-based number to core-pool-size-max = 10 } # Throughput defines the maximum number of messages to be # processed per actor before the thread jumps to the next actor. # Set to 1 for as fair as possible. throughput = 100 }
-
PinnedDispatcher
my-pinned-dispatcher { executor = "thread-pool-executor" type = PinnedDispatcher }
由于Actor每次获得的不一定都是同一个线程,所以当确有必要时,可以设置
thread-pool-executor.allow-core-timeout=off
,以确保始终使用同一线程。 -
设置线程关闭超时
无论是fork-join-executor还是thread-pool-executor,线程都将在无人使用时被关闭。如果想设置一个稍长点的时间,可进行如下调整。特别是当该Executor只是作为执行上下文使用(比如只进行Future调用),而没有关联Actor时更应如此,否则默认的1秒将会导致整个线程池过度频繁地被关闭。my-dispatcher-with-timeouts { type = Dispatcher executor = "thread-pool-executor" thread-pool-executor { fixed-pool-size = 16 # Keep alive time for threads keep-alive-time = 60s # Allow core threads to time out allow-core-timeout = off } # How long time the dispatcher will wait for new actors until it shuts down shutdown-timeout = 60s }
邮箱 Mailbox
邮箱是Actor接收待处理消息的队列,默认是没有容量上限的。但当Actor的处理消息的速度低于消息送达的速度时,就有必要设置邮箱的容量上限了,这样当有更多消息到达时,将被转投至系统的DeadLetter。
选择特定的邮箱
如果没有特别指定,将使用默认的邮箱SingleConsumerOnlyUnboundedMailbox
。否则在context.spawn时指定,且配置可从配置文件中动态加载。
context.spawn(childBehavior, "bounded-mailbox-child", MailboxSelector.bounded(100))
val props = MailboxSelector.fromConfig("my-app.my-special-mailbox")
context.spawn(childBehavior, "from-config-mailbox-child", props)
my-app {
my-special-mailbox {
mailbox-type = "akka.dispatch.SingleConsumerOnlyUnboundedMailbox"
}
}
Akka提供的邮箱
-
非阻塞类型的邮箱
邮箱 内部实现 有否上限 配置名称 SingleConsumerOnlyUnboundedMailbox(默认) 一个多生产者-单消费者队列,不能与BalancingDispatcher搭配 否 akka.dispatch.SingleConsumerOnlyUnboundedMailbox UnboundedMailbox 一个java.util.concurrent.ConcurrentLinkedQueue 否 unbounded 或 akka.dispatch.UnboundedMailbox NonBlockingBoundedMailbox 一个高效的多生产者-单消费者队列 是 akka.dispatch.NonBlockingBoundedMailbox UnboundedControlAwareMailbox
akka.dispatch.ControlMessage派生的控制消息将被优先投递两个java.util.concurrent.ConcurrentLinkedQueue 否 akka.dispatch.UnboundedControlAwareMailbox UnboundedPriorityMailbox
不保证同优先级消息的投递顺序一个java.util.concurrent.PriorityBlockingQueue 否 akka.dispatch.UnboundedPriorityMailbox UnboundedStablePriorityMailbox
严格按FIFO顺序投递同优先级消息一个使用akka.util.PriorityQueueStabilizer包装的java.util.concurrent.PriorityBlockingQueue 否 akka.dispatch.UnboundedStablePriorityMailbox -
阻塞类型的邮箱:若mailbox-push-timeout-time设置为非零时将阻塞,否则不阻塞
邮箱 内部实现 有否上限 配置名称 BoundedMailbox 一个java.util.concurrent.LinkedBlockingQueue 是 bounded 或 akka.dispatch.BoundedMailbox BoundedPriorityMailbox
不保证同优先级消息的投递顺序一个使用akka.util.BoundedBlockingQueue包装的java.util.PriorityQueue 是 akka.dispatch.BoundedPriorityMailbox BoundedStablePriorityMailbox
严格按FIFO顺序投递同优先级消息一个使用akka.util.PriorityQueueStabilizer和akka.util.BoundedBlockingQueue包装的java.util.PriorityQueue 是 akka.dispatch.BoundedStablePriorityMailbox BoundedControlAwareMailbox
akka.dispatch.ControlMessage派生的控制消息将被优先投递两个java.util.concurrent.ConcurrentLinkedQueue,且当塞满时将阻塞 是 akka.dispatch.BoundedControlAwareMailbox
自定义邮箱
如果要自己实现邮箱,则需要从MailboxType派生。该类的构造函数有2个重要参数:一个是ActorSystem.Settings对象,一个是Config的节。后者需要在Dispatcher或者Mailbox的配置中,修改mailbox-type
为自定义MailboxType的完全限定名。
💀 标记用trait的需求映射指的是什么?是必须的吗?
// Marker trait used for mailbox requirements mapping
trait MyUnboundedMessageQueueSemantics
object MyUnboundedMailbox {
// This is the MessageQueue implementation
class MyMessageQueue extends MessageQueue with MyUnboundedMessageQueueSemantics {
private final val queue = new ConcurrentLinkedQueue[Envelope]()
// these should be implemented; queue used as example
def enqueue(receiver: ActorRef, handle: Envelope): Unit =
queue.offer(handle)
def dequeue(): Envelope = queue.poll()
def numberOfMessages: Int = queue.size
def hasMessages: Boolean = !queue.isEmpty
def cleanUp(owner: ActorRef, deadLetters: MessageQueue): Unit = {
while (hasMessages) {
deadLetters.enqueue(owner, dequeue())
}
}
}
}
// This is the Mailbox implementation
class MyUnboundedMailbox extends MailboxType with ProducesMessageQueue[MyUnboundedMailbox.MyMessageQueue] {
import MyUnboundedMailbox._
// This constructor signature must exist, it will be called by Akka
def this(settings: ActorSystem.Settings, config: Config) = {
// put your initialization code here
this()
}
// The create method is called to create the MessageQueue
final override def create(owner: Option[ActorRef], system: Option[ActorSystem]): MessageQueue =
new MyMessageQueue()
}
测试
🏭
com.typesafe.akka:akka-actor-testkit-typed_2.13:2.6.5
org.scalatest:scalatest_2.13:3.1.1
测试可以是在真实的ActorSystem上进行的异步测试,也可以是在BehaviorTestKit工具提供的测试专用线程上进行的同步测试。
异步测试
-
ScalaTest提供了ActorTestKit作为真实ActorSystem的替代品,通过混入
BeforeAndAfterAll
,覆写其afterAll() = testKit.shutdownTestKit()
,可实现测试后关闭ActorSystem。通过使用一个固定的testKit实例,可以直接spawn/stop某个Actor(可以是匿名的Actor),并以这种方式创建临时的Mock Actor,用以测试某个Actor的行为是否符合预期。
-
同时,ScalaTest提供TestProbe用于接受Actor的回复,并附上一组probe.expectXXX对Actor的活动进行断言。
-
当然,更简便的方式便是继承ScalaTestWithActorTestKit并混入AnyFeatureSpecLike之类的trait,从而将注意力完全集中在测试用例本身,而不用关心ActorSystem如何关闭之类的细枝末节。
-
ScalaTest的配置从application-test.conf中加载,否则将会自动加载Akka库自带的reference.conf配置,而不是应用程序自定义的application.conf。同时,ScalaTest支持用ConfigFactory.load()加载自定义配置文件,或用parseString()直接解决配置字符串,若再附以withFallback()将实现一次性完成配置及其后备的加载。
ConfigFactory.parseString(""" akka.loglevel = DEBUG akka.log-config-on-start = on """).withFallback(ConfigFactory.load())
-
为测试与时间线关系密切的Actor活动,ScalaTest提供了手动的定时器ManualTime,可以象下面这样测试指定时间点的活动:
class ManualTimerExampleSpec extends ScalaTestWithActorTestKit(ManualTime.config) with AnyWordSpecLike with LogCapturing { val manualTime: ManualTime = ManualTime() "A timer" must { "schedule non-repeated ticks" in { case object Tick case object Tock val probe = TestProbe[Tock.type]() val behavior = Behaviors.withTimers[Tick.type] { timer => // 10ms后才会调度消息 timer.startSingleTimer(Tick, 10.millis) Behaviors.receiveMessage { _ => probe.ref ! Tock Behaviors.same } } spawn(behavior) // 在9ms时还没有任何消息 manualTime.expectNoMessageFor(9.millis, probe) // 再经过2ms后,收到Tock消息 manualTime.timePasses(2.millis) probe.expectMessage(Tock) // 在10ms之后再没有消息传来 manualTime.expectNoMessageFor(10.seconds, probe) } } }
-
为了验证Actor是否发出了某些日志事件,ScalaTest提供了LoggingTestKit。
LoggingTestKit .error[IllegalArgumentException] .withMessageRegex(".*was rejected.*expecting ascii input.*") .withCustom { event => event.marker match { case Some(m) => m.getName == "validation" case None => false } } .withOccurrences(2) .expect { ref ! Message("hellö") ref ! Message("hejdå") }
-
为了集中有序输出日志信息,ScalaTest提供了LogCapturing,把日志和控制台输出信息整理在一起,在测试失败的时候才一次性输出,方便分析错误原因。具体示例参见交互模式一章。
同步测试
-
ScalaTest提供BehaviorTestKit用于Actor的同步测试。
val testKit = BehaviorTestKit(Hello()) // 创建子Actor testKit.run(Hello.CreateChild("child")) testKit.expectEffect(Spawned(childActor, "child")) // 创建匿名的子Actor testKit.run(Hello.CreateAnonymousChild) testKit.expectEffect(SpawnedAnonymous(childActor)) // 用一个InBox模拟Mailbox,方便测试收到的消息 val inbox = TestInbox[String]() testKit.run(Hello.SayHello(inbox.ref)) inbox.expectMessage("hello") // 测试子Actor的InBox testKit.run(Hello.SayHelloToChild("child")) val childInbox = testKit.childInbox[String]("child") childInbox.expectMessage("hello") // 测试匿名子Actor的InBox testKit.run(Hello.SayHelloToAnonymousChild) val child = testKit.expectEffectType[SpawnedAnonymous[String]] val childInbox = testKit.childInbox(child.ref) childInbox.expectMessage("hello stranger")
-
在以下一些情况下,不推荐使用BehaviorTestKit(未来可能会逐步改善):
- 涉及Future及类似的带异步回调的场景
- 涉及定时器或消息定时调度的场景
- 涉及EventSourcedBehavior的场景
- 涉及必须实测的Stubbed Actor的场景
- 黑盒测试
-
除了Spawned和SpawnedAnonymous,BehaviorTestKit还支持以下一些Effect:
- SpawnedAdapter
- Stopped
- Watched
- WatchedWith
- Unwatched
- Scheduled
-
BehaviorTestKit也支持日志验证
val testKit = BehaviorTestKit(Hello()) val inbox = TestInbox[String]("Inboxer") testKit.run(Hello.LogAndSayHello(inbox.ref)) testKit.logEntries() shouldBe Seq(CapturedLogEvent(Level.INFO, "Saying hello to Inboxer"))
Akka之Classic与Typed共存
现阶段的Akka Typed的内部,实质还是由传统Akka实现的,但未来将会有所改变。目前两类Akka有以下一些共存的方式:
- Classic ActorSystem可以创建Typed Actor
- Typed Actor与Classic Actor可以互发消息
- Typed Actor与Classic Actor可以相互建立监管或观察关系
- Classic Actor可以转换为Typed Actor
在导入命名空间时使用别名,以示区别:
import akka.{ actor => classic }
⚠️ 在监管策略方面,由于Classic默认为重启,而Typed为停止,所以Akka根据Child来决定实际策略。即如果被创建的Child是Classic,则默认采取重启策略,否则采取停止策略。
⭕ 从Classic到Typed
// 导入Typed的Adapter几乎必不可少
import akka.actor.typed.scaladsl.adapter._
val system = akka.actor.ActorSystem("ClassicToTypedSystem")
val typedSystem: ActorSystem[Nothing] = system.toTyped
val classicActor = system.actorOf(Classic.props())
class Classic extends classic.Actor with ActorLogging {
// context.spawn is an implicit extension method
val second: ActorRef[Typed.Command] = context.spawn(Typed(), "second")
// context.watch is an implicit extension method
context.watch(second)
// self can be used as the `replyTo` parameter here because
// there is an implicit conversion from akka.actor.ActorRef to
// akka.actor.typed.ActorRef
// An equal alternative would be `self.toTyped`
second ! Typed.Ping(self)
override def receive = {
case Typed.Pong =>
log.info(s"$self got Pong from ${sender()}")
// context.stop is an implicit extension method
context.stop(second)
case classic.Terminated(ref) =>
log.info(s"$self observed termination of $ref")
context.stop(self)
}
}
⭕ 从Typed到Classic
val system = classic.ActorSystem("TypedWatchingClassic")
val typed = system.spawn(Typed.behavior, "Typed")
object Typed {
final case class Ping(replyTo: akka.actor.typed.ActorRef[Pong.type])
sealed trait Command
case object Pong extends Command
val behavior: Behavior[Command] =
Behaviors.setup { context =>
// context.actorOf is an implicit extension method
val classic = context.actorOf(Classic.props(), "second")
// context.watch is an implicit extension method
context.watch(classic)
// illustrating how to pass sender, toClassic is an implicit extension method
classic.tell(Typed.Ping(context.self), context.self.toClassic)
Behaviors
.receivePartial[Command] {
case (context, Pong) =>
// it's not possible to get the sender, that must be sent in message
// context.stop is an implicit extension method
context.stop(classic)
Behaviors.same
}
.receiveSignal {
case (_, akka.actor.typed.Terminated(_)) =>
Behaviors.stopped
}
}
}
函数式与面向对象风格指南
区别 | 函数式编程风格 | 面向对象风格 |
---|---|---|
组成结构 | Singleton Object | Companion Object + AbstractBehavior[Message]派生类 |
工厂apply() | 在工厂方法里完成Behavior定义及其他所有工作 | 在Companion Object工厂方法里采取Behaviors.setup {context => new MyActor(context)} 这样的方式构造初始化的Behavior,然后把context和其他必要参数注入给类的构造函数,完成Behavior的链接 |
Actor扩展类 | 没有派生,所以只能用Behaviors.same | 从AbstractBehavior[Message]派生实例,所以可以使用this等同于Behaviors.same |
Behavior | 在Singleton Object里给Behaviors.receive这样的工厂方法传入一个函数(闭包)进行定义 | 覆写派生类的onMessage函数 |
Context | Context与Message一起传入给receive | 依赖Behaviors.setup等工厂方法传递给派生类,因此每实例对应一个context |
状态 | 给工厂方法传入参数(通常会把包括context在内的所有参数封装成一个类似DTO的Class以适当解耦),返回带新状态的Behavior | 在AbstractBehavior实例对象的内部维护所有的可变状态 |
推荐理由 |
|
|
推荐做法:
- 不要把消息定义为顶层Class,而应与Behavior一起定义在Companion Object里,这样在使用时带着对象名作为前缀才不会引起歧义。
- 如果某Protocol由几个Actor共享,那么建议是在一个单独的Object里定义完整的Protocol。
- 诸如定时器调度消息或者再包装后的消息,通常作为Actor的私有消息用private修饰,但它们同样要从trait Command派生。
- 另一种定义私有消息的方法,是所有消息均派生自trait Message,然后插入一个派生自Message的中间trait PrivateMessage,之后再从PrivateMessage派生所有的私有消息,使用这样的层次结构区分公有和私有消息。
- 通常顶层的Message要定义为sealed,以避免case匹配时编译器提示匹配项不完整的错误。
- 使用AskPattern从ActorSystem外部与Actor直接进行Request-Response方式的交互时,建议使用AskPattern.ask()而不是?的中缀语法,这样可以最大程度保证类型安全(在Actor之间的属于ActorContext.ask())。
- Behaviors.setup可以嵌套,用以加载不同类型的资源。习惯上也把setup放在最外层,不过要注意supervise对setup的影响。
从传统Akka过渡
项目依赖的变化
Classic | Typed |
---|---|
akka-actor | akka-actor-typed |
akka-cluster | akka-cluster-typed |
akka-cluster-sharding | akka-cluster-sharding-typed |
akka-cluster-tools | akka-cluster-typed |
akka-distributed-data | akka-cluster-typed |
akka-persistence | akka-persistence-typed |
akka-stream | akka-stream-typed |
akka-testkit | akka-actor-testkit-typed |
import package的变化
Classic | Typed for Scala |
---|---|
akka.actor | akka.actor.typed.scaladsl |
akka.cluster | akka.cluster.typed |
akka.cluster.sharding | akka.cluster.sharding.typed.scaladsl |
akka.persistence | akka.persistence.typed.scaladsl |
➡️ Cluster
🏭 com.typesafe.akka:akka-cluster-typed_2.13:2.6.5
Member状态图
消息妥投 Reliable Delivery
import akka.actor.typed.delivery._
⚠️ 此模块目前仍不成熟,不建议在生产环境使用。
确保消息至少投递一次或恰好投递一次,是此模块的核心任务,但Akka框架没法自主实现,因为确认收到消息并且处理之,是属于业务逻辑的职责,所以必须在应用程序的配合下才能完全实现。而且,将消息妥投到目标邮箱还只是其中一个步骤(不丢失消息),确保目标Actor在消息到达前尚未崩溃(消息能被处理)也是其中重要的一环。
一个完整的消息妥投方案,包括发送消息、检测丢包、重发消息、防止过载、幂等处理等细节,这些工作绝大部分要由消费消息的一方来承担。比如消息重发,就要由消费者发现有丢包,然后向生产者提出,限流等其他一些工作亦是如此。Akka提供了以下三种模式(留意关于消息重发的细节):
⭕ 点对点模式 Point to Point
点对点模式适用于2个单一Actor之间的消息妥投。
- P:我准备舀了。
- C:我坐好了。
- P:舀好了,张嘴!
- C:我吃完了,再来一口!
- 运行时将检查并确保Producer与ProducerController都必须是本地Actor,以保证高效率,Consumer一侧亦如此。
- 由应用程序负责使用ProducerController.RegisterConsumer或ConsumerController.RegisterToProducerController消息,建立并维护两个Controller之间的连接畅通。
- 在前一条消息被处理完并Confirmed之前,ConsumerController不会把下一条消息Delivery发给Consumer。
- 在两个Controller之间的消息数量将由一个ConsumerController负责的流控制窗口(flow control window)进行管理。
- 无论是ProducerController亦或ConsumerController崩溃,所有未被Confirmed的消息都会被重新投递(即使事实上Consumer已经处理过的消息),以确保至少投递一次,否则消息将严格按Producer发出的顺序投递给Consumer。
⭕ 拉取模式 Worker Pulling
Worker Pulling,是若干个Worker根据自己的消费进度,主动从一个WorkManager处拉取任务的模式。
- P:我这有一堆活需要找人干。
- M:没问题,我找人来做。
- W(C):我来应聘。
- M:你被录用了!
- W1:给我点活干。
- W2:也给我点活干。
- M:这是今天的活,你们自己分吧!
- W1:我抢到了3份!
- W2:我抢到了4份!”
有新Worker加入时
- 由Receptionist负责登记所有的Worker,由WorkPullingProducerController负责从Receptionist的Listing里指定执行任务的Worker。
- 在WorkPullingProducerController与Worker之间建立联系后,仍由ProducerController与ConsumerController负责具体的一对一投递。
⭕ 分片模式 Sharding
🏭 com.typesafe.akka:akka-cluster-sharding-typed_2.13:2.6.5
Sharding,是在集群进行了分片后的消息妥投模式,将由Producer与Consumer两端的ShardingController负责总协调,由ShardingController各自的小弟Controller负责点个端点的通信。
- P:喂喂,SPC,我有一批特定款式的鞋需要找工厂代工。
- SPC:好的,我在全世界找代工厂。
- SCC1:作为一家中国的鞋类加工连锁企业,我OK。
- SCC2:我是加工衬衣的,Sorry。
- SPC:SCC1就你了,我的小弟PC稍后会直接和你联系。
- PC:SCC1,订单发给你了。
- SCC1:PC,我的小弟CC负责这批订单,你们2个实际干活的直接联系吧。
- CC:OK,我交给流水线C专门生产这款鞋。
- C:我这条线生产完了,货交给你了CC。
- CC:PC,我按订单交付地址把货直接发给你了。
- PC:SPC,货备妥了。
- SPC:P老板,货备妥了你在哪?
- P:送过来吧。
发送消息到另一个Entity
从另一个节点上的Producer发送消息(图中WorkPullingProducerController有误,应为ShardingProducerController)
- 发送与接收方的任一端,均由本体(Producer或Consumer),Controller和ShardingController三个部件构成。其中,ShardingProducerController与ShardingConsumerController搭配,负责为ProducerController与ConsumerController牵线搭桥,但2个ShardingController之间不需要相互注册,而是通过EntityId找到对方。
- 建立联系通道后,消息从ShardingProducerController发出,经ProducerController发往ShardingConsumerController,由ShardingConsumerController找到相应的ConsumerController,将消发给最终的Consumer。Consumer在处理完消息后,直接回复给ConsumerController,再经其发还给ProducerController,最终由ShardingProducerController回复Producer。
- 消息RequestNext.entitiesWithDemand属性将指向Consumer端若干同EntityId的Actor,所以这可以是一对多的关系。
⭕ 耐久的Producer
🏭 com.typesafe.akka:akka-persistence-typed_2.13:2.6.5
需要Producer支持消息重发,就意味着Producer得把发出去的消息保存一段时间,直到确信该消息已被处理后才删除之,所以能暂存消息的即为耐用的Producer。Akka为此提供了一个DurableProducerQueue的具体实现EventSourcedProducerQueue。其中,每个Producer必须对应一个唯一的PersistenceId。
import akka.persistence.typed.delivery.EventSourcedProducerQueue
import akka.persistence.typed.PersistenceId
val durableQueue =
EventSourcedProducerQueue[ImageConverter.ConversionJob](PersistenceId.ofUniqueId("ImageWorkManager"))
val durableProducerController = context.spawn(
WorkPullingProducerController(
producerId = "workManager",
workerServiceKey = ImageConverter.serviceKey,
durableQueueBehavior = Some(durableQueue)),
"producerController")
⭕ 改用Ask模式
除了tell模式,Producer还可以改用ask模式发出消息,此时用askNext代替requestNext,回复将被包装在MessageWithConfirmation里。
context.ask[MessageWithConfirmation[ImageConverter.ConversionJob], Done](
next.askNextTo,
askReplyTo => MessageWithConfirmation(ImageConverter.ConversionJob(resultId, from, to, image), askReplyTo)) {
case Success(done) => AskReply(resultId, originalReplyTo, timeout = false)
case Failure(_) => AskReply(resultId, originalReplyTo, timeout = true)
}
序列化 Serialization
对同处一个JVM上的不同Actor,消息将直接发送给对方,而对于跨JVM的消息,则需要序列化成一串二进制字节后传出,再反序列化恢复成消息对象后接收。Akka推荐使用Jackson和Google Protocol Buffers,且使用后者用于其内部消息的序列化,但也允许使用自定义的序列化器。
使用序列化器
以配置方式使用序列化器
序列化的相关配置都保存在akka.actor.serializers
一节,其中指向各种akka.serialization.Serializer
的实现,并使用serialization-bindings
为特定对象实例时绑定序列化器。由于对象可能同时继承了某个trait或者class,所以在判断应使用哪一个序列化器时,通常是找其最特化的那一个。若二者之间没有继承关系,则会触发警告。
akka {
actor {
serializers {
jackson-json = "akka.serialization.jackson.JacksonJsonSerializer"
jackson-cbor = "akka.serialization.jackson.JacksonCborSerializer"
proto = "akka.remote.serialization.ProtobufSerializer"
myown = "docs.serialization.MyOwnSerializer"
}
serialization-bindings {
"docs.serialization.JsonSerializable" = jackson-json
"docs.serialization.CborSerializable" = jackson-cbor
"com.google.protobuf.Message" = proto
"docs.serialization.MyOwnSerializable" = myown
}
}
}
⚠️ 如果待序列化的消息包含在Scala对象中,则为了引用这些消息,需要使用标准Java类名称。对于包含在名为Wrapper对象中名为Message的消息,正确的引用是Wrapper $ Message
,而不是Wrapper.Message
。
以编程方式使用序列化器
完整的序列化信息包括三个部分:二进制字节串形式的有效载荷payload,序列化器的SerializerId及其适用类的清单manifest,所以它是自描述的,得以跨JVM使用。
而在启动ActorSystem时,序列化器由SerializationExtension负责初始化,因此序列化器本身不能从其构造函数访问SerializationExtension,而只能在完成初始化之后迟一点才能访问它。
import akka.actor._
import akka.actor.typed.scaladsl.Behaviors
import akka.cluster.Cluster
import akka.serialization._
val system = ActorSystem("example")
// Get the Serialization Extension
val serialization = SerializationExtension(system)
// Have something to serialize
val original = "woohoo"
// Turn it into bytes, and retrieve the serializerId and manifest, which are needed for deserialization
val bytes = serialization.serialize(original).get
val serializerId = serialization.findSerializerFor(original).identifier
val manifest = Serializers.manifestFor(serialization.findSerializerFor(original), original)
// Turn it back into an object
val back = serialization.deserialize(bytes, serializerId, manifest).get
自定义序列化器
创建序列化器
所有的序列化器均派生自akka.serialization.Serializer。
class MyOwnSerializer extends Serializer {
// If you need logging here, introduce a constructor that takes an ExtendedActorSystem.
// class MyOwnSerializer(actorSystem: ExtendedActorSystem) extends Serializer
// Get a logger using:
// private val logger = Logging(actorSystem, this)
// This is whether "fromBinary" requires a "clazz" or not
def includeManifest: Boolean = true
// Pick a unique identifier for your Serializer,
// you've got a couple of billions to choose from,
// 0 - 40 is reserved by Akka itself
def identifier = 1234567
// "toBinary" serializes the given object to an Array of Bytes
def toBinary(obj: AnyRef): Array[Byte] = {
// Put the code that serializes the object here
//#...
Array[Byte]()
//#...
}
// "fromBinary" deserializes the given array,
// using the type hint (if any, see "includeManifest" above)
def fromBinary(bytes: Array[Byte], clazz: Option[Class[_]]): AnyRef = {
// Put your code that deserializes here
//#...
null
//#...
}
}
SerializerId必须是全局唯一的,该Id可以编码指定,也可以在配置中指定:
akka {
actor {
serialization-identifiers {
"docs.serialization.MyOwnSerializer" = 1234567
}
}
}
使用StringManifest指定适用类
默认情况下,序列化器使用Class指定其适用目标,但也可以使用字符串名称指定,具体参见fromBinary的第2个参数:
class MyOwnSerializer2 extends SerializerWithStringManifest {
val CustomerManifest = "customer"
val UserManifest = "user"
val UTF_8 = StandardCharsets.UTF_8.name()
// Pick a unique identifier for your Serializer,
// you've got a couple of billions to choose from,
// 0 - 40 is reserved by Akka itself
def identifier = 1234567
// The manifest (type hint) that will be provided in the fromBinary method
// Use `""` if manifest is not needed.
def manifest(obj: AnyRef): String =
obj match {
case _: Customer => CustomerManifest
case _: User => UserManifest
}
// "toBinary" serializes the given object to an Array of Bytes
def toBinary(obj: AnyRef): Array[Byte] = {
// Put the real code that serializes the object here
obj match {
case Customer(name) => name.getBytes(UTF_8)
case User(name) => name.getBytes(UTF_8)
}
}
// "fromBinary" deserializes the given array,
// using the type hint
def fromBinary(bytes: Array[Byte], manifest: String): AnyRef = {
// Put the real code that deserializes here
manifest match {
case CustomerManifest =>
Customer(new String(bytes, UTF_8))
case UserManifest =>
User(new String(bytes, UTF_8))
}
}
}
序列化ActorRef
ActorRef均可以使用Jackson进行序列化,但也可以自定义实现。
其中,要以字符串形式表示ActorRef,应借助ActorRefResolver实现。它主要有2个方法,分别对应序列化和反序列化:
- def toSerializationFormat[T](ref: ActorRef[T]): String
- def resolveActorRef[T](serializedActorRef: String): ActorRef[T]
class PingSerializer(system: ExtendedActorSystem) extends SerializerWithStringManifest {
private val actorRefResolver = ActorRefResolver(system.toTyped)
private val PingManifest = "a"
private val PongManifest = "b"
override def identifier = 41
override def manifest(msg: AnyRef) = msg match {
case _: PingService.Ping => PingManifest
case PingService.Pong => PongManifest
case _ =>
throw new IllegalArgumentException(s"Can't serialize object of type ${msg.getClass} in [${getClass.getName}]")
}
override def toBinary(msg: AnyRef) = msg match {
case PingService.Ping(who) =>
actorRefResolver.toSerializationFormat(who).getBytes(StandardCharsets.UTF_8)
case PingService.Pong =>
Array.emptyByteArray
case _ =>
throw new IllegalArgumentException(s"Can't serialize object of type ${msg.getClass} in [${getClass.getName}]")
}
override def fromBinary(bytes: Array[Byte], manifest: String) = {
manifest match {
case PingManifest =>
val str = new String(bytes, StandardCharsets.UTF_8)
val ref = actorRefResolver.resolveActorRef[PingService.Pong.type](str)
PingService.Ping(ref)
case PongManifest =>
PingService.Pong
case _ =>
throw new IllegalArgumentException(s"Unknown manifest [$manifest]")
}
}
}
滚动升级 Rolling Updates
一个消息被反序列为消息对象,其决定因素只有3个:payload、serializerId和manifest。Akka根据Id选择Serializer,然后Serializer根据manifest匹配fromBinary,最后fromBinary使用payload解析出消息对象。在这个过程中,起关键作用的manifest并不等价于Serializer绑定的消息类型,所以一个Serializer可以应用于多个消息类型,这就给换用新的序列化器提供了机会。主要步骤包括两步:
- 第一步:暂时只向akka.actor.serializers配置节中添加Serializer的定义,而不添加到akka.actor.serialization-bindings配置节中,然后执行一次滚动升级。这相当于注册Serializer,为切换到新的Serializer作准备。
- 第二步:向akka.actor.serialization-bindings配置节中添加新的Serializer,然后再执行一次滚动升级。此时,旧的节点将继续使用旧的Serializer序列化消息,而新节点将切换使用新的Serializer进行序列化,并且它也可以反序列化旧的序列化格式。
- 第三步(可选):完全删除旧的Serializer,因为新的Serializer已经能同时承担新旧两种版本的序列化格式。
校验
为了在本地测试时确认消息被正常地序列化与反序列化,可以采取如下配置启用本地消息的序列化。如果要将某个消息排除出此列,则需要继承trait akka.actor.NoSerializationVerificationNeeded
,或者在配置akka.actor.no-serialization-verification-needed-class-prefix
指定类名的前缀。
akka {
actor {
# 启用本地消息序列化
serialize-messages = on
# 启用Prop序列化
serialize-creators = on
}
}
使用Jackson进行序列化
🏭 com.typesafe.akka:akka-serialization-jackson_2.12:2.6.6
Jackson支持文本形式的JSON(jackson-json)和二进制形式的CBOR字节串(jackson-cbor)。
使用前准备
在使用Jackson进行序列化前,需要在Akka配置里加入序列化器声明和绑定声明,此处用的JSON格式。
akka.actor {
serialization-bindings {
"com.myservice.MySerializable" = jackson-json
}
}
而所有要用Jackson序列化的消息也得扩展其trait以作标识。
// 约定的名称是CborSerializable或者JsonSerializable,此处用MySerializable是为了演示
trait MySerializable
final case class Message(name: String, nr: Int) extends MySerializable
安全要求
出于安全考虑,不能将Jackson序列化器应用到诸如java.lang.Object、java.io.Serializable、java.util.Comparable等开放类型。
注解 Annotations
适用于普通的多态类型 Polymorphic types
多态类型是指可能有多种不同实现的类型,这就导致在反序列化时将面对多种可能的子类型。所以在使用Jackson序列化前,需要用JsonTypeInfo和JsonSubTypes进行注解说明。
- @JsonTypeInfo用来开启多态类型处理,它有以下几个属性:
- use:定义使用哪一种类型识别码,其可选值包括:
- JsonTypeInfo.Id.CLASS:使用完全限定类名做识别
- JsonTypeInfo.Id.MINIMAL_CLASS:若基类和子类在同一包类,使用类名(忽略包名)作为识别码
- JsonTypeInfo.Id.NAME:一个合乎逻辑的指定名称
- JsonTypeInfo.Id.CUSTOM:自定义识别码,与@JsonTypeIdResolver相对应
- JsonTypeInfo.Id.NONE:不使用识别码
- include(可选):指定识别码是如何被包含进去的,其可选值包括:
- JsonTypeInfo.As.PROPERTY:作为数据的兄弟属性
- JsonTypeInfo.As.EXISTING_PROPERTY:作为POJO中已经存在的属性
- JsonTypeInfo.As.EXTERNAL_PROPERTY:作为扩展属性
- JsonTypeInfo.As.WRAPPER_OBJECT:作为一个包装的对象
- JsonTypeInfo.As.WRAPPER_ARRAY:作为一个包装的数组
- property(可选):制定识别码的属性名称。此属性只有当use为JsonTypeInfo.Id.CLASS(若不指定property则默认为@class)、JsonTypeInfo.Id.MINIMAL_CLASS(若不指定property则默认为@c)、JsonTypeInfo.Id.NAME(若不指定property默认为@type),include为JsonTypeInfo.As.PROPERTY、JsonTypeInfo.As.EXISTING_PROPERTY、JsonTypeInfo.As.EXTERNAL_PROPERTY时才有效。
- defaultImpl(可选):如果类型识别码不存在或者无效,可以使用该属性来制定反序列化时使用的默认类型。
- visible(可选):是否可见。该属性定义了类型标识符的值是否会通过JSON流成为反序列化器的一部分,默认为false,即jackson会从JSON内容中处理和删除类型标识符,再传递给JsonDeserializer。
- use:定义使用哪一种类型识别码,其可选值包括:
- @JsonSubTypes用来列出给定类的子类,只有当子类类型无法被检测到时才会使用它,一般是配合@JsonTypeInfo在基类上使用。它的的值是一个@JsonSubTypes.Type[]数组,里面枚举了多态类型(value对应子类)和类型的标识符值(name对应@JsonTypeInfo中的property标识名称的值。此为可选值,若未指定则需由@JsonTypeName在子类上指定)。
- @JsonTypeName作用于子类,用来为多态子类指定类型标识符的值。
⚠️ 切记不能使用@JsonTypeInfo(use = Id.CLASS)
或ObjectMapper.enableDefaultTyping
,这会给多态类型带来安全隐患。
final case class Zoo(primaryAttraction: Animal) extends MySerializable
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
Array(
new JsonSubTypes.Type(value = classOf[Lion], name = "lion"),
new JsonSubTypes.Type(value = classOf[Elephant], name = "elephant")))
sealed trait Animal
final case class Lion(name: String) extends Animal
final case class Elephant(name: String, age: Int) extends Animal
适用于trait和case object创建的ADT
由于上述注解只能用于class,所以case class可以直接使用,但case object就需要采取变通的方法,通过在case object继承的trait上使用注解@JsonSerialize和@JsonDeserialize,再使用StdSerializer和StdDeserializer实现序列化操作即可。
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.ser.std.StdSerializer
@JsonSerialize(using = classOf[DirectionJsonSerializer])
@JsonDeserialize(using = classOf[DirectionJsonDeserializer])
sealed trait Direction
object Direction {
case object North extends Direction
case object East extends Direction
case object South extends Direction
case object West extends Direction
}
class DirectionJsonSerializer extends StdSerializer[Direction](classOf[Direction]) {
import Direction._
override def serialize(value: Direction, gen: JsonGenerator, provider: SerializerProvider): Unit = {
val strValue = value match {
case North => "N"
case East => "E"
case South => "S"
case West => "W"
}
gen.writeString(strValue)
}
}
class DirectionJsonDeserializer extends StdDeserializer[Direction](classOf[Direction]) {
import Direction._
override def deserialize(p: JsonParser, ctxt: DeserializationContext): Direction = {
p.getText match {
case "N" => North
case "E" => East
case "S" => South
case "W" => West
}
}
}
final case class Compass(currentDirection: Direction) extends MySerializable
适用于枚举 Enumerations
Jackson默认会将Scala的枚举类型中的Value序列化为一个JsonObject,该JsonObject包含一个“value”字段和一个“type”字段(其值是枚举的完全限定类名FQCN)。为此,Jackson为每个字段提供了一个注解JsonScalaEnumeration,用于设定字段的类型,它将会把枚举值序列化为JsonString。
trait TestMessage
object Planet extends Enumeration {
type Planet = Value
val Mercury, Venus, Earth, Mars, Krypton = Value
}
// Uses default Jackson serialization format for Scala Enumerations
final case class Alien(name: String, planet: Planet.Planet) extends TestMessage
// Serializes planet values as a JsonString
class PlanetType extends TypeReference[Planet.type] {}
// Specifies the type of planet with @JsonScalaEnumeration
final case class Superhero(name: String, @JsonScalaEnumeration(classOf[PlanetType]) planet: Planet.Planet) extends TestMessage
纲要演进 Schema Evolution
参见Event Sourced一节中的Schema Evolution。
删除字段
Jackson会自动忽略class中不存在的属性,所以不需要做额外工作。
添加字段
如果新增的字段是可选字段,那么该字段默认值是Option.None,不需要做额外工作。如果是必备字段,那么需要继承JacksonMigration并设定其默认值。示例如下:
// Old Event
case class ItemAdded(shoppingCartId: String, productId: String, quantity: Int) extends MySerializable
// New Event: optional property discount and field note added.
// 为什么要区分property与field?
case class ItemAdded(shoppingCartId: String, productId: String, quantity: Int, discount: Option[Double], note: String)
extends MySerializable {
// alternative constructor because `note` should have default value "" when not defined in json
@JsonCreator
def this(shoppingCartId: String, productId: String, quantity: Int, discount: Option[Double], note: Option[String]) =
this(shoppingCartId, productId, quantity, discount, note.getOrElse(""))
}
// New Event: mandatory field discount added.
case class ItemAdded(shoppingCartId: String, productId: String, quantity: Int, discount: Double) extends MySerializable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.DoubleNode
import com.fasterxml.jackson.databind.node.ObjectNode
import akka.serialization.jackson.JacksonMigration
class ItemAddedMigration extends JacksonMigration {
// 注明这是第几个版本,之后还可以有更新的版本
override def currentVersion: Int = 2
override def transform(fromVersion: Int, json: JsonNode): JsonNode = {
val root = json.asInstanceOf[ObjectNode]
if (fromVersion <= 1) {
root.set("discount", DoubleNode.valueOf(0.0))
}
root
}
}
ItemAddedMigration与ItemAdded的联系,需要在配置里设定,下同:
akka.serialization.jackson.migrations {
"com.myservice.event.ItemAdded" = "com.myservice.event.ItemAddedMigration"
}
重命名字段
// 将productId重命名为itemId
case class ItemAdded(shoppingCartId: String, itemId: String, quantity: Int) extends MySerializable
import akka.serialization.jackson.JacksonMigration
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
class ItemAddedMigration extends JacksonMigration {
override def currentVersion: Int = 2
override def transform(fromVersion: Int, json: JsonNode): JsonNode = {
val root = json.asInstanceOf[ObjectNode]
if (fromVersion <= 1) {
root.set("itemId", root.get("productId"))
root.remove("productId")
}
root
}
}
重定义类结构
// Old class
case class Customer(name: String, street: String, city: String, zipCode: String, country: String) extends MySerializable
// New class
case class Customer(name: String, shippingAddress: Address, billingAddress: Option[Address]) extends MySerializable
//Address class
case class Address(street: String, city: String, zipCode: String, country: String) extends MySerializable
import akka.serialization.jackson.JacksonMigration
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
class CustomerMigration extends JacksonMigration {
override def currentVersion: Int = 2
override def transform(fromVersion: Int, json: JsonNode): JsonNode = {
val root = json.asInstanceOf[ObjectNode]
if (fromVersion <= 1) {
val shippingAddress = root.`with`("shippingAddress")
shippingAddress.set("street", root.get("street"))
shippingAddress.set("city", root.get("city"))
shippingAddress.set("zipCode", root.get("zipCode"))
shippingAddress.set("country", root.get("country"))
root.remove("street")
root.remove("city")
root.remove("zipCode")
root.remove("country")
}
root
}
}
重命名类
// Old class
case class OrderAdded(shoppingCartId: String) extends MySerializable
// New class
case class OrderPlaced(shoppingCartId: String) extends MySerializable
class OrderPlacedMigration extends JacksonMigration {
override def currentVersion: Int = 2
override def transformClassName(fromVersion: Int, className: String): String = classOf[OrderPlaced].getName
override def transform(fromVersion: Int, json: JsonNode): JsonNode = json
}
删除特定的序列化绑定
当某个类不再需要序列化,而只需要反序列化时,应将其加入序列化的白名单,名单是一组类名或其前缀:
akka.serialization.jackson.whitelist-class-prefix =
["com.myservice.event.OrderAdded", "com.myservice.command"]
Jackson模块
Akka默认启用了以下Jackson模块:
akka.serialization.jackson {
# The Jackson JSON serializer will register these modules.
jackson-modules += "akka.serialization.jackson.AkkaJacksonModule"
# AkkaTypedJacksonModule optionally included if akka-actor-typed is in classpath
jackson-modules += "akka.serialization.jackson.AkkaTypedJacksonModule"
// FIXME how does that optional loading work??
# AkkaStreamsModule optionally included if akka-streams is in classpath
jackson-modules += "akka.serialization.jackson.AkkaStreamJacksonModule"
jackson-modules += "com.fasterxml.jackson.module.paramnames.ParameterNamesModule"
jackson-modules += "com.fasterxml.jackson.datatype.jdk8.Jdk8Module"
jackson-modules += "com.fasterxml.jackson.datatype.jsr310.JavaTimeModule"
jackson-modules += "com.fasterxml.jackson.module.scala.DefaultScalaModule"
}
JSON压缩
默认的JSON压缩策略如下:
# Compression settings for the jackson-json binding
akka.serialization.jackson.jackson-json.compression {
# Compression algorithm.
# - off : no compression (it will decompress payloads even it's off)
# - gzip : using common java gzip (it's slower than lz4 generally)
# - lz4 : using lz4-java
algorithm = gzip
# If compression is enabled with the `algorithm` setting the payload is compressed
# when it's larger than this value.
compress-larger-than = 32 KiB
}
每个绑定关系单独配置
# 共有配置
akka.serialization.jackson.jackson-json {
serialization-features {
WRITE_DATES_AS_TIMESTAMPS = off
}
}
akka.serialization.jackson.jackson-cbor {
serialization-features {
WRITE_DATES_AS_TIMESTAMPS = on
}
}
akka.actor {
serializers {
jackson-json-message = "akka.serialization.jackson.JacksonJsonSerializer"
jackson-json-event = "akka.serialization.jackson.JacksonJsonSerializer"
}
serialization-identifiers {
jackson-json-message = 9001
jackson-json-event = 9002
}
serialization-bindings {
"com.myservice.MyMessage" = jackson-json-message
"com.myservice.MyEvent" = jackson-json-event
}
}
# 为每个绑定关系单独配置
akka.serialization.jackson {
jackson-json-message {
serialization-features {
WRITE_DATES_AS_TIMESTAMPS = on
}
}
jackson-json-event {
serialization-features {
WRITE_DATES_AS_TIMESTAMPS = off
}
}
}
使用Manifest无关的序列化
默认情况下,Jackson使用manifest里的完全限定类名进行序列化,但这比较耗费磁盘空间和IO资源,为此可以用type-in-manifest关闭之,使类名不再出现在manifest里,然后再使用deserialization-type指定即可,否则Jackson会在绑定关系里去查找匹配的类型。
Akka Remoting已经实现了manifest的压缩,所以这部分内容对它没有什么实际效果。
akka.actor {
serializers {
jackson-json-event = "akka.serialization.jackson.JacksonJsonSerializer"
}
serialization-identifiers {
jackson-json-event = 9001
}
serialization-bindings {
"com.myservice.MyEvent" = jackson-json-event
}
}
# 由于manifest无关的序列化通常只适用于一个类型,所以通常采取每绑定关系单独配置的方式
akka.serialization.jackson {
jackson-json-event {
type-in-manifest = off
# Since there is exactly one serialization binding declared for this
# serializer above, this is optional, but if there were none or many,
# this would be mandatory.
deserialization-type = "com.myservice.MyEvent"
}
}
日期与时间格式
WRITE_DATES_AS_TIMESTAMPS
和WRITE_DURATIONS_AS_TIMESTAMPS
默认情况下是被禁用的,这意味着日期与时间字段将按ISO-8601(rfc3339)标准的yyyy-MM-dd'T'HH:mm:ss.SSSZZ
格式,而不是数字数组进行序列化。虽然这样的互操作性更好,但速度较慢。所以如果不需要ISO格式即可与外部系统进行互操作,那么可以作如下配置,以拥有更佳的性能(反序列化不受此设置影响)。
akka.serialization.jackson.serialization-features {
WRITE_DATES_AS_TIMESTAMPS = on
WRITE_DURATIONS_AS_TIMESTAMPS = on
}
其他可用配置
akka.serialization.jackson {
# Configuration of the ObjectMapper serialization features.
# See com.fasterxml.jackson.databind.SerializationFeature
# Enum values corresponding to the SerializationFeature and their boolean value.
serialization-features {
# Date/time in ISO-8601 (rfc3339) yyyy-MM-dd'T'HH:mm:ss.SSSZ format
# as defined by com.fasterxml.jackson.databind.util.StdDateFormat
# For interoperability it's better to use the ISO format, i.e. WRITE_DATES_AS_TIMESTAMPS=off,
# but WRITE_DATES_AS_TIMESTAMPS=on has better performance.
WRITE_DATES_AS_TIMESTAMPS = off
WRITE_DURATIONS_AS_TIMESTAMPS = off
}
# Configuration of the ObjectMapper deserialization features.
# See com.fasterxml.jackson.databind.DeserializationFeature
# Enum values corresponding to the DeserializationFeature and their boolean value.
deserialization-features {
FAIL_ON_UNKNOWN_PROPERTIES = off
}
# Configuration of the ObjectMapper mapper features.
# See com.fasterxml.jackson.databind.MapperFeature
# Enum values corresponding to the MapperFeature and their
# boolean values, for example:
#
# mapper-features {
# SORT_PROPERTIES_ALPHABETICALLY = on
# }
mapper-features {}
# Configuration of the ObjectMapper JsonParser features.
# See com.fasterxml.jackson.core.JsonParser.Feature
# Enum values corresponding to the JsonParser.Feature and their
# boolean value, for example:
#
# json-parser-features {
# ALLOW_SINGLE_QUOTES = on
# }
json-parser-features {}
# Configuration of the ObjectMapper JsonParser features.
# See com.fasterxml.jackson.core.JsonGenerator.Feature
# Enum values corresponding to the JsonGenerator.Feature and
# their boolean value, for example:
#
# json-generator-features {
# WRITE_NUMBERS_AS_STRINGS = on
# }
json-generator-features {}
# Configuration of the JsonFactory StreamReadFeature.
# See com.fasterxml.jackson.core.StreamReadFeature
# Enum values corresponding to the StreamReadFeatures and
# their boolean value, for example:
#
# stream-read-features {
# STRICT_DUPLICATE_DETECTION = on
# }
stream-read-features {}
# Configuration of the JsonFactory StreamWriteFeature.
# See com.fasterxml.jackson.core.StreamWriteFeature
# Enum values corresponding to the StreamWriteFeatures and
# their boolean value, for example:
#
# stream-write-features {
# WRITE_BIGDECIMAL_AS_PLAIN = on
# }
stream-write-features {}
# Configuration of the JsonFactory JsonReadFeature.
# See com.fasterxml.jackson.core.json.JsonReadFeature
# Enum values corresponding to the JsonReadFeatures and
# their boolean value, for example:
#
# json-read-features {
# ALLOW_SINGLE_QUOTES = on
# }
json-read-features {}
# Configuration of the JsonFactory JsonWriteFeature.
# See com.fasterxml.jackson.core.json.JsonWriteFeature
# Enum values corresponding to the JsonWriteFeatures and
# their boolean value, for example:
#
# json-write-features {
# WRITE_NUMBERS_AS_STRINGS = on
# }
json-write-features {}
# Additional classes that are allowed even if they are not defined in `serialization-bindings`.
# This is useful when a class is not used for serialization any more and therefore removed
# from `serialization-bindings`, but should still be possible to deserialize.
whitelist-class-prefix = []
# settings for compression of the payload
compression {
# Compression algorithm.
# - off : no compression
# - gzip : using common java gzip
algorithm = off
# If compression is enabled with the `algorithm` setting the payload is compressed
# when it's larger than this value.
compress-larger-than = 0 KiB
}
# Whether the type should be written to the manifest.
# If this is off, then either deserialization-type must be defined, or there must be exactly
# one serialization binding declared for this serializer, and the type in that binding will be
# used as the deserialization type. This feature will only work if that type either is a
# concrete class, or if it is a supertype that uses Jackson polymorphism (ie, the
# @JsonTypeInfo annotation) to store type information in the JSON itself. The intention behind
# disabling this is to remove extraneous type information (ie, fully qualified class names) when
# serialized objects are persisted in Akka persistence or replicated using Akka distributed
# data. Note that Akka remoting already has manifest compression optimizations that address this,
# so for types that just get sent over remoting, this offers no optimization.
type-in-manifest = on
# The type to use for deserialization.
# This is only used if type-in-manifest is disabled. If set, this type will be used to
# deserialize all messages. This is useful if the binding configuration you want to use when
# disabling type in manifest cannot be expressed as a single type. Examples of when you might
# use this include when changing serializers, so you don't want this serializer used for
# serialization and you haven't declared any bindings for it, but you still want to be able to
# deserialize messages that were serialized with this serializer, as well as situations where
# you only want some sub types of a given Jackson polymorphic type to be serialized using this
# serializer.
deserialization-type = ""
# Specific settings for jackson-json binding can be defined in this section to
# override the settings in 'akka.serialization.jackson'
jackson-json {}
# Specific settings for jackson-cbor binding can be defined in this section to
# override the settings in 'akka.serialization.jackson'
jackson-cbor {}
# Issue #28918 for compatibility with data serialized with JacksonCborSerializer in
# Akka 2.6.4 or earlier, which was plain JSON format.
jackson-cbor-264 = ${akka.serialization.jackson.jackson-cbor}
}
➡️ Persistence
Event Sourcing
🏭 com.typesafe.akka:akka-persistence-typed_2.13:2.6.5
Akka Persistence为带状态的Actor提供了持久化其状态以备崩溃后恢复的支持,其本质是持久化Actor相关的事件Event,从而在恢复时利用全部事件或阶段性快照重塑(Reconstruct/Replay/Rebuild)Actor。ES在现实生活中最典型的一个例子是会计使用的复式记账法。
📎 参考书目
-
MSDN上的 CQRS Journey。
该书以一个用C#编写的Conference预约售票系统为例,由浅入深地展示了实现CQRS的各个环节需要关注的重点。书中的配图和讨论非常精彩,而其中提到的Process Manager也是当下实现Saga的流行方式之一。
-
Randy Shoup所著 Events as First-Class Citizens。
文中的Stitch Fix是一家智能零售商,它通过整合零售、技术、仓储、数据分析等资源,使用数据分析软件和机器学习来匹配顾客的服装定制需求,为其挑选符合其个人风格、尺寸和偏好的服饰和配饰,提供了良好的消费体验。
顾客按需订购服装或申请每月、每两个月或每季度交货。每个盒子有五件货物。如果顾客喜欢配送货物,可以选择以标签价购买,全部购买享受75%的折扣;如果不喜欢,则免费退货。如果顾客没有购买任何货物,则需支付20美元的设计费。Stitch Fix的平均商品单价约65美元,公司期望在每个盒子中,用户能够保存2件商品。造型师是兼职,薪水为每小时15美元。每小时,造型师会完成4个盒子,这样能产生较高的毛利率,以覆盖巨大的开销及库存成本。
⚠️ 通用数据保护条例(General Data Protection Regulation,GDPR)要求,必须能根据用户的要求删除其个人信息。然而,在一个以Event Sourcing为基础的应用里,要彻底删除或修改带有个人信息的所有事件是非常困难的,所以改用“数据粉碎”的技术来实现。其原理是给每个人分配一个唯一的ID,然后以该ID作为密钥,对其相关的所有个人数据进行加密。当需要彻底删除该用户的信息时,直接删除该ID,即可保证其个人数据无法被解密,从而达到保护目的。Lightbend为Akka Persistence提供了相应的工具,以帮助构建具有GDPR功能的系统。
Akka Persistence提供了event sourced actor(又称为 persistent actor)作为实现。这类Actor在收到Command时会先进行检验Validate。如果Command各项条件通过了检验,则使之作用于当前实体,并产生相应的事件Event,待这些Event被持久化后,以更新实体的状态结束;否则,实体将直接拒绝Reject该Command。(💀 不该是先更新状态,然后才持久化事件吗?貌似先持久化再更新会更靠谱。)
而在重塑Actor时,所有的事件将被加载,并无需再校验地直接用于更新Actor的状态,直到恢复到最新状态。
一个典型的EventSourcedBehavior包括ID、初始State,CommandHandler与EventHandler四个组成部分,如果需要传入ActorContext,则在外层用Behaviors.setup传入即可:
import akka.persistence.typed.scaladsl.EventSourcedBehavior
import akka.persistence.typed.PersistenceId
object MyPersistentBehavior {
sealed trait Command
sealed trait Event
final case class State()
def apply(): Behavior[Command] =
EventSourcedBehavior[Command, Event, State](
// 1. 该Actor的唯一Id
persistenceId = PersistenceId.ofUniqueId("abc"),
// 2. 初始状态
emptyState = State(),
// 3. Command Handler
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
// 4. Event Handler
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
}
PersistenceId
PersistenceId是Event Sourced Actor在其生命周期内唯一的身份标识(想想聚合Id)。因为Akka Cluster提供的EntityId可能为多个不同类型的Actor共享,所以一般配合EntityTypeKey一起组成唯一的PersistenceId。所以,PersistenceId.apply()用默认的分隔符|
将entityType.name与entityId两个字符串连接成所需的Id。当然,也可以使用PersistenceId.ofUniqueId生成自定义分隔符的Id。
即使在集群条件下,持同一PersistanceId的Actor在任何时候只能存在一个,否则就世界大乱了。当然,因为有Recovery,这个Actor可以被分片甚至迁移到任何一个片及其节点上。
🔗 摘选自 https://doc.akka.io/docs/akka/current/typed/cluster-sharding.html#persistence-example
sharding.init(Entity(typeKey = HelloWorld.TypeKey) { entityContext =>
HelloWorld(entityContext.entityId, PersistenceId(entityContext.entityTypeKey.name, entityContext.entityId))
})
Command Handler
一个CommandHandler有2个参数:当前的State、收到的Command,然后返回Effect。Effect由其工厂创建,创建动作包括:
- persist:原子性地保存处理完Command后产生的若干Event,若保存其中一个Event时失败则所有Event都将失败。但是在底层的事件存储不支持一次写入多个事件的情况下,CommandHandler为拒绝一次性持久化多个事件,可以抛出EventRejectedException(通常带有UnsupportedOperationException),从而由父Actor进行监管处理。
- none:什么也不做,比如一个只包括读操作的Query Command。
- unhandled:表明该命令不适用于当前状态。
- stop:停止该Actor。
- stash:暂存当前命令。
- unstashAll:处理所有被Effect.stash暂存起来的命令。
- reply:向发来命令的Actor发送一条回复。
在返回Effect的同时,还可以在该Effect后接副作用SideEffect,比如Effect.persist(...).thenRun(...)。具体包括:
- thenRun:运行某个副作用函数。
- thenStop:停止该Actor。
- thenUnstashAll:处理所有被Effect.stash暂存起来的命令。
- thenReply:向发来命令的Actor发送一条回复。
任何SideEffect都最多只能执行一次。如果持久化失败,或者Actor直接重启、停止后再启动,都不会执行任何副作用。所以通常是响应RecoveryCompleted信号,在其中去执行需要被确认的副作用,这种情况下,则可能会出现同一个副作用多次执行的情况。
副作用都是按注册的顺序同步执行,但也不能避免因为发送消息等而导致操作的并发执行。副作用也可能在事件被持久化之前就被执行,这样的话,即使持久化失败导致事件未被保存,副作用也生效了。
💀 关于翻译:Akka用“日记”——Journal指代的Event Store,并与“日志”Log相区别。虽然我更喜欢用“事件簿”这样的称谓,但一来请教了师姐说“日记”更准确,二来电影《Joker》里做心理咨询的社工在问Frank时也用的Journal这个词,于是就此作罢。
Event Handler
一个EventHandler有2个参数:当前State,触发的Event,然后返回新的State。
⚡ CommandHandler触发并持久化事件,EventHandler处理事件并更新状态,所以Actor的状态实际是在EventHandler里才真正被改变的!
当事件Event被持久化后,EventHandler将使用它去修改作为参数传入的当前状态State,从而产生新的State。至于State的具体实现,可以是FP风格的不可变量,也可以是OO风格的可变量,但通常都会封装在诸如Class这样的一个容器里。
不同于Command Handler的是,Event Handler不会产生副作用,所以它将直接用于Actor的重塑Recovery操作上。如果需要在Recovery之后做点什么,那么恰当的楔入点包括:CommandHandler最后创建的Effect附加的thenRun(),或者是RecoveryCompleted事件的处理函数里。
改变Actor的行为
因为不同的消息将触发Actor不同的行为,所以行为也是Actor状态的一部分。所以在Recovery时除了恢复数据,还要小心恢复其相应的行为。尽管行为是函数,而函数是一等公民,所以行为理应可以象数据一样保存,但困难的地方在于怎么保存编码,因此Akka Persistence不提供Behavior的持久化。
面对这个棘手的问题,最容易想到的办法是根据State定义不同的CommandHandler,并随State变化而切换,从而使Actor成为一台有限状态机。于是,由此得到的便是由State与Command两级匹配构成的逻辑,利用继承定义State的不同实现,然后先case State、再case Command,最后根据匹配结果将消息分发至相应的处理函数(处理函数亦相对独立,以凸显不同的逻辑分支)。而在代码实现的结构上,就是在一个CommandHandler里,定义若干个协助完成消息处理的private function。这些处理函数的参数由Handler在case分支里赋与,返回类型则统一为与CommandHandler相同的Effect[Event, State]。最后,只需要将这个CommandHandler连壳带肉交给EventSourcedBehavior工厂即可。
📎 更规范的方式是把Handler定义在State里,具体参见后续的Handler设计指南。
强制回复
Request-Response是最常见的通信模式之一。为了保证Persistent Actor一定会回复,EventSourcedBehavior推出了ReplyEffect,从而保证CommandHandler一定会发回Reply。它与Effect的唯一区别是必须用工厂Effect.reply
、Effect.noReply
、Effect.thenReply
或者Effect.thenNoReply
之一创建的结果作为返回值,而不再是Effect,否则编译器会提示类型不匹配的错误。
为此,在定义Command时必须包含一个replyTo属性,同时得用EventSourcedBehavior.withEnforcedReplies(id, state, cmdHandler, evtHandler)
来创建Behavior。
序列化
常见的序列化方案和工具也适用于Akka,推荐使用🔗 Jackson
在序列化时,必须考虑不同版本事件之间的向下兼容性,参考纲要演进 Schema Evolution(💀 统一个中文名真难。architecture 架构,pattern 模式,structure 结构,style 风格/样式,template 模板,boilerplate 样板,schema 纲要)
重塑
相比Recovery,我更喜欢Replay或者Reconstruct,使用“重塑实体”和“事件重播”在语义上也更生动。
Akka Persistence在Actor启动或重启时,将自动地直接使用EventHandler进行Actor的重塑。要注意的是,不要在EventHandler中执行副作用,而应该在重塑完成后,在receiveSignal里响应RecoveryCompleted信号,在响应程序里执行副作用。在RecoveryCompleted信号里带有重塑后的当前状态。而即使对于一个新的、还没有任何已记录事件的Actor,在执行Recovery之后也会触发RecoveryCompleted信号。
由于在重塑完成前,所有新消息将会被Stash,所以为防止失去响应,Akka提供了最大并发的重塑数,可以按akka.persistence.max-concurrent-recoveries = 50
的方式进行配置。
重塑过滤 Replay Filter
在某些情况下,事件流可能会损坏,而此时多个写入者(即多个Persistent Actor实例)准备写入具有相同序列号的不同消息,则会引发不一致的冲突。为此,Akka Persistence提供了Replay Filter,通过消息序列号和写入者的UUID来检测并解决消息之间的冲突。具体配置需要写入配置文件中的如下区段(leveldb视具体插件而不同):
💀 理解不能:为什么会有多个Actor实例要写入有相同序列号的消息?PersistenceId不该是唯一的吗?消息序列号是什么鬼?
🔈 Akka Persistence使用单一写入者原则,即任一时刻,对于任何一个特定的PersistenceId,只有一个EventSourcedBehavior能持久化事件。
akka.persistence.journal.leveldb.replay-filter {
mode = repair-by-discard-old
}
包括4种策略:
- repair-by-discard-old:抛弃旧写入者的事件,并且在Log里记下这次警告Warning。而在任何情况下,最高序列号的事件总会被重播,因此不用担心新的事件会打乱你已有的事件日志。
- fail:让重塑直接失败,并在Log里记下这次错误Error。
- warn:继续发出消息,并在Log里记下这次警告Warning。
- off:禁用此功能。
完全禁用重塑和快照功能
使用withRecovery()可以修改重塑的策略,包括禁用自动重塑功能。当然,快照功能可以单独禁用,或者只选择自己需要的那一类快照。
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.withRecovery(Recovery.disabled)
Tag标签
在不使用EventAdapter的情况下,可以直接使用withTagger为EventSourcedBehavior中的事件打上标签(准确说是标签集),方便将Event根据Tag实现分组,比如属于不同Actor实例但属相同类型的所有Event,然后在Persistence Query中使用。
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.withTagger(_ => Set("tag1", "tag2"))
适配Event
通过继承EventAdapter[T, Wrapper]
并安装到EventSourcedBehavior,可以自动将事件T转换为Wrapper,然后持久化。
case class Wrapper[T](event: T)
class WrapperEventAdapter[T] extends EventAdapter[T, Wrapper[T]] {
override def toJournal(e: T): Wrapper[T] = Wrapper(e)
override def fromJournal(p: Wrapper[T], manifest: String): EventSeq[T] = EventSeq.single(p.event)
override def manifest(event: T): String = ""
}
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.eventAdapter(new WrapperEventAdapter[Event])
处理日记失败
若Journal存取失败,则EventSourcedBehavior将停止。该默认行为可以通过使用覆写后的回退策略BackoffSupervisorStrategy
进行改变。普通的Supervisor在此处并不适用,因为事件已经被持久化,单纯地重启Actor并不能一并撤销日记发生的改变。如果Journal存取失败发生在重塑Actor的过程中,则会触发RecoveryFailed信号,同时Actor将停止或在回退后重新启动。
但若是Journal在持久化事件时发现错误,比如事件无法被序列化,那么它会主动拒绝持久化事件。此时该事件必定不会被Journal持久化,而会触发一个EventRejectedException异常传递给EventSourcedBehavior,然后按Supervisor设定的策略进行处理。
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.onPersistFailure(
SupervisorStrategy.restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1))
暂存消息
在执行Effect.persist或persistAll,上一个unstashAll或者创建快照期间,所有新到来的消息将自动地被暂存,直到所有事件被持久化且所有的副作用执行完毕。同理,在重塑过程中,新消息也将被暂存,直到重塑过程结束。除了自动暂存之外,在需要的时候,比如需要等候其他条件一并成立时,也可以用Effect.stash手动开始暂存,待条件全部齐备后再thenUnstashAll。
设置Stash的消息数量请配置:akka.persistence.typed.stash-capacity = 10000
⚠️ 由于Stash的消息都暂存在内存里,所以在以下情况发生时,这些消息将丢失:
- 当Actor被Cluster Sharding钝化或重新分配时
- 当Actor因处理命令或执行副作用时抛出异常而被停止或重启时
- 当Actor在持久化事件过程中触发异常时(若定义了onPersistFailure回退策略,则暂存的命令会被保留并在稍后再处理)
💀 Akka Persistence为什么没有为Mailbox提供一个持久化方案?或者,这应该是ConsumerController的责任?
🔈 参见:耐久的Producer
CQRS
Akka Persistence使用EventSourcedBehavior,配合Persistence Query的EventsByTag,实现CQRS模式。
Handler设计指南
与Handler单独放置的常见方案不同,Akka Persistence推荐将CommandHandler与EventHandler都设计在State里。这样State便可当作包括了业务逻辑和数据的完整领域对象。而在State刚创建时,除了专门定义一个初始化的State类外,也可以用Option[State]来代替,这样Option.None即代表了初始状态,而Option.Some则是更新后的状态,然后用case匹配即可。(💀 注意示例代码里State用的Option[Account])
完整示例如下:
/**
* Bank account example illustrating:
* - Option[State] that is starting with None as the initial state
* - event handlers in the state classes
* - command handlers in the state classes
* - replies of various types, using withEnforcedReplies
*/
object AccountExampleWithOptionState {
//#account-entity
object AccountEntity {
// Command
sealed trait Command extends CborSerializable
final case class CreateAccount(replyTo: ActorRef[OperationResult]) extends Command
final case class Deposit(amount: BigDecimal, replyTo: ActorRef[OperationResult]) extends Command
final case class Withdraw(amount: BigDecimal, replyTo: ActorRef[OperationResult]) extends Command
final case class GetBalance(replyTo: ActorRef[CurrentBalance]) extends Command
final case class CloseAccount(replyTo: ActorRef[OperationResult]) extends Command
// Reply
sealed trait CommandReply extends CborSerializable
sealed trait OperationResult extends CommandReply
case object Confirmed extends OperationResult
final case class Rejected(reason: String) extends OperationResult
final case class CurrentBalance(balance: BigDecimal) extends CommandReply
// Event
sealed trait Event extends CborSerializable
case object AccountCreated extends Event
case class Deposited(amount: BigDecimal) extends Event
case class Withdrawn(amount: BigDecimal) extends Event
case object AccountClosed extends Event
val Zero = BigDecimal(0)
// type alias to reduce boilerplate
type ReplyEffect = akka.persistence.typed.scaladsl.ReplyEffect[Event, Option[Account]]
// State
sealed trait Account extends CborSerializable {
def applyCommand(cmd: Command): ReplyEffect
def applyEvent(event: Event): Account
}
// State: OpenedAccount
case class OpenedAccount(balance: BigDecimal) extends Account {
require(balance >= Zero, "Account balance can't be negative")
override def applyCommand(cmd: Command): ReplyEffect =
cmd match {
case Deposit(amount, replyTo) =>
Effect.persist(Deposited(amount)).thenReply(replyTo)(_ => Confirmed)
case Withdraw(amount, replyTo) =>
if (canWithdraw(amount))
Effect.persist(Withdrawn(amount)).thenReply(replyTo)(_ => Confirmed)
else
Effect.reply(replyTo)(Rejected(s"Insufficient balance $balance to be able to withdraw $amount"))
case GetBalance(replyTo) =>
Effect.reply(replyTo)(CurrentBalance(balance))
case CloseAccount(replyTo) =>
if (balance == Zero)
Effect.persist(AccountClosed).thenReply(replyTo)(_ => Confirmed)
else
Effect.reply(replyTo)(Rejected("Can't close account with non-zero balance"))
case CreateAccount(replyTo) =>
Effect.reply(replyTo)(Rejected("Account is already created"))
}
override def applyEvent(event: Event): Account =
event match {
case Deposited(amount) => copy(balance = balance + amount)
case Withdrawn(amount) => copy(balance = balance - amount)
case AccountClosed => ClosedAccount
case AccountCreated => throw new IllegalStateException(s"unexpected event [$event] in state [OpenedAccount]")
}
def canWithdraw(amount: BigDecimal): Boolean = {
balance - amount >= Zero
}
}
// State: ClosedAccount
case object ClosedAccount extends Account {
override def applyCommand(cmd: Command): ReplyEffect =
cmd match {
case c: Deposit =>
replyClosed(c.replyTo)
case c: Withdraw =>
replyClosed(c.replyTo)
case GetBalance(replyTo) =>
Effect.reply(replyTo)(CurrentBalance(Zero))
case CloseAccount(replyTo) =>
replyClosed(replyTo)
case CreateAccount(replyTo) =>
replyClosed(replyTo)
}
private def replyClosed(replyTo: ActorRef[AccountEntity.OperationResult]): ReplyEffect =
Effect.reply(replyTo)(Rejected(s"Account is closed"))
override def applyEvent(event: Event): Account =
throw new IllegalStateException(s"unexpected event [$event] in state [ClosedAccount]")
}
// when used with sharding, this TypeKey can be used in `sharding.init` and `sharding.entityRefFor`:
val TypeKey: EntityTypeKey[Command] = EntityTypeKey[Command]("Account")
def apply(persistenceId: PersistenceId): Behavior[Command] = {
// type of State is Option[Account]
EventSourcedBehavior.withEnforcedReplies[Command, Event, Option[Account]](
persistenceId,
None,
// use result of case match for the parameter handler.
(state, cmd) =>
state match {
case None => onFirstCommand(cmd)
case Some(account) => account.applyCommand(cmd)
},
// match type Option[Account] declared in withEnforcedReplies.
(state, event) =>
state match {
case None => Some(onFirstEvent(event))
case Some(account) => Some(account.applyEvent(event))
})
}
def onFirstCommand(cmd: Command): ReplyEffect = {
cmd match {
case CreateAccount(replyTo) =>
Effect.persist(AccountCreated).thenReply(replyTo)(_ => Confirmed)
case _ =>
// CreateAccount before handling any other commands
Effect.unhandled.thenNoReply()
}
}
def onFirstEvent(event: Event): Account = {
event match {
case AccountCreated => OpenedAccount(Zero)
case _ => throw new IllegalStateException(s"unexpected event [$event] in state [EmptyAccount]")
}
}
}
}
快照 Snapshot
快照的初衷,是为了提高Recovery的效率,所以相应可以在构造EventSourcedBehavior时使用.snapshotWhen()定义创建快照的两种情况:一是每N条事件时创建一份快照;二是当满足特定条件时创建一份快照。
💀 snapshotWhen里的case匹配什么鬼?
🔈 参见Akka API
snapshotWhen:在指定状态和序列号的条件下,当指定事件被持久化后,即创建一份快照。当有多条事件时,则要等所有事件完成持久化后才会创建快照。
def snapshotWhen(predicate: (State, Event, Long) ⇒ Boolean): EventSourcedBehavior[Command, Event, State]
withRetention:指定保留或删除快照的策略。默认情况下,快照不会自动保存和删除。
def withRetention(criteria: RetentionCriteria): EventSourcedBehavior[Command, Event, State]
以下示例即指定在触发BookingCompleted事件后创建一份快照:
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => state)
.snapshotWhen {
case (state, BookingCompleted(_), sequenceNumber) => true
case (state, event, sequenceNumber) => false
}
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 100, keepNSnapshots = 2))
在重塑Actor时,默认会使用SnapshotSelectionCriteria.Latest
来选择最新的(最年轻)的快照版本,除非使用withRecovery里的Recovery参数指定其他策略(比如彻底禁用快照):
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.withRecovery(Recovery.withSnapshotSelectionCriteria(SnapshotSelectionCriteria.none))
除了默认提供的snapshot-store
插件(akka.persistence.snapshot-store.plugin,需要配置),可以使用EventSourcedBehavior.withSnapshotPluginId
指定其他的替代插件。
保存快照可能会失败,但它不会导致Actor的停止或重启,只会触发信号SnapshotCompleted或者SnapshotFailed,并记入日志Log。
删除快照
每当有新的快照成功创建时,旧的快照都将根据RetentionCriteria里设置的条件自动删除。在上面的例子里,将在每100条事件时(numberOfEvents = 100)创建一份快照,然后每份序列号小于已保存快照的序列号减去keepNSnapshots * numberOfEvents的快照会被自动删除(每隔200号删除之前的快照)。
⚠️ 根据Akka API的说明,如果将EventSourcedBehavior.withRetention和RetentionCriteria.snapshotEvery一起使用,则符合snapshotWhen定义条件而触发的快照将不会导致旧快照被删除。此类删除仅当单独使用withRetention,且匹配RetentionCriteria中的numberOfEvents设定值时才会触发。
在删除快照时,将会触发DeleteSnapshotsCompleted或DeleteSnapshotsFailed信号,可借此进行调试。
删除事件
💀 除非头铁,在一个以Event Sourced为基础实现模式的系统里,谁会没事删除事件?!相反,即使是因为应用版本升级而对原有的事件进行改造,那么在CQRS Journey里提出的事件版本迁移才理应是更恰当的选择。而在Akka Persistence里,这被称为纲要演进Schema Evolution。
删除事件与删除快照的策略,都是在withRetention里用RetentionCriteria.withDeleteEventsOnSnapshot
指定的,且同期的事件会先于快照被删除,而只保留最新版本的快照(💀这便与非EventSourced的应用有何区别?)。但这只是Akka Persistence认为的删除,至于底层的Event Store是否真的从数据库中删除该事件,则由EventStore的具体实现决定。
EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId("abc"),
emptyState = State(),
commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"),
eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state"))
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 100, keepNSnapshots = 2).withDeleteEventsOnSnapshot)
.receiveSignal { // optionally respond to signals
case (state, _: SnapshotFailed) => // react to failure
case (state, _: DeleteSnapshotsFailed) => // react to failure
case (state, _: DeleteEventsFailed) => // react to failure
}
测试 Persistent Actor
🏭 (看到此处时候更新到2.6.6,要注意这部分和Akka Persistence一样在未来版本会有大的变化)
com.typesafe.akka:akka-persistence-typed_2.12:2.6.6
com.typesafe.akka:akka-persistence-testkit_2.12:2.6.6
单元测试
Akka Persistence提供了EventSourcedBehaviorTestKit帮助进行测试,它按照一次一条命令的方式同步执行并返回结果,方便你断言其行为。
使用时,通过加载EventSourcedBehaviorTestKit.config来启动在内存中模拟的事件存储和快照功能。
Command、Event以及State的序列化校验会自动完成,相关的设置可以在创建EventSourcedBehaviorTestKit时,使用SerializationSettings进行自定义。默认情况下,它只负责序列化是否可用而不检查结果是否一致,所以要检查一致性就要启用verifyEquality,并且用case class之类的方法实现Command、Event和State的equals。
要测试重塑功能,可以使用EventSourcedBehaviorTestKit.restart
。完整示例如下:
class AccountExampleDocSpec
extends ScalaTestWithActorTestKit(EventSourcedBehaviorTestKit.config)
with AnyWordSpecLike
with BeforeAndAfterEach
with LogCapturing {
private val eventSourcedTestKit =
EventSourcedBehaviorTestKit[AccountEntity.Command, AccountEntity.Event, AccountEntity.Account](
system,
AccountEntity("1", PersistenceId("Account", "1")))
override protected def beforeEach(): Unit = {
super.beforeEach()
eventSourcedTestKit.clear()
}
"Account" must {
"be created with zero balance" in {
val result = eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.CreateAccount(_))
result.reply shouldBe AccountEntity.Confirmed
result.event shouldBe AccountEntity.AccountCreated
result.stateOfType[AccountEntity.OpenedAccount].balance shouldBe 0
}
"handle Withdraw" in {
eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.CreateAccount(_))
val result1 = eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.Deposit(100, _))
result1.reply shouldBe AccountEntity.Confirmed
result1.event shouldBe AccountEntity.Deposited(100)
result1.stateOfType[AccountEntity.OpenedAccount].balance shouldBe 100
val result2 = eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.Withdraw(10, _))
result2.reply shouldBe AccountEntity.Confirmed
result2.event shouldBe AccountEntity.Withdrawn(10)
result2.stateOfType[AccountEntity.OpenedAccount].balance shouldBe 90
}
"reject Withdraw overdraft" in {
eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.CreateAccount(_))
eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.Deposit(100, _))
val result = eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.Withdraw(110, _))
result.replyOfType[AccountEntity.Rejected]
result.hasNoEvents shouldBe true
}
"handle GetBalance" in {
eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.CreateAccount(_))
eventSourcedTestKit.runCommand[AccountEntity.OperationResult](AccountEntity.Deposit(100, _))
val result = eventSourcedTestKit.runCommand[AccountEntity.CurrentBalance](AccountEntity.GetBalance(_))
result.reply.balance shouldBe 100
result.hasNoEvents shouldBe true
}
}
}
持久化测试
🏭 com.typesafe.akka:akka-persistence-testkit_2.12:2.6.6
要测试事件是否被成功持久化,则要使用PersistenceTestKit。它提供了不同的工具集:
- 类PersistenceTestKit用于事件,对应的PersistenceTestKitPlugin用于模拟事件存储。
- 类SnapshotTestKit用于快照,对应的PersistenceTestKitSnapshotPlugin用于模拟快照存储。
使用前,需要在用于初始化TestKit的ActorSystem中进行配置:
object TestKitTypedConf {
val yourConfiguration = ConfigFactory.defaultApplication()
val system = ActorSystem(
??? /*some behavior*/,
"test-system",
PersistenceTestKitPlugin.config.withFallback(yourConfiguration))
val testKit = PersistenceTestKit(system)
}
object SnapshotTypedConf {
val yourConfiguration = ConfigFactory.defaultApplication()
val system = ActorSystem(
??? /*some behavior*/,
"test-system",
PersistenceTestKitSnapshotPlugin.config.withFallback(yourConfiguration))
val testKit = SnapshotTestKit(system)
}
使用PersistenceTestKit,可以实施以下测试行为:
- 检查某个关注事件是否将要被持久化的那一个。
- 检查某个关注事件是否已被持久化。
- 读取一组事件序列,方便逐个检视。
- 清空所有已持久化的事件。
- 读取所有已持久化的事件。
- 拒绝某个事件被持久化(快照不能被拒绝)。
- 把事件放入存储,以测试重塑功能。
- 当要持久化、读取或删除某个事件时抛出异常。
- 自定义底层存储设施的存取策略。
自定义存储策略
通过为事件存储实现ProcessingPolicy[EventStorage.JournalOperation]
或者为快照存储实现ProcessingPolicy[SnapshotStorage.SnapshotOperation]
,然后使用withPolicy()加载,可以自定义存储的存取策略,实现更细粒度的控制。
其中,较为关键的是ProcessingPolicy.tryProcess(persistenceId, storageOperation)方法。storageOperation方法包括:
- Event Storage
- ReadEvents
- WriteEvents
- DeleteEvents
- ReadSeqNum
- Snapshot Storage
- ReadSnapshot
- WriteSnapshot
- DeleteSnapshotByCriteria
- DeleteSnapshotByMeta:由SequenceNumber和TimeStamp构成的Meta
而tryProcess的结果则是下列情形之一:
- ProcessingSuccess:所有事件都被成功存取或删除。
- StorageFailure:模拟触发异常。
- Reject:模拟拒绝存取。
object PersistenceTestKitSampleSpec {
final case class Cmd(data: String) extends CborSerializable
final case class Evt(data: String) extends CborSerializable
object State {
val empty: State = new State
}
final class State extends CborSerializable {
def updated(event: Evt): State = this
}
}
class PersistenceTestKitSampleSpec
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config.withFallback(ConfigFactory.defaultApplication()))
with AnyWordSpecLike
with BeforeAndAfterEach {
val persistenceTestKit = PersistenceTestKit(system)
override def beforeEach(): Unit = {
persistenceTestKit.clearAll()
}
"Persistent actor" should {
"persist all events" in {
val persistenceId = PersistenceId.ofUniqueId("your-persistence-id")
val persistentActor = spawn(
EventSourcedBehavior[Cmd, Evt, State](
persistenceId,
emptyState = State.empty,
commandHandler = (_, cmd) => Effect.persist(Evt(cmd.data)),
eventHandler = (state, evt) => state.updated(evt)))
val cmd = Cmd("data")
persistentActor ! cmd
val expectedPersistedEvent = Evt(cmd.data)
persistenceTestKit.expectNextPersisted(persistenceId.id, expectedPersistedEvent)
}
}
}
class SampleEventStoragePolicy extends EventStorage.JournalPolicies.PolicyType {
//you can use internal state, it does not need to be thread safe
var count = 1
override def tryProcess(persistenceId: String, processingUnit: JournalOperation): ProcessingResult =
if (count < 10) {
count += 1
//check the type of operation and react with success or with reject or with failure.
//if you return ProcessingSuccess the operation will be performed, otherwise not.
processingUnit match {
case ReadEvents(batch) if batch.nonEmpty => ProcessingSuccess
case WriteEvents(batch) if batch.size > 1 =>
ProcessingSuccess
case ReadSeqNum => StorageFailure()
case DeleteEvents(_) => Reject()
case _ => StorageFailure()
}
} else {
ProcessingSuccess
}
}
class SampleSnapshotStoragePolicy extends SnapshotStorage.SnapshotPolicies.PolicyType {
//you can use internal state, it does not need to be thread safe
var count = 1
override def tryProcess(persistenceId: String, processingUnit: SnapshotOperation): ProcessingResult =
if (count < 10) {
count += 1
//check the type of operation and react with success or with reject or with failure.
//if you return ProcessingSuccess the operation will be performed, otherwise not.
processingUnit match {
case ReadSnapshot(_, payload) if payload.nonEmpty =>
ProcessingSuccess
case WriteSnapshot(meta, payload) if meta.sequenceNr > 10 =>
ProcessingSuccess
case DeleteSnapshotsByCriteria(_) => StorageFailure()
case DeleteSnapshotByMeta(meta) if meta.sequenceNr < 10 =>
ProcessingSuccess
case _ => StorageFailure()
}
} else {
ProcessingSuccess
}
}
class PersistenceTestKitSampleSpecWithPolicy
extends ScalaTestWithActorTestKit(PersistenceTestKitPlugin.config.withFallback(ConfigFactory.defaultApplication()))
with AnyWordSpecLike
with BeforeAndAfterEach {
val persistenceTestKit = PersistenceTestKit(system)
override def beforeEach(): Unit = {
persistenceTestKit.clearAll()
persistenceTestKit.resetPolicy()
}
"Testkit policy" should {
"fail all operations with custom exception" in {
val policy = new EventStorage.JournalPolicies.PolicyType {
class CustomFailure extends RuntimeException
override def tryProcess(persistenceId: String, processingUnit: JournalOperation): ProcessingResult =
processingUnit match {
case WriteEvents(_) => StorageFailure(new CustomFailure)
case _ => ProcessingSuccess
}
}
persistenceTestKit.withPolicy(policy)
val persistenceId = PersistenceId.ofUniqueId("your-persistence-id")
val persistentActor = spawn(
EventSourcedBehavior[Cmd, Evt, State](
persistenceId,
emptyState = State.empty,
commandHandler = (_, cmd) => Effect.persist(Evt(cmd.data)),
eventHandler = (state, evt) => state.updated(evt)))
persistentActor ! Cmd("data")
persistenceTestKit.expectNothingPersisted(persistenceId.id)
}
}
}
集成测试
PersistenceTestKit可以配合ActorTestKit一起使用,但有几点需要注意。一是对集群条件下涉及多个节点的测试,得使用单独的事件和快照存储。尽管可以使用Persistence Plugin Proxy,但使用真实的数据库通常会更好、更现实。二是某些Persistence插件会自动创建数据库的表,但在多个ActorSystem并发要求建表时就有一定的局限性了。所以为协调数据库的初始化工作,就得使用PersistenceInit工具。
val timeout = 5.seconds
val done: Future[Done] = PersistenceInit.initializeDefaultPlugins(system, timeout)
Await.result(done, timeout)
纲要演进
💀 这就是前面提到的事件版本迁移了!Schema,比如XML Schema,是描述特定结构的一种方式,翻译为“纲要”貌似妥帖一些。
这一章的重点,是介绍不同的纲要演进策略,以及如何根据领域模型的实际情况,在不同策略之间作出抉择。当然,这些策略并不是全部或者唯一的选择,而只是Akka给出的有限方案。其本质,都是为了保证旧系统下旧纲要规格格式的事件,在迁移到新系统后也能保持一致性,且不会为了处理这些不同版本的同一类型事件,而给业务逻辑带来额外负担。所以,纲要演进要实现的目标包括:
- 保证系统继续正常运行,而无需进行大规模的事件版本迁移。
- 保证新旧版本事件兼容,即使是旧版本的事件也能以新面貌统一呈现。
- 在重塑或查询过程中,将旧版本事件透明地升级为最新版本,从而使业务逻辑无需考虑事件的多个版本的兼容性问题。
其中,纲要演进的诱因包括,相应的解决方案也多是利用EventAdapter实现过滤:
- 向事件中增加一个字段。
- 原有字段被删除或更名。
- 事件从Protocol中删除。
- 将一个事件划分为几个更小粒度的事件。
选择适当的序列化格式
选择适当的序列化格式非常重要,这不仅关乎序列化的性能,还关乎纲要演进的方案确定和细节的实现。选择不当的话,序列化的扩展将非常困难,系统中将不得不保留多个不同版本的序列化代码。Akka Persistence推荐的序列化方案主要包括:
- Jackson:这是Akka强烈推荐的方案。
- Google Protocol Buffers:能获得更精细的控制,但需要处理更多的序列化与领域模型之间的映射细节。
- Apache的Thrift与Avro:主要提供二进制格式的序列化支持。
🔗 参考文献:Martin Kleppmann 所著Schema evolution in Avro, Protocol Buffers and Thrift
默认情况下,Akka Persistence使用Akka Serialization模块里基于Google Protocol Buffers实现的一个序列化器。如果Journal插件希望使用其他类型的序列化器,则需要根据不同的数据库进行挑选。但无论如何,Akka Persistence只是提供一个序列化器的可插拔接口,它不会自动处理消息的序列化。
所有的消息都会被序列化成如下封装结构:最底层也是最内层的是用黄色标注的有效载荷,它就是消息对象的实例被序列化后的结果,然后序列化器会附加它自己的SerializerId等信息一起组成中间层的PersistentPayload,之后才是Akka Persistence附加的SequenceNumber、PersistenceId等其他一些信息包裹组成的最外层。(💀 想像一下对方收到这封信时又是怎么一层层剥开的就好理解了。外面2层都是框架直接包揽了,只有核心那层需要自己操心。)所以序列化的要点,就在于最内层的消息对象要序列化成什么样子。对此,Java内置的序列化器用于调试还算勉强(想想多层属性嵌套的情形),生产环境下最好还是另寻他途。所以,了解序列化器的优势和局限性很重要,这样才能在进行项目时迅速行动,并且无惧重构模型。
以下是在Akka Serialization里自定义有效载荷序列化器的示例:
/**
* Usually a serializer like this would use a library like:
* protobuf, kryo, avro, cap'n proto, flatbuffers, SBE or some other dedicated serializer backend
* to perform the actual to/from bytes marshalling.
*/
final case class Person(name: String, surname: String)
class SimplestPossiblePersonSerializer extends SerializerWithStringManifest {
val Utf8 = Charset.forName("UTF-8")
val PersonManifest = classOf[Person].getName
// unique identifier of the serializer
// 在反序列化时,这个SerializerId将用于加载同一类型的序列化器,以保证完全对称
def identifier = 1234567
// extract manifest to be stored together with serialized object
override def manifest(o: AnyRef): String = o.getClass.getName
// serialize the object
override def toBinary(obj: AnyRef): Array[Byte] = obj match {
case p: Person => s"""${p.name}|${p.surname}""".getBytes(Utf8)
case _ => throw new IllegalArgumentException(s"Unable to serialize to bytes, class was: ${obj.getClass}!")
}
// deserialize the object, using the manifest to indicate which logic to apply
override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef =
manifest match {
case PersonManifest =>
val nameAndSurname = new String(bytes, Utf8)
val Array(name, surname) = nameAndSurname.split("[|]")
Person(name, surname)
case _ =>
throw new NotSerializableException(
s"Unable to deserialize from bytes, manifest was: $manifest! Bytes length: " + bytes.length)
}
}
相应在application.conf里的配置:
akka {
actor {
serializers {
person = "docs.persistence.SimplestPossiblePersonSerializer"
}
serialization-bindings {
"docs.persistence.Person" = person
}
}
}
Akka Serialization提供了相应的Jackson示例
⭕ 情形一:增加字段
适用场景:向已经存在的事件类型里添加一个新的字段。
解决方案:添加字段是最常见的演进事由之一,只要添加的字段是二进制兼容的(💀 Jackson就是文本兼容的,不是二进制兼容的吗?),就能很容易在序列化里实现演进。此处用ProtoBuf示范,为值机选座增加了一个靠窗或过道的字段seatType,然后给它一个默认值(此处用的SeatType.Unknown),或者可以用Option[T]包装,最后用ProtoBuf提供的方法hasSeatType区分新旧事件,再使用SeatType.fromString从字符串析取值。
class ProtobufReadOptional {
sealed abstract class SeatType { def code: String }
object SeatType {
def fromString(s: String) = s match {
case Window.code => Window
case Aisle.code => Aisle
case Other.code => Other
case _ => Unknown
}
case object Window extends SeatType { override val code = "W" }
case object Aisle extends SeatType { override val code = "A" }
case object Other extends SeatType { override val code = "O" }
case object Unknown extends SeatType { override val code = "" }
}
case class SeatReserved(letter: String, row: Int, seatType: SeatType)
/**
* Example serializer impl which uses protocol buffers generated classes (proto.*)
* to perform the to/from binary marshalling.
*/
class AddedFieldsSerializerWithProtobuf extends SerializerWithStringManifest {
override def identifier = 67876
final val SeatReservedManifest = classOf[SeatReserved].getName
override def manifest(o: AnyRef): String = o.getClass.getName
override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef =
manifest match {
case SeatReservedManifest =>
// use generated protobuf serializer
seatReserved(FlightAppModels.SeatReserved.parseFrom(bytes))
case _ =>
throw new NotSerializableException("Unable to handle manifest: " + manifest)
}
override def toBinary(o: AnyRef): Array[Byte] = o match {
case s: SeatReserved =>
FlightAppModels.SeatReserved.newBuilder
.setRow(s.row)
.setLetter(s.letter)
.setSeatType(s.seatType.code)
.build()
.toByteArray
}
// -- fromBinary helpers --
private def seatReserved(p: FlightAppModels.SeatReserved): SeatReserved =
SeatReserved(p.getLetter, p.getRow, seatType(p))
// handle missing field by assigning "Unknown" value
private def seatType(p: FlightAppModels.SeatReserved): SeatType =
if (p.hasSeatType) SeatType.fromString(p.getSeatType) else SeatType.Unknown
}
}
相应的ProtoBuf配置FlightAppModels.proto,其中新增加的seatType是optional
。ProtoBuf会根据配置生成一个具体负责Marshall的工具类,optional字段将会赋与一hasXXX的方法:
option java_package = "docs.persistence.proto";
option optimize_for = SPEED;
message SeatReserved {
required string letter = 1;
required uint32 row = 2;
optional string seatType = 3; // the new field
}
⭕ 情形二:重命名字段
适用场景:解决设计之初不恰当的字段命名,使其更符合业务需求。此处举例用了SeatReserved中原来的code,现在的seatNr。
解决方案一:使用符合IDL规范(Interface Description Language)的序列化器。这是最简单有效的方案,也是ProtoBuf和Thrift采取的方案,比如上面的.proto即是用IDL描述的映射结构,然后ProtoBuf再据此描述自动生成工具类。要改名时,只需要维护IDL映射结构,保持字段的ID不变,修改映射的名称即可。
// protobuf message definition, BEFORE:
message SeatReserved {
required string code = 1;
}
// protobuf message definition, AFTER:
message SeatReserved {
required string seatNr = 1; // field renamed, id remains the same
}
解决方案二:手动处理事件版本的迁移。在没办法使用IDL方式,比如使用Jackson格式进行序列化时,就只有手动进行转换,给事件附加一个版本号字段,然后用手写的EventAdapter进行反序列化的转换(该EventAdapter在EventSourcedBehavior创建时加载)。在使用Jackson进行增加字段、改变事件结构等情况下,这样的方法也是适用的。
class JsonRenamedFieldAdapter extends EventAdapter {
import spray.json.JsObject
val marshaller = new ExampleJsonMarshaller
val V1 = "v1"
val V2 = "v2"
// this could be done independently for each event type
override def manifest(event: Any): String = V2
override def toJournal(event: Any): JsObject =
marshaller.toJson(event)
override def fromJournal(event: Any, manifest: String): EventSeq = event match {
case json: JsObject =>
EventSeq(marshaller.fromJson(manifest match {
case V1 => rename(json, "code", "seatNr")
case V2 => json // pass-through
case unknown => throw new IllegalArgumentException(s"Unknown manifest: $unknown")
}))
case _ =>
val c = event.getClass
throw new IllegalArgumentException("Can only work with JSON, was: %s".format(c))
}
def rename(json: JsObject, from: String, to: String): JsObject = {
val value = json.fields(from)
val withoutOld = json.fields - from
JsObject(withoutOld + (to -> value))
}
}
⭕ 情形三:删除事件并忽略之
适用场景:某个事件被认为是多余的、毫无价值甚至影响效率的,但是在重塑时却没法跳过该事件。本例中是乘客按灯呼叫服务的事件CustomerBlinked。
最简单的方案:由于事件并不能真正从Journal中彻底删除,所以通常是在重塑时通过忽略特定的事件达到删除的效果。最简单的方案,就是在EventAdapter中截留该事件而返回一个空的EventSeq,同时放过其他类型的事件。该方案的弊端,在于从Storage中读取事件时,仍需要反序列化这个事件,从而导致效率的损失。
更成熟的方案:在上述方案基础上,增加了在序列化器端的过滤,使特定事件不再被反序列化。被忽略的事件被称为墓碑Tombstone。
final case class CustomerBlinked(customerId: Long)
case object EventDeserializationSkipped
class RemovedEventsAwareSerializer extends SerializerWithStringManifest {
val utf8 = Charset.forName("UTF-8")
override def identifier: Int = 8337
val SkipEventManifestsEvents = Set("docs.persistence.CustomerBlinked"
// 其他被忽略的事件...
)
override def manifest(o: AnyRef): String = o.getClass.getName
override def toBinary(o: AnyRef): Array[Byte] = o match {
case _ => o.toString.getBytes(utf8) // example serialization
}
override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef =
manifest match {
case m if SkipEventManifestsEvents.contains(m) =>
EventDeserializationSkipped
case other => new String(bytes, utf8)
}
}
class SkippedEventsAwareAdapter extends EventAdapter {
override def manifest(event: Any) = ""
override def toJournal(event: Any) = event
override def fromJournal(event: Any, manifest: String) = event match {
case EventDeserializationSkipped => EventSeq.empty
case _ => EventSeq(event)
}
}
⭕ 情形四:从数据模型中分离出域模型
适用场景:这主要是从持久化无关(Persistence Ignorance)的角度,坚持采用POJO(Plain Ordinary Java Object)这样的case class实现领域模型,并尽量避免数据模型及数据库、序列化器等底层细节和框架对领域模型的入侵。
解决方案:创建一个EventAdapter,实现case class与数据库存取class之间一对一的映射。
/** Domain model - highly optimised for domain language and maybe "fluent" usage */
object DomainModel {
final case class Customer(name: String)
final case class Seat(code: String) {
def bookFor(customer: Customer): SeatBooked = SeatBooked(code, customer)
}
final case class SeatBooked(code: String, customer: Customer)
}
/** Data model - highly optimised for schema evolution and persistence */
object DataModel {
final case class SeatBooked(code: String, customerName: String)
}
class DetachedModelsAdapter extends EventAdapter {
override def manifest(event: Any): String = ""
override def toJournal(event: Any): Any = event match {
case DomainModel.SeatBooked(code, customer) =>
DataModel.SeatBooked(code, customer.name)
}
override def fromJournal(event: Any, manifest: String): EventSeq = event match {
case DataModel.SeatBooked(code, customerName) =>
EventSeq(DomainModel.SeatBooked(code, DomainModel.Customer(customerName)))
}
}
⭕ 情形五:以可读样式存储事件
适用场景:希望以JSON等更可读的样式,而不是一个二进制流的方式来保存事件。这在最近的一些诸如MongoDB、PostgreSQL的NoSQL类型数据库中应用较为普遍。在做这样的决定前,必须要确定是存储格式便是要可读样式的,还只是想窥探事件存储而需要可读样式的。如果是后者,Persistence Query也能达到同样目的,且不会影响存储效率。
解决方案:创建EventAdapter,将事件转化为JSON后交给Journal直接保存。前提是必须有适配Akka Persistence的Journal插件支持,这样数据库才能直接识别EventAdapter转化来的JSon对象,然后存储它。
// act as-if JSON library
class ExampleJsonMarshaller {
def toJson(any: Any): JsObject = JsObject()
def fromJson(json: JsObject): Any = new Object
}
class JsonDataModelAdapter extends EventAdapter {
override def manifest(event: Any): String = ""
val marshaller = new ExampleJsonMarshaller
override def toJournal(event: Any): JsObject =
marshaller.toJson(event)
override def fromJournal(event: Any, manifest: String): EventSeq = event match {
case json: JsObject =>
EventSeq(marshaller.fromJson(json))
case _ =>
throw new IllegalArgumentException("Unable to fromJournal a non-JSON object! Was: " + event.getClass)
}
}
替代方案:如果找不到能支持上述方案的Journal插件,使用akka.persistence.journal.AsyncWriteJournal
来自己手动实现一个JSON格式的序列化器,再配合一个EventAdapter实现toJournal与fromJournal也是可行的。
⭕ 情形六:把事件切分为更小的粒度
适用场景:随着领域分析的深入,需要将原有粗粒度的一个事件切分为更小粒度的若干事件。此处以“用户信息改变”为例,将其切分为更小粒度的“用户名改变”“地址改变”等等。
解决方案:依旧是借助EventAdapter,将Journal里保存的一个大事件,切分为若干个小事件,反之亦然。
trait Version1
trait Version2
// V1 event:
final case class UserDetailsChanged(name: String, address: String) extends Version1
// corresponding V2 events:
final case class UserNameChanged(name: String) extends Version2
final case class UserAddressChanged(address: String) extends Version2
// event splitting adapter:
class UserEventsAdapter extends EventAdapter {
override def manifest(event: Any): String = ""
override def fromJournal(event: Any, manifest: String): EventSeq = event match {
case UserDetailsChanged(null, address) => EventSeq(UserAddressChanged(address))
case UserDetailsChanged(name, null) => EventSeq(UserNameChanged(name))
case UserDetailsChanged(name, address) =>
EventSeq(UserNameChanged(name), UserAddressChanged(address))
case event: Version2 => EventSeq(event)
}
override def toJournal(event: Any): Any = event
}
Persistence Query
🏭 com.typesafe.akka:akka-persistence-query_2.12:2.6.6
Akka Persistence公开了一个基于异步流的查询接口,使CQRS的读端(Read-Side)可以利用该接口读取Journal里保存的事件,从而执行更新UI等任务。但是,Persistence Query不能完全胜任读端的要求,它通常只能协助应用将数据从写端迁移到读端,这也是为了更好的执行效率和可扩展性,而建议读端与写端分别使用不同类型数据库的原因。
💀 写端通常都是以追加方式写入带Id的Event,所以传统的关系型数据库MySQL、Oracle或者象LevelDB、Redis这类Key-Value类型的NoSQL数据库通常会有较好的性能。而在读端,类似MongoDB这样文档类型的NoSQL数据库会有更大的市场。
考虑到要尽可能保持接口的通用性,Akka Persistence没有过多干涉Persistence Query的API定义,只要求:每个读日记(Read Journal)都必须明确表明其支持的查询类型,并按最常见的场景预定义了一些查询类型,而由Journal的插件自己去选择其中的一部分并实现之。
读日记
ReadJournal都属于Akka社区插件(🔗 Community Plugins),由用户自己开发并维护。每个ReadJournal对应一个存储方案(可以是数据库,甚至文本文件),且都有一个固定的Id。该Id可以使用类似readJournalFor[NoopJournal](NoopJournal.identifier)
的方式获取它,但这不是强制的,只是推荐做法。
要使用ReadJournal进行查询,就需要先获取它的一个实例(此处的Id即为akka.persistence.query.my-read-journal
):
// obtain read journal by plugin id
val readJournal =
PersistenceQuery(system).readJournalFor[MyScaladslReadJournal]("akka.persistence.query.my-read-journal")
// issue query to journal
val source: Source[EventEnvelope, NotUsed] =
readJournal.eventsByPersistenceId("user-1337", 0, Long.MaxValue)
// materialize stream, consuming events
source.runForeach { event =>
println("Event: " + event)
}
Akka Persistence的Read Journal API主要包括以下内容:
-
persistenceIds():查询系统中所有活动的Live PersistenceId,每当有新的Id被创建时也将被加入流当中。
-
currentPersistenceIds():查询系统中当前的PersistenceId,在执行查询之后新创建的Id不会被加入流中。
-
eventsByPersistenceId(id, fromSequenceNr = 0L, toSequenceNr = Long.MaxValue):查询PersistenceId对应Persistent Actor的事件,这有点类似于重塑过程中依次获取事件的活动,但该查询流也是活动的,所以多数Journal会采用轮询的方式确保结果是最新的。此处的SequenceNr用于从指定位置开始查询事件,这也变相地为“断点续注”提供了支持。
-
eventTag(tag, offset):查询指定Tag的所有事件。事件可能来源于多个PersistenceId,而且不能保证其先后顺序,除非Journal在反馈结果时提前排好序。
val NumberOfEntityGroups = 10 def tagEvent(entityId: String, event: Event): Set[String] = { val entityGroup = s"group-${math.abs(entityId.hashCode % NumberOfEntityGroups)}" event match { // OrderCompleted类型的事件会额外多一个标签 case _: OrderCompleted => Set(entityGroup, "order-completed") case _ => Set(entityGroup) } } def apply(entityId: String): Behavior[Command] = { EventSourcedBehavior[Command, Event, State]( persistenceId = PersistenceId("ShoppingCart", entityId), emptyState = State(), commandHandler = (state, cmd) => throw new NotImplementedError("TODO: process the command & return an Effect"), eventHandler = (state, evt) => throw new NotImplementedError("TODO: process the event return the next state")) .withTagger(event => tagEvent(entityId, event)) } // assuming journal is able to work with numeric offsets we can: val completedOrders: Source[EventEnvelope, NotUsed] = readJournal.eventsByTag("order-completed", Offset.noOffset) // find first 10 completed orders: val firstCompleted: Future[Vector[OrderCompleted]] = completedOrders .map(_.event) .collectType[OrderCompleted] .take(10) // cancels the query stream after pulling 10 elements .runFold(Vector.empty[OrderCompleted])(_ :+ _) // start another query, from the known offset val furtherOrders = readJournal.eventsByTag("order-completed", offset = Sequence(10))
-
查询附属信息:Persistence Query还支持查询属于流的附属信息,不过具体得由Journal实现。
final case class RichEvent(tags: Set[String], payload: Any) // a plugin can provide: order & infinite case class QueryMetadata(deterministicOrder: Boolean, infinite: Boolean) // Journal提供的查询附属信息API def byTagsWithMeta(tags: Set[String]): Source[RichEvent, QueryMetadata] = ??? // 使用上述API查询Materialized values val query: Source[RichEvent, QueryMetadata] = readJournal.byTagsWithMeta(Set("red", "blue")) query .mapMaterializedValue { meta => println( s"The query is: " + s"ordered deterministically: ${meta.deterministicOrder}, " + s"infinite: ${meta.infinite}") } .map { event => println(s"Event payload: ${event.payload}") } .runWith(Sink.ignore)
性能与非范式化
在CQRS模式下,读写端只要能保证最终的一致性,可以分别拥有不同形式的存储方案。据此,可以在读端采取类似数据库视图的方式,建立固定结构的物化视图Materialized Views反复使用,从而提高查询的效率。这样的视图无需严格遵守数据库的范式规则,怎么方便怎么来,比如用文档类型的NoSQL就不错。
-
借助兼容🔗 JDK9中Reactive Streams接口的数据库创建物化视图:这需要读端数据存储支持Reactive Streams接口,这样Journal直接将写端数据注入读端即可。
implicit val system = ActorSystem() val readJournal = PersistenceQuery(system).readJournalFor[MyScaladslReadJournal](JournalId) val dbBatchWriter: Subscriber[immutable.Seq[Any]] = ReactiveStreamsCompatibleDBDriver.batchWriter // Using an example (Reactive Streams) Database driver readJournal .eventsByPersistenceId("user-1337", fromSequenceNr = 0L, toSequenceNr = Long.MaxValue) .map(envelope => envelope.event) .map(convertToReadSideTypes) // convert to datatype .grouped(20) // batch inserts into groups of 20 .runWith(Sink.fromSubscriber(dbBatchWriter)) // write batches to read-side database
-
借助mapAsync创建物化视图:在没有Reactive Streams支持的情况下,自己动手实现从写端事件数据库到读端数据库的转换过程。
// 模拟的读端数据库 trait ExampleStore { def save(event: Any): Future[Unit] } val store: ExampleStore = ??? readJournal .eventsByTag("bid", NoOffset) .mapAsync(parallelism = 1) { e => store.save(e) } .runWith(Sink.ignore)
- 实现可恢复的注入:保存好Offset或者SequenceNumber,即可实现“断点续注”。
def runQuery(writer: ActorRef[TheOneWhoWritesToQueryJournal.Command])(implicit system: ActorSystem[_]): Unit = { val readJournal = PersistenceQuery(system.toClassic).readJournalFor[MyScaladslReadJournal](JournalId) import system.executionContext implicit val timeout = Timeout(3.seconds) val bidProjection = new MyResumableProjection("bid") bidProjection.latestOffset.foreach { startFromOffset => readJournal .eventsByTag("bid", Sequence(startFromOffset)) .mapAsync(8) { envelope => writer .ask((replyTo: ActorRef[Done]) => TheOneWhoWritesToQueryJournal.Update(envelope.event, replyTo)) .map(_ => envelope.offset) } .mapAsync(1) { offset => bidProjection.saveProgress(offset) } .runWith(Sink.ignore) } } // 用一个Actor来实际执行注入任务 object TheOneWhoWritesToQueryJournal { sealed trait Command final case class Update(payload: Any, replyTo: ActorRef[Done]) extends Command def apply(id: String, store: ExampleStore): Behavior[Command] = { updated(ComplexState(), store) } private def updated(state: ComplexState, store: ExampleStore): Behavior[Command] = { Behaviors.receiveMessage { case command: Update => val newState = updateState(state, command) if (state.readyToSave) store.save(Record(state)) updated(newState, store) } } private def updateState(state: ComplexState, command: Command): ComplexState = { // some complicated aggregation logic here ... state } }
查询插件
无论使用何种数据库,只要是实现了ReadJournal的插件,都属于查询插件Query Plugins,都要公开查询适用的场景和语义。
读日记插件
所有的ReadJournal插件都必须实现akka.persistence.query.ReadJournalProvider
,且该Provider必须能同时支持创建Scala与Java版本的ReadJournal实例(akka.persistence.query.scaladsl/javadsl.ReadJournal),其构造子主要有以下4种形式:
- (ExtendedActorSystem, com.typesafe.config.Config, pathOfConfig):Config里是ActorSystem中有关插件的配置,path里是插件自身的配置
- (ExtendedActorSystem, com.typesafe.config.Config)
- (ExtendedActorSystem)
- ()
如果数据库只支持查询当前结果集,那么对于这一类无限的事件流,ReadJournal就必须采取轮询方式反复尝试读取更新的事件,此时建议在配置中使用refresh-interval
定义轮询间隔。
class MyReadJournalProvider(system: ExtendedActorSystem, config: Config) extends ReadJournalProvider {
override val scaladslReadJournal: MyScaladslReadJournal =
new MyScaladslReadJournal(system, config)
override val javadslReadJournal: MyJavadslReadJournal =
new MyJavadslReadJournal(scaladslReadJournal)
}
class MyScaladslReadJournal(system: ExtendedActorSystem, config: Config)
extends akka.persistence.query.scaladsl.ReadJournal
with akka.persistence.query.scaladsl.EventsByTagQuery
with akka.persistence.query.scaladsl.EventsByPersistenceIdQuery
with akka.persistence.query.scaladsl.PersistenceIdsQuery
with akka.persistence.query.scaladsl.CurrentPersistenceIdsQuery {
private val refreshInterval: FiniteDuration =
config.getDuration("refresh-interval", MILLISECONDS).millis
/**
* You can use `NoOffset` to retrieve all events with a given tag or retrieve a subset of all
* events by specifying a `Sequence` `offset`. The `offset` corresponds to an ordered sequence number for
* the specific tag. Note that the corresponding offset of each event is provided in the
* [[akka.persistence.query.EventEnvelope]], which makes it possible to resume the
* stream at a later point from a given offset.
*
* The `offset` is exclusive, i.e. the event with the exact same sequence number will not be included
* in the returned stream. This means that you can use the offset that is returned in `EventEnvelope`
* as the `offset` parameter in a subsequent query.
*/
override def eventsByTag(tag: String, offset: Offset): Source[EventEnvelope, NotUsed] = offset match {
case Sequence(offsetValue) =>
Source.fromGraph(new MyEventsByTagSource(tag, offsetValue, refreshInterval))
case NoOffset => eventsByTag(tag, Sequence(0L)) //recursive
case _ =>
throw new IllegalArgumentException("MyJournal does not support " + offset.getClass.getName + " offsets")
}
override def eventsByPersistenceId(
persistenceId: String,
fromSequenceNr: Long,
toSequenceNr: Long): Source[EventEnvelope, NotUsed] = {
// implement in a similar way as eventsByTag
???
}
override def persistenceIds(): Source[String, NotUsed] = {
// implement in a similar way as eventsByTag
???
}
override def currentPersistenceIds(): Source[String, NotUsed] = {
// implement in a similar way as eventsByTag
???
}
// possibility to add more plugin specific queries
def byTagsWithMeta(tags: Set[String]): Source[RichEvent, QueryMetadata] = {
// implement in a similar way as eventsByTag
???
}
}
class MyJavadslReadJournal(scaladslReadJournal: MyScaladslReadJournal)
extends akka.persistence.query.javadsl.ReadJournal
with akka.persistence.query.javadsl.EventsByTagQuery
with akka.persistence.query.javadsl.EventsByPersistenceIdQuery
with akka.persistence.query.javadsl.PersistenceIdsQuery
with akka.persistence.query.javadsl.CurrentPersistenceIdsQuery {
override def eventsByTag(tag: String, offset: Offset = Sequence(0L)): javadsl.Source[EventEnvelope, NotUsed] =
scaladslReadJournal.eventsByTag(tag, offset).asJava
override def eventsByPersistenceId(
persistenceId: String,
fromSequenceNr: Long = 0L,
toSequenceNr: Long = Long.MaxValue): javadsl.Source[EventEnvelope, NotUsed] =
scaladslReadJournal.eventsByPersistenceId(persistenceId, fromSequenceNr, toSequenceNr).asJava
override def persistenceIds(): javadsl.Source[String, NotUsed] =
scaladslReadJournal.persistenceIds().asJava
override def currentPersistenceIds(): javadsl.Source[String, NotUsed] =
scaladslReadJournal.currentPersistenceIds().asJava
// possibility to add more plugin specific queries
def byTagsWithMeta(tags: java.util.Set[String]): javadsl.Source[RichEvent, QueryMetadata] = {
import akka.util.ccompat.JavaConverters._
scaladslReadJournal.byTagsWithMeta(tags.asScala.toSet).asJava
}
}
class MyEventsByTagSource(tag: String, offset: Long, refreshInterval: FiniteDuration)
extends GraphStage[SourceShape[EventEnvelope]] {
private case object Continue
val out: Outlet[EventEnvelope] = Outlet("MyEventByTagSource.out")
override def shape: SourceShape[EventEnvelope] = SourceShape(out)
override protected def initialAttributes: Attributes = Attributes(ActorAttributes.IODispatcher)
override def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
new TimerGraphStageLogic(shape) with OutHandler {
lazy val system = materializer.system
private val Limit = 1000
private val connection: java.sql.Connection = ???
private var currentOffset = offset
private var buf = Vector.empty[EventEnvelope]
private val serialization = SerializationExtension(system)
override def preStart(): Unit = {
scheduleWithFixedDelay(Continue, refreshInterval, refreshInterval)
}
override def onPull(): Unit = {
query()
tryPush()
}
override def onDownstreamFinish(): Unit = {
// close connection if responsible for doing so
}
private def query(): Unit = {
if (buf.isEmpty) {
try {
buf = Select.run(tag, currentOffset, Limit)
} catch {
case NonFatal(e) =>
failStage(e)
}
}
}
private def tryPush(): Unit = {
if (buf.nonEmpty && isAvailable(out)) {
push(out, buf.head)
buf = buf.tail
}
}
override protected def onTimer(timerKey: Any): Unit = timerKey match {
case Continue =>
query()
tryPush()
}
object Select {
private def statement() =
connection.prepareStatement("""
SELECT id, persistence_id, seq_nr, serializer_id, serializer_manifest, payload
FROM journal WHERE tag = ? AND id > ?
ORDER BY id LIMIT ?
""")
def run(tag: String, from: Long, limit: Int): Vector[EventEnvelope] = {
val s = statement()
try {
s.setString(1, tag)
s.setLong(2, from)
s.setLong(3, limit)
val rs = s.executeQuery()
val b = Vector.newBuilder[EventEnvelope]
while (rs.next()) {
val deserialized = serialization
.deserialize(rs.getBytes("payload"), rs.getInt("serializer_id"), rs.getString("serializer_manifest"))
.get
currentOffset = rs.getLong("id")
b += EventEnvelope(
Offset.sequence(currentOffset),
rs.getString("persistence_id"),
rs.getLong("seq_nr"),
deserialized)
}
b.result()
} finally s.close()
}
}
}
}
扩展
关于这部分,请参考Lagom框架。Lagom是Lightbend开发的一个微服务框架,里面涉及了ES和CQRS的大量实现,里面已经有成熟的方案,可以借助Cluster Sharding实现有效扩展。
Lagom: The opinionated microservices framework for moving away from the monolith.
Lagom helps you decompose your legacy monolith and build, test, and deploy entire systems of Reactive microservices.
LevelDB实现Persistence Query的示例
📌 LevelDB是Google推出的一款Key-Value类型的NoSQL本地数据库(暂不支持网络)。本示例用LevelDB演示了如何用绿黑蓝作Tag,然后进行Persistence Query。
LevelDB is a fast key-value storage library written at Google that provides an ordered mapping from string keys to string values.
import akka.NotUsed
import akka.testkit.AkkaSpec
import akka.persistence.query.{ EventEnvelope, PersistenceQuery, Sequence }
import akka.persistence.query.journal.leveldb.scaladsl.LeveldbReadJournal
import akka.stream.scaladsl.Source
object LeveldbPersistenceQueryDocSpec {
//#tagger
import akka.persistence.journal.WriteEventAdapter
import akka.persistence.journal.Tagged
class MyTaggingEventAdapter extends WriteEventAdapter {
val colors = Set("green", "black", "blue")
override def toJournal(event: Any): Any = event match {
case s: String =>
var tags = colors.foldLeft(Set.empty[String]) { (acc, c) =>
if (s.contains(c)) acc + c else acc
}
if (tags.isEmpty) event
else Tagged(event, tags)
case _ => event
}
override def manifest(event: Any): String = ""
}
//#tagger
}
class LeveldbPersistenceQueryDocSpec(config: String) extends AkkaSpec(config) {
def this() = this("")
"LeveldbPersistentQuery" must {
"demonstrate how get ReadJournal" in {
//#get-read-journal
import akka.persistence.query.PersistenceQuery
import akka.persistence.query.journal.leveldb.scaladsl.LeveldbReadJournal
val queries = PersistenceQuery(system).readJournalFor[LeveldbReadJournal](LeveldbReadJournal.Identifier)
//#get-read-journal
}
"demonstrate EventsByPersistenceId" in {
//#EventsByPersistenceId
val queries = PersistenceQuery(system).readJournalFor[LeveldbReadJournal](LeveldbReadJournal.Identifier)
val src: Source[EventEnvelope, NotUsed] =
queries.eventsByPersistenceId("some-persistence-id", 0L, Long.MaxValue)
val events: Source[Any, NotUsed] = src.map(_.event)
//#EventsByPersistenceId
}
"demonstrate AllPersistenceIds" in {
//#AllPersistenceIds
val queries = PersistenceQuery(system).readJournalFor[LeveldbReadJournal](LeveldbReadJournal.Identifier)
val src: Source[String, NotUsed] = queries.persistenceIds()
//#AllPersistenceIds
}
"demonstrate EventsByTag" in {
//#EventsByTag
val queries = PersistenceQuery(system).readJournalFor[LeveldbReadJournal](LeveldbReadJournal.Identifier)
val src: Source[EventEnvelope, NotUsed] =
queries.eventsByTag(tag = "green", offset = Sequence(0L))
//#EventsByTag
}
}
}
相应的配置如下:
# Configuration for the LeveldbReadJournal
akka.persistence.query.journal.leveldb {
# Implementation class of the LevelDB ReadJournalProvider
class = "akka.persistence.query.journal.leveldb.LeveldbReadJournalProvider"
# Absolute path to the write journal plugin configuration entry that this
# query journal will connect to. That must be a LeveldbJournal or SharedLeveldbJournal.
# If undefined (or "") it will connect to the default journal as specified by the
# akka.persistence.journal.plugin property.
write-plugin = ""
# The LevelDB write journal is notifying the query side as soon as things
# are persisted, but for efficiency reasons the query side retrieves the events
# in batches that sometimes can be delayed up to the configured `refresh-interval`.
refresh-interval = 3s
# How many events to fetch in one query (replay) and keep buffered until they
# are delivered downstreams.
max-buffer-size = 100
}
持久化插件 Persistence Plugins
持久化插件Persistence Plugins为数据库存储事件和快照提供支持,要注意与查询插件Query Plugins区别。由Akka团队负责维护的持久化插件包括:
- akka-persistence-cassandra
- akka-persistence-couchbase
- akka-persistence-jdbc
在Persistent Actor没有覆写journalPluginId和snapshotPluginId的情况下,Akka将使用在reference.conf中akka.persistence.journal.plugin
和akka.persistence.snapshot-store.plugin
中配置的默认日记和快照存储插件。若配置留空,则需要在application.conf中明确指定。
如果需要迟早加载持久化插件,则需按如下配置:
akka {
extensions = [akka.persistence.Persistence]
persistence {
journal {
plugin = "akka.persistence.journal.leveldb"
auto-start-journals = ["akka.persistence.journal.leveldb"]
}
snapshot-store {
plugin = "akka.persistence.snapshot-store.local"
auto-start-snapshot-stores = ["akka.persistence.snapshot-store.local"]
}
}
}
LevelDB Plugin使用示例
配置文件指定启用LevelDB Plugin作为Persistence Plugin,并指定数据库文件存放位置(默认是当前工作目录下的journal文件夹,快照是snapshots文件夹):
# Path to the journal plugin to be used
akka.persistence.journal.plugin = "akka.persistence.journal.leveldb"
akka.persistence.journal.leveldb.dir = "target/journal"
# Path to the snapshot store plugin to be used
akka.persistence.snapshot-store.plugin = "akka.persistence.snapshot-store.local"
akka.persistence.snapshot-store.local.dir = "target/snapshots"
Gradle包管理加入LevelDB Plugin
dependencies {
implementation org.fusesource.leveldbjni:leveldbjni-all:1.8
}
设定LevelDB的持久化参数,此处主要是设定每到哪个id,就通过删除事件而保留快照的方式(并非真正删除,而是给事件打上逻辑标志,使之成为“墓碑”),实现压缩数据库的功能:
# Number of deleted messages per persistence id that will trigger journal compaction
akka.persistence.journal.leveldb.compaction-intervals {
persistence-id-1 = 100
persistence-id-2 = 200
# ...
persistence-id-N = 1000
# use wildcards to match unspecified persistence ids, if any
"*" = 250
}
仅供测试的共享LevelDB
Akka内置了用于测试的可共享的LevelDB实例,启用配置如下:
akka.persistence.journal.plugin = "akka.persistence.journal.leveldb-shared"
akka.persistence.journal.leveldb-shared.store.dir = "target/shared"
在使用前,还必须用SharedLeveldbJournal.setStore注入一个Actor完成初始化:
import akka.persistence.journal.leveldb.SharedLeveldbStore
trait SharedStoreUsage extends Actor {
override def preStart(): Unit = {
context.actorSelection("akka://example@127.0.0.1:2552/user/store") ! Identify(1)
}
def receive = {
case ActorIdentity(1, Some(store)) =>
SharedLeveldbJournal.setStore(store, context.system)
}
}
// 然后就能正常使用了
val store = system.actorOf(Props[SharedLeveldbStore], "store")
仅供测试的持久化插件代理
Akka还内置了用于测试的Persistence Plugin Proxy,它通过定向转发整合若干个Journal,从而让多个Actor共享底层的持久化支持,在一个Journal节点崩溃后,也能通过其灾备节点继续为Actor提供事件存储和快照支持。代理的启动,则可以通过实例化PersistencePluginProxyExtension扩展或调用PersistencePluginProxy.start方法来完成。
# 配置信息需放入相应配置块
akka.persistence.journal.proxy { ... }
akka.persistence.snapshot-store.proxy { ... }
# 指定底层的Journal
target-journal-plugin = akka.persistence.journal.leveldb
target-snapshot-store-plugin = ...
# 指定用于初始化代理的ActorSystem
start-target-journal = xxx.xxx.myActor
start-target-snapshot-store = ...
## 指定Actor的位置(也可以在初始化代码中调用PersistencePluginProxy.setTargetLocation指定)
target-journal-address =
target-snapshot-store-address =
建造持久化后端
为自定义持久化事件的后端数据库支持,Akka Persistence公开了一组API。
🏭
import akka.persistence._
import akka.persistence.journal._
import akka.persistence.snapshot._
Journal插件API:AsyncWriteJournal
🔗 AsyncWriteJournal本质也是一个Actor,公开的方法只有以下3个:
/**
* Plugin API: asynchronously writes a batch (`Seq`) of persistent messages to the
* journal.
*
* The batch is only for performance reasons, i.e. all messages don't have to be written
* atomically. Higher throughput can typically be achieved by using batch inserts of many
* records compared to inserting records one-by-one, but this aspect depends on the
* underlying data store and a journal implementation can implement it as efficient as
* possible. Journals should aim to persist events in-order for a given `persistenceId`
* as otherwise in case of a failure, the persistent state may be end up being inconsistent.
*
* Each `AtomicWrite` message contains the single `PersistentRepr` that corresponds to
* the event that was passed to the `persist` method of the `PersistentActor`, or it
* contains several `PersistentRepr` that corresponds to the events that were passed
* to the `persistAll` method of the `PersistentActor`. All `PersistentRepr` of the
* `AtomicWrite` must be written to the data store atomically, i.e. all or none must
* be stored. If the journal (data store) cannot support atomic writes of multiple
* events it should reject such writes with a `Try` `Failure` with an
* `UnsupportedOperationException` describing the issue. This limitation should
* also be documented by the journal plugin.
*
* If there are failures when storing any of the messages in the batch the returned
* `Future` must be completed with failure. The `Future` must only be completed with
* success when all messages in the batch have been confirmed to be stored successfully,
* i.e. they will be readable, and visible, in a subsequent replay. If there is
* uncertainty about if the messages were stored or not the `Future` must be completed
* with failure.
*
* Data store connection problems must be signaled by completing the `Future` with
* failure.
*
* The journal can also signal that it rejects individual messages (`AtomicWrite`) by
* the returned `immutable.Seq[Try[Unit]]`. It is possible but not mandatory to reduce
* number of allocations by returning `Future.successful(Nil)` for the happy path,
* i.e. when no messages are rejected. Otherwise the returned `Seq` must have as many elements
* as the input `messages` `Seq`. Each `Try` element signals if the corresponding
* `AtomicWrite` is rejected or not, with an exception describing the problem. Rejecting
* a message means it was not stored, i.e. it must not be included in a later replay.
* Rejecting a message is typically done before attempting to store it, e.g. because of
* serialization error.
*
* Data store connection problems must not be signaled as rejections.
*
* It is possible but not mandatory to reduce number of allocations by returning
* `Future.successful(Nil)` for the happy path, i.e. when no messages are rejected.
*
* Calls to this method are serialized by the enclosing journal actor. If you spawn
* work in asynchronous tasks it is alright that they complete the futures in any order,
* but the actual writes for a specific persistenceId should be serialized to avoid
* issues such as events of a later write are visible to consumers (query side, or replay)
* before the events of an earlier write are visible.
* A PersistentActor will not send a new WriteMessages request before the previous one
* has been completed.
*
* Please note that the `sender` field of the contained PersistentRepr objects has been
* nulled out (i.e. set to `ActorRef.noSender`) in order to not use space in the journal
* for a sender reference that will likely be obsolete during replay.
*
* Please also note that requests for the highest sequence number may be made concurrently
* to this call executing for the same `persistenceId`, in particular it is possible that
* a restarting actor tries to recover before its outstanding writes have completed. In
* the latter case it is highly desirable to defer reading the highest sequence number
* until all outstanding writes have completed, otherwise the PersistentActor may reuse
* sequence numbers.
*
* This call is protected with a circuit-breaker.
*/
def asyncWriteMessages(messages: immutable.Seq[AtomicWrite]): Future[immutable.Seq[Try[Unit]]]
/**
* Plugin API: asynchronously deletes all persistent messages up to `toSequenceNr`
* (inclusive).
*
* This call is protected with a circuit-breaker.
* Message deletion doesn't affect the highest sequence number of messages,
* journal must maintain the highest sequence number and never decrease it.
*/
def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit]
/**
* Plugin API
*
* Allows plugin implementers to use `f pipeTo self` and
* handle additional messages for implementing advanced features
*
*/
def receivePluginInternal: Actor.Receive = Actor.emptyBehavior
如果想让Journal只支持同步写入,那么按如下方式阻塞掉异步的写入即可:
def asyncWriteMessages(messages: immutable.Seq[AtomicWrite]): Future[immutable.Seq[Try[Unit]]] =
Future.fromTry(Try {
// blocking call here
???
})
Journal还必须实现AsyncRecovery中定义的用于重塑和序列号恢复的方法:
/**
* Plugin API: asynchronously replays persistent messages. Implementations replay
* a message by calling `replayCallback`. The returned future must be completed
* when all messages (matching the sequence number bounds) have been replayed.
* The future must be completed with a failure if any of the persistent messages
* could not be replayed.
*
* The `replayCallback` must also be called with messages that have been marked
* as deleted. In this case a replayed message's `deleted` method must return
* `true`.
*
* The `toSequenceNr` is the lowest of what was returned by [[#asyncReadHighestSequenceNr]]
* and what the user specified as recovery [[akka.persistence.Recovery]] parameter.
* This does imply that this call is always preceded by reading the highest sequence
* number for the given `persistenceId`.
*
* This call is NOT protected with a circuit-breaker because it may take long time
* to replay all events. The plugin implementation itself must protect against
* an unresponsive backend store and make sure that the returned Future is
* completed with success or failure within reasonable time. It is not allowed
* to ignore completing the future.
*
* @param persistenceId persistent actor id.
* @param fromSequenceNr sequence number where replay should start (inclusive).
* @param toSequenceNr sequence number where replay should end (inclusive).
* @param max maximum number of messages to be replayed.
* @param recoveryCallback called to replay a single message. Can be called from any
* thread.
*
* @see [[AsyncWriteJournal]]
*/
def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)(
recoveryCallback: PersistentRepr => Unit): Future[Unit]
/**
* Plugin API: asynchronously reads the highest stored sequence number for the
* given `persistenceId`. The persistent actor will use the highest sequence
* number after recovery as the starting point when persisting new events.
* This sequence number is also used as `toSequenceNr` in subsequent call
* to [[#asyncReplayMessages]] unless the user has specified a lower `toSequenceNr`.
* Journal must maintain the highest sequence number and never decrease it.
*
* This call is protected with a circuit-breaker.
*
* Please also note that requests for the highest sequence number may be made concurrently
* to writes executing for the same `persistenceId`, in particular it is possible that
* a restarting actor tries to recover before its outstanding writes have completed.
*
* @param persistenceId persistent actor id.
* @param fromSequenceNr hint where to start searching for the highest sequence
* number. When a persistent actor is recovering this
* `fromSequenceNr` will be the sequence number of the used
* snapshot or `0L` if no snapshot is used.
*/
def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long]
编码完成后,通过配置即可启用Journal,但切记不能在默认Dispatcher上执行Journal的任务或者Future,否则会造成其他Actor陷入饥饿:
# Path to the journal plugin to be used
akka.persistence.journal.plugin = "my-journal"
# My custom journal plugin
my-journal {
# Class name of the plugin.
class = "docs.persistence.MyJournal"
# Dispatcher for the plugin actor.
plugin-dispatcher = "akka.actor.default-dispatcher"
}
Snapshot插件API:SnapshotStore
🔗 SnapshotStore也是一个Actor:
/**
* Plugin API: asynchronously loads a snapshot.
*
* If the future `Option` is `None` then all events will be replayed,
* i.e. there was no snapshot. If snapshot could not be loaded the `Future`
* should be completed with failure. That is important because events may
* have been deleted and just replaying the events might not result in a valid
* state.
*
* This call is protected with a circuit-breaker.
*
* @param persistenceId id of the persistent actor.
* @param criteria selection criteria for loading.
*/
def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]]
/**
* Plugin API: asynchronously saves a snapshot.
*
* This call is protected with a circuit-breaker.
*
* @param metadata snapshot metadata.
* @param snapshot snapshot.
*/
def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit]
/**
* Plugin API: deletes the snapshot identified by `metadata`.
*
* This call is protected with a circuit-breaker.
*
* @param metadata snapshot metadata.
*/
def deleteAsync(metadata: SnapshotMetadata): Future[Unit]
/**
* Plugin API: deletes all snapshots matching `criteria`.
*
* This call is protected with a circuit-breaker.
*
* @param persistenceId id of the persistent actor.
* @param criteria selection criteria for deleting.
*/
def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit]
/**
* Plugin API
* Allows plugin implementers to use `f pipeTo self` and
* handle additional messages for implementing advanced features
*/
def receivePluginInternal: Actor.Receive = Actor.emptyBehavior
类似Journal,编码完成后通过配置即可启用Snapshot插件:
# Path to the snapshot store plugin to be used
akka.persistence.snapshot-store.plugin = "my-snapshot-store"
# My custom snapshot store plugin
my-snapshot-store {
# Class name of the plugin.
class = "docs.persistence.MySnapshotStore"
# Dispatcher for the plugin actor.
plugin-dispatcher = "akka.persistence.dispatchers.default-plugin-dispatcher"
}
插件开发辅助工具 TCK
Akka开发了TCK(Technology Compatibility Kit),用于插件的测试。
🏭 com.typesafe.akka:akka-persistence-tck_2.12:2.6.6
以下分别是Journal与Snapshot必备的测试。如果插件需要一些额外的设置,比如启动模拟数据库、删除临时文件等,那么可以覆写beforeAll和afterAll方法:
class MyJournalSpec
extends JournalSpec(
config = ConfigFactory.parseString("""akka.persistence.journal.plugin = "my.journal.plugin"""")) {
override def supportsRejectingNonSerializableObjects: CapabilityFlag =
false // or CapabilityFlag.off
override def supportsSerialization: CapabilityFlag =
true // or CapabilityFlag.on
}
class MySnapshotStoreSpec
extends SnapshotStoreSpec(
config = ConfigFactory.parseString("""
akka.persistence.snapshot-store.plugin = "my.snapshot-store.plugin"
""")) {
override def supportsSerialization: CapabilityFlag =
true // or CapabilityFlag.on
}
损坏的事件日志
如果无法阻止用户同时运行具有相同persistenceId的Actor,则事件日志Log可能会因具有相同序列号的事件而被破坏。建议Journal在重塑过程中仍继续传递这些事件,同时使用reply-filter来决定如何处理。
➡️ 其他
打包
使用Gradle打包,主要借助其Java插件的Jar任务来完成。为了保证多个reference.conf正确合并,推荐使用Gradle插件🔗 Shadow plugin,然后在build.gradle里这样写:
import com.github.jengelman.gradle.plugins.shadow.transformers.AppendingTransformer
plugins {
id 'java'
id "com.github.johnrengelman.shadow" version "5.0.0"
}
shadowJar {
transform(AppendingTransformer) {
resource = 'reference.conf'
}
with jar
}
以Docker包的形式发布
在Docker容器中,可以同时使用Akka Remoting和Akka Cluster,但要注意配置好网络(🔗 Akka behind NAT or in a Docker container),并适当调整可用的CPU、内存等资源。
书籍与视频
🔗 https://doc.akka.io/docs/akka/current/additional/books.html
Akka API
🔗 https://doc.akka.io/api/akka/2.6/index.html