cqrs 与 读写分离区别
相当早以前,我们已经开始探索命令查询责任分离 (CQRS)体系结构,作为开发分布式系统的替代方法。 上一次,我们仅介绍了命令和事件,而没有涉及查询。 这篇博客文章的目的是填补空白,并讨论遵循CQRS架构的查询处理方法。
我们将从上次中断的地方开始,该示例应用程序能够处理命令并将事件保留在日志中。 为了支持读取路径或查询,我们将介绍数据存储。 为了使事情简单,请使其成为内存中的H2数据库。 数据访问层将由超棒的Slick库处理。
首先,我们必须为User类提供一个简单的数据模型,该模型由UserAggregate持久性参与者进行管理。 在这方面, Users类是关系表的典型映射:
class Users(tag: Tag) extends Table[User](tag, "USERS") {
def id = column[String]("ID", O.PrimaryKey)
def email = column[String]("EMAIL", O.Length(512))
def uniqueEmail = index("USERS_EMAIL_IDX", email, true)
def * = (id, email) <> (User.tupled, User.unapply)
}
请注意,此时我们必须对用户的电子邮件实施唯一性约束,这一点很重要。 在与UserAggregate持久性参与者集成期间,我们稍后将回到这个细微的细节。 我们需要的下一步是管理数据存储访问的服务,即持久化和查询Users 。 正如我们在Akka宇宙中一样,显然它将也将是一名演员。 这里是:
case class CreateSchema()
case class FindUserByEmail(email: String)
case class UpdateUser(id: String, email: String)
case class FindAllUsers()
trait Persistence {
val users = TableQuery[Users]
val db = Database.forConfig("db")
}
class PersistenceService extends Actor with ActorLogging with Persistence {
import scala.concurrent.ExecutionContext.Implicits.global
def receive = {
case CreateSchema => db.run(DBIO.seq(users.schema.create))
case UpdateUser(id, email) => {
val query = for { user <- users if user.id === id } yield user.email
db.run(users.insertOrUpdate(User(id, email)))
}
case FindUserByEmail(email) => {
val replyTo = sender
db.run(users.filter( _.email === email.toLowerCase).result.headOption)
.onComplete { replyTo ! _ }
}
case FindAllUsers => {
val replyTo = sender
db.run(users.result) onComplete { replyTo ! _ }
}
}
}
请注意, PersistenceService是常规的无类型Akka actor,而不是持久的。 为了使事情集中,我们将仅支持四种消息:
- CreateSchema初始化数据库架构
- UpdateUser更新用户的电子邮件地址
- FindUserByEmail通过用户的电子邮件地址查询用户
- FindAllUsers查询数据存储中的所有用户
好的,数据存储服务已经准备就绪,但是没有什么能真正填满数据。 继续下一步,我们将重构UserAggregate ,更确切地说是它处理UserEmailUpdate命令的方式。 在其当前实施中,用户的电子邮件更新无条件地发生。 但是请记住,我们对电子邮件施加了唯一性约束,因此我们将更改命令逻辑以解决该问题:在实际执行更新之前,我们将针对读取模型(数据存储区)运行查询,以确保没有用户已经注册了此类电子邮件。
val receiveCommand: Receive = {
case UserEmailUpdate(email) =>
try {
val future = (persistence ? FindUserByEmail(email)).mapTo[Try[Option[User]]]
val result = Await.result(future, timeout.duration) match {
case Failure(ex) => Error(id, ex.getMessage)
case Success(Some(user)) if user.id != id => Error(id, s"Email '$email' already registered")
case _ => persist(UserEmailUpdated(id, email)) { event =>
updateState(event)
persistence ! UpdateUser(id, email)
}
Acknowledged(id)
}
sender ! result
} catch {
case ex: Exception if NonFatal(ex) => sender ! Error(id, ex.getMessage)
}
}
非常简单,但不是很习惯: Await.result看起来不属于它,因为它属于此代码。 我的第一个尝试是使用Future / map / recover / pipeTo管道来保持流完全异步。 但是,我观察到的副作用是,在这种情况下,大多数时候应该在另一个线程中执行persist(UserEmailUpdated(id,email)){event =>…}块(如果结果尚未准备好),但是并非如此,很可能是因为线程上下文切换。 因此, Await.result在这里进行了救援。
现在,每次用户的电子邮件更新发生时,伴随事件的持续发生,我们也将在数据存储中记录这一事实。 好的,我们已经接近了。
我们必须考虑的最后一件事是如何从事件日志中填充数据存储? Akka Persistence产品组合中的另一个实验性模块Akka Persistence Query在这里有很大的帮助。 除其他功能外,它还提供了通过持久性标识符查询事件日志的功能,这是我们要执行的操作,以便从日志中填充数据存储。 不足为奇,将由UserJournal参与者负责。
case class InitSchema()
class UserJournal(persistence: ActorRef) extends Actor with ActorLogging {
def receive = {
case InitSchema => {
val journal = PersistenceQuery(context.system)
.readJournalFor[LeveldbReadJournal](LeveldbReadJournal.Identifier)
val source = journal.currentPersistenceIds()
implicit val materializer = ActorMaterializer()
source
.runForeach { persistenceId =>
journal.currentEventsByPersistenceId(persistenceId, 0, Long.MaxValue)
.runForeach { event =>
event.event match {
case UserEmailUpdated(id, email) => persistence ! UpdateUser(id, email)
}
}
}
}
}
}
基本上,代码很简单,但是让我们重申一下它的功能。 首先,我们使用currentPersistenceIds方法向日记询问其具有的所有持久性标识符。 其次,对于每个持久性标识符,我们从日志中查询所有事件。 因为在我们的案例中只有一个事件UserEmailUpdated ,所以我们将其直接转换为数据存储服务UpdateUser消息。
太棒了,我们大功告成! 最后,最简单的事情是向UserRoute添加另一个终结点,该终结点通过查询读取的模型来返回现有用户的列表。
pathEnd {
get {
complete {
(persistence ? FindAllUsers).mapTo[Try[Vector[User]]] map {
case Success(users) =>
HttpResponse(status = OK, entity = users.toJson.compactPrint)
case Failure(ex) =>
HttpResponse(status = InternalServerError, entity = ex.getMessage)
}
}
}
}
我们已经准备好为我们改版的CQRS样本应用程序进行测试! 一旦启动并运行,请确保我们的日志和数据存储为空。
$ curl -X GET http://localhost:38080/api/v1/users
[]
有道理,因为我们尚未创建任何用户。 让我们通过用不同的电子邮件地址更新两个用户并再次查询读取模型来做到这一点。
$ curl -X PUT http://localhost:38080/api/v1/users/123 -d email=a@b.com
Email updated: a@b.com
$ curl -X PUT http://localhost:38080/api/v1/users/124 -d email=a@c.com
Email updated: a@c.com
$ curl -X GET http://localhost:38080/api/v1/users
[
{"id":"123","email":"a@b.com"},
{"id":"124","email":"a@c.com"}
]
不出所料,这次的结果有所不同,我们可以看到正在返回两个用户。 关键时刻,让我们尝试的用户提供123一124个更新的用户的电子邮件。
$ curl -X PUT http://localhost:38080/api/v1/users/123 -d email=a@c.com
Email 'a@c.com' already registered
好极了,这就是我们想要的! 读取(或查询)模型非常有用,并且效果很好。 请注意,当我们重新启动应用程序时,应该从日志中重新填充读取的数据存储,并返回以前创建的用户:
$ curl -X GET http://localhost:38080/api/v1/users
[
{"id":"123","email":"a@b.com"},
{"id":"124","email":"a@c.com"}
]
在本文结束时,我们将对CQRS体系结构进行介绍。 尽管可能会说很多事情不在讨论范围之内,但我希望在我们的旅程中提供的示例可以很好地说明这一想法,并且CQRS可能是下一个项目考虑的有趣选择。
- 与往常一样,完整的源代码可在GitHub上获得 。
非常感谢两位出色的开发人员Regis Leray和Esfandiar Amirrahimi在本系列博客文章中提供了很多帮助。
cqrs 与 读写分离区别