Nest + Emp 实现BFF能做什么?

1,015 阅读9分钟

image.png

今天和大家分享一下自己工作中基于Nest+Emp微前端框架实现一些业务场景和思路。本文需要了解的知识有NestWebpack5 Module Federation(模块联邦)

模块联邦

前端的痛点

1、多项目抽离公共组件更新,需要修改引用组件进行升级,如果存在多个依赖方,这种“发布-> 通知-> 更改”模式无疑是低效率的

2、编译速度减慢,业务多,模块多,影响到编译速度问题的因素有很多种,不仅仅是模块多,还有三方的模块依赖等

这种情况下会导致开发效率低下、维护成本高、新同事入手困难等一些列的问题

Module Federation(模块联邦)

Module Federation是Webpack 5 的新特性之一,可以解决多个webpack编译下共享模块、依赖、页面、组件甚至应用。

以下是特性有:

  • 每个微应用独立部署运行: 并通过CDN的方式引入主程序中,因此只需要部署一次,便可以提供给任何基于Module Federation的应用使用
  • 动态更新微应用: 通用CDN的加载其他微应用,每个微应用中的代码变化、无需重新打包发布就能直接加载最新的微应用。
  • 去中心化: 每个微应用间都可以引入其他的微应用,无中心应用的概念
  • 按需加载: 开发者可以选择只加载微应用中需要的部分,而不是强制只能将整个应用全部加载
  • 应用件通信每一个应用都可以进行状态共享等等

ModuleFederationPlugin 提供了相关配置属性:

字段名类型含义
namestring必传值,即输出的模块名,被远程引用时路径为${name}/${expose}
libraryobject声明全局变量的方式,name为umd的name
filenamestring构建输出的文件名
remotesobject远程引用的应用名及其别名的映射,使用时以key值作为name
exposesobject被远程引用时可暴露的资源路径及其别名
sharedobject与其他应用之间可以共享的第三方依赖,使你的代码中不用重复加载同一份依赖

接下来就讲讲在项目中基于Nest + Emp框架在实际项目中使用场景分析和处理。

1、相同用户体系多端支持授权访问

首先用一张图来分析同一用户下多个子字应用间的流程。

image.png

以上是BFF的核心架构图,前端是基于EMP微前端框架处理多个子应用,BFF即使用Nest搭建的服务,后端即后端服务。现在对整个流程进行分析

前端

  1. 首先使Nest和Emp融合在一个项目中,需要修改相关tsconfig文件,使其互不影响。
  2. 基于Emp框架搭建的前端,通过修改Emp配置devMiddleware中serverSideRender:true并且指定编译输出文件目录,使本地编译后的文件存储在指定的目录下,Nest服务通过模版引擎渲染本地文件,这样整个流程就打通。此时的Emp只能作为Host。

image.png

  1. 宿主Host中引用其他Remote应用。宿主Host作为载体为其他子应用。如图2 image.png

image.png

4.运行emp dts;可以根据 config.empShare.remote 自动同步所需类型

前端整个流程大致如上。针对服务端数据注入到window下需要添加webpack插件插入一段Javascript代码,使其在运行时注入到window下。因为webpack处理html文件时也是用的模版引起,导致编译后的html中不支持模版语法。此时需要动态注入。

chain.plugin('InlineCodePlugin').use(new InlineCodePlugin({
    begin: false,
    tag: 'script',
    inject: 'body',
    code: `window.INIT_DATA = <%- JSON.stringify(data) %>`
}))

BFF

BFF是一个服务前端的后端服务。在本项目中主要负责渲染、鉴权、API和路由守卫、接口聚合等功能。可以说有了BFF,后端更多的关注业务本身,从而减少处理不必要的业务逻辑,同时也提高系统的安全性和开发效率。

1、服务端渲染: SSR和模版引擎是服务端渲染的方式,而本项目使用的是模版引擎渲染html。所以首先需要配置模版引擎。

image.png

通过不能路由渲染不同的页面,并且返回参数注入到window下。

image.png

2、CORS中间件: 是一种允许从另外一个域请求资源的机制。在这里处理运行白名单域名请求,同时起到安全作用

image.png

3、Origin中间件: 用于验证访问来源是否合法。

image.png 4、Session:主要是用于不同请求间用户登录信息存取,这里使用了Redis作为Session信息存储库,这样有利于在多进程中共享登录状态信息。

image.png

针对每个用户登录都会产生一个token,并保存到session中,把生成的token和后端返回的userId映射到cookie,用于用户下次访问时,进行身份验证。同时用户每次访问都会更新session回话时间。

image.png

image.png

5、 API和路由守卫: 它是根据用户访问页面或者API是来确定用户本身权限、角色来判断用户是否有权限访问。下图是API的守卫逻辑,同时新增白名单API,方便不需要授权的接口也能请求。(路由守卫也是同样的处理逻辑)

image.png

6、多端授权登录: 同一个应用可能会在多端中访问,此时存在用户权限访问问题。为了解决这个问题,可以在Node层处理每个端登录访问问题。以下都是在同一用户体系下。同时根据APP注入UA的相关信息判断来源。

APP端: APP怎么样传递用户信息到H5端?个人觉得有两种比较好的方案。

  1. 如果APP是根据Token 来验证用户是否登录,可以在访问H5时,把Token映射到Cookie中,Node层会通过token去验证登录状态。此处Token存储的位置在和Node中Session回话使用的Redis存在同一个库中,如果Cookie注入H5无法拿到就注入到UA中。这种方式就不需要单独中间件处理。

  2. 如果APP不是根据Token验证,那就把用户登录的账号和密码注入到UA中,密码可以通过加密处理,Node层解密来进行登录,登录成功后会把生成的Token映射到Cookie中,方便后续H5页面跳转时,登录状态一直存在。(图下)

image.png

微信H5端:微信授权有两种情况:静默授权和用户手动点击授权。根据业务不同来选择微信授权方式。以下是用户手动点击触发授权代码

image.png

如果业务中同时存在这两种方式,可以设置中间件根据路由来进行处理授权方式。

export class AppModule implements NestModule { 
 configure(consumer: MiddlewareConsumer) { 
     consumer.apply(WechatMiddleware).forRoutes({ path: 'cats'}); 
 }
}

微信授权整个流程:

  1. 引导用户进入授权页面同意授权,获取code
  2. 通过 code 换取网页授权access_token(与基础支持中的access_token不同)
  3. 如果需要,开发者可以刷新网页授权access_token,避免过期
  4. 通过网页授权access_token和 openid 获取用户基本信息(支持 UnionID 机制)
  5. 获取用户信息后传递给后端处理返回相关信息后保存到session和cookie中

PC端登录:PC端登录走的是接口登录,这里没什么好说的,只是在登录后保存用户信息。

/**
 * 登录接口
 * @param {Request} req
 * @param {HttpRequest} data
 * @param {Response} res
 * @return {*} 
 * @memberof AuthController
 */
@Responsor.api()
@Post('login')
public async adminLogin(@Req() req: Request, @Body(new TransformPipe()) data: HttpRequest, @Res() res: Response) {
    const { access_token, token, ...result } = await this.authService.login(data)
    res.cookie('jwt', access_token);
    res.cookie('userId', result.userId);
    req.session.user = result;
    return res.status(200).send({
        result: result,
        status: ResponseStatus.Success,
        message: '登录成功',
    })
}
/**
 * 调用后端服务返回用户信息
 * @param {HttpRequest} { transformUrl, transferData }
 * @return {*}  {Promise<any>}
 * @memberof AuthService
 */
public async login({ transformUrl, transferData }: HttpRequest): Promise<any> {
    const res = await this.axiosService.post(transformUrl, transferData) as any
    const token = this.creatToken({ usernmae: res.account, userId: res.userId })
    return { ...res, ...token }
}

本地开发:本地授权中间件用本地快速切换不同测试用户进行测试开发。

/**
 * 用于本地模仿用户登录授权
 * @export
 * @class DevMiddleware
 * @implements {NestMiddleware}
 */
@Injectable()
export class DevMiddleware implements NestMiddleware {
    constructor(
        private readonly authService: AuthService
    ) { }

    use(request: Request, response: Response, next: NextFunction) {
        if (isDevEnv) {
            const userInfo = {
                username: 'admin',
                userId: '6177ad66d32b52cbf5bd8ac8',
            }
            const token = this.authService.creatToken(userInfo)
            response.cookie('jwt', token.access_token);
            response.cookie('userId', userInfo.userId);
            // 强制注入cookie,方便后续权限严重
            request.cookies['jwt'] = token.access_token
            request.session.user = userInfo;
        }
        // 更新token 日期
        request.session.touch();
        return next()
    }
}

7、API网关: API网关是位于客户端与后端服务集之间的工具。而Node层的网关,只是做服务转发,再请求后端API。

image.png

AxiosService服务作用是用于请求后端接口。针对线上用户反馈的问题,需要进入当前用户登录状态页面查看具体问题时,可以通过在灰度环境修改API转发中的用户ID,来获取用户页面数据。然后进行问题排除。

import logger from "@app/utils/logger";
import { UnAuthStatus } from "@app/constants/error.constant";
import { BadRequestException, HttpStatus, Injectable, UnauthorizedException } from "@nestjs/common";
import axios, { AxiosRequestConfig, AxiosResponse, CancelTokenSource, Method } from "axios";

/**
 * https://github.com/nestjs/axios
 * @export
 * @class AxiosService
 */
@Injectable()
export class AxiosService {

    public get<T>(
        url: string,
        data?: any,
        config?: AxiosRequestConfig,
    ): Promise<AxiosResponse<T>> {
        return this.makeObservable<T>('get', url, data, config);
    }

    public post<T>(
        url: string,
        data?: any,
        config?: AxiosRequestConfig,
    ): Promise<AxiosResponse<T>> {
        return this.makeObservable<T>('post', url, data, config);
    }

    protected makeObservable<T>(
        method: Method,
        url: string,
        data: any,
        config?: AxiosRequestConfig,
    ): Promise<AxiosResponse<T>> {

        let axiosConfig: AxiosRequestConfig = {
            method: method,
            url,
        }

        const instance = axios.create()

        let cancelSource: CancelTokenSource;
        if (!axiosConfig.cancelToken) {
            cancelSource = axios.CancelToken.source();
            axiosConfig.cancelToken = cancelSource.token;
        }
        // 请求拦截  这里只创建一个,后续在优化拦截
        instance.interceptors.request.use(
            cfg => {
                cfg.params = { ...cfg.params, ts: Date.now() / 1000 }
                return cfg
            },
            error => Promise.reject(error)
        )

        // 响应拦截
        instance.interceptors.response.use(
            response => {
                const rdata = response.data || {}
                if (rdata.code == 200 || rdata.code == 0) {
                    logger.info(`转发请求接口成功=${url}, 获取数据${JSON.stringify(rdata.result).slice(0, 350)}`)
                    return rdata.result
                } else {
                    return Promise.reject({
                        msg: rdata.message || '转发接口错误',
                        errCode: rdata.code || HttpStatus.BAD_REQUEST,
                        config: response.config
                    })
                }
            },
            error => {
                const data = error.response && error.response.data || {}
                const msg = error.response && (data.error || error.response.statusText)
                return Promise.reject({
                    msg: msg || error.message || 'network error',
                    errCode: data.code || HttpStatus.BAD_REQUEST,
                    config: error.config
                })
            }
        )
        if (method === 'get') {
            axiosConfig.params = data
        } else {
            axiosConfig.data = data
        }
        if (config) {
            axiosConfig = Object.assign(axiosConfig, config)
        }
        return instance
            .request(axiosConfig)
            .then((res: any) => res || {})
            .catch((err) => {
                logger.error(`转发请求接口=${url},参数为=${JSON.stringify(data)},错误原因=${err.msg || '请求报错了'}; 请求接口状态code=${err.errCode}`)
                if (UnAuthStatus.includes(err.errCode)) {
                    throw new UnauthorizedException({
                        status: err.errCode,
                        message: err.msg || err.stack
                    }, err.errCode)
                } else {
                    throw new BadRequestException({
                        isApi: !url.includes('/user/info'),
                        status: err.errCode,
                        message: err.msg || err.stack
                    }, err.errCode)
                }
            })

    };
}

其他功能处理,可以查看github

2、其他类型项目

其他项目类型处理流程一样,只是针对不同项目时,需要特殊处理。如:

  1. 同一项目多个APP访问:只是根据不同APP设置的UA来判断来源,然后授权不同服务下登录。
  2. 同一个项目多个微信H5授权:这里是配置不同的公众号授权不同的域名,来进行登录授权。

总结

以上就是Nest、Emp结合在项目中应用场景分析,涉及到BFF层和微前端相关逻辑处理;对于后续业务开发可以提高开发效率,结合前端日志采集和Node层日志可以快速排查问题。

文笔有限、才疏学浅,文中如有不正之处,万望告知。