Web 前端開發日誌(四):構建現代化 Node 應用

LancerComet發表於2019-03-03

文章為在下以前開發時的一些記錄與當時的思考, 學習之初的內容總會有所考慮不周, 如果出錯還請多多指教.

TL;DR

使用裝飾器,和諸如 TS.EDNest.js 來幫助您構建物件導向的 Node 應用.

靈車漂移

如果您就是傳說中的秋名山五菱老司機,您可能已經見過諸如

// Spring.
package hello;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
public class HelloController {
    @RequestMapping("/my-jj-burst-url")
    public String index() {
        return "Greetings from Spring Boot!";
    }
}
複製程式碼

諸如

// ASP.Net Core.
using Microsoft.AspNetCore.Mvc;

namespace MyAwesomeApp {
    [Route("/my-jj-burst-url")]
    public class HelloController: Controller {
        [HttpGet]
        public async Task<string> Index () {
            return "Greetings from ASP.Net Core!";
        }
    }
}
複製程式碼

諸如

# Flask.
from flask import Flask
app = Flask(__name__)

@app.route("/my-jj-burst-url")
def helloController():
    return "Greetings from Flask!"
複製程式碼

因此當您拿到一個這樣的 Node.js 程式碼時

// Express.

// ./hello-router.js
app.get(`/my-jj-burst-url`, require(`./hello-controller`))

// ./hello-controller.js
module.exports = function helloController (req, res) {
    res.send(`Greetings from Express!`)
}
複製程式碼

您的內心 OS 實際是

// Express + TS.ED.

import { Request, Response } from `express`
import { Controller, Get } from `@tsed/common`

@Controller(`/my-jj-burst-url`)
class HelloController {
    @Get(`/`)
    async greeting (req: Request, res: Response) {
        return `Greetings from Express!`
    }
}
複製程式碼

其實通過一些方式,可以非常方便地在 Node.js 中以這種形式構建您的應用,如果您再配合 TypeScript,就可以瞬間找回型別安全帶來的舒適感.

在使用這樣的方式後,您可能需要以物件導向的方式來構建您的應用.

以一個 Express 應用為例

這裡有一個小巧精緻的 Express 應用:

import * as Express from `express`

const app = Express()

app.get(`/`, (req: Express.Request, res: Express.Response) => {
  res.send(`Hello!`)
})

app.listen(3000, `0.0.0.0`, (error: Error) => {
  if (error) {
    console.error(`[Error] Failed to start server:`, error)
    process.exit(1)
  }

  console.log(`[Info] Server is on.`)
})
複製程式碼

現在我們將把它改造成 OOP、現代化的 Express.

這裡使用 TypeScript 進行編寫.

使用裝飾器

如果您打算在 Node 中找物件,裝飾器是您得力的美顏助手,它可以提升您的顏值,使您再也不會被女嘉賓瞬間滅燈.

裝飾器將為您對某個物件做一些額外的事 ♂ 情,而這樣的能力對物件導向程式設計是非常有幫助的:

// 程式碼引自: http://es6.ruanyifeng.com/#docs/decorator

@testable
class MyTestableClass {
  // ...
}

function testable(target) {
  target.isTestable = true;
}

MyTestableClass.isTestable // true
複製程式碼

請您確認開啟了 TS 的 “experimentalDecorators”;關於裝飾器的內容請您查閱其他文章.

裝飾一個 Server

我們將把一個 Express 程式裝飾為一個 Class,每啟動一個伺服器就 new 程式() 即可,大致效果不出意外應該是:

// 從實現了裝飾器的模組引入裝飾器.
import { AppServer, Server } from `./decorator.server`

// 一個代表 Express 應用的 Class.
@Server({
  host: `0.0.0.0`,
  port: 3000
})
class App extends AppServer {
  private onListen () {
    console.log(`[Info] Server is on.`)
  }

  private onError (error: Error) {
    console.error(`[Error] Failed to start server:`, error)
    process.exit(1)
  }
}

const app = new App()  // 嚯嚯.
console.log(app.app)  // 還要能獲取到 Express.Application, 這是墜吼的.
app.start()
複製程式碼

那麼裝飾器的話,大致搞成這副醜樣:

// decorator.server.ts

import * as Express from `express`

/**
 * Server 裝飾器.
 * 將一個 Class 轉換為 Express.Application 封裝類.
 *
 * @param {IServerOptions} options
 */
function Server (options: IServerOptions) {
  return function (Constructor: IConstructor) {
    // 建立一個 Express.Application.
    const serverApp = Express()

    // 從 prototype 上獲取事件函式.
    const { onListen, onError } = Constructor.prototype

    // 從裝飾器引數獲取設定.
    const host = options.host || `0.0.0.0`
    const port = options.port || 3000

    // 建立 Start 方法.
    Constructor.prototype.start = function () {
      serverApp.listen(port, host, (error: Error) => {
        if (error) {
          isFunction(onError) && onError(error)
          return
        }

        isFunction(onListen) && onListen()
      })
    }

    // 將 App 掛在至原型.
    Constructor.prototype.app = serverApp

    return Constructor
  }
}

/**
 * Server 介面定義.
 * 經過 Server 裝飾的 Class 將包含此型別上的屬性.
 * 若需使用則需要顯式繼承.
 *
 * @class AppServer
 */
class AppServer {
  app: Express.Application
  start: () => void
}

/**
 * Server 裝飾器函式引數介面.
 *
 * @interface IServerOptions
 */
interface IServerOptions {
  host?: string
  port?: number
}

/**
 * 目標是否為函式.
 *
 * @param {*} target
 * @returns {boolean}
 */
function isFunction (target: any) {
  return typeof target === `function`
}

/**
 * "類建構函式" 型別定義.
 * 代表一個 Constructor.
 */
interface IConstructor {
  new (...args: any[]): any
  [key: string]: any
}

export {
  Server,
  AppServer
}
複製程式碼

能用就行.

裝飾一個 Class

控制器的話,我們希望是一個 Class,Class 上面的方法即為路由所使用的控制器方法.

方法由 Http Method 裝飾器進行裝飾,註明路由 URL 與 Method.

使用起來應該長這樣:

import { Request, Response } from `express`
import { Controller, Get } from `./decorator.controller`

@Controller(`/hello`)
class HelloController {
  @Get(`/`)
  async index (req: Request, res: Response) {
    res.send(`Greetings from Hello Controller!`)
  }

  // 加入了一個新的測試函式.
  @Get(`/wow(/:name)?`)
  async doge (req: Request, res: Response) {
    const name = req.params.name || `Doge`
    res.send(`
      <span>Wow</span>
      <br/>
      <span>Such a controller</span>
      <br/>
      <span>Very OOP</span>
      <br/>
      <span>Many decorators</span>
      <br/>
      <span>Good for you, ${name}!</span>
    `)
  }
}

export {
  HelloController
}
複製程式碼

這樣的話,裝飾器需要記錄傳入的 URL 和對應的函式與 Http Method 即可,然後被 @Server 所使用即可.

// decorator.controller.ts

/**
 * Controller 裝飾器.
 * 將一個 Class 裝飾為 App 控制器.
 *
 * @param {string} url
 * @returns
 */
function Controller (url: string = ``) {
  return function (Constructor: IConstructor) {
    // 將控制器的 Url 進行儲存.
    Object.defineProperty(Constructor, `$CONTROLLER_URL`, {
      enumerable: true,
      value: url
    })

    return Constructor
  }
}

/**
 * Http Get 方法裝飾器.
 *
 * @param {string} url
 * @returns {*}
 */
function Get (url: string = ``): any {
  return function (Constructor: IConstructor, name: string, descriptor: PropertyDescriptor) {
    // 將 URL 和 Http Method 註冊至函式.
    const controllerFunc = Constructor[name] as (...args: any[]) => any
    
    // 儲存資訊, 方法上註冊的 url 與 http method.
    Object.defineProperty(controllerFunc, `$FUNC_URL`, {
      enumerable: true,
      value: url
    })
    Object.defineProperty(controllerFunc, `$HTTP_METHOD`, {
      enumerable: true,
      value: `get`
    })
  }
}

export {
  Controller,
  Get
}

/**
 * "類建構函式" 型別定義.
 * 代表一個 Constructor.
 */
interface IConstructor {
  new (...args: any[]): any
  [key: string]: any
}
複製程式碼

回頭修改 Server 裝飾器

編寫好 Controller 後,我們希望能通過指定檔案路徑直接將 Controller 引入,像這樣:

@Server({
  host: `0.0.0.0`,
  port: 3000,
  controllers: [
    `./controller.hello.ts`  // 指定需要使用的控制器.
  ]
})
class App extends AppServer {
  private onListen () {
    console.log(`[Info] Server is on.`)
  }

  private onError (error: Error) {
    console.error(`[Error] Failed to start server:`, error)
    process.exit(1)
  }
}
複製程式碼

@Server 多了一個 controllers: string[] 屬性,用於指定引入的控制器檔案;檔案引入後的路由初始化由程式自動處理, 茲不茲詞?

因此我們需要對 @Server 多加兩句話:

/**
 * Server 裝飾器.
 * 將一個 Class 轉換為 Express.Application 封裝類.
 *
 * @param {IServerOptions} options
 */
function Server (options: IServerOptions) {
  return function (Constructor: IConstructor) {
    // 建立一個 Express.Application.
    const serverApp = Express()

    // 新的邏輯:
    // 從 options.controllers 指定的目錄中讀取檔案並獲取控制器物件.
    // 並將控制器物件註冊至 serverApp.
    const controllers = getControllers(options.controllers || [])
    controllers.forEach(Controller => registerController(Controller, serverApp))

    // ...
  }
}
複製程式碼

兩句話的作用大概是:

  • 從檔案中讀取到 Controller Class;
  • 將 Controller Class 加入至 Express 豪華午餐.
/**
 * 從檔案地址讀取控制器檔案並返回控制器物件的陣列.
 *
 * @param {string[]} controllerFilesPath
 * @returns {IConstructor[]}
 */
function getControllers (controllerFilesPath: string[]): IConstructor[] {
  const controllerModules: IConstructor[] = []
  controllerFilesPath.forEach(filePath => {
    // 從控制器檔案中讀取模組. 模組可能會匯出多個控制器, 將進行遍歷註冊.
    // 假設這裡的路徑是安全的, 例子嘛.
    const module = require(filePath)
    Object.keys(module).forEach(funcName => {
      const controller = module[funcName] as IConstructor
      controllerModules.indexOf(controller) < 0 && controllerModules.push(controller)
    })
  })
  return controllerModules
}

/**
 * 註冊控制器子路由模組至 serverApp.
 *
 * @param {IConstructor} Controller
 * @param {Express.Application} serverApp
 */
function registerController (Controller: IConstructor, serverApp: Express.Application) {
  // 建立控制器的子路由模組.
  const router = Express.Router()

  // 將控制器下的函式進行註冊.
  Object.getOwnPropertyNames(Controller.prototype)
    .filter(funcName => funcName !== `constructor`)
    .map(funcName => Controller.prototype[funcName])
    .forEach(func => {
      const url = func[`$FUNC_URL`] as string
      const method = func[`$HTTP_METHOD`] as string
      if (typeof url === `string` && typeof method === `string`) {
        const matcher = (router as any)[method] as any  // router.get, router.post, ...
        if (matcher) {
          // 這裡用 call 重新指向 router, Express 中的程式碼用到了 this.
          matcher.call(router, url, (req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
            func(req, res, next)
          })
        }
      }
    })

  const controllerPath = Controller[`$CONTROLLER_URL`] as string
  serverApp.use(controllerPath, router)
}
複製程式碼

這樣就差不多齊了,執行一下 OK,截圖就不上了 ?

Middlewares

實際上我們可以做更多的東西,比如加入中介軟體茲詞:

@Controller(`/bye`)
class ByeController {
  @Auth()  // 登陸請求 Only.
  @UseBefore(CheckCSRF)  // CSRF 檢查.
  @Post(`/`)
  async index (req: Request, res: Response) {
    res.send(`Good bye!`)
  }

  @Get(`*`)
  async redirect (req: Request, res: Response) {
    res.redirect(`/bye`)
  }
}
複製程式碼

或者單獨為一個常用的中介軟體定義一個裝飾器;再加上依賴注入等功能,讓整個應用用起來十分得心應手.

詳細邏輯不再舉例,我看各位老司機已經開始飆車了 ??

市面上的輪子

目前市面上已經有類似的輪子出現:

  • TS.ED:一套針對 Express 開發的 TypeScript 裝飾器元件,加入了常見功能的中介軟體與物件導向設計.

  • Nest.js:一套使用 TypeScript 編寫的全新的物件導向設計的 Node.js 框架,功能和風格與 TS.ED 非常相似.

如果您對現代化開發或物件導向的方式很感興趣,不妨嘗試一下這兩個專案.

相關文章