《MMO 服务器开发》11.1 频道模型与权限

聊天是 MMO 里最容易被低估的系统:频道种类多、投递范围各异、发言资格还会随等级、队伍与活动变化。本节把频道建模成数据、把投递范围收敛成一份最小世界视图、把权限判断写成纯函数,用三个能 go test 通过的 L1 实现讲清频道模型与权限的边界。

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 敏感词与限速治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标