在今天的帖子中,我们将揭露一些非常有趣的(在我看来)体系结构样式: 事件源和命令查询责任分离 ( CQRS )。 本质上,这两个事件都是系统设计的核心,反映了正在发生的任何状态变化。 它与传统的CRUD架构有很大的不同,传统的CRUD架构通常只保留最近的已知状态,而实际上没有任何历史背景。
CQRS带来的另一个吸引人的选择是写模型(由命令启动并导致事件的状态修改)和读模型(在大多数情况下,查询最后一个已知状态)的自然分离。 它使您可以自由选择合适的数据存储(一个或多个)来满足不同的应用程序用例和需求。 但是,一如既往,没有免费的午餐:要付出的代价是系统日益复杂。
为了使文章简短,整个讨论分为两部分: 命令和事件 (本部分)和查询 (即将进行)。 以非常简单的用户实体模型为例,我们将设计使用CQRS体系结构的应用程序。 JVM平台上几乎没有可用的库,但是我们将要使用的库是Akka ,更确切地说是它最近添加的两个组件Akka Persistence和Akka HTTP 。 如果Akka对您来说不是什么新鲜事物,请不要担心,这些示例将非常基础,并且(希望)易于理解。 因此,让我们开始吧!
如前所述,系统中的更改是命令的结果,可能导致零个或多个事件。 让我们通过定义两个特征来建模:
trait Event
trait Command
反过来,这些事件可能会触发应用程序实体内的状态更改,因此让我们也使用通用特征对其进行建模:
trait State[T] {
def updateState(event: Event): State[T]
}
到目前为止非常简单。 现在,当我们为用户建模时, UserAggregate将构成我们的模型,并负责将更新应用到特定的User (可通过其id识别)。 此外,每个用户都将具有一个电子邮件属性,该属性可以在收到UserEmailUpdate命令后更改,并可能导致创建UserEmailUpdated事件作为结果。 以下代码段根据UserAggregate伴随对象内的状态 , 命令和事件定义了所有内容。
object UserAggregate {
case class User(id: String, email: String = "") extends State[User] {
override def updateState(event: Event): State[User] = event match {
case UserEmailUpdated(id, email) => copy(email = email)
}
}
case class UserEmailUpdate(email: String) extends Command
case class UserEmailUpdated(id: String, email: String) extends Event
}
对于熟悉CQRS和事件源的读者而言 ,此示例可能看起来很幼稚,但我认为它足以掌握基础知识。
定义好基础块之后,该是时候研究最有趣的部分了:接受命令,将它们转换为持久事件并应用状态更改,这些都是UserAggregate的职责 。 在CQRS和事件源的上下文中,持续事件是系统的关键功能。 事件是真理的唯一来源,任何单个实体的状态都可以通过重放与事件相关的所有事件而重建到任何时间点。 这是Akka Persistence加入舞台的时刻。
退一步, Akka是一个出色的库(甚至是工具包),可基于actor模型构建分布式系统。 最重要的是, Akka Persistence使参与者具有持久性功能,使他们能够将其消息(或事件)存储在持久日志中。 可能有人会说日记本会变得非常非常大,并且重放所有事件以重建状态可能会花费很多时间。 这是有道理的,因此Akka Persistence还添加了将持久性快照存储在持久性存储中的功能。 由于仅重播自上次快照以来发生的事件,因此可以大大加快操作速度。 默认情况下, LevelDB是Akka Persistence使用的持久存储引擎,但它是可插拔功能,具有许多可用的替代方法。
这样,让我们看一下将负责管理User实体的UserAggregate actor。
class UserAggregate(id: String) extends PersistentActor with ActorLogging {
import UserAggregate._
override def persistenceId = id
var state: State[User] = User(id)
def updateState(event: Event): Unit = {
state = state.updateState(event)
}
val receiveCommand: Receive = {
case UserEmailUpdate(email) => {
persist(UserEmailUpdated(id, email)) { event =>
updateState(event)
sender ! Acknowledged(id)
}
}
}
override def receiveRecover: Receive = {
case event: Event => updateState(event)
case SnapshotOffer(_, snapshot: User) => state = snapshot
}
}
到目前为止,这是最复杂的部分,因此让我们剖析关键部分。 首先, UserAggregate扩展了PersistentActor ,这为其添加了持久功能。 其次,每个持久性参与者都必须具有唯一的persistenceId :它用作事件日志和快照存储中的标识符。 最后,与常规的Akka actor相比,持久性actor确实有两个入口点: receiveCommand处理命令,而receiveRecover重放事件。
回到我们的示例,一旦UserAggregate收到UserEmailUpdate命令,首先它将使用persist(...)调用将UserEmailUpdated事件保留在日志中,然后使用updateState(...)调用更新聚合的状态,并以确认的方式答复发送者。 为了查看完整的示例,让我们使用来自Akka家族的另一个很棒的项目Akka HTTP (一个很棒的Spray框架出现)创建一个简单的REST端点。
现在,我们将定义一个简单的路由来处理/ api / v1 / users / {id}位置的PUT请求,其中{id}本质上是用户标识符的占位符。 它将接受单个表单编码的参数电子邮件以更新用户的电子邮件地址。
object UserRoute {
import scala.concurrent.ExecutionContext.Implicits.global
import scala.language.postfixOps
implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
implicit val timeout: Timeout = 5 seconds
val route = {
logRequestResult("eventsourcing-example") {
pathPrefix("api" / "v1" / "users") {
path(LongNumber) { id =>
(put & formFields('email.as[String])) { email =>
complete {
system
.actorSelection(s"user/user-$id")
.resolveOne
.recover {
case _: ActorNotFound =>
system.actorOf(Props(new UserAggregate(id.toString)), s"user-$id")
}
.map {
_ ? UserEmailUpdate(email) map {
case Acknowledged(_) =>
HttpResponse(status = OK, entity = "Email updated: " + email)
case Error(_, message) =>
HttpResponse(status = Conflict, entity = message)
}
}
}
}
}
}
}
}
}
唯一遗漏的部分是可运行类,用于为该REST端点插入处理程序,因此让我们定义一个,由于Akka HTTP,它很简单:
object Boot extends App with DefaultJsonProtocol {
Http().bindAndHandle(route, "localhost", 38080)
}
除了使用ScalaTest框架(由Akka HTTP TestKit补充)构建的简单易读的测试用例之外,没有任何东西可以保证一切都在正常运行 。
class UserRouteSpec extends FlatSpec
with ScalatestRouteTest
with Matchers
with BeforeAndAfterAll {
import com.example.domain.user.UserRoute
implicit def executionContext = scala.concurrent.ExecutionContext.Implicits.global
"UserRoute" should "return success on email update" in {
Put("http://localhost:38080/api/v1/users/123", FormData("email" -> "a@b.com")) ~> UserRoute.route ~> check {
response.status shouldBe StatusCodes.OK
responseAs[String] shouldBe "Email updated: a@b.com"
}
}
}
最后,让我们运行完整的示例,并从命令行使用curl来对REST端点执行真正的调用,从而迫使应用程序的所有部分协同工作。
$ curl -X PUT http://localhost:38080/api/v1/users/123 -d email=a@b.com
Email updated: a@b.com
真好! 结果符合我们的期望。 在完成本部分之前,还有一件事:请注意,重新启动应用程序时,将为已经存在聚合的用户恢复状态。 另一种使用Akka Persistence细节进行表述的方式,如果事件日志中有与参与者的persistenceId相关联的事件/快照(这就是为什么它应该唯一且永久)的原因,则恢复过程将通过应用更新的快照(如果有)并重放事件来进行。
在这篇文章中,我们探究了事件源和CQRS的幕后 ,仅涵盖了体系结构的写入(或命令)部分。 有许多工作要做,例如电子邮件唯一性验证,用户最新状态检索等,它们表示应用程序的读取(或查询)流程,并将在随后的博客文章中进行讨论。 就目前而言,我希望事件源和CQRS有点神秘,并且可以成为您将来或现有应用程序的一部分。
最终免责声明: Akka HTTP目前被标记为实验性组件(尽管主要目标仍然停留,API可能会略有变化),而Akka Persistence在最近的Akka 2.4.0-RC1版本中刚刚脱离实验性状态。 请注意。
完整项目可在GitHub上找到 。 非常感谢两位出色的开发人员Regis Leray和Esfandiar Amirrahimi在本系列博客文章中提供了很多帮助。