CloverAdmin是一个Nest后台后端解决方案。它借助强大的Nest和社区快速、轻巧地构建更优雅的Node服务端应用程序。目前已集成权限验证、使用Typeorm的MySql与SqlServer方案、全局常量配置、URI多版本控制、数据验证、文件上传、静态服务、日志等功能。它提供了一个完整的RestFul风格的接口示例。
node:14.20.1
pnpm:7.13.3
如果事先未安装Nest, 则需要先安装Nest
$ pnpm i -g @nestjs/cli
安装项目依赖
$ pnpm install
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
建设中。。。
仅需在mian.ts中添加如下配置:
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
cors: true, // 跨域
});
Nest路由由如下几部分组成:
域名(或 IP:端口)
/全局路由前缀(如果有)
/api版本(如果有)
/controller层标识
/路由层标识
一个路由的行为,Nest对其进行了划分:
- controller主要负责接收接口传来的参数和返回数据
- service主要负责接口逻辑处理,如数据库操作
- pipe管道则主要负责对接口传来的数据进行验证和转换
- interceptor拦截器则主要是为接口处理绑定额外的逻辑,比如在接口返回值前进行额外处理(对日期进行格式化等)
- guard守卫则是为某些需要特殊身份才能访问的接口绑定鉴权行为
仅需在main.ts中添加如下配置:
app.setGlobalPrefix('clover-api'); // 全局路由前缀
在main.ts中添加如下配置:
app.enableVersioning({
// URI版本控制
type: VersioningType.URI,
defaultVersion: '1', // 为所有URI指定默认版本
}); // 默认版本为v1, 即api/v1/xxx
如果需要手动指定控制器版本, 参照如下:
@Controller({
version: '2',
})
export class CatsControllerV1 {
@Get('cats')
findAll(): string {
return 'This action returns all cats for version 1';
}
}
同样, 路由也是如此:
@Controller()
export class CatsController {
@Version('1')
@Get('cats')
findAllV1(): string {
return 'This action returns all cats for version 1';
}
@Version('2')
@Get('cats')
findAllV2(): string {
return 'This action returns all cats for version 2';
}
}
version也可以是数组, 比如: ['1', '2']
,因为有些特殊路由需要在多个版本间保持一致行为,这样做可以减少重复代码。
更多内容见: API多版本
全局常量配置存放在src/config
目录下, 分为三种环境(开发环境、生产环境、测试环境),依据环境不同加载不同env文件(不建议创建多环境公用的env文件!!)
使用示例(以development环境为例):
-
在development.env中添加配置:
APP_PORT=8888
-
在config下创建
app.config.ts
(后续根据不同模块,创建不同的config.ts)import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ port: parseInt(process.env.APP_PORT, 10) || 3000, }));
推荐使用这种"带命名空间"的配置对象, 不仅增强可读性, 也为环境变量划分了模块, 而且由于是ts, 可以为访问环境变量添加逻辑处理(例如本例就为port添加了int类型转换和默认值)。
-
在
app.module.ts
中引入appConfig:
import appConfig from './config/app.config';
@Module({
imports: [
/** 导入config模块 */
ConfigModule.forRoot({
envFilePath: `./src/config/${ConfigEnum[process.env?.NODE_ENV ?? 'development']}.env`, // 自定义env文件路径
load: [appConfig], // 自定义配置文件
isGlobal: true, // 设为全局模块
}),
]
})
-
在
app.controller.ts
中访问变量@Controller() export class AppController { constructor( private configService: ConfigService, ) {} @Get() getHello(): string { console.log(this.configService.get<string>('app.port')); // 读取env变量示例 } }
登录注册相关接口在src/core/auth模块下,
CloverAdmin将验证phone和password字段并检验是否与数据库记录匹配来生成token, 如果需要使用其他字段则在src/core/auth/strategies/local.strategy.ts配置:
usernameField: 'phone', // 改为你的字段
passwordField: 'password', // 改为你的字段
其中验证数据库记录的逻辑是auth.service.ts的validateUser方法, 此方法将依据phone查询数据库记录, 并检查密码是否正确, 最终返回去除password字段的user实体。
jwt中存储的user信息可自行在src/core/auth/strategies/kwt.strategy.ts中配置:
async validate(payload: any) {
console.log('jwt', payload);
// 此处可以执行其他逻辑操作: 如查询数据以获取完整的user对象
return { id: payload.sub, phone: payload.phone, roles: payload.role }; // 即user的id, phone, role
}
jwt密钥在src/core/auth/constants.ts中。
jwt生效时间在src/core/auth/auth.module.ts中配置:
@Module({
imports: [
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: `${60 * 60}s` }, // 过期时间
}),
]
})
为登录路由使用管道: @UseGuards(LocalAuthGuard)
, 其他需要验证token的路由使用管道: @UseGuards(JwtAuthGuard)
,在此之后可直接获取到jwt中的user信息:
@Post()
@UseGuards(AuthGuard('jwt'))
async createPost(
@Req() req: any,
): Promise<Result> {
createInput.user = req.user; // 获取user
}
验证token身份之后, 验证用户权限, 权限Role在src/common/enums/role.enum.ts下
首先为路由或控制器使用管道: @UseGuards(RolesGuard)
, 再为路由或控制器配置权限@Roles(Role.ADMIN)
ps:
在nest中, 不仅可以为路由设置管道, 也可以为controller、模块、甚至全局设置管道。
采用Typeorm 文档地址: https://typeorm.bootcss.com/
Nest文档:https://docs.nestjs.cn/8/techniques?id=%e6%95%b0%e6%8d%ae%e5%ba%93
ormconfig.json一般放在项目根目录下, 且mysql与sqlserver配置略有不同, 如下
mysql:
// ormconfig.json
{
"type": "mysql",
"host": "数据库ip地址",
"port": 数据库端口号,
"username": "xxxx",
"password": "xxxxxx",
"database": "数据库名",
"entities": ["dist/feature/**/entities/*.entity{.ts,.js}"],
"synchronize": true,
"autoLoadEntities": true
}
sqlserver:
// ormconfig.json
{
"type": "mssql",
"host": "数据库ip地址",
"port": 数据库端口号,
"username": "xxxx",
"password": "xxxxxx",
"database": "数据库名",
"entities": ["dist/feature/**/entities/*.entity{.ts,.js}"],
"synchronize": true,
"autoLoadEntities": true,
"options": { # mssql如果出现ssl问题, 则需要添加此options
"encrypt": false,
"trustServerCertificate": true,
"cryptoCredentialsDetails": {
"minVersion": "TLSv1"
}
}
}
注意:
- Typeorm生成的sql语句可能不支持SqlServer2008,因此可能需要使用QueryBuilder来拼接sql。
- 同时使用relations, skip,take可能会出现TypeError: Cannot read property 'databaseName' of undefined", 只需再加一个order即可以解决。
@Module({
imports: [
TypeOrmModule.forRoot()
],
// ...
})
export class AppModule {
// 注入TypeORM 的Connection对象
constructor(private readonly connection: Connection) {}
}
@Module({
imports: [
TypeOrmModule.forFeature([User]) // 注册User实体
],
// ...
exports: [TypeOrmModule], // 导出模块, 在导入TypeOrmModule.forFeature 的模块之外使用存储库
})
接着在user.service.ts中就可以使用User存储库(Repository)以及Connection对象,存储库可以胜任所有数据库操作,而Connection对象用于事务。
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
private connection: Connection,
) {}
}
使用示例:
// 查询所有user
findAll(): Promise<Array<User>> {
return this.usersRepository.find();
}
更多typeorm操作见typeorm部分, 或typeorm官方文档。
Nest文档:https://docs.nestjs.cn/8/techniques?id=%e9%aa%8c%e8%af%81
使用示例:
export class User {
@IsNotEmpty() // 不为空
firstName: string;
@IsString() // 字符串
firstName: string;
@IsMobilePhone('zh-CN', { // 大陆手机号
message: '手机号格式不正确', // 自定义验证失败信息
})
phone: string;
@IsEnum(Role) // 验证枚举
@ValidateIf((o) => o?.role) // 条件验证, 只有当role存在时才验证
role?: Role;
}
注意:
CloverAdmin在main.ts中启用了全局验证, 配置如下:
app.useGlobalPipes(
new ValidationPipe({
disableErrorMessages: false, // 禁用详细错误信息(开启后不会再返回详细的错误信息)
whitelist: true, // 自动去除非白名单内的属性(即验证类中没有声明的属性)
forbidNonWhitelisted: false, // 开启后, 如果出现非白名单内的属性, 将不再是自动去除而是抛出错误
transform: false, // 全局开启转换器(默认关闭, 多数情况是在controller层开启)---开启后, '14' => 14
}),
); // 全局启用验证
在设置whitelist: true
后,前端传递的数据如果没有在entity或dto中设置验证, 那么这个数据将被忽略。
对接口出入参作转换 官方文档: https://docs.nestjs.cn/9/techniques?id=%e5%ba%8f%e5%88%97%e5%8c%96%ef%bc%88serialization%ef%bc%89
https://github.com/typestack/class-transformer
- 在Entity中为要排除的属性添加注解
@Exclude()
import { Exclude } from 'class-transformer'; export class UserEntity { @Exclude() password: string; }
- 在controller中使用
@UseInterceptors(ClassSerializerInterceptor)
@UseInterceptors(ClassSerializerInterceptor) @Get() findOne(): UserEntity{}
这类似于计算属性(或者说, getter函数)
- 使用注解
@Expose()
@Expose() get fullName(): string { return `${this.firstName} ${this.lastName}`; }
对数据进行转换, 常常需要为接口出入参做数据转换
- 使用注解
@Transform()
// 比如在查询时希望展示role的name而不是整个对象 @Transform(role => role.name) role: RoleEntity;
ClassSerializerInterceptor的默认行为可以被覆盖,只需要传入一个options
对象
// 自动排除了所有以_前缀开头的属性
@SerializeOptions({
excludePrefixes: ['_'],
})
@Get()
findOne(): UserEntity {
return {};
}
在app.module.ts中添加配置:
@Module({
imports: [
ServeStaticModule.forRoot({
// 配置静态服务目录---访问: http://localhost:3000/client目录内/xxx.png
rootPath: join(__dirname, '..', 'client'),
}),
]
})
配置完成后, 所有需要静态访问的静态资源文件均可放在根目录的client(没有则需创建)目录下。
文件上传后将会保存在client/fileUpload目录下, 也可以在file.module.ts中更改destination进行重新设置。
文件上传有一个专门的模块, 在src/feature/file下, 此功能基于multer实现, 相关的所有配置均在file.module.ts下。
如果在上传文件同时, 希望一同传递其他参数, 请在src/feature/file/dto中进行设置。
官方文档: https://docs.nestjs.cn/9/techniques?id=%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1
定时任务模块位于: src/core/task
创建一个定时任务需要使用注解@Cron()
@Cron('45 * * * * *')
代表每分钟执行一次,在第45秒执行, 参数遵从如下规则:
* * * * * *
| | | | | |
| | | | | day of week
| | | | month
| | | day of month
| | hour
| minute
second (optional)
一些常用的计时模式示例如下:
* * * * * *
每秒
45 * * * * *
每分钟第 45 秒
_ 10 _ * * *
每小时,从第 10 分钟开始
0 _/30 9-17 _ * *
上午 9 点到下午 5 点之间每 30 分钟
0 30 11 * * 1-5
周一至周五上午 11:30
@nestjs/schedule
包的内置CronExpression对象也提供一些常用枚举, 如CronExpression.EVERY_30_SECONDS
每分钟执行一次,在第30秒执行
使用注解@Interval(10000)
声明像js中setInterval()一样的任务
使用注解@Timeout(5000)
声明延时任务, 就像js中
setTimeout()一样
CloverAdmin在Nest默认日志系统基础上进行一些扩展(你可以在core/logger模块看到详细代码),并提供了两种日志记录实现,分别是中间件日志监控(只记录请求信息)和拦截器日志监控(记录请求信息和部分响应信息,默认已启用此方案)。
MyLogger模块会在项目根目录下创建logs文件夹,并会将日志输出到logs/info.log和logs/err.log文件中。
要使用MyLogger模块首先需要全局启用MyLogger模块,在app.module.ts中执行导入:
@Module({
imports: [
LoggerModule
]
})
接着在main.ts中全局启用MyLogger:
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
app.useLogger(new MyLogger());
现在你可以在任何地方注入MyLogger并调用其中的日志方法来输出日志,像这样:
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
/** 注入日志服务 */
constructor(
private readonly myLogger: MyLogger,
) {
// 设置日志上下文
this.myLogger.setContext(LoggerMiddleware.name);
}
use(req: Request, res: Response, next: NextFunction) {
// 记录请求日志
this.myLogger.log(`Request... ${req.url} ${req.method}
body: ${JSON.stringify(req.body)}
query: ${JSON.stringify(req.query)}
user-agent: ${req.headers['user-agent']}`);
next();
}
}
这个示例中,我们在中间件中注入了MyLogger,并依据其log方法输出日志。
在每一个controller中注入MyLogger输出请求信息十分繁琐,所以我们借助中间件和拦截器提供了两种记录请求日志的方案:
如果你希望使用中间件日志监控,则需要在app.module.ts中增加如下配置:
@Module({...})
export class AppModule implements NestModule {
// ...
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware) // 启用日志中间件
// .exclude({ path: 'cats', method: RequestMethod.GET }) // 排除指定路由, 可指定多个规则
.forRoutes({ path: '*', method: RequestMethod.ALL }); // 匹配所有路由下的所有请求方法, 参数亦可是多个Controller
}
}
如果你希望使用拦截器日志监控,我们建议你将日志拦截器设为全局拦截器,在main.ts中增加:
app.useGlobalInterceptors(new LoggerInterceptor(app.get(MyLogger)));
如果你仅仅希望在控制台输出请求的简单信息,那么仅需直接在main.ts使用函数式日志中间件:
app.use(logger);
另外,我们也可以控制Nest默认日志在控制台输出哪些级别的日志,在main.ts中:
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'debug', 'log', 'verbose'], // 输出的日志级别
});
-
实体
实体推荐在相应模块目录下, 创建entities目录, 并创建实体, 命名方式推荐为: xxx.entity.ts
实体需加
@entity
注解@Entity({ name: 'questions', // 存储进数据库时的表名 orderBy: { // find等查询方法返回数据时的默认排序方式 id: 'ASC', // 依据id正序排序 }, }) export class Question {}
建议为实体创建构造函数
export class User { // 构造函数, 用户构建实体 constructor(partial: Partial<User>) { Object.assign(this, partial); } }
实体成员在运行时将被创建为数据库表的列, 相关注解如下:
@PrimaryGeneratedColumn()
自增主键@Column
普通列@CreateDateColumn
特殊列,自动为实体插入日期。无需设置此列,该值将自动设置@UpdateDateColumn
特殊列,在每次调用实体管理器或存储库的save时,自动更新实体日期。无需设置此列,该值将自动设置。@VersionColumn
特殊列,在每次调用实体管理器或存储库的save时自动增长实体版本(增量编号)。无需设置此列,该值将自动设置。
每个Column都有额外的options可配置, 常用如下:
@Column({ type: 'varchar', // 列类型 name: 'phone', // 数据库表中的列名(默认为属性名) length: 16, // 列长度 nullable: false, // 列可否为空(默认false) select: true, // 查询时是否隐藏此列,设为false则列数据不会显示标准查询(默认true) primary: false, // 标记为主列(作用等同于@PrimaryColumn) unique: false, // 将列标记为唯一列(创建唯一约束) comment: '手机号', // 数据库列备注 })
实体间的关系也是依据注解实现, 见关系 | TypeORM 中文文档 | TypeORM 中文网 (bootcss.com)
-
查询所有用户
this.usersRepository.find();
-
查询指定id的用户
this.usersRepository.findOneBy({ id, });
-
查询指定学生某张试卷的成绩
this.gradeRecordRepository.findOneBy({ examPaper: { id: submitScore.examPaperId }, user: { id: user.id }, });
-
查询指定学生的所有错题(这里错题记录和题目是两个实体, 他们之间关系为多对一)
this.wrongQuestionRecordRepository.find({ where: { user: { id: user.id } }, relations: ['question'], // 开启查询级联, 同时将对应题目信息也返回 });
-
查询指定id的题目信息(题目信息需要包含一个关键字实体, 他们之间关系为一对多)
this.questionsRepository.findOne({ where: { id, actionRecords: { mark: Mark.NORMAL, }, }, relations: [ 'words', ], });
-
高级查询
this.examPaperRepository .createQueryBuilder('exam_paper') // 创建queryBuilder .leftJoinAndSelect('exam_paper.scorePapers', 'score_paper') // 左连表, 第一个参数为要加载的关系, 第二个字段是为此关系的表分配的别名 .leftJoinAndSelect('exam_paper.creator', 'creator') // 可以左连任意多个表 .leftJoinAndSelect('exam_paper.modifier', 'modifier') .leftJoinAndSelect('score_paper.question', 'question') .leftJoinAndSelect('question.selectQuestion', 'select_question') .leftJoinAndSelect('question.judgmentQuestion', 'judgement_question') .leftJoinAndSelect('question.narrateQuestion', 'narrate_question') .where('exam_paper.id = :id', { id }) // 查询条件 .andWhere('exam_paper.actionRecords.mark = :mark', { mark: Mark.NORMAL }) // and 查询条件, 另一种是 or .select([ // 自定义返回字段 'exam_paper.id', 'exam_paper.name', 'exam_paper.description', 'exam_paper.type', 'exam_paper.status', 'exam_paper.actionRecords.updateTime', 'score_paper.id', 'score_paper.score', 'question.id', 'select_question.id', 'judgement_question.id', 'narrate_question.id', 'creator.id', 'creator.username', 'modifier.id', 'modifier.username', ]) .getOne();
-
在创建实体Entity时, 如果为password字段设置了
select:false
@Column({select:false}) password: string;
那么一般的查询则不会获取到password字段, 需要使用addSelect:
this.usersRepository .createQueryBuilder('user') .addSelect('user.password') // 增加查询字段(密码) .where('user.phone = :phone', { phone }) .getOne();
-
更新指定id的用户新
this.usersRepository.update(id, user);
-
创建一个新用户
this.usersRepository.save(user);
-
移除指定id的用户
this.usersRepository.delete(id);
-
执行一个事务
await this.connection.transaction(async (manager) => { // 想要在事务运行的所有操作都必须在这个回调函数里 /** * 我们不建议在循环中使用await,而是使用Promise.all(),让异步并发执行以提高效率 */ const createTasks = []; for (const user of users) { createTasks.push(manager.save(new User(user))); } await Promise.all(createTasks); });
Nest is MIT licensed.