Skip to content

Interaction with API

Chen Junda edited this page Jul 22, 2018 · 3 revisions

与API交互

在前后端分离的架构里,前后端通过预先定义的Rest风格的API进行交互。前端需要数据时,发起一次XHR/fetch请求,拿到数据后进行处理显示在前端。

听起来是个比较简单的过程,可是在实际操作中,有如下的问题需要解决:

  • API分类
  • 数据结构定义
  • Mock
  • 灵活切换开发服务器和生产服务器
  • JWT鉴权

有关JWT鉴权的问题将会在使用JWT进行用户管理里涉及。这篇文章将会对前面三个问题及对应的解决方法进行讲解。

API分类

所谓API分类,即将一个业务相关的接口放在同一个类里,每一个这样的API类称为一个Service

在此之外,对每一个Service标注@Injectable并在apiProvider.ts里注册成一个provider,这样,当一个组件或者其他服务需要使用这个接口时,可以通过DI框架将其注入。

每一个Service都应该注入HttpService,并通过这个对象发起HTTP请求,不要自己独立发起HTTP请求。HttpService基于fetch API,提供发起请求等接口,并提供一些共用的API相关类型定义,例如有关返回值的NetworkResponse等。HttpService的具体文档请参考本文最后一部分。通过HttpService访问HTTP请求,有助于代码的统一和用户鉴权的管理。其定义请看src/api/HttpService.ts

更多关于DI的信息,请查看DIP, IoC and DI

例如,在示例代码中,我们提供了有关用户管理的API Service示例类。由于登录、注册和登出都和用户管理有关,所以这个类中,提供用户登录、注册和登出的接口,并给出了示例实现。

src/api/UserService.ts

import { HttpService, NetworkResponse } from "./HttpService";
import { HttpMethod } from "./utils";
import { Inject, Injectable } from "react.di";
import { UserRole } from "../models/user/User";
import { LevelInfo } from "../models/user/LevelInfo";
import { LoginResponse } from "../models/user/LoginResponse";
import { UserRegisterResponse } from "../models/user/UserRegisterResponse";

function encryptPassword(password: string) {
  return password;
}

@Injectable
export class UserService {

  constructor(@Inject private http: HttpService) {
  }

  async login(username: string, password: string): Promise<NetworkResponse<LoginResponse>> {
    password = encryptPassword(password);

    const res = await this.http.fetch({
      path: "account/login",
      queryParams: {username, password}
    });
    if (res.ok) {
      this.http.token = res.response.token;
    }
    return res;
  }

  logout() {
    this.http.token = "";
  }

  async register(username: string, password: string, email: string, role: UserRole): Promise<NetworkResponse<UserRegisterResponse>> {
    password = encryptPassword(password);
    return await this.http.fetch({
      path: "account/register",
      queryParams: {username, password, email, role},
      method: HttpMethod.POST
    });
  }

}

src/api/apiProviders.ts

import { Binding } from "react.di";
import { UserService } from "./UserService";
import { UserServiceMock } from "./mock/UserServiceMock";
import { HttpService } from "./HttpService";

export default function(useMock: boolean) {
  return [
    {provide: UserService, useClass: useMock ? UserServiceMock : UserService},
    {provide: HttpService, useClass: HttpService}
  ] as Binding[];
}

数据结构定义

在开发中,开发者经常会遇到以下场景:

  • 后端返回的字段名和前端不匹配
  • 在一次API修正之后,前端漏改了一处涉及到后端返回值字段名的地方,出现undefined
  • 前端在开发过程中,忘记了后端返回的数据的字段名或者类型,需要切出文档重新查看,很影响思路

这些问题都是由于JS动态类型和弱类型所导致的。为了解决这个问题,我们采用了TypeScript。在预先定义好数据类型的前提下,使用TypeScript,有以下好处:

  • 若前后端字段名不匹配,将会报错
  • API修正后,IDE能够自动修改所有出现过这个字段名的地方
  • 在写代码的过程中,IDE能够给予智能提示对象的字段名和字段类型

我们推荐把所有的类型定义文件放在model目录下。

Mock

前后端的进度极大概率是不同的,并且前端开发时也不可能跑着真正的后端,因此我们需要mock。现有许多前端mock框架,可是许多都使用不够傻瓜,不用直白,需要自己配置诸如类型定义等配置信息,没有充分利用TypeScript强类型的优势。于是,我们认为,与其使用框架,不如自己写!

我们建议,对于每一个Service,都写一个对应的ServiceMock。每个Mock继承其原类,并重写所有的方法,内容为直接返回一个mock值。

在此之外,每增加一个Service/ServiceMock,都在src/api/apiProviders.ts里的export default function的返回值里,增加一项{provide: xxxService, useClass: useMock ? ...Service: xxxService}。这样,我们只需要在src/providers/index.ts修改useMock的值,即可便捷切换Mock或者真实服务器。

例如,对于之前提到的有关用户管理的UserService,我们提供了一个UserServiceMock可供参考。在mock中,只是简单的返回一个满足类型定义的值即可。

src/api/mock/UserServiceMock.ts

import { Injectable } from "react.di";
import { NetworkResponse, createNetworkResponse } from "../HttpService";
import { UserRegisterResponse, UserService } from "../UserService";
import { UserRole } from "../../models/user/User";
import { LevelInfo } from "../../models/user/LevelInfo";
import { LoginResponse } from "../../models/user/LoginResponse";


const sampleAvatar = "https://en.gravatar.com/userimage/57315252/e9c37404163b4b2e73fd72003e391aac.jpg?size=200";

@Injectable
export class UserServiceMock extends UserService {

  async login(username: string, password: string): Promise<NetworkResponse<LoginResponse>> {

    if (username === "worker") {
      return createNetworkResponse(200, {
        token: "123",
        jwtRoles: [{roleName: UserRole.ROLE_WORKER}],
        email: "1@1.com",
        avatarUrl: sampleAvatar,
        registerDate: Date.now().toString()
      })
    }

    else if (username === "admin") {
      return createNetworkResponse(200, {
        token: "123",
        jwtRoles: [{roleName: UserRole.ROLE_ADMIN}],
        email: "1@1.com",
        avatarUrl: sampleAvatar,
        registerDate: Date.now().toString()
      })
    }
    return createNetworkResponse(200, {
        token: "123",
        jwtRoles: [{roleName: UserRole.ROLE_REQUESTER}],
        email: "1@1.com",
        avatarUrl: sampleAvatar,
      registerDate: Date.now().toString()
      }
    );
  }

  logout() {

  }

  async register(username: string, password: string): Promise<NetworkResponse<UserRegisterResponse>> {
    return createNetworkResponse(201, {
        token: "123",
      }
    );
  }

}

灵活切换开发服务器和生产服务器

只使用Mock也不能满足的开发的需求,有时我们需要连接真正的开发服务器进行测试,在上线后,也要改为使用生产服务器。

针对这个需求,我们使用webpack插件DefinePlugin来解决。

在HttpService这个核心HTTP库里,可以看到定义了一个全局变量APIROOTURL。这个全局变量将会被用于和参数path拼接真正的URL地址。

src/api/HttpService.ts

declare var APIROOTURL: string;

这个变量的值通过webpack在编译时确定。

webpack.config.js里,开发环境(dev)和生产环境(prod)会对APIROOTURL有着不同的定义。devPlugins存有开发环境的配置,而prodPlugins存有生产环境的配置。这样,当使用npm start编译的时候,环境是开发环境,APIROOTURL将会被设置为开发服务器的终结点。而在使用npm run build时,APIROOTURL将会被设置为生产服务器的终结点。这样就解决了不同环境使用不同API终结点的问题。

开发者应该根据自己的实际情况,修改此处的两个终结点定义。

webpack.config.js

const devPlugins = [
  new webpack.DefinePlugin({
    APIROOTURL: JSON.stringify("http://localhost:8080/")
  }),

];

const prodPlugins = [
  new webpack.DefinePlugin({
    APIROOTURL: JSON.stringify("http://118.25.4.191:8080/"),
  }),
];

推荐流程

在这些基础设施的基础上,对于API的生成和修改,我们推荐以下流程:

创建

  1. 由前后端共同讨论接口和数据结构的细节
  2. 将数据结构的定义写入models目录,建议一个数据结构(class/interface)为一个文件,不要将多个数据类型写入一个文件中。
  3. API的定义写入src/api下对应API类的文件中,并对每一个API类的接口书写mock类型。API发起网络请求时,使用注入的HttpService对象。
  4. src/api/apiProvider.ts里,增加{provide: xxxService, useClass: useMock ? ...Service: xxxService}项以注册这个Service。

修改

  1. 由前后端共同讨论接口和数据结构的细节
  2. 若修改接口终结点,可直接修改xxxService的具体实现;若修改接口定义或者数据结构,使用IDE的Rename功能进行批量重命名,避免漏掉一处。
  3. 修改Mock对象以符合新的API。

HttpService文档

token字段

用于保存当前登录的用户的JWT token。更多信息请查看使用JWT进行用户管理

FetchInfo

FetchInfo是网络请求参数的封装,包含以下部分:

export interface FetchInfo {
  path?: string; // 相对地址。例如/login
  method?: HttpMethod; // Http方法。枚举类型
  queryParams?: any; // query参数,应传入一个对象
  body?: any; // body对象。将会被JSON.stringify
  headers?: {[s: string]: string}; // headers对象
  mode?: RequestMode; // RequestMode,用来控制CORS。具体取值参考[Fetch API文档](https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch)。一般不需要设置。
}

fetchRaw方法

是HttpService的核心,进行实际的fetch请求操作。

  async fetchRaw(fetchInfo: FetchInfo = {}): Promise<Response> {
    const body = fetchInfo.body
      ? {body: fetchInfo.body}
      : null;

    const mode = fetchInfo.mode
    ? { mode: fetchInfo.mode}
    : {};

    return await fetch(appendQueryString(fetchInfo.path, fetchInfo.queryParams), {
        method: fetchInfo.method || HttpMethod.GET,
        headers: fetchInfo.headers,
        ...mode,
        ...body
      });
  }

fetch方法

最常用的方法,在fetchRaw的基础上提供以下功能:

  1. 将path和APIROOTURL拼接成为真正的URL
  2. 将queryParams转换成querystring拼接到URL
  3. 将鉴权信息(token)增加进header
  4. 将返回值自动JSON.parse成为对象
  5. 捕获本地Promise reject异常

将会返回NetworkResponse对象。

  async fetch<T = any>(fetchInfo: FetchInfo = {}): Promise<NetworkResponse<T>> {
    const authHeader = this.token
      ? {"Authorization": `Bearer ${this.token}`}
      : {};
    try {
      const response = await this.fetchRaw({
        ...fetchInfo,
        body: JSON.stringify(fetchInfo.body),
        path: urlJoin(APIROOTURL, fetchInfo.path),
        headers: {
          ...authHeader,
          'Content-Type': 'application/json',
          ...fetchInfo.headers,
        }
      });
      return createNetworkResponse(response.status, (await response.json()));
    } catch (e) {
      return createNetworkResponse(NetworkErrorCode, null, e);
    }
  }

NetworkResponse

NetworkResponse是一个对网络访问结果的封装,定义如下:

export interface NetworkResponse<T = any> {
  statusCode: number; // 状态码
  response: T; // 返回对象
  ok: boolean; // === statusCode>=200 && statusCode<300
  error: { // 错误对象
    statusCode: number; // 状态码
    info: any; // 错误返回值
    isNetworkError: boolean; // 是本地错误。在Fetch实现中,当Fetch的Promise被reject时,设置为true
    isServerError: boolean; // 是服务器错误。当statusCode>=500时设置为true
  };
}

提供一个创建这个对象的方法。若未发生错误,把response设为返回对象;若发生了错误(statusCode不为[200,300)),将response设置为null,将error设置为返回对象。

export function createNetworkResponse<T>(statusCode: number, response: T, error?: any) {
  return {
    statusCode,
    response,
    ok: 200 <= statusCode && statusCode < 300,
    error: {
      statusCode: statusCode,
      info: error,
      isNetworkError: statusCode === NetworkErrorCode,
      isServerError: statusCode >= 500
    }
  }
}

sendFile方法

对发送文件的一个简单封装。