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

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

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 更适合你!

相关推荐
10年前端老司机1 小时前
什么!纯前端也能识别图片中的文案、还支持100多个国家的语言
前端·javascript·vue.js
摸鱼仙人~1 小时前
React 性能优化实战指南:从理论到实践的完整攻略
前端·react.js·性能优化
程序员阿超的博客2 小时前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 2452 小时前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇7 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖8 小时前
http的缓存问题
前端·javascript·http
小小小小宇8 小时前
请求竞态问题统一封装
前端
loriloy8 小时前
前端资源帖
前端
源码超级联盟8 小时前
display的block和inline-block有什么区别
前端
GISer_Jing8 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js