主题、scope 与空间
这份文档只做一件事:用跑得出来的例子说清楚一条消息从发布到投递经过哪些层,以及每种订阅写法各自能收到什么。下面每段输出都是在真实网关上用 mosquitto_pub / mosquitto_sub 跑出来的,映射表是直接调用网关自己的函数打印的。
概念性的说明在资源与协议接入手册;这里只有例子。
1. 物理主题的四层
客户端写的是逻辑主题,网关在 broker 做任何匹配之前把它改写成物理主题。物理形式永远是:
{scope}/{domain}/{space}/{topic}| 层 | 管什么 | 取值 | | --- | --- | --- | | scope | 给谁 | all(广播)或 {publisherId}(只给这一个客户端) | | domain | 谁的 | 租户 ID,或几个租户共享的组 ID(见第 6 节) | | space | 在哪 | 客户自己划的隔离区,或默认空间 - | | topic | 你的主题 | 完全归你,网关一个字不加 |
客户端永远看不到前三层——它写 tele/s,收到 tele/s。
2. scope:广播 vs 定向
三个客户端都订 #,然后发三次。
① 资源 d1 普通发布 tele/s 物理 all/acme/-/tele/s
② 租户发 $earth/deliver/resource:d2/cmd/run 物理 resource:d2/acme/-/cmd/run
③ HTTP 给 d1 下命令(命令没带主题) 物理 resource:d1/acme/-/$earth/message实际收到:
| 订阅者 | 收到 | | --- | --- | | 资源 d1 订 # | tele/s {"c":23.5}<br>$earth/message {"sequence":2,"command":{"action":"reboot"}} | | 资源 d2 订 # | tele/s {"c":23.5}<br>cmd/run {"sequence":1,"target":"resource:d2","data":{"go":1}} | | 租户 acme 订 # | tele/s {"c":23.5} |
三件事值得看清:
- ① 广播给所有人,包括发送者自己。 d1 发的
tele/s,d1 自己也收到了——这就是普通 MQTT 的行为,网关没有做「不回给自己」这种事。 - ② 和 ③ 只到了目标一个人。 d1 没收到给 d2 的
cmd/run,租户两条都没收到。 - 定向消息带信封,广播不带。
cmd/run和$earth/message的载荷里有sequence、target,tele/s只有原文。
一条 # 订阅能同时覆盖两种,是因为挂载时 scope 位放的是 +(见第 4 节),投递时再把不该给你的 resource:xxx 丢掉。
3. $ 前缀:规则的关键词是「开头」
MQTT 3.1.1 §4.7.2:以通配符开头的过滤器不匹配以 $ 开头的主题名。
所以网关流量的物理名钉成 $earth/{domain}/{space}/…($ 在第一层),客户的 # 永远碰不到:
$earth/acme/-/events ← 信封流,# 收不到
$earth/acme/-/errors/tenant:acme ← 错误回执,# 收不到$earth/message 是例外,因为它是客户流量(一条给某台资源的命令):
resource:d1/acme/-/$earth/message
└───┬────┘
第一层是 resource:d1,不是 $earth → 所以 # 收得到也因此它可以被直接订阅。只想收命令的固件不必为了拿命令把整个空间的流量都拉过来:
| 订阅 | 收到 | | --- | --- | | # | tele/s(空间里的所有流量)+ $earth/message(命令) | | $earth/message | 只有 $earth/message {"sequence":2,"command":{"action":"reboot"}} |
$earth/ 下其他名字一律拒绝(SUBACK 0x80),并在 $earth/errors 上说明原因——不会静默接受一个永远不会投递的订阅。
4. 订阅映射表
直接调用网关的 mountFilter 打印。左列是这条连接持有的空间。
| 持有 | 你写的过滤器 | 实际挂载成 | | --- | --- | --- | | - | tele/# | +/acme/-/tele/# | | - | tele/+/state | +/acme/-/tele/+/state | | - | # | +/acme/-/# | | - | $earth/message | +/acme/-/$earth/message | | - | $earth/events | $earth/acme/-/events | | - | $earth/errors | $earth/acme/-/errors/+ | | - | $earth/nonsense | 拒绝(SUBACK 0x80) | | - | $space/mkt/tele/# | 拒绝(SUBACK 0x80) | | ops,mkt | tele/# | +/acme/ops/tele/# | | ops,mkt | $earth/events | $earth/acme/+/events | | ops,mkt | $space/ops/tele/# | +/acme/ops/tele/# | | ops,mkt | $space/mkt/tele/# | +/acme/mkt/tele/# | | ops,mkt | $space/+/tele/# | +/acme/+/tele/# | | ops,mkt | $space/# | +/acme/+/# | | ops,mkt | $space/secret/# | 拒绝(SUBACK 0x80) |
发布侧:
| 谁发的什么 | 物理主题 | | --- | --- | | d1 普通发布 tele/s | all/acme/-/tele/s | | 定向给 d2 的 cmd/run | resource:d2/acme/-/cmd/run | | 无主题的命令给 d1 | resource:d1/acme/-/$earth/message | | 信封流 | $earth/acme/-/events | | 给 tenant:acme 的错误 | $earth/acme/-/errors/tenant:acme |
注意 ops,mkt 那两行:tele/# 和 $space/ops/tele/# 挂载成同一个东西——因为 ops 是主空间,裸主题就是它。
5. 空间:一条连接持有多个
用户名决定持有哪些空间,第一个是主空间:
acme 持有 [-]
acme@ops 持有 [ops]
acme@ops,mkt 持有 [ops, mkt],主空间 ops
acme@* 持有全部,主空间 -三个订阅者,三次发布(分别在 ops、mkt、默认空间):
| 订阅者 | 收到 | | --- | --- | | acme@ops,mkt 订 # | tele/s {"value":"FROM-OPS"} | | acme@ops,mkt 订 $space/+/tele/# | tele/s {"value":"FROM-OPS"}<br>$space/mkt/tele/s {"value":"FROM-MKT"} | | acme@* 订 $space/# | $space/ops/tele/s {"value":"FROM-OPS"}<br>$space/mkt/tele/s {"value":"FROM-MKT"}<br>tele/s {"value":"FROM-DEFAULT"} |
读法:
#永远只是主空间。 第一行虽然持有mkt,#也不会多收 —— 裸主题绝不因为多持有而变宽。- 主空间保持裸名,其他空间带
$space/{空间}/前缀。 第二行里ops是裸的、mkt带前缀;第三行acme@*的主空间是-,所以ops和mkt都带前缀、默认空间那条是裸的。 - 前缀是必须的:两个空间里客户的主题完全一样(都叫
tele/s),跨空间的订阅者否则分不清消息来自哪边。
6. 组:domain 位换掉
前面所有例子里 domain 都是 acme。组是同一个位置上的另一种取值——它是几个租户共享的域:
acme@ops 物理 all/acme/ops/tele/s domain = acme(自己的域)
acme@g:ops 物理 all/g/ops/tele/s domain = g(组)
↑
只有这一位不同挂载也只差这一位:
| 用户名 | 订阅 | 挂载成 | | --- | --- | --- | | acme@ops | tele/# | +/acme/ops/tele/# | | acme@g:ops | tele/# | +/g/ops/tele/# | | acme@g:* | $space/# | +/g/+/# |
信封流同理:acme@g:ops 的是 $earth/g/ops/events。
6.1 组空间和同名的租户空间不是一回事
这是最容易踩的一条。acme 自己域里有个 ops,组 g 里也有个 ops——同名,但是两个完全独立的地方。
组 g 的成员是 acme 和 other。四个订阅者都订 #,然后发三次:
| 订阅者 | 收到 | | --- | --- | | acme@g:ops 订 # | tele/s {"value":"OTHER-in-GROUP-ops"} | | other@g:ops 订 # | tele/s {"value":"OTHER-in-GROUP-ops"} | | acme@ops 订 # | tele/s {"value":"ACME-in-OWN-ops"} | | acme 订 # | (什么都没有) | | acme@g:* 订 $space/# | $space/ops/tele/s {"value":"OTHER-in-GROUP-ops"}<br>$space/mkt/tele/s {"value":"ACME-in-GROUP-mkt"} |
读法:
other发的,acme收到了。 这就是组的全部意义:两个不同的租户在同一个组空间里能看见彼此。acme@ops没收到组里的消息,尽管那个空间也叫ops。acme@g:ops也没收到acme@ops的消息。同名不等于同一个地方——在自己域里建一个ops对acme@g:ops毫无帮助,反之亦然。acme(自己的默认空间)什么都没收到。 三次发布没有一次落在那里。acme@g:*看到组里的两个空间,各自带名字(主空间是-,而三次发布都不在-,所以两条都带前缀)。
6.2 谁进得去
acme@g: 退出码 0 组的默认空间
acme@g:ops 退出码 0 组的 ops
acme@g:-,mkt 退出码 0 组里两个空间
acme@g:* 退出码 0 组里能用的全部
other@g:ops 退出码 0 另一个成员,同一个地方
outsider@g:ops 退出码 5 不是成员
acme/d1@g: 退出码 5 资源进不了组两条拒绝的理由:
outsider "outsider" is not a member of group "g" (members: acme, other)
acme/d1 only a tenant key may enter a group; a group is an agreement between tenants资源进不了组是刻意的:组是租户之间的约定,不是它们硬件之间的约定。要让资源的数据进组,由租户在组空间里转发。
6.3 租户名和组名共用一个命名空间
既然组和租户都落在 domain 位,它们就共用同一个命名空间——all/acme/-/… 是同一个地方,不管 acme 是租户还是组。所以两边都会被拒:
已有租户 shared,再建同名组 → 409 domain_taken
"shared" is already in use as a tenant.
已有组 partners,再领同名租户 → 409 domain_taken
"partners" is already in use as a group.自助注册领租户走的是同一道检查,而且是 create-only 的占名,所以两个浏览器同时抢一个名字只会有一个成功。
空间名不受这条限制:空间的键是 {domain}:{space},按域隔离。组 g 里的 ops 和租户 acme 里的 ops 是两个独立的空间(见 6.1),这是允许且常见的。
同一种类型重复占名是正常操作:轮换租户密钥就是重新注册这个租户,编辑组就是重写它的记录——两者都不会被这道检查挡住。
6.4 冒号是组的唯一标志
acme@g → 本域里名叫 g 的空间(没有就拒绝)
acme@g: → 组 g 的默认空间不带冒号的名字永远指自己域里的空间,跟数据库里有什么无关。这样一个用户名在凭据的整个生命周期里只指一个地方——如果按「查一下 g 是空间还是组」来解析,那么哪天在自己域里建了个叫 g 的空间,所有用 acme@g 的客户端就会被静默改道。
漏了冒号不会猜,会拒绝并说清楚:
"g" is a group, not a space in "acme" — a group always takes a colon:
"@g:" for its default space, "@g:{space}" for one inside it7. 被拒时会说什么
订阅没持有的空间:
$ mosquitto_sub -u 'acme@ops' -t '$space/mkt/tele/#'
All subscription requests were denied.同时 $earth/errors 上收到:
Subscription denied: this connection holds [ops], not "mkt"
— name the spaces you need in the username, e.g. "user@ops,mkt" or "user@*"发布到没持有的空间:
{
"topic": "$space/mkt/tele/s",
"error": "This connection may not publish into space \"mkt\". It holds [ops] — name the spaces you need in the username, e.g. \"user@ops,mkt\"."
}两种都不断开连接——客户端的错误在 $earth/errors 上报告,只有协议层面的违规才结束会话。
8. 保留名一览
| 逻辑名 | 方向 | 用途 | | --- | --- | --- | | $earth/events | 订阅 | 可靠信封流:sequence、eventId、发布者、空间、原主题、原载荷 | | $earth/errors | 订阅 | 你的发布/订阅为什么被拒 | | $earth/message | 订阅 | 没带主题的命令投递在这里。唯一活在客户命名空间里的保留名 | | $earth/resume | 发布 | 「我上次收到第 N 号」,网关补齐漏掉的 | | $earth/deliver/{publisherId}/{topic} | 发布 | 记一条只给某一个客户端的消息 | | $space/{space}/{topic} | 订阅 + 发布 | 持有多个空间时,指定其中一个 |
9. 未来:标签 scope(尚未实现)
> 这一节是提案,代码里没有。标签目前只做存储和控制台筛选,消息流完全不认识它们。
资源可以带多个标签(见控制台文档的「标签」一节)。如果以后要支持「按标签订阅」,不能照搬空间那套:
d1 同时是 line-a 和 pilot
若标签进主题 → 得发两份 → 同时订了两个标签的人收到两遍 ✗
若按发布者展开 → 发一份,订阅端取并集 → 每人恰好一份 ✓第二个差异:空间在 CONNECT 时定死,标签随时能改;而物理主题是发布那一刻烘死的,所以标签进主题意味着每条发布都要读一次存储查当前标签——现在的发布热路径是零读。
所以标签不进主题,而是订阅时展开成发布者集合,复用已有的 scope 层:
| | 今天 | 提案 | | --- | --- | --- | | d1 普通发布 tele/s | all/acme/-/tele/s | from:resource:d1/acme/-/tele/s | | d2 普通发布 tele/s | all/acme/-/tele/s | from:resource:d2/acme/-/tele/s | | 定向给 d2 的 cmd/run | resource:d2/acme/-/cmd/run | to:resource:d2/acme/-/cmd/run | | 订阅 tele/# | +/acme/-/tele/# | +/acme/-/tele/#(不变) | | 订阅 $tag/line-a/tele/# | 没有 | from:resource:d1/acme/-/tele/#<br>from:resource:d2/acme/-/tele/# |
from: / to: 必须分开——今天靠一个 scope 值分不清「来自 d2」和「发给 d2」,加标签后就必须分。好处是不加新层级、不改深度,滚动升级不会静默丢消息。
做之前要定死的三条:
- 标签不是安全边界,空间才是。
$tag/x/#只做与已持有空间的交集,绝不是越出空间的途径。 - 不能往标签发布。 扇出的发布收不回来,且会让消息出现在没人在那里发过的地方。「给 line-a 全体下命令」是控制面的事——HTTP 上循环发 N 条各自寻址、各自入账的命令。
- 不提供
$tag/+。 没打标签的资源一个都不在里面,它和普通主题的差别没人能预测;普通主题已经表示「全体」。
还有一件必须做的:在线重挂。N 条字面量订阅是快照,重新打标签如果到不了在线订阅者,「改分组不动固件」这个唯一的价值就没了。走现有的集群信号总线(和凭据吊销同一套)。
最后是做之前该量的数:典型标签的成员数 ÷ 空间里的资源总数。小于 1/10 值得做;接近 1/2 就别做——那时候订 $earth/events 在客户端过滤更省事。