如何优雅解决前后端数据结构重复问题

如何优雅解决前后端数据结构重复问题

21 年的时候曾在知乎上提了一个问题 如何优雅解决前后端重复数据结构问题,那个时候自己写了后端接口,需要编写成文档通知到前端,然后前端的开发去根据文档一个个修改,效率十分低下。一直在想有没有什么好的解决方案,也确实找到一些比较好的解决方案。

前后端通信

现在前后端项目都基本是分离了的,随着 TS 的流行,越来越多的前端项目开始使用 TS 来写,后端百家争鸣,基本每种语言都有自己的 web 框架。从 PHP、Python、Java、Golang 等等都有,但是都避免不了的是定义各种 数据结构 进行数据的传输,那么这个时候就会出现一个问题:

TS 的静态类型检查,让我们在前端进行数据请求的时候,需要明确定义响应的相应数据结构,这时,前端需要重新定义一份在后端已经定义过的 数据结构 ,一旦后端发生一点改变不及时通知,前后端就会造成不统一的情况。这时前后端就必须保持两者的一致性,一个修改另一个必须时刻保持同步,同时还要维护不同语言的相同代码。

要解决这个问题无非就是两种方案

  1. 统一用一套数据结构代码:前后端基于同一种语言,开发引入的是一个公共模块的实体代码。

  2. 一端修改另一端监听后自动生成:以后端为主,前端随着后端结构体修改而自动生成

今天我们主要说的也就是第二种方案,因为第一种对于大多数国内公司不适用,少部分可以适用的无疑是幸福的~

openapi-typescript

大概一年前参与开发 Astro 的 大佬 drwpow 开发了 openapi-typescript,仿佛发现了新大陆。直接基于 openapi 文档进行生成 types,完美解决了这个问题。 相比起基于 Java class 或者 Golang struct 的生成更加方便快捷。而且现在很多库都可以直接集成 openapi 生成文档,可以说适用性更高。同时他生成的 types 也可以用于 Axios。只需要一个命令就可以生成。并且配套了 openapi-fetch 模块,直接可以使用。

生成类型

我们可以直接 npm/yarn/pnpm 进行安装

shell 复制代码
npm i -D openapi-typescript
# or
yarn add -D openapi-typescript
# or
pnpm add -D openapi-typescript

然后直接生成即可

shell 复制代码
# npm Local schema
npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# 🚀 ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]

# pnpm Remote schema
pnpm exec openapi-typescript https://localhost:8080/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# 🚀 https://localhost:8080/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]

在我们需要的地方进行导入

typescript 复制代码
import type { paths, components } from "./api/v1"; // generated by openapi-typescript

// Schema Obj
type MyType = components["schemas"]["MyType"];

// Path params
type EndpointParams = paths["/my/endpoint"]["parameters"];

// Response obj
type SuccessResponse =
  paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
  paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];

openapi-fetch

我们直接使用它配套的 openapi-fetch

shell 复制代码
npm i openapi-fetch
# or
yarn add openapi-fetch
# or
pnpm add openapi-fetch

使用方式也很简单,引用我们上一步生成的 path​ 即可直接创建一个客户端

typescript 复制代码
import createClient from "openapi-fetch";
import type { paths } from "./api/v1";

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });

const { data, error } = await client.GET("/blogposts/{post_id}", {
  params: {
    path: { post_id: "my-post" },
    query: { version: 2 },
  },
});

const { data, error } = await client.PUT("/blogposts", {
  body: {
    title: "New Post",
    body: "<p>New post body</p>",
    publish_date: new Date("2023-03-01T12:00:00Z").getTime(),
  },
});

Spring Boot 适配

每个请求库对于数组格式的 query param​ 处理方式不一样,我们需要修改 openapi-typescript 一下以适配 Spring Boot 中的格式

typescript 复制代码
import createClient from "openapi-fetch";
import {decodeUrl} from "~/server/utils";
import {env} from "~/env";
import {type paths} from "~/common/core-client";

const coreClient = createClient<paths>({
  baseUrl: decodeUrl(env.CORE_API_ROOT).baseUrl,
  headers: {
    // "x-api-key": "",
  },
  querySerializer(query) {
    const queryArray: string[] = []
    for (const [k, v] of Object.entries(query)) {
      if (Array.isArray(v)) {
        // 数组格式需要修改为 a=1&a=2 而不是 a[]=1,2
        for (const value of v) {
          queryArray.push(`${k}=${value}`)
        }
      } else if (k === 'pageable') {
        // Spring boot 接受的参数是分页的时候需要进行处理下
        const pv = v as Record<string, never>;
        for (const [k, v] of Object.entries(pv)) {
          queryArray.push(`${k}=${v}`)
        }
      } else {
        queryArray.push(`${k}=${v}`)
      }
    }
    // 可以添加打印语句看看结果 console.log(`query param ${queryArray.join("&")}`)
    return queryArray.join("&");
  }
});

export default coreClient;

授权

请求之前添加授权校验即可,这里对于每个请求库差别不大

typescript 复制代码
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./api/v1";

let accessToken: string | undefined = undefined;

const authMiddleware: Middleware = {
  async onRequest(req) {
    // 获取 access token,例如可以从 cookie 获取
    if (!accessToken) {
      const authRes = await someAuthFunc();
      if (authRes.accessToken) {
        accessToken = authRes.accessToken;
      } else {
        // 授权错误处理,可以全局消息提示或者 抛出异常
      }
    }

    // 添加 token 到请求头中
    req.headers.set("Authorization", `Bearer ${accessToken}`);
    return req;
  },
};

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });

// 使用中间件
client.use(authMiddleware);

const authRequest = await client.GET("/some/auth/url");

uni-app 使用

在 uni-app 中微信小程序端无法使用 openapi-fetch​ 的,因为有一部分 api 在微信小程序中是没有的。但是可以使用 axios​,所以可以使用 openapi-typescript 配合一起使用。

生成了以后直接使用即可

typescript 复制代码
import axios from 'uniapp-axios-adapter'
const request = axios.create({
  baseURL: 'http://127.0.0.1:8080',
  timeout: 10000
})

request.interceptors.request.use((config) => {
  // 权限校验等
  return config
})

request.interceptors.response.use(async (response) => {
  if (response.status >= 200 && response.status < 300) {
    return await Promise.resolve(response.data)
  }
  return await Promise.reject(response)
})


// 直接使用即可
export const wechatAuth = async (code: string) => {
  const response = await request.get<components['schemas']['CodeSession']>(`/wechat/auth/${code}`)
  return response.data
}

swagger-typescript-api

swagger-typescript-api 则是基于 swagger 的生成器,同上面一样,安装后直接使用即可

shell 复制代码
npm i -D swagger-typescript-api
# or
yarn add -D swagger-typescript-api
# or
pnpm add -D swagger-typescript-api

直接使用即可

shell 复制代码
# npm Local schema
npx  swagger-typescript-api -p ./swagger.json -o ./src -n myApi.ts
# 🚀 ./wagger.json -> ./src/myApi.ts

# pnpm Remote schema
pnpm exec swagger-typescript-api -p http://127.0.0.1:8080/swagger/documentation.yaml   -o ./src -n myApi.ts 
# 🚀 http://127.0.0.1:8080/swagger/documentation.yaml -> ./src/myApi.ts

相比于 openapi-typescript,他可选项更多,他不依赖于特定的请求库但是使用门槛也相对高一点。他还提供了与 axios 直接集成,对于接入老项目也很方便!

生成结果对比

简单对比一下一个微信登录接口的生成情况

openapi-typescript

typescript 复制代码
/**
 * This file was auto-generated by openapi-typescript.
 * Do not make direct changes to the file.
 */

export interface paths {
  '/': {
    get: {
      responses: {
        /** @description OK */
        200: {
          content: {
            'text/plain': string
          }
        }
      }
    }
  }
  '/wechat/auth/{code}': {
    get: {
      parameters: {
        path: {
          code: string
        }
      }
      responses: {
        /** @description OK */
        200: {
          content: {
            '*/*': components['schemas']['CodeSession']
          }
        }
      }
    }
  }
}

export type webhooks = Record<string, never>

export interface components {
  schemas: {
    CodeSession: {
      sessionKey: string
      unionId: string
      errorMessage: string
      openId: string
      /** Format: int32 */
      errorCode?: number
    }
  }
  responses: never
  parameters: never
  requestBodies: never
  headers: never
  pathItems: never
}

export type $defs = Record<string, never>

export type external = Record<string, never>

export type operations = Record<string, never>

swagger-typescript-api

typescript 复制代码
/* eslint-disable */
/* tslint:disable */
/*
 * ---------------------------------------------------------------
 * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API        ##
 * ##                                                           ##
 * ## AUTHOR: acacode                                           ##
 * ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
 * ---------------------------------------------------------------
 */

export interface CodeSession {
  sessionKey?: string;
  unionId?: string;
  errorMessage?: string;
  openId?: string;
  /** @format int32 */
  errorCode?: number;
}

export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;

export interface FullRequestParams extends Omit<RequestInit, "body"> {
  /** set parameter to `true` for call `securityWorker` for this request */
  secure?: boolean;
  /** request path */
  path: string;
  /** content type of request body */
  type?: ContentType;
  /** query params */
  query?: QueryParamsType;
  /** format of response (i.e. response.json() -> format: "json") */
  format?: ResponseFormat;
  /** request body */
  body?: unknown;
  /** base url */
  baseUrl?: string;
  /** request cancellation token */
  cancelToken?: CancelToken;
}

export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;

export interface ApiConfig<SecurityDataType = unknown> {
  baseUrl?: string;
  baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
  securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
  customFetch?: typeof fetch;
}

export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
  data: D;
  error: E;
}

type CancelToken = Symbol | string | number;

export enum ContentType {
  Json = "application/json",
  FormData = "multipart/form-data",
  UrlEncoded = "application/x-www-form-urlencoded",
  Text = "text/plain",
}

export class HttpClient<SecurityDataType = unknown> {
  public baseUrl: string = "https://TimeLine";
  private securityData: SecurityDataType | null = null;
  private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
  private abortControllers = new Map<CancelToken, AbortController>();
  private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);

  private baseApiParams: RequestParams = {
    credentials: "same-origin",
    headers: {},
    redirect: "follow",
    referrerPolicy: "no-referrer",
  };

  constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
    Object.assign(this, apiConfig);
  }

  public setSecurityData = (data: SecurityDataType | null) => {
    this.securityData = data;
  };

  protected encodeQueryParam(key: string, value: any) {
    const encodedKey = encodeURIComponent(key);
    return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
  }

  protected addQueryParam(query: QueryParamsType, key: string) {
    return this.encodeQueryParam(key, query[key]);
  }

  protected addArrayQueryParam(query: QueryParamsType, key: string) {
    const value = query[key];
    return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
  }

  protected toQueryString(rawQuery?: QueryParamsType): string {
    const query = rawQuery || {};
    const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
    return keys
      .map((key) => (Array.isArray(query[key]) ? this.addArrayQueryParam(query, key) : this.addQueryParam(query, key)))
      .join("&");
  }

  protected addQueryParams(rawQuery?: QueryParamsType): string {
    const queryString = this.toQueryString(rawQuery);
    return queryString ? `?${queryString}` : "";
  }

  private contentFormatters: Record<ContentType, (input: any) => any> = {
    [ContentType.Json]: (input: any) =>
      input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
    [ContentType.Text]: (input: any) => (input !== null && typeof input !== "string" ? JSON.stringify(input) : input),
    [ContentType.FormData]: (input: any) =>
      Object.keys(input || {}).reduce((formData, key) => {
        const property = input[key];
        formData.append(
          key,
          property instanceof Blob
            ? property
            : typeof property === "object" && property !== null
            ? JSON.stringify(property)
            : `${property}`,
        );
        return formData;
      }, new FormData()),
    [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
  };

  protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
    return {
      ...this.baseApiParams,
      ...params1,
      ...(params2 || {}),
      headers: {
        ...(this.baseApiParams.headers || {}),
        ...(params1.headers || {}),
        ...((params2 && params2.headers) || {}),
      },
    };
  }

  protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
    if (this.abortControllers.has(cancelToken)) {
      const abortController = this.abortControllers.get(cancelToken);
      if (abortController) {
        return abortController.signal;
      }
      return void 0;
    }

    const abortController = new AbortController();
    this.abortControllers.set(cancelToken, abortController);
    return abortController.signal;
  };

  public abortRequest = (cancelToken: CancelToken) => {
    const abortController = this.abortControllers.get(cancelToken);

    if (abortController) {
      abortController.abort();
      this.abortControllers.delete(cancelToken);
    }
  };

  public request = async <T = any, E = any>({
    body,
    secure,
    path,
    type,
    query,
    format,
    baseUrl,
    cancelToken,
    ...params
  }: FullRequestParams): Promise<HttpResponse<T, E>> => {
    const secureParams =
      ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
        this.securityWorker &&
        (await this.securityWorker(this.securityData))) ||
      {};
    const requestParams = this.mergeRequestParams(params, secureParams);
    const queryString = query && this.toQueryString(query);
    const payloadFormatter = this.contentFormatters[type || ContentType.Json];
    const responseFormat = format || requestParams.format;

    return this.customFetch(`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, {
      ...requestParams,
      headers: {
        ...(requestParams.headers || {}),
        ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
      },
      signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null,
      body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
    }).then(async (response) => {
      const r = response as HttpResponse<T, E>;
      r.data = null as unknown as T;
      r.error = null as unknown as E;

      const data = !responseFormat
        ? r
        : await response[responseFormat]()
            .then((data) => {
              if (r.ok) {
                r.data = data;
              } else {
                r.error = data;
              }
              return r;
            })
            .catch((e) => {
              r.error = e;
              return r;
            });

      if (cancelToken) {
        this.abortControllers.delete(cancelToken);
      }

      if (!response.ok) throw data;
      return data;
    });
  };
}

/**
 * @title TimeLine API
 * @version 1.0.0
 * @baseUrl https://TimeLine
 *
 * TimeLine API
 */
export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDataType> {
  /**
   * No description
   *
   * @name GetRoot
   * @request GET:/
   */
  getRoot = (params: RequestParams = {}) =>
    this.request<string, any>({
      path: `/`,
      method: "GET",
      ...params,
    });

  wechat = {
    /**
     * No description
     *
     * @name AuthDetail
     * @request GET:/wechat/auth/{code}
     */
    authDetail: (code: string, params: RequestParams = {}) =>
      this.request<CodeSession, any>({
        path: `/wechat/auth/${code}`,
        method: "GET",
        ...params,
      }),
  };
}

可以看得出后者会更复杂约束会更多一点,但是也更完整一点。

总结

如果你希望前端约束开箱即用,那么 openapi-typescript 无疑是你最好的选择。如果你希望可以有更多自定义空间,并且可以与 axios 无缝集成,那么 swagger-typescript-api 更适合你!

相关推荐
耶啵奶膘1 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^3 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie3 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic4 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿4 小时前
webWorker基本用法
前端·javascript·vue.js
cy玩具4 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
qq_390161775 小时前
防抖函数--应用场景及示例
前端·javascript
John.liu_Test6 小时前
js下载excel示例demo
前端·javascript·excel
Yaml46 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事6 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro