-
Notifications
You must be signed in to change notification settings - Fork 0
Interaction with API
在前后端分离的架构里,前后端通过预先定义的Rest风格的API进行交互。前端需要数据时,发起一次XHR/fetch请求,拿到数据后进行处理显示在前端。
听起来是个比较简单的过程,可是在实际操作中,有如下的问题需要解决:
- API分类
- 数据结构定义
- Mock
- 灵活切换开发服务器和生产服务器
- JWT鉴权
有关JWT鉴权的问题将会在使用JWT进行用户管理里涉及。这篇文章将会对前面三个问题及对应的解决方法进行讲解。
所谓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框架,可是许多都使用不够傻瓜,不用直白,需要自己配置诸如类型定义等配置信息,没有充分利用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的生成和修改,我们推荐以下流程:
- 由前后端共同讨论接口和数据结构的细节
- 将数据结构的定义写入
models
目录,建议一个数据结构(class/interface)为一个文件,不要将多个数据类型写入一个文件中。 - API的定义写入
src/api
下对应API类的文件中,并对每一个API类的接口书写mock类型。API发起网络请求时,使用注入的HttpService对象。 - 在
src/api/apiProvider.ts
里,增加{provide: xxxService, useClass: useMock ? ...Service: xxxService}
项以注册这个Service。
- 由前后端共同讨论接口和数据结构的细节
- 若修改接口终结点,可直接修改xxxService的具体实现;若修改接口定义或者数据结构,使用IDE的Rename功能进行批量重命名,避免漏掉一处。
- 修改Mock对象以符合新的API。
用于保存当前登录的用户的JWT token。更多信息请查看使用JWT进行用户管理。
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)。一般不需要设置。
}
是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
});
}
最常用的方法,在fetchRaw的基础上提供以下功能:
- 将path和APIROOTURL拼接成为真正的URL
- 将queryParams转换成querystring拼接到URL
- 将鉴权信息(token)增加进header
- 将返回值自动JSON.parse成为对象
- 捕获本地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是一个对网络访问结果的封装,定义如下:
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
}
}
}
对发送文件的一个简单封装。