既然我们已经了解了核心服务,接下来来看图 7.1 所示的构成整个系统的其他几个服务。
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 则作为备份准备随时接管。
然而,进一步分析这种设计会暴露出架构上的一些缺陷。
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 管理。
图 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 事件(如 CommandRejected 和 SwitchEvents):
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
)
此处为简洁起见,省略了部分必填字段。
这里出现的大多数新类型是基于 String 或 UUID 的新类型(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)
下面来看命令与事件的关系,以更好理解不同结果如何对应输入:
| COMMAND | AUTHOR EVENT | FORECAST EVENT |
|---|---|---|
| Register | Registered | N/A |
| Publish | N/A | Published |
| Vote | N/A | Voted |
将这两类事件拆分的初衷,是考虑到它们可能需要在不同服务中处理。
但实际上,目前没有事件消费者使用这些事件,因为暂时没有相应功能需求。
正如表格所示,这里没有状态,因为预测引擎不需要状态机。接下来我们将详细了解它。
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),以及作者和预测的存储接口。
我们已熟悉 Producer 和 Acker,下面介绍剩余的类型。
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
如稍后会学到的,save 和 outbox 操作需要在外部组成的事务中执行,所以定义在不同接口里。
在 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(命令中的作者尚未注册)。
错误处理方式同样使用 recoverWith 和 handleNack。
我们复用 CommandId 作为 EventId,在数据库事务成功但确认失败时避免命令重发造成重复事件。这个问题只影响 Publish,Register 会优先触发 DuplicateAuthorError。
7.2.4.3 出站箱处理器(Outbox handler)
Register 和 Publish 命令会将事件持久化到出站箱(outbox)表,之后由下面的处理器消费:
trait OutboxHandler[F[_]]:
def run: Msg[OutboxEvent] => F[Unit]
OutboxEvent 可能包含 AuthorEvent 或 ForecastEvent:
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 基于 ConnectionIO 和 updateMany,示例:
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,而用自然变换,使 outbox 和 save 可以在外部组合,且保持事务性。
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):非常适合单写者的无状态应用。
图 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 需要监听 AuthorEvents 和 ForecastEvents,以保证前述的有效性。
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 服务中的随机数据生成。
最后,我们讨论了集成测试在系统中的角色。