返回博客
Architecture12 min read

对端与频道聊天架构

本文端到端地介绍了 PlainApp 离线优先的聊天机制:一条消息如何从 UI 中的点击出发,经由对端传输层到达另一台设备;群组频道如何将消息扇出给众多成员;以及当网络消失时系统如何保持韧性。配对(用于引导两台设备完成信任与密钥交换的过程)在单独的《Pairing Flow》文章中介绍。

目录

总体架构

PlainApp 聊天是无服务器的。每台设备都运行一个内嵌的 Ktor HTTP 服务器,设备之间通过局域网、Wi-Fi Aware(NAN)或蓝牙低功耗(Bluetooth Low Energy)直接通信。没有中继服务器,没有云端收件箱,也没有基于手机号的身份。设备通过自生成的 clientId 标识,并在配对期间执行 Ed25519 + ECDH 握手来完成认证。

存在两种会话类型:

类型常量描述
PEERChatTargetType.PEER两台已配对设备之间的 1 对 1 直接聊天。
CHANNELChatTargetType.CHANNEL由一台设备拥有的多方群聊;成员之间相互扇出消息。

一个特殊的 "local" 目标是设备自身的草稿本(给自己的备忘)——向其发送在网络上是一个 no-op。

组件映射

Diagram 1
1

架构有意设计为分层的:

  1. UI / GraphQL 入口永远不会直接接触传输层或数据库。
  2. ChatManager 是一个 façade——每个调用方(UI、GraphQL resolver、对端接收器)都通过它。
  3. ChatSender 是一个分发器,根据 ChatTargetType 分支并委托给对端或频道发送器。
  4. 传输层是一个可插拔的策略链,带有熔断机制,因此不稳定的 Wi-Fi Aware 链路永远不会阻塞本可以通过 BLE 发送的消息。

数据模型

ChatTarget

路由的最小单位是 ChatTarget——一个 (toId, type) 对,其中 typePEERCHANNEL。它暴露一个 encodedToIdpeer:<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" 的 peers 是当前设备从未直接配对过的频道成员。它们的 key 为空——它们使用频道密钥而非成对共享密钥进行认证。
  • owner="me" 是一个哨兵值,允许刚安装的设备在其 clientId 尚未稳定之前充当 owner;isOwnedByMe() 同时接受 "me"TempData.clientId

GraphQL API 概览

PlainApp 暴露两套 GraphQL schema:

  1. Web GraphQLaddChatChannelSchema + addChatMessageSchema,位于 shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) ——由本地 Ktor 服务器提供给浏览器 UI 和 apitest/ 测试套件。通过 ChaCha20 加密的 token 进行认证。
  2. Peer GraphQLPeerGraphQLService.applyPeerSchema)——在 /peer_graphql 暴露给其他设备,通过加密的对端传输层访问。通过 Ed25519 签名 + ChaCha20 body 加密进行认证。

两套 schema 共享相同的业务逻辑单例(ChannelManagerChatMessageReceiver 等),但暴露不同的接口,因为信任模型不同:Web GraphQL 信任本地 UI,而 Peer GraphQL 只信任经过加密认证的对端。

Web GraphQL 接口(聊天)

Queries:chatChannels(列出所有频道)、chatItems(id)(获取某个目标的消息——id 为 "local"、peer:<id>channel:<id>),以及 latestChatItems(跨所有聊天的预览)。

Chat mutations:sendChatItem(toId, content)deleteChatItem(id)deleteChatItems(query)retryChatItem(id)

Channel mutations: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 body 加密进行认证。只有三个 mutation 跨越传输边界:createChatItem(content)(一条入站对端消息)、channelSystemMessage(type, payload)(频道生命周期事件,如邀请/离开),以及 startAware(一种提醒,请求对端启动其 Wi-Fi Aware 服务,以便更快的传输层接管)。

c-id HTTP header 携带发送方的 clientIdc-cid header 在请求属于频道作用域时携带频道 id(以便接收方使用频道密钥而非成对 peer 密钥进行解密)。

对端聊天:发送消息

当用户在 peer 会话中点击 Send 时,调用链如下:

Diagram 3
3

每一跳强制执行的关键不变量:

  1. ChatManager.createChatItem 总是先插入一行,然后再发送。这意味着 UI 会立即看到一个 "pending" 气泡,并且即使投递尚未完成,消息也能在应用崩溃后幸存。
  2. PeerGraphQLClient.buildSignedRequest 构建一个形如 signature|timestamp|requestJson 的信封。签名是 Ed25519 对 "$timestamp$requestJson" 的签名,将时间戳与 body 绑定,使其无法用新的时间戳被重放。
  3. PeerTransportRouter.sendLan → WifiAware → Ble 的顺序遍历各传输层。每个传输层可以抛出 TransportUnavailable 以让路由器尝试下一个。
  4. 在接收侧,PeerChatParser.decrypt 会检查时间戳是否在 ±5 min 之内,并在 GraphQL mutation 执行之前验证 Ed25519 签名。
  5. ChatMessageReceiver.receive 维护一个 seenSignatures 集合,键为 "$fromPeerId|$signature|$timestamp",遇到重复时抛出 ReplayedMessageException——这一点至关重要,因为传输层可能会投递相同的 payload 两次(LAN + BLE)。

如果 PeerChatSender.send 返回非空的错误字符串,ChatSender 会调用 triggerPeerRediscovery(peerId),发起一次定向的加密 DISCOVER 广播,以便对端重新公告其当前的 IP/端口。

对端聊天:接收消息

入站请求落在本地 Ktor 服务器的 /peer_graphql 路由上,由 PeerGraphQLService 处理:

Diagram 4
4

通知

emitNotificationIfNeeded 是最后一步。当 TempData.activeToId == targetId(即用户当前正在查看该会话)或 canShowNotifications() 为 false 时,它会抑制通知。频道通知会以发送方的名字为前缀。

频道聊天:Leader 选举与扇出

频道是多方的,但无服务器。为了避免每个成员都将同一条消息扇出 N 次,发送方会选举出单一的 leader,由其负责向所有已加入的成员广播。

Leader 选举算法(DChatChannel.electLeader

  1. 筛选出当前在线且已加入的成员(本地设备始终被视为在线)。
  2. 如果 owner 在已加入的在线成员之中 → owner 即为 leader。
  3. 否则,leader 是已加入的在线成员中 clientId 最小的那个(确定性平局裁决,无需协调)。
  4. 如果不存在已加入的在线成员,返回 null

发送流程

Diagram 5
5

为什么要有 leader?

设想一个 5 成员频道,每个人都向其他所有人广播:一条消息会产生 20 次网络往返,每个成员会收到 4 份重复副本。通过选举一个 leader,只有那一台设备执行扇出——发送方要么自己执行扇出(如果它就是 leader),要么将单副本转发给 leader,再由 leader 扇出。

如果 leader 离线,发送方回退到 Result.NoLeader,触发 peer rediscovery(以便找到 leader 的 IP),并清除状态以让用户重试。

频道密钥路由

频道消息使用频道的 ChaCha20 密钥加密,而非成对 peer 密钥。这正是允许一个仅通过频道认识其他成员(从未 1 对 1 配对)的成员接收消息的原因——它的 peers 行具有 status="channel"key=""。发送方将 c-cid HTTP header 设为频道 id;接收方查找 ChannelCacher.getKeyBytes(channelId) 而非成对密钥。

按收件人重试

每个 sendToMember 返回一个 DMessageDeliveryResult。聚合后的 DMessageStatusData 作为聊天项的 status_data JSON 持久化。UI 显示"Delivered to Alice, Bob; Failed for Carol",并允许用户专门为 Carol 点击 Retry——ChatManager.sendToChannelMembers 对重试子集重新运行 sendToRecipients,并将新结果与已有结果合并,仅替换被重试的 peers。

频道系统消息

频道控制面消息(invite、accept、decline、update、kick、leave)通过 peer GraphQLchannelSystemMessage mutation 交换。它们是按 type 字符串分类的 JSON payload:

Type方向是否签名?用途
channel_inviteOwner → invitee邀请某个 peer;携带频道密钥 + 成员列表。
channel_invite_acceptInvitee → owner接受邀请;携带接受者的公钥。
channel_invite_declineInvitee → owner拒绝邀请;owner 移除该成员。
channel_updateOwner → 所有成员成员/名称变更的广播。
channel_kickOwner → 被踢 peer定向踢出;在频道删除时也会广播。
channel_leaveMember → owner成员主动发起的离开通知。

签名 payload 格式

三种签名类型(inviteupdatekick)使用一种规范的管道分隔字符串:"$channelId|$version|$action|$target",其中 actioninviteupdatekick 之一,target 是被邀请/被踢的 peer id(对于广播 kick 为空)。

owner 用其 Ed25519 密钥对该字符串签名。接收方会在检查签名之前就拒绝任何 channel.owner != fromId 的消息,并拒绝 version 本地版本的 ChannelUpdate payload(防止乱序投递的旧版本保护)。

Diagram 6
6

惰性 peer 注入

ChannelInviteChannelUpdate 携带一个 memberPeers: List<MemberPeerInfo> 列表——每个成员的轻量级 peer 信息(id、name、publicKey、deviceType、ip、port)。接收方的 ensureChannelPeer 会为任何它从未见过的成员创建一条 status="channel"DPeer 行。这一点至关重要,因为扇出路由需要每个成员的 peer 记录才能发送消息。

频道生命周期

Diagram 7
7

对端传输层(LAN → Wi-Fi Aware → BLE)

PeerTransportRouter 是一个带熔断的策略链。有序的传输层列表为:

  1. LanTransport——首选。使用 OkHttp,并通过 HTTPS 上的 ChaCha20 加密拦截器。当 peer.ip 为空时(尚未发现的跨子网 peer),完全跳过。
  2. WifiAwareTransport(仅 Android 13+)——使用 Wi-Fi Aware(NAN)数据通路。当 peer 的 awareRunning 标志为 false 时(由 BLE 预热扫描刷新)快速跳过。peer 的 IPv6 通过自定义 DNS 解析,该 DNS 将主机名 plain-aware-peer 映射到链路本地地址。
  3. BleTransport——任何已配对 peer 的保底回退。通过 GATT 流式传输分块 RPC。较慢,但无需任何 IP 连接即可工作。

Diagram 8
8

为什么是这个顺序?

  • LAN 最快(单次 HTTPS 往返,~10 ms 超时)。
  • Wi-Fi Aware 中等(数据通路建立 ~5 s,随后 ~10 ms 往返),且可跨子网工作(例如一台设备在访客 Wi-Fi 上,另一台在 IoT Wi-Fi 上)。已调优为在 peer 的 Aware 服务未运行时快速跳过,避免 10 s 的 buildLink 超时。
  • BLE 最慢,但完全无需任何 IP 连接即可工作——即使没有 Wi-Fi,消息仍能送达。用作已配对 peers 的保底回退。

熔断器确保不稳定的传输层(尤其是网络抖动期间的 Wi-Fi Aware)在 2 次失败后被跳过 30 s,从而让回退快速发生,而不是等待反复的 10 s 超时。

Wi-Fi Aware 握手

AwareSession 在打开数据通路之前进行两次消息握手:

  • MSG_HELLO(subscriber → publisher):"我看到你了,这是我的 peer handle。"
  • MSG_READY(publisher → subscriber):"我已注册我的 network specifier,你可以 requestNetwork 了。"

这在 Android 框架约 500 ms 的窗口内同步双方的 connectivityManager.requestNetwork(...) 调用。subscriberclientId 较小的一方(确定性角色划分——双方无需协调即可达成一致),并由它拥有重试循环。

对端状态与在线状态

在线状态通过长生命周期的 WebSocket 连接跟踪。每一对中只有一方打开 socket——由确定性规则 TempData.clientId < peer.id 决定。另一方在 /peer_status 接受入站连接。

Diagram 9
9

PeerCacher.onlineMap 是在线状态的真相来源。它以 onlinePeerIds: StateFlow<Set<String>> 的形式暴露,被频道 leader 选举(electLeader(onlinePeerIds, myId))消费。

缓存层

两个缓存将数据库表镜像到内存中,并暴露 Compose 直接收集的 StateFlow

Diagram 10
10

为什么用 copy-on-write?

Kotlin 的 MutableStateFlow.distinctUntilChanged 使用结构相等。如果原地修改 DPeer,派生的 pairedPeers 列表在前后会包含同一个 DPeer 引用,distinctUntilChanged 会看不出差异并抑制发射。通过先复制实体、修改副本,再用新的 PeerRuntime/ChannelRuntime 替换 map 条目,派生列表会得到一个"新引用的新列表",flow 就会触发。

文件下载

入站的文件/图片消息由一个有界 worker 池自动下载。每次下载流式通过任何可用的传输层(PeerTransportRouter.downloadFile),写入临时文件,然后导入到应用的 media store,并修补聊天项的 uri 字段。

Diagram 11
11

与传输层无关的流式传输

DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable 抽象让 LAN 和 Wi-Fi Aware 流式传输实时 HTTP body,同时 BLE 通过同一个 ByteReadChannel 流式传输分块 RPC(通过 GET /fs?id=…&offset=…&length=… 的 16 KiB 分块)。onClose 回调让 BLE 在消费者提前关闭响应(例如暂停时)时能取消其后台下载协程。

设计模式回顾

模式位置原因
FaçadeChatManager单一入口点;调用方永远不直接接触 DB/传输层。
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport可插拔的传输层,以 TransportUnavailable 作为贯穿信号。
Circuit BreakerPeerCircuitBreaker2 次失败 / 30 s 打开一条 (peer, transport) 链路,让 Wi-Fi Aware 不会阻塞回退。
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkState为 socket 生命周期和 NDP 链路生命周期提供显式状态转换。
Producer/Consumer + PoolDownloadQueue(3 个 workers,Channel.BUFFERED文件下载的有界并发。
Observer / Reactive处处可见的 StateFlowCompose 直接收集;无需手动刷新。
Replay ProtectionChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS丢弃 LAN+BLE 双投递产生的重复;拒绝窗口外的时间戳。
Exponential BackoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6))——上限为 64 s。
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannel强制 StateFlow.distinctUntilChanged 在每次变更时触发。
Signed EnvelopePeerGraphQLClient.buildSignedRequestsignature|timestamp|body——将时间戳与 body 绑定以防止重放。
Deterministic Role SplitTempData.clientId < peer.id决定 WebSocket 客户端 vs 服务器,以及 Wi-Fi Aware subscriber vs publisher。
Lazy Hydrationinvite/update 时的 ensureChannelPeer为未见过的频道成员创建 peers 行,使扇出路由可行。
Encrypted IdentityLANDiscoverManager.discoverSpecificDevice定向 DISCOVER 用 peer 密钥加密目标 id——只有目标能识别。

延伸阅读

  • Pairing Flow——两台设备如何建立信任并交换共享的 ChaCha20 密钥,本文中每个传输层都使用该密钥。
  • apitest/groups/chat-messages.shapitest/groups/chat-channels.sh——可执行的测试计划,端到端演练每个 GraphQL mutation。