警告万字长文,如果不喜欢我这里录制了一个视频介绍
仓库地址: nest_rbac_service
本文目标
本文将通过 介绍一个简单的RBAC 系统,为大家讲解有一下 ,一个 Nest的 企业级的工程 到底长什么样, 究竟怎么样的Nest工程才能上企业的产线(注意每个企业的要求不一样,我的标准不一定是你的标准,不过我想借由此 你应该有所启发) 本人不喜欢好为人师,只是分享 自己在工作的实践 , 希望能够帮助到大家。
另外说一下,一个人的能量总是有限,前行的道路总是孤独,也难免走不少的弯路子,但是一群人的能力就很大,互相帮助才能走的更远, 寻找一些志同道合的朋友 一起前进, 欢迎加入 GithubTeam
在这里我得说明一下, 整体使用下来的感受 Nest还是有很多的 不完善的地方 作为一个这样的框架 引入了很多的复杂的概念 和能力。 我想说 我仅仅是想简单的使用 目的就是快速和高效 但是你这个Nest就.... 未免违背了我的初心
基础通用功能
在这个小节 我们将会完成下面的基础功能
- zk_config
- erro 和res 统一处理
- mysql
- unify_login
- validation & Serilaze
- swager
- winston_log
- security
zk_config
需求
我们需要的功能比较的简单
- 可以从.env 或者YAML 中读取配置,这样在部署的时候就很方便
- .env 中的不要留过多的配置
- 配置放到Zk中去
- 要求同时支持 本地JSON 和 远程 Zk 两种形式
设计
- 对于 需求 1.2实现起来比较的简单 ,在官方的文档有说得非常的详细了官方文档
- 对于 3 需求 可以来看我的另一文章[一期 -结束] 这可能是你看过最全的 「Nest」
- 需求4 的实现 我们可以借助1 需求中的 env 来制定 prd/dev dev就读取 本地的json文件,prd就读取remote zk的配置
重点和难点
- 主要的难点 mysql 设置多db的时候 ,不好直接从remote/local 读取,必选从env 中读取key 然后再集合config 中的mysql 配置 进行连接
- 一般来说 zk的config 都是程序启动后马上读取所有的值 然后cache下来,而且要监听zk的变化,然后重新更新这个cache(但是我没有实现,就交给各位朋友给仓库提PR吧)
实现
- .env / conf/***.config.ts
// ~ .env
# DEV Staging PRD
ENV = DEV
PROT = 3000
ZK_HOST_PATH = RBAC_Config
ZK_HOST = localhost:2181
MYSQL_DBS = rbac_db, rbac_db_1
// ~ .configuration
import db from './db.config';
import RESTAPI from './RESTAPI.config';
export const InitConfig = () => ({
env: process.env.ENV,
port: process.env.APP_PROT,
zkHost: process.env.ZK_HOST,
mysqlDBS: process.env.MYSQL_DBS.split(',').map((i) => i.trim()),
});
export const config: RBAC_Service = {
RBAC_Service: {
database: db,
RESTAPI: RESTAPI,
RedisConfig: {
host: '192.168.101.2',
// host: 'localhost',
port: 6379,
db: 0,
family: 4,
password: '',
},
AuthInfo: {
secret: "1234567890-=qwertyuiop[]asdfghjkl;'zxcvbnm,./",
expiresIn: '8h',
},
},
};
// ~ .db.config
const MysqlDBConfig: ConfigType['database'] = {
mysql: [
{
name: 'rbac_db',
host: '192.168.101.2',
// host: 'localhost',
port: 3306,
username: 'root',
password: '123456',
database: 'rbac_db',
synchronize: true,
},
{
name: 'rbac_db_1',
host: '192.168.101.2',
// host: 'localhost',
port: 3306,
username: 'root',
password: '123456',
database: 'rbac_db_1',
synchronize: true,
},
],
};
export default MysqlDBConfig;
// ~ RESTAPI.config
const RESTAPI: RESTAPI = {
a: {
b: {
c: {
d: {
name: 'this is a test',
},
},
},
},
};
export default RESTAPI;
- 封装 zkService (loacl 和remote 的实现也在这里了)
export enum EnumZkConfigPath {
'nest' = '/nest',
}
export enum EnumInterInitConfigKey {
env = 'env',
port = 'port',
zkHost = 'zkHost',
}
export const ZOOKEEPER_CLIENT = Symbol('ZOOKEEPER_CLIENT');
export const ZOOKEEPER_MODULE_OPTIONS = Symbol('ZOOKEEPER_MODULE_OPTIONS');
import { InitConfig } from '../../conf/configuration';
// ~ CoreModule
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [InitConfig],
}),
ZKModule.forRootAsync({
useFactory: async (configService: ConfigService) => {
return {
env: configService.get<any>('env'),
zkHost: configService.get<any>('zkHost'),
localConfig: config,
};
},
inject: [ConfigService],
}),
],
providers: [],
})
export class CoreModule {}
// ~ ZKModule
import { ModuleMetadata, Provider, Type } from '@nestjs/common';
export type ZkConfigModuleOptions = {
env: 'DEV' | 'Staging' | 'PRD';
zkHost: string;
localConfig: any;
};
interface ZKModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useExisting?: Type<any>;
useClass?: Type<any>;
useFactory?: (
...args: any[]
) => Promise<ZkConfigModuleOptions> | ZkConfigModuleOptions;
inject?: any[];
extraProviders?: Provider[];
}
@Module({})
export class ZKModule {
static forRootAsync(options: ZKModuleAsyncOptions): DynamicModule {
return {
module: ZKCoreModule,
imports: [ZKCoreModule.forRootAsync(options)],
};
}
}
// ~ zk-core.module
import * as zookeeper from 'node-zookeeper-client';
import { get } from 'lodash';
@Global()
@Module({})
export class ZKCoreModule implements OnModuleDestroy {
private static zookeeperClient;
private static createAsyncOptionsProvider(
options: ZKModuleAsyncOptions,
): Provider {
if (options.useFactory) {
return {
provide: ZOOKEEPER_MODULE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
};
}
}
static forRootAsync(options: ZKModuleAsyncOptions): DynamicModule {
const asyncProviders = ZKCoreModule.createAsyncOptionsProvider(options);
return {
module: ZKCoreModule,
imports: options.imports,
providers: [asyncProviders, this.createClient(), ZKService],
exports: [ZKService],
};
}
onModuleDestroy() {
ZKCoreModule.zookeeperClient.close();
}
private static createClient = (): Provider => ({
provide: ZOOKEEPER_CLIENT,
useFactory: async (moduleOptions: any) => {
const config = moduleOptions;
const getClient = async (options: any) => {
const { url, ...opt } = options;
// remote 时的配置
ZKCoreModule.zookeeperClient = zookeeper.createClient(url, opt);
ZKCoreModule.zookeeperClient.once('connected', () => {
Logger.verbose('zk connected success...');
});
ZKCoreModule.zookeeperClient.on('state', (state: string) => {
const sessionId = ZKCoreModule.zookeeperClient
.getSessionId()
.toString('hex');
});
ZKCoreModule.zookeeperClient.connect();
return ZKCoreModule.zookeeperClient;
};
// 如果是 dev 请使用本地配置文件
if (config.env === 'DEV') {
ZKLocalConfig.localConfig = config.localConfig;
return ZKLocalConfig;
}
// remote 时的配置
ZKCoreModule.zookeeperClient = await getClient({
url: config.zkHost,
});
return ZKCoreModule.zookeeperClient;
},
inject: [ZOOKEEPER_MODULE_OPTIONS],
});
}
class ZKLocalConfig {
static localConfig: any;
static getChildren(
path: any,
cb: (e: any, res?: any, status?: any) => void,
): void {
const keys = (path.split('/') as Array<string>).filter((it) => it);
const LocalConfig = get(this.localConfig, keys, undefined);
cb(null, JSON.stringify(LocalConfig));
}
static getData(
path: any,
cb: (e?: any, res?: any, status?: any) => void,
): void {
const keys = (path.split('/') as Array<string>).filter((it) => it);
const LocalConfig = get(this.localConfig, keys, undefined);
cb(null, JSON.stringify(LocalConfig));
}
}
// ~ zk.service
@Injectable()
export class ZKService {
constructor(
@Inject(ZOOKEEPER_CLIENT)
private readonly client: typeof ZKLocalConfig,
) {}
getChildren = (path) => {
return new Promise((resolve, reject) => {
this.client.getChildren(path, (error, children, stat) => {
if (error) {
reject(error);
} else {
resolve((children || []).sort());
}
});
});
};
getData = (path) => {
return new Promise((resolve, reject) => {
this.client.getData(path, (error, data, stat) => {
if (error) {
reject(error);
} else {
resolve(data ? data.toString() : '');
}
});
});
};
getConfig = async <T>(path): Promise<T> => {
const res = (await this.getData(path)) as any;
return JSON.parse(res);
};
}
- 使用的时候非常的nice(注意你要全局的注入这个ZKModule哦 并且要导出ZkService )
// 注入 ZkService 之后 就可以很简单的 获取本地和 remote的配置
inject: [ZKService],
const DBConfigAll = await zkService.getConfig<
Array<ConfigDBMYSQLType>
>('/RBAC_Service/database/mysql');
- 他在zk 中长这样
图
erro 和res 统一处理
这个很简单 早在这篇文章就说了 这里不赘述Erro 处理 和
mysql
需求
- 能够实现从配置获取信息 进行连接
- 能够 实现一次性的连接全部DB连接注册
设计
从配置获取信息比较的简单,重点和难点是 批量的批次连接 由于 TypeOrmModule.forRootAsync 只支持 一次性注册一个连接,所以我们需要自己封装一个动态 模块,进行批量连接.
实现
- 基础的实现
// core 中直接顶层
MysqlModule.forRootAsync({
dbs: InitConfig().mysqlDBS,
}),
// MysqlModule
@Module({})
export class MysqlModule {
static forRootAsync(options: MysqlModuleAsyncOptions): DynamicModule {
const DBName = options.dbs;
const imports = DBName.map((db) =>
TypeOrmModule.forRootAsync({
name: db,
useFactory: async (zkService: ZKService) => {
const DBConfigAll = await zkService.getConfig<
Array<ConfigDBMYSQLType>
>('/RBAC_Service/database/mysql');
const DBConfig = DBConfigAll.find((item) => item.name === db);
const options: ConnectionOptions = {
type: 'mysql',
name: DBConfig.name,
host: DBConfig.host,
port: DBConfig.port,
username: DBConfig.username,
password: DBConfig.password,
database: DBConfig.database,
entities: [
resolve(
__dirname,
`../../entities/${DBConfig.name}/**/*.entity{.ts,.js}`,
),
], // 扫描本项目中.entity.ts或者.entity.js的文件
synchronize: true,
};
return options;
},
inject: [ZKService],
}),
);
return {
module: MysqlModule,
imports: [...imports],
};
}
}
- 注意 ~ entity 的文件夹放置方式和 工程规范 我们把 每一个DB单独设置为一个文件夹 在里面存在 entity
E:
├─entities
│ ├─rbac_db
│ └─rbac_db_1
unify_login
设计
就是一个非常简单的 JWT login 功能 请直接去CV 我另一篇文章的内容 具体的实现这里就不贴了
validation & Serilaze
在此次工程中 这个是比较 麻烦且蛋疼的地方,有的时候采取 1 方案 2 问题出来了 解决了 2 问题 1 方案 又有问题了.... , 但是我还是找到了一个虽然麻烦,但是还算符合工程规范的的 差不多方案
需求
- 实现 对 "进" 从外传入程序内的数据的 validation
- 实现 对 '出' 数据 进行 解析和 格式化输出 Serilaze
- 实现对 '进 出' 数据的 Swager
设计
我们计划把 进 出 数据的DTO进行 分组管理 Swager 直接分别+ 到同的class 上就好了 @装饰器。而不是把Swager注解 和vlidation Serlize 都加到 了entity 上 。相信我 如果你把这些东西加到 entity 上 后续管理会很难搞
实现
文件夹结构
├─dto
│ ├─request
│ └─response
看一个例子
// reposet/rbac.dto.ts
class PartialIdDTO {
id?: number;
}
class AuthLoginReqDTO extends PartialIdDTO {
@IsNotEmpty({
message: '用户名不能为空',
})
username: string;
@IsEmail({})
email: string;
@IsNotEmpty({
message: '密码不能为空',
})
password: string;
}
// reposet/rbac.dto.ts
class AuthInfoResDTO {
@Expose()
public token: string;
@Expose()
@Type(() => UserInfoResDTO)
userInfo: UserInfoResDTO;
constructor(partial: Partial<AuthInfoResDTO>) {
Object.assign(this, partial);
}
}
// 使用
@Controller({
path: '/auth',
scope: Scope.REQUEST,
})
@SerializeOptions({
enableImplicitConversion: false,
})
@UseInterceptors(ClassSerializerMysqlInterceptor)
export class AuthController {
constructor(
private authService: AuthService,
private authUserService: AuthUserService,
) {}
// 开始验证 注意由于 签名的 的实现原因,你这里必须传递 jwt 实现的东西 要不然会报错
@NotAuth()
@MysqlEntityClass(AuthInfoResDTO)
@Post('/login')
async login(@Body() loginParams: AuthLoginReqDTO) {
return this.authService.loginSingToken(loginParams);
}
//.....
}
重点难点
在上述的代码中 不难发现 有下面的问题
- ClassSerializerMysqlInterceptor 是什么
- SerializeOptions 是什么
- MysqlEntityClass 是什么
接下来我们来分析一下
- ClassSerializerMysqlInterceptor
这是自定义的一个 Interceptor 它和 MysqlEntityClass SerializeOptions 一起起作用,主要的作用是 在数据 出 的时候进行 自定义的序列化 , 详情请看这篇文章 序列化
import { SetMetadata } from '@nestjs/common';
export const MYSQL_ENTITY_CLASS = 'MYSQL_ENTITY_CLASS';
export const MysqlEntityClass = (entity: any) =>
SetMetadata(MYSQL_ENTITY_CLASS, entity);
@Injectable()
export class ClassSerializerMysqlInterceptor extends ClassSerializerInterceptor {
// 扩展一个方法
transform(MysqlEntity, data) {
return Array.isArray(data)
? data.map((obj) => {
return new MysqlEntity(obj);
})
: new MysqlEntity(data);
}
// 重写这个方法
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const MysqlEntity = this.reflector?.getAllAndOverride(MYSQL_ENTITY_CLASS, [
context.getHandler(),
context.getClass(),
]);
const contextOptions = this.getContextOptions(context);
const options = {
...this.defaultOptions,
...contextOptions,
};
return next.handle().pipe(
map((data) => {
if (!MysqlEntity) {
return data;
}
return instanceToPlain(this.transform(MysqlEntity, data), {});
}),
map((res: PlainLiteralObject | Array<PlainLiteralObject>) => {
return this.serialize(res, options);
}),
);
}
}
@SerializeOptions({
enableImplicitConversion:false
})
/*
这个参数 enableImplicitConversion
`@SerializeOptions()` 是一个装饰器,用于指定对象在序列化过程中的选项,其
中 `enableImplicitConversion` 是其中的一个选项。它可以用来控制对象在序列化时是否进行隐式类型转换。
当 `enableImplicitConversion` 为 `true` 时,对象在序列化时会自动进行隐式类型转换。
例如,一个字符串类型的属性值可能会在序列化时被转换为数字类型。
这种转换可能会导致一些意外的行为和错误,因此在一些情况下,需要禁用隐式类型转换,
以确保对象在序列化时保持原本的类型。
*/
但是我不得不说一个问题,class-transformer class-vlidation 库 并不完善bug和不足挺多的
// 1. T 泛型目前还不支持
class PagenationWrapVO<T> {
@ApiProperty()
pageInfo: PagenationVO;
// @Type( () => T) 泛型暂时无法使用
list: T[];
}
// 2. @Type 才能完成嵌套
@Type(() => RoleInfoResDTO)
roles: RoleInfoResDTO[];
// 或者这样
@Type(() => RoleInfoResDTO)
roles: RoleInfoResDTO;
// 如果是自定义实现 你需要重写constructor 比如
class ActionButtonResDTO implements ActionButton {
@Expose()
id: number;
@Expose()
name: string;
@Expose()
code: string;
@Expose()
type: number;
@Expose()
description: string;
@Expose()
create_time: Date;
@Expose()
update_time: Date;
@Exclude()
permissionABs: PermissionAB[];
@Exclude()
isDeleted: boolean;
constructor(partial: Partial<ActionButtonResDTO>) {
Object.assign(this, partial);
}
}
// 目前的issue 还有1k 多没有处理吧 ....
Swager
这个比较的简单 详情请看文章 OpenAPI
实现
// Swager
const config = new DocumentBuilder()
.setTitle('RBAC Service Nestjs API ')
.setDescription('This is RBAS Service Nestjs API description')
.setVersion('1.0')
.addTag('最佳实践')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-doc', app, document);
@Controller({
path: '/acb',
scope: Scope.REQUEST,
})
@ApiExtraModels(PagenationWrapResDTO)
@ApiTags('acb')
@SerializeOptions({
enableImplicitConversion: false,
})
@ApiBearerAuth()
// @Roles(Role.Admin)
@Roles(Role.Guset)
@UseGuards(RoleGuard)
@UseInterceptors(ClassSerializerMysqlInterceptor)
export default class ACBController {
constructor(
private readonly acbService: ACBService,
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: LoggerService,
) {} // please do CRUD here
throwError(message: string, httpCode: HttpStatus) {
throw new HttpException(
{
status: httpCode,
error: message,
},
httpCode,
);
}
@Post('/add')
@MysqlEntityClass(ActionButtonResDTO)
@ApiResponse({
type: ActionButtonResDTO,
})
addACB(@Body() createACBDTO: ACBInfoReqDTO) {
try {
return this.acbService.addACB(createACBDTO);
} catch (error) {
this.logger.error(JSON.stringify(error));
this.throwError('创建失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
// 简单的说说 在 进 参数的时候
class ACBInfoReqDTO extends PartialIdDTO {
@ApiProperty({
required: false,
})
id?: number;
@ApiProperty()
@IsNotEmpty()
name: string;
@ApiProperty()
@IsNotEmpty()
code: string;
@ApiProperty()
@IsNotEmpty()
type: number;
@ApiProperty()
description: string;
}
// 在 出 参数的时候
class UserInfoResDTO implements UserInfo {
@Exclude()
password: string;
@Exclude()
userRoles: any[];
// 注意如果你是type 嵌套什么 需要 加上这个type: RoleInfoResDTO
@ApiProperty({
type: [RoleInfoResDTO],
})
@Expose()
@Type(() => RoleInfoResDTO)
roles: RoleInfoResDTO[];
// ......
}
重难点说明
在这篇 文章中我们提到了对于分页如何处理的方案 OpenAPI ,但是在这个工程中 我们却不能直接拿来就用,因为我们的序列化不允许class 不允许 因为 Serialize 不支持 T 泛型, 啊这 无解了吗?不! 但是我们或许想错了问题,因为我们这个工程中 dto 和swager 是分离的 也就是说 Serialize 和 swager @ApiResponse 可以不是同一个Class
// 泛型 照样用
class PagenationWrapResDTO<T> {
@ApiProperty()
pageInfo: PagenationResDTO;
// @Type( () => T) 泛型暂时无法使用
@ApiProperty()
list: T[];
}
class PermissionListResDTO {
@Expose()
@Type(() => PagenationResDTO)
pageInfo: PagenationResDTO;
@Expose()
@Type(() => PermissionSimpleResDTO)
list: PermissionSimpleResDTO[];
constructor(partial: Partial<PermissionListResDTO>) {
Object.assign(this, partial);
}
}
// 序列化的时候 重新定义一个 ResDTO 就好, 虽然麻烦 但是我想这是最好的解法了
@Get('/all')
@MysqlEntityClass(PermissionListResDTO)
@ApiPaginatedResponse(PermissionSimpleResDTO)
getAllACB(@Query() pagenation: PagenationReqDTO) {
try {
return this.permissionService.getAllPermission(pagenation);
} catch (error) {
this.logger.error(JSON.stringify(error));
this.throwError('查询失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
最后关于 ApiPaginatedResponse 的实现请看 另一篇文章 OpenAPI
winston_log
需求
这个就比较简单了 就是实现 log 就好了
实现
@Global()
@Module({
imports: [
WinstonModule.forRoot({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonModuleUtilities.format.nestLike('RBAC_Service', {
colors: true,
prettyPrint: true,
}),
),
}),
new DailyRotateFile({
filename: resolve(__dirname, '../../logs', 'application-%DATE%.log'),
dirname: 'logs',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
}),
],
}),
],
providers: [],
})
export class CoreModule {}
// 把 默认的log记录器换成我们的
// Log
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
// 使用的时候 直接注入就好了
constructor(
private readonly acbService: ACBService,
@Inject(WINSTON_MODULE_NEST_PROVIDER) // 从 nest-winston 来
private readonly logger: LoggerService, // LoggerService 从 @nestjs/common
) {} // please do CRUD here
security
由于是 Service 为目标 所以我们只加了一个 限流安全措施,其它的安全问题 ,请交给 网关层处理
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
}),
);
整体骨架完成
到此为止我们的骨架就完成了,直接cv从 master 出去就能具备下面的能力
能力介绍
- config 的local 和remote zk 的能力
- erro 和res 统一处理 的能力
- mysql DB 多库连接的能力
- unify_login 统一登陆能力(基础jwt 和 多端统一登录能力)
- validation & Serilaze DTO 的能力
- swager 完全且完整完善的 swager能力
- winston_log 能力
- 加了一个简单的限流 security 的能力
接下来各位朋友 只需要基于上面的基础能力就能够完成各种各样的功能了. 如果有同学想共享PR欢迎 提哈
开发规范介绍
- 多数据库请 放到 与之DB配置同名的entity 目录下
- 在代码中使用 db 的时候请 使用 InjectEntityManager 这个很简单 不需要一个个的麻烦的去加 InjectRepository
- 编码时 请区分对待ReqDto 和 ResDto 已经swager 这三个要分开看
- 编码流程 请按照下面方式进行 -> 分析req 和res 到底需要什么样的结构 -> 编写对应的dto -> 具体实现
- 如果是要返回给出去的 错误 请统一放到 controller 层
- 仅一个参数的时候swwager DTO 没法把它 变成做可选的,这个时候 约定俗称 <=0 就是不传的含义
结尾
以上 的所有内容 都是一个 非常的common的内容 ,下一篇我们将会来看看具体的业务设计和分析 (RBAC 管理系统,管理User Role Permission Mnue Buttons)