文章為在下以前開發時的一些記錄與當時的思考, 學習之初的內容總會有所考慮不周, 如果出錯還請多多指教.
TL;DR
使用裝飾器,和諸如 TS.ED、Nest.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 非常相似.
如果您對現代化開發或物件導向的方式很感興趣,不妨嘗試一下這兩個專案.