首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >全栈工程师如何用契约门禁守住端到端类型安全

全栈工程师如何用契约门禁守住端到端类型安全

原创
作者头像
IT大佬 jzit-top
发布于 2026-10-02 10:08:56
发布于 2026-10-02 10:08:56
120
举报

去年我们线上出过一次事故:后端把 dueAt 从 string 改成 Date,OpenAPI 没更新,前端仍按字符串渲染,结果任务列表在 iOS Safari 上直接白屏 27 分钟,影响 1.2 万用户。复盘时发现,问题不在某个框架,而在契约没有版本、没有兼容性门禁、没有端到端校验。这篇文章只讲我们后来怎么把这类问题在 CI 阶段拦住。

1. 契约层:Zod + ts-rest,而不是手写 DTO

我们选 Zod 而不是 io-ts,原因是 z.infer 推断更直观,且 zod-to-openapi 能直接生成文档。ts-rest 负责把契约绑定到 Fastify 和 React Query,避免两端各写一份类型。

代码语言:javascript
复制
// packages/contracts/src/task.ts
import { z } from 'zod';
import { initContract } from '@ts-rest/core';

const c = initContract();

export const TaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().trim().min(1).max(120),
  status: z.enum(['todo', 'doing', 'done']),
  dueAt: z.coerce.date().nullable(), // 明确 nullable,禁止 undefined
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});

export type Task = z.infer<typeof TaskSchema>;

export const taskContract = c.router({
  create: {
    method: 'POST',
    path: '/api/tasks',
    body: TaskSchema.pick({ title: true, dueAt: true }),
    responses: {
      201: TaskSchema,
      422: z.object({ code: z.literal('VALIDATION_ERROR'), issues: z.unknown() }),
    },
  },
});

2. 破坏性变更门禁:oasdiff + 语义化版本

契约变更必须过 CI。我们用 zod-to-openapi 生成 openapi.new.json,和主干上的 openapi.base.json 做 oasdiff breaking,ERR 级别直接阻断合并。

代码语言:javascript
复制
# .github/workflows/contract.yml
- run: pnpm tsx scripts/gen-openapi.ts > openapi.new.json
- run: |
    oasdiff breaking openapi.base.json openapi.new.json \
      --fail-on ERR --format githubactions

规则很硬:删除字段、收窄类型、新增必填字段 = ERR;新增可选字段 = WARN 可合。过去半年拦下 3 次破坏性变更,其中一次就是把 dueAt 从 nullable 改成 required。

3. 幂等性:真实业务里的隐藏杀手

全栈工程师容易忽略 POST 的幂等。用户双击、网络重试、移动端切后台再回来,都会重复创建。我们用 Idempotency-Key + Redis 做 24 小时去重。

代码语言:javascript
复制
// apps/api/src/plugins/idempotency.ts
export const idempotency: FastifyPluginAsync = async (app) => {
  app.addHook('preHandler', async (req, reply) => {
    if (req.method !== 'POST') return;
    const key = req.headers['idempotency-key'];
    if (typeof key !== 'string' || key.length < 16) {
      return reply.code(400).send({ code: 'IDEMPOTENCY_KEY_REQUIRED' });
    }
    const scope = `${req.user.id}:${req.routeOptions.url}:${key}`;
    const cached = await app.redis.get(scope);
    if (cached) {
      const { status, body } = JSON.parse(cached);
      return reply.code(status).send(body);
    }
    reply.header('x-idempotency-scope', scope);
  });

  app.addHook('onSend', async (req, reply, payload) => {
    const scope = reply.getHeader('x-idempotency-scope');
    if (typeof scope === 'string' && reply.statusCode < 500) {
      await app.redis.set(scope, JSON.stringify({
        status: reply.statusCode,
        body: payload,
      }), 'EX', 86400);
    }
    return payload;
  });
};

上线后统计:每天拦截约 340 次重复创建,其中 78% 来自移动端双击。

4. 前端:乐观更新 + 冲突解决

React Query 的乐观更新必须能回滚,且要处理服务端返回的 updatedAt 覆盖本地。

代码语言:javascript
复制
export function useCreateTask() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (input: CreateTaskInput) =>
      fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'idempotency-key': crypto.randomUUID(),
        },
        body: JSON.stringify(input),
      }).then(async (r) => {
        if (!r.ok) throw new Error((await r.json()).code);
        return TaskSchema.parse(await r.json());
      }),
    onMutate: async (input) => {
      await qc.cancelQueries({ queryKey: ['tasks'] });
      const prev = qc.getQueryData<Task[]>(['tasks']);
      const optimistic: Task = {
        id: `tmp-${crypto.randomUUID()}`,
        title: input.title,
        status: 'todo',
        dueAt: input.dueAt ?? null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };
      qc.setQueryData<Task[]>(['tasks'], (old = []) => [optimistic, ...old]);
      return { prev };
    },
    onError: (_e, _v, ctx) => qc.setQueryData(['tasks'], ctx?.prev),
    onSettled: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
  });
}

5. 指标与权衡

引入契约门禁后,契约相关事故从每月 1.8 次降到 0;MTTR 从 27 分钟降到 4 分钟;重复创建率从 2.3% 降到 0.07%。代价是每个 PR 多 40 秒 CI 时间,以及开发者必须学 Zod 的 .nullable() 和 .optional() 区别。

全栈工程师的专业性,不是“前后端都会写”,而是在边界上建立不可绕过的约束。类型、幂等、门禁、指标,缺一不可。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 契约层:Zod + ts-rest,而不是手写 DTO
  • 2. 破坏性变更门禁:oasdiff + 语义化版本
  • 3. 幂等性:真实业务里的隐藏杀手
  • 4. 前端:乐观更新 + 冲突解决
  • 5. 指标与权衡
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档