“我们赢了”:事件来源/ 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"}
]

不出所料,这次的结果有所不同,我们可以看到正在返回两个用户。 关键时刻,让我们尝试的用户提供123124个更新的用户的电子邮件。

$ 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可能是下一个项目考虑的有趣选择。

非常感谢两位出色的开发人员Regis LerayEsfandiar Amirrahimi在本系列博客文章中提供了很多帮助。

翻译自: https://www.javacodegeeks.com/2016/01/divided-win-event-sourcing-cqrs-prospective-write-read-models-separation.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值