11.1 频道模型与权限
聊天系统在 MMO 里常被当成「发个字符串而已」,直到世界频道开始刷屏、公会频道有人对骂、私聊消息发给了错误的玩家。它不改变任何战斗数值,却是玩家感知最强的功能之一:一条发错频道的公告、一次漏发的队伍消息,都会被立刻放大成投诉。
难点分三层。第一层是频道种类:世界、当前、队伍、公会、私聊、系统公告,每一种的投递范围都不一样——世界是全服扇出,当前只发给同一张地图实例里的玩家,队伍和公会按成员集合投递,私聊点对点,系统公告只有服务端能发。第二层是发言资格:等级不够不能上世界频道、没入队不能发队伍频道、被禁言的玩家哪个频道都发不了,而这些规则还会随活动、赛季、VIP 等级随时调整。第三层是热点的扇出:世界频道一条消息可能要推给上万人,投递本身就是需要小心处理的热点。
本节不选型、不聊部署,只把三件事写成能跑、能测的代码:把频道建模成数据而不是散落的 if-else,把投递范围收敛成一份最小世界视图,把发言资格写成纯函数。写完之后,11.2 在它后面接敏感词与限速,11.3 在它旁边补好友关系。
与站内既有文章的分工
聊天与社交在站内 game/server/practice/ 专题里已有成熟的架构文章。它们负责「线上怎么设计、怎么分层、怎么风控」,本节负责「从零写出可运行的那一层」,分工如下:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-server-chat-channel-governance-architecture/ | 频道治理的整体架构、冷热分层与全服广播 | 只把频道分类与投递范围写成可测代码 |
/game-server-profanity-filter-and-text-moderation/ | 内容审核与敏感词体系的产品与运营侧设计 | 本节不碰过滤,留到 11.2 |
/game-server-social-graph-and-friend-system-design/ | 社交图谱与好友系统的架构取舍 | 本节只做聊天投递,关系一致性见 11.3 |
一句话:要看架构取舍去读那三篇,要一份能 go test 通过的频道与权限实现读本节。
频道分类与投递范围
先把六个频道摆出来,它们的投递范围各不相同:
| 频道 | 投递范围 | 谁可发 |
|---|---|---|
世界 world | 全服所有在线玩家 | 玩家(通常有等级门槛) |
当前 local | 同一地图实例内 | 玩家 |
队伍 party | 同一队伍成员 | 玩家(须在队) |
公会 guild | 同一公会成员 | 玩家(须入会) |
私聊 private | 单个目标玩家 | 玩家 |
系统 system | 全服(或指定人群) | 仅服务端 |
一个常见的错误是把「投递范围」和「发言资格」混在同一个 switch 里:if kind == world { if level < 10 { ... } else { broadcast } }。这样写一开始能跑,等运营要求「世界频道只发给 30 级以上玩家」或者「公会频道允许跨服」时,这个函数就会变成一团乱麻。正确的拆法是:范围只回答「发给谁」,资格只回答「能不能发」,两者互不引用。
范围由一份最小世界视图决定:谁在哪张图、谁和谁同队、谁和谁同会。真实服务端里这些数据分散在场景、队伍、公会三个子系统,本节把它们合并成一份只读快照 World,投递时只查这份快照。下面这段是 L1,go test 可以直接跑:
// file: internal/chat/channel.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import (
"errors"
"sort"
)
// 卷二共享的内部类型。本章自包含,命名与全卷约定保持一致。
type (
PlayerID uint64
InstanceID uint64
PartyID uint64
GuildID uint64
)
// ChannelKind 是聊天频道的类型。前五个可由玩家发起,最后一个只允许服务端发。
type ChannelKind uint8
const (
ChanWorld ChannelKind = iota // 世界:全服可见
ChanLocal // 当前:同一地图实例内
ChanParty // 队伍
ChanGuild // 公会
ChanPrivate // 私聊
ChanSystem // 系统公告:仅服务端可发
)
func (k ChannelKind) String() string {
switch k {
case ChanWorld:
return "world"
case ChanLocal:
return "local"
case ChanParty:
return "party"
case ChanGuild:
return "guild"
case ChanPrivate:
return "private"
case ChanSystem:
return "system"
}
return "unknown"
}
var (
ErrBadChannel = errors.New("chat: unknown channel kind")
ErrUnknownPlayer = errors.New("chat: speaker is not in the world")
ErrNoSuchTarget = errors.New("chat: private target does not exist")
ErrNotInParty = errors.New("chat: speaker has no party")
ErrNotInGuild = errors.New("chat: speaker has no guild")
)
// World 是投递所需的最小世界视图:谁在哪、谁和谁同队同会。
// 真实服务端由场景、队伍、公会三个子系统各自维护,这里合并成一份便于验证。
type World struct {
Players map[PlayerID]struct{}
Scene map[PlayerID]InstanceID
Party map[PlayerID]PartyID
Guild map[PlayerID]GuildID
}
// ChatMessage 是进入投递管线的一条聊天消息。
type ChatMessage struct {
From PlayerID
Kind ChannelKind
Target uint64 // 私聊=目标 PlayerID,队伍=PartyID,公会=GuildID
Body string
}
// Recipients 解析出一条消息的收件人集合(去重、升序,便于测试与回放)。
// 注意它只回答「发给谁」,不回答「能不能发」——权限由 CanSpeak 负责。
func Recipients(w *World, m ChatMessage) ([]PlayerID, error) {
switch m.Kind {
case ChanWorld, ChanSystem:
return w.all(), nil
case ChanLocal:
scene, ok := w.Scene[m.From]
if !ok {
return nil, ErrUnknownPlayer
}
return w.members(func(p PlayerID) bool { return w.Scene[p] == scene }), nil
case ChanParty:
pid, ok := w.Party[m.From]
if !ok {
return nil, ErrNotInParty
}
return w.members(func(p PlayerID) bool { return w.Party[p] == pid }), nil
case ChanGuild:
gid, ok := w.Guild[m.From]
if !ok {
return nil, ErrNotInGuild
}
return w.members(func(p PlayerID) bool { return w.Guild[p] == gid }), nil
case ChanPrivate:
target := PlayerID(m.Target)
if _, ok := w.Players[target]; !ok {
return nil, ErrNoSuchTarget
}
return []PlayerID{target}, nil
}
return nil, ErrBadChannel
}
func (w *World) all() []PlayerID {
out := make([]PlayerID, 0, len(w.Players))
for p := range w.Players {
out = append(out, p)
}
sortIDs(out)
return out
}
func (w *World) members(pred func(PlayerID) bool) []PlayerID {
var out []PlayerID
for p := range w.Players {
if pred(p) {
out = append(out, p)
}
}
sortIDs(out)
return out
}
func sortIDs(ids []PlayerID) {
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
}
Recipients 的返回刻意排序:同一份世界快照下,收件人顺序必须稳定,否则回放、测试和「按顺序推送」都会漂移。注意它只解析收件人——玩家不在世界里、私聊目标不存在、没入队没入会,都返回一个具体的错误,投递失败要能被上层区分,而不是静默发空。
发言权限:等级、身份与禁言
资格判断是一个纯函数:输入发言人的最小快照和频道规则,输出「通过」或「第一个拒绝原因」。写成纯函数的好处是它天然可测、可热更新、可被网关和逻辑服同时调用。
规则本身是数据。等级门槛、是否要求入队入会、是否仅服务端可发,都是 ChannelPolicy 的字段,而不是写死的分支。这样运营改一条活动规则,只需要换一份配置,不用重新发版。
判断顺序也是设计的一部分:先判禁言(最重的处罚,且与频道无关),再看频道是否仅服务端可发,然后才是等级、队伍、公会。顺序固定下来,报错信息才稳定,客服和日志才能据此定位。下面这段是 L1:
// file: internal/chat/permission.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import "errors"
// Speaker 是发言人的最小快照。权限判断只看这几个字段,
// 不碰实体运行时——聊天域不该为了鉴权去解引用一个可能已下线的实体。
type Speaker struct {
ID PlayerID
Level int
Party PartyID
HasParty bool
Guild GuildID
HasGuild bool
Muted bool
}
// ChannelPolicy 是一个频道的发言规则。规则是数据,不是 if-else 硬编码,
// 这样才能随活动、赛季、VIP 等级热更新。
type ChannelPolicy struct {
Kind ChannelKind
MinLevel int // 低于该等级不可发言;0 表示不限
NeedParty bool // 必须已入队
NeedGuild bool // 必须已入会
ServerOnly bool // 仅服务端可发(系统公告)
}
var (
ErrMuted = errors.New("chat: speaker is muted")
ErrLevelTooLow = errors.New("chat: speaker level is too low")
ErrNeedParty = errors.New("chat: speaker must be in a party")
ErrNeedGuild = errors.New("chat: speaker must be in a guild")
ErrServerOnly = errors.New("chat: channel is server-only")
)
// CanSpeak 判断发言人是否满足频道规则,返回第一个不满足的原因。
// 顺序刻意固定:先看禁言(最重的处罚),再看频道本身的准入。
func CanSpeak(s Speaker, p ChannelPolicy) error {
if s.Muted {
return ErrMuted
}
if p.ServerOnly {
return ErrServerOnly
}
if s.Level < p.MinLevel {
return ErrLevelTooLow
}
if p.NeedParty && !s.HasParty {
return ErrNeedParty
}
if p.NeedGuild && !s.HasGuild {
return ErrNeedGuild
}
return nil
}
命令号与投递管线
卷二沿用卷一定稿的聊天命令号:客户端上行 CmdChatSend 0x0301,服务端下行 CmdChatRecv 0x0302。这两个号在卷一就已经占用,全卷不得改写。帧格式也沿用卷一的 11 字节固定头:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1),length 统计 cmd + seq + flags + payload,不含 length 字段自身。
一条下行帧的载荷是 from(8, BE) | kind(1) | body(UTF-8)。下面这段是 L1,go test 可以直接跑,测试逐字节校验了头与载荷:
// file: internal/chat/wire.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import "encoding/binary"
// 卷二沿用卷一定稿的命令号,不得改写。
const (
CmdChatSend uint16 = 0x0301 // 客户端 -> 服务端
CmdChatRecv uint16 = 0x0302 // 服务端 -> 客户端
)
// HeaderSize 是卷一固定头的长度:length(4) | cmd(2) | seq(4) | flags(1)。
const HeaderSize = 11
// EncodeRecv 把一条聊天消息编成 CmdChatRecv 帧:
// 固定头 + from(8, BE) + kind(1) + body(UTF-8)。
// length 统计 cmd+seq+flags+payload,不含 length 字段自身的 4 字节。
func EncodeRecv(seq uint32, from PlayerID, kind ChannelKind, body string) []byte {
payload := make([]byte, 0, 9+len(body))
var id [8]byte
binary.BigEndian.PutUint64(id[:], uint64(from))
payload = append(payload, id[:]...)
payload = append(payload, byte(kind))
payload = append(payload, body...)
frame := make([]byte, HeaderSize+len(payload))
binary.BigEndian.PutUint32(frame[0:4], uint32(7+len(payload)))
binary.BigEndian.PutUint16(frame[4:6], CmdChatRecv)
binary.BigEndian.PutUint32(frame[6:10], seq)
frame[10] = 0 // flags 留给分片与压缩
copy(frame[HeaderSize:], payload)
return frame
}
把三块拼起来,投递管线就是一条直线:CanSpeak 通过 → Recipients 解析收件人 → 对每个收件人各编一帧 → 逐个发送。下面是一段 snippet,展示这条直线;注意单个收件人发送失败只记日志,不影响其余收件人:
// snippet: internal/chat/dispatch.go(投递主循环,节选)
func Dispatch(send func(to PlayerID, frame []byte) error, w *World, m ChatMessage, seq uint32) error {
to, err := Recipients(w, m)
if err != nil {
return err // 没入队、私聊目标不存在等,直接回错误给调用方
}
frame := EncodeRecv(seq, m.From, m.Kind, m.Body)
for _, r := range to {
if err := send(r, frame); err != nil {
// 一个收件人写失败(掉线、缓冲满)不该中断整条广播。
// 真实的 seq 由每条连接的出站计数器提供,这里从参数传入。
_ = err
}
}
return nil
}
本节小结
频道模型与权限的要点只有三句:频道是数据(ChannelKind + ChannelPolicy),范围是快照(World),资格是纯函数(CanSpeak)。三者解耦之后,世界频道的扇出优化、跨服公会的扩展、活动期的临时规则,都变成了替换数据而不是改代码。下一节在这条管线的中间插入两级治理:敏感词过滤与限速。
阅读导航:上一节:目录 · 下一节:11.2 敏感词与限速治理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。