函数式事件驱动架构——交易系统(替代服务)

61 阅读18分钟

既然我们已经了解了核心服务,接下来来看图 7.1 所示的构成整个系统的其他几个服务。

image.png

Apache Pulsar 是我们分布式系统的核心。部分服务还需要访问数据库、缓存或外部服务,这些内容将在接下来的两章中介绍。

此外,我们还有一个 feed 服务,用于生成随机的交易和预测命令,以便观察整个系统的运行效果(用于手动测试)。

本章将重点介绍快照(snapshots)、预测(forecasts)和 feed 服务。最后,我们还会进行一些集成测试的定义。

剩余的追踪服务将在第8章中进行探讨。

7.1 快照(Snapshots)

如系统图所示,该服务会消费 TradeEvents 和 SwitchEvents,并将快照持久化到 Redis。那么这到底意味着什么呢?

我们在第3章曾简要提及过这个话题(见状态快照 State snapshots)。状态快照表示某一时间点的内部状态,在我们的案例中即 TradeState。

快照有助于加快服务重启速度,避免每次都从头重放事件以重建当前状态。

该服务的核心工作是:每隔一段可配置时间,从 CommandExecuted 事件计算出 TradeState 形式的快照,并连同对应的消息 ID 一起持久化,方便其他服务启动时读取最新版本。

此外,每当 SwitchEvent 发生且当前状态发生变化时,会立即写入 Redis,因为这类事件相对较少。

我们已经熟悉这两种数据类型,下面直接进入正题。

7.1.1 可扩展性(Scalability)

在深入了解本服务的订阅细节和资源使用前,先讨论它背后的设计决策。

该服务的职责是写入与最后处理的 TradeEvent 相关联的 TradeState 快照,并关联对应的 MessageId。当有多个实例运行时,这个过程需要协调。

最简单的处理方式是确保同一时间只有一个实例进行写操作,这就是所谓的“单写原则”(single writer principle)。

在数据复制(即将同一数据保存到多台机器)时,这个原则也被称为基于领导者的复制(leader-based replication,也称为主备复制或主从复制)。

对于这类应用,Failover 订阅看起来非常合适,它允许只有一个活动消费者,同时至少有一个备份消费者在出现故障时能够接管。

图 7.2 展示了这种场景:S2 实例被选举为 leader,而 S1 和 S3 则作为备份准备随时接管。

image.png

然而,进一步分析这种设计会暴露出架构上的一些缺陷。

7.1.1.1 为什么不采用 Failover 模式?

为了加快启动速度,我们初始会读取最新快照,并将订阅回溯(rewind)到特定的消息 ID。

但当应用第一次运行时,没有快照可用,我们唯一的选择是将订阅回溯到 topic 开头,这会导致启动变慢。理想情况下,我们总希望能从最新快照开始启动。

假设图 7.2 中的所有实例同时启动,而 S2 已经处理了 30 小时的事件后崩溃。那么 S1 和 S3 接管时,它们的内部状态都会过时。为了解决这个问题,它们需要重新获取最新快照并据此回溯订阅。

这听起来简单,实际上并非如此。首先,Pulsar 没有机制告知其他实例它们何时成为活跃消费者。如果有这种机制,我们就可以被通知,并在开始处理事件之前准备好应用状态。

但现实是,Pulsar 会在 fail-over 切换期间一旦分配新活跃消费者,就立即开始发送消息,不会给我们机会先获取快照和回溯订阅。

这种限制或许可以在应用层解决:当检测到第一条消息时,我们可以丢弃它,先获取快照再回溯订阅(比如用第6章提到的 onFirstMessage 扩展方法)。不过,这并不自然,需要写一些复杂代码来同步实例。

正是基于这个原因,我们将使用独占订阅(exclusive subscription),并借助 Redis 实现的分布式锁来同步(详见第2章分布式锁)。

最终效果看起来就像是使用 Failover 订阅,但切换机制由我们通过分布式锁控制,而非由 Pulsar 管理。

image.png

图 7.3 展示了我们选择的应用架构。

另一种可能的解决方案是,将快照更新发布到一个 Pulsar 的压缩(compacted)主题,而不是写入 Redis,确保消息 key 始终相同。当触发压缩时,只会保留最新的快照。

这需要读取快照的服务同步消费额外的消息,就像 alerts 服务处理 PriceUpdates 一样(见 Alerts Rebalance)。

不过本服务将采用之前讨论过的分布式锁设计,来展示我们如何用不同方式解决这个问题。

7.1.2 入口(Entry point)

我们的入口是为每个实例使用一个独占(Exclusive)订阅(通过 AppId 标识):

def mkSub(appId: AppId) =
  Subscription.Builder
    .withName(appId.show)
    .withType(Subscription.Type.Exclusive)
    .withMode(Subscription.Mode.NonDurable)
    .build

我们把订阅设置为非持久化(non-durable),因为不需要持久化订阅游标。如果实例宕机,下次重连时会使用不同的应用 ID。

这个订阅同时适用于 TradeEvents 和 SwitchEvents,不过 SwitchEvents 来自一个压缩主题,需要额外的配置:

val compact =
  PulsarConsumer.Settings[IO, SwitchEvent]().withReadCompacted.some

接下来是一系列资源初始化:

def resources =
  for
    config <- Resource.eval(Config.load[IO])
    pulsar <- Pulsar.make[IO](config.pulsar.url)
    _      <- Resource.eval(Logger[IO].info("Initializing: ${config.appId.show}"))
    teTopic = AppTopic.TradingEvents.make(config.pulsar)
    swTopic = AppTopic.SwitchEvents.make(config.pulsar)
    redis <- RedisClient[IO].from(config.redisUri.value)
    distLock = DistLock.make[IO]("snap-lock", config.appId, redis)
    reader <- SnapshotReader.fromClient[IO](redis)
    writer <- SnapshotWriter.fromClient[IO](redis, config.keyExpiration)
    sub = mkSub(config.appId)
    trConsumer <- Consumer.pulsar[IO, TradeEvent](pulsar, teTopic, sub)
    swConsumer <- Consumer.pulsar[IO, SwitchEvent](pulsar, swTopic, sub, compact)
    fsm    = Engine.fsm(trConsumer, swConsumer, writer)
    server = Ember.default[IO](config.httpPort)
  yield (server, distLock, trConsumer, swConsumer, reader, fsm)

除了之前看到的资源外,我们还有一个带有刷新方法(refresh)的 DistLock 接口,以及它的实现(详见第2章分布式锁):

trait DistLock[F[_]]:
  def refresh: F[Unit]

主构造函数参数如下:

object DistLock:
  def make[F[_]: Logger: MkRedis: Temporal](
      lockName: String,
      appId: AppId,
      client: RedisClient
  ): Resource[F, DistLock[F]] = ???

每个实例都会竞争获取分布式锁,锁的唯一标识由 AppId 决定。获得锁的实例将被选举为 leader,负责执行写操作。

此外,还有相关的快照写入接口:

trait SnapshotWriter[F[_]]:
  def save(state: TradeState, id: Consumer.MsgId): F[Unit]
  def saveStatus(st: TradingStatus): F[Unit]

MsgId 用于关联快照的 TradeState,方便后续将主题订阅回溯到该点,继续进行状态聚合。

快照持久化到 Redis,存储的键应当设置过期时间,因为无限期存储缓存数据是不好的实践。

object SnapshotWriter:
  def from[F[_]: MonadThrow](
      redis: RedisCommands[F, String, String],
      exp: KeyExpiration
  ): SnapshotWriter[F] = ???

其具体实现此处不做展开,详情请参阅配套源码。

7.1.3 有限状态机(FSM)

对应的状态机处理一些有趣的边界情况,定义了如下构造函数:

type Tick = Unit

object Engine:
  type In = Either[Msg[TradeEvent], Msg[SwitchEvent]] | Tick

  def fsm[F[_]: MonadThrow: Logger](
      tradeAcker: Acker[F, TradeEvent],
      switchAcker: Acker[F, SwitchEvent],
      writer: SnapshotWriter[F]
  ): FSM[F, (TradeState, List[MsgId]), In, Unit] = FSM { ... }

注意本服务不需要去重(de-duplication),因为保证同一时间只有一个实例消费这些事件。

内部状态是 (TradeState, List[MsgId]),输入是消息的组合。我们已经熟悉 TradeState,这里重点讲讲 List[MsgId] 以及状态转换。

每处理一个 CommandExecuted 事件时,会执行:

case ((st, ids), Left(Msg(msgId, _, evt @ CommandExecuted(_, _, _, _)))) =>
  ().pure[F].tupleLeft(TradeEngine.eventsFsm.runS(st, evt) -> (ids :+ msgId))

通过交易 FSM 得到新的 TradeState,并把当前消息 ID 添加到内部状态中,但此时还未确认(ack)消息。

当收到 CommandRejected 事件时,直接确认消息:

case (st, Left(Msg(msgId, _, CommandRejected(evId, _, _, _, _)))) =>
  Logger[F].debug(s"Event ID: $evId, no persistence") *>
    tradeAcker.ack(msgId).tupleLeft(st)

处理 SwitchEvent 时,先用 FSM 得到新状态,再根据状态是否改变写入交易状态,最后确认消息:

case ((st, ids), Right(Msg(msgId, _, evt))) =>
  val nst = TradeEngine.eventsFsm.runS(st, evt)
  writer.saveStatus(nst.status).whenA(nst.status =!= st.status) *>
    switchAcker.ack(msgId).tupleLeft(nst, ids)

Tick 消息可能是最有趣的:

case ((st, ids), (_: Tick)) if ids.nonEmpty =>
  val lastId = ids.last
  writer.save(st, lastId).attempt.flatMap {
    case Left(e) =>
      Logger[F].warn(
        s"Failed to persist state: $lastId"
      ).tupleLeft(st -> ids)
    case Right(_) =>
      Logger[F].debug(
        s"State persisted: $lastId. Acking ${ids.size} messages."
      ) *>
        tradeAcker.ack(ids.toSet).attempt.map {
          case Left(_)  => (st -> ids)        -> ()
          case Right(_) => (st -> List.empty) -> ()
        }
  }
case (st, (_: Tick)) =>
  ().pure[F].tupleLeft(st)

每次收到 Tick,尝试把当前状态持久化到 Redis。如果失败,记录日志,继续处理消息但不清空状态;成功则批量确认累积的消息 ID,并清空 List[MsgId],继续处理后续消息。

Tick 消息由 Main 自己产生(见 Run 部分)。

7.1.3.1 单元测试(Unit tests)

所有这些情况都有单元测试保证实现的健壮性。定义了一个基础测试方法 baseTest,其签名如下:

def baseTest(
    gen: Gen[Either[TradeEvent, SwitchEvent]],
    mkWriter: Ref[IO, Option[TradeState]] => SnapshotWriter[IO],
    expWrites: TradeState => Option[TradeState],
    expAcks: List[MsgId]
): IO[Expectations] = ???

主要测试场景是生成 TradeEvent.CommandExecuted,确保快照写入总是成功(包括 Tick)。

exp 前缀的三个参数分别表示期望的快照写入状态和消息确认情况。

test("fsm w/ command executed events should ack AND write new state") {
  baseTest(
    gen = genCommandExecEvt.map(_.asLeft),
    mkWriter = succesfulWriter,
    expWrites = _.some,
    expAcks = List(msgId)
  )
}

同理,可以测试状态机如何处理非 CommandExecuted 事件(如 CommandRejectedSwitchEvents):

test("fsm w/ other events should ack without writing new state") {
  baseTest(
    gen = genTradeEventNoCmdExec,
    mkWriter = succesfulWriter,
    expWrites = _ => None,
    expAcks = List(msgId)
  )
}

最后,测试当写快照失败时状态机行为:

test("snapshot fsm w/ failing snapshot writer should NOT ack") {
  baseTest(
    gen = genCommandExecEvt.map(_.asLeft),
    mkWriter = _ => failingWriter,
    expWrites = _ => None,
    expAcks = List.empty
  )
}

这些测试给我们极大的信心,可以放心在此基础上构建稳健的软件。

7.1.4 运行(Run)

最后,快照应用的运行方式如下:

def run: IO[Unit] =
  Stream
    .resource(resources)
    .flatMap { (server, distLockRes, trConsumer, swConsumer, reader, fsm) =>
      Stream.eval(server.useForever).concurrently {
        Stream.resource(distLockRes).flatMap { distLock =>
          val ticks: Stream[IO, Engine.In] =
            Stream.fixedDelay[IO](2.seconds).evalMap(_ => distLock.refresh)

          Stream.eval(IO.deferred[Unit]).flatMap { gate =>
            def process(st: TradeState, trading: Stream[IO, Msg[TradeEvent]]) =
              trading
                .either(Stream.exec(gate.get) ++ swConsumer.receiveM)
                .merge(ticks)
                .evalMapAccumulate(st -> List.empty)(fsm.run)

            Stream.eval(reader.latest).flatMap {
              case Some(st, id) =>
                process(st, trConsumer.rewind(id, gate))
              case None =>
                process(TradeState.empty, trConsumer.rewind(MsgId.earliest, gate))
            }
          }
        }
      }
    }
    .compile
    .drain

它一边运行 HTTP 服务器,一边并发尝试获取分布式锁。

获取成功后,读取最新快照并执行同步回溯(类似 alerts 服务)。随后继续消费 TradeEvents 和 SwitchEvents,并将它们送入 FSM 处理。

此外,每隔两秒发送一个 Tick,用于触发快照持久化,并用同一个 Tick 刷新锁的 TTL(存活时间)。

定期持久化快照并批量确认消息,提高了吞吐量。当然,两秒的间隔值可以根据后续监控和分析进行调整。

总结来说,拿到快照时就会回溯订阅到对应偏移量;若无快照,则从头开始,像事件溯源应用那样重建状态。

7.2 预测(Forecasts)

在第6章开头,我们简要介绍了预测的含义。

它允许作者自助注册,从而发布市场预测文章,文章可以被匿名点赞或点踩。

这些操作可以建模为三种不同的消息类型——ForecastCommand,如下节所示。

7.2.1 命令(Commands)

ForecastCommand 定义了一些与 TradeCommand 类似的重要字段:

enum ForecastCommand derives Codec.AsObject, Eq, Show:
  def id: CommandId
  def cid: CorrelationId
  def createdAt: Timestamp

接下来是上面提到的三种命令,作为枚举成员:

case Register(
    authorName: AuthorName,
    authorWebsite: Option[Website]
)

case Publish(
    authorId: AuthorId,
    symbol: Symbol,
    description: ForecastDescription,
    tag: ForecastTag
)

case Vote(
    forecastId: ForecastId,
    result: VoteResult
)

此处为简洁起见,省略了部分必填字段。

这里出现的大多数新类型是基于 StringUUID 的新类型(newtypes),除了 ForecastTag,它是另一个枚举:

enum ForecastTag derives Codec.AsObject, Eq, Show:
  case Long, Short, Unknown

VoteResult 也类似:

enum VoteResult derives Eq, Show:
  case Up, Down

这些基本上不言自明,简单说明如下:

  • Register:注册一个新作者,包含唯一的姓名和可选的网站。
  • Publish:为某个特定 symbol 发布新文章(仅限注册作者)。
  • Vote:对指定预测文章(通过 forecastId 标识)进行投票。

7.2.2 事件(Events)

不同于 TradeCommands 处理后只能产生 TradeEvents,ForecastCommand 的处理可以产出两类不同事件。

第一类是 ForecastEvent,其中 forecastId 字段是必填:

enum ForecastEvent derives Codec.AsObject:
  def id: EventId
  def cid: CorrelationId
  def forecastId: ForecastId
  def createdAt: Timestamp

接下来是两个可能的结果(省略部分字段):

case Published(
    authorId: AuthorId,
    symbol: Symbol
)

case Voted(
    result: VoteResult
)

第二类是 AuthorEvent,定义如下:

enum AuthorEvent derives Codec.AsObject:
  def id: EventId
  def cid: CorrelationId
  def createdAt: Timestamp

目前它仅包含以下具体事件:

case Registered(
    authorId: AuthorId,
    authorName: AuthorName,
    authorWebsite: Option[Website]
)

同样省略了部分必填字段以简洁表达。

7.2.3 命令与事件关系(Command-event relationship)

下面来看命令与事件的关系,以更好理解不同结果如何对应输入:

COMMANDAUTHOR EVENTFORECAST EVENT
RegisterRegisteredN/A
PublishN/APublished
VoteN/AVoted

将这两类事件拆分的初衷,是考虑到它们可能需要在不同服务中处理。

但实际上,目前没有事件消费者使用这些事件,因为暂时没有相应功能需求。

正如表格所示,这里没有状态,因为预测引擎不需要状态机。接下来我们将详细了解它。

7.2.4 引擎(Engine)

该服务的主要业务逻辑不是定义为状态机。

trait Engine[F[_]]:
  def run: Msg[ForecastCommand] => F[Unit]

我们直接处理类型为 ForecastCommand 的消息。

object Engine:
  def make[F[_]: GenUUID: Logger: MonadCancelThrow: Time](
      producer: Producer[F, ForecastEvent],
      atStore: AuthorStore[F],
      fcStore: ForecastStore[F],
      acker: Acker[F, ForecastCommand]
  ): Engine[F] = new:
    def run: Msg[ForecastCommand] => F[Unit] =
      case Msg(msgId, _, cmd) => ???

它的主构造函数需要一个 ForecastEvent 生产者、一个 ForecastCommand 确认器(Acker),以及作者和预测的存储接口。

我们已熟悉 ProducerAcker,下面介绍剩余的类型。

7.2.4.1 存储(Store)

先看 AuthorStore,里面有些新数据类型。

trait AuthorStore[F[_]]:
  def fetch(id: AuthorId): F[Option[Author]]
  def tx: Resource[F, TxAuthorStore[F]]

trait TxAuthorStore[F[_]]:
  def save(author: Author): F[Unit]
  def outbox(evt: AuthorEvent): F[Unit]

Author 数据类型可能包含一组 ForecastId 及其他信息:

final case class Author(
    id: AuthorId,
    name: AuthorName,
    website: Option[Website],
    forecasts: Set[ForecastId]
) derives Codec.AsObject, Show

如稍后会学到的,saveoutbox 操作需要在外部组成的事务中执行,所以定义在不同接口里。

Engine 层我们只通过接口操作存储,因此目前无需关心具体实现是写入 SQL 还是 NoSQL 数据库,不过下一节会详细探讨。

接着是 ForecastStore,写操作同样是事务性的:

trait ForecastStore[F[_]]:
  def fetch(fid: ForecastId): F[Option[Forecast]]
  def tx: Resource[F, TxForecastStore[F]]

trait TxForecastStore[F[_]]:
  def save(aid: AuthorId, fc: Forecast): F[Unit]
  def castVote(fid: ForecastId, res: VoteResult): F[Unit]
  def registerVote(evt: ForecastEvent.Voted): F[Unit]
  def outbox(evt: ForecastEvent): F[Unit]

Forecast 总是关联一个具体的 Symbol,并包含根据投票数计算的分数:

final case class Forecast(
    id: ForecastId,
    symbol: Symbol,
    tag: ForecastTag,
    description: ForecastDescription,
    score: ForecastScore
) derives Codec.AsObject, Show

此外,我们定义了一些自定义错误类型,用于适配存储解释器的错误。为避免栈追踪,这些错误继承自 NoStackTrace,例如:

case object DuplicateAuthorError extends NoStackTrace
type DuplicateAuthorError = DuplicateAuthorError.type

以上就是所有新增数据类型,下面进入命令处理部分。

7.2.4.2 命令处理器(Command handler)

引擎入口如下:

def run: Msg[ForecastCommand] => F[Unit] =
  case Msg(msgId, _, cmd) =>
    val eid = EventId(cmd.id.value)
    Time[F].timestamp.flatMap { ts =>
      cmd match ???
    }

我们通过模式匹配提取消息 ID 和负载,并生成事件的时间戳。注意这里重用了 CommandId 创建 EventId,避免重复事件处理。这只有在命令与事件一一对应时才可行。

接下来对命令做模式匹配,先处理 Register

case ForecastCommand.Register(_, cid, name, site, _) =>
  GenUUID[F].make[AuthorId].flatMap { aid =>
    atStore.tx
      .use { db =>
        db.save(Author(aid, name, site, Set.empty)) *>
          db.outbox(AuthorEvent.Registered(eid, cid, aid, name, site, ts))
      }
      .productR(acker.ack(msgId))
      .recoverWith { case DuplicateAuthorError =>
        Logger[F].error(s"Author name $name already registered!") *> acker.ack(msgId)
      }
      .handleNack
  }

我们先创建唯一的 AuthorId,然后在同一事务中保存作者和作者事件(AuthorEvent),这符合第2章介绍的出站箱模式(Outbox pattern)。

持久化作者时可能抛出三类错误(解释器中定义),但这里只关心 DuplicateAuthorError(用户名已被占用),因注册时总是带空预测集。出错时通过 recoverWith 记录日志并确认消息。

最后是 handleNack 扩展方法:

extension (fa: F[Unit])
  def handleNack: F[Unit] =
    fa.handleErrorWith {
      case e: DuplicateEventId =>
        Logger[F].error(s"Ignoring $e") *> acker.ack(msgId)
      case e =>
        Logger[F].error(s"Unexpected: ${e.getMessage}") *> acker.nack(msgId)
    }

当遇到 DuplicateEventId(事件已在出站箱处理过)时,记录错误并确认消息;其他错误则记录并负确认。

handleNack 逻辑同样用于 Publish 命令。

Publish 命令处理类似:

case ForecastCommand.Publish(_, cid, aid, symbol, desc, tag, _) =>
  GenUUID[F].make[ForecastId].flatMap { fid =>
    fcStore.tx
      .use { db =>
        db.save(aid, Forecast(fid, symbol, tag, desc, ForecastScore(0))) *>
          db.outbox(ForecastEvent.Published(eid, cid, aid, fid, symbol, ts))
      }
      .productR(acker.ack(msgId))
      .recoverWith { case AuthorNotFound =>
        Logger[F].error(s"Author not found: $aid") *> acker.ack(msgId)
      }
      .handleNack
  }

Register 处理类似,区别是持久化的是 ForecastEvent,且 save 可能抛出 AuthorNotFound(命令中的作者尚未注册)。

错误处理方式同样使用 recoverWithhandleNack

我们复用 CommandId 作为 EventId,在数据库事务成功但确认失败时避免命令重发造成重复事件。这个问题只影响 PublishRegister 会优先触发 DuplicateAuthorError

7.2.4.3 出站箱处理器(Outbox handler)

RegisterPublish 命令会将事件持久化到出站箱(outbox)表,之后由下面的处理器消费:

trait OutboxHandler[F[_]]:
  def run: Msg[OutboxEvent] => F[Unit]

OutboxEvent 可能包含 AuthorEventForecastEvent

final case class OutboxEvent(
    event_id: EventId,
    correlation_id: CorrelationId,
    event: Either[AuthorEvent, ForecastEvent],
    created_at: Timestamp
) derives Codec.AsObject

主解释器如下:

object OutboxHandler:
  def make[F[_]: Applicative: Logger](
      authors: Producer[F, AuthorEvent],
      forecasts: Producer[F, ForecastEvent],
      acker: Acker[F, OutboxEvent]
  ): OutboxHandler[F] = new:
    def run: Msg[OutboxEvent] => F[Unit] =
      case Msg(msgId, _, OutboxEvent(_, _, ev, _)) =>
        ev.bitraverse(authors.send, forecasts.send) *> acker.ack(msgId)

每条入站事件都被提取并发布,然后确认消息,完成了出站箱模式的闭环。

这大致就是使用 Debezium 和 PostgreSQL-Pulsar 连接器的实现思路。但由于我们使用 H2 数据库,没有此类连接器。

因此,在参考实现中,你会看到更多代码用来弥补这部分功能。这里不细讲,简单说是通过数据库触发器响应数据变化,将事件推送到内存队列,再由另一个进程反复读取队列,发布 OutboxEvents 到 Pulsar 主题。

完整的 PostgreSQL + Pulsar 示例可见 PulsarCDC 演示应用,详情请参阅仓库中的 README。

7.2.4.4 投票处理器(Votes handler)

最后一个命令是 Vote。从引擎角度看,它的处理相当简单。

case ForecastCommand.Vote(_, cid, fid, res, _) =>
  producer.send(ForecastEvent.Voted(eid, cid, fid, res, ts)) *>
    acker.ack(msgId)

等等!这里到底发生了什么?

我们使用了第2章介绍的“监听自己”模式(见 Change Data Capture)。也就是说,应用中有一个与其它部分并发运行的 ForecastEvent 消费者,事件由下面的处理器负责处理。

trait VotesHandler[F[_]]:
  def run: Msg[ForecastEvent] => F[Unit]

object VotesHandler:
  def make[F[_]: Logger: MonadCancelThrow](
      store: ForecastStore[F],
      acker: Acker[F, ForecastEvent]
  ): VotesHandler[F] = new:
    def run: Msg[ForecastEvent] => F[Unit] = ???

Published 事件的处理很简单,因为我们不关心它(该事件已在引擎中用出站箱模式处理):

case Msg(msgId, _, ForecastEvent.Published(_, _, _, _, _, _)) =>
  acker.ack(msgId)

接下来是核心的 Voted 事件处理:

case Msg(msgId, _, evt @ ForecastEvent.Voted(_, _, fid, res, _)) =>
  store.tx
    .use { db =>
      db.registerVote(evt) *> db.castVote(fid, res)
    }
    .productR(acker.ack(msgId))
    .handleErrorWith {
      case DuplicateEventId(eid) =>
        Logger[F].error(s"Duplicate event ID: $eid") *> acker.ack(msgId)
      case e =>
        Logger[F].error(s"Vote registration error: $fid - ${e.getMessage}") *>
          acker.nack(msgId)
    }

我们开启数据库事务,投票操作更新预测表的分数,并注册事件(更新投票表)。后者可防止重复投票,因为若事件 ID 已存在,操作会失败。

若操作成功或遇到 DuplicateEventId 错误,则确认消息;否则记录错误并发送负确认。

7.2.4.5 单元测试(Unit tests)

以上逻辑都包含在 EngineSuite 单元测试中,部分测试示例如下:

test("Successful author registration") {
  val out = AuthorEvent.Registered(
    eventId, cid, authorId, authorName, None, ts
  )
  baseTest(
    in = ForecastCommand.Register(id, cid, authorName, None, ts),
    ex1 = expect.same(_, Some(out)),
    ex2 = expect.same(_, None)
  )
}

test("Fail to register author (duplicate username)") {
  baseTest(
    mkAuthorStore = _ => failAuthorStore,
    in = ForecastCommand.Register(id, cid, authorName, None, ts),
    ex1 = expect.same(_, None),
    ex2 = expect.same(_, None)
  )
}

baseTest 方法定义如下(为简洁起见省略部分细节):

private def baseTest(
    mkAuthorStore: Ref[IO, Option[AuthorEvent]] => AuthorStore[IO],
    mkForecastStore: Ref[IO, Option[ForecastEvent]] => ForecastStore[IO],
    in: ForecastCommand,
    ex1: Option[AuthorEvent] => Expectations,
    ex2: Option[ForecastEvent] => Expectations
): IO[Expectations] = ???

给定存储和输入命令后,会对可能产生的输出事件运行断言(可能无事件)。由于事件不直接发布(采用出站箱模式),我们用这些 Ref 来做相应补充。

7.2.5 SQL 存储

如系统图所示,作者和预测数据均存储在关系型 SQL 数据库中。

为简化起见,我们使用内存型的 H2 数据库,避免在本地环境引入额外的重量级服务。生产环境中应使用 PostgreSQL 等成熟数据库。

作为数据库客户端,我们使用了经过严格测试的 Doobie 库,它支持多种 JDBC(Java 数据库连接)驱动。

如果直接使用 PostgreSQL,另一个不错的选择是 Skunk,它避免了 JDBC 的阻塞特性。

下面不赘述,直接从底层看实现细节。

7.2.5.1 数据库连接

首先,创建数据库连接并通过 Flyway 执行数据库迁移:

object DB:
  private val uri = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"

  def init[F[_]: Async]: Resource[F, H2Transactor[F]] =
    ExecutionContexts
      .fixedThreadPool 
      .flatMap { ce =>
        H2Transactor.newH2Transactor[F](uri, "sa", "", ce)
      }
      .evalTap {
        _.configure { ds =>
          Async[F].delay(
            Flyway.configure().dataSource(ds).load().migrate()
          )
        }
      }

Flyway 会查找 resource/db/migration 目录下的版本化 SQL 文件。我们有一个初始文件 V1__baseline.sql,定义了基础表结构:

CREATE TABLE authors (
  id UUID PRIMARY KEY,
  name VARCHAR UNIQUE NOT NULL,
  website TEXT NULL
);

CREATE TABLE forecasts (
  id UUID PRIMARY KEY,
  symbol TEXT,
  tag TEXT,
  description TEXT,
  score INT DEFAULT 0
);

CREATE TABLE author_forecasts (
  id UUID PRIMARY KEY,
  author_id UUID NOT NULL,
  CONSTRAINT author_key FOREIGN KEY (author_id) REFERENCES authors(id),
  CONSTRAINT forecast_key FOREIGN KEY (id) REFERENCES forecasts(id)
);

包含两个主表和一个一对多的作者-预测关系表。

接下来定义了用于作者和预测事件的出站箱表(outbox):

CREATE TABLE outbox (
  event_id UUID PRIMARY KEY,
  correlation_id UUID NOT NULL,
  event TEXT NOT NULL,
  created_at DATETIME
);

CREATE TRIGGER h2_cdc
AFTER INSERT
ON outbox
FOR EACH ROW
CALL "trading.forecasts.cdc.H2OutboxTrigger";

定义了触发器调用 H2OutboxTrigger 类,弥补了 H2 缺少原生 Pulsar-CDC 连接器的不足。

最后一个迁移文件定义了投票表:

CREATE TABLE votes (
  event_id UUID PRIMARY KEY,
  fid UUID NOT NULL,
  result INT,
  created_at DATETIME,
  CONSTRAINT votes_forecast_key FOREIGN KEY (fid) REFERENCES forecasts(id)
);

它确保不重复处理事件,并提供投票审计能力。

7.2.5.2 扩展方法

trading.forecasts.store 包下,我们还定义了一些实用扩展方法,用于将特定数据库错误映射为业务错误,基于 adaptError 实现:

extension [F[_]: MonadThrow, A](fa: F[A])
  /* 违反唯一约束的重复键错误 */
  def onDuplicate(err: Throwable): F[A] =
    fa.adaptError {
      case e: SQLException if e.getSQLState == "23505" => err
    }

  /* 违反参照完整性约束 */
  def onConstraintViolation(err: Throwable): F[A] =
    fa.adaptError {
      case e: SQLException if e.getSQLState == "23506" => err
    }

extension [F[_]: MonadThrow](fa: F[Int])
  /* 用于更新操作的失败检测 */
  def onUpdateFailure(err: Throwable): F[Unit] =
    fa.flatMap {
      case 1 => ().pure[F]
      case _ => err.raiseError[F, Unit]
    }

我们将在主要解释器中大量使用这些方法,后续会详细展示。

7.2.5.3 组合事务(Composable transactions)

到目前为止,我们了解到数据库写入是通过事务原子地处理的,因为出站箱(outbox)模式非常依赖这一特性。

使用事务从来不是轻松的决定,因为并非所有系统都能承受整体性能下降。但它保证了正确性,并且符合当前业务需求(或缺乏需求)的服务场景。

与 Skunk 的事务不同,Doobie 的事务不是建模为 Resource,这使得从接口外部组合多个操作有些尴尬。因此,我们设计了如下辅助抽象:

trait DoobieTx[F[_]]:
  def transaction(xa: Transactor[F]): Resource[F, ConnectionIO ~> F]

extension [F[_]: DoobieTx](xa: Transactor[F])
  def transaction: Resource[F, ConnectionIO ~> F] =
    DoobieTx[F].transaction(xa)

实现细节这里不作展开(可参考附带源码),但请注意 transaction 方法的返回类型。

我们获得了一个自然变换(natural transformation)ConnectionIO ~> F 的资源,可用于解释器,将 SQL 语句(ConnectionIO)转换成应用效果 F

不建议在 Doobie 或 Skunk 事务中运行其他 I/O 操作,因为这会使我们对无关操作出错负责。

7.2.5.4 SQL 查询和语句

SQL 对象中,定义了两件事。首先是必须的 Doobie 类型类实例:

object SQL:
  given Meta[UUID] = Meta[String].imap[UUID](UUID.fromString)(_.toString)

  given Read[Author] = Read[(UUID, String, Option[String], Option[UUID])].map {
    (id, name, website, fid) =>
      Author(
        AuthorId(id),
        AuthorName(name),
        website.map(Website(_)),
        fid.toSet.map(ForecastId(_))
      )
  }

  given Read[Forecast] = Read[(UUID, String, String, String, Int)].map {
    (id, sl, tag, desc, sc) =>
      Forecast(
        ForecastId(id),
        Symbol.unsafeFrom(sl),
        ForecastTag.from(tag),
        ForecastDescription(desc),
        ForecastScore(sc)
      )
  }

这里只定义了 Read 实例,写操作则直接在 SQL 语句中定义,而非通过 Write 类型类。

举例,作者表相关的 SQL 查询和写入语句:

/* ---------------------- authors table ----------------------- */
val selectAuthor: AuthorId => Query0[Author] = id => sql"""
    SELECT a.id, a.name, a.website, f.id FROM authors AS a
    LEFT JOIN author_forecasts AS f ON a.id=f.author_id
    WHERE a.id=${id.show}
  """.query[Author]

val insertAuthor: Author => Update0 = a => sql"""
    INSERT INTO authors (id, name, website)
    VALUES (${a.id.value}, ${a.name.value}, ${a.website.map(_.value)})
  """.update

insertAuthor 语句中我们手动指定如何持久化 Author 实体,也可以用 Writer 类型类实现。

7.2.5.5 作者存储解释器(Author store interpreter)

现在看 AuthorStore 的实现,先是主构造函数:

object AuthorStore:
  def from[F[_]: DoobieTx: MonadCancelThrow](
      xa: Transactor[F]
  ): AuthorStore[F] = new: ???

需要一个 Transactor[F] 参数,在我们这里是 H2Transactor,但也支持其他数据库引擎。我们还利用了 DoobieTx 能力特质实现自定义事务支持。

接下来是 fetch 的实现:

def fetch(id: AuthorId): F[Option[Author]] =
  SQL.selectAuthor(id).accumulate[List].transact(xa).map {
    case Nil       => None
    case (x :: xs) => x.copy(
      forecasts = x.forecasts.union(xs.toSet.flatMap(_.forecasts))
    ).some
  }

先累积所有可能结果到列表,随后分析结果:非空时取首个,合并其余预测集(通过 union),因为查询用的是 LEFT JOIN

如果暂时忽略 forecasts 集合和事务可扩展性,save 方法可简单实现为:

def save(author: Author): F[Unit] =
  val saveAuthor =
    SQL
      .insertAuthor(author)
      .run
      .onDuplicate(DuplicateAuthorError)
      .transact(xa)

onDuplicate 扩展检测用户名重复错误,后执行事务。

考虑 forecasts 集合,则需额外语句作为同一事务部分,可批量插入:

def save(author: Author): F[Unit] =
  val saveAuthor =
    SQL
      .insertAuthor(author)
      .run
      .onDuplicate(DuplicateAuthorError)

  val saveForecasts =
    SQL
      .insertAuthorForecasts(author)
      .whenA(author.forecasts.nonEmpty)
      .onDuplicate(DuplicateForecastError)
      .onConstraintViolation(ForecastNotFound)

  (saveAuthor *> saveForecasts).transact(xa)

insertAuthorForecasts 基于 ConnectionIOupdateMany,示例:

def insertAuthorForecasts(a: Author): ConnectionIO[Int] =
  val sql = "INSERT INTO author_forecasts (id, author_id) VALUES (?, ?)"
  val ids = a.forecasts.toList.map(_.value -> a.id.value)
  Update[(UUID, UUID)](sql).updateMany(ids)

只有 forecasts 非空时才尝试持久化。考虑预测可能属于其他作者或不存在,将两者映射为特定业务错误。

作者及预测的持久化均在同一事务内执行,出错可回滚。

大多数解释器都以此方式实现,但没有 DoobieTx 扩展前无法从接口调用组合事务。为此我们定义了显式事务接口 TxAuthorStore

def tx: Resource[F, TxAuthorStore[F]] =
  xa.transaction.map(transactional)

私有构造器构建事务存储实例:

private def transactional[F[_]: MonadCancelThrow](
    fk: ConnectionIO ~> F
): TxAuthorStore[F] = new: ???

首个实现方法是 outbox

def outbox(evt: AuthorEvent): F[Unit] = fk {
  SQL
    .insertOutbox(evt.asLeft)
    .run.void
    .onConstraintViolation(DuplicateEventId(evt.id))
}

事件已处理时通过 onConstraintViolation 抛出 DuplicateEventId

最后实现 save 方法:

def save(author: Author): F[Unit] =
  val saveAuthor =
    SQL
      .insertAuthor(author)
      .run
      .onDuplicate(DuplicateAuthorError)

  val saveForecasts =
    SQL
      .insertAuthorForecasts(author)
      .whenA(author.forecasts.nonEmpty)
      .onDuplicate(DuplicateForecastError)
      .onConstraintViolation(ForecastNotFound)

  fk(saveAuthor *> saveForecasts)

不再用 transact(xa) 运行两条 SQL,而用自然变换,使 outboxsave 可以在外部组合,且保持事务性。

7.2.5.6 预测存储解释器(Forecasts store interpreter)

ForecastsStore 的构造函数与 AuthorStore 完全相同,这里略过,直接看 fetch 方法:

def fetch(fid: ForecastId): F[Option[Forecast]] =
  SQL.selectForecast(fid).option.transact(xa)

该查询不涉及联表,逻辑非常简单。接着是 tx 方法,构建方式和 AuthorStore 的一样:

def tx: Resource[F, TxForecastStore[F]] =
  xa.transaction.map(transactional)

TxForecastStore 实现中,有如下几个方法:

def outbox(evt: ForecastEvent): F[Unit] = fk {
  SQL
    .insertOutbox(evt.asRight)
    .run.void
    .onConstraintViolation(DuplicateEventId(evt.id))
}

outbox 方法插入包含 ForecastEvent 的出站箱事件,可能产生 DuplicateEventId 错误。

接下来是 castVote 方法:

def castVote(fid: ForecastId, res: VoteResult): F[Unit] = fk {
  SQL
    .updateVote(fid, res)
    .run
    .onUpdateFailure(ForecastNotFound)
}

它执行单条更新语句,定义如下:

def updateVote(id: ForecastId, res: VoteResult): Update0 =
  sql"""
    UPDATE forecasts
    SET score=COALESCE(score, 0)+${res.asInt}
    WHERE id=${id.show}
  """.update

onUpdateFailure 扩展方法作用于该语句执行返回的 Int,将其适配为 ForecastNotFound 错误。

res.asInt 扩展方法定义如下:

extension (res: VoteResult)
  def asInt: Int = res match
    case VoteResult.Up   => 1
    case VoteResult.Down => -1

如投票处理器中所见,castVote 操作与 registerVote 运行在同一事务内,后者定义如下:

def registerVote(evt: ForecastEvent.Voted): F[Unit] = fk {
  SQL
   .insertVote(evt)
   .run.void
   .onConstraintViolation(DuplicateEventId(evt.id))
}

最后是 save 方法,由两条不同语句组成:

def save(aid: AuthorId, fc: Forecast): F[Unit] =
  val saveForecast =
    SQL.insertForecast(fc).run

  val saveRelationship =
    SQL
      .updateAuthorForecast(aid, fc.id)
      .run.void
      .onConstraintViolation(AuthorNotFound)

  fk(saveForecast *> saveRelationship)

先持久化预测,如无异常再持久化作者与预测的关联关系,均在单一事务内完成。

注意,若给定的 AuthorId 不存在,后者可能失败,因此我们会适配错误。

EngineSuite 覆盖了所有事件生产与否的情况,取决于操作是否成功。虽说这些测试只用内存中的两种存储表示。

建议对 SQL 查询和语句进行测试,以排查数据库特有问题。

本章最后一节将介绍如何进行这类集成测试(见后文 Integration tests)。

7.2.6 可扩展性(Scalability)

与快照服务类似,我们也需要一个单写实例(虽然可依赖数据库事务协调写入,但这可能因与其他写者冲突而导致更高开销)。

因此,我们采用故障转移订阅(fail-over subscription):非常适合单写者的无状态应用。

image.png

图 7.4 展示了被选为领导者的 F2 实例处理 ForecastCommands,同时按照出站箱(outbox)模式将数据写入 SQL 数据库。它还处理接收到的 OutboxEvents 并将事件发布到对应的 Pulsar 主题。其他两个实例 F1 和 F3 准备在 F2 出现故障时接管工作。

由于预期该服务不会有大量流量,我们对每个收到的命令都直接写入数据库。若流量较大,则可以考虑第 5 章讨论过的各种数据处理方案(见 数据管道)。

7.2.7 入口点

与其他服务类似,我们从订阅类型开始:

val sub =
  Subscription.Builder
    .withName("forecasts")
    .withType(Subscription.Type.Failover)
    .build

接着是生产者设置,生产者名称必须唯一,以便重启后能正确去重(见 去重):

def settings[A](name: String) =
  PulsarProducer
    .Settings[IO, A]()
    .withDeduplication
    .withName(s"fc-$name-event")
    .some

由于我们使用 H2 数据库,且不存在针对它的 Pulsar CDC 连接器,因此需要手动处理 OutboxEvents 的发布:

def cdcResources(
    pulsar: Pulsar.T,
    topic: Topic.Single
): Resource[IO, Stream[IO, Unit]] =
  Producer.pulsar[IO, OutboxEvent](
    pulsar, topic, settings("outbox")
  ).map(p => OutboxProducer.make(p).stream)

OutboxProducer 会从内部队列读取数据(H2 数据库触发器将事件发布到该队列),再发布到 outbox-events 主题。这类功能,任何 Pulsar CDC 连接器都可开箱即用。

接下来是常规的资源初始化序列:

def resources =
  for
    config <- Resource.eval(Config.load[IO])
    pulsar <- Pulsar.make[IO](config.pulsar.url, Pulsar.Settings().withTransactions)
    _      <- Resource.eval(Logger[IO].info("Initializing forecasts service"))
    cmdTopic = AppTopic.ForecastCommands.make(config.pulsar)
    atTopic  = AppTopic.AuthorEvents.make(config.pulsar)
    fcTopic  = AppTopic.ForecastEvents.make(config.pulsar)
    outTopic = AppTopic.OutboxEvents.make(config.pulsar)
    authors     <- Producer.pulsar[IO, AuthorEvent](pulsar, atTopic, settings("at"))
    forecasts   <- Producer.pulsar[IO, ForecastEvent](pulsar, fcTopic, settings("fc"))
    cmdConsumer <- Consumer.pulsar[IO, ForecastCommand](pulsar, cmdTopic, sub)
    evtConsumer <- Consumer.pulsar[IO, ForecastEvent](pulsar, fcTopic, sub)
    outConsumer <- Consumer.pulsar[IO, OutboxEvent](pulsar, outTopic, sub)
    cdcEvents   <- cdcResources(pulsar, outTopic)
    xa          <- DB.init[IO]
    atStore = AuthorStore.from(xa)
    fcStore = ForecastStore.from(xa)
    server  = Ember.default[IO](config.httpPort)
    engine  = Engine.make(forecasts, atStore, fcStore, cmdConsumer)
    handler = VotesHandler.make(fcStore, evtConsumer)
    outbox  = OutboxHandler.make(authors, forecasts, outConsumer)
  yield (
    server, cmdConsumer, evtConsumer, outConsumer, handler, outbox, cdcEvents, engine
  )

如前所述,我们有一个命令消费者,两个事件生产者(分别对应不同事件类型),以及数据库连接用于存储。

此外,还有一个 OutboxEvents 消费者(用于 Outbox 处理器),和一个 ForecastEvents 消费者(用于投票处理器)。

7.2.8 运行

本服务无需初始化任何状态,因此 run 方法实现较为简洁:

def run: IO[Unit] =
  Stream
    .resource(resources)
    .flatMap { (
       server, cmdConsumer, evtConsumer, outConsumer, handler, outbox, cdc, engine
    ) =>
      Stream.eval(server.useForever).concurrently {
        Stream(
          cmdConsumer.receiveM.evalMap(engine.run),  // ForecastCommand
          outConsumer.receiveM.evalMap(outbox.run),  // OutboxEvent
          evtConsumer.receiveM.evalMap(handler.run), // Voted 事件(无 CDC)
          cdc                                        // CDC 连接器
        ).parJoin(5)
      }
    }
    .compile
    .drain

该方法后台运行 HTTP 服务,同时消费预测命令并通过 engine 处理。并行地消费事件,交给对应处理器(见 命令处理器 和 投票处理器),还有一个自定义的 H2 数据库 CDC 生产者。

7.3 Feed(数据生成服务)

数据生成服务会生成随机的交易指令和预测指令,并发布到 Pulsar,以触发整个系统的响应。
其唯一目的主要是手动测试,因为在系统尚未接入外部系统前,缺乏真实的输入指令。

实现细节并不重要,这里只看引擎的主构造函数,定义如下:

object Feed:
  def random[F[_]: GenUUID: Logger: Parallel: Temporal: Time](
      trProducer: Producer[F, TradeCommand],
      switcher: Producer[F, SwitchCommand],
      fcProducer: Producer[F, ForecastCommand]
  ): F[Unit] = ???

它为每种命令类型接收一个生产者,利用 Scalacheck 生成器模拟随机数据的发送。

但随机生成的预测事件不代表真实的命令流,因此我们有专门的 ForecastFeed 用来保证发布的 Publish 命令只携带有效的 AuthorId,通过监听 Registered 事件实现。Vote 命令同理,确保存在对应的 ForecastId,通过消费 Published 事件实现。

object ForecastFeed:
  def stream(
      fp: Producer[IO, ForecastCommand],
      fc: Consumer[IO, ForecastEvent],
      ac: Consumer[IO, AuthorEvent]
  ): Stream[IO, Unit] = ???

默认情况下,应用使用 ForecastFeed 替代完全随机的生成器。

7.3.1 生成器(Generators)

这是交易指令和开关指令的主要生成器:

val tradeCommandGen: Gen[TradeCommand] =
  Gen.oneOf(createCommandGen, updateCommandGen, deleteCommandGen)

val switchCommandGen: Gen[SwitchCommand] =
  Gen.oneOf(startCommandGen, stopCommandGen)

def tradeCommandListGen: List[TradeCommand | SwitchCommand] =
  val gen = Gen.frequency[TradeCommand | SwitchCommand](
    2 -> switchCommandGen,
    9 -> tradeCommandGen
  )
  Gen.listOfN(10, gen).sample.toList.flatten

以下是预测指令的生成器:

val forecastCommandGen: Gen[ForecastCommand] =
  Gen.oneOf(publishCommandGen, registerCommandGen, voteCommandGen)

val forecastCommandListGen: List[ForecastCommand] =
  Gen.listOfN(10, forecastCommandGen).sample.toList.flatten

交易指令中,优先保证 CRUD 命令的随机比例公平高于启动停止命令。

由于本服务需要访问域测试模块下定义的生成器,模块声明需要如下配置:

lazy val feed = (project in file("modules/feed"))
  .settings(commonSettings: _*)
  .settings(
    libraryDependencies += Libraries.scalacheck
  )
  .dependsOn(core)
  .dependsOn(domain.jvm % "compile->compile;compile->test")

最后那条 compile->test 配置实现了功能,但意味着 feed 服务无法直接作为 Docker 容器部署(除非用一些变通方法)。不过鉴于此服务性质,这通常不成问题。

我们将在最后一章学习更多关于容器和部署的内容(见 构建与运行),但请注意该服务设计为通过 sbt run 直接运行。

7.3.2 运行(Run)

最初只需要生产者,但 ForecastFeed 需要监听 AuthorEventsForecastEvents,以保证前述的有效性。

def resources =
  for
    config <- Resource.eval(Config.load[IO])
    pulsar <- Pulsar.make[IO](config.pulsar.url)
    _      <- Resource.eval(Logger[IO].info("Initializing feed service"))
    trTopic   = AppTopic.TradingCommands.make(config.pulsar)
    swTopic   = AppTopic.SwitchCommands.make(config.pulsar)
    fcTopic   = AppTopic.ForecastCommands.make(config.pulsar)
    atEvTopic = AppTopic.AuthorEvents.make(config.pulsar)
    fcEvTopic = AppTopic.ForecastEvents.make(config.pulsar)
    trading  <- Producer.pulsar[IO, TradeCommand](pulsar, trTopic, settings("trade"))
    switcher <- Producer.pulsar[IO, SwitchCommand](pulsar, swTopic, swSettings)
    forecast <- Producer.pulsar[IO, ForecastCommand](pulsar, fcTopic, settings("fc"))
    fcConsumer <- Consumer.pulsar[IO, ForecastEvent](pulsar, fcEvTopic, sub)
    atConsumer <- Consumer.pulsar[IO, AuthorEvent](pulsar, atEvTopic, sub)
    fcFeed = ForecastFeed.stream(forecast, fcConsumer, atConsumer)
    server = Ember.default[IO](config.httpPort)
  yield (server, Feed.random(trading, switcher, forecast), fcFeed)

此外,每个生产者需要不同的配置。

交易和预测命令需要开启去重功能,并设置 Pulsar 消息的 orderingKey,通过第 5 章中介绍的 Shard 类型类实现(见 Neutron Producer)。还需设置唯一生产者名,保证重启后正确去重。

def settings[A: Shard](name: String) =
  PulsarProducer
    .Settings[IO, A]()
    .withDeduplication
    .withName(s"feed-$name-command")
    .withShardKey(Shard[A].key)
    .some

我们也为开关命令开启去重,但通过 Compaction 类型类(用于压缩主题)设置分区键:

val swSettings =
  PulsarProducer
    .Settings[IO, SwitchCommand]()
    .withDeduplication
    .withMessageKey(Compaction[SwitchCommand].key)
    .withName("feed-switch-command")
    .some

消费者订阅配置相当简单:

val sub =
  Subscription.Builder
    .withName("forecasts-gen")
    .withType(Subscription.Type.Exclusive)
    .build

最后,run 方法定义如下:

def run: IO[Unit] =
  resources.use { (server, feed, fcFeed) =>
    Stream
      .eval(server.useForever)
      .concurrently {
        Stream(
          Stream.eval(feed),
          fcFeed
        ).parJoin(2)
      }
      .compile
      .drain
  }

它会运行 HTTP 服务器,同时并行运行交易与预测数据生成流。

7.4 集成测试

作为集成测试,我们会考虑所有访问数据存储的解释器(Interpreters),本例中包括 Redis 和 SQL 数据库。
我们不会测试与 Apache Pulsar 的交互,因为这并无太大价值,除非我们运行整个系统的自动化模拟。
后者虽然是个好主意,但模拟整体系统的交互通常很难做到准确,并且随着时间推移维护难度和成本都很高。
不过,我们很快会学习一种技术,用来在部署前验证多服务间通信的完整性(参见第 8 章的冒烟测试 Smoke tests)。

7.4.1 Redis 测试套件

RedisSuite 从定义 Redis 连接开始:

object RedisSuite extends ResourceSuite:
  type Res = RedisCommands[IO, String, String]

  override def sharedResource: Resource[IO, Res] =
    Redis[IO].utf8("redis://localhost").beforeAll(_.flushAll)

接着,我们测试 snapshots 读写解释器:

test("snapshots reader and writer") { redis =>
  val reader = SnapshotReader.from[IO](redis)
  val writer = SnapshotWriter.from[IO](redis, KeyExpiration(30.seconds))
  val msgId  = MsgId.earliest

  NonEmptyList
    .of(tradeStateGen.sample.replicateA(3).toList.flatten.last)
    .traverse { ts =>
      for
        x <- reader.latest
        _ <- writer.save(ts, msgId)
        y <- reader.latest
      yield NonEmptyList.of(
        expect.same(None, x),
        expect.same(Some(ts.status), y.map(_._1.status)),
        expect.same(Some(ts.prices.keySet), y.map(_._1.prices.keySet)),
        expect.same(Some(msgId.serialize), y.map(_._2.serialize))
      )
    }.map(_.flatten.reduce)
}

这是一个简单的测试实现,用于验证读取和写入功能。
注意,这里没有使用基于属性的测试(property-based testing),因为写入 Redis 属于有状态操作。
为了写出确定性的断言,我们只做一次测试,即采样 TradeState 生成器并取最后一个值。

7.4.2 SQL 测试套件

我们还有一个 SQLSuite,用来检查所有 SQL 查询和语句。

由于使用的是内存型 H2 数据库,这也可以看作是单元测试。

当然,你可以将 JDBC 连接改成 PostgreSQL,这里也很适合做这件事。

下面是交易仓库中该测试套件的部分内容,首先是连接定义和一些初始值:

object SQLSuite extends IOSuite:
  type Res = H2Transactor[IO]

  override def sharedResource: Resource[IO, Res] =
    DB.init[IO]

  val aid  = AuthorId(UUID.randomUUID())
  val fid  = ForecastId(UUID.randomUUID())

接下来是一个触发所有查询和语句的测试(这里只展示部分):

test("authors and forecasts") { xa =>
  val at = AuthorStore.from(xa)
  val fc = ForecastStore.from(xa)

  for
    a <- at.tx.use(_.save(Author(aid, AuthorName("gvolpe"), None, Set(fid)))).attempt
    _ <- at.tx.use(_.save(Author(aid, AuthorName("gvolpe"), None, Set())))
    b <- fc.tx.use(_.save(aid2, Forecast(fid, symbol, tag, desc, ForecastScore(1)))).attempt
    _ <- fc.tx.use(_.save(aid, Forecast(fid, symbol, tag, desc, ForecastScore(1))))
    c <- at.fetch(aid)
    d <- at.tx.use(_.save(Author(aid2, AuthorName("gvolpe"), None, Set()))).attempt
  yield NonEmptyList
    .of(
      expect.same(a, Left(ForecastNotFound)),
      expect.same(b, Left(AuthorNotFound)),
      expect.same(c.map(_.id), Some(aid)),
      expect.same(d, Left(DuplicateAuthorError))
    )
    .reduce
}

注意,运行该测试也会执行 Flyway 迁移,因此它不仅验证了 SQL 查询和语句,还验证了数据库的模式(Schema)。

此外,代码库中还包含更多未展示的测试,覆盖了其他方法。

7.5 总结

我们学习了快照(snapshots)和预测(forecasts)这两个内容,虽然它们不属于核心服务,但在整个系统中发挥着重要作用。

关于快照,我们了解了如何正确利用分布式锁技术来同步多个附加到独占订阅(exclusive subscription)的消费者,有效地用额外的保障替代了 Failover 订阅的功能。

关于预测,我们看到了适用于 Failover 订阅的无状态命令处理方式,以及最终与 SQL 数据库交互的存储方案。

此外,我们学习了事务型出站箱(transactional outbox)和“监听自己”(listen-to-yourself)模式,这些模式有助于保证系统的正确性和强一致性。
我们还了解了 Scalacheck 生成器,它既用于测试,也用于 Feed 服务中的随机数据生成。

最后,我们讨论了集成测试在系统中的角色。