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 框架的主要区别如下:
-
express 框架中集成了很多的中间件,比如路由、视图等等,而koa2框架不集成任何的中间件(因此它更轻量),需要时由开发人员自主安装中间件,比如路由中间件 koa-router,这看起来虽然麻烦,但是不一定是坏事,这让整体代码变得更加可控。
-
对于异步流程的控制,express 框架使用回调函数的方式(callback 或者 promise),随着代码变得复杂,一层一层的回调足以让开发者在调试时变得头疼(所以被称作地狱回调),而 koa2 框架使用 async 和 await 来处理异步控制,使得代码运行时看起来像是同步,所以开发人员可以更方便的进行代码调试,也使得代码逻辑变得更容易理解。
async 将函数声明为异步,所以此函数不会阻塞后续代码的执行,async 会自动将函数转换成 Promise,但是必须要等到 async 函数内部执行完毕之后,才会执行 then() 回调函数;async 函数内部的 await ,会让函数内部的代码执行阻塞住,只要 await 的这个函数返回 Promise 对象 resolve,才会继续执行 await 后面的代码,因此,所有的代码在最终表现上看起来就像是同步执行了。
-
对于错误处理,express 框架使用回调函数来处理,对于深层次的错误无法捕获;koa2 框架使用 try-catch 来捕获异常,能很好的解决异步捕获(可见后面代码示例,koa 定义全局的 error handler)。
-
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 中间件
-
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装饰器有如下几种:
- 类装饰器,用于类的构造函数。
- 方法装饰器,用于方法的属性描述符上。
- 方法参数装饰器,用于方法的参数上。
- 属性装饰器,用于类的属性上。
有多个参数装饰器时,从最后一个参数依次向前执行,方法装饰器和方法参数装饰器中方法参数装饰器先执行,类装饰器总是最后执行,方法和属性装饰器,谁在前面谁先执行。因为参数属于方法一部分,所以参数会一直紧紧挨着方法执行。
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