前言
文章主要涉及到几个关键词 Nestjs
和微服务
,在这样的技术框架下实现针对请求的鉴权
。
我们聊到微服务
也会联想到另一种架构模式单体服务
。单体服务一般来说在项目初期,项目开发效率较高,开发成本较低,但是在项目逐渐扩大的过程中会遇到 代码仓库越来越庞大,模块之间牵扯程度加深,扩展功能和引入变得困难,难以维护的情况。
为了解决这种状况,微服务
这种架构思想,主旨是将单体服务
中按照功能模块拆分成不同的服务,每个服务单独开发和部署,服务之间相互交互来获取对方的数据。这样主要的优势是可扩展和灵活性,单个服务复杂性低,便于开发和维护,服务之间相对松耦合。当然这样也有一定的劣势,就是从整个项目看来,开发技术要求和成本较高,例如 分布式事务处理。
一般来说微服务适合体量较大项目,适合成熟的技术团队。
nestjs 微服务架构
在nestjs 框架张使用微服务,需要安装 @nestjs/microservices ,默认是使用TCP 传输协议进行个服务之间的通讯,同时支持 REDIS、NATS、MQTT、RMQ、KAFKA、GRPC 做为可选的通讯机制。
服务器之间的通讯模式有两种:
- Request-response 也就是发送请求,等待回复模式,返回Observable对象。 使用
@MessagePattern
装饰器 - Event-based 也就是只发布事件,不需要等待回复。使用
@EventPattern
装饰器 - 以上两种装饰器必须用在
controller
里,在provider
里会被忽略。
项目初始化
当前项目主要有3 个服务,分别是
- 网关: 对对外界开放接口,通过内部对接
用户
和鉴权
对接口进行鉴权 - 用户:用户的增删改查,权限赋予
- 鉴权:基于角色的权限认证
执行以下命令,把项目建立起来
nest new nest-micro-app
nest g app user-center
nest g app permission
这三部就是把三个微服务代码目录建好了,这里采用的是monorepo 方式,每个微服务都放在一个git 项目里。
目录结构是这样样子的
.
├── README.md
├── apps
│ ├── nest-micro-app
│ ├── permission
│ └── user-center
├── nest-cli.json
├── package.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
这里需要注意下nest-cli.json 这里存放的是nest 项目的一些配置,至此项目初始化完成,开始进入开发。
项目开始
公共文件
nest g lib common #微服务配置
执行命令后在 在最外层 tsconfig.json 会增加几行,这样可以使用 import { ConfigService } from 'lib/config' 直接调用
"paths": {
"lib/common": [
"libs/common/src"
],
"lib/common/*": [
"libs/common/src/*"
]
}
新建文件夹 config 作为整个项目的配置文件,目录结构如下
├── config
│ ├── dev.yaml
│ ├── prod.yaml
│ └── test.yaml
配置文件的格式是这给样子的
GATE_WAY:
PORT: 8000
USER_SERVICE:
PORT: 8001
HOST: 127.0.0.1
transport: "TCP"
PERMISSION_SERVICE:
PORT: 8002
HOST: 127.0.0.1
transport: "TCP"
修改 /libs/common/config/src/config.service.ts
import { Transport } from '@nestjs/microservices';
const path = require('path');
const fs = require('fs');
import { parse } from 'yaml'
export class ConfigService {
private readonly envConfig: { [key: string]: any } = null;
private readonly environment: String = "";
constructor() {
this.environment = process.env.RUNNING_ENV || "dev"
const configData = this.getConfigFile()
this.envConfig = {};
this.envConfig.port = configData.GATE_WAY.PORT
this.envConfig.userService = {
options: {
port: configData.USER_SERVICE.PORT,
host: configData.USER_SERVICE.HOST,
},
transport: Transport[configData.USER_SERVICE.transport],
};
}
getConfigFile() {
const environment = this.environment
const yamlPath = path.join(process.cwd(), `./config/${environment}.yaml`)
const file = fs.readFileSync(yamlPath, 'utf8')
const config = parse(file)
return config
}
get(key: string): any {
return this.envConfig[key];
}
}
最小可运行微服务demo
修改 apps/user-center/src/user-center.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UserCenterService } from './user-center.service';
import { MessagePattern } from '@nestjs/microservices';
@Controller()
export class UserCenterController {
constructor(private readonly userCenterService: UserCenterService) {}
@MessagePattern('getHello')
getHello(name:String): string {
return this.userCenterService.getHello(name);
}
}
修改 apps/user-center/src/main.ts
import { NestFactory } from '@nestjs/core';
import { UserCenterModule } from './user-center.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { ConfigService } from 'lib/config';
async function bootstrap() {
const configService = new ConfigService();
const userServiceOptions = configService.get("userService")
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
UserCenterModule,
userServiceOptions
);
await app.listen();
}
bootstrap();
修改 apps/nest-micro-app/src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigService } from 'lib/config';
import { ClientProxyFactory } from '@nestjs/microservices';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService,
ConfigService,
{
provide: 'USER_SERVICE',
useFactory: (configService: ConfigService) => {
const userServiceOptions = configService.get("userService")
return ClientProxyFactory.create(userServiceOptions);
},
inject: [ConfigService],
}
],
})
export class AppModule { }
修改 apps/nest-micro-app/src/app.controller.ts
import { AppService } from './app.service';
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
@Controller()
export class AppController {
constructor(@Inject('USER_SERVICE') private readonly client: ClientProxy) { }
@Get('hello')
getHello(@Query() query: any): Promise<string> {
return firstValueFrom(this.client.send<string>('getHello', query.name));
}
}
修改 apps/nest-micro-app/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from 'lib/config';
async function bootstrap() {
const configService = new ConfigService();
const app = await NestFactory.create(AppModule);
await app.listen(configService.get("port"));
}
bootstrap();
现在把两个服务起来就可以测试了
curl localhost:8000/hello?name=Nest
Hello World! Nest
从调用接口到返回数据经过了以下几个过程
- 首先进入 网关项目 nest-micro-app controller 的hello 接口
@Get('hello')
标识 - 接口内通过已注入的
USER_SERVICE
发送消息到 user-center controller getHello 接口@MessagePattern('getHello')
标识 - 最后数据 返回到网关接口 并且返回到前台
增加数据库配置
配置文件增加配置项
MONGODB_CONFIG:
name: "nest-micro"
type: "mongodb"
url: "mongodb://localhost:27017"
username: ""
password: ""
database: "nest-micro"
logging: false
synchronize: true
增加libs/common/src/database/database.provider.ts
import { DataSource } from 'typeorm';
import { ConfigService } from 'lib/common';
const configService = new ConfigService();
const mongConfig = configService.get("mongoConfig")
import { User } from 'apps/user-center/src/user.mongo.entity';
const MONGODB_DATABASE_CONFIG = {
...mongConfig,
entities: [User],// 当前采用了手动注册实体类
}
const MONGODB_DATA_SOURCE = new DataSource(MONGODB_DATABASE_CONFIG)
// 数据库注入
export const DatabaseProviders = [
{
provide: 'MONGODB_DATA_SOURCE',
useFactory: async () => {
if (!MONGODB_DATA_SOURCE.isInitialized) await MONGODB_DATA_SOURCE.initialize()
return MONGODB_DATA_SOURCE
}
}
];
增加了 apps/user-center/src/user.database-provider.ts
import { User } from './user.mongo.entity';
export const UserProviders = [
{
provide: 'USER_REPOSITORY',
useFactory: (AppDataSource) => AppDataSource.getRepository(User),
inject: ['MONGODB_DATA_SOURCE'], //这里的字符串表示上面一个Provider
},
];
在 apps/user-center/src/user-center.module.ts
里引入provider
import { Module, forwardRef } from '@nestjs/common';
import { UserCenterController } from './user-center.controller';
import { UserCenterService } from './user-center.service';
import { DatabaseModule } from 'lib/common/database/database.module';
import { UserProviders } from './user.database-provider';
@Module({
imports: [forwardRef(() => DatabaseModule),],
controllers: [UserCenterController],
providers: [UserCenterService, ...UserProviders],
})
export class UserCenterModule { }
这里有两个知识点
- libs 里面的 database.provider 使用里工厂函数返回值,为的是把数据源初始化后返回
- user-center 里的 database-provider 依赖的第一条的provider。拿到它的返回值之后,继续 getRepository。
大家注意到两个database provider 抛出的都是数组,意思就是我们可以有多个数据源。一个项目里可能会同时存在操作mongo 或sql的情况。在lib 里同时提供 mongo 和sql 的数据源,后面的具体模块按需取用,比如我们的user 用的就是mongo,可能其他模块用的sql,我们在inject 的时候出入 sql 对应的provider 就可以了。
具体使用的地方在 apps/user-center/src/user-center.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { User } from './user.mongo.entity';
import { Repository } from 'typeorm';
@Injectable()
export class UserCenterService {
constructor(@Inject('USER_REPOSITORY')
private userRepository: Repository<User>) {
}
getHello(name: string) {
this.userRepository.find()
return name
}
}
大家看到具体应用的地方,不管你之前配置的是mongo 还是sql 这里没什么差别,可以说是抹平了数据库的差异。
守卫和自定义装饰器
项目架构搭完了我们进入权限架构的设计当中,主要jwt 做登录验证和 自定义角色守卫做权限认证,主要思路如下
在 apps/nest-micro-app/src/app.module.ts
导入两个provider,把它两作为全局守卫使用。
import { JwtAuthGuard } from "./auth/guards/jwt.auth.guard"
import { RoleGuard } from "./auth/guards/role.auth.guard"
import { APP_GUARD } from '@nestjs/core';
...... // 已有内容省略
{
provide: APP_GUARD,
useClass: JwtAuthGuard
},
{
provide: APP_GUARD,
useClass: RoleGuard
}
下面装饰器登场了
新建 apps/nest-micro-app/src/users.controller.ts
import {Controller,Post,Body,Req,Inject} from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
import { ClientProxy } from '@nestjs/microservices';
import { Public } from './decorator/no-auth.decorator';
import { Roles } from './decorator/role.decorator';
@Controller('users')
export class UsersController {
constructor(
@Inject('USER_SERVICE') private readonly userServiceClient: ClientProxy,
) { }
@Public()
@Post("/register")
public async createUser(@Body() userRequest): Promise<any> {
}
@Public()
@Post("/login")
public async loginUser(@Body() LoginRequest): Promise<any> {
}
@Post("/logout")
public async loginOut(@Req() request): Promise<any> {
}
@Roles("ADMIN")
@Post("/blockUser")
public async blockUser(@Body() blockBody): Promise<any> {
}
}
@Public
是一个自定义装饰器,意思是当前接口不需要做jwt 验证,比如上面的登录接口,还有就拿掘金做例子,你可以不登陆就看文章,但是你要点赞收藏就必须要登录了,那么调用查看文章的接口就不需要做jwt验证。
整个跳过验证的功能是需要和JwtAuthGuard
配合实现
apps/nest-micro-app/src/decorator/no-auth.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
apps/nest-micro-app/src/auth/guards/jwt.auth.guard.ts
import { Injectable, ExecutionContext } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from "../../decorator/no-auth.decorator";
import { isInstance } from "class-validator";
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
if (isInstance(info, Error)) {
throw info
}
throw err
}
return user;
}
}
我们使用元数据在路由上设置了 一个bool值字段,在guard里获取它,发现为true 的话就跳过验证。
@Roles("ADMIN")
这个装饰器是用来表示某个接口需要某个特定角色才可以访问,举一种最简单的例子,一个系统有两种角色,普通人和 管理员。大多数接口普通人就可以调用,但是少数接口,比如锁定人员需要管理员才可以。
和上面相似的情况,在这个装饰器上我们使用元数据设置相应的值,并且配合RoleGuard
实现用户角色的认证
apps/nest-micro-app/src/decorator/role.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
apps/nest-micro-app/src/auth/guards/role.auth.guard.ts
import { Injectable, ExecutionContext, CanActivate } from "@nestjs/common";
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from "../../decorator/no-auth.decorator";
@Injectable()
export class RoleGuard implements CanActivate {
constructor(
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user
let userRoles = user.roles
const hasRole = roles.some(function (val) {
return userRoles.indexOf(val) > -1
})
return hasRole
}
}
这里就是取在controller 上的元数据,和当前用户的角色进行对比,符合条件的就返回true 不然就是false
以上实现了一个基本角色认证逻辑
基于角色的认证设计
根据上面的设计,我们把某个路由的权限写在代码里了,这样的话需要需要调整系统权限的时候就涉及到更改代码重新部署。当然我们可以数据库配置的方式来指定。一个用户可以有多个角色,一个角色可以有多个权限(访问路径),示例图如下
用户当前访问接口的地址可以通过元数据获取到,我们拿它和数据库里权限数据对比就能判断出是否能访问次接口。
import { Injectable, ExecutionContext, CanActivate } from "@nestjs/common";
import { Reflector } from '@nestjs/core';
@Injectable()
export class RoleGuard implements CanActivate {
constructor(
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext) {
// 省略其他代码
const cMethod = this.reflector.get("method", context.getHandler());// 是GET,POST 等http method
const cPath = this.reflector.get("path", context.getHandler());// 接口的具体路径
const pri_path = cMetho + ":" + cPath // 这样我们就拿到了当前接口地址
}
}
使用了这种方式后,就不需要在 controller 里添加 @Roles()
装饰器了
后语
本篇内容主要是在实现功能时候的大体思路, 并未把代码都贴出来。permission 服务主要是 角色和权限数据操作,限于篇幅有限,没有展开说 。本项目的代码还在完善之中,当中如有发现有意思的地方也会拿出来和大家一起分享。 我之前也写过一篇nesjs 概念的文章,欢迎大家观摩指正