今天和大家分享一下自己工作中基于Nest+Emp微前端框架实现一些业务场景和思路。本文需要了解的知识有Nest、Webpack5 Module Federation(模块联邦)。
模块联邦
前端的痛点
1、多项目抽离公共组件更新,需要修改引用组件进行升级,如果存在多个依赖方,这种“发布-> 通知-> 更改”模式无疑是低效率的
2、编译速度减慢,业务多,模块多,影响到编译速度问题的因素有很多种,不仅仅是模块多,还有三方的模块依赖等
这种情况下会导致开发效率低下、维护成本高、新同事入手困难等一些列的问题
Module Federation(模块联邦)
Module Federation是Webpack 5 的新特性之一,可以解决多个webpack编译下共享模块、依赖、页面、组件甚至应用。
以下是特性有:
- 每个微应用独立部署运行: 并通过CDN的方式引入主程序中,因此只需要部署一次,便可以提供给任何基于
Module Federation
的应用使用 - 动态更新微应用: 通用CDN的加载其他微应用,每个微应用中的代码变化、无需重新打包发布就能直接加载最新的微应用。
- 去中心化: 每个微应用间都可以引入其他的微应用,无中心应用的概念
- 按需加载: 开发者可以选择只加载微应用中需要的部分,而不是强制只能将整个应用全部加载
- 应用件通信每一个应用都可以进行状态共享等等
ModuleFederationPlugin 提供了相关配置属性:
字段名 | 类型 | 含义 |
---|---|---|
name | string | 必传值,即输出的模块名,被远程引用时路径为${name}/${expose} |
library | object | 声明全局变量的方式,name为umd的name |
filename | string | 构建输出的文件名 |
remotes | object | 远程引用的应用名及其别名的映射,使用时以key值作为name |
exposes | object | 被远程引用时可暴露的资源路径及其别名 |
shared | object | 与其他应用之间可以共享的第三方依赖,使你的代码中不用重复加载同一份依赖 |
接下来就讲讲在项目中基于Nest + Emp框架在实际项目中使用场景分析和处理。
1、相同用户体系多端支持授权访问
首先用一张图来分析同一用户下多个子字应用间的流程。
以上是BFF的核心架构图,前端是基于EMP微前端框架处理多个子应用,BFF即使用Nest搭建的服务,后端即后端服务。现在对整个流程进行分析
前端
- 首先使Nest和Emp融合在一个项目中,需要修改相关tsconfig文件,使其互不影响。
- 基于Emp框架搭建的前端,通过修改Emp配置devMiddleware中serverSideRender:true并且指定编译输出文件目录,使本地编译后的文件存储在指定的目录下,Nest服务通过模版引擎渲染本地文件,这样整个流程就打通。此时的Emp只能作为Host。
- 宿主Host中引用其他Remote应用。宿主Host作为载体为其他子应用。如图2
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。所以首先需要配置模版引擎。
通过不能路由渲染不同的页面,并且返回参数注入到window下。
2、CORS中间件: 是一种允许从另外一个域请求资源的机制。在这里处理运行白名单域名请求,同时起到安全作用
3、Origin中间件: 用于验证访问来源是否合法。
4、Session:主要是用于不同请求间用户登录信息存取,这里使用了Redis作为Session信息存储库,这样有利于在多进程中共享登录状态信息。
针对每个用户登录都会产生一个token,并保存到session中,把生成的token和后端返回的userId映射到cookie,用于用户下次访问时,进行身份验证。同时用户每次访问都会更新session回话时间。
5、 API和路由守卫: 它是根据用户访问页面或者API是来确定用户本身权限、角色来判断用户是否有权限访问。下图是API的守卫逻辑,同时新增白名单API,方便不需要授权的接口也能请求。(路由守卫也是同样的处理逻辑)
6、多端授权登录: 同一个应用可能会在多端中访问,此时存在用户权限访问问题。为了解决这个问题,可以在Node层处理每个端登录访问问题。以下都是在同一用户体系下。同时根据APP注入UA的相关信息判断来源。
APP端: APP怎么样传递用户信息到H5端?个人觉得有两种比较好的方案。
-
如果APP是根据Token 来验证用户是否登录,可以在访问H5时,把Token映射到Cookie中,Node层会通过token去验证登录状态。此处Token存储的位置在和Node中Session回话使用的Redis存在同一个库中,如果Cookie注入H5无法拿到就注入到UA中。这种方式就不需要单独中间件处理。
-
如果APP不是根据Token验证,那就把用户登录的账号和密码注入到UA中,密码可以通过加密处理,Node层解密来进行登录,登录成功后会把生成的Token映射到Cookie中,方便后续H5页面跳转时,登录状态一直存在。(图下)
微信H5端:微信授权有两种情况:静默授权和用户手动点击授权。根据业务不同来选择微信授权方式。以下是用户手动点击触发授权代码
如果业务中同时存在这两种方式,可以设置中间件根据路由来进行处理授权方式。
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(WechatMiddleware).forRoutes({ path: 'cats'});
}
}
微信授权整个流程:
- 引导用户进入授权页面同意授权,获取code
- 通过 code 换取网页授权access_token(与基础支持中的access_token不同)
- 如果需要,开发者可以刷新网页授权access_token,避免过期
- 通过网页授权access_token和 openid 获取用户基本信息(支持 UnionID 机制)
- 获取用户信息后传递给后端处理返回相关信息后保存到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。
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、其他类型项目
其他项目类型处理流程一样,只是针对不同项目时,需要特殊处理。如:
- 同一项目多个APP访问:只是根据不同APP设置的UA来判断来源,然后授权不同服务下登录。
- 同一个项目多个微信H5授权:这里是配置不同的公众号授权不同的域名,来进行登录授权。
总结
以上就是Nest、Emp结合在项目中应用场景分析,涉及到BFF层和微前端相关逻辑处理;对于后续业务开发可以提高开发效率,结合前端日志采集和Node层日志可以快速排查问题。
文笔有限、才疏学浅,文中如有不正之处,万望告知。