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

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

TL;DR

使用裝飾器,和諸如 TS.EDNest.js 來幫助您構建面向對象的 Node 應用.java

靈車漂移

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

// 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!";
    }
}
複製代碼

諸如git

// 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!";
        }
    }
}
複製代碼

諸如es6

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

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

所以當您拿到一個這樣的 Node.js 代碼時github

// 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 實際是web

// 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,就能夠瞬間找回類型安全帶來的溫馨感.spring

在使用這樣的方式後,您可能須要以面向對象的方式來構建您的應用.typescript

以一個 Express 應用爲例

這裏有一個小巧精緻的 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 很是類似.

若是您對現代化開發或面向對象的方式很感興趣,不妨嘗試一下這兩個項目.

相關文章
相關標籤/搜索