基于Pulsar的事件驱动铁路网

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这张照片拍摄于瑞士的 "},{"type":"link","attrs":{"href":"https:\/\/en.wikipedia.org\/wiki\/Landwasser_Viaduct","title":"","type":null},"content":[{"type":"text","text":"Landwasser 高架桥"}]},{"type":"text","text":"。瑞士以其"},{"type":"link","attrs":{"href":"https:\/\/en.wikipedia.org\/wiki\/Rail_transport_in_Switzerland","title":"","type":null},"content":[{"type":"text","text":"铁路网络"}]},{"type":"text","text":"闻名于世,根据维基百科,瑞士拥有世界上最密集的铁路网。本文带你一起模拟瑞士的铁路网络。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/a4\/1f\/a4fa859b75df334d4f1967b5cbb7ee1f.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们会用到 "},{"type":"link","attrs":{"href":"https:\/\/pulsar.apache.org\/","title":"","type":null},"content":[{"type":"text","text":"Apache Pulsar"}]},{"type":"text","text":" 和 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/cr-org\/neutron","title":"","type":null},"content":[{"type":"text","text":"Neutron"}]},{"type":"text","text":"。Apache Pulsar 是开源分布式 pub-sub 消息系统,最初由 Yahoo! 开发,目前属于 Apache 软件基金会。数据架构师、数据分析师、程序员等经常对比 Apache Pulsar 和 Apache Kafka,目前已有许多对比二者优劣势的文章。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Neutron 是基于 "},{"type":"link","attrs":{"href":"https:\/\/fs2.io\/","title":"","type":null},"content":[{"type":"text","text":"FS2"}]},{"type":"text","text":" 流媒体库的 Pulsar 客户端。作为一款成熟的产品, Neutron 已经用于 "},{"type":"link","attrs":{"href":"https:\/\/about.chatroulette.com\/","title":"","type":null},"content":[{"type":"text","text":"Chatroulette"}]},{"type":"text","text":" 的生产,但 Neutron 的开发并未停止。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"拥有一套玩具铁路网一直是我童年时的梦想。现在,我可以自己动手搭建一套虚拟铁路网了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来,我们将一起开发一个事件驱动的铁路网络模拟器。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"思路"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们要搭建一套包含三个车站的铁路网:日内瓦、伯尔尼和苏黎世。其中日内瓦和苏黎世均与伯尔尼相连,但日内瓦与苏黎世不相连。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/d4\/06\/d4d294c5cc48f1f910762de29dab2d06.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每个站点为一个节点,相连节点通过消息 broker——Apache Pulsar 通信。节点消费其相连节点发布的事件。consumer过滤传入事件后消费与特定城市相关的事件。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"有两种方式可以控制模拟器的行为,一是添加可用于人工干预的 HTTP 端点。用户通过发送 HTTP 请求向系统中添加新列车。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们不持久保存任何数据,无需使用数据库或缓存系统,将所有数据保存在内存中。因此我们可以使用类似于 "},{"type":"link","attrs":{"href":"https:\/\/zio.dev\/docs\/datatypes\/datatypes_ref","title":"","type":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"Ref"}]}]},{"type":"text","text":" 的高级并发机制。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Apache Pulsar 是系统的核心,负责节点间通信。一旦状态发生改变,系统应该发布描述这一动作的新事件。也就是说,每个事件都应该有一个"},{"type":"codeinline","content":[{"type":"text","text":"时间戳"}]},{"type":"text","text":"。此外,每个事件应有一个"},{"type":"codeinline","content":[{"type":"text","text":"列车 ID"}]},{"type":"text","text":",代表特定列车的标识号码。初始时,有两个事件:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"出发(Departed)"}]},{"type":"text","text":"事件——列车出发时发布出发事件。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"到达(Arrived)"}]},{"type":"text","text":"事件——列车到达时发布到达事件。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这两个事件包含关于列车的基本信息:列车标识号码、出发城市、目的地城市、预计到达时间和事件时间戳。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每个城市都消费来自相连城市的事件。例如,苏黎世消费来自伯尔尼的事件,但不关注来自日内瓦的事件。苏黎世的事件 consumer 应确保能够捕获到由伯尔尼"},{"type":"codeinline","content":[{"type":"text","text":"出发"}]},{"type":"text","text":"并且苏黎世为目的地的事件。每个城市对应一个 topic,3 个城市就对应 3 个 topic。需要优化时,可以把通用的 \"城市 topic \"分成几个更具体的 topic。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"业务逻辑通过 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/cr-org\/neutron","title":"","type":null},"content":[{"type":"text","text":"Neutron"}]},{"type":"text","text":" 连接到 Apache Pulsar。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每个被消费的 topic 都会转换为 "},{"type":"codeinline","content":[{"type":"text","text":"fs2"}]},{"type":"text","text":" 流,如果你不了解如何处理 fs2 流,可以参考 "},{"type":"link","attrs":{"href":"https:\/\/fs2.io\/#\/guide","title":"","type":null},"content":[{"type":"text","text":"fs2 指南"}]},{"type":"text","text":",本文代码不会涉及到这部分内容。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我基于 "},{"type":"codeinline","content":[{"type":"text","text":"cats"}]},{"type":"text","text":" 库的 Tagless Final 技术编写了这一应用程序,并以 "},{"type":"codeinline","content":[{"type":"text","text":"ZIO"}]},{"type":"text","text":" 作为运行时 "},{"type":"link","attrs":{"href":"https:\/\/typelevel.org\/cats-effect\/","title":"","type":null},"content":[{"type":"text","text":"effect"}]},{"type":"text","text":"。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"Pulsar 简介"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Apache Pulsar 是分布式消息和流平台,可用于搭建高扩展性系统。系统内部通过消息进行通信,topic 数量可达数百万个。从开发者的角度来讲,Apache Pulsar 可以看作是一个黑匣子,但我建议多了解它的底层工作原理。为了更好地理解本文中的操作,我先介绍几个概念:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"topic"}]},{"type":"text","text":"——信息传输的媒介。topic 分为两种:"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"持久化 topic"}]},{"type":"text","text":"——持久存储消息数据。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"非持久化 topic"}]},{"type":"text","text":"——不持久存储消息数据,将消息保存在内存中。如果 Pulsar broker 宕机,所有传输中的消息都会丢失。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"producer"}]},{"type":"text","text":"——与 topic 相连,用于发布消息。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"consumer"}]},{"type":"text","text":"——通过订阅与 topic 相连,用于接收消息。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"订阅"}]},{"type":"text","text":"——制定向 consumer 发布消息的配置规则。Pulsar 支持四种订阅类型:"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"独占"}]},{"type":"text","text":"——单一 consumer,如有多个 consumer 同时订阅则会引发错误;"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"故障转移"}]},{"type":"text","text":"——多个 consumer,但只有一个 consumer 能收到消息;"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"共享"}]},{"type":"text","text":"——多个 consumer,以轮询方式接收消息;"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":1,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"Key_Shared"}]},{"type":"text","text":"——多个 consumer,按 key 分发消息(一个 consumer 对应一个 key)。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"消息系统发布事件后,由 "},{"type":"codeinline","content":[{"type":"text","text":"producer"}]},{"type":"text","text":" 处理这些事件并发布到 "},{"type":"codeinline","content":[{"type":"text","text":"topic"}]},{"type":"text","text":" 上,另一个系统里的 "},{"type":"codeinline","content":[{"type":"text","text":"consumer"}]},{"type":"text","text":" 通过"},{"type":"codeinline","content":[{"type":"text","text":"订阅"}]},{"type":"text","text":"连接到这个 topic。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"点击"},{"type":"link","attrs":{"href":"http:\/\/pulsar.apache.org\/docs\/en\/standalone\/","title":"","type":null},"content":[{"type":"text","text":"这里"}]},{"type":"text","text":"了解更多关于 Apache Pulsar 的信息。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"业务逻辑"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上文提到铁路网中会发生的两个事件——列车的出发与到达。定义这两个事件的代码如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"case class Departed(id: EventId, trainId: TrainId, from: From, to: To, expected: Expected, created: Timestamp) extends Event\ncase class Arrived(id: EventId, trainId: TrainId, from: From, to: To, expected: Expected, created: Timestamp) extends Event\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事件需包含系统中已发生动作的基本信息:唯一的事件 id、列车 id、出发城市、目的地城市、预计到达时间和实际事件时间戳。我们以后还可以添加站台号等信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"为确保本文简单易懂,我们对本系统工作所需的数据量加以限制。为了便于区分事件中的字段(比如目的地和出发城市),所有字段都使用强类型。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"由于没有可以自动检测火车到达或出发的系统,我们需要手动控制铁路网。假设有一名火车调度员在通过按钮和仪表盘来控制铁路网络,我们虽然没有炫酷的 UI,但可以搭建 API,API 的核心是两个简单的命令,用于触发车站的业务逻辑:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"case class Arrival(trainId: TrainId, time: Actual)\ncase class Departure(id: TrainId, to: To, time: Expected, actual: Actual)\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"列车出发"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"让我们从创建火车出发开始吧!这个命令比较简单,可以通过 cURL 发送:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"curl --request POST \\\n --url http:\/\/localhost:8081\/departure \\\n --header 'content-type: application\/json' \\\n --data '{\n \"id\": \"153\",\n \"to\": \"Zurich\",\n \"time\": \"2020-12-03T10:15:30.00Z\",\n \"actual\": \"2020-12-03T10:15:30.00Z\"\n}'\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上述命令假设伯尔尼服务节点在 8081 端口运行,每个节点都运行 HTTP 服务器,也都能够处理这一请求。我们使用 "},{"type":"codeinline","content":[{"type":"text","text":"Http4s"}]},{"type":"text","text":" 库作为 HTTP 服务器,第一个线路定义如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"case req @ POST -> Root \/ \"departure\" =>\n req\n .asJsonDecode[Departure]\n .flatMap(departures.register)\n .flatMap(_.fold(handleDepartureErrors, _ => Ok()))\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"调用 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 服务仅需注册(register)一列出发的火车:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"trait Departures[F[_]] {\n def register(departure: Departure): F[Either[DepartureError, Departed]]\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Scala 支持多种验证数据的方式,我选择最直接的一种——返回带有自定义错误的 "},{"type":"codeinline","content":[{"type":"text","text":"Either"}]},{"type":"text","text":"。如果火车注册成功,则返回 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件;否则,返回错误。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"为确保本文简单易懂,我们会在 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 服务执行过程中调用消息 producer。首先需执行 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 服务,即在 Departures 伴生对象中创建 "},{"type":"codeinline","content":[{"type":"text","text":"make"}]},{"type":"text","text":" 函数 :"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"object Departures {\n def make[F[_]: Monad: UUIDGen: Logger](\n city: City,\n connectedTo: List[City],\n producer: Producer[F, Event]\n ): Departures[F] = new Departures[F] {\n def register(departure: Departure): F[Either[DepartureError, Departed]] = ???\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"为实现 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 接口,我们要给 effect F 设置边界:需有 "},{"type":"codeinline","content":[{"type":"text","text":"UUIDGen"}]},{"type":"text","text":" 和 "},{"type":"codeinline","content":[{"type":"text","text":"Logger"}]},{"type":"text","text":" 实例。我已经在程序中创建了虚拟的 "},{"type":"codeinline","content":[{"type":"text","text":"UUIDGen"}]},{"type":"text","text":" 和 "},{"type":"codeinline","content":[{"type":"text","text":"Logger"}]},{"type":"text","text":" 接口。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"F"}]},{"type":"text","text":" 还应有 "},{"type":"codeinline","content":[{"type":"text","text":"Monad"}]},{"type":"text","text":" 实例,用于连接函数调用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先执行验证逻辑,检查"},{"type":"codeinline","content":[{"type":"text","text":"出发"}]},{"type":"text","text":"事件是否有效。我们只需检查目的地城市是否在相连城市列表中:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def validated(departure: Departure)(f: F[Departed]): F[Either[DepartureError, Departed]] = {\n val destination = departure.to.city\n\n connectedTo.find(_ === destination) match {\n case None =>\n val e: DepartureError = DepartureError.UnexpectedDestination(destination)\n F.error(s\"Tried to departure to an unexpected destination: $departure\")\n .as(e.asLeft)\n case _ =>\n f.map(_.asRight)\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果目的地城市不在列表中,则生成错误信息日志并返回错误。否则创建 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件并将其作为结果返回。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来需要实现"},{"type":"codeinline","content":[{"type":"text","text":"注册"}]},{"type":"text","text":"功能,示例代码如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def register(departure: Departure): F[Either[DepartureError, Departed]] =\n validated(departure) {\n F.newEventId\n .map {\n Departed(\n _,\n departure.id,\n From(city),\n departure.to,\n departure.time,\n departure.actual.toTimestamp\n )\n }\n .flatTap(producer.send_)\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"先验证目的地城市,若有效,生成一个 "},{"type":"codeinline","content":[{"type":"text","text":"newEventId"}]},{"type":"text","text":",用于创建新的 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件,该事件将通过传递给 "},{"type":"codeinline","content":[{"type":"text","text":"make"}]},{"type":"text","text":" 函数的 "},{"type":"codeinline","content":[{"type":"text","text":"producer"}]},{"type":"text","text":" 发布到 Pulsar 的"},{"type":"codeinline","content":[{"type":"text","text":"城市"}]},{"type":"text","text":" topic。点击"},{"type":"link","attrs":{"href":"https:\/\/github.com\/psisoyev\/train-station\/blob\/ec3841784841ebc03c6d1cdc3347b04065e81d1c\/service\/src\/main\/scala\/com\/psisoyev\/train\/station\/departure\/Departures.scala#L13","title":"","type":null},"content":[{"type":"text","text":"这里"}]},{"type":"text","text":"查看 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 事件的最终版本。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"预计出发列车"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们已经了解了如何生成列车。如果一列火车从苏黎世开往伯尔尼,那么伯尔尼会收到相应通知。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"伯尔尼收听来自苏黎世的事件,一旦有把伯尔尼设为目的地的 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件,就将其加入预期列车表中。现在我们只关注业务逻辑,后文会再讨论消息消费。为预期"},{"type":"codeinline","content":[{"type":"text","text":"出发"}]},{"type":"text","text":"事件定义 "},{"type":"codeinline","content":[{"type":"text","text":"DepartureTracker"}]},{"type":"text","text":",示例代码如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"trait DepartureTracker[F[_]] {\n def save(e: Departed): F[Unit]\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"该服务会成为 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件流中的 sink,所以我们不关注返回类型,也不希望出现任何验证错误。和上文 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 服务一样,先创建伴生对象,定义 "},{"type":"codeinline","content":[{"type":"text","text":"make"}]},{"type":"text","text":" 函数:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def make[F[_]: Applicative: Logger](\n city: City,\n expectedTrains: ExpectedTrains[F]\n ): DepartureTracker[F] = new DepartureTracker[F] {\n def save(e: Departed): F[Unit] =\n val updateExpectedTrains =\n expectedTrains.update(e.trainId, ExpectedTrain(e.from, e.expected)) *>\n F.info(s\"$city is expecting ${e.trainId} from ${e.from} at ${e.expected}\")\n\n\n updateExpectedTrains.whenA(e.to.city === city)\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们依赖于 "},{"type":"codeinline","content":[{"type":"text","text":"ExpectedTrains"}]},{"type":"text","text":" 服务。ExpectedTrain 是存储进站列车的服务,我们很快就能实现该服务。我们实现了 "},{"type":"codeinline","content":[{"type":"text","text":"save"}]},{"type":"text","text":" 函数,只有进站列车的目的地城市与预期城市相符时,该函数才会执行。例如,日内瓦和苏黎世均消费来自伯尔尼的事件。伯尔尼发出 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 事件时,其中一个城市会忽略此消息,而另一个城市,即目的地城市,会更新预期列车表,创建日志消息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"预期列车存储中至少包含以下函数:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"trait ExpectedTrains[F[_]] {\n def get(id: TrainId): F[Option[ExpectedTrain]]\n def remove(id: TrainId): F[Unit]\n def update(id: TrainId, expectedTrain: ExpectedTrain): F[Unit]\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"即使我们尝试删除不存在于系统中的列车,也不会操作失败。在某些业务情况下可能会出现系统故障的错误,但在这种特殊情况下,我们会忽略这一错误。整个测试过程中,数据一直存储在内存中,不持久保存。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def make[F[_]: Functor](\n ref: Ref[F, Map[TrainId, ExpectedTrain]]\n ): ExpectedTrains[F] = new ExpectedTrains[F] {\n def get(id: TrainId): F[Option[ExpectedTrain]] = \n ref.get.map(_.get(id))\n def remove(id: TrainId): F[Unit] = \n ref.update(_.removed(id))\n def update(id: TrainId, train: ExpectedTrain): F[Unit] = \n ref.update(_.updated(id, train))\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们在这一应用程序中使用 "},{"type":"link","attrs":{"href":"https:\/\/zio.dev\/docs\/datatypes\/datatypes_ref","title":"","type":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"Ref"}]}]},{"type":"text","text":" 作为高级并发机制。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"列车到达"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"业务逻辑三部曲的最后一部分是列车到达。与列车出发类似,先创建一个 HTTP 端点,可以用简单的 cURL POST 请求来调用:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"curl --request POST \\\n --url http:\/\/localhost:8081\/arrival \\\n --header 'Content-Type: application\/json' \\\n --data '{\n \"trainId\": \"123\",\n \"time\": \"2020-12-03T10:15:30.00Z\"\n}'\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"再由 Http4s 路线处理请求:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"case req @ POST -> Root \/ \"arrival\" =>\n req\n .asJsonDecode[Arrival]\n .flatMap(arrivals.register)\n .flatMap(_.fold(handleArrivalErrors, _ => Ok()))\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"Arrivals"}]},{"type":"text","text":" 服务类似于上文介绍的 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 服务。"},{"type":"codeinline","content":[{"type":"text","text":"Arrivals"}]},{"type":"text","text":" 服务中也只有一个方法,即 "},{"type":"codeinline","content":[{"type":"text","text":"register"}]},{"type":"text","text":" 方法:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"trait Arrivals[F[_]] {\n def register(arrival: Arrival): F[Either[ArrivalError, Arrived]]\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然后需要验证请求,示例代码如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def validated(arrival: Arrival)(f: ExpectedTrain => F[Arrived]): F[Either[ArrivalError, Arrived]] =\n expectedTrains\n .get(arrival.trainId)\n .flatMap {\n case None =>\n val e: ArrivalError = ArrivalError.UnexpectedTrain(arrival.trainId)\n F.error(s\"Tried to create arrival of an unexpected train: $arrival\")\n .as(e.asLeft)\n case Some(train) =>\n f(train).map(_.asRight)\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"检查到达的列车是否与预期相符,若相符,则创建 "},{"type":"codeinline","content":[{"type":"text","text":"Arrived"}]},{"type":"text","text":" 事件;否则,生成错误日志。列车到达事件中 "},{"type":"codeinline","content":[{"type":"text","text":"register"}]},{"type":"text","text":" 方法的实现中与之前 register 方法的实现类似:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def register(arrival: Arrival): F[Either[ArrivalError, Arrived]] =\n validated(arrival) { train =>\n F.newEventId\n .map {\n Arrived(\n _,\n arrival.trainId,\n train.from,\n To(city),\n train.time,\n arrival.time.toTimestamp\n )\n }\n .flatTap(a => expectedTrains.remove(a.trainId))\n .flatTap(producer.send_)\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"与 "},{"type":"codeinline","content":[{"type":"text","text":"Departures"}]},{"type":"text","text":" 相比,到达事件不仅发布了新事件,还把到达列车从预计出发列车列表中删除。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以上为全部业务逻辑,代码已经通过单元测试(使用 "},{"type":"link","attrs":{"href":"https:\/\/scala.monster\/zio-test\/","title":"","type":null},"content":[{"type":"text","text":"ZIO Test"}]},{"type":"text","text":" 实现),可参考 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/psisoyev\/train-station\/tree\/ec3841784841ebc03c6d1cdc3347b04065e81d1c\/service\/src\/test\/scala\/com\/psisoyev\/train\/station","title":"","type":null},"content":[{"type":"text","text":"GitHub 文件"}]},{"type":"text","text":" 。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"消息消费"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这一节主要讲消息消费,也会把所有逻辑服务连在一起。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"创建资源"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先创建所需资源。一个城市节点包含四个组件:配置、事件 producer、事件 consumer,以及存储 "},{"type":"codeinline","content":[{"type":"text","text":"ExpectedTrains"}]},{"type":"text","text":" 的 "},{"type":"codeinline","content":[{"type":"text","text":"Ref"}]},{"type":"text","text":"。我们可以把这四种资源在一个 case class 中组合起来,在 "},{"type":"codeinline","content":[{"type":"text","text":"Main"}]},{"type":"text","text":" 类外创建:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"final case class Resources[F[_], E](\n config: Config,\n producer: Producer[F, E],\n consumers: List[Consumer[F, E]],\n trainRef: Ref[F, Map[TrainId, ExpectedTrain]]\n)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们使用 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/vlovgr\/ciris","title":"","type":null},"content":[{"type":"text","text":"ciris"}]},{"type":"text","text":" 库从环境变量中读取 "},{"type":"codeinline","content":[{"type":"text","text":"Config"}]},{"type":"text","text":"。关于配置,可以参考 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/psisoyev\/train-station\/blob\/ec3841784841ebc03c6d1cdc3347b04065e81d1c\/server\/src\/main\/scala\/com\/psisoyev\/train\/station\/Config.scala#L13","title":"","type":null},"content":[{"type":"text","text":"GitHub 文件"}]},{"type":"text","text":"。我们使用 "},{"type":"link","attrs":{"href":"https:\/\/about.chatroulette.com\/","title":"","type":null},"content":[{"type":"text","text":"Chatroulette"}]},{"type":"text","text":" 开发的 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/cr-org\/neutron\/","title":"","type":null},"content":[{"type":"text","text":"Neutron"}]},{"type":"text","text":" 库来创建 producer 和 consumer。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先,创建一个 "},{"type":"codeinline","content":[{"type":"text","text":"Pulsar"}]},{"type":"text","text":" 对象实例,用于与 Apache Pulsar 集群建立连接:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"Pulsar.create[F](config.pulsar.serviceUrl)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以上操作仅需 "},{"type":"codeinline","content":[{"type":"text","text":"serviceUrl"}]},{"type":"text","text":",我们会得到 "},{"type":"codeinline","content":[{"type":"text","text":"Resource[F, PulsarClient]"}]},{"type":"text","text":",可以用来创建 producer 和 consumer。创建 producer 之前,应该先创建包含 "},{"type":"codeinline","content":[{"type":"text","text":"topic"}]},{"type":"text","text":" 配置的 topic 对象:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def topic(config: PulsarConfig, city: City) =\n Topic(\n Topic.Name(city.value.toLowerCase),\n config\n ).withType(Topic.Type.Persistent)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Topic 名就是城市名,而且是"},{"type":"codeinline","content":[{"type":"text","text":"持久化"}]},{"type":"text","text":" topic,这样,任何未确认的消息都不会丢失。另外,作为配置的一部分,我们传递了"},{"type":"codeinline","content":[{"type":"text","text":"命名空间"}]},{"type":"text","text":"和"},{"type":"codeinline","content":[{"type":"text","text":"租户"}]},{"type":"text","text":"。关于命名空间和租户的更多信息,可以查阅 "},{"type":"link","attrs":{"href":"https:\/\/pulsar.apache.org\/docs\/en\/next\/standalone\/","title":"","type":null},"content":[{"type":"text","text":"Pulsar 文档"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"创建 producer 操作只是简单的一行:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def producer(client: Pulsar.T, config: Config): Resource[F, Producer[F, E]] =\n Producer.create[F, E](client, topic(config.pulsar, config.city))\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"创建 producer 的方法有很多,我们选择最简单的一种,只需使用之前创建的 Pulsar 客户端和一个 topic。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"创建 consumer 所需操作稍多,因为还要创建"},{"type":"codeinline","content":[{"type":"text","text":"订阅"}]},{"type":"text","text":":"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"def consumer(client: PulsarClient, config: Config, city: City): Resource[F, Consumer[F, E]] = {\n val name = s\"${city.value}-${config.city.value}\"\n val subscription =\n Subscription\n .Builder\n .withName(Subscription.Name(name))\n .withType(Subscription.Type.Failover)\n .build\n\n Consumer.create[F, E](client, topic(config.pulsar, city), subscription)\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"创建订阅,设置订阅名称为相连的城市名称与火车经停城市名组合。默认使用 "},{"type":"codeinline","content":[{"type":"text","text":"Failover"}]},{"type":"text","text":" 订阅类型,并行运行 2 个实例(以防其中一个实例宕机)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"加上所需 "},{"type":"codeinline","content":[{"type":"text","text":"Ref"}]},{"type":"text","text":",我们终于可以创建 "},{"type":"codeinline","content":[{"type":"text","text":"Resources"}]},{"type":"text","text":" 了:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"for {\n config ???\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"依然是 4 个参数。先初始化服务,为 HTTP 服务器创建"},{"type":"codeinline","content":[{"type":"text","text":"路线"}]},{"type":"text","text":":"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val expectedTrains = ExpectedTrains.make[Task](trainRef)\nval arrivals = Arrivals.make[Task](config.city, producer, expectedTrains)\nval departures = Departures.make[Task](config.city, config.connectedTo, producer)\nval departureTracker = DepartureTracker.make[Task](config.city, expectedTrains)\n\nval routes = new StationRoutes[F](arrivals, departures).routes.orNotFound\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"创建 HTTP 服务器:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val httpServer = Task.concurrentEffectWith { implicit CE =>\n BlazeServerBuilder[Task](ec)\n .bindHttp(config.httpPort.value, \"0.0.0.0\")\n .withHttpApp(routes)\n .serve\n .compile\n .drain\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你很了解 Http4s,那么以上操作应该不难理解。若不了解,点击"},{"type":"link","attrs":{"href":"https:\/\/http4s.org\/","title":"","type":null},"content":[{"type":"text","text":"这里"}]},{"type":"text","text":"查看相关文档。开始消费传入消息,并创建一个流:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"val departureListener =\n Stream\n .emits(consumers)\n .map(_.autoSubscribe)\n .parJoinUnbounded\n .collect { case e: Departed => e }\n .evalMap(departureTracker.save)\n .compile\n .drain\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"简而言之,我们使用 FS2 库创建了事件流。首先,创建 consumer 流,对每个 consumer 调用 "},{"type":"codeinline","content":[{"type":"text","text":"autoSubscribe"}]},{"type":"text","text":" 方法,用于订阅 topic,再通过 "},{"type":"codeinline","content":[{"type":"text","text":"parJoinUnbounded"}]},{"type":"text","text":" 把所有流合在一起,然后,用 "},{"type":"codeinline","content":[{"type":"text","text":"collect"}]},{"type":"text","text":" 方法删除 "},{"type":"codeinline","content":[{"type":"text","text":"Departed"}]},{"type":"text","text":" 以外的所有消息。最后,在之前实现的 "},{"type":"codeinline","content":[{"type":"text","text":"departureTracker"}]},{"type":"text","text":" 上调用 "},{"type":"codeinline","content":[{"type":"text","text":"save"}]},{"type":"text","text":" 方法,编译并排出流。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"现在有两个最终流:HTTP 服务器和 Pulsar 的传入消息。此时我们已经处理完了所有消息,只需运行流,即并行压缩并丢弃结果:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"departureListener\n .zipPar(httpServer)\n .unit\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"组成 "},{"type":"codeinline","content":[{"type":"text","text":"Main"}]},{"type":"text","text":" 类的代码块都比较简单,读取和维护也相对容易。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"结语"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文给出了事件驱动系统示例,按步骤梳理了业务逻辑,模拟了瑞士铁路网。你可以在本文示例代码的基础上进行修改和拓展。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文中使用到了 Apache Pulsar 的部分功能,但 Pulsar 不止于此,它操作简易,功能强大。我们搭建了一个简单的分布式系统,由几个节点组成,这些节点在 Apache Pulsar 上使用消息传递进行通信。本应用程序使用基于 "},{"type":"codeinline","content":[{"type":"text","text":"cats"}]},{"type":"text","text":" 库的 Tagless Final 技术编写,其中 "},{"type":"codeinline","content":[{"type":"text","text":"ZIO Task"}]},{"type":"text","text":" 为主要的 effect 类型。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此外,我们还尝试了 "},{"type":"link","attrs":{"href":"https:\/\/github.com\/cr-org\/neutron\/","title":"","type":null},"content":[{"type":"text","text":"Neutron"}]},{"type":"text","text":",虽然 Neutron 已用于 "},{"type":"link","attrs":{"href":"https:\/\/about.chatroulette.com\/","title":"","type":null},"content":[{"type":"text","text":"Chatroulette"}]},{"type":"text","text":" 生产,但仍在开发中。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"点击"},{"type":"link","attrs":{"href":"https:\/\/github.com\/psisoyev\/train-station\/","title":"","type":null},"content":[{"type":"text","text":"这里"}]},{"type":"text","text":"查看本程序的最终版本,操作指南可见 readme 部分。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"原文链接"},{"type":"text","text":":"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"https:\/\/scala.monster\/train-station\/"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章