前不久组内的萌新用不晓得从哪里学来的技术,说要封装一套 axios
库供大家应用。
等他开发完,在 code review
环节,大家看到他写的代码都面面相觑,不晓得该如何评估。
我一时间也不晓得该如何评估,只能揭示他不要写死代码,目前 axios
还没入选开源库,前期有可能换成其余替代品。
会后我专门到网上搜一番,发现二次封装 axios
的案例的确不少,但给我感觉其实都一丘之貉,不见得哪个更优良。
过后咱们刚从Java切换到Go,因为Go对于 swagger 反对不够好,前后端对接的接口文档须要手写。
有时候后端批改了接口没有告诉前端,常常遇到互相扯皮的事件。
我突发奇想,既然Go对注解、装璜器的反对很不好,前端的 typescript
语法跟 Java
十分相似,为什么不把Java那套照搬到前端?
不仅能解决前端接口封装的问题,还能躲避go不反对swagger文档的问题。
useResource:申明式API
说干就干,我参考 Open Feign
的设计,Feign
的设计很大水平上借鉴了 Spring MVC
。
只是 Feign
次要面向客户端,而 Spring MVC
面向服务端,两者的注解大同小异,Feign 兼容后者而已。
interface GitHub { @RequestLine("GET /repos/{owner}/{repo}/contributors") List<Contributor> contributors(@Param("owner") String owner, @Param("repo") String repo); @RequestLine("POST /repos/{owner}/{repo}/issues") void createIssue(Issue issue, @Param("owner") String owner, @Param("repo") String repo);}
显然这种申明式API的设计,比那些二次封装 axios
的计划优雅太多了,真正做到形象接口与具体实现拆散。
申明式API能够不改变业务代码的前提下,依据理论状况把具体实现在原生 fetch
和 axios
之间切换。
装璜器
其实说照搬Java的说法是不正确的,Typescript 只有装璜器的说法,并没有注解。
而且两者差异还挺大的,Java是先定义注解Annotation
,而后在运行时通过反射取得注解的元数据metadata
;
然而装璜器 Decorator
的做法就十分间接白,间接一次性把所有的事件做完了。
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTION"export interface Exchange { (...args: any[]): Promise<Response>}export interface Operation { method?: Method path?: string headers?: Record<string, string> pathVariables: {name: string, order: number}[] requestBody?: {order: number, encode: (body: any) => BodyInit}}export interface Operations { [key: string]: Operation}export interface Resource { exchange: Exchange endpoint?: string resourceName?: string headers?: Record<string, string> operations?: Operations}export const RESTfulHeader: Record<string, string> = { "Content-Type": "application/json"}export function RESTful(endpoint: string, resource?: string, headers?: Record<string, string>) { return function<T extends { new (...args: any[]): Resource}>(target: T) { return class extends target { constructor(...args: any[]) { super(...args) this.endpoint = endpoint this.resourceName = resource this.headers = headers ? {...headers, ...RESTfulHeader} : {...RESTfulHeader} } } }}export function RequestMapping(method: Method, path: string, headers?: Record<string, string>) { return function(target: Resource, methodName: string, descriptor: PropertyDescriptor) { if (!target.operations) { target.operations = {} } const op = target.operations[methodName] ?? {pathVariables: []} op.method = method op.path = path op.headers = headers target.operations[methodName] = op }}export function Get(path: string, headers?: Record<string, string>) { return RequestMapping("GET", path, headers)}export function Post(path: string, headers?: Record<string, string>) { return RequestMapping("POST", path, headers)}export function Put(path: string, headers?: Record<string, string>) { return RequestMapping("PUT", path, headers)}export function Patch(path: string, headers?: Record<string, string>) { return RequestMapping("PATCH", path, headers)}export function Delete(path: string, headers?: Record<string, string>) { return RequestMapping("DELETE", path, headers)}export function Option(path: string, headers?: Record<string, string>) { return RequestMapping("OPTION", path, headers)}export function PathVariable(name: string) { return function(target: Resource, propertyKey: string | symbol, parameterIndex: number) { if (!target.operations) { target.operations = {} } const methodName = String(propertyKey) const op = target.operations[methodName] ?? {pathVariables: []} const pv = {name: name, order: parameterIndex} op.pathVariables.push(pv) target.operations[methodName] = op }}export const PV = PathVariableexport interface Encoder<T> { (src: T): BodyInit}export function RequestBody<T>(encoder: Encoder<T>) { return function(target: Resource, propertyKey: string | symbol, parameterIndex: number) { if (!target.operations) { target.operations = {} } const methodName = String(propertyKey) const op = target.operations[methodName] ?? {pathVariables: []} op.requestBody = {order: parameterIndex, encode: encoder} target.operations[methodName] = op }}export function JSONBody() { return RequestBody<Object>(JSON.stringify)}export function PlainBody() { return RequestBody<Object>(String)}export function FileBody() { return RequestBody<Blob>((src) => src)}
然而我在实现的过程,还是保持把这个过程给解耦了,装璜器只是单纯地把元数据保留到指标的 Resource
中。
useResource
接下来就是把保留在 Resource
的元数据读取进去,而后把 exchange
函数替换掉。
import { Delete, Exchange, Get, JSONBody, PV, Post, Put, RESTful, Resource } from "../annotations/restful"import { useIoC } from "./ioc"export interface Provider<T extends Resource> { (exchange: Exchange): T}export interface RequestInterceptor { (req: RequestInit): RequestInit}export interface ResponseInterceptor { (res: Response): Response}const globalRequestInterceptor: RequestInterceptor[] = []const globalResponseInterceptor: ResponseInterceptor[] = []export function addRequestInterceptor(interceptor: RequestInterceptor) { globalRequestInterceptor.push(interceptor)}export function addResponseInterceptor(interceptor: ResponseInterceptor) { globalResponseInterceptor.push(interceptor)}export function useResource<T extends Resource>(provider: (exchange: Exchange) => T): T { const context = useIoC() const exchange = context.inject(DefaultExchange) const sub = context.inject(provider) const resource = sub(exchange) invoke(resource, resource) return resource}function DefaultExchange(...args: any[]) { return Promise.resolve(new Response("{}"))}function invoke<T extends Resource>(resource: T, top: T) { const proto = Object.getPrototypeOf(resource) if (!proto) { return } invoke(proto, top) const props = Object.getOwnPropertyDescriptors(resource) for (const key in props) { const prop = props[key].value if (typeof prop == "function") { const exchange = sendRequest(key, resource, top) if (exchange) { const replace = prop.bind({...resource, exchange: exchange}) const map = new Map([[key, replace]]) Object.assign(resource, Object.fromEntries(map.entries())) } } }}function sendRequest<T>(methodName: string, res: Resource, top: Resource): Exchange | undefined { if (!res.operations) { return } const op = res.operations[methodName] if (!op) { return } const headers = top.headers ?? {} const opHeaders = op.headers ?? {} return async (...args: any[]) => { let path = op.path if (path && op.pathVariables) { for (const pv of op.pathVariables) { path = path.replace("{" + pv.name + "}", String(args[pv.order])) } } const url = `${top.endpoint}/${top.resourceName}/${path}` let request: RequestInit = { method: op.method, headers: {...headers, ...opHeaders} } if (op.requestBody) { const order = op.requestBody.order request.body = op.requestBody.encode(args[order]) } try { for (const interceptor of globalRequestInterceptor) { request = interceptor(request) } let response = await fetch(url, request) for (const interceptor of globalResponseInterceptor) { response = interceptor(response) } return Promise.resolve(response) } catch (e) { return Promise.reject(e) } }}
一时间看不懂所有代码实现也没关系,能够先看看怎么应用:
先编写一个实现增删改查的基类 CURD<T>
,T
由子类决定,再继承基类编写 UserResource
import { Delete, Exchange, Get, JSONBody, PV, Post, Put, RESTful, Resource } from "../annotations/restful"@RESTful("example.com", "resource")export class CURD<T> implements Resource { exchange: Exchange constructor(exchange: Exchange) { this.exchange = exchange } @Get("?page={page}&pageSize={pageSize}") async list(@PV("page") page?: number, @PV("pageSize") pageSize?: number): Promise<T[]> { return (await this.exchange(page ?? 1, pageSize ?? 10)).json() } @Post("") async create(@JSONBody() t: T): Promise<Response> { return this.exchange(t) } @Get("{id}") async get(@PV("id") id: string): Promise<T> { return (await this.exchange(id)).json() } @Put("{id}") async update(@PV("id") id: string, @JSONBody() t: T): Promise<Response> { return this.exchange(id, t) } @Delete("{id}") async delete(@PV("id") id: string): Promise<Response> { return this.exchange(id) }}export interface User { username: string password: string role: string[]}@RESTful("localhost", "users")export class UserResource extends CURD<User> {}export function UserResourceProvider(exchange: Exchange): UserResource { return new UserResource(exchange)}
接着,通过注入 UserResourceProvider
取得 UserResource
的实例,最初通过实例办法调用后端的接口:
const userRes = useResource(UserResourceProvider)userRes.list().then(console.info)const user = {username: "", password: "", role: []}userRes.get('1').then(console.info)userRes.create(user).then(console.info)userRes.update('1', user).then(console.info)userRes.delete('1').then(console.info)
拦截器
给每个request设置token
addRequestInterceptor((req) => { const authToken = {} if (req.headers) { const headers = new Headers() headers.append("Authorization", "bear:xxxxx") if (req.headers instanceof Array) { for (const h of req.headers) { headers.append(h[0], h[1]) } } req.headers = headers } req.headers = authToken return req})
useMock:基于依赖注入的mock工具
组内的成员都是搞前端开发的老手,不晓得如何 mock
后端接口。
我想起以前从没有为这件事件发过愁,起因是后端接口都接入 swagger/openapi
,能够间接生成mock server。
只是后端切换到Go当前,他们不晓得该如何接入 swagger
,只能每个人都在本地保护一套 mock server。
要害是他们都放心 mock 代码会影响到生产环境,所以都没有提交代码仓库。
后果遇到某个问题须要定位,还得一个个找他们要 mock 数据。
当初有了依赖注入,要实现 mock 性能几乎不要太容易,几行代码就封装一个 useMock
:
import { Resource } from "../annotations/restful";import { useIoC } from "./ioc";import { Provider } from "./resource";export function useMock<T extends Resource>(provider: Provider<T>, sub: Provider<T>) { const context = useIoC() context.define(provider, sub)}
mockServer
对于曾经在应用 mock Server
的接口,能够继承派生出一个子类: XXXResourceForMock
,
而后通过 RESTful
设置新的 endpoint
和 resource
,就能够就把申请转发到指定的服务器。
useMock(UserResourceProvider, (exchange: Exchange) => { @RESTful("http://mock-server:8080/backend", "users") class UserResourceForMock extends UserResource { } return new UserResourceForMock(exchange)})
如果遇到问题,仔细观察endpoint是否为绝对路径,以及是否蕴含http://
mockOperation
如果 mock server
返回后果无奈满足需要,能够独自 mock
某个办法,能够依据理论需要返回特定的后果。
useMock(UserResourceProvider, (exchange: Exchange) => { @RESTful("http://mock-server:8080/backend", "users") class UserResourceForMock extends UserResource { async list(page: number, pageSize: number): Promise<User[]> { return Promise.resolve([]) } async create(user: User): Promise<Response> { return Promise.resolve(new Response("{}")) } async get(id: string): Promise<User> { return Promise.resolve({username: "", password: "", role: []}) } async update(id: string, user: User): Promise<Response> { return Promise.resolve(new Response("{}")) } async delete(id: string): Promise<Response> { return Promise.resolve(new Response("{}")) } } return new UserResourceForMock(exchange)})
pure_func
为了避免以上 mock
操作一不小心影响到生产环境,能够定义一个 developMockOnly
函数:
// 只用于开发环境的mock操作function developMockOnly() {}
把所有的 mock
操作都放到下面的函数外部,而后批改生产环境的 webpack
配置:
{ minimizer: [ new TerserPlugin({ terserOptions: { extractComments: 'all', compress: { pure_funcs: ['console.info', 'developMockOnly'] }, } }), ]}
把 developMockOnly
加到 pure_funcs
数组中。
这样即使把 mock
操作提交到骨干分支,也不会呈现开发环境的mock操作不会影响到生产环境的问题。
总结
以上代码早在半年前就曾经写好,奈何公司的窃密措施十分严格,没有方法把代码带进去。
进去之后,想从新实现一遍的想法在脑海中酝酿许久,终于在上周末花了一天的工夫就写进去大部分代码。
而后又额定花了一天工夫,解决一些潜在的问题,而后写了本文分享给大家,心愿大家都能从中受到启发。