实践中的事件源和CQRS

任何尝试实施完全符合ACID的系统的人都知道,您需要做很多事情。 您需要确保可以自由创建,修改和删除数据库实体而不会出错,在大多数情况下,解决方案将以性能为代价。 可以用来解决此问题的一种方法是根据一系列事件而不是可变状态来设计系统。 这通常称为事件来源。

在本文中,我将展示一个演示应用程序,该应用程序使用开源工具包Speedment快速启动并运行可扩展的基于事件的数据库应用程序。 示例的完整源代码在此处

什么是事件源?

在典型的关系数据库系统中,您将实体的状态存储为数据库中的一行。 状态更改时,应用程序使用UPDATE或DELETE语句修改行。 这种方法的问题在于,当要确保没有更改任何行以致使系统处于非法状态时,它将对数据库增加很多要求。 您不希望任何人提取比他们帐户中更多的钱,或者要竞标已经结束的拍卖。

在基于事件的系统中,我们对此采取了不同的方法。 无需将实体的状态存储在数据库中,而是存储导致该状态的一系列更改 。 事件一旦创建便是不可变的,这意味着您仅需实现两个操作CREATE和READ。 如果实体被更新或删除,则可以通过创建“更新”或“删除”事件来实现。

事件源系统可以轻松扩展以提高性能,因为任何节点都可以简单地下载事件日志并重播当前状态。 由于写入和查询由不同的机器处理,因此您还可以获得更好的性能。 这称为CQRS(命令查询职责隔离)。 正如您将在示例中看到的,使用Speedment工具包,我们可以在极短的时间内获得最终一致的实例化视图并开始运行。

可预订的桑拿

为了展示构建事件源系统的工作流程,我们将创建一个小型应用程序来处理住宅区中共享桑拿浴室的预订。 我们有多个租户有兴趣预订桑拿浴室,但我们需要确保害羞的租户永远不会意外预订它。 我们还希望在同一系统中支持多个桑拿浴室。

为了简化与数据库的通信,我们将使用Speedment工具箱 。 Speedment是一个Java工具,它使我们能够从数据库生成完整的域模型,并且还可以使用优化的Java 8流轻松查询数据库。 在Apache 2-license下可以使用Speedment ,在Github页面上有很多很好的例子说明了不同的用法。

步骤1:定义数据库架构

第一步是定义我们的(MySQL)数据库。 我们仅拥有一张称为“预订”的桌子,用于存储与预订桑拿浴室有关的事件。 请注意,预订是事件而不是实体。 如果我们要取消预订或对其进行更改,则必须将其他更改发布为新行。 我们不允许修改或删除已发布的行。

CREATE DATABASE `sauna`;

CREATE TABLE `sauna`.`booking` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `booking_id` BIGINT NOT NULL,
  `event_type` ENUM('CREATE', 'UPDATE', 'DELETE') NOT NULL,
  `tenant` INT NULL,
  `sauna` INT NULL,
  `booked_from` DATE NULL,
  `booked_to` DATE NULL,
  PRIMARY KEY (`id`)
);

“ id”列是一个递增的整数,每次将新事件发布到日志时都会自动分配。 “ booking_id”告诉我们我们指的是哪个预订。 如果两个事件共享相同的预订ID,则它们引用相同的实体。 我们还有一个名为“ event_type”的枚举,它描述了我们试图执行的操作。 之后是属于预订的信息。 如果列为NULL,则与任何先前值相比,我们将其视为未修改。

步骤2:使用加速生成代码

下一步是使用Speedment为项目生成代码。 只需创建一个新的maven项目并将以下代码添加到pom.xml文件即可。

pom.xml

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.compiler.source>1.8</maven.compiler.source>
  <maven.compiler.target>1.8</maven.compiler.target>
  <speedment.version>3.0.0-EA2</speedment.version>
  <mysql.version>5.1.39</mysql.version>
</properties>

<build>
  <plugins>
    <plugin>
      <groupId>com.speedment</groupId>
      <artifactId>speedment-maven-plugin</artifactId>
      <version>${speedment.version}</version>

      <dependencies>
        <dependency>
          <groupId>mysql</groupId>
          <artifactId>mysql-connector-java</artifactId>
          <version>${mysql.version}</version>
        </dependency>
      </dependencies>
    </plugin>
  </plugins>
</build>

<dependencies>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
  </dependency>

  <dependency>
    <groupId>com.speedment</groupId>
    <artifactId>runtime</artifactId>
    <version>${speedment.version}</version>
    <type>pom</type>
  </dependency>
</dependencies>

如果生成项目,则IDE 中将出现一个名为speedment:tool的新maven目标。 运行它以启动Speedment用户界面。 在其中,连接到Sauna数据库并使用默认设置生成代码。 现在应在项目中填充源文件。

提示:如果对数据库进行了更改,则可以使用speedment:reload -goal下载新配置,并使用speedment:generate 重新生成源。 无需重新启动该工具!

步骤3:创建物化视图

物化视图是一个组件,该组件定期轮询数据库以查看是否已添加任何新行,如果已添加,则以正确的顺序下载并将它们合并到视图中。 由于轮询有时会花费很多时间,因此我们希望此过程在单独的线程中运行。 我们可以使用Java Timer和TimerTask来实现。

轮询数据库? 真? 好吧,需要考虑的重要一点是,只有服务器才能轮询数据库,而不会轮询客户端。 这给我们提供了很好的可伸缩性,因为我们可以让少数服务器轮询数据库,从而为成千上万的租户提供服务。 将此与常规系统进行比较,在常规系统中,每个客户端都会从服务器请求资源,然后服务器又会联系数据库。

BookingView.java

public final class BookingView {

  ...

  public static BookingView create(BookingManager mgr) {
    final AtomicBoolean working = new AtomicBoolean(false);
    final AtomicLong last  = new AtomicLong();
    final AtomicLong total = new AtomicLong();
        
    final String table = mgr.getTableIdentifier().getTableName();
    final String field = Booking.ID.identifier().getColumnName();

    final Timer timer = new Timer();
    final BookingView view = new BookingView(timer);
    final TimerTask task = ...;

    timer.scheduleAtFixedRate(task, 0, UPDATE_EVERY);
    return view;
  }
}

计时器任务是匿名定义的,这就是轮询逻辑所在的位置。

final TimerTask task = new TimerTask() {
  @Override
  public void run() {
    boolean first = true;

    // Make sure no previous task is already inside this block.
    if (working.compareAndSet(false, true)) {
      try {

        // Loop until no events was merged 
        // (the database is up to date).
        while (true) {

          // Get a list of up to 25 events that has not yet 
          // been merged into the materialized object view.
          final List added = unmodifiableList(
            mgr.stream()
              .filter(Booking.ID.greaterThan(last.get()))
              .sorted(Booking.ID.comparator())
              .limit(MAX_BATCH_SIZE)
              .collect(toList())
            );

          if (added.isEmpty()) {
            if (!first) {
              System.out.format(
                "%s: View is up to date. A total of " + 
                "%d rows have been loaded.%n",
                System.identityHashCode(last),
                total.get()
              );
            }

            break;
          } else {
            final Booking lastEntity = 
              added.get(added.size() - 1);

            last.set(lastEntity.getId());
            added.forEach(view::accept);
            total.addAndGet(added.size());

            System.out.format(
              "%s: Downloaded %d row(s) from %s. " + 
              "Latest %s: %d.%n", 
              System.identityHashCode(last),
              added.size(),
              table,
              field,
              Long.parseLong("" + last.get())
            );
          }

          first = false;
        }

        // Release this resource once we exit this block.
      } finally {
        working.set(false);
      }
    }
  }
};

有时,合并任务可能需要花费比计时器间隔更多的时间。 为避免此问题,我们使用AtomicBoolean进行检查并确保只能同时执行一个任务。 这类似于信号量,不同之处在于我们想要删除没有时间的任务而不是排队,因为我们确实不需要执行所有任务,因此只需一秒钟即可完成一个新任务。

构造函数和基本成员方法很容易实现。 我们将传递给类的计时器作为参数存储在构造函数中,以便在需要停止时可以取消该计时器。 我们还会存储一张地图,以将所有预订的当前视图保存在内存中。

private final static int MAX_BATCH_SIZE = 25;
private final static int UPDATE_EVERY   = 1_000; // Milliseconds

private final Timer timer;
private final Map<Long, Booking> bookings;

private BookingView(Timer timer) {
  this.timer    = requireNonNull(timer);
  this.bookings = new ConcurrentHashMap<>();
}

public Stream<Booking> stream() {
  return bookings.values().stream();
}

public void stop() {
  timer.cancel();
}

BookingView类的最后一个缺少的部分是合并过程中上面使用的accept()方法。 在这里考虑新事件并将其合并到视图中。

private boolean accept(Booking ev) {
    final String type = ev.getEventType();

    // If this was a creation event
    switch (type) {
        case "CREATE" :
            // Creation events must contain all information.
            if (!ev.getSauna().isPresent()
            ||  !ev.getTenant().isPresent()
            ||  !ev.getBookedFrom().isPresent()
            ||  !ev.getBookedTo().isPresent()
            ||  !checkIfAllowed(ev)) {
                return false;
            }

            // If something is already mapped to that key, refuse the 
            // event.
            return bookings.putIfAbsent(ev.getBookingId(), ev) == null;

        case "UPDATE" :
            // Create a copy of the current state
            final Booking existing = bookings.get(ev.getBookingId());

            // If the specified key did not exist, refuse the event.
            if (existing != null) {
                final Booking proposed = new BookingImpl();
                proposed.setId(existing.getId());

                // Update non-null values
                proposed.setSauna(ev.getSauna().orElse(
                    unwrap(existing.getSauna())
                ));
                proposed.setTenant(ev.getTenant().orElse(
                    unwrap(existing.getTenant())
                ));
                proposed.setBookedFrom(ev.getBookedFrom().orElse(
                    unwrap(existing.getBookedFrom())
                ));
                proposed.setBookedTo(ev.getBookedTo().orElse(
                    unwrap(existing.getBookedTo())
                ));

                // Make sure these changes are allowed.
                if (checkIfAllowed(proposed)) {
                    bookings.put(ev.getBookingId(), proposed);
                    return true;
                }
            }

            return false;


        case "DELETE" :
            // Remove the event if it exists, else refuse the event.
            return bookings.remove(ev.getBookingId()) != null;

        default :
            System.out.format(
                "Unexpected type '%s' was refused.%n", type);
            return false;
    }
}

在事件源系统中,规则在收到事件时不执行,但在实现时才执行。 基本上,任何人都可以在表的末尾插入新事件到系统中。 在这种方法中,我们选择丢弃不遵循规则设置的事件。

步骤4:示例用法

在此示例中,我们将使用标准的Speedment API将三个新的预订插入到数据库中,其中两个有效,第三个与先前的一个相交。 然后,我们将等待视图更新并打印出所有预订。

public static void main(String... params) {
  final SaunaApplication app = new SaunaApplicationBuilder()
    .withPassword("password")
    .build();

  final BookingManager bookings = 
    app.getOrThrow(BookingManager.class);

  final SecureRandom rand = new SecureRandom();
  rand.setSeed(System.currentTimeMillis());

  // Insert three new bookings into the system.
  bookings.persist(
    new BookingImpl()
      .setBookingId(rand.nextLong())
      .setEventType("CREATE")
      .setSauna(1)
      .setTenant(1)
      .setBookedFrom(Date.valueOf(LocalDate.now().plus(3, DAYS)))
      .setBookedTo(Date.valueOf(LocalDate.now().plus(5, DAYS)))
  );

  bookings.persist(
    new BookingImpl()
      .setBookingId(rand.nextLong())
      .setEventType("CREATE")
      .setSauna(1)
      .setTenant(2)
      .setBookedFrom(Date.valueOf(LocalDate.now().plus(1, DAYS)))
      .setBookedTo(Date.valueOf(LocalDate.now().plus(2, DAYS)))
  );

  bookings.persist(
    new BookingImpl()
      .setBookingId(rand.nextLong())
      .setEventType("CREATE")
      .setSauna(1)
      .setTenant(3)
      .setBookedFrom(Date.valueOf(LocalDate.now().plus(2, DAYS)))
      .setBookedTo(Date.valueOf(LocalDate.now().plus(7, DAYS)))
  );

  final BookingView view = BookingView.create(bookings);

  // Wait until the view is up-to-date.
  try { Thread.sleep(5_000); }
  catch (final InterruptedException ex) {
    throw new RuntimeException(ex);
  }

  System.out.println("Current Bookings for Sauna 1:");
  final SimpleDateFormat dt = new SimpleDateFormat("yyyy-MM-dd");
  final Date now = Date.valueOf(LocalDate.now());
  view.stream()
    .filter(Booking.SAUNA.equal(1))
    .filter(Booking.BOOKED_TO.greaterOrEqual(now))
    .sorted(Booking.BOOKED_FROM.comparator())
    .map(b -> String.format(
      "Booked from %s to %s by Tenant %d.", 
      dt.format(b.getBookedFrom().get()),
      dt.format(b.getBookedTo().get()),
      b.getTenant().getAsInt()
    ))
    .forEachOrdered(System.out::println);

  System.out.println("No more bookings!");
  view.stop();
}

如果运行它,将得到以下输出:

677772350: Downloaded 3 row(s) from booking. Latest id: 3.
677772350: View is up to date. A total of 3 rows have been loaded.
Current Bookings for Sauna 1:
Booked from 2016-10-11 to 2016-10-12 by Tenant 2.
Booked from 2016-10-13 to 2016-10-15 by Tenant 1.
No more bookings!

我的GitHub页面上提供了此演示应用程序的完整源代码。 在这里您还可以找到许多其他示例,这些示例说明了如何在各种情况下使用Speedment快速开发数据库应用程序。

摘要

在本文中,我们在数据库表上开发了一个物化视图,该视图可评估物化而不是插入时的事件。 这样就可以启动应用程序的多个实例,而不必担心对其进行同步,因为它们最终将保持一致。 然后,我们通过展示如何使用Speedment API查询实例化视图来生成当前预订列表来结束。

感谢您的阅读,请在Github页面上查看更多Speedment示例

翻译自: https://www.javacodegeeks.com/2016/10/event-sourcing-cqrs-practise.html

第1章我们的领域: 会议管理系统1 1.1Contoso公司简介1 1.2谁与我们同行2 1.3Contoso会议管理系统3 1.3.1系统概览3 1.3.2非功能性需求4 1.4开始我们的旅程5 1.5更多信息5 第2章领域分解——站点规划6 2.1本章术语定义6 2.2会议管理系统里面的有界上下文7 2.2.1订单和注册有界上下文7 2.2.2会议管理有界上下文7 2.2.3支付有界上下文8 2.2.4不包括在内的有界上下文8 2.2.5Contoso会议管理系统的上下文路线图9 2.3为什么选择这些有界上下文10 2.4更多信息11 第3章订单和注册有界上下文12 3.1订单和注册有界上下文简介12 3.2本章术语定义13 3.3领域定义(普适语言)14 3.4订单创建的需求分析16 3.5系统架构17探索CQRS事件目录3.6模式和概念17 3.6.1系统验证21 3.6.2交易边界22 3.6.3并发处理22 3.6.4Aggregates和Aggregate Roots22 3.7实现细节23 3.7.1高层架构23 3.7.2写者模型28 3.7.3使用Windows Azure服务总线37 3.8对测试的影响44 3.9本章小结47 3.10更多信息47 第4章扩展和改进订单和注册有界上下文48 4.1修改有界上下文48 4.1.1本章术语定义49 4.1.2用户需求49 4.1.3系统架构49 4.2模式和概念51 4.2.1记录定位器51 4.2.2读者端查询51 4.2.3向读者端提供部分履行的订单信息54 4.2.4CQRS命令验证55 4.2.5倒计时定时器和读者模型56 4.3实现细节56 4.3.1订单访问码(记录定位器)57 4.3.2倒计时定时器58 4.3.3使用ASP.NET MVC验证60 4.3.4将改动推送到读者端62 4.3.5重构SeatsAvailability aggregate66 4.4对测试的影响68 4.4.1接受测试和领域专家68 4.4.2使用SpecFlow功能来定义接受测试68 4.4.3通过测试来帮助开发人员理解消息流75 4.5代码理解的旅程: 痛苦、释放和学习的故事77 4.5.1测试很重要77 4.5.2领域测试78 4.5.3硬币的另外一面80 4.6本章小结83 4.7更多信息84 第5章准备V1发布85 5.1Contoso会议管理系统的V1发布版85 5.1.1本章术语定义85 5.1.2用户需求86 5.1.3系统架构87 5.2模式和概念91 5.2.1事件91 5.2.2基于任务的用户界面92 5.2.3有界上下文之间的集成95 5.2.4分布式交易和事件98 5.2.5自治与集权99 5.2.6读者端的实现方法100 5.2.7最终一致性100 5.3实现细节101 5.3.1会议管理有界上下文101 5.3.2支付有界上下文102 5.3.3事件105 5.3.4基于Windows Azure表格的事件库111 5.3.5订单总价计算114 5.4对测试的影响114 5.4.1时序问题114 5.4.2引入领域专家115 5.5本章小结115 5.6更多信息115 第6章系统版本控制116 6.1本章术语定义116 6.1.1用户需求116 6.1.2系统架构117 6.2模式和概念118 6.2.1修改事件定义118 6.2.2确保消息的自洽性119 6.2.3集成事件的保存121 6.2.4消息排序122 6.3实现细节123 6.3.1对零成本订单的支持123 6.3.2显示剩余座位数127 6.3.3删除重复命令130 6.3.4确保消息排序131 6.3.5保存会议管理有界上下文的事件135 6.3.6从V1版本迁移到V2版本139 6.4对测试的影响140 6.4.1重访SpecFlow140 6.4.2在迁移过程发现错误143 6.5本章小结143 6.6更多信息144 第7章加入弹性和优化性能145 7.1本章术语定义145 7.2系统架构145 7.3加入弹性147 7.3.1增加事件重复处理时的弹性148 7.3.2确保命令的发送148 7.4优化性能148 7.4.1优化前的用户界面流程149 7.4.2用户界面优化150 7.4.3基础设施优化151 7.5无停机迁移158 7.6实现细节159 7.6.1改进RegistrationProcessManager类160 7.6.2用户界面流程优化165 7.6.3消息的异步接收、处理和发送170 7.6.4在流程内部对命令进行同步处理171 7.6.5使用备忘录模式来实现快照173 7.6.6对事件进行并行发布175 7.6.7在订购服务里面对消息进行过滤176 7.6.8为SeatsAvailability aggregate创建专门的SessionSubscriptionReceiver 实例177 7.6.9缓存读者模型数据179 7.6.10使用多个议题来划分服务总线180 7.6.11其他的优化和强化措施181 7.7对测试的影响184 7.7.1集成测试185 7.7.2用户界面测试185 7.8本章小结185 7.9更多信息185 第8章尾声: 经验教训186 8.1我们学到了什么186 8.1.1性能很重要186 8.1.2实现消息驱动并不简单187 8.1.3云平台的挑战187 8.1.4不同的CQRS188 8.1.5事件和交易日志记录190 8.1.6引入领域专家190 8.1.7什么时候该使用CQRS190 8.2如果重新来过,我们会做的有什么不同191 8.2.1以牢靠的消息和保存基础设施为起点191 8.2.2更好地利用基础设施的能力191 8.2.3采纳更加系统化的方法来实现流程管理器192 8.2.4对应用程序实施不同的划分192 8.2.5以不同方式组织项目团队192 8.2.6对领域和有界上下文的CQRS适用性进行评估192 8.2.7为性能进行规划192 8.2.8重新考虑用户界面193 8.2.9探索事件的其他用处193 8.2.10探索有界上下文的集成问题193 8.3更多信息194
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值