前不久組內的萌新用不知道從哪裏學來的技術,説要封裝一套 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操作不會影響到生產環境的問題。
總結
以上代碼早在半年前就已經寫好,奈何公司的保密措施非常嚴格,沒有辦法把代碼帶出來。
出來之後,想重新實現一遍的想法在腦海中醖釀許久,終於在上週末花了一天的時間就寫出來大部分代碼。
然後又額外花了一天時間,解決一些潛在的問題,然後寫了本文分享給大家,希望大家都能從中受到啓發。