首页 前端知识 用 Typescript 搭建 Nodejs Server

用 Typescript 搭建 Nodejs Server

2024-08-16 22:08:26 前端知识 前端哥 808 863 我要收藏

Typescript 是微软开发的自由和开源的变成语言,是 Javascript 的超集,它可以编译成 Javascript。Typescript 支持 Javascript 的语法,同时它又包含了类型定义、接口、枚举、泛型等很多后端语言的特点,能在编译时支持类型检查,因此可以很好的提升代码质量。

本文将演示如何使用 Typescipt 搭建一个 Nodejs Server(非常像 Spring MVC),所使用的主要框架和插件如下:

  • koa2,nodejs 构造 web service 的框架,由 express 框架的原班人马开发
  • routing-controllers,基于 express/koa2 的 nodejs 框架,提供了大量装饰器,能够极大的简化代码量,正是此插件,使得代码框架可以非常像 Spring MVC
  • sequelize,数据库插件
  • sqlite,数据库

主要框架和插件概述

Koa2 框架

Nodejs 自诞生以来,产生了很多用于构建 web service 的框架,其中 express 框架是比较出名的,一套快速、极简、开放的 web 开发框架,我们可以先看下创建 Nodejs Server 的一个变化(以 Javascript 作为示例)。

最初,只使用 Nodejs 创建Server 和 路由:

const http = require('http');

const routes = {
    '/': indexHandler
}

const indexHandler = (req, res) => {
    res.statusCode = 200;
    res.setHeader('Content-type', 'text/plain');
    res.end('<h1>Welcome!</h2>');
}

const server = http.createServer((req, res) => {
    const url = req.url;
    if(routes[url]) {
        routes[url](req, res);
    } else {
        res.statusCode = 404;
        res.setHeader('Content-type', 'text/plain');
        res.end('404 - Not Found')
    }
});

server.listen(3000, () => {
    console.log('server start at 3000');
})

然后,使用 express 框架创建 Server 和 路由,可以看到,创建 server 的过程由 express 框架处理完成了,开发者可以更加关注于业务的实现,而不用花很多时间在通用代码逻辑上:

const express = require("express");
const app = express();

app.get("/", (req, res) => {
    res.write('<h1>Welcome!</h2>');
    res.end();
});

app.listen(3000, () => {
    console.log('server start at 3000')
});

koa2 框架是 express 框架的开发人员原班人马,基于ES6的新特性而开发的敏捷框架,相比于 express,koa2 框架更加的轻量化,用 async 和 await 来实现异步流程的控制,解决了地狱回调和麻烦的错误处理。Express 和 Koa2 框架的主要区别如下:

  1. express 框架中集成了很多的中间件,比如路由、视图等等,而koa2框架不集成任何的中间件(因此它更轻量),需要时由开发人员自主安装中间件,比如路由中间件 koa-router,这看起来虽然麻烦,但是不一定是坏事,这让整体代码变得更加可控。

  2. 对于异步流程的控制,express 框架使用回调函数的方式(callback 或者 promise),随着代码变得复杂,一层一层的回调足以让开发者在调试时变得头疼(所以被称作地狱回调),而 koa2 框架使用 async 和 await 来处理异步控制,使得代码运行时看起来像是同步,所以开发人员可以更方便的进行代码调试,也使得代码逻辑变得更容易理解。

    async 将函数声明为异步,所以此函数不会阻塞后续代码的执行,async 会自动将函数转换成 Promise,但是必须要等到 async 函数内部执行完毕之后,才会执行 then() 回调函数;async 函数内部的 await ,会让函数内部的代码执行阻塞住,只要 await 的这个函数返回 Promise 对象 resolve,才会继续执行 await 后面的代码,因此,所有的代码在最终表现上看起来就像是同步执行了。

  3. 对于错误处理,express 框架使用回调函数来处理,对于深层次的错误无法捕获;koa2 框架使用 try-catch 来捕获异常,能很好的解决异步捕获(可见后面代码示例,koa 定义全局的 error handler)。

  4. express 是线性模型,koa2 是洋葱模型,即所有请求在经过中间件时会执行两次,所有可以比较方便的进行前置和后置的处理。关于洋葱模型更直观的解释,请看如下示例:

    const Koa = require('koa');
    
    const app = new Koa();
    const mid1 = async (ctx, next) => {
        ctx.body = '';
        ctx.body += 'request: mid1 中间件\n';
        await next();
        ctx.body += 'response: mid1 中间件\n';
    }
    const mid2 = async (ctx, next) => {
        ctx.body += 'request: mid2 中间件\n';
        await next();
        ctx.body += 'response: mid2 中间件\n';
    }
    app.use(mid1);
    app.use(mid2);
    
    app.use(async (ctx, next) => {
        ctx.body += 'This is body\n'
    })
    
    app.listen(3000);
    

    当访问 http://localhost:3000,将会看到如下结果:

    request: mid1 中间件
    request: mid2 中间件
    This is body
    response: mid2 中间件
    response: mid1 中间件
    
  5. express 框架中有 request 和 response 两个对象,而 koa2 框架把这两个对象统一到了 context 对象中。

koa2 框架创建 Server 和 路由 的示例(需要额外安装 @koa/router 插件):

const Koa = require('koa');
const router = require('@koa/router')();
const app = new Koa();

router.get('/', async (ctx, next) => {
    ctx.body = '<h1>Welcome!</h2>';
})

app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000,()=>{
    console.log('server start at 3000')
});

routing-controllers

routing-controllers 是一个基于 express/koa2 的 nodejs 框架,它提供了大量的装饰器(就像是 SpringMVC 中的注解,但是装饰器和注解是完全不同的概念,虽然在语法上非常相似),比如下面这个示例:

@Controller('/user')
@UseBefore(RequestFilter)
export class UserController {

    private logger = LogUtil.getInstance().getLogger();

    constructor(
        private userService: UserService
    ) {
        this.logger.debug('UserController init');
    }

    @Get('/list')
    async getUserList() {
        const users = await this.userService.getAllUser();
        return users;
    }

    @Get('/:uuid')
    async getUserByUuid(@Param('uuid') uuid: string) {
        this.logger.debug(`get user by uuid ${uuid}`);
        const user = await this.userService.getUserByUuid(uuid);
        return user;
    }

    @Post()
    createUser(@Body() userParam: UserParam) {
        this.logger.debug('create user with param ', JSON.stringify(userParam));
        return this.userService.saveUser('', userParam);
    }

    @Put('/:uuid')
    updateUser(@Param('uuid') uuid: string, @Body() userParam: UserParam) {
        this.logger.debug(`update user ${uuid} with param `, JSON.stringify(userParam));
        return this.userService.saveUser(uuid, userParam);
    }

    @Delete('/:uuid')
    deleteUser(@Param('uuid') uuid: string) {
        this.logger.debug(`delete user ${uuid}`);

        return this.userService.deleteUser(uuid);
    }
}

用 @Get @Post 这种装饰器,可以极大的方便我们来定义路由,整个代码风格也更偏向于后端代码风格。需要注意的是,因为 routing-controllers 是基于 express/koa2 上的二次开发,所以我们开发时可以尽量用 routing-controllers 提供的语法糖来实现代码逻辑。

Typescript 的装饰器

Typescript 的装饰器是一种特殊类型的声明,它能被附加到类、方法、属性或者参数上,使用 @expression 这种格式。expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

TypeScript装饰器有如下几种:

  1. 类装饰器,用于类的构造函数。
  2. 方法装饰器,用于方法的属性描述符上。
  3. 方法参数装饰器,用于方法的参数上。
  4. 属性装饰器,用于类的属性上。

有多个参数装饰器时,从最后一个参数依次向前执行,方法装饰器和方法参数装饰器中方法参数装饰器先执行,类装饰器总是最后执行,方法和属性装饰器,谁在前面谁先执行。因为参数属于方法一部分,所以参数会一直紧紧挨着方法执行。

routing-controllers 装饰器的实现以及 MetadataArgsStorage

MetadataArgsStorage 是一个全局、单例的对象,用来保存整个代码运行期间的全局数据。

routing-controllers 实现的主要逻辑,就是先实现一些装饰器,将信息存储到 MetadataArgsStorage 中,然后 createServer 时,再从 MetadataArgsStorage 将信息提取出来,进行 express/koa2 框架需要的初始化工作。

比如 Get 装饰器源码,将路由信息存入 MetadataArgsStorage:

export function Get(route?: string|RegExp): Function {
    return function (object: Object, methodName: string) {
        getMetadataArgsStorage().actions.push({
            type: "get",
            target: object.constructor,
            method: methodName,
            route: route
        });
    };
}

然后,比如创建 koa server时,通过 registerAction 等函数,将 MetadataArgsStorage 中的信息提取出来,注册到 koa2 框架中。

/**
 * Integration with koa framework.
 */
export class KoaDriver extends BaseDriver {
    constructor(public koa?: any, public router?: any) {
        super();
        this.loadKoa();
        this.loadRouter();
        this.app = this.koa;
    }

    /**
     * 初始化server
     */
    initialize() {
        const bodyParser = require("koa-bodyparser");
        this.koa.use(bodyParser());
        if (this.cors) {
            const cors = require("kcors");
            if (this.cors === true) {
                this.koa.use(cors());
            } else {
                this.koa.use(cors(this.cors));
            }
        }
    }

    /**
     * 注册中间件
     */
    registerMiddleware(middleware: MiddlewareMetadata): void {
        if ((middleware.instance as KoaMiddlewareInterface).use) {
            this.koa.use(function (ctx: any, next: any) {
                return (middleware.instance as KoaMiddlewareInterface).use(ctx, next);
            });
        }
    }

    /**
     * 注册action
     */
    registerAction(actionMetadata: ActionMetadata, executeCallback: (options: Action) => any): void {
        // ...一些处理action的逻辑

        const uses = actionMetadata.controllerMetadata.uses.concat(actionMetadata.uses);
        const beforeMiddlewares = this.prepareMiddlewares(uses.filter(use => !use.afterAction));
        const afterMiddlewares = this.prepareMiddlewares(uses.filter(use => use.afterAction));

        const route = ActionMetadata.appendBaseRoute(this.routePrefix, actionMetadata.fullRoute);
        const routeHandler = (context: any, next: () => Promise<any>) => {
            const options: Action = {request: context.request, response: context.response, context, next};
            return executeCallback(options);
        };

        // 将所有action注册到koa中
        this.router[actionMetadata.type.toLowerCase()](...[
            route,
            ...beforeMiddlewares,
            ...defaultMiddlewares,
            routeHandler,
            ...afterMiddlewares
        ]);
    }

    /**
     * 注册路由
     */
    registerRoutes() {
        this.koa.use(this.router.routes());
        this.koa.use(this.router.allowedMethods());
    }

    /**
     * 动态加载koa
     */
    protected loadKoa() {
        if (require) {
            if (!this.koa) {
                try {
                    this.koa = new (require("koa"))();
                } catch (e) {
                    throw new Error("koa package was not found installed. Try to install it: npm install koa@next --save");
                }
            }
        } else {
            throw new Error("Cannot load koa. Try to install all required dependencies.");
        }
    }

    /**
     * 动态加载koa-router
     */
    private loadRouter() {
        if (require) {
            if (!this.router) {
                try {
                    this.router = new (require("koa-router"))();
                } catch (e) {
                    throw new Error("koa-router package was not found installed. Try to install it: npm install koa-router@next --save");
                }
            }
        } else {
            throw new Error("Cannot load koa. Try to install all required dependencies.");
        }
    }

	...
}

routing-controllers 的装饰器功能强大,除了路由外,还支持对于 http request 参数解析、参数校验、拦截器等很多的装饰器,具体可以参考 routing-controllers

题外话:

注解与装饰器:

  • 从语言上,注解主要应用于 Java,C# 等,装饰器主要应用于 Python, Typescript 等。

  • 注解,从字面意义上来说仅是一种代码级别上的说明,是给别人看的,仅提供附加元数据的支持,不能实现任何操作,比如常见的@RequestMapping这个注解,作用就是将请求和处理请求的控制器方法关联起来,建立映射关系,而这注解本身并不能影像控制器内部的方法。

  • 装饰器,可以对被标记的代码进行修改,装饰器本身也是有代码逻辑的,使用装饰器相当于将装饰器自身的代码逻辑附加到被装饰的对象上,并且完成对被装饰对象的代码改造,例如:

    interface Person {
        name: string;
        age: number;
    }
    
    function baseInfo(target: any) {
        target.prototype.name = 'Tom';
        target.prototype.age = 18;
    }
    
    @baseInfo
    class Person {
        construct() {}
    }
    

    当你 new Person() 时,Person 对象就直接被赋予了 name 和 age 两个属性和值,但是 class Person 本身并没有声明这两个属性,因此可以看出来,装饰器可以对被装饰对象进行代码上的修改。

Sequelize

sequelize 是一个功能强大的 nodejs 数据库插件,支持 Postgres, MySQL, MariaDB, SQLite 以及 Microsoft SQL Server 这几个常见的数据库,能够帮助开发者方便的进行数据库连接、数据库增删改查等操作,也支持事务、池化、钩子等高级特性,具体请查看 Sequelize

搭建 Typescript 编写的 Nodejs Server

1. 项目初始化

首先执行 npm init,初始化好基本的 package.json 文件,然后执行以下命令,安装一些基本的依赖:

npm install -D typescript     // 基本的 typescript 依赖
npm install -D @types/node    // 在 typescript 中 import nodejs 的类库时需要的类型声明插件
npm install -D ts-node        // 直接运行 ts 代码的插件
npm install -D nodemon        // 检测文件变化的插件,方便调试热部署

然后执行 npx tsc init ,生成默认的 tsconfig.json 文件,这个文件定义了 typescript 编译时的一些设置,具体的参数含义,可以参考生成文件中的说明,比较重要的参数如下:

{
  "compilerOptions": {
    /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "target": "es2016",
    "experimentalDecorators": true,     /* Enable experimental support for legacy experimental decorators. */
    "emitDecoratorMetadata": true,      /* Emit design-type metadata for decorated declarations in source files. */
    "module": "commonjs",               /* Specify what module code is generated. */
    "rootDir": "./src",                 /* Specify the root folder within your source files. */
    "moduleResolution": "node",         /* Specify how TypeScript looks up a file from a given module specifier. */
    "types": [   /* Specify type package names to be included without being referenced in a source file. */
      "node"
    ],
    "sourceMap": true,                  /* Create source map files for emitted JavaScript files. */
    "outDir": "./dist",                 /* Specify an output folder for all emitted files. */
    /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    "strict": true,         /* Enable all strict type-checking options. */
    "noImplicitAny": true,  /* Enable error reporting for expressions and declarations with an implied 'any' type. */
    "skipLibCheck": true    /* Skip type checking all .d.ts files. */
  }
}

在 package.json 文件的 scripts 中定义一些基本的脚本:

"scripts": {
    。。。,
    
    "build": "tsc",
    "start": "nodemon --watch src/**/*.ts --exec \"ts-node\" src/app.ts local"
}

执行 npm run build 可以将 ts 代码编译为 js 文件

执行 npm run start 可以直接运行 ts 代码,并在修改代码时热部署而不必手动重启

2. 安装必要组件

routing-controllers 相关:

根据 routing-controllers 的文档,执行以下命令,安装必要的框架依赖和 types 声明依赖:

npm install koa koa-bodyparser @koa/router @koa/multer routing-controllers reflect-metadata class-transformer
npm install -D @types/koa @types/koa-bodyparser

可选的插件,需要使用相关功能时才选择安装:

npm install @koa/cors class-validator typedi
npm install -D @types/validator

@koa/cors: 允许跨域访问

class-transformer: 类转换插件,后面会细说

class-validator: 类属性校验插件

typedi: 自定义依赖注入所用的插件,比如将某个类注册成 Service 可供其他类注入并使用

数据库:

npm install sequelize @journeyapps/sqlcipher

Log4js(可选)

npm install log4js
npm install -D @types/log4js

ini 文件操作(可选)

npm install ini
npm install @types/ini

3. 创建代码目录

创建 src 目录来存放源码(这个目录要与 tsconfig.json 文件中定义的 rootDir 一致),然后按照实际业务,某个业务逻辑范围内的代码,放到同一个目录下,比如示例的目录结构如下:

/src             // 源代码文件夹
/--/app.ts       // 程序主入口
/--/common/      // 通用工具类,实体类
/--/filter/      // 过滤器
/--/user/        // 与用户相关的 controller/service/实体类
/--/others/      // 其他自定义的业务代码文件
/package.json
/tsconfig.json

4. 程序主入口

  • import 'reflect-metadata'; 一定要写在最前
  • 如果要使用 typedi ,则需要声明 useContainer(Container);
  • 捕获全局的异常,使用 try-catch 来包装,并且一定要在声明路由之前,关于错误的处理,请看后续章节
  • 使用 useKoaServer 来进一步初始化 app 配置,声明路由、中间件、拦截器等等
import 'reflect-metadata';  // 此依赖为 routing-controllers 插件必须引入的依赖

import Koa, { Context, Next } from 'koa';
import { useContainer, useKoaServer } from 'routing-controllers';
import { Container } from "typedi";
import { UserController } from './user/user-controller';
import { LogUtil } from './common/log-util';
import { ConfigUtil, CONFIG_SECTION, CONFIG_KEY } from './common/config-util';
import { ResponseFilter } from './filter/response-filter';
import { RestJson } from './common/rest-json';
import { AnkonError, ERROR_CODE, ERROR_MSG } from './common/ankon-error';

const LOGGER = LogUtil.getInstance().getLogger();

// 启用依赖注入,目的是为了 @Service 注解能正常使用
useContainer(Container);

const app: Koa = new Koa();
// 自定义统一的全局 error handler
app.use(async (ctx: Context, next: Next) => {
    try {
        await next();
    } catch (err: any) {
        if (err.errorCode) {
            // 自定义错误
            ctx.status = 200;
            const result = new RestJson();
            const error = new AnkonError(err.errorCode, err.errorMsg, err.errorDetail);
            result.createFail(error);
            ctx.body = result;
        } else {
            // 未知异常
            ctx.status = err.status || 500;
            const result = new RestJson();
            const error = new AnkonError(ERROR_CODE.FAIL, ERROR_MSG.FAIL, err.message);
            result.createFail(error);
            ctx.body = result;
        }
    }
})

// 使用 routing-controllers 进一步初始化 app 配置
useKoaServer(app, {
    cors: true,
    // classTransformer: true, // 此配置可以将参数转换成类对象,并包含class的所有方法
    defaultErrorHandler: false, // 关闭默认的 error handler,载入自定义 error handler
    controllers: [
        UserController
    ],
    interceptors: [
        ResponseFilter
    ]
});
const port = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.SERVER, CONFIG_KEY.PORT);

app.listen(port, () => {
    LOGGER.info(`Node Server listening on port ${port}`);
});

5. routing-controllers 的实际应用

以 UserController 为例:

  • 使用 @Controller(‘/user’) 装饰器声明为路由,并定义基础路由路径
  • 使用 @UseBefore(RequestFilter) 声明引用 RequestFilter 这个中间件,并且会在函数执行之前使用,相当于 filter。对应的,还有 @UseAfter,相当于后置过滤器,它们都属于中间件
  • 构造函数 private userService: UserService 即为依赖注入,将 UserService 注入到 UserController 中进行使用
  • @Get(‘/list’)/@Post() 等方法使用对应的装饰器来实现路由以及内部业务逻辑
import { Body, Controller, Delete, Get, Param, Post, Put, UseBefore } from 'routing-controllers';
import { Service } from 'typedi';

import { LogUtil } from '../common/log-util';
import { UserService } from './user-service';
import { UserParam } from './user-param';
import { RequestFilter } from '../filter/request-filter';

@Service()  // 因为额外使用的 typedi,所以此处必须加上这个注解
@Controller('/user')
@UseBefore(RequestFilter)
export class UserController {

    private logger = LogUtil.getInstance().getLogger();

    constructor(
        private userService: UserService
    ) {
        this.logger.debug('UserController init');
    }

    @Get('/list')
    async getUserList() {
        const users = await this.userService.getAllUser();
        return users;
    }

    @Get('/:uuid')
    async getUserByUuid(@Param('uuid') uuid: string) {
        this.logger.debug(`get user by uuid ${uuid}`);
        const user = await this.userService.getUserByUuid(uuid);
        return user;
    }

    @Post()
    createUser(@Body() userParam: UserParam) {
        this.logger.debug('create user with param ', JSON.stringify(userParam));
        return this.userService.saveUser('', userParam);
    }

    @Put('/:uuid')
    updateUser(@Param('uuid') uuid: string, @Body() userParam: UserParam) {
        this.logger.debug(`update user ${uuid} with param `, JSON.stringify(userParam));
        return this.userService.saveUser(uuid, userParam);
    }

    @Delete('/:uuid')
    deleteUser(@Param('uuid') uuid: string) {
        this.logger.debug(`delete user ${uuid}`);

        return this.userService.deleteUser(uuid);
    }
}

UserService:

  • @Service() 即使用 typedi 来自定义一个可以被注入的类,注意一定要在 app.ts 中声明 useContainer(Container);
  • 使用 Squelize 插件来完成增删改查的业务逻辑,注意因为 Squelize 是基于 Promise 的,所以我们可以利用这个特点,在所有函数中使用 async - await 来实现异步函数同步处理
import { Service } from 'typedi';
import { randomUUID } from 'crypto';

import { UserModel } from './user-model';
import { UserParam } from './user-param';
import { LogUtil } from '../common/log-util';
import { UserDTO } from './user-dto';
import { ListDTO } from '../common/list-dto';
import { AnkonError, ERROR_CODE, ERROR_MSG } from '../common/ankon-error';

@Service()
export class UserService {

    private logger = LogUtil.getInstance().getLogger();

    /**
     * 更新或者新增一个用户信息
     * 如果 uuid 传值,则更新用户
     * 如果 uuid 不传值,则新建用户
     * 
     * @param uuid 
     * @param userParam 
     * @returns 
     */
    async saveUser(uuid: string, userParam: UserParam): Promise<UserDTO> {
        if (uuid) {
            // 更新
            const userFromDB = await UserModel.findByPk(uuid);
            if (userFromDB) {
                // 更新
                this.logger.debug(`find user ${uuid}, will update`);
                const userAfterUpdate = await userFromDB.update(userParam);

                return new UserDTO(userAfterUpdate);
            } else {
                throw new AnkonError(ERROR_CODE.USER_NOT_FOUND, ERROR_MSG.USER_NOT_FOUND, `user ${uuid} not found`);
            }
        } else {
            // 新增
            const uuid = randomUUID().replace(/\-/g, '');
            const data: any = {};
            Object.assign(data, userParam);
            data.uuid = uuid;
            return UserModel.create(data);
        }
    }

    /**
     * 根据 uuid 获取用户信息(uuid 为主键)
     * 
     * @param uuid 
     * @returns 
     */
    async getUserByUuid(uuid: string): Promise<UserDTO> {
        const userModel = await UserModel.findByPk(uuid);
        if (!userModel) {
            throw new AnkonError(ERROR_CODE.USER_NOT_FOUND, ERROR_MSG.USER_NOT_FOUND, `user ${uuid} not found`);
        }
        return new UserDTO(userModel);
    }

    /**
     * 获取所有用户列表
     * 
     * @returns 
     */
    async getAllUser(): Promise<ListDTO<UserDTO>> {
        const result = await UserModel.findAndCountAll();
        const users: UserDTO[] = new Array();
        result.rows.forEach((userModel: UserModel) => {
            const user: UserDTO = new UserDTO(userModel);
            users.push(user);
        })
        return new ListDTO(users, result.count);
    }

    /**
     * 删除用户信息,返回 true/false
     * 
     * @param uuid 
     * @returns 
     */
    async deleteUser(uuid: string): Promise<boolean> {
        const count = await UserModel.destroy({ where: { uuid: uuid } });
        return count > 0;
    }
}

6. 数据库操作

自定义数据库 DBUtil,单例模式的工具类,此工具类实例化 Squelize 对象,并且根据 ini 文件的配置,决定数据库连接和参数

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { Sequelize } from 'sequelize';
import { LogUtil } from './log-util';
import { CONFIG_KEY, CONFIG_SECTION, ConfigUtil } from './config-util';
import { InternalServerError } from 'routing-controllers';

const DATABASE_TYPE = {
    MYSQL: 'mysql',
    SQLITE: 'sqlite'
}

const iv = 'ankon_encryptkey';
const key = '86e1e84b81e5787a122441f9548ea2df';

export class DBUtil {

    private logger = LogUtil.getInstance().getLogger();

    private sequelize: Sequelize;

    private static dbUtil: DBUtil;

    private constructor() {
        this.logger.debug('DBUtil init');

        const dialect = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.DIALECT);
        const host = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.HOST);
        const database = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.DATABASE);
        const username = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.USER_NAME);

        if (dialect == DATABASE_TYPE.MYSQL) {
            const password = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.PASSWORD);
            this.sequelize = new Sequelize({
                dialect: 'mysql',
                host: host,
                username: username,
                password: password,
                database: database,
                logging: (msg) => this.logger.debug(msg),
                define: {
                    charset: 'utf8mb4'
                }
            })
        } else if (dialect == DATABASE_TYPE.SQLITE) {
            this.sequelize = new Sequelize({
                dialect: 'sqlite',
                storage: path.join(host, database),
                logging: (msg) => this.logger.debug(msg),
                password: this.getSqlitePassword(),
                dialectModulePath: '@journeyapps/sqlcipher'
            })
        } else {
            throw new InternalServerError(`database ${dialect} not suppoorted`);
        }

        if (this.sequelize) {
            this.sequelize.sync();
        }
    }

    public static getInstance(): DBUtil {
        if (!this.dbUtil) {
            this.dbUtil = new DBUtil();
        }
        return this.dbUtil;
    }

    /**
     * 获取 Sequelize 实例化的对象
     * 
     * @returns 
     */
    public getSequelize() {
        return this.sequelize;
    }

    /**
     * 获取 sqlite 的密码
     * 如果本地密码文件存在,则读取并解密
     * 如果不存在,则创建一个新的密码文件
     * 
     * @returns 
     */
    private getSqlitePassword(): string {
        let password = '';

        const basePath = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.HOST);
        const databaseKey = ConfigUtil.getInstance().getConfig(CONFIG_SECTION.DATABASE, CONFIG_KEY.PASSWORD);
        const keyFilePath = path.join(basePath, databaseKey);
        if (fs.existsSync(keyFilePath)) {
            // 读取文件内容并解密
            const pwdBuffer = fs.readFileSync(keyFilePath);
            const cipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
            let passwordBuffer = cipher.update(pwdBuffer);
            passwordBuffer = Buffer.concat([passwordBuffer, cipher.final()]);
            password = passwordBuffer.toString();
        } else {
            // 动态生成密码并加密之后写入文件
            const passwordBuffer = crypto.randomBytes(32);
            const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
            let data = cipher.update(passwordBuffer);
            data = Buffer.concat([data, cipher.final()]);
            fs.writeFileSync(keyFilePath, data);

            password = passwordBuffer.toString();
        }

        this.logger.debug('init sqlite database with password ', password);

        return password;
    }
}

声明数据库实体类,以 UserModel 为例:

  • 与 Javascript 写法不同的是,Typescript 中必须 extends Model<InferAttributes<T>, InferCreationAttributes<T>>,并且在其中 declare 对应的属性,CreationOptional 是声明该属性为可选(可为空),init 方法与 Javascript 并无不同
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
import { DBUtil } from '../common/db-util';

export class UserModel extends Model<
    InferAttributes<UserModel>,
    InferCreationAttributes<UserModel>> {
        declare uuid: string;
        declare userName: string;
        declare nickName: CreationOptional<string>
}

UserModel.init({
    uuid: {
        type: DataTypes.STRING(32),
        primaryKey: true,
        allowNull: false,
        comment: '主键,用户唯一性标识'
    },
    userName: {
        type: DataTypes.STRING(64),
        allowNull: false,
        comment: '用户姓名'
    },
    nickName: {
        type: DataTypes.STRING(64),
        comment: '用户昵称'
    }
}, {
    sequelize: DBUtil.getInstance().getSequelize(),
    timestamps: false,
    createdAt: false,
    updatedAt: false,
    freezeTableName: true,
    tableName: 'user'
})

7. 自定义中间件和拦截器

实现一个自定义中间件,只要继承 KoaMiddlewareInterface 并实现 use 方法即可,KoaMiddlewareInterface 为 routing-controllers 封装实现的 Koa 的中间件。

import { KoaMiddlewareInterface } from 'routing-controllers';
import { Service } from 'typedi';
import { LogUtil } from '../common/log-util';

/**
 * 请求拦截器
 * 可用于签名校验、用户校验等
 * 
 */
@Service()
export class RequestFilter implements KoaMiddlewareInterface {

    private logger = LogUtil.getInstance().getLogger();

    use(context: any, next: (err?: any) => Promise<any>): Promise<any> {
        this.logger.debug('in request filter');
        this.logger.debug(`get request header content-type = ${context.headers['content-type']}`);
        return next();
    }
}

中间件使用时,可以用 @UseBefore 或者 @UseAfter,在路由之前或者之后应用(这就对应了 koa2 的洋葱模型,每个中间件可以执行两次)。@UseBefore 或者 @UseAfter 可以声明在 Controller 类上,也可以声明到某个具体的函数之上。

全局中间件的定义,需要使用 @Middleware 这个装饰器来声明,并且在 app.ts 初始化时指定使用,具体参考 routing-controllers 说明文档

拦截器 Interceptor,本质上还是个中间件,其实就是相当于实现一个 KoaMiddlewareInterface 并且 @UseAfter,routing-controllers 定义了 InterceptorInterface 来更方便的实现拦截器,并且可以全局应用。

如下,以ResponseFilter为例,定义一个全局的拦截器,该拦截器拦截所有 response,将结果封装为 RestJson 对象:

import { Action, Interceptor, InterceptorInterface } from 'routing-controllers';
import { LogUtil } from '../common/log-util';
import { Service } from 'typedi';
import { ListDTO } from '../common/list-dto';
import { RestJson } from '../common/rest-json';

/**
 * 返回值拦截器
 * 可以在此对于返回值做一些处理
 * 
 */
@Service()
@Interceptor()
export class ResponseFilter implements InterceptorInterface {

    private logger = LogUtil.getInstance().getLogger();

    intercept(action: Action, result: any) {
        this.logger.debug('in response filter ', result);
        const restJson = new RestJson<any>();
        restJson.createSuccess();
        if (result instanceof ListDTO) {
            restJson.setTotal(result.count);
            restJson.setData(result.data);
        } else {
            restJson.setData(result);
        }

        return restJson;
    }

}

同时,app.ts 中需要声明:
useKoaServer(app, {
    。。。
    interceptors: [
        ResponseFilter
    ]
});

非全局的拦截器,只需要删除拦截器类声明上的 @Interceptor(),在具体需要使用的地方用 @UseInterceptor(ResponseFilter) 来装饰即可,可参考 routing-controllers

8. 自定义错误以及错误处理

routing-controllers 中预置了很多通用错误:

  • HttpError
  • BadRequestError
  • ForbiddenError
  • InternalServerError
  • MethodNotAllowedError
  • NotAcceptableError
  • NotFoundError
  • UnauthorizedError

HttpError extends Error,其他的 Error 都是 extends HttpError。如果这些错误不能涵盖业务代码的所有错误,可以自定义错误类,同样 extends HttpError 即可。

import { HttpError } from "routing-controllers";

export class AnkonError extends HttpError  {
    errorCode!: number;
    errorMsg!: string;
    errorDetail?: string;

    constructor(errorCode: number, errorMsg: string, errorDetail?: string) {
        super(500, errorMsg);
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
        this.errorDetail = errorDetail;
    }
}

export const ERROR_CODE = {
    SUCCESS: 0,
    FAIL: -1,
    // user 相关错误
    USER_NOT_FOUND: 10000
}

export const ERROR_MSG = {
    SUCCESS: 'success',
    FAIL: 'fail',
    // user 相关错误
    USER_NOT_FOUND: 'user not found'
}

使用时,
throw new AnkonError(ERROR_CODE.USER_NOT_FOUND, ERROR_MSG.USER_NOT_FOUND, `user ${uuid} not found`);

当错误发生时,请求被意外中断,因此 @UseAfter 的中间件不会被调用,所以不能通过定义全局的中间件或者拦截器来处理异常,只能在 app.ts 中事先处理掉,并且关闭 defaultErrorHandler

const app: Koa = new Koa();
// 自定义统一的全局 error handler
app.use(async (ctx: Context, next: Next) => {
    try {
        await next();
    } catch (err: any) {
        if (err.errorCode) {
            // 自定义错误
            ctx.status = 200;
            const result = new RestJson();
            const error = new AnkonError(err.errorCode, err.errorMsg, err.errorDetail);
            result.createFail(error);
            ctx.body = result;
        } else {
            // 未知异常
            ctx.status = err.status || 500;
            const result = new RestJson();
            const error = new AnkonError(ERROR_CODE.FAIL, ERROR_MSG.FAIL, err.message);
            result.createFail(error);
            ctx.body = result;
        }
    }
})

useKoaServer(app, {
    。。。
    defaultErrorHandler: false, // 关闭默认的 error handler,载入自定义 error handler
});

9. 其他装饰器

routing-controllers 中提供了多种多样的装饰器,具体可以参考 routing-controllers 装饰器参考

class-transformer

大家应该注意到,在 app.ts 中,有一行被注释掉的代码 classTransformer: true

useKoaServer(app, {
    cors: true,
    // classTransformer: true, // 此配置可以将参数转换成类对象,并包含class的所有方法
    defaultErrorHandler: false, // 关闭默认的 error handler,载入自定义 error handler
    controllers: [
        UserController
    ],
    interceptors: [
        ResponseFilter
    ]
});

routing-controllers 框架中,使用 classTransformer 来将用户参数转换成类对象实例,classTransformer 为 true 和 false 的区别在于,为 true 时实例化的对象包含类的所有属性和方法,而为 false 时,实例化的对象仅包含基础属性,例如

export class User {
  firstName: string;
  lastName: string;

  getName(): string {
    return this.lastName + ' ' + this.firstName;
  }
}

@Controller()
export class UserController {
  post(@Body() user: User) {
    console.log('saving user ' + user.getName());
  }
}

// 当 classTransformer = true 时,可以调用 user.getName() 方法
// 当 classTransformer = false 时,调用 user.getName() 会报错

当然,在我们日常开发中,也可以使用 class-transformer 来转换比如 JSON 数据为一个具体的类对象

import { plainToClass } from 'class-transformer';

const userJson = {
    firstName: 'zhang',
    lastName: 'san'
}
const user = plainToClass(User, userJson);

编译和运行

按照 package.json 文件中的定义,执行 npm run build 将所有 src 目录下的 ts 文件编译成 js 文件,并且输出到 dist 目录下,然后将 dist 目录下的所有文件,以及 node_modules 文件夹一起打包即可正常运行 node app.js

转载请注明出处或者链接地址:https://www.qianduange.cn//article/15804.html
标签
koa2
评论
发布的文章

HTML5学习记录

2024-04-29 12:04:01

大家推荐的文章
会员中心 联系我 留言建议 回顶部
复制成功!