记录一次nest服务开发&上线

年底了,高中同学被老板安排去核对两个表格,一个表格相当于是字典, 另一个表格会提供一个抽象的店名, 去字典表里查询该店名的店铺编号,一开始想这简单,写个脚本分分钟给他弄好,结果啪啪啪打脸,要查的店铺名实在是太抽象了,可能是字典表里三列的某一列数据。

shop_id shop_name shop_addr province sys_name
234432 天M超市神墩三路店 湖北武汉市江夏区神墩三路 武汉市 天M超市
shop_id shop_name
要填写的code 江夏神墩路社区超市

想了一下还是给他弄一个管理后台,让他自己去搜索查找吧,搜索多列字段听说用全文索引会好很多,正好也学习一下这方面的知识,顺带练练一下刚学的nest。

开发工具

  • 后台服务:nest
  • 数据库:mysql
  • 前端: TDesign Starter
  • 一台服务器:docker 装好 nginx, mysql
  • 一个cos桶 + 域名

后台服务

在数据库创建表

在服务器上我已经通过docker搭建了一个mysql,远程连接上去执行下面语句

CREATE TABLE
`fulltext_test` (
`id` int (11) NOT NULL AUTO_INCREMENT,
`shop_name` varchar(255) NOT NULL COMMENT '店名',
`province` varchar(255) DEFAULT NULL COMMENT '省份',
`sys_name` varchar(255) DEFAULT NULL COMMENT '系统名',
`shop_id` varchar(255) DEFAULT NULL COMMENT '店铺id',
`shop_addr` varchar(255) DEFAULT NULL COMMENT '店铺详细地址',
PRIMARY KEY (`id`),
FULLTEXT KEY `index_content_tag` (`shop_name`, `sys_name`, `shop_addr`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8

创建项目

使用脚手架创建项目

npm install -g @nestjs/cli
nest new zizi-mgr-serve (你的项目名)

通过cli创建curd的 api模块

这个时候就已经有一个nest开发框架了,通过cli创建一个可以curd的 api模块

nest g resource excelFind

会在src目录生成一个excel-find的模块

image

在src/excel-find/entities/excel-find.entity.ts 编写对应的数据库实例对象,熟悉一下注解语法

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity({
name: 'fulltext_test',
})
class ExcelFind {
@PrimaryGeneratedColumn()
id: number;

@Column({
comment: '店铺名称',
})
shop_name: string;
// ... 省略代码
}
export {ExcelFind}

连接数据库

通过typeorm在 src/app.module.ts 连接数据库,首先安装依赖

npm install --save @nestjs/typeorm typeorm mysql2

src/app.module.ts

@Module({
imports: [
ExcelFindModule,
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'root',
database: 'test',
synchronize: true,
logging: true,
entities: [ExcelFindModule],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'sha256_password',
},
})
})

服务开发

查询getShopList

主要记录

  • mysql的全文索引通配符匹配
  • 参数里特殊字符在全文索引匹配报错问题
    创建一个post的路由,通过@Body 获取对应的参数对象,主要逻辑在service层处理

src/excel-find/excel-find.controller.ts

@Post('getShopList')
async getShopList(@Body() queryExcelFindDto: QueryExcelFindDto) {
const ret = await this.excelFindService.getShopList(queryExcelFindDto);
return { code: 0, data:ret }
}

src/excel-find/excel-find.service.ts
代码就不全部贴了,主要记录一下遇到的坑点

   async getShopList(params: QueryExcelFindDto, isLike = false) {
const {
keyword = '',
province,
sys_name,
page: { pageNum = 1 },
} = { page: {}, ...params };
let { page: { pageSize = 10 } } = { page: {}, ...params }; // 解构入参,并给page默认值
if(pageSize > 100) { // pageSize限制一下,不然我怕我的服务器扛不住
pageSize = 100
}
// 通过keyword长度,去选择使用什么样的查询,全文索引对匹配的字段长度有限制,
// 我目前设置的是4,长度4以下的就走like查询,否则走全文索引搜索
let sqlStr = `select * from fulltext_test `;
let whereStr = keyword.length >= 4 && isLike === false
? ` where match(shop_name,shop_addr,sys_name) against(? in boolean mode)`
: ' where 1=1';
const db_params = [];
if (keyword.length >= 4 && isLike === false) {
// 表格里会有一些特殊字符需要处理一下,
// 一开始还怀疑为什么使用了参数预编译还被注入攻击了,后来了解原来全文索引有多种模式,
// 有一个模式一个使用通配符规则,而-属于通配符,数量和位置有要求,不对会报错
db_params.push(keyword.replaceAll(/-/g, ''))
} else {
whereStr += ' and (shop_name like ? or shop_addr like ? or sys_name like ?)';
db_params.push(...[`%${keyword}%`, `%${keyword}%`, `%${keyword}%`])
}
}
// ......省略代码
// 执行查询语句
const [ret, count] = await Promise.all([this.manager.query(sqlStr, db_params), this.manager.query(`select count(*) as total from fulltext_test ${whereStr}`, db_params)])
// ......省略代码

解析上传文件的数据插入数据库

主要记录

  • nest对上传文件的验证与处理
  • nest验证器的使用
  • 高版本依赖包esmodule导出,引入打包会有问题
  • 上传文件安全校验问题
  • 通过typeorm的queryBuilder进行sql处理
    对上传文件处理需要安装依赖,然后通过拦截器和管道,便可以获取文件对象
npm install -D @types/multer

src/excel-find/excel-find.controller.ts

@Post('uploadExcel')
// 通过 `@UseInterceptors` 使用nest的 interceptor 拦截器和文件Pipe 管道来处理上传文件
@UseInterceptors(FileInterceptor('file'))
// 通过 自定义的validation 对文件大小和类型进行验证处理
async uploadExcel(@UploadedFile(FileSizeValidationPipePipe) file: Express.Multer.File) {
// ...省略代码
// 通过 xlsx解析文件buffer,来获取xlsx数据,并插入数据库
const xlsxData = xlsx.parse(file.buffer)
// 第一行是字段表头, 后面的是数据
dataList = xlsxData[0].data.filter(Boolean)
headKey = dataList.splice(0, 1)[0]
// ...省略代码
const rowCount = await this.excelFindService.insertData(headKey, dataList)
// ...省略代码
}

通过验证器对参数进行校验
创建一个validationPipe,通过filetype依赖包对文件来下进行校验
filetype会获取文件的二进制类型,比较靠谱,可以防止文件伪装
不过这里filetype高版本引入会有问题,因为打包变成CommonJS引入,提示不能使用esmodule导出的包…

nest g pipe file-size-validation-pipe --no-spec --flat

src/file-size-validation-pipe.pipe

@Injectable()
export class FileSizeValidationPipePipe implements PipeTransform {
async transform(value: any, metadata: ArgumentMetadata) {
if(value.size > 5 * 1024 * 1024) {
throw new HttpException('文件大于 5m', HttpStatus.BAD_REQUEST);
}
const typeObj = await filetype.fromBuffer(value.buffer)
const allowType = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
if(!typeObj || !allowType.includes(typeObj.mime)) throw new HttpException('文件类型不正确', HttpStatus.BAD_REQUEST)
return value;
}
}

src/excel-find.service
这里insert().into第一个参数好像不能使用resource模块生成的entity

 // ...省略代码
const { raw } = await queryBuilder.insert().into('fulltext_test', headKey).values(dataValues).execute()
// ...省略代码

跨域处理

主要记录

  • 带cookie的跨域处理

src/main.ts

 // 不支持带cookie的跨域
// const app = await NestFactory.create<NestExpressApplication>(AppModule, {cros: true});
// 带cookie跨域请求 access-control-allow-Origin/Methods/Headers 不能为 *
app.enableCors({
origin: 'http://localhost:3002',
methods: 'GET,HEAD,POST,DELETE',
allowedHeaders: 'Content-Type, Accept',
credentials: true,
});

日志处理

主要记录

  • winston处理日志
  • 动态模块
    首先安装依赖
    npm install --save  winston dayjs chalk@4
    创建一个winston模块
    nest g module winston
    创建有一个winston类

src/winston/MyLogger.ts

import { ConsoleLogger, LoggerService, LogLevel } from '@nestjs/common';
import * as chalk from 'chalk';
import * as dayjs from 'dayjs';
import { createLogger, format, Logger, transports } from 'winston';
export class MyLogger implements LoggerService {
private logger: Logger;
constructor(options) {
this.logger = createLogger(options);
}
log(message: string, context: string) {
const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');
this.logger.log('info', message, { context, time });
}
error(message: string, context: string) {
const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');
this.logger.log('info', message, { context, time });
}
warn(message: string, context: string) {
const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');
this.logger.log('info', message, { context, time });
}
}

封装成一个动态模块,导出模块

src/winston/winston.module.ts

import { DynamicModule, Global, Module } from '@nestjs/common';
import { LoggerOptions, createLogger } from 'winston';
import { MyLogger } from './MyLogger';
export const WINSTON_LOGGER_TOKEN = 'WINSTON_LOGGER';
@Global()
@Module({})
export class WinstonModule {
public static forRoot(options: LoggerOptions): DynamicModule {
return {
module: WinstonModule,
providers: [
{
provide: WINSTON_LOGGER_TOKEN,
useValue: new MyLogger(options)
}
],
exports: [
WINSTON_LOGGER_TOKEN
]
};
}
}

在app.module.ts初始化winston配置

src/app.module.ts

WinstonModule.forRoot({
level: 'debug',
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({context, level, message, time}) => {
const appStr = chalk.green(`[NEST]`);
const contextStr = chalk.yellow(`[${context}]`);
return `${appStr} ${time} ${level} ${contextStr} ${message} `;
})
),
}),
new transports.File({
format: format.combine(
format.timestamp(),
format.json()
),
filename: `${dayjs(Date.now()).format('YYYY-MM')}.log`,
dirname: 'log'
})
]
})