用TypeScript做API层设计

457 阅读4分钟

为什么需要构建一个层

  1. 使用简洁
    让使用者不用关注无关的内容,专注自己的内容。
  2. 依赖反转
    相对于细节的多变性,抽象的东西要稳定的多。  
    以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多
    
  3. 提供类型
    需要输入和输出都有类型。

稳定的代码

任何代码可以分成两个角色:客户端和服务端,或服务提供者和服务消费者,或调用者和被调用者,或使用者和被使用者,或依赖者和被依赖者。
被依赖的代码应该都是稳定的,这样才能保证使用它的代码不会因为修改导致bug。
在API的问题中,API就是被依赖的代码。
那么如何构造稳定的请求和响应呢。这看起来是设计接口(后端)的职责,其实也是前端的职责。

  • 什么是稳定的呢?
    将变化部分抽取掉,剩下的就是不变的部分,这些就是稳定的。
  • 变化的部分,举例:
    • 命名
      • 到底是叫userName还是username。
    • 表示
      • id用number还是string表示。
      • 性别男用0还是1表示。
      • 用1/0还是true/false。
    • 数据结构
      • 用CSV还是数组?1,2,3还是[1,2,3]
    • 算法/方式/手段
  • 不变的,举例:
    • 命名
      • 用标准,正确的拼写,完整表示概念。
    • 表示
      • 用新的类型,例如UserId,封装表示变化。
      • 用枚举,并且不依赖于枚举的值,枚举值只和枚举值比较。
      • 推荐此文章
    • 数据结构
      • 避免使用字符串。
    • 算法/方式/手段
      • 面向使用需求抽象。

变化的影响是连续的,如使用这些变化的部分,使用者也就必须去变化,形象地说,就是被污染了。因此不能直接使用变化的部分,而只使用抽象出来的不变的部分。

从使用角度,并且以面向对象的设计来看,API层有两个主要类型的对象:请求和响应。 要保证我们的请求和响应是稳定的,这需要一些经验,以下只是一些形式和术语,供参考和启发。

抽象

请求

interface APIRequest {}
  • 为什么要定义这个类型?
    所有请求对象从APIRequest扩展而来,从该类型扩展而来的好处是,能将所有请求统一看作APIRequest,在实现阶段我们会看到它的用处。

具体的请求

class LoginRequest implements APIRequest {
  username: string;
  password: string;
}
  • 为什么我们选择用class,而不是一个type或接口(如下)?
    type LoginRequest = {
      username: string;
      password: string;
    }
    
    因为类其实也是一个对象(在js里一切皆对象),它的引用就是一个标识,这意味着我们能够识别,并能将它映射到/login上,或它的相应的处理器"。
请求参数

通常如果我们要保证使用者不会漏掉参数,可以将参数放到构造器上:

class LoginRequest implements APIRequest {
  username: string;
  password: string;
  constructor(username: string, password: string) {
    this.username = username;
    this.password = password;
  }
}

可以使用ts的语法糖简化:

class LoginRequest implements APIRequest {
  constructor(public username: string, public password: string) {}
}

但注意在方法上的参数不能过多,而且有类型相同的多个参数时,会导致按位置传错参数,这个原则对构造器方法也适用。

响应

export type APIResponse<D = null> = Readonly<{
  code: number;
  ok: boolean;
  message: string | null;
  data: D;
}>;
  • 为什么要定义这个类型?
    响应可以抽象成 业务状态码,消息和数据,通常就是这样定义的。
  • 为什么它不是class?
    与请求不同,我们不需要识别响应,而只是取它的字段。也希望响应是一个纯数据对象。
  • 为什么是Readonly的?
    和使用const的理由一样,只要能变成不可修改的,那就变成不可修改的,因为希望响应是一个数据,而不是一个有状态的对象,修改状态能引发很多意想不到的bug。如果你要修改,那就用函数式的方法,创建一个新的数据:
function withData<S, T>(res: APIResponse<S>, data: T): APIResponse<T> {
  return {
    ...res,
    data
  };
}

所有响应对象从APIResponse扩展而来,例如,一个分页响应对象:

import { APIResponse } from './response';

export type PageResponse<T> = APIResponse<Page<T>>;

export type Page<T> = Readonly<{
  total: number;
  items: T[];
}>;

export function emptyPage<T>(): Page<T> {
  return {
    items: [] as T[],
    total: 0
  };
}

export function emptyPageResponse<T>(): PageResponse<T> {
  return {
    code: 0,
    ok: true,
    message: null,
    data: emptyPage<T>()
  };
}

一个登录响应对象:

export type LoginResponse = APIResponse<{
  token: string;
}>;

API中介者模式

我们希望这样使用:

const req = new LoginRequest(x, y);
const res = await api.perform(req);
if (res.ok) ...

实现

请求的实现

我们需要一个真正做请求的,我们叫它Performer

interface Performer<Req, Res = APIResponse> {
  perform(req: Req): Promise<Res>;
}

具体的请求实现

@Perform(LoginRequest)
export class Login implements Performer<LoginRequest, LoginResponse> {
  perform(req: LoginRequest): Promise<LoginResponse> {
    return httpClient.post(`/login`, { u: req.username, p: req.password });
  }
}

响应的实现

可以看到上面Login的http请求不一定返回我们想要的LoginResponse类型,此时需要转换。

实现不是本文的重点,可以自寻探索:D