Web 前端开发日志(四):构建现代化 Node 应用

1,139 阅读6分钟

文章为在下以前开发时的一些记录与当时的思考, 学习之初的内容总会有所考虑不周, 如果出错还请多多指教.

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 非常相似.

如果您对现代化开发或面向对象的方式很感兴趣,不妨尝试一下这两个项目.