Quay lại blog
Architecture12 min read

Kiến trúc Chat Peer & Channel

Bài viết này giải thích cách chat ưu tiên ngoại tuyến của PlainApp hoạt động đầu cuối: cách một tin nhắn đi từ một cú chạm trong giao diện UI đến một thiết bị khác qua tầng vận chuyển peer, cách các kênh nhóm phân phát tin nhắn đến nhiều thành viên, và cách hệ thống giữ được khả năng chịu lỗi khi mạng biến mất. Quá trình pairing (việc thiết lập tin cậy và trao đổi khóa để khởi tạo hai thiết bị) được đề cập đến trong bài viết Pairing Flow riêng biệt.

Mục lục

Kiến trúc Tổng quan {#high-level-architecture}

Chat của PlainApp là không máy chủ. Mỗi thiết bị chạy một máy chủ Ktor HTTP nhúng, và các thiết bị giao tiếp trực tiếp với nhau qua mạng nội bộ, Wi-Fi Aware (NAN), hoặc Bluetooth Low Energy. Không có máy chủ trung chuyển, không có hộp thư đám mây, không có nhận dạng dựa trên số điện thoại. Các thiết bị được định danh bằng một clientId tự sinh và xác thực qua một lần bắt tay Ed25519 + ECDH được thực hiện trong quá trình pairing.

Hai loại cuộc hội thoại tồn tại:

LoạiHằng sốMô tả
PEERChatTargetType.PEERChat trực tiếp 1-1 giữa hai thiết bị đã pair.
CHANNELChatTargetType.CHANNELChat nhóm đa phương do một thiết bị sở hữu; các thành viên phân phát tin nhắn cho nhau.

Một target đặc biệt "local" là khu vực ghi chú của chính thiết bị (ghi chú cho bản thân) — gửi đến nó là một thao tác rỗng trên đường truyền.

Bản đồ thành phần

Diagram 1
1

Kiến trúc được cố ý phân lớp:

  1. Điểm vào UI / GraphQL không bao giờ tiếp cận tầng vận chuyển hay cơ sở dữ liệu trực tiếp.
  2. ChatManager là một mặt tiền — mọi bên gọi (UI, GraphQL resolver, peer receiver) đều đi qua nó.
  3. ChatSender là một bộ phân phát rẽ nhánh trên ChatTargetType và ủy quyền cho peer hoặc channel sender.
  4. Tầng vận chuyển là một chuỗi chiến lược có thể cắm rời với ngắt mạch, để một đường truyền Wi-Fi Aware không ổn định không bao giờ chặn một tin nhắn có thể đi qua BLE.

Mô hình Dữ liệu {#data-model}

ChatTarget

Đơn vị định tuyến nhỏ nhất là một ChatTarget — một cặp (toId, type) trong đó typePEER hoặc CHANNEL. Nó cung cấp một encodedToId (peer:<id> hoặc channel:<id>) mà UI dùng làm khóa định tuyến ổn định (ví dụ TempData.activeToId để bên nhận biết có nên phát thông báo), một kiểm tra isLocal() (toId == "local"), và một parseId companion tái dựng target từ một chuỗi đã lưu.

Các bảng dữ liệu

Toàn bộ việc lưu trữ dùng Room. Ba bảng quan trọng cho chat:

BảngThực thểMục đích
chatsDChatMột hàng mỗi tin nhắn (text / image / file).
chat_channelsDChatChannelMột hàng mỗi kênh nhóm.
peersDPeerMột hàng mỗi thiết bị đã biết (đã pair hoặc chỉ-qua-kênh).

Diagram 2
2

Vài điều đáng chú ý:

  • Nhận dạng là clientId, không bao giờ là MAC. Android ngẫu nhiên hóa BLE MAC trên mỗi kết nối, nên cơ sở dữ liệu dùng một id 13 ký tự tự sinh ổn định. Chỉ tiền tố SHA-256 8-byte (shortId) được phát sóng qua BLE để cho phép khám phá.
  • Peer status="channel" là thành viên của một kênh mà thiết bị này chưa bao giờ pair trực tiếp. key của họ rỗng — họ xác thực dùng khóa kênh thay vì khóa chia sẻ theo cặp.
  • owner="me" là một sentinel cho phép một thiết bị mới cài đặt đóng vai trò làm owner trước khi clientId của nó ổn định; isOwnedByMe() chấp nhận cả "me"TempData.clientId.

Bề mặt API GraphQL {#graphql-api-surface}

PlainApp cung cấp hai GraphQL schema:

  1. Web GraphQL (addChatChannelSchema + addChatMessageSchema) — phục vụ bởi máy chủ Ktor nội bộ cho UI trình duyệt và cho bộ kiểm thử apitest/. Được xác thực bằng token mã hóa ChaCha20.
  2. Peer GraphQL (PeerGraphQLService.applyPeerSchema) — cung cấp tại /peer_graphql cho các thiết bị khác qua tầng vận chuyển peer mã hóa. Được xác thực bằng chữ ký Ed25519 + mã hóa body ChaCha20.

Hai schema chia sẻ cùng các singleton logic nghiệp vụ (ChannelManager, ChatMessageReceiver, …) nhưng cung cấp các bề mặt khác nhau vì mô hình tin cậy khác nhau: web GraphQL tin tưởng UI nội bộ, trong khi peer GraphQL chỉ tin tưởng các peer đã được xác thực bằng mật mã.

Bề mặt Web GraphQL (chat)

Truy vấn: chatChannels (liệt kê tất cả các kênh), chatItems(id) (tin nhắn cho một target — id là "local", peer:<id>, hoặc channel:<id>), và latestChatItems (xem trước qua tất cả chat).

Chat mutation: sendChatItem(toId, content), deleteChatItem(id), deleteChatItems(query), và retryChatItem(id).

Channel mutation: createChatChannel(name), updateChatChannel(id, name), deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id), và declineChatChannelInvite(id).

Bề mặt Peer GraphQL (vận chuyển)

Cung cấp tại /peer_graphql và xác thực bằng chữ ký Ed25519 + mã hóa body ChaCha20. Chỉ ba mutation vượt qua ranh giới tầng vận chuyển: createChatItem(content) (một tin nhắn peer đến), channelSystemMessage(type, payload) (sự kiện vòng đời kênh như mời/rời), và startAware (một lời nhắc nhờ peer khởi động dịch vụ Wi-Fi Aware để một tầng vận chuyển nhanh hơn có thể tiếp quản).

HTTP header c-id mang clientId của người gửi; header c-cid mang channel id khi request là phạm vi kênh (để bên nhận chọn khóa kênh thay vì khóa peer theo cặp cho giải mã).

Chat Peer: Gửi một Tin nhắn {#peer-chat-sending-a-message}

Khi người dùng chạm Gửi trong một cuộc hội thoại peer, chuỗi gọi là:

Diagram 3
3

Các bất biến chính được áp dụng ở mỗi hop:

  1. ChatManager.createChatItem luôn chèn một hàng trước, sau đó gửi. Điều này nghĩa là UI thấy một bong bóng "đang chờ" ngay lập tức và tin nhắn tồn tại qua các sự cố ứng dụng kể cả khi việc giao chưa xảy ra.
  2. PeerGraphQLClient.buildSignedRequest xây dựng một phong bì dạng signature|timestamp|requestJson. Chữ ký là Ed25519 trên "$timestamp$requestJson", liên kết timestamp với body để không thể phát lại với timestamp mới.
  3. PeerTransportRouter.send lặp qua các transport theo thứ tự Lan → WifiAware → Ble. Mỗi transport có thể ném TransportUnavailable để router thử transport kế tiếp.
  4. Ở bên nhận, PeerChatParser.decrypt kiểm tra timestamp nằm trong ±5 min và xác minh chữ ký Ed25519 trước khi GraphQL mutation được thực thi.
  5. ChatMessageReceiver.receive giữ một tập seenSignatures khóa bởi "$fromPeerId|$signature|$timestamp" và ném ReplayedMessageException khi trùng lặp — thiết yếu vì transport có thể giao cùng payload hai lần (LAN + BLE).

Nếu PeerChatSender.send trả về một chuỗi lỗi không rỗng, ChatSender gọi triggerPeerRediscovery(peerId), kích hoạt một broadcast DISCOVER có hướng, mã hóa để peer có thể thông báo lại IP/port hiện tại.

Chat Peer: Nhận một Tin nhắn {#peer-chat-receiving-a-message}

Các request đến hạ cánh tại route /peer_graphql của máy chủ Ktor nội bộ, xử lý bởi PeerGraphQLService:

Diagram 4
4

Thông báo

emitNotificationIfNeeded là bước cuối. Nó kìm hãm thông báo khi TempData.activeToId == targetId (tức người dùng đang xem cuộc hội thoại đó) hoặc khi canShowNotifications() là false. Thông báo kênh được tiền tố với tên người gửi.

Chat Channel: Bầu Leader & Phân phát {#channel-chat-leader-election--fan-out}

Kênh là đa phương nhưng không máy chủ. Để tránh mỗi thành viên phân phát cùng một tin nhắn N lần, bên gửi bầu một leader duy nhất có nhiệm vụ phát sóng đến tất cả thành viên đã tham gia.

Thuật toán bầu leader (DChatChannel.electLeader)

  1. Lọc đến các thành viên đã tham gia đang trực tuyến (thiết bị nội bộ luôn được coi là trực tuyến).
  2. Nếu owner trong số các thành viên đã tham gia trực tuyến → owner là leader.
  3. Nếu không, leader là thành viên đã tham gia trực tuyến có clientId nhỏ nhất (phá thế hòa deterministic, không cần phối hợp).
  4. Trả về null nếu không có thành viên đã tham gia trực tuyến nào.

Luồng gửi

Diagram 5
5

Tại sao lại cần leader?

Tưởng tượng một kênh 5 thành viên nơi mọi người phát sóng đến tất cả người khác: một tin nhắn duy nhất sẽ tạo 20 vòng mạng và 4 bản sao trùng lặp đến mỗi thành viên. Bằng cách bầu một leader, chỉ thiết bị đó làm việc phân phát — người gửi hoặc tự thực hiện phân phát (nếu là leader) hoặc chuyển tiếp một bản sao duy nhất cho leader, sau đó leader phân phát.

Nếu leader ngoại tuyến, người gửi quay về Result.NoLeader, kích hoạt khám phá lại peer (để IP của leader có thể được tìm thấy), và xóa trạng thái để người dùng thử lại.

Định tuyến khóa kênh

Tin nhắn kênh được mã hóa với khóa ChaCha20 của kênh, không phải khóa peer theo cặp. Đây là điều cho phép một thành viên chỉ từng gặp các thành viên khác qua kênh (chưa bao giờ pair 1-1) nhận tin nhắn — hàng peers của họ có status="channel"key="". Người gửi đặt HTTP header c-cid thành channel id; bên nhận tra cứu ChannelCacher.getKeyBytes(channelId) thay vì khóa theo cặp.

Thử lại theo từng người nhận

Mỗi sendToMember trả về một DMessageDeliveryResult. DMessageStatusData tổng hợp được lưu trữ làm JSON status_data của chat item. UI hiển thị "Đã giao cho Alice, Bob; Thất bại cho Carol" và cho phép người dùng chạm Thử lại cho Carol cụ thể — ChatManager.sendToChannelMembers chạy lại sendToRecipients cho tập con thử lại và hợp nhất kết quả mới với kết quả hiện có, thay thế chỉ các peer được thử lại.

Tin nhắn Hệ thống Channel {#channel-system-messages}

Tin nhắn mặt phẳng điều khiển kênh (mời, chấp nhận, từ chối, cập nhật, đuổi, rời) được trao đổi qua peer GraphQL mutation channelSystemMessage. Chúng là payload JSON được định kiểu bởi một chuỗi type:

LoạiHướngĐã ký?Mục đích
channel_inviteOwner → người được mờiMời một peer; mang khóa kênh + thành viên.
channel_invite_acceptNgười được mời → ownerKhôngChấp nhận; mang public key của người chấp nhận.
channel_invite_declineNgười được mời → ownerKhôngTừ chối; owner xóa thành viên.
channel_updateOwner → tất cả thành viênPhát sóng thay đổi thành viên/tên.
channel_kickOwner → peer bị đuổiĐuổi có mục tiêu; cũng phát sóng khi xóa kênh.
channel_leaveThành viên → ownerKhôngThông báo rời do thành viên khởi xướng.

Định dạng payload đã ký

Ba loại đã ký (invite, update, kick) dùng một chuỗi phân tách bằng ống chuẩn: "$channelId|$version|$action|$target", trong đó action là một trong invite, update, kick, và target là id của peer được mời/đuổi (rỗng cho kick phát sóng).

Owner ký chuỗi này với khóa Ed25519. Bên nhận từ chối bất kỳ tin nhắn nào mà channel.owner != fromId trước khi kiểm tra chữ ký, và từ chối payload ChannelUpdateversion phiên bản nội bộ (bảo vệ stale-version chống delivery ngoài thứ tự).

Diagram 6
6

Hydrate peer lười biếng

ChannelInviteChannelUpdate mang một danh sách memberPeers: List<MemberPeerInfo> — thông tin peer nhẹ (id, name, publicKey, deviceType, ip, port) cho mọi thành viên. ensureChannelPeer của bên nhận tạo một hàng DPeer với status="channel" cho bất kỳ thành viên nào chưa từng thấy. Điều này then chốt vì định tuyến phân phát cần bản ghi peer của mọi thành viên để gửi tin nhắn.

Vòng đời Channel {#channel-lifecycle}

Diagram 7
7

Tầng Vận chuyển Peer (LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}

PeerTransportRouter là một chuỗi chiến lược với ngắt mạch. Danh sách transport theo thứ tự:

  1. LanTransport — lựa chọn đầu tiên. Dùng OkHttp với một interceptor crypto ChaCha20 qua HTTPS. Bỏ qua hoàn toàn khi peer.ip rỗng (peer chéo-subnet chưa khám phá).
  2. WifiAwareTransport (chỉ Android 13+) — dùng đường dữ liệu Wi-Fi Aware (NAN) . Bỏ qua nhanh khi cờ awareRunning của peer là false (làm mới bởi BLE prewarmer scan). IPv6 của peer được giải quyết qua một DNS tùy chỉnh ánh xạ hostname plain-aware-peer sang địa chỉ link-local.
  3. BleTransport — fallback đảm bảo cho bất kỳ paired peer nào. Stream chunked RPC qua GATT. Chậm hơn nhưng hoạt động không cần kết nối IP.

Diagram 8
8

Tại sao thứ tự này?

  • LAN nhanh nhất (một vòng HTTPS đơn, timeout ~10 ms).
  • Wi-Fi Aware trung bình (thiết lập data-path ~5 s, sau đó ~10 ms mỗi vòng) và hoạt động chéo-subnet (ví dụ một thiết bị trên guest Wi-Fi, thiết bị khác trên IoT Wi-Fi). Tinh chỉnh để bỏ qua nhanh khi Aware service của peer không chạy, tránh một timeout buildLink 10 s.
  • BLE chậm nhất nhưng hoạt động không cần bất kỳ kết nối IP nào — kể cả không có Wi-Fi, tin nhắn vẫn được gửi. Dùng làm fallback đảm bảo cho paired peer.

Circuit breaker đảm bảo một transport không ổn định (đặc biệt Wi-Fi Aware trong biến động mạng) bị bỏ qua trong 30 s sau 2 lần thất bại, để fallback xảy ra nhanh thay vì chờ timeout 10 s lặp lại.

Bắt tay Wi-Fi Aware

AwareSession thực hiện một bắt tay hai tin nhắn trước khi mở data path:

  • MSG_HELLO (subscriber → publisher): "Tôi thấy bạn, đây là peer handle của tôi."
  • MSG_READY (publisher → subscriber): "Tôi đã đăng ký network specifier, bạn có thể requestNetwork rồi."

Điều này đồng bộ hóa các lệnh gọi connectivityManager.requestNetwork(...) của cả hai bên trong cửa sổ ~500 ms của khung Android. Subscriber là bên có clientId nhỏ hơn (phân vai deterministic — cả hai bên đồng ý không cần phối hợp), và nó sở hữu vòng thử lại.

Trạng thái Peer & Trạng thái Hiện diện {#peer-status--presence}

Trạng thái hiện diện được theo dõi qua các kết nối WebSocket tồn tại lâu. Chỉ một bên của mỗi cặp mở socket — quyết định bởi quy tắc deterministic TempData.clientId < peer.id. Bên kia chấp nhận kết nối inbound tại /peer_status.

Diagram 9
9

PeerCacher.onlineMap là nguồn sự thật cho trạng thái hiện diện. Nó được cung cấp làm onlinePeerIds: StateFlow<Set<String>>, được tiêu thụ bởi channel leader election (electLeader(onlinePeerIds, myId)).

Tầng Bộ nhớ Đệm {#caching-layer}

Hai bộ nhớ đệm phản ánh các bảng cơ sở dữ liệu trong bộ nhớ và cung cấp các StateFlow mà Compose thu thập trực tiếp:

Diagram 10
10

Tại sao copy-on-write?

MutableStateFlow.distinctUntilChanged của Kotlin dùng structural equality. Nếu chúng ta thay đổi DPeer tại chỗ, danh sách pairedPeers dẫn xuất sẽ chứa cùng tham chiếu DPeer trước và sau, và distinctUntilChanged sẽ thấy không khác biệt và kìm hãm phát. Bằng cách sao chép entity trước, thay đổi bản sao, và thay thế mục nhập bản đồ bằng một PeerRuntime/ChannelRuntime mới, danh sách dẫn xuất nhận một danh sách-của-mới- tham chiếu mới và flow kích hoạt.

Tải xuống Tệp {#file-downloads}

Tin nhắn file/image đến được tự động tải xuống bởi một bounded worker pool. Mỗi lần tải xuống stream qua transport nào available (PeerTransportRouter.downloadFile) và ghi vào temp file, sau đó import vào media store của app và vá trường uri của chat item.

Diagram 11
11

Stream không phụ thuộc transport

Trừu tượng DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable cho phép LAN và Wi-Fi Aware stream body HTTP trực tiếp, trong khi BLE stream chunked RPC (chunk 16 KiB qua GET /fs?id=…&offset=…&length=…) qua cùng ByteReadChannel. Callback onClose cho phép BLE hủy background download coroutine khi consumer đóng response sớm (ví dụ khi tạm dừng).

Tóm tắt Design Pattern {#design-patterns-recap}

MẫuNơiLý do
FaçadeChatManagerĐiểm vào duy nhất; caller không bao giờ tiếp cận DB/transport trực tiếp.
Strategy + Chain of Resp.PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransportTransport có thể cắm rời với TransportUnavailable làm tín hiệu fall-through.
Circuit BreakerPeerCircuitBreaker2 fail / 30 s mở một leg (peer, transport) để Wi-Fi Aware không chặn fallback.
State MachinePeerStatusManager.PeerState, AwarePeerLink.LinkStateChuyển đổi explicit cho socket lifecycle và NDP link lifecycle.
Producer/Consumer + PoolDownloadQueue (3 worker, Channel.BUFFERED)Đồng thời có giới hạn cho file download.
Observer / ReactiveStateFlow ở khắp nơiCompose thu thập trực tiếp; không cần refresh thủ công.
Replay ProtectionChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MSBỏ trùng lặp từ dual delivery LAN+BLE; từ chối timestamp ngoài cửa sổ.
Exponential BackoffPeerStatusManager.scheduleReconnectmin(60 s, 1 s × 2^min(n-1, 6)) — giới hạn tại 64 s.
Copy-on-WritePeerCacher.mutatePeer, ChannelCacher.mutateChannelBuộc StateFlow.distinctUntilChanged kích hoạt trên mỗi mutation.
Signed EnvelopePeerGraphQLClient.buildSignedRequestsignature|timestamp|body — liên kết timestamp với body để chống replay.
Deterministic Role SplitTempData.clientId < peer.idQuyết định WebSocket client vs server, và Wi-Fi Aware subscriber vs publisher.
Lazy HydrationensureChannelPeer trên invite/updateTạo hàng peers cho channel member chưa thấy để fan-out routing hoạt động.
Encrypted IdentityLANDiscoverManager.discoverSpecificDeviceDISCOVER có hướng mã hóa target id với peer key — chỉ target mới nhận ra.

Đọc thêm {#further-reading}

  • Pairing Flow — cách hai thiết bị thiết lập tin cậy và trao đổi khóa ChaCha20 chia sẻ được dùng bởi mọi transport trong bài viết này.
  • apitest/groups/chat-messages.shapitest/groups/chat-channels.sh — kế hoạch kiểm thử thực thi chạy mọi GraphQL mutation đầu cuối.