基於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\/"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章