Nest.js、gRPC 和 Emp.js 构建高性能的微前端和微服务架构-Client端

374 阅读17分钟

image.png

系列文章传送门: 系列文章传送门:

Client 端是在 Nest.js 中使用模板引擎渲染 EMP 并构建具有服务器端渲染(SSR)的应用程序。具体构建可以看这篇文章Nest + Emp2 构建BFF层

image.png

EMP微前端 image.png

Client 端架构中,涵盖了以下技术点:

  1. EMPEMP 是一个高性能的微前端构建工具,可帮助将前端应用程序拆分为独立模块,实现独立开发、测试和部署,最终动态组合模块以构建完整前端应用。
  2. gRPC:gRPC是一个高性能的开源远程过程调用(RPC)框架,用于构建分布式应用。在Client端,gRPC用于远程调用服务,在Node.js层提供Gateway支持将数据首次渲染注入到模板中,实现首屏快速渲染。
  3. Redis:Redis是一款开源的内存数据存储系统,也被称为键值存储或缓存数据库。它支持多种数据结构、快速读写、持久化和集群功能,在Client端架构中用作缓存、会话存储、消息队列等。
  4. Session:会话是客户端与服务器之间建立的持久连接,用于跟踪用户状态和操作。会话管理在Client端架构中至关重要,涉及用户认证、权限管理、数据保护等,使用 Redis 存储会话数据。
  5. WebSocket:WebSocket是基于TCP协议的双向通信协议,允许客户端和服务器建立实时通信通道。在Client端架构中,WebSocket会用于实时通知、实时数据更新等场景,提供低延迟和高效的双向通信能力。

Client 端架构中集成了 EMP 微前端构建工具、gRPC 远程过程调用框架、Redis 内存数据存储、会话管理和WebSocket双向通信协议,为构建高性能、实时通信的分布式应用提供了基础支持和功能特性。

SSR 和 CSR 的区别

首先我们了解下 SSR 和 CSR 是两种常见的前端渲染方式,它们在渲染页面时有一些关键的区别:

  1. Server-Side Rendering (SSR):

    • 定义:在服务器端生成完整的 HTML 页面,然后将其发送到客户端。

    • 工作原理:当用户请求页面时,服务器会动态生成页面内容,包括数据获取、模板渲染等,最终将完整的 HTML 页面发送给客户端。

    • 优点

      • 更好的 SEO:搜索引擎能够更容易地抓取页面内容,提升搜索引擎排名。
      • 更快的首次加载时间:因为用户收到的是已经渲染好的 HTML 页面,可以更快地看到内容。
      • 对于低性能设备和网络条件更友好。
    • 缺点

      • 服务器压力大:每个请求都要在服务器端渲染页面,可能导致服务器负载增加。
      • 前后端耦合紧密:前端开发者需要考虑服务器端渲染的逻辑。
  2. Client-Side Rendering (CSR):

    • 定义:在客户端(浏览器)中使用 JavaScript 在运行时生成并渲染页面内容。

    • 工作原理:浏览器下载一个基本的 HTML 页面和相应的 JavaScript 文件,然后在浏览器中执行 JavaScript 代码来动态生成页面内容。

    • 优点

      • 更快的页面切换:一旦初始加载完成,后续的页面切换会更快。
      • 更好的交互体验:可以在页面加载完成后通过 JavaScript 实现更复杂的交互。
    • 缺点

      • SEO 难度大:搜索引擎可能无法很好地理解和索引由 JavaScript 动态生成的内容。
      • 首次加载时间较长:因为需要下载并执行 JavaScript 代码才能显示内容,首次加载时间可能会延长。

image.png

而本项目中 SSR 是有所区别的:

image.png

Redis

在本项目中使用 Redis 主要目的是用作缓存、会话存储、发布和订阅,以提高应用程序的性能和扩展性。

首先我们要安装 ioredisioredis 是一个强大且高性能的 Redis 客户端库,可以支持 Redis Sentinel(哨兵)和 Cluster (集群)等模式,根据自身业务需求进行配置。

yarn add ioredis

定义模块 RedisCoreModule,用于处理 Redis 相关的配置,已支持不同模式下的连接。并且通过 @Global() 配置该模块为全局的。

import {
  RedisModuleAsyncOptions,
  RedisModuleOptions,
  RedisModuleOptionsFactory,
} from '@app/interfaces/redis.interface'
import { DynamicModule, Global, Module, Provider } from '@nestjs/common'
import { createRedisConnection, getRedisConnectionToken, getRedisOptionsToken } from './redis.util'
import { RedisService } from './redis.service'
import { createLogger } from '@app/utils/logger'
import { isDevEnv } from '@app/app.env'
import { CacheService } from './cache.service'

const logger = createLogger({ scope: 'RedisCoreModule', time: isDevEnv })

@Global()
@Module({
  imports: [],
  providers: [RedisService, CacheService],
  exports: [RedisService, CacheService],
})
export class RedisCoreModule {
  /**
   * 同步
   * @static
   * @param {RedisModuleOptions} options  // redis 配置
   * @return {*}  {DynamicModule}
   * @memberof RedisCoreModule
   */
  static forRoot(options: RedisModuleOptions): DynamicModule {
    // 提供非class类提供器的令牌, 获取redis配置信息  使用useValue值提供器
    const redisOptionsProvider: Provider = {
      provide: getRedisOptionsToken(),
      useValue: options,
    }

    // 提供非class类提供器的令牌,获取redis实例
    const redisConnectionProvider: Provider = {
      provide: getRedisConnectionToken(),
      useValue: createRedisConnection(options),
    }

    return {
      module: RedisCoreModule,
      providers: [redisOptionsProvider, redisConnectionProvider],
      exports: [redisOptionsProvider, redisConnectionProvider],
    }
  }

  /**
   * 异步动态模块
   * @static
   * @param {RedisModuleAsyncOptions} options
   * @return {*}  {DynamicModule}
   * @memberof RedisCoreModule
   */
  static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
    // redis 连接工厂
    const redisConnectionProvider: Provider = {
      provide: getRedisConnectionToken(), // 提供非class类的令牌
      useFactory(options: RedisModuleOptions) {
        return createRedisConnection(options)
      },
      inject: [getRedisOptionsToken()], // 向工厂提供注入相关依赖
    }
    return {
      module: RedisCoreModule,
      imports: options.imports,
      providers: [...this.createAsyncProviders(options), redisConnectionProvider],
      exports: [redisConnectionProvider],
    }
  }

  /**
   * 处理不同提供器的返回Provider
   * @static
   * @param {RedisModuleAsyncOptions} options
   * @return {*}  {Provider[]}
   * @memberof RedisCoreModule
   */
  public static createAsyncProviders(options: RedisModuleAsyncOptions): Provider[] {
    // 提供器只提供useClass、useFactory、useExisting这三种自定义提供器
    if (!(options.useExisting || options.useFactory || options.useClass)) {
      throw new Error('无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器')
    }

    if (options.useExisting || options.useFactory) {
      return [this.createAsyncOptionsProvider(options)]
    }

    // 不存在就报错
    if (!options.useClass) {
      throw new Error('无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器')
    }
    return [this.createAsyncOptionsProvider(options), { provide: options.useClass, useClass: options.useClass }]
  }

  /**
   * 使用工厂提供器
   * @static
   * @param {RedisModuleAsyncOptions} options
   * @return {*}  {Provider}
   * @memberof RedisCoreModule
   */
  public static createAsyncOptionsProvider(options: RedisModuleAsyncOptions): Provider {
    // 提供器只提供useClass、useFactory、useExisting这三种自定义提供器
    if (!(options.useExisting || options.useFactory || options.useClass)) {
      throw new Error('无效配置,提供器只提供useClass、useFactory、useExisting这三种自定义提供器')
    }

    //  使用工厂提供器
    if (options.useFactory) {
      return {
        provide: getRedisOptionsToken(), // 使用非类提供器令牌
        useFactory: options.useFactory,
        inject: options.inject || [], // 注入器,
      }
    }

    return {
      provide: getRedisOptionsToken(), // 使用非类提供器令牌, 通过Inject()  string 类型的值,
      // 工厂提供器,是用过inject  注入options.useClass、options.useExisting 执行createRedisModuleOptions方法返回redis 实例
      async useFactory(optionsFactory: RedisModuleOptionsFactory): Promise<RedisModuleOptions> {
        return await optionsFactory.createRedisModuleOptions()
      },
      inject: [options.useClass || options.useExisting] as never,
    }
  }
}

它提供了同步和异步的配置方式,以适应不同的需求,并通过提供者和工厂提供者的方式来管理 Redis 的连接和配置信息。同时,通过设置为全局模块,可以在整个应用程序中共享模块提供的服务和功能。

RedisService 用于封装与 Redis 数据库的交互以及封装提供对外调用的方法。

import { Injectable } from '@nestjs/common'
import { Redis } from 'ioredis'
import { createLogger } from '@app/utils/logger'
import { isDevEnv } from '@app/app.env'
import { isNil, UNDEFINED } from '@app/constants/value.constant'
import { RedisModuleOptions } from '@app/interfaces/redis.interface'
import { InjectRedis, InjectRedisOptions } from '@app/decorators/redis.decorator'

const logger = createLogger({ scope: 'RedisService', time: isDevEnv })

@Injectable()
export class RedisService {
  public client: Redis
  constructor(
    @InjectRedis() private readonly redis: Redis,
    @InjectRedisOptions() private readonly redisOptions: RedisModuleOptions,
  ) {
    this.client = this.redis
    // 监听redis相关方法
    this.redis.on('connect', () => {
      logger.info('[Redis]', 'connecting...')
    })
    this.redis.on('reconnecting', () => {
      logger.warn('[Redis]', 'reconnecting...')
    })
    this.redis.on('ready', () => {
      logger.info('[Redis]', 'readied!')
    })
    this.redis.on('end', () => {
      logger.error('[Redis]', 'Client End!')
    })
    this.redis.on('error', (error) => {
      logger.error('[Redis]', `Client Error!`, error.message)
    })
  }

  /**
   * 解析参数
   * @private
   * @template T
   * @param {(string | null | void)} value
   * @return {*}
   * @memberof RedisService
   */
  private parseValue<T>(value: string | null | void) {
    return isNil(value) ? UNDEFINED : (JSON.parse(value) as T)
  }

  /**
   * value 转成 string
   * @private
   * @param {unknown} value
   * @return {*}  {string}
   * @memberof RedisService
   */
  private stringifyValue(value: unknown): string {
    return isNil(value) ? '' : JSON.stringify(value)
  }

  /**
   * redis 设置值
   * @param {string} key
   * @param {*} value
   * @param {number} [ttl]
   * @return {*}  {Promise<void>}
   * @memberof RedisService
   */
  public async set(key: string, value: any, ttl?: number): Promise<void> {
    const _value = this.stringifyValue(value)
    if (!isNil(ttl) && ttl !== 0) {
      await this.redis.set(key, _value, 'EX', ttl)
    } else {
      await this.redis.set(key, _value)
    }
  }

  /**
   * redis 获取值
   * @template T
   * @param {string} key
   * @return {*}  {(Promise<T | undefined>)}
   * @memberof RedisService
   */
  public async get<T>(key: string): Promise<T | undefined> {
    const value = await this.redis.get(key)
    return this.parseValue<T>(value)
  }

  /**
   * mset事务添加或者批量添加
   * @param {[string, any][]} kvList
   * @param {number} [ttl]
   * @return {*}  {Promise<void>}
   * @memberof RedisService
   */
  public async mset(kvList: [string, any][], ttl?: number): Promise<void> {
    if (!isNil(ttl) && ttl !== 0) {
      const multi = this.redis.multi()
      for (const [key, value] of kvList) {
        multi.set(key, this.stringifyValue(value), 'EX', ttl)
      }
      await multi.exec()
    } else {
      // 批量添加
      await this.redis.mset(
        kvList.map(([key, value]) => {
          return [key, this.stringifyValue(value)] as [string, string]
        }),
      )
    }
  }

  /**
   * 批量获取
   * @param {...string[]} keys
   * @return {*}  {Promise<any[]>}
   * @memberof RedisService
   */
  public mget(...keys: string[]): Promise<any[]> {
    return this.redis.mget(keys).then((values) => {
      return values.map((value) => this.parseValue<unknown>(value))
    })
  }

  /**
   * 批量删除
   * @param {...string[]} keys
   * @memberof RedisService
   */
  public async mdel(...keys: string[]) {
    await this.redis.del(keys)
  }

  /**
   * 单个删除
   * @param {string} key
   * @return {*}  {Promise<boolean>}
   * @memberof RedisService
   */
  public async del(key: string): Promise<boolean> {
    const deleted = await this.redis.del(key)
    return deleted > 0
  }

  /**
   * 查询集合中是否有指定的key
   * @param {string} key
   * @return {*}  {Promise<boolean>}
   * @memberof RedisService
   */
  public async has(key: string): Promise<boolean> {
    const count = await this.redis.exists(key)
    return count !== 0
  }

  /**
   * 以秒为单位,返回给定 key 的剩余生存时间
   * @param {string} key
   * @return {*}  {Promise<number>}
   * @memberof RedisService
   */
  public async ttl(key: string): Promise<number> {
    return await this.redis.ttl(key)
  }

  /**
   * 获取所有key
   * @param {string} [pattern='*']
   * @return {*}  {Promise<string[]>}
   * @memberof RedisService
   */
  public async keys(pattern = '*'): Promise<string[]> {
    return await this.redis.keys(pattern)
  }

  /**
   * 清除所有
   * @param {string} key
   * @memberof RedisService
   */
  public async clean(key: string) {
    await this.redis.del(await this.keys())
  }
}

通过提供的方法可以方便地进行 Redis 数据的读取、设置、删除等操作,并提供了一些便利的批量操作方法。

CacheService 简单封装用于处理缓存相关的操作,方便整个应用调用。

import { Injectable } from '@nestjs/common'
import { RedisService } from './redis.service'

export type CacheKey = string
export type CacheResult<T> = Promise<T>

export interface CachePromiseOption<T> {
  key: CacheKey
  promise(): CacheResult<T>
}

export interface CacheIOResult<T> {
  get(): CacheResult<T>
  update(): CacheResult<T>
}

export interface CachePromiseIOOption<T> extends CachePromiseOption<T> {
  ioMode?: boolean
}

@Injectable()
export class CacheService {
  constructor(private readonly redisService: RedisService) {}

  /**
   * 设置缓存
   * @param {string} key
   * @param {string} value
   * @param {number} [ttl]
   * @return {*}
   * @memberof CacheService
   */
  public set(key: string, value: string, ttl?: number): Promise<void> {
    return this.redisService.set(key, value, ttl)
  }

  /**
   * 获取缓存
   * @template T
   * @param {string} key
   * @return {*}  {Promise<T>}
   * @memberof CacheService
   */
  public get<T>(key: string): Promise<T> {
    return this.redisService.get(key) as Promise<T>
  }

  /**
   * 删除缓存
   * @param {string} key
   * @return {*}  {Promise<Boolean>}
   * @memberof CacheService
   */
  public delete(key: string): Promise<Boolean> {
    return this.redisService.del(key)
  }

  /**
   * promise 缓存
   * @template T
   * @param {CachePromiseOption<T>} options
   * @return {*}  {CacheResult<T>}
   * @memberof CacheService
   * @example CacheService.promise({ key: CacheKey, promise() }) -> promise()
   * @example CacheService.promise({ key: CacheKey, promise(), ioMode: true }) -> { get: promise(), update: promise() }
   */
  promise<T>(options: CachePromiseOption<T>): CacheResult<T>
  promise<T>(options: CachePromiseIOOption<T>): CacheIOResult<T>
  promise(options) {
    const { key, promise, ioMode = false } = options

    const doPromiseTask = async () => {
      const data = await promise()
      await this.set(key, data)
      return data
    }

    // passive mode
    const handlePromiseMode = async () => {
      const value = await this.get(key)
      return value !== null && value !== undefined ? value : await doPromiseTask()
    }

    // sync mode
    const handleIoMode = () => ({
      get: handlePromiseMode,
      update: doPromiseTask,
    })

    return ioMode ? handleIoMode() : handlePromiseMode()
  }
}

Session

根据上面 RedisCoreModule 实现 Redis 连接,我们只需要获取到 Redis 就可以实现 Session 会话。通过在根模块下注入 RedisService 获取到 Redis 客户端实例。

import { MiddlewareConsumer, Module, ValidationPipe } from '@nestjs/common'
import modules from '@app/modules/index'
import { LocalMiddleware } from '@app/middlewares/local.middleware'
import { CorsMiddleware } from '@app/middlewares/cors.middleware'
import { AppMiddleware } from '@app/middlewares/app.middleware'
import { RedisCoreModule } from '@app/processors/redis/redis.module'
import { RedisService } from '@app/processors/redis/redis.service'
import session from 'express-session'
import { CONFIG, SESSION } from './config'
import RedisStore from 'connect-redis'
import { APP_PIPE } from '@nestjs/core'
import { MicroserviceModule } from '@app/processors/microservices/microservice.module'
import { WebsocketModule } from '@app/processors/websocket/websocket.module'

@Module({
  imports: [RedisCoreModule.forRoot(CONFIG.redis), MicroserviceModule, WebsocketModule, ...modules],
  controllers: [],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {
  constructor(private readonly redisService: RedisService) {}
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        CorsMiddleware,
        session({
          store: new RedisStore({
            client: this.redisService.client,
          }),
          ...SESSION,
        }),
        AppMiddleware,
        LocalMiddleware,
      )
      .forRoutes('*')
  }
}

这样就可以实现会话持久化、共享和管理,从而提高应用的可伸缩性和性能。

Middleware 中间件

中间件是在路由处理程序之前调用的函数。中间件函数可以访问 request 和 response 对象,以及应用请求-响应周期中的 next() 中间件函数。

1. AppMiddleware 中间件

这个中间件的作用是用于 App 内嵌 H5 授权访问。通过 Cookie 或者 UA 中注入 token 来获取登录授权信息,以实现登录访问,并确保访问的授权合法性。这里要注意下生成的 token 规则要一致,不行可以通 gRPC 获取 token 解析信息。

这种方法可以支持内嵌大量非核心功能的 H5 页面,在开发周期和需求迭代方面带来了极大的效率提升。通过这种方式,开发团队能够更快速地开发和更新功能,同时也为用户提供了更灵活、更丰富的功能体验。

import { isDevEnv } from '@app/app.env'
import { AuthService } from '@app/modules/auth/auth.service'
import logger from '@app/utils/logger'
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { get } from 'lodash'

/**
 * 用于app内嵌页面授权访问
 * @export
 * @class OriginMiddleware
 * @implements {NestMiddleware}
 */
@Injectable()
export class AppMiddleware implements NestMiddleware {
  constructor(private readonly authService: AuthService) {}

  async use(request: Request, response: Response, next: NextFunction) {
    // isApp 这里还要加上一个app 来源判断,在UA或者cookie 加上一个标识用于判断是否为内部APP.
    // if (!isDevEnv && isApp) {
    if (!isDevEnv) {
      logger.info('来源为app内嵌h5页面授权')
      // 把用户相关信息注入到UA或者cookie中,通过UA或者cookie获取用户信息进行授权登录。
      // 确保服务端生成的jwt和node服务规则一致,如果不行可以通过grpc调用服务获取数据
      const jwt = get(request, 'cookies.jwt')
      if (jwt) {
        try {
          const { data } = await this.authService.verifyAsync(jwt)
          response.cookie('jwt', data.accessToken, {
            sameSite: true,
            httpOnly: true,
            // secure: true,
          })
          response.cookie('userId', data.userId)
          // 强制注入cookie
          request.cookies['jwt'] = data.accessToken
          request.session.user = data
        } catch (error) {
          throw new Error('抛出异常,如果是api, 直接报错,如果是页面就跳转到登录页面')
        }
      }
    }
    return next()
  }
}

2.CorsMiddleware 中间件

该中间件的作用主要用于处理跨域请求,设置合适的 CORS 相关的响应头信息,以确保客户端能够安全地进行跨域请求访问。并设置了允许的 Origin 、请求方法和请求头。

import { Request, Response, NextFunction } from 'express'
import { Injectable, NestMiddleware, HttpStatus, RequestMethod } from '@nestjs/common'
import { isDevEnv } from '@app/app.env'
import { CROSS_DOMAIN } from '@app/config'
import logger from '@app/utils/logger'

@Injectable()
export class CorsMiddleware implements NestMiddleware {
  use(req: Request, response: Response, next: NextFunction) {
    try {
    } catch (error) {}
    const getMethod = (method) => RequestMethod[method]
    const origins = req.headers.origin
    const origin = (Array.isArray(origins) ? origins[0] : origins) || ''
    const allowedOrigins = CROSS_DOMAIN.allowedOrigins

    const allowedMethods = [
      RequestMethod.GET,
      RequestMethod.HEAD,
      RequestMethod.PUT,
      RequestMethod.PATCH,
      RequestMethod.POST,
      RequestMethod.DELETE,
    ]
    const allowedHeaders = [
      'Authorization',
      'Origin',
      'No-Cache',
      'X-Requested-With',
      'If-Modified-Since',
      'Pragma',
      'Last-Modified',
      'Cache-Control',
      'Expires',
      'Content-Type',
      'X-E4M-With',
    ]
    // Allow Origin
    if (!origin || allowedOrigins.includes(origin) || isDevEnv) {
      response.setHeader('Access-Control-Allow-Origin', origin || '*')
    }
    // Headers
    response.setHeader('Timing-Allow-Origin', '*')
    response.header('Access-Control-Allow-Credentials', 'true')
    response.header('Access-Control-Allow-Headers', allowedHeaders.join(','))
    response.header('Access-Control-Allow-Methods', allowedMethods.map(getMethod).join(','))
    response.header('Access-Control-Max-Age', '1728000')
    response.header('Content-Type', 'application/json; charset=utf-8')

    response.on('finish', () => {
      const statusCode = response.statusCode
      const statusMessage = HttpStatus[statusCode]
      logger.info(`Response Code: ${statusCode} - ${statusMessage}`)
    })

    if (req.method === getMethod(RequestMethod.OPTIONS)) {
      return response.sendStatus(HttpStatus.NO_CONTENT)
    } else {
      return next()
    }
  }
}

3.LocalMiddleware 中间件

在前后端分离开发中,通常后端接口不支持本地访问,而前端通过代理访问指定服务。若公司内部使用 UDB 提供的用户登录,且不支持本地登录,这时切换用户验证不同流程需修改在测试环境或者正式环境登录后修改 cookie 或编写插件拷贝 cookie到本地,这样每次替换都会比较麻烦。

还有如果是 App 内嵌 h5开发过程中,往往需要本机设置HOST,并且使用Charles、Fiddle、ProxyMan等工具开启代理服务,供客户端调试使用,这样也很麻烦。

此时 LocalMiddleware 中间件就能解决以上问题,后端服务只提供内网访问。

  • 本地开发:可以通过修改 userId 修改访问不同用户数据,同时支持局域网内各端都可以访问调试页面。
  • App 内嵌 H5:App 端支持提供输入连接访问H5页面,在相同局域网内使用 IP 访问 H5 地址,这样可以进行页面访问。同时也是通过修改 userId,刷新页面就可以看到不同用户效果以及调试调用 App 不同方法。
  • 灰度访问用户数据: 在实际应用,用户可能存在各种各样的问题。我们不可能去要用户的账号和密码访问他们的系统。此时我们可以在灰度环境中修改此处代码if (isDevEnv) 默认设置为true,userId 改成用户id,通过pm2 重启,灰度环境下访问都是用户的数据,这样方便快速定位问题。
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { AuthService } from '@app/modules/auth/auth.service'
import { AuthInfo } from '@app/interfaces/auth.interface'
import { isDevEnv } from '@app/app.env'
import { get } from 'lodash'

/**
 * 用于本地开发登录
 * @export
 * @class LocalMiddleware
 * @implements {NestMiddleware}
 */
@Injectable()
export class LocalMiddleware implements NestMiddleware {
  constructor(private readonly authService: AuthService) {}

  /**
   * 接口和API访问都会生产session, 支持本地访问API和页面
   * @param {Request} request
   * @param {Response} response
   * @param {NextFunction} next
   * @return {*}
   * @memberof LocalMiddleware
   */
  async use(request: Request, response: Response, next: NextFunction) {
    if (isDevEnv) {
      const userInfo = {
        account: 'admin',
        userId: 1000000000,
      }
      const token = await this.authService.creatToken(userInfo)
      response.cookie('jwt', token.accessToken, {
        sameSite: true,
        httpOnly: true,
        // secure: true,
      })
      response.cookie('userId', userInfo.userId)
      // 强制注入cookie
      request.cookies['jwt'] = token.accessToken
      request.session.user = userInfo
    } else {
      const user = get(request, 'session.user') as AuthInfo
    }
    return next()
  }
}

gRPC

首先我们需要写个脚本用于获取 protos 项目

#!/bin/bash

PROTO_DIR="./server/protos" 

rm -rf "$PROTO_DIR"

cd ./server

git clone https://github.com/huazai128/protos-file.git

mv ./protos-file/protos ./

rm -rf ./protos-file

然后执行命令,就可以拉取最新的 protos

yarn proto // 获取最新proto

Auth

从上面我们已经获取到 proto 项目代码,现在我们通过 AuthModule 模块实现 gRPC远程调用。

1.Auth Module

首先是通过导入 ClientsModule。使用 register() 方法将 .proto 文件中定义的服务包绑定到注入令牌,并配置服务。name 属性是注入令牌。使用 transport: Transport.GRPCoptions 配置具体信息。

同时该模块导入了两个外部的模块:PassportModuleJwtModulePassportModule 是一个用于身份验证的,JwtModule 是一个用于创建和验证 JSON Web Token(JWT)。

该模块还导入了三个文件:AuthControllerAuthServiceJwtStrategyAuthControllerAuthService 是用于处理身份验证的控制器和业务逻辑,JwtStrategy 是用于验证和解码 JWT 的策略。

import { Module } from '@nestjs/common'
import { ClientsModule, Transport } from '@nestjs/microservices'
import { join } from 'path'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { PassportModule } from '@nestjs/passport'
import { AUTH } from '@app/config'
import { JwtModule } from '@nestjs/jwt'
import { JwtStrategy } from './jwt.strategy'

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'AUTHPROTO_PACKAGE',
        transport: Transport.GRPC,
        options: {
          url: '0.0.0.0:50052',
          package: 'authproto',
          protoPath: ['auth.proto'],
          loader: {
            includeDirs: [join(__dirname, '../../protos')],
            keepCase: true,
          },
          // protoPath: join(__dirname, '../../protos/auth.proto'),
        },
      },
    ]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      global: true,
      secret: AUTH.jwtTokenSecret,
      signOptions: {
        expiresIn: AUTH.expiresIn as number,
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

2. AuthService

AuthService主要负责与 gRPC 服务交互,处理用户认证、生成 Token、用户登录和验证等操作,为应用程序提供了用户认证和授权的功能

import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import { ClientGrpc } from '@nestjs/microservices'
import { AuthDTO } from './auth.dto'
import { lastValueFrom } from 'rxjs'
import { AuthService as AuthServiceT, LoginResponse, ValidateUserRequest } from '@app/protos/auth'
import { JwtService } from '@nestjs/jwt'
import { AUTH } from '@app/config'
import { TokenInfo } from '@app/interfaces/auth.interface'

@Injectable()
export class AuthService implements OnModuleInit {
  public authService: AuthServiceT

  constructor(
    @Inject('AUTHPROTO_PACKAGE') private readonly client: ClientGrpc,
    private readonly jwtService: JwtService,
  ) {}
  onModuleInit() {
    this.authService = this.client.getService<AuthServiceT>('AuthService')
  }

  /**
   * 生成token
   * @param {*} data
   * @return {*}  {TokenInfo}
   * @memberof AuthService
   */
  public creatToken(data): TokenInfo {
    const token = {
      accessToken: this.jwtService.sign({ data }),
      expiresIn: AUTH.expiresIn as number,
    }
    return token
  }

  /**
   * 登录
   * @param {AuthDTO} data
   * @return {*}  {Promise<LoginResponse>}
   * @memberof AuthService
   */
  public async login(data: AuthDTO): Promise<LoginResponse & TokenInfo> {
    const { userId, account } = await lastValueFrom(this.authService.login(data))
    const token = this.creatToken({
      account: account,
      userId: userId,
    })
    return {
      ...token,
      userId,
      account,
    }
  }

  /**
   * 验证用户
   * @param {ValidateUserRequest} data
   * @return {*}  {Promise<LoginResponse>}
   * @memberof AuthService
   */
  public async validateUser(data: ValidateUserRequest): Promise<LoginResponse> {
    return lastValueFrom(this.authService.validateUser(data))
  }

  /**
   * 通过验证获取用户信息
   * @param {string} jwt
   * @return {*}  {Promise<any>}
   * @memberof AuthService
   */
  public async verifyAsync(jwt: string): Promise<any> {
    const payload = await this.jwtService.verifyAsync(jwt, {
      secret: AUTH.jwtTokenSecret,
    })
    return payload
  }
}

用 @Inject() 注入配置的 ClientGrpc 对象。然后我们使用 ClientGrpc 对象的 getService() 方法来检索服务实例

  constructor(
    @Inject('AUTHPROTO_PACKAGE') private readonly client: ClientGrpc,
    private readonly jwtService: JwtService,
  ) {}
  onModuleInit() {
    this.authService = this.client.getService<AuthServiceT>('AuthService')
  }

3.AuthController

AuthController 制器负责处理用户登录请求、获取用户信息等接口

import { Body, Controller, Get, Post, Req, Res, Session } from '@nestjs/common'
import { AuthService } from './auth.service'
import { AuthDTO } from './auth.dto'
import { Request, Response } from 'express'
import { SessionInfo } from '@app/interfaces/session.interfave'
import { AuthInfo } from '@app/interfaces/auth.interface'
import { RedisMicroserviceService } from '@app/processors/microservices/redis.microservice.service'

@Controller('api/auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly client: RedisMicroserviceService,
  ) {}

  @Post('login')
  public async adminLogin(
    @Req() req: Request,
    @Body() data: AuthDTO,
    @Session() session: SessionInfo,
    @Res() res: Response,
  ) {
    const { accessToken, ...result } = await this.authService.login(data)
    res.cookie('jwt', accessToken, {
      sameSite: true,
      httpOnly: true,
    })
    res.cookie('userId', result.userId)
    session.user = result as AuthInfo
    return res.status(200).send({
      result: result,
      status: 'success',
      message: '登录成功',
    })
  }

  /**
   * 获取用户列表
   * @return {*}
   * @memberof AuthController
   */
  @Get('list')
  getUserList() {
    const pattern = { cmd: 'getUserListRes' }
    return this.client.sendData(pattern, {})
  }
}

4.JwtStrategy

JwtStrategy 是一个 Passport 策略类,用于基于 JSON Web Token (JWT) 的身份验证。该类通过继承 PassportStrategy 类和 Strategy 类来进行实现

在构造函数中,使用 super() 方法来调用 Strategy 类的构造函数,并传递一个对象作为参数,其中对象包括两个属性:

  1. jwtFromRequest 方法用于从 HTTP 请求中获取 JWT Token。本项目中是通过获取 Cookie 来从 HTTP 请求中检索 JWT Token
  2. secretOrKey 属性用于生成 JWT Token 的密钥。

validate() 方法用于验证 JWT Token中的用户信息。通过调用 AuthService 类中的 validateUser() 方法,验证传入的用户信息并返回结果。

import { AUTH } from '@app/config'
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy } from 'passport-jwt'
import { AuthService } from './auth.service'
import { Request } from 'express'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: (req: Request) => {
        // cookie 获取token
        return req.cookies['jwt']
      },
      secretOrKey: AUTH.jwtTokenSecret,
    })
  }

  /**
   * 验证用户
   * @param {*} payload
   * @return {*}
   * @memberof JwtStrategy
   */
  async validate(payload: any) {
    const res = await this.authService.validateUser(payload.data)
    return res
  }
}

WebSocket

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,允许客户端和服务器之间实时地进行双向通信。相比传统的 HTTP 请求-响应模式,WebSocket 允许服务器主动向客户端推送数据,而不需要客户端发起请求。 以下是一些 WebSocket 的关键特点:

  1. 实时通信

    • WebSocket 提供了双向通信的能力,允许客户端和服务器之间实时地发送消息。
  2. 低延迟

    • 由于使用单个持久的连接,WebSocket 可以减少通信的延迟,适用于需要即时响应的应用程序。
  3. 推送数据

    • 服务器可以主动向客户端推送数据,而不需要客户端发起请求,这在实时信息更新和通知的场景中非常有用。
  4. 跨平台支持

    • WebSocket 是一种标准化协议,支持跨平台的通信,可以在 Web 应用、移动应用和服务器端应用之间进行通信。

1. 服务端

首先我们要安装 WebSockets 应用所需相关包

yarn add @nestjs/websockets @nestjs/platform-socket.io

WebSocketGateway 是一个特殊的装饰器,用于定义 WebSocket 网关。WebSocket 网关允许在 Nest.js 应用中处理 WebSocket 连接,接收来自客户端的消息并向客户端发送消息。

作用

  • 处理 WebSocket 连接:WebSocketGateway 允许你处理客户端与服务器之间的 WebSocket 连接,包括处理连接建立、断开连接等事件。
  • 消息处理:通过订阅不同类型的消息,你可以定义处理不同消息类型的逻辑,从而实现实时的双向通信。
  • 广播消息:通过 WebSocketGateway,你可以向所有连接的客户端广播消息,实现实时推送数据的功能。

常用 API 说明

  • @WebSocketGateway() 装饰器:用于将一个类标记为 WebSocket 网关。
  • @WebSocketServer() 装饰器:用于注入 WebSocket 服务器实例,可以在类中直接访问 WebSocket 服务器的方法和属性。
  • @SubscribeMessage() 装饰器:用于订阅特定类型的消息,在接收到对应消息时执行相应的处理逻辑。
  • handleConnection 方法:处理客户端连接的事件,在客户端连接到 WebSocket 服务器时调用。
  • handleDisconnect 方法:处理客户端断开连接的事件,在客户端从 WebSocket 服务器断开时调用。
  • @MessageBody() 装饰器:用于从客户端接收的消息体中提取数据。
  • server.clients:用于访问当前连接到 WebSocket 服务器的客户端列表

生命周期钩子

OnGatewayInit强制实现 afterInit() 方法。将特定于库的服务器实例作为参数(并在需要时传播其余部分)。
OnGatewayConnection强制实现 handleConnection() 方法。将特定于库的客户端套接字实例作为参数。
OnGatewayDisconnect强制实现 handleDisconnect() 方法。将特定于库的客户端套接字实例作为参数。
import { isDevEnv } from '@app/config'
import { createLogger } from '@app/utils/logger'
import {
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
  WsResponse,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets'
import { from, Observable, map, lastValueFrom, of } from 'rxjs'
import { Server } from 'socket.io'
import { RedisMicroserviceService } from '@app/processors/microservices/redis.microservice.service'
import { USER_LOGIN } from '@app/constants/pattern.constant'

const Logger = createLogger({ scope: 'EventsGateway', time: isDevEnv })

@WebSocketGateway(8081, {
  cors: {
    origin: '*',
  },
  transports: ['websocket'], //要将 socket.io 与多个负载平衡实例一起使用,你必须通过在客户端 socket.io 配置中设置 transports: ['websocket'] 来禁用轮询

})
export class WsGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(private readonly redisService: RedisMicroserviceService) {}
  @WebSocketServer()
  server: Server

  handleDisconnect(client: any) {
    Logger.log('websocket 连接关闭')
  }

  handleConnection(client: any, ...args: any[]) {
    // this.server.emit('events', { data: 'websocket 连接成功' })
    Logger.log('websocket 连接成功')
  }

  /**
   * 提供给个服务调用
   * @param {string} events
   * @param {*} data
   * @memberof WsGateway
   */
  sendWs(events: string, data: any) {
    this.server.emit(events, data)
  }

  /**
   * 提供对外监听数据, 这里就要自己处理守卫、拦截器、异常处理。
   * @template T
   * @param {string} events
   * @return {*}  {Promise<T>}
   * @memberof WsGateway
   */
  onMessage<T>(events: string): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.server.on(events, (data) => {
        resolve(data)
      })
    })
  }

  /**
   * events 事件名称,SubscribeMessag 只适用于网关内监听消息。
   * @param {*} data
   * @return {*}  {Observable<WsResponse<number>>}
   * @memberof EventsGateway
   */
  @SubscribeMessage('events')
  handleMessage(@MessageBody() data: any): Observable<WsResponse<number>> {
    Logger.log('接收消息events的数据', data)
    return from([1, 2, 3]).pipe(map((item) => ({ event: 'events', data: item })))
  }

  /**
   * 通过redis 发布订阅获取数据,可以是当前服务,也可以是其他服务获取。这样满足了守卫、拦截器、异常处理等逻辑处理。
   * @param {*} data
   * @return {*}  {Observable<WsResponse<any>>}
   * @memberof WsGateway
   */
  @SubscribeMessage('userLogin')
  handleLoginMessage(@MessageBody() data: any): Observable<WsResponse<any>> {
    Logger.log('接收消息userLogin的数据', data)
    return this.redisService.sendData(USER_LOGIN, data).pipe(map((data) => ({ event: 'userLogin', data })))
  }
}

WsGateway 类注入了 RedisMicroserviceService , RedisMicroserviceService 是微服务 Redis 用于传输器实现发布/订阅消息传递。

import { REDIS_SERVICE } from '@app/constants/redis.constant'
import { Inject, Injectable } from '@nestjs/common'
import { ClientProxy } from '@nestjs/microservices'

@Injectable()
export class RedisMicroserviceService {
  constructor(@Inject(REDIS_SERVICE) private readonly client: ClientProxy) {}

  /**
   * 基于request-response模式 发送
   * @param {*} pattern
   * @param {*} data
   * @return {*}
   * @memberof RedisMicroserviceService
   */
  public sendData(pattern: any, data: any) {
    return this.client.send(pattern, data)
  }

  /**
   * 基于事件模式 发送,只发布事件而不等待响应。
   * @param {*} pattern
   * @param {*} data
   * @return {*}
   * @memberof RedisMicroserviceService
   */
  public emitData(pattern: any, data: any) {
    return this.client.emit(pattern, data)
  }
}

ClientProxy 底层也是使用 routingMap 储存在内存中来处理响应,所以使用时需要改造下。不然内存一直被占用,导致内存高。

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)

  // redis 微服务,支持当前服务监听事件
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.REDIS,
    options: CONFIG.redisConf,
  })
  await app.startAllMicroservices()
  await app.listen(APP.PORT).then(() => {
    logger.info(`Application is running on: http://${getServerIp()}:${APP.PORT}, env: ${environment}}`)
  })
}
bootstrap()

然后再 main.ts 关联到redis 微服务开发。这个在整个项目中可以监听事件了。

WebSocket 适配器

WebSocket 适配器在 NestJS 中的作用是将底层的 WebSocket 实现与 NestJS 框架整合起来,使得在 NestJS 应用中可以方便地使用 WebSocket 功能。WebSocket 适配器充当了 WebSocket 服务器和 NestJS 应用之间的桥梁,提供了一种标准的方式来处理 WebSocket 连接、消息传递等操作。

本项目使用 Redis 作为是适配器。

import { IoAdapter } from '@nestjs/platform-socket.io'
import { ServerOptions } from 'socket.io'
import { createAdapter } from '@socket.io/redis-adapter'
import { createRedisConnection } from '@app/processors/redis/redis.util'
import { CONFIG } from '@app/config'

/**
 * Redis 适配器
 * @export
 * @class RedisIoAdapter
 * @extends {IoAdapter}
 */
export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>

  // 连接redis
  async connectToRedis(): Promise<void> {
    const pubClient = createRedisConnection(CONFIG.redis)
    const subClient = pubClient.duplicate()
    this.adapterConstructor = createAdapter(pubClient, subClient)
  }

  createIOServer(port: number, options?: ServerOptions): any {
    const server = super.createIOServer(port, options)
    server.adapter(this.adapterConstructor)
    return server
  }
}

配置适配器

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)

  // redis 适配器
  const redisIoAdapter = new RedisIoAdapter(app)
  await redisIoAdapter.connectToRedis()

  app.useWebSocketAdapter(redisIoAdapter)

  // redis 微服务
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.REDIS,
    options: CONFIG.redisConf,
  })
  await app.startAllMicroservices()
  await app.listen(APP.PORT).then(() => {
    logger.info(`Application is running on: http://${getServerIp()}:${APP.PORT}, env: ${environment}}`)
  })
}
bootstrap()

2. 客户端

首先安装 socket.io 客户端

yarn add socket.io-client

SocketStore 类,用于管理 WebSocket 连接和消息处理

import { action, makeAutoObservable, observable } from 'mobx'
import { io, Socket } from 'socket.io-client'

export class SocketStore {
  private socket!: Socket
  constructor() {
    makeAutoObservable(this)
  }

  @action
  initScoket = () => {
    this.socket = io('ws://localhost:8081')
    // this.socket = io('ws://172.26.130.15:8081')

    // 监听连接成功
    this.socket.on('connect', () => {
      console.log('连接成功')
    })

    // 监听断开连接
    this.socket.on('disconnect', () => {
      console.log('disconnect')
    })

    // 监听连接失败
    this.socket.on('connect_error', (err) => {
      console.log(err, '连接错误')
    })

    // 向events发送data
    this.socket.emit('events', { data: 'q23123' })

    // 返回数据
    this.socket.on('events', (data) => {
      console.log(data, 'data')
    })

    // 触发socket
    this.socket.emit('userLogin', { data: '登录成功' })

    // 监听socket
    this.socket.on('userLogin', (data) => {
      console.log(data, 'data')
    })
  }

  /**
   * 向 event 发送 数据
   * @param {string} event
   * @param {*} data
   * @memberof SocketStore
   */
  @action
  emitWs = (event: string, data: any) => {
    this.socket.emit(event, data)
  }

  /**
   * 监听事件 进行处理
   * @param {string} event
   * @memberof SocketStore
   */
  @action
  onMessage = <T>(event: string) => {
    return new Promise<T>((resolve, reject) => {
      this.socket.on(event, (data) => {
        resolve(data)
      })
    })
  }
}

export default new SocketStore()

运行后 image.png image.png

整个 Client 端项目运行命令如下

yarn proto // 拉取proto 项目

yarn start:dev // 运行 node 服务

yarn dev // 运行前端页面

然后访问 node 服务启动的域名就可以访问页面和接口。

总结

构建高性能微前端和微服务架构时,结合 Nest.js、gRPC 和 Emp.js 是一个强大的组合。

  1. Nest.js

    • Nest.js 是一个基于 TypeScript 的渐进式 Node.js 框架,它结合了 OOP、FP 和 FRP 的元素,提供了强大的依赖注入、模块化和易于测试的特性。
    • 在微服务架构中,Nest.js 提供了良好的模块化和架构设计,使得构建和管理微服务变得更加简单和可维护。
    • Nest.js 的 WebSocket 模块和 WebSockets 网关可用于实现实时通信和事件驱动架构。
  2. gRPC

    • gRPC 是一个高性能、开源和通用的远程过程调用(RPC)框架,基于 HTTP/2 协议,支持多种编程语言。
    • 在微服务架构中,gRPC 提供了轻量级的通信协议和基于 Protocol Buffers 的接口定义语言,使得不同微服务之间的通信更加高效和可靠。
    • gRPC 还支持双向流、流式处理和认证等功能,适合构建复杂的微服务架构。
  3. Emp.js

    • Emp.js 是一个面向现代浏览器和微前端的 JavaScript 运行时,可以帮助构建高性能的微前端应用。
    • Emp.js 提供了模块化加载、动态加载和共享状态管理等功能,使得微前端应用的开发、部署和维护更加灵活和高效。

结合 Nest.js、gRPC 和 Emp.js 可以构建一个高性能、可扩展和易维护的微前端和微服务架构。Nest.js 提供了强大的后端支持和微服务架构设计,gRPC 提供了高效的远程过程调用机制,而 Emp.js 则用于构建现代化的微前端应用,共同为整个架构提供了可靠的基础和高效的通信机制。这样的架构可以帮助实现系统的高性能、高可用性和易扩展性。

在写技术文章分享时,通过查找资料,获取了许多宝贵的知识,这些知识在日常开发中往往很容易被忽略。同时也为后续项目提供技术经验和架构支持。

目前整个项目架构还没应用到实际开发中,打算把原来已上线的日志上报系统改造成这样,同时优化现有日志上报存在的问题(和原有日志上报项目处理逻辑不一样)。

项目代码地址: