
# 主题、scope 与空间

这份文档只做一件事：**用跑得出来的例子**说清楚一条消息从发布到投递经过哪些层，以及每种订阅写法各自能收到什么。下面每段输出都是在真实网关上用 `mosquitto_pub` / `mosquitto_sub` 跑出来的，映射表是直接调用网关自己的函数打印的。

概念性的说明在[资源与协议接入手册](RESOURCE-INTEGRATION.zh-CN.md)；这里只有例子。

## 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 it
```

## 7. 被拒时会说什么

订阅没持有的空间：

```
$ 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@*"
```

发布到没持有的空间：

```json
{
  "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（**尚未实现**）

> 这一节是**提案**，代码里没有。标签目前只做存储和控制台筛选，消息流完全不认识它们。

资源可以带多个标签（见[控制台文档](CONSOLE.zh-CN.md)的「标签」一节）。如果以后要支持「按标签订阅」，**不能照搬空间那套**：

```
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」，加标签后就必须分。好处是不加新层级、不改深度，滚动升级不会静默丢消息。

做之前要定死的三条：

1. **标签不是安全边界，空间才是。** `$tag/x/#` 只做与已持有空间的交集，绝不是越出空间的途径。
2. **不能往标签发布。** 扇出的发布收不回来，且会让消息出现在没人在那里发过的地方。「给 line-a 全体下命令」是控制面的事——HTTP 上循环发 N 条各自寻址、各自入账的命令。
3. **不提供 `$tag/+`。** 没打标签的资源一个都不在里面，它和普通主题的差别没人能预测；普通主题已经表示「全体」。

还有一件必须做的：**在线重挂**。N 条字面量订阅是快照，重新打标签如果到不了在线订阅者，「改分组不动固件」这个唯一的价值就没了。走现有的集群信号总线（和凭据吊销同一套）。

最后是**做之前该量的数**：典型标签的成员数 ÷ 空间里的资源总数。小于 1/10 值得做；接近 1/2 就别做——那时候订 `$earth/events` 在客户端过滤更省事。
