Deno從零到架構級系列(二)——註解路由

飛狐發表於2020-08-13

image

上回介紹了Deno的基本安裝、使用。基於oak框架搭建了控制層、路由層、對入口檔案進行了改造。那這回我們接著繼續改造路由,模擬springmvc實現註解路由。

裝飾器模式

裝飾者模式(decorator),就是給物件動態新增職責的方式稱為裝飾者模式。直接先上例子:

// 新建檔案fox.ts
// 建立一個fox類
class Fox {
 // skill方法,返回狐狸會跑的字樣,假設就是構建了狐狸類都會跑的技能
 skill() {
   return '狐狸會跑。'
 }
}
// 建立一個flyingfox類
class Flyingfox  {
   private fox: any  
   // 構造方法,傳入要裝飾的物件
   constructor(fox: any) {
     this.fox = fox;
     // 這裡直接列印該類的skill方法返回值
     console.log(this.skill())
   }
   // 該類的skill方法
   skill() {
     // 在這裡獲取到了被裝飾者
     let val = this.fox.skill();
     // 這裡簡單的加字串,假設給被裝飾者加上了新的技能
     return val + '再加一對翅膀,就會飛啦!'
   }
}
// new一個fox物件
let fox = new Fox();

// 列印結果為:狐狸會跑。再加一對翅膀,就會飛啦!
new Flyingfox(fox);

直接執行deno run fox.ts就會列印結果啦。這是一個非常簡單的裝飾者模式例子,我們繼續往下,用TS的註解來實現這個例子。

image

TypeScript裝飾器配置

因為deno本來就支援TS,但用TS實現裝飾器,需要先配置。在根目錄新建配置檔案tsconfig.json,配置檔案如下:

{
 "compilerOptions": {
   "allowJs": true,
   "module": "esnext",
   "emitDecoratorMetadata": true,
   "experimentalDecorators": true
 }
}

TS裝飾器

這裡提一下,註解和裝飾器是兩個東西,對於不同的語言來講,功能不同。

  • 註解(Annotation):僅提供附加後設資料支援,並不能實現任何操作。需要另外的 Scanner 根據後設資料執行相應操作。
  • 裝飾器(Decorator):僅提供定義劫持,能夠對類及其方法的定義並沒有提供任何附加後設資料的功能。

我一直稱註解稱習慣了。大家理解就好。

TypeScript裝飾器是一種函式,寫法:@ + 函式名。作用於類和類方法前定義。 還是拿上面的例子來改寫,如下

@Flyingfox
class Fox {}

// 等同於
class Fox {}
Fox = Flyingfox(Fox) || Fox;

很多小夥伴經常看到這樣的寫法,如下:

function Flyingfox(...list) {
  return function (target: any) {
    Object.assign(target.prototype, ...list)
  }
}

這樣在裝飾器外面再封裝一層函式,好處是便於傳引數。基本語法掌握了,我們就來實戰一下,實戰中才知道更深層次的東東。

裝飾器修飾類class

裝飾器可以修飾類,也可以修飾方法。我們先來看修飾類的例子,如下:

// test.ts
// 定義一個Time方法
function Time(ms: string){
  console.log('1-第一步')
  // 這裡的target就是你要修飾的那個類
  return function(target: Function){
    console.log(`4-第四步,${value}`)
  }
}
// 定義一個Controller方法,也是個工廠函式
function Controller(path: string) {
  console.log('2-第二步')
  return function(target: Function){
    console.log(`3-第三步,${value}`)
  }
}

@Time('計算時間')
@Controller('這是controller')
class Controller {
}
// 執行:deno run -c tsconfig.json ./test.ts
// 1-第一步
// 2-第二步
// 3-第三步, 這是controller
// 4-第四步, 計算時間

有疑問的小夥伴可以console出來看看這個target。 這裡要注意三個點:

  • 執行命令:deno run -c tsconfig.json ./test.ts,這裡的-c是執行ts配置檔案,注意是json檔案
  • 外層工廠函式的執行順序:從上到下依次執行。
  • 裝飾器函式的執行順序:從下到上依次執行。

TS註解路由

好啦,下面我們接著上一回的內容,正式改造註解路由了。oak和以前koa、express改造思路都一樣。改造之前,按照路由分發請求流程,如下圖:

image

改造之後,我們的流程如下圖。

image

新建decorators資料夾,包含三個檔案,如下:

// decorators/router.ts
// 這裡統一引入oak框架
import { Application, Router } from 'https://deno.land/x/oak@v6.0.1/mod.ts'
// 統一匯出oak的app和router,這裡的其實可以單獨放一個檔案,因為還有入口檔案server.ts會用到
export const app: Application = new Application();
export const router: Router  = new Router();
// 路由字首,這裡其實應該放到配置檔案
const prefix: string = '/api'
// 構建一個map,用來存放路由
const routeMap: Map<{target: any, method: string, path: string}, Function | Function[]> = new Map()

// 這裡就是我們作用於類的修飾器
export function Controller (root: string): Function {
  return (target: any) => {
    // 遍歷所有路由
    for (let [conf, controller] of routeMap) {
      // 這裡是判斷如果類的路徑是@Controller('/'),否則就跟類方法上的路徑合併
      let path = prefix + (root === '/' ? conf.path : `${root}${conf.path}`)
      // 強制controller為陣列
      let controllers = Array.isArray(controller) ? controller : [controller]
      // 這裡是最關鍵的點,也就是分發路由
      controllers.forEach((controller) => (router as any)[conf.method.toLowerCase()](path, controller))
    }
  }
}

這裡就是類上的路由了,每一行我都加了註釋。給小夥伴們一個建議,哪裡不明白,就在哪裡console一下。 這裡用的Map來存放路由,其實用反射更好,只是原生的reflect支援比較少,需要額外引入reflect的檔案。有興趣可以去看alosaur框架的實現方式。

// decorators/index.ts
export * from "./router.ts";
export * from "./controller.ts";

這個其實沒什麼好講的了,就是入口檔案,把該資料夾下的檔案匯出。這裡的controller.ts先留個懸念,放到彩蛋講。 接著改造控制層,程式碼如下:

// controller/bookController.ts
import { Controller } from "../decorators/index.ts";
// 這裡我們假裝是業務層過來的資料
const bookService = new Map<string, any>();
bookService.set("1", {
  id: "1",
  title: "聽飛狐聊deno",
  author: "飛狐",
});

// 這裡是類的裝飾器
@Controller('/book')
export default class BookController {
  getbook (context: any) {
    context.response.body = Array.from(bookService.values());
  }
  getbookById (context: any) {
    if (context.params && context.params.id && bookService.has(context.params.id)) {
      context.response.body = bookService.get(context.params.id);
    }
  }
}

接著改造專案入口檔案server.ts

// server.ts
// 這裡的loadControllers先不管,彩蛋會講
import { app, router, loadControllers } from './decorators/index.ts'

class Server {
  constructor () {
    this.init()
  }

  async init () {
    // 這裡就是匯入所有的controller,這裡的controller是控制層資料夾的名稱
    await loadControllers('controller');
    app.use(router.routes());
    app.use(router.allowedMethods());
    this.listen()
  }

  async listen () {
    // await app.listen({ port: 8000 });
    setTimeout(async () => {
      await app.listen({ port: 8000 })
    }, 1);
  }
}
new Server()

好啦,整個類的裝飾器改造就結束了。整個專案目錄結構如下:

image

先不著急執行,雖然執行也會成功,但啥都做不了,為啥呢? 因為類方法的路由還沒有做,不賣關子了,接下來做類方法的裝飾器。

TS類方法的裝飾器

還是先從程式碼上來,先改造控制層,如下:

// controller/bookController.ts
const bookService = new Map<string, any>();
bookService.set("1", {
  id: "1",
  title: "聽飛狐聊deno",
  author: "飛狐",
});

@Controller('/book')
export default class BookController {
  // 這裡就是類方法修飾器
  @Get('/getbook')
  getbook (context: any) {
    context.response.body = Array.from(bookService.values());
  }
  // 這裡就是類方法修飾器
  @Get('/getbookById')
  getbookById (context: any) {
    if (context.params && context.params.id && bookService.has(context.params.id)) {
      context.response.body = bookService.get(context.params.id);
    }
  }
}

類方法修飾器實現,這裡就只講解有改動的地方,如下:

// decorators/router.ts
import { Application, Router } from 'https://deno.land/x/oak@v6.0.1/mod.ts'
// 這裡是TS的列舉
enum MethodType {
  GET='GET',
  POST='POST',
  PUT='PUT',
  DELETE='DELETE'
}

export const app: Application = new Application();
export const router: Router  = new Router();
const prefix: string = '/api'

const routeMap: Map<{target: any, method: string, path: string}, Function | Function[]> = new Map()

export function Controller (root: string): Function {
  return (target: any) => {
    for (let [conf, controller] of routeMap) {
      let path = prefix + (root === '/' ? conf.path : `${root}${conf.path}`)
      let controllers = Array.isArray(controller) ? controller : [controller]
      controllers.forEach((controller) => (router as any)[conf.method.toLowerCase()](path, controller))
    }
  }
}
// 這裡就是http請求工廠函式,傳入的type就是http的get、post等
function httpMethodFactory (type: MethodType) {
  // path是類方法的路徑,如:@Get('getbook'),這個path就是指getbook。
  // 類方法修飾器傳入三個引數,target是方法本身,key是屬性名
  return (path: string) => (target: any, key: string, descriptor: any) => {
    // 第三個引數descriptor我們這裡不用,但是還是講解一下,物件的值如下:
    // {
    //   value: specifiedFunction,
    //   enumerable: false,
    //   configurable: true,
    //   writable: true
    // };
    (routeMap as any).set({
      target: target.constructor,
      method: type,
      path: path,
    }, 
    target[key])
  }
}

export const Get = httpMethodFactory(MethodType.GET)
export const Post = httpMethodFactory(MethodType.POST)
export const Delete = httpMethodFactory(MethodType.DELETE)
export const Put = httpMethodFactory(MethodType.PUT)

到這裡,註解路由就改造完了。但是,這個時候請大家跳到彩蛋把匯入檔案的方法補上。然後一氣呵成的執行入口檔案,就大功告成了。

image

彩蛋

這裡的彩蛋部分,其實是一個deno的匯入檔案方法,程式碼如下:

// decorators/controller.ts
export async function loadControllers (controllerPath: string) {
  try {
    for await (const dirEntry of Deno.readDirSync(controllerPath)) {
      import(`../${controllerPath}/${dirEntry.name}`);
    }
  } catch (error) {
    console.error(error)
    console.log("no such file or dir :---- " + controllerPath)
  }
}

這裡的readDirSync就是讀取傳入的資料夾路徑,然後用import匯入迭代的檔案。

解決Deno的bug

另外大家如果在1.2以前的版本遇到報錯如下:

Error: Another accept task is ongoing

不要著急,這個是deno的錯誤。解決方法如下:

async listen () {
  // await app.listen({ port: 8000 });
  setTimeout(async () => {
    await app.listen({ port: 8000 })
  }, 1);
}

找到入口檔案,在監聽埠方法加個setTimeout就可以搞定了。之前deno官方的issue,很多人在提這個bug。飛狐在此用點特殊的手法解決了。嘿嘿~

image

下回預告

學會了TS裝飾器可以做的很多,比如:請求引數註解、日誌、許可權判斷等等。回顧一下,這篇的內容比較多,也比較深入。大家可以好好消化一下,概括一下:

  • 裝飾者模式
  • TS類的裝飾器,TS類方法的裝飾器
  • 資料夾的匯入,檔案的引入

下回我們講全域性錯誤處理,借鑑alosaur做異常處理。有任何問題大家可以在評論區留言~

Ta-ta for now ヾ( ̄▽ ̄)

相關文章