Skip to content

警告万字长文,如果不喜欢我这里录制了一个视频介绍

仓库地址: nest_rbac_service

本文目标

本文将通过 介绍一个简单的RBAC 系统,为大家讲解有一下 ,一个 Nest的 企业级的工程 到底长什么样, 究竟怎么样的Nest工程才能上企业的产线(注意每个企业的要求不一样,我的标准不一定是你的标准,不过我想借由此 你应该有所启发) 本人不喜欢好为人师,只是分享 自己在工作的实践 , 希望能够帮助到大家。

另外说一下,一个人的能量总是有限,前行的道路总是孤独,也难免走不少的弯路子,但是一群人的能力就很大,互相帮助才能走的更远, 寻找一些志同道合的朋友 一起前进, 欢迎加入 GithubTeam

在这里我得说明一下, 整体使用下来的感受 Nest还是有很多的 不完善的地方 作为一个这样的框架 引入了很多的复杂的概念 和能力。 我想说 我仅仅是想简单的使用 目的就是快速和高效 但是你这个Nest就.... 未免违背了我的初心

基础通用功能

在这个小节 我们将会完成下面的基础功能

  • zk_config
  • erro 和res 统一处理
  • mysql
  • unify_login
  • validation & Serilaze
  • swager
  • winston_log
  • security

zk_config

需求

我们需要的功能比较的简单

  1. 可以从.env 或者YAML 中读取配置,这样在部署的时候就很方便
  2. .env 中的不要留过多的配置
  3. 配置放到Zk中去
  4. 要求同时支持 本地JSON 和 远程 Zk 两种形式

设计

  1. 对于 需求 1.2实现起来比较的简单 ,在官方的文档有说得非常的详细了官方文档
  2. 对于 3 需求 可以来看我的另一文章[一期 -结束] 这可能是你看过最全的 「Nest」
  3. 需求4 的实现 我们可以借助1 需求中的 env 来制定 prd/dev dev就读取 本地的json文件,prd就读取remote zk的配置

重点和难点

  1. 主要的难点 mysql 设置多db的时候 ,不好直接从remote/local 读取,必选从env 中读取key 然后再集合config 中的mysql 配置 进行连接
  2. 一般来说 zk的config 都是程序启动后马上读取所有的值 然后cache下来,而且要监听zk的变化,然后重新更新这个cache(但是我没有实现,就交给各位朋友给仓库提PR吧)

实现

  • .env / conf/***.config.ts
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 的实现也在这里了)
ts

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 )
ts
// 注入 ZkService 之后 就可以很简单的 获取本地和 remote的配置
 inject: [ZKService],
 const DBConfigAll = await zkService.getConfig<
            Array<ConfigDBMYSQLType>
          >('/RBAC_Service/database/mysql');
  • 他在zk 中长这样

erro 和res 统一处理

这个很简单 早在这篇文章就说了 这里不赘述Erro 处理 和

mysql

需求

  1. 能够实现从配置获取信息 进行连接
  2. 能够 实现一次性的连接全部DB连接注册

设计

从配置获取信息比较的简单,重点和难点是 批量的批次连接 由于 TypeOrmModule.forRootAsync 只支持 一次性注册一个连接,所以我们需要自己封装一个动态 模块,进行批量连接.

实现

  • 基础的实现
ts
// 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
shell
E:
├─entities
  ├─rbac_db
  └─rbac_db_1

unify_login

设计

就是一个非常简单的 JWT login 功能 请直接去CV 我另一篇文章的内容 具体的实现这里就不贴了

validation & Serilaze

在此次工程中 这个是比较 麻烦且蛋疼的地方,有的时候采取 1 方案 2 问题出来了 解决了 2 问题 1 方案 又有问题了.... , 但是我还是找到了一个虽然麻烦,但是还算符合工程规范的的 差不多方案

需求

  1. 实现 对 "进" 从外传入程序内的数据的 validation
  2. 实现 对 '出' 数据 进行 解析和 格式化输出 Serilaze
  3. 实现对 '进 出' 数据的 Swager

设计

我们计划把 进 出 数据的DTO进行 分组管理 Swager 直接分别+ 到同的class 上就好了 @装饰器。而不是把Swager注解 和vlidation Serlize 都加到 了entity 上 。相信我 如果你把这些东西加到 entity 上 后续管理会很难搞

实现

文件夹结构

├─dto
│  ├─request
│  └─response

看一个例子

ts
// 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);
  }
  //.....
  }

重点难点

在上述的代码中 不难发现 有下面的问题

  1. ClassSerializerMysqlInterceptor 是什么
  2. SerializeOptions 是什么
  3. MysqlEntityClass 是什么

接下来我们来分析一下

  • ClassSerializerMysqlInterceptor

这是自定义的一个 Interceptor 它和 MysqlEntityClass SerializeOptions 一起起作用,主要的作用是 在数据 的时候进行 自定义的序列化 , 详情请看这篇文章 序列化

ts
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和不足挺多的

ts
// 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

实现

ts
  // 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

ts
// 泛型 照样用
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 就好了

实现

ts
@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 为目标 所以我们只加了一个 限流安全措施,其它的安全问题 ,请交给 网关层处理

ts
  app.use(
    rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100, // limit each IP to 100 requests per windowMs
    }),
  );

整体骨架完成

到此为止我们的骨架就完成了,直接cv从 master 出去就能具备下面的能力

能力介绍

  1. config 的local 和remote zk 的能力
  2. erro 和res 统一处理 的能力
  3. mysql DB 多库连接的能力
  4. unify_login 统一登陆能力(基础jwt 和 多端统一登录能力)
  5. validation & Serilaze DTO 的能力
  6. swager 完全且完整完善的 swager能力
  7. winston_log 能力
  8. 加了一个简单的限流 security 的能力

接下来各位朋友 只需要基于上面的基础能力就能够完成各种各样的功能了. 如果有同学想共享PR欢迎 提哈

开发规范介绍

  1. 多数据库请 放到 与之DB配置同名的entity 目录下
  2. 在代码中使用 db 的时候请 使用 InjectEntityManager 这个很简单 不需要一个个的麻烦的去加 InjectRepository
  3. 编码时 请区分对待ReqDto 和 ResDto 已经swager 这三个要分开看
  4. 编码流程 请按照下面方式进行 -> 分析req 和res 到底需要什么样的结构 -> 编写对应的dto -> 具体实现
  5. 如果是要返回给出去的 错误 请统一放到 controller 层
  6. 仅一个参数的时候swwager DTO 没法把它 变成做可选的,这个时候 约定俗称 <=0 就是不传的含义

结尾

以上 的所有内容 都是一个 非常的common的内容 ,下一篇我们将会来看看具体的业务设计和分析 (RBAC 管理系统,管理User Role Permission Mnue Buttons)