【设计】基于 express + OOP 做一个小型服务器轮子

165 阅读7分钟

直接使用 express, koa 这些开源框架的问题

  1. 路由划分不明确:一旦项目变大了之后,路由的代码会变得混乱且繁杂
  2. 代码复用性差
  3. 代码结构混乱,职责不明确

比如下面这一段使用 express 直接调用的代码:

import express, { Application, Router } from 'express';

const mainRouter: Router = Router();

mainRouter.get('/', (req: Request, res: Response) => {
  res.status(200).json({
    status: 'Success',
    message: 'Hello',
    query: req.query,
  })
});

mainRouter.post(
  '/test-post',
  bodyParser.urlencoded({ extended: true }), // 解析urlencoded格式的数据
  bodyParser.json(), // 解析 application/json
  (req: Request, res: Response) => {
  res.status(200).json({
    status: 'Success',
    message: 'Hello',
    body: req.body,
  })
});

const routerMap: Map<string, Router> = new Map([
  ['/', mainRouter],
]);

const app: Application = express();

for (let [prefix, router] of routerMap.entries()) {
  app.use(prefix, router);
}

app.listen(15000, () => {
  console.log('Server1 is Running at port 15000');
});

关于 MVC 的分层设计

  1. Controller 提供访问服务器的 API 接口
  2. 调用接口时,匹配控制器的方法
  3. 控制器内部调用 service 层的方法
  4. service 层调用 model 层的方法
  5. model 层调用数据库
  6. model 层返回数据给 service 层
  7. service 层返回数据给 controller 层
  8. controller 层返回数据给客户端

Controller 提供访问服务器的 API 接口 - 示例代码:

import { Request, Response } from 'express';
import { UserService } from '../services/user.service';

class UserController {
  private userService: UserService = new UserService();

  public getUserInfo(req: Request, res: Response) {
    const userId = req.query.userId;
    this.userService.getUserInfo(userId).then((userInfo) => {
      res.status(200).json(userInfo);
    });
  }
}

export default new UserController();

调用接口时,匹配控制器的方法 - 示例代码

import router from '../router';
import userController from '../controllers/user.controller';

router.get('/user', userController.getUserInfo);

控制器内部调用 service 层的方法 - 示例代码

import { UserService } from '../services/user.service';

export class UserService {
  public getUserInfo(userId: string): Promise<any> {
    return new Promise((resolve, reject) => {
      // 调用数据库
      resolve({
        userId: userId,
        userName: '张三',
      });
    });
  }
}

直接使用 MVC 可能存在的问题:

  • 不可否认:使用 MVC 架构能对项目的每个操作进行逻辑解耦
  • 但实际开发中,我们更希望只关注业务操作本身,其他的事情(e.g 路由注册,控制器注册,sql 操作)我们并不关心

思考:

能不能采用一种约定 > 配置 > 编码的方式来设计一个小轮子,让方法如下调用:

  1. App 声明 & 配置 & 启动 (链式调用)
import { bootstrapApplication } from '@mvc-pkg';

// 引入服务器的模块
import myModule from './modules/test.module';

bootstrapApplication({
  host: 'localhost',
  port: 15001,
  modules: [myModule],
})
  .start();
  1. 声明服务模块 MyModule
import Module from '@mvc-pkg/app/classes/Module';

// 引入控制器
import Test1Controller from '../controllers/test1.controller';

// 引入服务 ...
// 引入中间件 ...

class MyModule extends Module {
  constructor() {
    super(MyModule.name);
  }
}

export default new MyModule().createController([
  Test1Controller,
  // ... rest controllers
]);
  1. 声明控制器模块 Test1Controller, 控制器中非业务模块以装饰器声明为主:

    • @Controller:全局托管的控制器的类装饰器
    • @Cors:允许跨域的类装饰器
    • @Get:GET 请求方法说明的方法装饰器
    • @Post:POST 请求方法说明的方法装饰器
    • @Put:PUT 请求说明的方法装饰器
    • @Delete:DELETE 请求方法说明的方法装饰器
    • @Patch:PATCH 请求方法说明的方法装饰器
    • @PathVariable:路径变量的参数装饰器
    • @Query:查询参数的参数装饰器
    • @Body:请求体的参数装饰器
import {
  Body,
  Controller,
  Cors,
  Get,
  Post,
  Query,
  PathVariable
} from '../../mvc-pkg';
import PostBody from '../bean/PostBody';
import StudentBean from '../bean/Student';

@Controller() // 全局托管的控制器
@Cors() // 允许跨域
class TestController {
  // @Get('') -> 没有使用 Method 装饰器,方法默认为 Get, 请求路径默认为 ''
  public getHelloWorld(): string {
    return 'hello world';
  }

  @Get('/get-json')
  public getJson(a: string, b: string) {
    console.log(a, b);
    return {
      msg: '/get-json',
      query: {
        a,
        b,
      },
    };
  }

  @Get('/get-query-json')
  public getQueryJson(@Query() query: any) {
    return new Promise((resolve, reject) => {
      resolve({
        msg: '/get-query-json',
        query,
      });
    });
  }

  @Get('/get-student-query-json')
  public getStudentQuery(@Query() studentQuery: StudentBean) {
    return {
      msg: '/get-student-query-json',
      studentQuery,
    };
  }

  @Post('/post-json')
  public postJson(@Body() body: PostBody) {
    return {
      msg: '/post-json',
      body,
    };
  }

  @Post('/post-body-json')
  public postBodyJson(@Body() body: PostBody) {
    return {
      msg: '/post-body-json',
      body,
    };
  }

  @Get('/get-path/:id')
  public getPathVariable(@PathVariable() id: string) {
    return {
      msg: '/get-student-query-json',
      id,
    };
  }
}

export default TestController;

当然可以!这里提供一个编写 node 轮子的思路(仅代表个人观点)。

轮子的架构设计

mvc-pkg
  |- app # 应用模块
    |- classes
      |- Application.ts
      |- Module.ts
    |- bootstrap.ts # 启动配置
    |- index.ts # app 出口
  |- http # http 服务模块
    |- request
      |- decorators
        |- @Controller.ts
        |- @Cors.ts
        |- @Get.ts
        |- @Post.ts
        |- @Put.ts
        |- @Delete.ts
        |- @Patch.ts
        |- @PathVariable.ts
        |- @Query.ts
        |- @Body.ts
      |- index.ts # http.request 包的出口
      |- response
        |- decorators
          |- @ResponseHeader.ts # 设置响应头
        |- index.ts # http.response 包的出口
  |- middleware # 中间件模块
    |- decorators
      |- @Middleware.ts
    |- index.ts # 服务包的出口
  |- service # 服务模块
    |- decorators
      |- @Middleware.ts
    |- index.ts # 服务包的出口
  |- shared # 包内共享木块
    |- index.ts # utils 包的出口
  |- typings # 包内部的类型声明
  |- index.ts # mvc-pkg 包的出口

轮子的功能设计

1. 出口:

@pkg-mvc 中,导出所有的模块

export * from './app';
export * from './http';
export * from './shared';
export * from './service';
export * from './middleware';
export type * from './typings';

2. 类型模块

import Module from '../app/classes/Module';

export interface BootstrapApplicationOptions {
  host: string;
  port: number;
  controllers?: Array<Function | Object>;
  modules?: Array<Module>;
}

import { Router } from "express";

export type TControllerOptions = undefined | string | {
  prefix: string;
};

export type TPathVariableOptions = string | {
  name: string;
};

export type TDecorateClass = (
  & Function
  & { router?: Router }
  & { new(...args: any[]): any }
  & { [key: string]: any }
);

type TMethodTarget = Record<(string | symbol), any>;

3. 工具模块:

Singleton.ts

function Singleton(): ClassDecorator {
  return ((Target: Function & { new(...args: any[]): any }) => {
    // 单例模式实现
    let _instance: Function & { new(...args: any[]): any } | null = null;

    Object.defineProperty(Target, '_instance', {
      get: function () {
        return _instance;
      },
      enumerable: true,
    })

    Object.defineProperty(Target, 'create', {
      value: function (...args: any[]) {
        if (_instance === null) {
          _instance = new Target(...args);
        }
        return _instance;
      },
      enumerable: false,
    });

    return Target;
  }) as ClassDecorator;
}

export default Singleton;

Tools.ts

import express, { Application } from 'express';
import bodyParser from 'body-parser';
import { TControllerOptions, TDecorateClass } from '../typings';

class Tools {
  static Tools = Tools;

  // 获取 express router 的 baseURL (app.use() 的时候使用)
  getRouterBasePath(options: TControllerOptions): string {
    if (typeof options === 'string') {
      return options;
    }
    if (typeof options === 'object' && options !== null) {
      return `${options.prefix}`;
    }

    return '/';
  }

  // 获取控制器方法的参数列表
  getFnParams(
    fnString: string,
    methodName: string,
    controllerObject: TDecorateClass,
  ) {
    const argList: string[] = [];
    fnString
      ?.replace
      ?.(/\n/igm, '')
      .replace(/((.+?))/, (s: string, k: string) => {
        const methodQueryMap: Map<string, number> = controllerObject.$methodQueryMap;
        const methodBodyMap: Map<string, number> = controllerObject.$methodBodyMap;
        const pathVariableMap: Map<
          string | Symbol,
          {
            index: number;
            value: string;
            parameterName?: string;
          }
        > = controllerObject.$pathVariableMap;

        const kList = k
          .split(',')
          .map(item => item.trim())
          .map((item, index) => {
            if (methodQueryMap && methodQueryMap.has(methodName) && index === methodQueryMap.get(methodName)) {
              return '$query';
            }
            if (methodBodyMap && methodBodyMap.has(methodName) && index === methodBodyMap.get(methodName)) {
              return '$body';
            }
            if (pathVariableMap && pathVariableMap.has(methodName) && index === pathVariableMap.get(methodName).index) {
              const pathVariableObject = pathVariableMap.get(methodName);
              return `$params:${pathVariableObject.parameterName || item}`;
            }

            return item;
          })

        argList.push(...kList);
        return s;
      });
    return argList;
  }

  createApp(): Application {
    const expressApp = express();

    expressApp.use(bodyParser.urlencoded({ extended: true }));
    expressApp.use(bodyParser.json());

    return expressApp;
  }
}

export default new Tools();

3. 应用模块

出口

在应用模块中,需要一个核心方法:bootstrapApplication():

import { BootstrapApplicationOptions } from '../typings';
import Application from './classes/Application';

function bootstrapApplication(
  options: BootstrapApplicationOptions,
): Application {
  return Application.create(options);
}

应用类

应用类有以下几个作用:

  1. 内置 express (将 express.Application 作为一个观察者来进行使用)
  2. 集成模块 & 依赖
import type { Application as ExpressApp } from 'express';
import type { BootstrapApplicationOptions } from '../../typings';
import Singleton from '../../shared/Singleton';
import tools from '../../shared/Tools';
import Module from './Module';

@Singleton()
class Application {
  private static _instance: Application;

  private host: string;

  private port: number;

  private expressApp: ExpressApp;

  private _modulesMap: Map<string, Module>;

  private constructor(options: Partial<BootstrapApplicationOptions>) {
    this.host = options.host || 'localhost';
    this.port = options.port || 3000;
    this._modulesMap = new Map([
      ['main', new Module('main')],
    ]);
    options.modules?.forEach?.(m => [
      this._modulesMap.set(m.getModuleName(), m)
    ])
    this.expressApp = tools.createApp();
  }

  public start() {
    // ... 注册 Controller

    // ... 注册 Service

    // ... 注册 Middleware

    // 启动服务, 返回应用实例
    this.expressApp.listen(this.port, this.host, () => {
      console.log(`Server is running at http://${this.host}:${this.port}`);
    })
  }

  public static create(options: Partial<BootstrapApplicationOptions>): Application {
    throw new Error('Method not implemented.');
  }

  public static getInstance(): Application {
    return Application._instance;
  }

  public getExpressApp(): ExpressApp {
    return this.expressApp;
  }

  public getModulesMap(): Map<string, Module> {
    return this._modulesMap;
  }

  public getModule(moduleName: string): Module | null {
    return this._modulesMap.get(moduleName);
  }

  public createController(controllers?: any[]): Application {
    const mainModule: Module = this._modulesMap.get('main');
    mainModule.createController(controllers);
    this._modulesMap.set('main', mainModule);
    return this;
  }
}

export default Application;

模块类

模块类有以下几个作用:

  1. 注册 Controller
  2. 注册 Service
  3. 注册 Middleware
export default class Module {
  constructor(
    private _moduleName: string,
    private _controllerSet: Set<Object> = new Set(),
    private _serviceSet: Set<Object> = new Set(),
    private _middlewareSet: Set<Object> = new Set(),
  ) {}

  public getModuleName() {
    return this._moduleName;
  }

  public createController(controllers?: any[]) {
    if (Array.isArray(controllers) && controllers.length) {
      controllers.forEach(c => {
        this._controllerSet.add(c);
      });

      console.log('====【%s】模块添加了控制器: %s ====', this._moduleName, controllers);
    }

    return this;
  }

  public createService(services?: any[]) {
    if (Array.isArray(services) && services.length) {
      services.forEach(s => {
        this._serviceSet.add(s);
      });
    }
    console.log('====【%s】模块添加了服务: %s ====', this._moduleName, services);

    return this;
  }

  public createMiddleware(middlewares?: any[]) {
    if (Array.isArray(middlewares) && middlewares.length) {
      middlewares.forEach(m => {
        this._middlewareSet.add(m);
      });
    }

    return this;
  }
}

http 模块

http.request:

  • index.ts
export { default as Controller } from './decorators/@Controller';
export { default as Cors } from './decorators/@Cors';
export { default as Get } from './decorators/@Get';
export { default as Post } from './decorators/@Post';
export { default as Put } from './decorators/@Put';
export { default as Query } from './decorators/@Query';
export { default as Body } from './decorators/@Body';
export { default as PathVariable } from './decorators/@PathVariable';
  • decorators/@Controller.ts
import { Router, Request, Response } from 'express';
import Application from '../../../app/classes/Application';
import { TControllerOptions, TDecorateClass } from '../../../typings';
import { tools } from '../../../shared';

function Controller(options?: TControllerOptions) {
  return (ControllerClass: TDecorateClass) => {
    setImmediate(() => {
      const controllerObject: TDecorateClass = new ControllerClass();
      const application: Application = Application.getInstance();
      const router: Router = Router();
      const routerBasePath: string = tools.getRouterBasePath(options);

      const keys: string[] = [
        ...Object.getOwnPropertyNames(controllerObject),
        ...Object.getOwnPropertyNames(ControllerClass.prototype),
      ];

      keys.forEach((k) => {
        if (k === 'constructor') {
          return;
        }

        const controllerMethod = controllerObject[k];
        if (typeof controllerMethod === 'function') {
          const requestMethod: 'get' | 'post' | 'put' | 'delete' | 'options' | 'patch' =
            ['get', 'post', 'put', 'delete', 'options', 'patch'].includes(controllerMethod.method)
              ? controllerMethod.method
              : 'get';
          const requestPath = controllerMethod.path || '';
          const fnParamsList = tools.getFnParams(
            controllerMethod.fnString || controllerMethod.toString(),
            k,
            controllerObject,
          );

          // TODO add request method & params & pathvariable
          router[requestMethod](requestPath, async (req: Request, resp: Response) => {
            const paramsList = fnParamsList.map(k => {
              if (k === '$query') {
                return req.query;
              }
              if (k === '$body') {
                return req.body;
              }
              if (k.includes('$params')) {
                const variableProperty = k.split('$params:')[1];
                return req.params[variableProperty];
              }

              return req.query[k];
            });

            const result = await controllerMethod.apply(controllerObject, [...paramsList]);
            resp.send(result);
          });
        }
      });

      application.getExpressApp().use(routerBasePath, router);
      ControllerClass.router = router;
    });
  }
}

export default Controller;
  • decorators/@Cors.ts
import type { Router, Request, Response, NextFunction } from 'express';
import { TDecorateClass } from '../../../typings';

function Cors() {
  return (Target: TDecorateClass) => {
    const router: Router = Target.router;

    if (router) {
      router.use('*', async (req: Request, res: Response, next: NextFunction) => {

        // CORS
        res.header('Access-Control-Allow-Origin', '*');
        // res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
        // res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With');
        
        // intercepts OPTIONS method
        if ('OPTIONS' === req.method) {
          res.sendStatus(200);
        }

        await next();
      });
    }
  };
}

export default Cors;
  • decorators/@Get.ts (@Post, @Put, @Delete 同理)
function Get(path: string = '') {
  return (
    Target: any,
    name: string,
    descriptor: PropertyDescriptor,
  ) => {
    const fn = descriptor.value;

    descriptor.value = function (...args: any[]) {
      return fn.apply(this, args);
    }

    descriptor.value.path = path;
    descriptor.value.method = 'get';
    descriptor.value.fnString = fn.toString();
  }
}

export default Get;
  • decorators/@Query.ts
import type { TMethodTarget } from '../../../typings';

function Query(): ParameterDecorator {

  return (tar, name, index) => {
    const target: TMethodTarget = <TMethodTarget> tar;
    // const TargetConstructor: Function = target.constructor;
    // const fn = target[name];

    if (!target.$methodQueryMap) {
      target.$methodQueryMap = new Map<string, number>();
    }

    target.$methodQueryMap.set(name, index);

    return target;
  }
}

export default Query;
  • decorators/@Body.ts
import type { TMethodTarget } from '../../../typings';

function Body(): ParameterDecorator {

  return (tar, name, index) => {
    const target: TMethodTarget = <TMethodTarget> tar;
    // const TargetConstructor: Function = target.constructor;
    // const fn = target[name];

    if (!target.$methodBodyMap) {
      target.$methodBodyMap = new Map<string, number>();
    }

    target.$methodBodyMap.set(name, index);

    return target;
  }
}

export default Body;
  • decorators/@PathVariable.ts
import { TPathVariableOptions } from '../../../typings';

function PathVariable(opt?: TPathVariableOptions): ParameterDecorator {

  return (tar, name, index) => {
    /**
     * Map {
      [methodName: string]: {
        argIndex: number,
        argValue: params.value,
      }
     }
     */

    const target = <typeof tar & {
      $pathVariableMap: Map<
        string | Symbol,
        {
          index: number;
          value: string;
          parameterName?: string;
        }
      >
    }>tar;

    if (!target.$pathVariableMap) {
      target.$pathVariableMap = new Map();
    }

    target.$pathVariableMap.set(name, {
      index,
      value: '',
      parameterName: (
        typeof opt === 'object' && opt !== null ?
          opt?.name
          : typeof opt === 'string'
            ? opt
            : ''
      ),
    });
  };
}

export default PathVariable;

http.response:


Service 模块

出口:

export { default as Service } from './decorators/@Service';

业务模块:

  • decorators/@Service.ts:
function Service(
  Services: Array<{ new (...args: any[]): any }> = []
): ClassDecorator {

  return (Target) => {
    for (let Service of Services) {
      const serviceInstanceName = Service.name.replace(
        /^[A-Z]/, (match) => match.toLowerCase()
      );

      Target.prototype[serviceInstanceName] = new Service();
    }

    return Target;
  }
}

export default Service;

参考代码:

📎express-demo.zip