目录
- 总体架构
- 数据模型
- GraphQL API 概览
- 对端聊天:发送消息
- 对端聊天:接收消息
- 频道聊天:Leader 选举与扇出
- 频道系统消息
- 频道生命周期
- 对端传输层(LAN → Wi-Fi Aware → BLE)
- 对端状态与在线状态
- 缓存层
- 文件下载
- 设计模式回顾
总体架构
PlainApp 聊天是无服务器的。每台设备都运行一个内嵌的 Ktor HTTP 服务器,设备之间通过局域网、Wi-Fi Aware(NAN)或蓝牙低功耗(Bluetooth Low Energy)直接通信。没有中继服务器,没有云端收件箱,也没有基于手机号的身份。设备通过自生成的 clientId 标识,并在配对期间执行 Ed25519 + ECDH 握手来完成认证。
存在两种会话类型:
| 类型 | 常量 | 描述 |
|---|---|---|
PEER | ChatTargetType.PEER | 两台已配对设备之间的 1 对 1 直接聊天。 |
CHANNEL | ChatTargetType.CHANNEL | 由一台设备拥有的多方群聊;成员之间相互扇出消息。 |
一个特殊的 "local" 目标是设备自身的草稿本(给自己的备忘)——向其发送在网络上是一个 no-op。
组件映射
架构有意设计为分层的:
- UI / GraphQL 入口永远不会直接接触传输层或数据库。
ChatManager是一个 façade——每个调用方(UI、GraphQL resolver、对端接收器)都通过它。ChatSender是一个分发器,根据ChatTargetType分支并委托给对端或频道发送器。- 传输层是一个可插拔的策略链,带有熔断机制,因此不稳定的 Wi-Fi Aware 链路永远不会阻塞本可以通过 BLE 发送的消息。
数据模型
ChatTarget
路由的最小单位是 ChatTarget——一个 (toId, type) 对,其中 type 为 PEER 或 CHANNEL。它暴露一个 encodedToId(peer:<id> 或 channel:<id>),UI 将其用作稳定的路由键(例如 TempData.activeToId,以便接收器知道是否要发出通知),一个 isLocal() 检查(toId == "local"),以及一个 parseId 伴生函数,可从存储的字符串重建目标。
数据库表
所有持久化都使用 Room。聊天相关有三张重要的表:
| 表 | 实体 | 用途 |
|---|---|---|
chats | DChat | 每条消息(文本/图片/文件)一行。 |
chat_channels | DChatChannel | 每个群组频道一行。 |
peers | DPeer | 每台已知设备(已配对或仅属于频道)一行。 |
有几点值得注意:
- 身份是
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:
- Web GraphQL(
addChatChannelSchema+addChatMessageSchema,位于shared/src/commonMain/kotlin/com/ismartcoding/plain/httpserver/) ——由本地 Ktor 服务器提供给浏览器 UI 和apitest/测试套件。通过 ChaCha20 加密的 token 进行认证。 - Peer GraphQL(
PeerGraphQLService.applyPeerSchema)——在/peer_graphql暴露给其他设备,通过加密的对端传输层访问。通过 Ed25519 签名 + ChaCha20 body 加密进行认证。
两套 schema 共享相同的业务逻辑单例(ChannelManager、ChatMessageReceiver 等),但暴露不同的接口,因为信任模型不同: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 携带发送方的 clientId;c-cid header 在请求属于频道作用域时携带频道 id(以便接收方使用频道密钥而非成对 peer 密钥进行解密)。
对端聊天:发送消息
当用户在 peer 会话中点击 Send 时,调用链如下:
每一跳强制执行的关键不变量:
ChatManager.createChatItem总是先插入一行,然后再发送。这意味着 UI 会立即看到一个 "pending" 气泡,并且即使投递尚未完成,消息也能在应用崩溃后幸存。PeerGraphQLClient.buildSignedRequest构建一个形如signature|timestamp|requestJson的信封。签名是 Ed25519 对"$timestamp$requestJson"的签名,将时间戳与 body 绑定,使其无法用新的时间戳被重放。PeerTransportRouter.send按Lan → WifiAware → Ble的顺序遍历各传输层。每个传输层可以抛出TransportUnavailable以让路由器尝试下一个。- 在接收侧,
PeerChatParser.decrypt会检查时间戳是否在±5 min之内,并在 GraphQL mutation 执行之前验证 Ed25519 签名。 ChatMessageReceiver.receive维护一个seenSignatures集合,键为"$fromPeerId|$signature|$timestamp",遇到重复时抛出ReplayedMessageException——这一点至关重要,因为传输层可能会投递相同的 payload 两次(LAN + BLE)。
如果 PeerChatSender.send 返回非空的错误字符串,ChatSender 会调用 triggerPeerRediscovery(peerId),发起一次定向的加密 DISCOVER 广播,以便对端重新公告其当前的 IP/端口。
对端聊天:接收消息
入站请求落在本地 Ktor 服务器的 /peer_graphql 路由上,由 PeerGraphQLService 处理:
通知
emitNotificationIfNeeded 是最后一步。当 TempData.activeToId == targetId(即用户当前正在查看该会话)或 canShowNotifications() 为 false 时,它会抑制通知。频道通知会以发送方的名字为前缀。
频道聊天:Leader 选举与扇出
频道是多方的,但无服务器。为了避免每个成员都将同一条消息扇出 N 次,发送方会选举出单一的 leader,由其负责向所有已加入的成员广播。
Leader 选举算法(DChatChannel.electLeader)
- 筛选出当前在线且已加入的成员(本地设备始终被视为在线)。
- 如果 owner 在已加入的在线成员之中 → owner 即为 leader。
- 否则,leader 是已加入的在线成员中
clientId最小的那个(确定性平局裁决,无需协调)。 - 如果不存在已加入的在线成员,返回
null。
发送流程
为什么要有 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 GraphQL 的 channelSystemMessage mutation 交换。它们是按 type 字符串分类的 JSON payload:
| Type | 方向 | 是否签名? | 用途 |
|---|---|---|---|
channel_invite | Owner → invitee | 是 | 邀请某个 peer;携带频道密钥 + 成员列表。 |
channel_invite_accept | Invitee → owner | 否 | 接受邀请;携带接受者的公钥。 |
channel_invite_decline | Invitee → owner | 否 | 拒绝邀请;owner 移除该成员。 |
channel_update | Owner → 所有成员 | 是 | 成员/名称变更的广播。 |
channel_kick | Owner → 被踢 peer | 是 | 定向踢出;在频道删除时也会广播。 |
channel_leave | Member → owner | 否 | 成员主动发起的离开通知。 |
签名 payload 格式
三种签名类型(invite、update、kick)使用一种规范的管道分隔字符串:"$channelId|$version|$action|$target",其中 action 为 invite、update、kick 之一,target 是被邀请/被踢的 peer id(对于广播 kick 为空)。
owner 用其 Ed25519 密钥对该字符串签名。接收方会在检查签名之前就拒绝任何 channel.owner != fromId 的消息,并拒绝 version ≤ 本地版本的 ChannelUpdate payload(防止乱序投递的旧版本保护)。
惰性 peer 注入
ChannelInvite 和 ChannelUpdate 携带一个 memberPeers: List<MemberPeerInfo> 列表——每个成员的轻量级 peer 信息(id、name、publicKey、deviceType、ip、port)。接收方的 ensureChannelPeer 会为任何它从未见过的成员创建一条 status="channel" 的 DPeer 行。这一点至关重要,因为扇出路由需要每个成员的 peer 记录才能发送消息。
频道生命周期
对端传输层(LAN → Wi-Fi Aware → BLE)
PeerTransportRouter 是一个带熔断的策略链。有序的传输层列表为:
LanTransport——首选。使用 OkHttp,并通过 HTTPS 上的 ChaCha20 加密拦截器。当peer.ip为空时(尚未发现的跨子网 peer),完全跳过。WifiAwareTransport(仅 Android 13+)——使用 Wi-Fi Aware(NAN)数据通路。当 peer 的awareRunning标志为 false 时(由 BLE 预热扫描刷新)快速跳过。peer 的 IPv6 通过自定义 DNS 解析,该 DNS 将主机名plain-aware-peer映射到链路本地地址。BleTransport——任何已配对 peer 的保底回退。通过 GATT 流式传输分块 RPC。较慢,但无需任何 IP 连接即可工作。
为什么是这个顺序?
- 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(...) 调用。subscriber 是 clientId 较小的一方(确定性角色划分——双方无需协调即可达成一致),并由它拥有重试循环。
对端状态与在线状态
在线状态通过长生命周期的 WebSocket 连接跟踪。每一对中只有一方打开 socket——由确定性规则 TempData.clientId < peer.id 决定。另一方在 /peer_status 接受入站连接。
PeerCacher.onlineMap 是在线状态的真相来源。它以 onlinePeerIds: StateFlow<Set<String>> 的形式暴露,被频道 leader 选举(electLeader(onlinePeerIds, myId))消费。
缓存层
两个缓存将数据库表镜像到内存中,并暴露 Compose 直接收集的 StateFlow:
为什么用 copy-on-write?
Kotlin 的 MutableStateFlow.distinctUntilChanged 使用结构相等。如果原地修改 DPeer,派生的 pairedPeers 列表在前后会包含同一个 DPeer 引用,distinctUntilChanged 会看不出差异并抑制发射。通过先复制实体、修改副本,再用新的 PeerRuntime/ChannelRuntime 替换 map 条目,派生列表会得到一个"新引用的新列表",flow 就会触发。
文件下载
入站的文件/图片消息由一个有界 worker 池自动下载。每次下载流式通过任何可用的传输层(PeerTransportRouter.downloadFile),写入临时文件,然后导入到应用的 media store,并修补聊天项的 uri 字段。
与传输层无关的流式传输
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable 抽象让 LAN 和 Wi-Fi Aware 流式传输实时 HTTP body,同时 BLE 通过同一个 ByteReadChannel 流式传输分块 RPC(通过 GET /fs?id=…&offset=…&length=… 的 16 KiB 分块)。onClose 回调让 BLE 在消费者提前关闭响应(例如暂停时)时能取消其后台下载协程。
设计模式回顾
| 模式 | 位置 | 原因 |
|---|---|---|
| Façade | ChatManager | 单一入口点;调用方永远不直接接触 DB/传输层。 |
| Strategy + Chain of Resp. | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | 可插拔的传输层,以 TransportUnavailable 作为贯穿信号。 |
| Circuit Breaker | PeerCircuitBreaker | 2 次失败 / 30 s 打开一条 (peer, transport) 链路,让 Wi-Fi Aware 不会阻塞回退。 |
| State Machine | PeerStatusManager.PeerState, AwarePeerLink.LinkState | 为 socket 生命周期和 NDP 链路生命周期提供显式状态转换。 |
| Producer/Consumer + Pool | DownloadQueue(3 个 workers,Channel.BUFFERED) | 文件下载的有界并发。 |
| Observer / Reactive | 处处可见的 StateFlow | Compose 直接收集;无需手动刷新。 |
| Replay Protection | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | 丢弃 LAN+BLE 双投递产生的重复;拒绝窗口外的时间戳。 |
| Exponential Backoff | PeerStatusManager.scheduleReconnect | min(60 s, 1 s × 2^min(n-1, 6))——上限为 64 s。 |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | 强制 StateFlow.distinctUntilChanged 在每次变更时触发。 |
| Signed Envelope | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body——将时间戳与 body 绑定以防止重放。 |
| Deterministic Role Split | TempData.clientId < peer.id | 决定 WebSocket 客户端 vs 服务器,以及 Wi-Fi Aware subscriber vs publisher。 |
| Lazy Hydration | invite/update 时的 ensureChannelPeer | 为未见过的频道成员创建 peers 行,使扇出路由可行。 |
| Encrypted Identity | LANDiscoverManager.discoverSpecificDevice | 定向 DISCOVER 用 peer 密钥加密目标 id——只有目标能识别。 |
延伸阅读
- Pairing Flow——两台设备如何建立信任并交换共享的 ChaCha20 密钥,本文中每个传输层都使用该密钥。
apitest/groups/chat-messages.sh和apitest/groups/chat-channels.sh——可执行的测试计划,端到端演练每个 GraphQL mutation。