返回部落格
Architecture12 min read

對端與頻道聊天架構

本文說明 PlainApp 離線優先聊天如何端對端運作:一則訊息如何從 UI 中的點選一路旅行到另一台裝置的對端傳輸層、群組頻道如何將訊息扇出給多名成員,以及系統如何在網路消失時保持韌性。配對(啟動兩台裝置的信任與金鑰交換)在另一篇《Pairing Flow》文章中說明。

目錄

高階架構 {#high-level-architecture}

PlainApp 聊天是無伺服器的。每台裝置執行一個內嵌的 Ktor HTTP 伺服器, 裝置之間透過區域網路、Wi-Fi Aware(NAN)或藍牙低功耗直接通訊。沒有中繼伺服器、沒有雲端收件匣、沒有以電話號碼為基礎的身分。裝置以自我產生的 clientId 識別,並透過在配對期間執行的 Ed25519 + ECDH 握手進行認證。

存在兩種對話:

類型常數說明
PEERChatTargetType.PEER兩台已配對裝置之間的 1 對 1 直接聊天。
CHANNELChatTargetType.CHANNEL由一台裝置擁有的多方群組聊天;成員彼此扇出訊息。

一個特殊的 "local" 目標是裝置自己的草稿區(給自己的備忘)—— 傳送到它對線路是無動作(no-op)。

元件圖

Diagram 1
1

架構刻意分層:

  1. UI / GraphQL 進入點 永不直接觸及傳輸層或資料庫。
  2. ChatManager 是一個外觀模式(façade)——每個呼叫端(UI、GraphQL 解析器、對端 接收器)都經過它。
  3. ChatSender 是一個分派器,根據 ChatTargetType 分支並 委派給對端或頻道傳送器。
  4. 傳輸層 是一個可插拔的策略鏈,具備斷路, 因此不穩定的 Wi-Fi Aware 連結永遠不會阻塞本可透過 BLE 傳送的訊息。

資料模型 {#data-model}

ChatTarget

最小的路由單位是一個 ChatTarget —— 一個 (toId, type) 配對,其中 type 為 PEER 或 CHANNEL。它公開一個 encodedToId(peer:<id> 或 channel:<id>),UI 將其作為穩定的路由鍵(例如 TempData.activeToId,讓接收器知道是否要發出通知)、 一個 isLocal() 檢查(toId == "local"),以及一個 parseId 伴生物件,從儲存的字串 重建目標。

資料表

所有持久化使用 Room。三個資料表與聊天相關:

資料表實體用途
chatsDChat每則訊息一筆記錄(文字/影像/檔案)。
chat_channelsDChatChannel每個群組頻道一筆記錄。
peersDPeer每台已知裝置一筆記錄(已配對或僅頻道)。

Diagram 2
2

有幾點值得注意:

  • 身分是 clientId,絕非 MAC。 Android 在每次連線時隨機化 BLE MAC, 因此資料庫使用穩定的 13 字元自我產生 ID。只有 8 位元組的 SHA-256 前綴(shortId)會透過 BLE 廣播以允許 探索。
  • status="channel" 的對端 是某個頻道的成員,但本裝置從未 直接與之配對。它們的 key 為空——它們使用 頻道金鑰 而非成對共享金鑰進行認證。
  • owner="me" 是一個哨兵值,讓剛安裝的裝置在 clientId 尚未 穩定前可作為擁有者;isOwnedByMe() 同時接受 "me" 與 TempData.clientId。

GraphQL API 介面 {#graphql-api-surface}

PlainApp 公開兩個 GraphQL 結構描述:

  1. Web GraphQL(addChatChannelSchema + addChatMessageSchema,位於 shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) —— 由本地 Ktor 伺服器提供給瀏覽器 UI 與 apitest/ 測試框架。以 ChaCha20 加密的權杖進行認證。
  2. Peer GraphQL(PeerGraphQLService.applyPeerSchema)—— 公開於 /peer_graphql,供其他裝置透過加密的對端傳輸層存取。 以 Ed25519 簽章 + ChaCha20 主體加密進行認證。

兩個結構描述共用相同的商業邏輯單例(ChannelManager、 ChatMessageReceiver,…),但公開不同的介面,因為信任 模型不同 GraphQL 信任本地 UI,而 Peer GraphQL 只信任經密碼學認證的對端。

Web GraphQL 介面(聊天)

查詢:chatChannels(列出所有頻道)、chatItems(id)(某個 目標的訊息——id 為 "local"、peer:<id> 或 channel:<id>),以及 latestChatItems(跨所有聊天的預覽)。

聊天變更:sendChatItem(toId, content)、deleteChatItem(id)、 deleteChatItems(query) 與 retryChatItem(id)。

頻道變更:createChatChannel(name)、updateChatChannel(id, name)、 deleteChatChannel(id)、leaveChatChannel(id)、addChatChannelMember(id, peerId)、removeChatChannelMember(id, peerId)、acceptChatChannelInvite(id) 與 declineChatChannelInvite(id)。

Peer GraphQL 介面(傳輸層)

公開於 /peer_graphql,以 Ed25519 簽章 + ChaCha20 主體加密進行認證。只有三個變更跨越傳輸邊界: createChatItem(content)(傳入的對端訊息)、channelSystemMessage(type, payload)(頻道生命週期事件,如 invite/leave),以及 startAware(一個 請求對端啟動其 Wi-Fi Aware 服務的提醒,讓更快的傳輸層 可接管)。

c-id HTTP 標頭帶有發送方的 clientId;c-cid 標頭 在請求為頻道範圍時帶有頻道 ID(讓接收端挑選 頻道金鑰 而非成對對端金鑰進行解密)。

對端聊天:傳送一則訊息 {#peer-chat-sending-a-message}

當使用者在對端對話中點選 Send 時,呼叫鏈為:

Diagram 3
3

每一跳強制的關鍵不變式:

  1. ChatManager.createChatItem 永遠先插入一筆記錄,然後才傳送。 這意味著 UI 立即看到「待處理」氣泡,且即使 尚未送達,訊息也能在應用程式崩潰後存活。
  2. PeerGraphQLClient.buildSignedRequest 建立一個 signature|timestamp|requestJson 形式的封套。簽章是對 "$timestamp$requestJson" 的 Ed25519 簽署,將時間戳綁定到主體,使其無法 以新鮮的時間戳重放。
  3. PeerTransportRouter.send 按順序 Lan → WifiAware → Ble 迭代傳輸層。每個傳輸層可拋出 TransportUnavailable 讓路由器嘗試下一個。
  4. 在接收端,PeerChatParser.decrypt 檢查時間戳 是否在 ±5 分鐘 內,並在 GraphQL 變更執行之前驗證 Ed25519 簽章。
  5. ChatMessageReceiver.receive 維護一個以 "$fromPeerId|$signature|$timestamp" 為鍵的 seenSignatures 集合,並在 重複時拋出 ReplayedMessageException —— 這至關重要,因為 傳輸層可能會將同一負載送達兩次(LAN + BLE)。

若 PeerChatSender.send 傳回非空錯誤字串,ChatSender 會呼叫 triggerPeerRediscovery(peerId),觸發定向、加密的 DISCOVER 廣播,讓對端可重新公告其當前 IP/連接埠。

對端聊天:接收一則訊息 {#peer-chat-receiving-a-message}

入境請求抵達本地 Ktor 伺服器的 /peer_graphql 路由, 由 PeerGraphQLService 處理:

Diagram 4
4

通知

emitNotificationIfNeeded 是最後一步。它在 TempData.activeToId == targetId(即使用者目前正在檢視 該對話)或 canShowNotifications() 為 false 時抑制通知。頻道 通知會以發送方的名稱作為前綴。

頻道聊天:領導者選舉與扇出 {#channel-chat-leader-election--fan-out}

頻道是多方的,但無伺服器。為避免每個成員將同一訊息扇出 N 次, 傳送端會選出單一領導者,其職責是 向所有已加入的成員廣播。

領導者選舉演算法(DChatChannel.electLeader)

  1. 篩選出目前上線的已加入成員(本地裝置 始終視為上線)。
  2. 若擁有者在已上線的已加入成員中 → 擁有者為 領導者。
  3. 否則,領導者為已上線的已加入成員中 clientId 最小者 (確定性打破平手,無需協調)。
  4. 若無已上線的已加入成員,則傳回 null。

傳送流程

Diagram 5
5

為什麼要有領導者?

想像一個 5 成員頻道,每個人都向其他所有人廣播:一則 訊息會產生 20 次網路往返,且每個成員收到 4 份重複副本。 選出一個領導者後,只有該裝置負責 扇出——傳送端要嘛自己執行扇出(若它是領導者), 要嘛將單一副本中繼給領導者,再由領導者扇出。

若領導者離線,傳送端退回 Result.NoLeader, 觸發對端重新探索(以找出領導者的 IP),並清除 狀態讓使用者重試。

頻道金鑰路由

頻道訊息以頻道的 ChaCha20 金鑰加密,而非 成對對端金鑰。這正是讓某個只透過頻道(從未 1 對 1 配對)認識 其他成員的成員能接收訊息的關鍵—— 它的 peers 記錄有 status="channel" 且 key=""。傳送端將 c-cid HTTP 標頭設為頻道 ID;接收端查詢 ChannelCacher.getKeyBytes(channelId) 而非成對金鑰。

每位收件者的重試

每個 sendToMember 傳回一個 DMessageDeliveryResult。彙總的 DMessageStatusData 以聊天項目的 status_data JSON 持久化。 UI 顯示「已送達 Alice、Bob;Carol 失敗」,並讓使用者 對 Carol 特別點選 Retry —— ChatManager.sendToChannelMembers 對重試子集重新執行 sendToRecipients,並將新結果與 現有結果合併,僅替換重試的對端。

頻道系統訊息 {#channel-system-messages}

頻道控制平面訊息(invite、accept、decline、update、kick、leave) 透過對端 GraphQL channelSystemMessage 變更交換。它們是 以 type 字串分類的 JSON 負載:

類型方向已簽章?用途
channel_invite擁有者 → 受邀者是邀請對端;帶有頻道金鑰 + 成員。
channel_invite_accept受邀者 → 擁有者否接受;帶有接受者的公鑰。
channel_invite_decline受邀者 → 擁有者否拒絕;擁有者移除成員。
channel_update擁有者 → 所有成員是成員/名稱變更廣播。
channel_kick擁有者 → 被踢對端是目標踢除;頻道刪除時亦廣播。
channel_leave成員 → 擁有者否成員發起的離開通知。

已簽章負載格式

三個已簽章類型(invite、update、kick)使用標準的管道 分隔字串:"$channelId|$version|$action|$target",其中 action 為 invite、update、kick 之一,target 為受邀者/被踢對端 ID (廣播 kick 時為空)。

擁有者以 Ed25519 金鑰簽署此字串。接收端會在 甚至檢查簽章之前就拒絕任何 channel.owner != fromId 的訊息,並拒絕 version 為 ≤ 本地版本的 ChannelUpdate 負載(針對亂序送達的過期版本守護)。

Diagram 6
6

延遲對端充實

ChannelInvite 與 ChannelUpdate 帶有 memberPeers: List<MemberPeerInfo> 清單——為每個成員提供的輕量對端資訊(id、name、publicKey、deviceType、ip、port)。接收端的 ensureChannelPeer 為任何先前未見過的成員 建立一筆 status="channel" 的 DPeer 記錄。這非常關鍵, 因為扇出路由需要每個成員的對端記錄才能傳送訊息。

頻道生命週期 {#channel-lifecycle}

Diagram 7
7

對端傳輸層(LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}

PeerTransportRouter 是一條具斷路的策略鏈。有序的 傳輸層清單為:

  1. LanTransport —— 首選。使用 OkHttp,具備 ChaCha20 加密 攔截器,透過 HTTPS。當 peer.ip 為空時完全跳過(跨 子網對端,尚未探索到)。
  2. WifiAwareTransport(僅 Android 13+)—— 使用 Wi-Fi Aware(NAN)資料 路徑。當對端的 awareRunning 旗標為 false 時(由 BLE 預熱掃描器重新整理)快速跳過。對端的 IPv6 透過自訂 DNS 解析, 將主機名稱 plain-aware-peer 對應到 link-local 位址。
  3. BleTransport —— 任何已配對對端的保證備援。透過 GATT 串流分塊 RPC。較慢但無需任何 IP 連線即可運作。

Diagram 8
8

為什麼是這個順序?

  • LAN 最快(單一 HTTPS 往返,約 10 ms 逾時)。
  • Wi-Fi Aware 中等(資料路徑設定約 5 秒,之後約 10 ms 往返), 且跨子網運作(例如一台裝置在訪客 Wi-Fi,另一台在 IoT Wi-Fi)。當對端的 Aware 服務未運行時調整為快速跳過, 避免 10 秒的 buildLink 逾時。
  • BLE 最慢,但無需任何 IP 連線即可運作——即使 沒有 Wi-Fi,訊息仍能送達。作為已配對對端的保證 備援。

斷路器確保不穩定的傳輸層(尤其是網路動盪期間的 Wi-Fi Aware) 在 2 次失敗後跳過 30 秒,因此備援 會快速發生,而非等待重複的 10 秒逾時。

Wi-Fi Aware 握手

AwareSession 在開啟資料路徑之前進行兩次訊息握手:

  • MSG_HELLO(訂閱者 → 發布者):「我看見你了,這是我的對端 代碼。」
  • MSG_READY(發布者 → 訂閱者):「我已註冊我的網路 規範器,你現在可以 requestNetwork 了。」

這在 Android 框架約 500 ms 的視窗內同步雙方的 connectivityManager.requestNetwork(...) 呼叫。訂閱者 是 clientId 較小的一方(確定性的角色切分——雙方無需協調即可 同意),並擁有重試迴圈。

對端狀態與上線狀態 {#peer-status--presence}

上線狀態透過長存 WebSocket 連線追蹤。每一對中 只有一方開啟 socket——由確定性規則 TempData.clientId < peer.id 決定。另一方在 /peer_status 接受入境連線。

Diagram 9
9

PeerCacher.onlineMap 是上線狀態的真實來源。它以 onlinePeerIds: StateFlow<Set<String>> 公開,由頻道 領導者選舉(electLeader(onlinePeerIds, myId))消費。

快取層 {#caching-layer}

兩個快取在記憶體中映射資料表,並公開 Compose 直接收集的 StateFlow:

Diagram 10
10

為什麼使用 copy-on-write?

Kotlin 的 MutableStateFlow.distinctUntilChanged 使用結構相等性。若 我們就地修改 DPeer,衍生的 pairedPeers 清單在 前後會包含相同的 DPeer 參考,而 distinctUntilChanged 會看不到差異並抑制發射。透過先複製實體, 修改副本,並以新的 PeerRuntime/ChannelRuntime 替換對應項目,衍生清單會得到新的「新參考清單」, 而 flow 會觸發。

檔案下載 {#file-downloads}

入境檔案/影像訊息由有界的工作者集區自動下載。 每次下載串流透過任何可用的傳輸層 (PeerTransportRouter.downloadFile),寫入暫存檔,然後匯入 應用程式的媒體櫃並修補聊天項目的 uri 欄位。

Diagram 11
11

與傳輸層無關的串流

DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable 抽象讓 LAN 與 Wi-Fi Aware 串流即時 HTTP 主體,而 BLE 則透過相同的 ByteReadChannel 串流分塊 RPC(透過 GET /fs?id=…&offset=…&length=… 取得 16 KiB 分塊)。 onClose 回呼讓 BLE 在消費者提前關閉回應 (例如暫停時)時取消其背景下載協程。

設計模式回顧 {#design-patterns-recap}

模式所在位置原因
外觀模式ChatManager單一進入點;呼叫端永不直接觸及資料庫/傳輸層。
策略 + 職責鏈PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport可插拔的傳輸層,以 TransportUnavailable 作為 fall-through 訊號。
斷路器PeerCircuitBreaker2 次失敗 / 30 秒開啟(對端, 傳輸層)這一腿,讓 Wi-Fi Aware 不會阻塞備援。
狀態機PeerStatusManager.PeerState、AwarePeerLink.LinkStatesocket 生命週期與 NDP 連結生命週期的明確轉換。
生產者/消費者 + 集區DownloadQueue(3 個工作者,Channel.BUFFERED)檔案下載的有界並行。
觀察者 / 反應式處處可見的 StateFlowCompose 直接收集;無需手動重新整理。
重放保護ChatMessageReceiver.seenSignatures、PeerChatParser.MAX_TIMESTAMP_DIFF_MS捨棄 LAN+BLE 雙重送達的重複;拒絕視窗外時間戳。
指數退避PeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) —— 上限 64 秒。
Copy-on-WritePeerCacher.mutatePeer、ChannelCacher.mutateChannel強制 StateFlow.distinctUntilChanged 在每次變更時觸發。
已簽章封套PeerGraphQLClient.buildSignedRequestsignature|timestamp|body —— 將時間戳綁定到主體以防止重放。
確定性角色切分TempData.clientId < peer.id決定 WebSocket 用戶端 vs 伺服器,以及 Wi-Fi Aware 訂閱者 vs 發布者。
延遲充實invite/update 時的 ensureChannelPeer為未見過的頻道成員建立 peers 記錄,讓扇出路由運作。
加密身分LANDiscoverManager.discoverSpecificDevice定向 DISCOVER 以對端金鑰加密目標 ID——只有目標能識別它。

延伸閱讀

  • Pairing Flow —— 兩台裝置如何建立信任並 交換本文中每個傳輸層使用的共享 ChaCha20 金鑰。
  • apitest/groups/chat-messages.sh 與 apitest/groups/chat-channels.sh ——可執行的測試計畫,端對端演練每個 GraphQL 變更。