nestjs 微服务架构鉴权实践

1,252 阅读9分钟

前言

文章主要涉及到几个关键词 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

以上实现了一个基本角色认证逻辑

基于角色的认证设计

根据上面的设计,我们把某个路由的权限写在代码里了,这样的话需要需要调整系统权限的时候就涉及到更改代码重新部署。当然我们可以数据库配置的方式来指定。一个用户可以有多个角色,一个角色可以有多个权限(访问路径),示例图如下

用户权限.png 用户当前访问接口的地址可以通过元数据获取到,我们拿它和数据库里权限数据对比就能判断出是否能访问次接口。

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 概念的文章,欢迎大家观摩指正

nestjs 学习笔记-概念篇