基於koa實現一個裝飾器風格的框架
裝飾器(Decorator)是用來修改類行為的一個函式(語法糖),在許多面向物件語言中都有這個東西。
語法
裝飾器是一個函式,接受3個引數 target
name
descriptor
- target是被修飾的目標物件
- name是被修飾的屬性名
- descriptor是屬性的描述
定義一個裝飾器函式
function setName(target, name, descriptor) { target.prototype.name = 'hello world' } @setName class A { } console.log((new A()).name) // hello world
差異
裝飾器裝飾不同型別的目標是有一些差異的,這些差異體現在裝飾函式接受的引數裡面。
首先對一個類的裝飾是由內到外的,先從類的屬性開始,從上到下,按順序修飾,如果類的屬性是個方法,那麼會先裝飾這個方法的屬性,再裝飾這個方法。如上demo的console
裝飾Class
裝飾函式接收到的引數 target
是類的本身, name
與 descriptor
都是 undefined
裝飾Class的屬性
裝飾函式接收到的引數 target
是類的原型,也就是 class.prototype
name
為該屬性的名字
當這個屬性是個函式時:
descriptor
為該方法的描述,通過 Object.getOwnPropertyDescriptor(obj, prop)
獲得
當這個屬性非函式時:
descriptor
為 undefined
裝飾Class方法的引數
裝飾函式接受到的引數 target
是類的原型
name
為該引數的名字
descriptor
為該引數是這個函式的第幾個引數, index:number
瞭解Reflect.metadate
Reflect可以理解為反射,可以改變 Object
的一些行為。
ofollow,noindex">reflect.metadata 從名字上看,就是對物件設定一些元資料。
有2個比較重要的api
Reflect.getMetadata(key, target)
通過 key
獲得在 target
上設定的元資料
Reflect.defineMetadata(key, value, target)
通過 key
設定 value
到 target
上
實現這個2個api不難,通過 weakMap
和 Map
就可以實現了。
這樣的資料結構
weakMap[target, Map[key, value]]
koa路由
koa的中介軟體模型不做介紹, koa-router
就是個中介軟體。
路由其實就是對映一個 controller
方法到一個 path
字串上。
通過 ctx
去 match
匹配到的 path
然後呼叫這個 controller
方法。
簡單的例子
在這個例子裡面,通過裝飾器,來實現繫結一個 Controller
方法到路由上。
首先如上所說的,有以下思路:
- 裝飾器記錄
Controller
元資料,實現一個Bind方法,取出元資料繫結到路由上
實現一個裝飾器 Router(path)
用來裝飾 Controller
的方法
import * as koa from "koa"; import * as router from "koa-router"; const koaRouter = new router(); const app = new koa(); function Router(path) { return function(target, name) {}; } function bind(router, controller) {} class Controller { @Router("/hello") sayHello(ctx) { ctx.body = "say hello"; } } bind(koaRouter, Controller); app.use(koaRouter.routes()); app.listen(8080);
來實現 bind
方法和 Router
裝飾器
首先是 Router
裝飾器
function Router(path) { return function(target, name) { Reflect.defineMetadata("path", { path, name }, target); }; } // 裝飾器如果需要傳參得再裝飾器上層封裝一個函式,然後再返回這個裝飾器函式
使用 Reflect.metadata
需要在程式的開始 import "reflect-metadata";
首先是 bind
function bind(router, controller) { const meta = Reflect.getMetadata("path", controller.prototype); console.log(meta); const instance = new controller(); router.get(meta.path, ctx => { instance[meta.name](ctx); }); }
這裡的 bind
也很簡單,首先是,裝飾器裝飾一個方法的 target
是類的原型,所以這邊 getMetadata
的 target
應該是 controller.prototype
,meta的屬性 path
對應的是 /hello
name
對應的是 sayHello
,然後就是例項化 controller
,然後通過router去繫結這個 path
和方法。
開啟例子在右邊的瀏覽器輸入 /hello
就能看到 say hello
的輸出。
進入正題
進入正題,開始封裝一個不是那麼完整的裝飾器框架。
先定義一堆的 constants
export enum METHODS { GET = 'get', POST = 'post', PUT = 'put', DEL = 'del', ALL = 'all' } export const PATH = 'DEC_PATH' export const PARAM = 'DEC_PARAM'
請求方法
首先是各種請求方法 GET
POST
PUT
DELETE
因為現在有了請求方法的區分,所以在收集資訊的時候需要加一個欄位。
現在收集資訊的方法變為
import { METHODS, PATH } from "./constants"; export function Route(path: string, verb: METHODS) { return function(target, name, descriptor) { const meta = Reflect.getMetadata(PATH, target) || [] meta.push({ name, verb, path }) Reflect.defineMetadata(PATH, meta, target) } }
可以看見,多了一個 verb
引數表示該 controller
的請求方法
這邊用陣列是因為, target
只有這個 controller
要記錄的資訊不止一個有很多。
通過這個基礎方法,再封裝一下其他裝飾器
export function ALL(path: string) { return Route(path, METHODS.ALL) } export function GET(path: string) { return Route(path, METHODS.GET) } export function POST(path: string) { return Route(path, METHODS.POST) } export function PUT(path: string) { return Route(path, METHODS.PUT) } export function DEL(path: string) { return Route(path, METHODS.DEL) }
裝飾器寫完,這裡的 bind
應該和之前的不一樣,畢竟 metadata
是個陣列,處理起來其實沒有區別,加個迴圈罷了。
import * as Router from 'koa-router' import * as Koa from 'koa' import { PATH } from './constants'; export function BindRoutes(koaRouter: Router, controllers: any[]) { for(const ctrl of controllers) { const pathMeta = Reflect.getMetadata(PATH, ctrl.prototype) || [] console.log(pathMeta) const instance = new ctrl() for(const item of pathMeta) { const { path, verb, name } = item koaRouter[verb](path, (ctx: Koa.Context) => { instance[name](ctx) }) } } }
這裡的 pathMeta
的輸出:
[ { name: 'sayHello', verb: 'get', path: '/hello' }, { name: 'postMessage', verb: 'post', path: '/post' }, { name: 'putName', verb: 'put', path: '/put' }, { name: 'delMe', verb: 'del', path: '/del' } ]
點開例子右邊的瀏覽輸入 /get
就能預覽得到,控制檯也打印出來上面的輸出。
請求引數
請求方法處理完了,處理一下請求引數。
舉個例子
getUser(@Body() user, @Param('id') id) { }
想要的是,這個 user
引數自動變成 ctx.body
, id
變為 ctx.params.id
。
如上,繫結路由的時候, controller
的引數是傳進去的,並且,在裝飾器對函式引數進行裝飾的時候,可以通過 descriptor
獲得到這個引數在所有引數裡面的第幾個位置。所以通過這些特性,可以實現想要的需求。
只要把 bind
方法改寫成:
instance[name](arg1, arg2, arg3) // arg1 = ctx.body // arg2 = ctx.params.id // arg3 = .....
所有能從ctx中獲取到的,都可以 ctx.body
ctx.params
ctx.query
同樣的,實現一個基礎方法,叫做 Inject
來收集引數的資訊
export function Inject(fn: Function) { return function(target, name, descriptor) { const meta = Reflect.getMetadata(PARAM, target) || [] meta.push({ name, fn, index: descriptor }) Reflect.defineMetadata(PARAM, meta, target) } }
這裡的的 fn
必須是個函式,因為需要通過請求的 ctx
拿到需要的值。這裡的 index
是該變數在引數中的位置。
實現了 Inject
接下來繼續實現其他的裝飾器
export function Ctx() { return Inject(ctx => ctx) } export function Body() { return Inject(ctx => ctx.request.body) } export function Req() { return Inject(ctx => ctx.req) } export function Res() { return Inject(ctx => ctx.res) } export function Param(arg) { return Inject(ctx => ctx.params[arg]) } export function Query(arg) { return Inject(ctx => ctx.query[arg]) }
這些裝飾器都很簡單,都是基於 Inject
,這個裝飾器的函式會先收集起來,後面會用到。
通過自己實現的 bind
函式可以很容易的把需要的引數傳入到 controller
中
看一下修改以後的 bind
函式
import * as Router from 'koa-router' import * as Koa from 'koa' import { PATH, PARAM } from './constants'; export function BindRoutes(koaRouter: Router, controllers: any[]) { for(const ctrl of controllers) { const pathMeta = Reflect.getMetadata(PATH, ctrl.prototype) || [] const argsMeta = Reflect.getMetadata(PARAM, ctrl.prototype) || [] console.log(argsMeta) const instance = new ctrl() for(const item of pathMeta) { const { path, verb, name } = item koaRouter[verb](path, (ctx: Koa.Context) => { const args = argsMeta.filter(i => i.name === name).sort((a, b) => a.index - b.index).map(i => i.fn(ctx)) instance[name](...args, ctx) }) } } }
args
先 filter
出這個 controller
方法有關的引數,再根據這些引數的 index
排序,排序以後就是 args[i]
的fn函式 ctx => ctx.xxx
的形式,通過執行 fn(ctx)
可以拿到需要的值。
最後執行 controller
的時候把這些值傳入,就得到了想要的結果。
所以上面 bind
函式的 args
就是通過裝飾器得到的所需要的引數。
這樣來使用它們:
import { GET, PUT, DEL, POST, Ctx, Param, Body } from "../src"; export class Controller { @GET('/:id') sayHello (@Ctx() Ctx, @Param('id') id, @Query('name') name) { Ctx.body = 'hello' + id + name } @POST('/post') postMessage(@Body() body, ctx) { console.log(body) ctx.body = 'post' } }
當請求進入 sayHello
繫結的路由的時候, sayHello
會被執行,並且會傳入以下引數執行。
sayHello(ctx, ctx.params['id'], ctx.query['name'], ctx)
至此,就封裝出了一個很簡陋的裝飾器風格的框架。
可以在右邊的瀏覽地址輸入 123?name=chs97
可以看到 hello123chs97
總結
裝飾器還可以做很多事情,在這裡主要使用裝飾器來記錄一些資訊,然後通過其他方法獲取這些資訊出來,進行處理。
裝飾器風格的框架可以參考 nestjs
這是一個完全裝飾器風格的框架,和 Sprint boot
非常像,可以嘗試體驗一下。
還有一些裝飾器風格的庫:
- Typeorm 裝飾器風格的
ORM
框架 - routing-controllers 裝飾器風格的框架可以使用
express
和koa
做底層 - trafficlight 裝飾器風格的框架,底層為koa
- nestjs 一個非常棒的node框架,開發體驗非常好