PAYATHON 2026

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 字符串”的设计:

  1. database 用 BIGINT 保存主键。
  2. Domain Layer 用 PostId、UserId 等类型表示身份。
  3. Application Layer 只处理这些领域 ID。
  4. HTTP、API 和 CLI adapter 通过共享的 PublicIdCodec 编解码。
  5. Infrastructure Layer 只完成领域 ID 与数据库字段的映射。
  6. 公开接口是否统一使用 base36,由 API contract 和用户体验决定,与架构分层无关。

这种设计把 URL 表示、业务身份和数据库存储分开,同时保持公开 ID 的格式一致。以后修改公开编码格式时,领域逻辑和持久化结构也不需要随之调整。

备注:内容仅供参考。