Dark Dwarf Blog background

前后端类型对齐设计

前后端类型对齐设计

这几天在写一个项目的前后端部分时,因为之前没定义好类型后面写得很痛苦,现在整理一下自己重构后采用的一些设计。

1. 后端类型定义

a.a. Service 类型定义

Service 只负责定义自己需要的 DTO,如:

interface FileUploadDTO {
  userId: string;
  folderId: string;
  fileBuffer: Buffer;
  fileSize: number;
  mimeType: string;
  originalName: string;
  hash: string;
}

interface DownloadLinkDTO {
  userId: string;
  fileId: string;
}

然后整理一下需要返回的内容,可以定义一些新的接口来存要返回的东西,比如我在处理文件返回的时候定义的针对前端的脱敏数据结构:

export interface IFilePublic {
  id: string;
  name: string;
  originalName: string;
  extension: string;
  mimeType: string;
  size: number;
  folder: string;
  user: IUserBasic;
  isStarred: boolean;
  isTrashed: boolean;
  trashedAt?: Date;
  isPublic: boolean;
  sharedWith: IShareInfo[];
  createdAt: Date;
  updatedAt: Date;
}

b.b. Response 类型定义

这一部分是我主要重构的地方,刚开始我并没有写这个东西、而是直接返回下面的 json:

res.status(StatusCodes.CREATED).json({
  success: true,
  message: "Registration successful",
  data: { user, token },
});

这有一个问题:首先手写 StatusCode 和这些字段还是比较麻烦的,然后后端定义了下面的 ApiResponse

export interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  message?: string;
}

如果直接返回 json 的话,data 字段的处理很容易不统一,比如有的是 data: file,有的又是 data: {file},前端解析很容易出错。于是我做了下面的设计:

  • 对每个 API 端口的 Response 进行明确的类型定义,而不是像前面这样自己手动构造 express Request。

  • 然后定义一个帮助方法 ResponseHelper,它会根据我们需要的 Response 进行统一处理。

这个 Response 类型定义虽然放在 src/types 目录下,但是它依赖了 service 中定义的前端脱敏数据结构,因为这本来就是后端决定返回给前端的东西。虽然 types 感觉应该不依赖别的东西、包含纯粹的类型定义,但我觉得这些 services 要直接返回的东西直接定义在 services 中更方便一些,就没有全部迁移到 response.types.ts 了。下面是一些定义的 Response:

// ==================== Auth Responses ====================
export interface AuthResponse {
  user: IUserPublic;
  token: string;
}

// ==================== User Responses ====================
export interface UserResponse {
  user: IUserPublic;
}

// ==================== File Responses ====================
export interface FileUploadResponse {
  file: IFilePublic;
}

如果不需要对 service 返回的内容做任何修饰的话,直接 extends 即可:

export interface FolderContentResponse extends IFolderContent {}

然后 ResponseHelper 接收这些东西、把它处理成最终的 express Response:

import { Response } from "express";
import { ApiResponse } from "../types/response.types";
import { StatusCodes } from "http-status-codes";

export class ResponseHelper {
  static success<T>(
    res: Response,
    data: T,
    statusCode: number = StatusCodes.OK,
    message?: string
  ): Response {
    const response: ApiResponse<T> = {
      success: true,
      data,
      ...(message && { message }),
    };
    return res.status(statusCode).json(response);
  }

  static created<T>(res: Response, data: T, message?: string): Response {
    return this.success(res, data, StatusCodes.CREATED, message);
  }

  static message(
    res: Response,
    message: string,
    statusCode: number = StatusCodes.OK
  ): Response {
    const response: ApiResponse = {
      success: true,
      message,
    };
    return res.status(statusCode).json(response);
  }

  static noContent(res: Response): Response {
    return res.status(StatusCodes.NO_CONTENT).send();
  }

  static ok<T>(res: Response, data: T): Response {
    return this.success(res, data, StatusCodes.OK);
  }
}

这样我们的后端的 Controller 的返回值就变成下面这样了:

return ResponseHelper.created<AuthResponse>(
  res,
  { user, token },
  "Registration successful"
);

这个返回就非常统一美观了。

2. 前端类型定义

我们也对前端做类似的封装,把我们的前端 Request 作为泛型类型传给 apiClient 调用,service 只需要传入即可:

get: <T>(url: string): Promise<ApiResponse<T>> => {
  return apiClient.get<ApiResponse<T>>(url).then((response) => response.data);
},

post: <T, D>(url: string, data?: D): Promise<ApiResponse<T>> => {
  return apiClient
    .post<ApiResponse<T>>(url, data)
    .then((response) => response.data);
},

然后剩下的就比较好办了,前端直接在 src/types 中把 Request、Response 全部放进去就行了:

export const loginSchema = z.object({
  email: z.email("Invalid email format"),
  password: z.string().min(1, "Password is required"),
});

export type LoginRequest = z.infer<typeof loginSchema>;

注意看 backend 的 controller 是从前端的哪里,比如对于下面的 controller:

const file = await this.fileService.uploadFile({
  userId: req.user.id,
  folderId,
  fileBuffer: req.file.buffer,
  fileSize: req.file.size,
  mimeType: req.file.mimetype,
  originalName: req.file.originalname,
  hash,
});

originalName 这个字段是从 req.file 中解析的,我们前端的 Request 就不需要包含这个东西了。

最终的效果如下:

login: async (req: LoginRequest): Promise<AuthResponse> => {
  try {
    const response = await api.post<AuthResponse, LoginRequest>(
      "/api/auth/login",
      req
    );
    if (response.success && response.data) {
      localStorage.setItem("token", response.data.token);
      return response.data;
    }
    throw new Error(response.message || "Login failed");
  } catch (error) {
    throw new Error(error instanceof Error ? error.message : "Login failed");
  }
},

这样前后端类型对齐就比较方便了。

p.s.p.s.这个方案也是初步的,之后可能还会修正。