base36 ID 转换应由哪个架构层负责?
结论
base36 编解码属于系统边界上的表示转换,适合放在 Presentation Layer。更准确地说,应由负责输入、输出的 interface adapter 处理。
各层可以这样分工:
- database 使用 integer 或
BIGINT存储主键。 - Domain Layer 使用强类型 ID,内部值仍是 integer 或
bigint。 - HTTP、REST API 和面向用户的 CLI 接收参数时,将 base36 字符串解析为领域 ID;输出 URL 或响应时,再将领域 ID 编码为 base36。
- 多个接口共用一个
PublicIdCodec,避免重复实现转换规则。 - Infrastructure Layer 只处理领域 ID 与数据库字段之间的映射。
所有外部接口使用相同的 base36 格式,说明它们遵循同一种外部 contract。转换逻辑是否属于 Domain Layer,则取决于这种格式本身有没有成为领域概念。
为什么转换应放在系统边界
同一个 ID 可以有多种表示:
数据库表示:123456789
URL 表示: 21i3v9
领域身份: PostId(123456789)
三种表示指向同一个身份,用途各不相同。
base36 主要用于缩短 URL、命令行参数和 API 文本中的 ID。它不会改变实体身份,也没有引入新的业务规则。把格式改成 base58、带前缀的 post_21i3v9 或普通十进制字符串,通常不会影响领域模型和应用用例。
转换过程可以放在系统边界:
HTTP path / CLI argument / API JSON
↓
decode base36
↓
Domain ID
↓
Application Service
↓
Repository
↓
database BIGINT
输出时沿相反方向转换。
各层分别负责什么
Presentation Layer 或 Interface Adapter
这一层负责:
- 从 URL path、JSON、表单或 CLI 参数中读取 base36 ID。
- 校验字符、长度、大小写和取值范围。
- 将输入转换为领域 ID。
- 将领域 ID 编码成 API 字段或 URL。
- 将非法格式转换为
400 Bad Request、CLI 参数错误等接口级结果。
这里的 Presentation Layer 不限于 HTML 页面。HTTP controller、REST serializer 和 CLI command handler 都是系统的输入输出适配器。
Application Layer
Application Service 接收已经解析好的领域 ID:
const post = await getPost.execute(new PostId(value));
调用方传入的是 base36、十进制字符串还是其他格式,对应用服务没有影响。同一个用例因此可以供 HTTP、CLI、消息消费者和测试代码复用。
如果应用层的公开方法直接接收 "21i3v9",应用层就会依赖一种特定的外部表示。只有当 base36 已经被定义为应用级协议时,这种依赖才有必要。
Domain Layer
Domain Layer 需要表达 ID 的领域类型,例如 PostId。ID 在 URL 中以哪种格式显示,由边界层处理。
例如:
class PostId {
constructor(readonly value: bigint) {
if (value <= 0n) {
throw new Error("PostId must be positive");
}
}
equals(other: PostId): boolean {
return this.value === other.value;
}
}
强类型 ID 可以防止把 UserId 传给需要 PostId 的方法,也能避免领域对象依赖 URL 格式。
编码后的值本身具有业务含义时,可以把它建模为领域对象。例如,业务明确要求某个公开编号长期稳定、便于人工抄写,并把格式纳入业务规则。这种情况下,可以建立独立的 PublicPostId 值对象,无需让整个领域模型都使用 base36 字符串。
Infrastructure Layer
Infrastructure Layer 负责数据库映射:
class SqlPostRepository implements PostRepository {
async findById(id: PostId): Promise<Post | null> {
const row = await db.queryOne(
"SELECT id, title FROM posts WHERE id = ?",
[id.value],
);
return row
? new Post(new PostId(BigInt(row.id)), row.title)
: null;
}
}
数据库适配器可以在 BIGINT 和 PostId 之间转换。URL 使用 base36,不应导致 data access 层返回 base36 字符串,因为数据库层并不知道数据最终会用于 URL、CLI 还是后台任务。
推荐实现方式
编码器可以放在 interface adapters 共用的模块中,例如:
src/
├── domain/
│ └── PostId.ts
├── application/
│ └── GetPost.ts
├── infrastructure/
│ └── SqlPostRepository.ts
└── interfaces/
├── shared/
│ └── PublicIdCodec.ts
├── http/
└── cli/
PublicIdCodec 可以用纯函数实现。下面的 TypeScript 示例使用 bigint,以免 JavaScript number 超过 Number.MAX_SAFE_INTEGER 后丢失精度:
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
const MAX_DATABASE_ID = 9223372036854775807n; // signed BIGINT
export function encodeBase36(value: bigint): string {
if (value <= 0n || value > MAX_DATABASE_ID) {
throw new RangeError("ID is outside the supported range");
}
return value.toString(36);
}
export function decodeBase36(input: string): bigint {
// 此处规定公开 ID 只接受小写 canonical form。
if (!/^[0-9a-z]+$/.test(input)) {
throw new Error("Invalid base36 ID");
}
// signed BIGINT 的 base36 表示不会很长,先限制输入长度。
if (input.length > 13) {
throw new RangeError("ID is outside the supported range");
}
let value = 0n;
for (const character of input) {
const digit = ALPHABET.indexOf(character);
if (digit < 0) {
throw new Error("Invalid base36 ID");
}
value = value * 36n + BigInt(digit);
if (value > MAX_DATABASE_ID) {
throw new RangeError("ID is outside the supported range");
}
}
if (value <= 0n) {
throw new RangeError("ID must be positive");
}
return value;
}
HTTP handler 在进入 Application Layer 前完成转换:
async function getPostHandler(req: Request, res: Response) {
let postId: PostId;
try {
postId = new PostId(decodeBase36(req.params.id));
} catch {
res.status(400).json({ error: "Invalid post ID" });
return;
}
const post = await getPost.execute(postId);
if (post === null) {
res.status(404).json({ error: "Post not found" });
return;
}
res.json({
id: encodeBase36(post.id.value),
title: post.title,
url: `/posts/${encodeBase36(post.id.value)}`,
});
}
CLI adapter 可以复用同一个 codec:
async function runShowPostCommand(idArgument: string): Promise<void> {
const id = new PostId(decodeBase36(idArgument));
const post = await getPost.execute(id);
if (post === null) {
throw new Error("Post not found");
}
console.log(`${encodeBase36(post.id.value)} ${post.title}`);
}
这样,所有外部接口使用统一格式,Application Layer 和 Domain Layer 也不会依赖 base36。
所有接口都必须使用 base36 吗
接口采用哪种文本格式,应根据使用者来决定,分层本身不要求所有接口保持一致。
公开 Web URL 和公开 REST API 统一使用 base36 字符串,通常更方便用户理解:
{
"id": "21i3v9",
"url": "/posts/21i3v9"
}
面向普通用户的 CLI 也可以接受同样的值:
app post show 21i3v9
内部运维 CLI、数据修复工具或数据库管理脚本可能更适合直接使用整数。如果需要同时支持两种格式,应通过参数明确区分,避免用隐含规则判断 "123" 是十进制还是 base36:
app post show --public-id 21i3v9
app post show --db-id 123456789
对外 contract 发布后,最好保持稳定。将来更换编码方式时,可以继续兼容旧格式、增加版本,或引入带类型前缀的公开 ID,无需迁移领域对象和数据库主键。
实现时容易忽略的问题
明确 canonical form
需要提前决定:
- 是否只输出小写字母。
- 是否接受大写输入。
- 是否允许前导零,例如
000a。 - 非 canonical URL 是拒绝,还是重定向到标准形式。
一种常见做法是只输出和接受小写形式,同时拒绝前导零,让每个数据库 ID 对应唯一的规范 URL。
不要使用不安全的数值转换
在 JavaScript 或 TypeScript 中,下面的代码可能使较大的 ID 丢失精度:
parseInt(id, 36);
数据库使用 BIGINT 时,应改用 bigint 或支持任意精度整数的库。原生 JSON.stringify() 无法直接序列化 bigint,API 输出前还需要先把 ID 编码成字符串。
限制输入长度和数值范围
解码器需要限制:
- 可接受的字符集。
- 最大字符串长度。
- 解码后的最大值。
- 是否允许
0或负数。
任意长度的字符串不能直接交给数据库,数值溢出也不应被报告成“资源不存在”。
区分格式错误和资源不存在
错误语义可以这样划分:
- base36 格式非法或越界:
400 Bad Request - 格式合法,但对应记录不存在:
404 Not Found
CLI 也应分别报告“ID 格式错误”和“找不到资源”。
base36 不提供安全性
base36 只是进制转换。每个整数都会得到固定结果,攻击者仍可连续枚举相邻 ID。它不能隐藏记录数量,也无法代替授权检查。
如果某类资源不能被其他用户读取,每次访问都要执行 authentication 和 authorization。需要难以猜测的公开标识时,可以在整数主键之外增加随机 public ID,不要把 base36 当作安全机制。
最终建议
可以采用“内部强类型整数 ID,外部 base36 字符串”的设计:
- database 用
BIGINT保存主键。 - Domain Layer 用
PostId、UserId等类型表示身份。 - Application Layer 只处理这些领域 ID。
- HTTP、API 和 CLI adapter 通过共享的
PublicIdCodec编解码。 - Infrastructure Layer 只完成领域 ID 与数据库字段的映射。
- 公开接口是否统一使用 base36,由 API contract 和用户体验决定,与架构分层无关。
这种设计把 URL 表示、业务身份和数据库存储分开,同时保持公开 ID 的格式一致。以后修改公开编码格式时,领域逻辑和持久化结构也不需要随之调整。
备注:内容仅供参考。
