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 很是类似.

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

相关文章
相关标签/搜索