函数式事件驱动架构——带副作用的流

Fs2(Functional streams for Scala,Scala 的函数式流库)绝对是我一直以来最喜欢的库。它最初以 scalaz-stream 的名字诞生,源自那本著名的《红皮书》(现在已经出到第二版),经过多年演化,发展到如今业界领先的状态。

在本章中,我们将通过实用示例和软件设计理念进行讲解,这些内容对于后续基于流的分布式系统开发至关重要。

5.1 有限状态机(Finite State Machines)

我们在 PFPS 里已经简单介绍过有限状态机(FSM)这个主题,但这里再回顾一下,因为后续交易系统会大量用到。引用维基百科的定义:

有限状态机(FSM,或有限自动机 FSA)是一种计算的数学模型。它是一台抽象机器,在任意时刻只能处于有限个状态中的一个。FSM 可以根据输入从一个状态切换到另一个状态,这个切换叫做"转移(transition)"。FSM 由状态集合、初始状态和触发每个转移的输入组成。FSM 分为确定型(DFA)和非确定型(NFA),任何 NFA 都可以构造出等价的 DFA。

我们将用如下的 Scala 3 表达方式:

typescript 复制代码
import cats.syntax.all.*
import cats.{ Functor, Id }

case class FSM[F[_], S, I, O](run: (S, I) => F[(S, O)]):
  def runS(using F: Functor[F]): (S, I) => F[S] =
    (s, i) => run(s, i).map(_._1)

object FSM:
  def id[S, I, O](run: (S, I) => Id[(S, O)]) = FSM(run)

run 函数接受一个状态 S 和一个输入 I,在上下文 F 中返回新的状态 S 和输出 O。当不需要 effect 上下文时,可以用身份态 FSM(cats.Id)。
runS 是扩展方法,运行状态机但丢弃输出,只返回新状态。如果需要,也可以写个只返回输出、丢弃新状态的方法。

5.1.0.1 交易引擎 FSM 示例

下面是下一章我们会用到的代码片段,建模了一个交易引擎的有限状态机:

typescript 复制代码
object TradeEngine:
  val fsm =
    FSM.id[
      TradeState,
      TradeCommand | SwitchCommand,
      (EventId, Timestamp) => TradeEvent | SwitchEvent
    ] {
      // 交易状态为 On
      case (st @ TradeState(On, _), cmd @ Create(_, cid, sl, ac, p, q, _, _)) =>
        val nst = st.modify(sl)(ac, p, q)
        nst -> ((id, ts) => CommandExecuted(id, cid, cmd, ts))
      case (st @ TradeState(On, _), cmd @ Update(_, cid, sl, ac, p, q, _, _)) =>
        val nst = st.modify(sl)(ac, p, q)
        nst -> ((id, ts) => CommandExecuted(id, cid, cmd, ts))
      case (st @ TradeState(On, _), cmd @ Delete(_, cid, sl, ac, p, _, _)) =>
        val nst = st.remove(sl)(ac, p)
        nst -> ((id, ts) => CommandExecuted(id, cid, cmd, ts))
      // 交易状态为 Off
      case (st @ TradeState(Off, _), cmd: TradeCommand) =>
        val rs = Reason("Trading is off")
        st -> ((id, ts) => CommandRejected(id, cmd.cid, cmd, rs, ts))
      // 切换 On / Off
      case (st @ TradeState(Off, _), Start(_, cid, _)) =>
        val nst = TradeState._Status.replace(On)(st)
        nst -> ((id, ts) => Started(id, cid, ts))
      case (st @ TradeState(On, _), Stop(_, cid, _)) =>
        val nst = TradeState._Status.replace(Off)(st)
        nst -> ((id, ts) => Stopped(id, cid, ts))
      case (st @ TradeState(On, _), Start(_, cid, _)) =>
        st -> ((id, ts) => Ignored(id, cid, ts))
      case (st @ TradeState(Off, _), Stop(_, cid, _)) =>
        st -> ((id, ts) => Ignored(id, cid, ts))
    }

先不深入数据类型细节(第 6 章会详细讲),这里可以看到如何通过 TradeState 处理命令并产生事件来建模状态转移。

我们之所以可以用身份 FSM(Id),是因为事件的 EventId 和 Timestamp 是在生成事件时"延后"生成的。如果希望输出就是 F[TradeEvent | SwitchEvent],那 effect F 就要有 ApplicativeGenUUIDTime 能力,Id 就不够了。

FSM 本质是纯函数,非常易于测试。只需传入初始状态和输入(命令),再断言输出的状态和事件即可。例如:

scss 复制代码
test("Trade engine fsm") {
  val st1 = fsm.runS(TradeState.empty, createCmd)
  val ex1 = TradeState(On, pricesMap)
  expect.same(st1, ex1)
}

这里只是简单举例,具体细节后面会有讲解。

5.1.0.2 流(Stream)集成

Fs2 提供了两个和 FSM 结构天然契合的方法:mapAccumulateevalMapAccumulate。它们的简化类型签名如下:

less 复制代码
def mapAccumulate[S, O](s: S)(f: (S, I) => (S, O)): Stream[F, (S, O)]
def evalMapAccumulate[S, O](s: S)(f: (S, I) => F[(S, O)]): Stream[F, (S, O)]

结合交易 FSM,可以这样用:

arduino 复制代码
val commands: Stream[IO, TradeCommand | SwitchCommand] = ???
commands.evalMapAccumulate(TradeState.empty)(fsm.run)

比如 commands 可以来自 Pulsar topic 的消息流。

后面章节我们处理状态转移时,大部分代码都会采用类似结构。

如需查看更多 FSM 示例,可参考我以前写过的一篇博客。

5.2 资源与生命周期(Resources and lifecycle)

所有与 Cats Effect 集成的库都可以利用 Resource 数据类型(及其实例),它用于建模在任务完成或失败时需要执行清理动作的场景。

比如,HTTP 服务器或数据库连接通常都属于资源的典型例子,因为获取它们成本较高。有时也只是为了确保资源关闭前,最后一定要执行一段清理逻辑。

最常见的例子就是默认的 Http4s 服务器 Ember:

arduino 复制代码
val mkServer: Resource[IO, Server] =
  EmberServerBuilder
    .default[IO]
    .withHost(host"0.0.0.0")
    .withPort(port"8080")
    .build

再比如创建 Redis 连接:

dart 复制代码
Redis[IO].utf8("redis://localhost").use { redis =>
  for
    _ <- redis.set("foo", "123")
    x <- redis.get("foo")
    _ <- redis.setNx("foo", "should not happen")
    y <- redis.get("foo")
    _ <- IO(assert(x === y))
  yield ()
}

不过,这两类资源用法上是有区别的:

  • 第一类(比如 HTTP 服务器)通常作为长期运行的任务,返回的 Server 实例其实不用关心,常见写法如下:

    ini 复制代码
    // 等同于 .use(_ => IO.never)
    mkServer.useForever
  • 第二类(比如 Redis 连接)通常要与多个业务组件共享,例如:

    scss 复制代码
    Redis[IO].utf8("redis://localhost").use { redis =>
      serviceOne(redis) *> serviceTwo(redis)
    }

但它们也有共同点:一般都在应用的顶层初始化,先获取一系列资源,再用 IO 或 Stream 运行主程序。

Fs2 的 Stream 自带 resource 方法,可以与 Cats Effect 的 Resource 无缝对接,非常适合用来管理 Redis 连接。同样也可以用于 HTTP 服务器(但有一些语义上的细节要注意)。

scss 复制代码
def run: IO[Unit] =
  Stream
    .resource(resources)
    .flatMap { (serverRes, redisRes) =>
      Stream.eval(serverRes.useForever).concurrently {
        Stream.resource(redisRes).evalMap { redis =>
          serviceOne(redis) *> serviceTwo(redis)
        }
      }
    }
    .compile
    .drain

在这个例子中,我们还是推荐用 useForever 并用 Stream.eval 提升到流中,这样写最简洁。当然也可以直接用 Stream.resource,但那样要加上 Stream.never 来实现等价语义:

scss 复制代码
(Stream.resource(serverRes) >> Stream.never[IO])
  .concurrently {
    Stream.resource(redisRes).evalMap { redis =>
      serviceOne(redis) *> serviceTwo(redis)
    }
  }

在本书应用里,我们采用第一种 useForever 的方式,但你可以根据需要选择适合自己的风格。

5.3 数据管道(Data pipelines)

Fs2 是构建数据管道的强大工具。它可以很方便地连接各种数据源,比如文件、数据库,甚至消息中间件收到的消息。

不过,不同场景往往需要不同的处理方式。例如,用于实时数据处理的管道,和用于分析(analytics)或批处理(batch)的管道,设计思路是完全不一样的。

下图展示了一个混合型的数据管道结构。

在本节中,我们将学习一些范例和设计思路,这些不仅适用于下一章我们要开发的系统,对你作为软件工程师的职业生涯、尤其是在系统设计领域同样大有裨益。

5.3.1 实时处理(Real-time)

专为实时处理设计的数据管道需要具备高吞吐。例如,接收足球比赛的相关事件(如 GoalScored、CornerKickAwarded),并实时反映到系统里,过程中可能包括一些计算、更新缓存、发布其他消息等。

如果每个事件都要落库,则会牺牲吞吐能力。因此,实时处理管道和分析/批处理管道需要严格区分,后面我们会举例说明。

实时管道分为需要保序和无需保序两类。需要保序时通常可用 evalMapparEvalMap 实现:

scss 复制代码
val events: Stream[IO, FootballEvent] = ???

events.parEvalMap(maxConcurrent = 10) {
  process(_).flatMap { e =>
    sendMessage(e) &> updateCache(e)
  }
}

一般来说,当数据源量大时,parEvalMap 性能更佳;数据量不大时和 evalMap 区别不大。

而如果不需要保序,可以用 parEvalMapUnordered(又名 mapAsyncUnordered)。

5.3.2 批处理(Batching)

另一个主流场景是:每条消息处理成本较高(比如写 SQL 数据库),也就是"快生产、慢消费"典型场景。若不优化,系统性能会大幅下降,甚至引发故障。此时应采用批处理。

仅用 evalMapparEvalMap 很难抗住大数据流,很容易丢数据或内存爆炸。Fs2 支持用 fs2.Chunk 类型实现分批处理。

scss 复制代码
scala> Stream(1,2,3).repeat.chunkN(2).take(5).toList
res0: List[Chunk[Int]] = List(
  Chunk(1, 2), Chunk(3, 1), Chunk(2, 3), Chunk(1, 2), Chunk(3, 1)
)

具体用法举例:

scss 复制代码
events
  .chunkN(1000)
  .zip(Stream.iterate(0)(_ + 1))
  .parEvalMap { (c, n) =>
    IO.println(s"Chunk #$n") *> persist(c)
  }

def persist(chunks: Chunk[FootballEvent]): IO[Unit] = ???

这里 zip 是为了统计批次数,可以省略。

5.3.3 分析型管道(Analytics)

分析型数据管道既可以是实时流,也可以是批处理流,或者两者结合。实际业务视需求而定。

比如,先实时处理,再按批处理结果入库(当批处理依赖实时处理结果时):

scss 复制代码
events
 .parEvalMap(10) {
   process(_).flatMap { e =>
     sendMessage(e) &> updateCache(e).as(e)
   }
 }
 .chunkN(1000)
 .parEvalMap(10)(persist)

不过要注意,这样会让批处理影响到实时处理。如果想解耦,建议引入内部 topic,采用 fan-out 拓扑:

scss 复制代码
Stream.eval(Topic[IO, FootballEvent]).flatMap { topic =>
  val realTime = consumer.receive.parEvalMap(10) {
    process(_).flatMap { e =>
      (
        sendMessage(e), updateCache(e), topic.publish1(e)
      ).parSequence_
    }
  }

  val batching =
    topic
     .subscribe(1000)
     .chunkN(1000)
     .parEvalMap(10)(persist)

  Stream(realTime, batching).parJoinUnbounded
}

如果批处理和实时处理完全独立,也可这样:

scss 复制代码
Stream.eval(Topic[IO, FootballEvent]).flatMap { topic =>
  val realTime =
    topic.subscribe(1000).parEvalMap(50) {
      process(_).flatMap { e =>
        sendMessage(e) &> updateCache(e)
      }
    }

  val batching =
    topic
     .subscribe(1000)
     .chunkN(1000)
     .parEvalMap(10)(persist)

  Stream(
    realTime,
    batching,
    consumer.receive.through(topic.publish)
  ).parJoinUnbounded
}

当然"更独立"只是相对的,因为还是一个进程,可能共享线程池等资源。

5.3.4 数据源(Data source)

每个数据管道都始于某种数据源,可以是文件、数据库、缓存、网络连接或消息中间件等。

5.3.4.1 文件

处理文件可以直接用 fs2.io.file,但如果需要处理已知格式(如 CSV、XML、JSON),推荐用 fs2-data 库。

CSV 读取示例:

kotlin 复制代码
import fs2.data.csv.*
import fs2.data.csv.generic.semiauto.*
import fs2.io.file.{ Files, Path }

// Stream[IO, Movie]
Files[IO]
  .readAll(Path("dataset/movies.csv"))
  .through(fs2.text.utf8.decode)
  .through(decodeUsingHeaders[Movie]())

XML 读取示例:

arduino 复制代码
import fs2.data.xml.*

Files[IO]
  .readAll(Path("dataset/demo.xml"))
  .through(fs2.text.utf8.decode)
  .through(events[IO, String])

其实还有许多兼容 Fs2 的库可选,fs2-data 只是个人推荐。

5.3.4.2 数据库

数据库流支持,取决于客户端是否支持 Fs2。PostgreSQL 的 Doobie 和 Skunk 都支持流式查询。

Doobie 示例:

arduino 复制代码
sql"SELECT name FROM country"
  .query[String]
  .stream // Stream[ConnectionIO, String]

Skunk 示例:

css 复制代码
val e: Query[String, String] =
  sql"SELECT name FROM country".query(varchar)

Stream
  .resource(session.prepare(e))
  .flatMap(_.stream("U%", 64))

5.3.4.3 网络

原生 TCP/UDP 可用 fs2.io.net API 实现。例如极简 Echo TCP 服务器:

scss 复制代码
Network[IO].server(port = Some(port"5555")).map { client =>
  client.reads
    .through(fs2.text.utf8.decode)
    .through(fs2.text.lines)
    .interleave(Stream.constant("\n"))
    .through(fs2.text.utf8.encode)
    .through(client.writes)
    .handleErrorWith(_ => Stream.empty)
}.parJoin(100)

更高层次可用如 fs2-grpc 等三方库。

5.3.4.4 消息中间件

后续章节会介绍如何用函数式 Scala 代码与 Apache Pulsar、Kafka 通信。其实市面上还有 RabbitMQ、ZeroMQ、MQTT(Mosquito)、AWS Kinesis、SQS、Google Cloud Pub/Sub 等各种消息系统,也都有可用库。

如 ZeroMQ 用 fmq

arduino 复制代码
import io.fmq.*
import io.fmq.socket.pubsub.Subscriber
import io.fmq.syntax.literals.*

val topic = Subscriber.Topic.utf8String("demo"))

Stream.resource {
  Context
    .create 
    .evalMap(_.createSubscriber(topic))
    .flatMap(_.connect(tcp"://localhost:31234"))
}.flatMap { socket =>
  Stream.repeatEval(socket.receiveFrame[String])
}

AWS Kinesis/SQS 用 fs2-aws

less 复制代码
import fs2.aws.*

val kinesis: Stream[IO, CommittableRecord] =
  readFromKinesisStream[IO]("appName", "streamName")

val sqs: Stream[IO, String] =
  sqsStream[IO, String](
    sqsConfig,
    (cfg, cb) => SQSConsumerBuilder(cfg, cb)
  ).map(_.body())

无论数据源是什么,都可以根据实际业务需求采用前面介绍的管道设计思路。

5.4 生产者-消费者(Producer-consumer)

Producer 和 Consumer 可以表示某个具体的消息中间件,比如 Kafka 或 Pulsar。在 Scala 代码中,我们可以通过客户端库提供的接口与它们交互。当然,也可以自己实现抽象接口。

先来看 Producer 的接口,这是最简单的部分:

less 复制代码
trait Producer[F[_], A]:
  def send(a: A): F[Unit]
  def send(a: A, properties: Map[String, String]): F[Unit]

它用 F[_] 表示 effect 类型,A 表示消息类型。因此生产消息本质就是 A => F[Unit]。第二个方法允许携带附加属性(元数据)。

Consumer 接口稍微复杂一些:

scala 复制代码
trait Acker[F[_], A]:
  def ack(id: Consumer.MsgId): F[Unit]
  def ack(ids: Set[Consumer.MsgId]): F[Unit]
  def nack(id: Consumer.MsgId): F[Unit]

trait Consumer[F[_], A] extends Acker[F, A]:
  def receiveM: Stream[F, Consumer.Msg[A]]
  def receiveM(id: Consumer.MsgId): Stream[F, Consumer.Msg[A]]
  def receive: Stream[F, A]
  def lastMsgId: F[Option[Consumer.MsgId]]

object Consumer:
  type MsgId = String
  type Properties = Map[String, String]
  final case class Msg[A](id: MsgId, props: Properties, payload: A)

ack/nack 相关函数被单独放在 Acker 接口中,便于复用。

有了这些接口(tagless algebra),我们可以建模基本的业务逻辑。例如用 concurrently 组合生产者和消费者流:

ini 复制代码
val c1 =
  consumer.receive
    .evalMap(n => IO.println(s"Consumed: $n"))

val p2 =
  Stream.range(0, 100)
    .evalMap(producer.send)

c1.concurrently(p2)

p2 会在 c1 结束时自动终止。如果需要二者独立运行,可以用 parJoinparJoinUnbounded

scss 复制代码
Stream(c1, p2).parJoin(2)

多个 stream 并发运行时,类似如下结构常见于实际服务:

ini 复制代码
def run: IO[Unit] =
  Stream
    .resource(resources)
    .flatMap { (consumer, topic, server) =>
      val http =
        Stream.eval(server.useForever)

      val subs =
        topic.subscribers.evalMap { n =>
          Logger[IO].info(s"WS connections: $n")
        }

      val alerts =
        consumer.receive.through(topic.publish)

      Stream(http, subs, alerts).parJoin(3)
    }
    .compile
    .drain

这个例子运行三个独立小程序:HTTP 服务、WS 连接日志、告警消费与分发。任何一个失败,整体流会终止,因此实际项目需做异常处理或容错。

5.4.1 基于内存的实现(Queue)

我们可以为 Producer 和 Consumer 实现内存版 interpreter。例如 Producer:

less 复制代码
import cats.effect.std.Queue

def local[F[_]: Applicative, A](
    queue: Queue[F, Option[A]]
): Resource[F, Producer[F, A]] =
  Resource.make[F, Producer[F, A]](
    Applicative[F].pure(
      new:
        def send(a: A): F[Unit] = queue.offer(Some(a))
        def send(a: A, properties: Map[String, String]): F[Unit] = send(a)
    )
  )(_ => queue.offer(None))

这里用 Queue[F, Option[A]],生产者推送 None 时,消费者可以优雅地结束。也可以用 fs2.concurrent.Topic。

Consumer 类似:

less 复制代码
def local[F[_]: Applicative, A](
    queue: Queue[F, Option[A]]
): Consumer[F, A] = new:
  def receiveM: Stream[F, Msg[A]]       = receive.map(Msg("N/A", _))
  def receive: Stream[F, A]             = Stream.fromQueueNoneTerminated(queue)
  def ack(id: Consumer.MsgId): F[Unit]  = Applicative[F].unit
  def nack(id: Consumer.MsgId): F[Unit] = Applicative[F].unit
  ...

ack/nack 在内存实现下无实际意义,都是 no-op。receive 用 fromQueueNoneTerminated,只要收到 None 就终止。

可以写一个简单 demo:

ini 复制代码
def run: IO[Unit] =
  Queue.bounded .flatMap { q =>
    val consumer = Consumer.local(q)
    val producer = Producer.local(q)

    val p1 =
      consumer.receive
        .evalMap(s => IO.println(s">>> GOT: $s"))

    val p2 =
      Stream
        .resource(producer)
        .flatMap { p =>
          Stream
            .sleep[IO](100.millis)
            .as("test")
            .repeatN(3)
            .evalMap(p.send)
        }

    IO.println(">>> Initializing in-memory demo <<<") *>
      p1.concurrently(p2).compile.drain
  }

输出如下:

shell 复制代码
>>> Initializing in-memory demo <<<
>>> GOT: test
>>> GOT: test
>>> GOT: test

5.4.2 分布式实现:基于 Apache Pulsar

接下来我们基于 Apache Pulsar 和 Neutron 库实现分布式的生产者和消费者。

5.4.2.1 Producer

Pulsar 支持 Key-Shared 订阅模式,生产者可以给每条消息设置一个 ordering key,实现"分片"能力:

ini 复制代码
val m1 = Message("key-1", "a1")
val m2 = Message("key-2", "b1")
val m3 = Message("key-3", "a2")

比如有两个消费者,m1/m3 发给 c1,m2 发给 c2。

为此我们定义 Shard typeclass:

kotlin 复制代码
import dev.profunktor.pulsar.ShardKey

trait Shard[A]:
  def key: A => ShardKey

Compaction typeclass 用于 compacted topic:

kotlin 复制代码
import dev.profunktor.pulsar.MessageKey

trait Compaction[A]:
  def key: A => MessageKey

Producer 构造如下:

less 复制代码
import dev.profunktor.pulsar.{ Producer as PulsarProducer, * }
def pulsar[F[_]: Async: Logger: Parallel, A: Encoder](
    client: Pulsar.T,
    topic: Topic.Single,
    settings: Option[PulsarProducer.Settings[F, A]] = None
): Resource[F, Producer[F, A]] =
  val _settings = ...
  val encoder: A => Array[Byte] = _.asJson.noSpaces.getBytes(UTF_8)
  PulsarProducer
    .make[F, A](client, topic, encoder, _settings)
    .map { p =>
      new:
        def send(a: A): F[Unit] = p.send_(a)
        def send(a: A, properties: Map[String, String]): F[Unit] = p.send_(a, properties)
        def send(a: A, tx: Txn): F[Unit] = p.send_(a, tx.get)
    }

5.4.2.2 Consumer

Pulsar Consumer 更复杂。包括死信队列策略和解码错误处理:

ini 复制代码
import dev.profunktor.pulsar.{ Consumer as PulsarConsumer, * }

def pulsar[F[_]: Async: Logger, A: Decoder: Encoder](
    client: Pulsar.T,
    topic: Topic,
    sub: Subscription,
    settings: Option[PulsarConsumer.Settings[F, A]] = None
): Resource[F, Consumer[F, A]] =
  val deadLetterPolicy = ...
  val _settings = ...
  val decoder: Array[Byte] => F[A] = ...
  val handler: Throwable => F[PulsarConsumer.OnFailure] = ...
  PulsarConsumer.make[F, A](
    client, topic, sub, decoder, handler, _settings
  ).map { c =>
    new:
      def receiveM: Stream[F, Msg[A]] = ...
      def receive: Stream[F, A] = ...
      def ack(id: MsgId): F[Unit] = ...
      def nack(id: MsgId): F[Unit] = ...
      ...
  }

5.4.2.3 示例

用前述抽象创建 Producer 和 Consumer:

ini 复制代码
def resources =
  for
    config <- Resource.eval(Config.load[IO])
    pulsar <- Pulsar.make[IO](config.pulsar.url, Pulsar.Settings().withTransactions)
    cmdTopic = AppTopic.TradingCommands.make(config.pulsar)
    evtTopic = AppTopic.TradingEvents.make(config.pulsar)
    producer <- Producer.pulsar[IO, TradeEvent](pulsar, evtTopic, evtSettings)
    consumer <- Consumer.pulsar[IO, TradeCommand](pulsar, cmdTopic, sub)
  yield (server, consumer, Engine.fsm(producer, Txn.make(pulsar), consumer))

5.4.3 分布式实现:Apache Kafka

我们也可以用 fs2-kafka 库实现 Kafka 版 Producer/Consumer。

Producer:

less 复制代码
import fs2.kafka.{ KafkaProducer, ProducerSettings }

def kafka[F[_]: Async, A](
    settings: ProducerSettings[F, String, A],
    topic: String
): Resource[F, Producer[F, A]] =
  KafkaProducer.resource(settings).map { p =>
    new:
      def send(a: A): F[Unit] =
        p.produceOne_(topic, "key", a).flatten.void
      def send(a: A, properties: Map[String, String]): F[Unit] = send(a)
  }

Consumer 更复杂,需要维护 offset 状态:

ini 复制代码
import fs2.kafka.{ ConsumerSettings, KafkaConsumer }
import org.apache.kafka.clients.consumer.OffsetAndMetadata
import org.apache.kafka.common.TopicPartition

def kafka[F[_]: Async, A](
    settings: ConsumerSettings[F, String, A],
    topic: String
): Resource[F, Consumer[F, A]] =
  Resource.eval(
    Ref.of[F, List[CommittableOffset[F]]](List.empty)
  ).flatMap { ref =>
    KafkaConsumer
      .resource[F, String, A](settings.withEnableAutoCommit(false))
      .evalTap(_.subscribeTo(topic))
      .map { c =>
        new:
          def receiveM: Stream[F, Msg[A]] = ...
          def receive: Stream[F, A] = ...
          def ack(ids: Set[MsgId]): F[Unit] = ...
          ...
      }
  }

5.4.3.3 示例

最终,组合 producer/consumer:

ini 复制代码
val topic = "trading-kafka"
val consumerSettings = ...
val producerSettings = ...

def resources =
  for
    c <- Consumer.kafka[IO, TradeEvent](consumerSettings, topic)
    p <- Producer.kafka[IO, TradeEvent](producerSettings, topic)
  yield c -> p

val event: Option[TradeEvent] = ??? // 随机数据

def run: IO[Unit] =
  Stream
    .resource(resources)
    .flatMap { (consumer, producer) =>
      val p1 =
        consumer.receive
          .evalMap(e => IO.println(s">>> KAFKA: $e"))

      val p2 =
        Stream
          .awakeEvery[IO](1.second)
          .as(event)
          .evalMap(_.traverse_(producer.send))

      p1.concurrently(p2)
    }
    .interruptAfter(5.seconds)
    .compile
    .drain

运行效果:

yaml 复制代码
>>> Initializing kafka demo <<<
>>> KAFKA: CommandExecuted(...)
>>> KAFKA: CommandExecuted(...)
>>> KAFKA: CommandExecuted(...)
>>> KAFKA: CommandExecuted(...)
[success] Total time: 8 s, completed Nov 18, 2021, 1:33:28 PM

5.5 小结

我们已经看到,Kafka 和 Pulsar 的语义实际上完全不同。虽然实现一个 Consumer 和 Producer 的抽象很有趣,而且在原型验证阶段也许能用,但你仍然需要深入理解所选消息中间件的优缺点,选定工具后,建议直接使用对应的官方库(比如 fs2-kafka 或 neutron)。

在我们下一章要写的应用中,依然会保留对 Consumer 和 Producer 创建的抽象接口。不过你会发现,很多与 Pulsar 相关的代码(比如订阅、消费/生产设置等)都会混杂在服务实现中。如果选 Kafka 也会遇到同样的情况。

也就是说,为所有服务提供一个通用的构造器(比如内置 JSON 日志、统一默认配置、统一语义),依然是很好的实践。

除了了解这两种流行消息中间件的客户端库之外,本章我们还学习了有限状态机、数据流水线及其他流式处理的实用技巧,这些内容在后续真正动手写应用代码时会非常有帮助。

相关推荐
张同学的IT技术日记25 分钟前
重构 MVC:让经典架构完美适配复杂智能系统的后端业务逻辑层(内附框架示例代码)
c++·后端·重构·架构·mvc·软件开发·工程应用
GoodTime1 小时前
CodeBuddy IDE深度体验:全球首个产设研一体AI工程师的真实使用报告
前端·后端·架构
失散132 小时前
大型微服务项目:听书——11 Redisson分布式布隆过滤器+Redisson分布式锁改造专辑详情接口
分布式·缓存·微服务·架构·布隆过滤器
程序员瓜叔2 小时前
JAVA知识点(四):SpringBoot与分布式、微服务架构
java·spring boot·架构
大佐不会说日语~12 小时前
Redis高可用架构演进面试笔记
redis·面试·架构
德育处主任Pro14 小时前
亚马逊云科技实战架构:构建可扩展、高效率、无服务器应用
科技·架构·serverless
Bar_artist16 小时前
云渲染的算力困局与架构重构:一场正在发生的生产力革命
重构·架构
三桥君16 小时前
AI应用爆发式增长,如何设计一个真正支撑业务的AI系统架构?——解析AI系统架构设计核心要点
人工智能·架构
一休哥助手17 小时前
ChatGPT Agent架构深度解析:OpenAI如何构建统一智能体系统
人工智能·chatgpt·架构
数据智能老司机20 小时前
函数式事件驱动架构——交易系统(可观测性)
架构·scala·响应式设计