前不久组内的萌新用不晓得从哪里学来的技术,说要封装一套 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 = PathVariable
export 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操作不会影响到生产环境的问题。
总结
以上代码早在半年前就曾经写好,奈何公司的窃密措施十分严格,没有方法把代码带进去。
进去之后,想从新实现一遍的想法在脑海中酝酿许久,终于在上周末花了一天的工夫就写进去大部分代码。
而后又额定花了一天工夫,解决一些潜在的问题,而后写了本文分享给大家,心愿大家都能从中受到启发。
发表回复