黃金搭檔 -- JS 裝飾器(Decorator)與Node.js路由

threerocks發表於2019-01-08

很多面物件語言中都有裝飾器(Decorator)函式的概念,Javascript語言的ES7標準中也提及了Decorator,個人認為裝飾器是和async/await一樣讓人興奮的的變化。正如其“裝飾器”的叫法所表達的,他可以對一些物件進行裝飾包裝然後返回一個被包裝過的物件,可以裝飾的物件包括:類,屬性,方法等。
Node.js目前已經支援了async/await語法,但decorator還需要babel外掛支援,具體的配置不在敘述。(截至發稿時間2018-12-29)
下面是引用的關於decorator語法的一個示例:

@testable
class Person {
  @readonly
  @nonenumerable
  name() { return `${this.first} ${this.last}` }
}
複製程式碼

從上面程式碼中,我們一眼就能看出,Person類是可測試的,而name方法是隻讀和不可列舉的。

關於 Decorator 的詳細介紹參見下面兩篇文章:

  1. 阮一峰《ECMAScript 6 入門》 -- Decorator
  2. 知乎 -- 《Decorators in ES7》

期望效果

關於Node.js中的路由,大家應該都很熟悉了,無論是在自己寫的http/https服務中,還是在ExpressKoa等框架中。我們要為路由提供請求的URL和其他需要的GETPOST等引數,隨後路由需要根據這些資料來執行相應的程式碼。
關於Decorator和路由的結合我們這次希望寫出類似下面的程式碼:

@Controller('/tags')
export default class TagRouter {
  @Get(':/id')
  @Login
  @admin(['developer', 'adminWebsite'])
  @require(['phone', 'password'])
  @Log
  async getTagDetail(ctx, next) {
    //...
  }
}
複製程式碼

關於這段程式碼的解釋: 第一行,通過Controller裝飾TagRouter類,為類下的路由函式新增統一路徑字首/tags。 第二行,建立並匯出TagRouter類。 第三行,通過裝飾器為getTagDetail方法新增路徑和請求方法。 第四行,通過裝飾器限制發起請求需要使用者登入。 第五行,通過裝飾器限制發起請求的使用者必須擁有開發者或者網站管理員許可權。 第六行,通過裝飾器檢查請求引數必須包含phonepassword欄位。 第七行,通過裝飾器為請求列印log。 第八行,路由真正執行的方法。

這樣不僅簡化、規範化了路由的寫法,減少了程式碼的冗餘和錯誤,還使程式碼含義一目瞭然,無需註釋也能通俗易懂,便於維護、交接等事宜。

##具體實現

下面就著手寫一個關於movies的路由具體例項,示例採用koa2 + koa-router為基礎組織程式碼。

檔案路徑:/server/routers/movies.js

import mongoose from 'mongoose';

import { Controller, Get, Log } from '../decorator/router';
import { getAllMovies, getSingleMovie, getRelativeMovies } from '../service/movie';

@Controller('/movies')
export default class MovieRouter {
  @Get('/all')
  @Log
  async getMovieList(ctx, next) {
    const type = ctx.query.type;
    const year = ctx.query.year;

    const movies = await getAllMovies(type, year);

    ctx.body = {
      data: movies,
      success: true,
    };
  }

  @Get('/detail/:id')
  @Log
  async getMovieDetail(ctx, next) {
    const id = ctx.params.id;
    const movie = await getSingleMovie(id);
    const relativeMovies = await getRelativeMovies(movie);

    ctx.body = {
      data: {
        movie,
        relativeMovies,
      },
      success: true,
    }
  }
}
複製程式碼

程式碼中Controller為路由新增統一字首,Get指定請求方法和路徑,Log列印日誌,參考上面的預期示例。

關於mongodb以及獲取資料的程式碼這裡就不貼出了,畢竟只是示例而已,大家可以根據自己的資源,自行修改為自己的邏輯。

重點我們看一下,GET /movies/all以及GET /movies//detail/:id這兩個路由的裝飾器實現。

檔案路徑:/server/decorator/router.js

import KoaRouter from 'koa-router';
import { resolve } from 'path';
import glob from 'glob'; // 使用shell模式匹配檔案

export class Route {
  constructor(app, routesPath) {
    this.app = app;
    this.router = new KoaRouter();
    this.routesPath = routesPath;
  }

  init = () => {
    const {app, router, routesPath} = this;
    glob.sync(resolve(routesPath, './*.js')).forEach(require);
    // 具體處理邏輯
    app.use(router.routes());
    app.use(router.allowedMethods());
  }
};

複製程式碼
  • 首先,匯出一個Route類,提供給外部使用,Route類的建構函式接收兩個引數approutesPathapp即為koa2例項,routesPath為路由檔案路徑,如上面movies.jsroutesPath/server/routers/
  • 然後,提供一個初始化函式init,引用所有routesPath下的路由,並use路由例項。

這樣的話我們就可以在外部這樣呼叫Route類:

import {Route} from '../decorator/router';
import {resolve} from 'path';

export const router = (app) => {
  const routesPath = resolve(__dirname, '../routes');
  const instance = new Route(app, routesPath);

  instance.init();
}
複製程式碼

好了,基本框架搭好了,來看具體邏輯的實現。

先補充完init方法:

檔案路徑:/server/decorator/router.js

  const pathPrefix = Symbol('pathPrefix');

  init = () => {
    const {app, router, routesPath} = this;
    glob.sync(resolve(routesPath, './*.js')).forEach(require);

    R.forEach( // R為'ramda'方法庫,類似'lodash'
      ({target, method, path, callback}) => {
        const prefix = resolvePath(target[pathPrefix]);
        router[method](prefix + path, ...callback);
      }
    )(routeMap)
    
    app.use(router.routes());
    app.use(router.allowedMethods());
  }
複製程式碼

為了載入路由,需要一個路由列表routeMap,然後遍歷routeMap,掛載路由,init工作就完成了。
下邊的重點就是向routeMap中塞入資料,這裡每個路由物件採用object的形式有四個key,分別為target, method, path, callback

target即為裝飾器函式的target(這裡主要為了獲取路由路徑的字首) method為請求方法 path為請求路徑 callback為請求執行的函式。

下邊是設定路由路徑字首和塞入routeMap內容的裝飾器函式:

export const Controller = path => (target, key, descriptor) => {
  target.prototype[pathPrefix] = path;
  return descriptor;
}

export const setRouter = method => path => (target, key, descriptor) => {
  routeMap.push({
    target,
    method,
    path: resolvePath(path),
    callback: changeToArr(target[key]),
  });
  return descriptor;
}
複製程式碼
  • Controller就不多說了,就是掛載字首路徑到類的原型物件上,這裡需要注意的是Controller作用於類,所以target是被修飾的類本身。

  • setRouter函式也很簡單,把接受到的引數path做格式化處理,把callback函式包裝成陣列,之後與targetmethod一起構造成物件塞入routeMap

這裡有兩個輔助函式,簡單貼下程式碼看下:

import R from 'ramda'; // 類似'lodash'的方法庫

// 如果路徑是以/開頭直接返回,否則補充/後返回
const resolvePath = R.unless(
  R.startsWith('/'),
  R.curryN(2, R.concat)('/'),
);

// 如果引數是函式直接返回,否則包裝成陣列返回
const changeToArr = R.unless(
  R.is(Array),
  R.of,
);
複製程式碼

接下來是getpostputdelete方法的具體實現,其實就是呼叫setRouter就行了:

export const Get = setRouter('get');

export const Post = setRouter('post');

export const Put = setRouter('put');

export const Delete = setRouter('delete');
複製程式碼

至此,主要的功能就全部實現了,接下來是一些輔助Decorator,大家可以參考和使用core-decorators.js,它是一個第三方模組,提供了幾個常見的修飾器,通過它也可以更好地理解修飾器。

下面以Log為示例,實現一個輔助Decorator,其他Decorator大家自己發揮:

let logTimes = 0;

export const convert = middleware => (target, key, descriptor) => {
  target[key] = R.compose(
    R.concat(
      changeToArr(middleware)
    ),
    changeToArr,
  )(target[key]);

  return descriptor;
}

export const Log = convert(async (ctx, next) => {
  logTimes++;
  console.time(`${logTimes}: ${ctx.method} - ${ctx.url}`);
  await next();
  console.timeEnd(`${logTimes}: ${ctx.method} - ${ctx.url}`);
})
複製程式碼

convert是一個輔助函式,首先把普通函式轉換成陣列,然後跟其他中介軟體函式合併。此輔助函式也可用於其他輔助Decorator。

好了,到此文章就結束了,大家多交流,本人github
下一篇:分享koa2原始碼解讀

最後貼出關鍵的/server/decorator/router.js的完整程式碼

import R from 'ramda';
import KoaRouter from 'koa-router';
import glob from 'glob';
import {resolve} from 'path';

const pathPrefix = Symbol('pathPrefix')
const routeMap = [];
let logTimes = 0;

const resolvePath = R.unless(
  R.startsWith('/'),
  R.curryN(2, R.concat)('/'),
);

const changeToArr = R.unless(
  R.is(Array),
  R.of,
);

export class Route {
  constructor(app, routesPath) {
    this.app = app;
    this.router = new KoaRouter();
    this.routesPath = routesPath;
  }

  init = () => {
    const {app, router, routesPath} = this;
    glob.sync(resolve(routesPath, './*.js')).forEach(require);

    R.forEach(
      ({target, method, path, callback}) => {
        const prefix = resolvePath(target[pathPrefix]);
        router[method](prefix + path, ...callback);
      }
    )(routeMap)
    app.use(router.routes());
    app.use(router.allowedMethods());
  }
};

export const Controller = path => (target, key, descriptor) => {
  console.log(target);
  target.prototype[pathPrefix] = path;
  return descriptor;
}

export const setRouter = method => path => (target, key, descriptor) => {
  console.log('setRouter');
  routeMap.push({
    target,
    method,
    path: resolvePath(path),
    callback: changeToArr(target[key]),
  });
  return descriptor;
}

export const Get = setRouter('get');

export const Post = setRouter('post');

export const Put = setRouter('put');

export const Delete = setRouter('delete');

export const convert = middleware => (target, key, descriptor) => {
  target[key] = R.compose(
    R.concat(
      changeToArr(middleware)
    ),
    changeToArr,
  )(target[key]);

  return descriptor;
}

export const Log = convert(async (ctx, next) => {
  logTimes++;
  console.time(`${logTimes}: ${ctx.method} - ${ctx.url}`);
  await next();
  console.timeEnd(`${logTimes}: ${ctx.method} - ${ctx.url}`);
})

複製程式碼

相關文章