本文基于Nest+Emp2实现的Node中间层。能够开箱即用的框架。如果用户登录(包含微信扫码登录等)、鉴权、中间件、服务聚合、渲染、Session、Cache等功能,同时具备Emp微前端特性。
什么是BFF层
BFF(Back-end For Front-end)是中间层概念,就是一层nodejs,能做请求转发和数据转化即可。Node既配合了前端技术栈,也更适应向微服务的并发请求。也可以做成对前Restful、对后RPC的实现;还可以在BFF上加Cache、鉴权、中间件、服务聚合、渲染等等,具体可以根据自身需求改造。
初识Nest
Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素在底层,Nest使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。
这里就不过多的介绍Nest。
Emp微前端
是基于下一代构建实现微前端解决方案,结合webpack5、Module Federation的丰富项目实战、建立三层共享模型.Emp2官网
Nest+Emp整合
怎样使两个不同的项目整合在一起运行、怎么实现热更新、怎样在Node层请求数据注入到Window下、本地怎样获取emp编译文件等问题。
构建项目
首先在本地构建nest项目和emp项目
// nest
$ npm i -g @nestjs/cli
$ nest new project-name
// emp
$ npm i -g @efox/emp
$ npm init // 选择构建模块
怎样支持热更新和本地Node访问文件
首先我们来看下emp-config.js配置信息
module.exports = {
webpack() {
return {
devServer: {
port: 8081,
},
}
},
webpackChain(config) {
config.plugin('html').tap(args => {
args[0] = {
...args[0],
template: resolve('./views/index.html'),
...{
title: 'demo',
files: {},
},
}
return args
})
},
moduleFederation: {
name: 'empReact',
filename: 'emp.js',
remotes: {},
exposes: {
'./App': 'src/App',
},
shared: {
react: {eager: true, singleton: true, requiredVersion: '^17.0.1'},
'react-dom': {eager: true, singleton: true, requiredVersion: '^17.0.1'},
'react-router-dom': {requiredVersion: '^5.1.2'},
},
},
}
解决Webpack本地编译热更新文件问题,需要对Webpack的devServer 进行处理。
server: {
port: 8001,
devMiddleware: {
index: true,
mimeTypes: { phtml: 'text/html' },
publicPath: './dist/client',
serverSideRender: true,
writeToDisk: true,
},
},
修改html引用文件的路径,并且修改导出后的路径。
html: {
template: resolve('./views/index.html'),
filename: resolve('./dist/views/index.html'),
title: '马克相机'
},
服务的请求接口注入到Window对象中需要特殊处理。因为针对Webpack注入代码使用的是ejs模版引擎,如果在html写入ejs代码会被Webpack编译时解析。导致后续使用服务渲染时注入失败。解决方法是通过Webpack插件 html-inline-code-plugin 在编译时注入JavaScript代码。
chain.plugin('InlineCodePlugin').use(new InlineCodePlugin({
begin: false,
tag: 'script',
inject: 'body',
code: `window.INIT_DATA = <%- JSON.stringify(data) %>`
}))
此时Emp代码修改的差不多了,然后修改下配置移动到Nest项目下。针对tsconfi.json需要移动位置,且文件影响文件要进行排除。
针对热更新问题,本地开发启动了devServer,通过服务的访问也可以实时编译更新。
Nest渲染页面
Node层渲染页面有许多引擎如ejs、hbs等等,本项目中使用的是ejs模版引擎。
首先配置下在Nest中配置ejs模版引擎渲染页面。
app.useStaticAssets(resolve(__dirname, '../../dist/client'))
app.setBaseViewsDir(join(__dirname, '../../dist/views'));
app.setViewEngine('html');
app.engine('html', ejs.renderFile);
页面路由配置如下:
/**
* 渲染页面
* @param {Request} req
* @return {*}
* @memberof AppController
*/
@Get('login')
@Render('index')
login(@QueryParams('request', new SessionPipe()) req: Request) {
if (req.isLogin) {
// 重定向
return { redirectUrl: '/' }
} else {
return { data: 121212 }
}
}
页面就可以正常访问了。
接口聚合
Nest本身提供了Axios模块。但是在开发工程中发现axios没有做请求拦截和打印日志,所以重写了AxioModel
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
* 没有使用@nest/axios 是因为rxjs版本不一样导致调用接口没有出发请求(再次去看居然更新了rxjs, 先用自己这套吧)
* @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: true,
status: err.errCode,
message: err.msg || err.stack
}, err.errCode)
}
})
};
}
现在有了Http模块了,接下来就要处理接口转发API了。目前值提供了Get、Post方法进行转发。后续可以根据情况实现Restful API
import { QueryParams } from '@app/decorators/params.decorator';
import { Responsor } from '@app/decorators/responsor.decorator';
import { ApiGuard } from '@app/guards/api.guard';
import { HttpRequest } from '@app/interfaces/request.interface';
import { TransformPipe } from '@app/pipes/transform.pipe';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiService } from './api.service';
@Controller('api')
export class ApiConstroller {
constructor(private readonly apiService: ApiService) { }
/**
* Get 接口转发
* @param {HttpRequest} data
* @return {*}
* @memberof ApiConstroller
*/
@UseGuards(ApiGuard)
@Responsor.api()
@Get('transform')
getTransform(@QueryParams('query', new TransformPipe()) data: HttpRequest) {
return this.apiService.get(data)
}
/**
* Post 接口转发
* @param {HttpRequest} data
* @return {*}
* @memberof ApiConstroller
*/
@UseGuards(ApiGuard)
@Responsor.api()
@Post('transform')
postTransform(@Body(new TransformPipe()) data: HttpRequest) {
return this.apiService.post(data)
}
}
Cache缓存
Redis 是为集群应用提供分布式缓存机制而闻名。可以在多个服务共享。
- redis.module.ts 提供全局Cache
import { CacheModule as NestCacheModule, Global, Module } from '@nestjs/common'
import { RedisConfigServer } from './redis.config.server';
import { RedisServer } from './redis.server';
/**
* Redis
* @export
* @class RedisModule
*/
@Global()
@Module({
imports: [
NestCacheModule.registerAsync({
useClass: RedisConfigServer,
inject: [RedisConfigServer]
})
],
providers: [RedisConfigServer, RedisServer],
exports: [RedisServer]
})
export class RedisModule { }
- redis.config.server.ts redis 配置
import { REDIS } from '@app/config'
import logger from '@app/utils/logger'
import { CacheModuleOptions, CacheOptionsFactory, Injectable } from '@nestjs/common'
import redisStore, { RedisStoreOptions } from './redis.store'
@Injectable()
export class RedisConfigServer implements CacheOptionsFactory {
// 重试策略
private retryStrategy(retries: number): number | Error {
const errorMessage = ['[Redis]', `retryStrategy!retries: ${retries}`]
logger.error(...(errorMessage as [any]))
if (retries > 6) {
return new Error('[Redis] 尝试次数已达极限!')
}
return Math.min(retries * 1000, 3000)
}
public createCacheOptions(): CacheModuleOptions<Record<string, any>> | Promise<CacheModuleOptions<Record<string, any>>> {
const redisOptions: RedisStoreOptions = {
host: REDIS.host as string,
port: REDIS.port as number,
retry_strategy: this.retryStrategy.bind(this),
}
if (REDIS.password) {
redisOptions.password = REDIS.password
}
return {
isGlobal: true,
store: redisStore,
redisOptions,
}
}
}
- redis.store.ts 连接redis
import { createClient, ClientOpts } from 'redis'
import { CacheStoreFactory, CacheStoreSetOptions, CacheModuleOptions } from '@nestjs/common'
export type RedisStoreOptions = ClientOpts
export type RedisCacheStore = ReturnType<typeof createRedisStore>
export interface CacheStoreOptions extends CacheModuleOptions {
redisOptions: RedisStoreOptions
}
const createRedisStore = (options: CacheStoreOptions) => {
const client = createClient(options.redisOptions) as any
const set = async <T>(key: string, value: T, options: CacheStoreSetOptions<T> = {}): Promise<void> => {
const { ttl } = options
const _value = value ? JSON.stringify(value) : ''
if (ttl) {
const _ttl = typeof ttl === 'function' ? ttl(value) : ttl
await client.setEx(key, _ttl, _value)
} else {
await client.set(key, _value)
}
}
const get = async <T>(key: string): Promise<T> => {
const value = await client.get(key)
return value ? JSON.parse(value) : value
}
const del = async (key: string) => {
await client.del(key)
}
return { set, get, del, client }
}
const redisStoreFactory: CacheStoreFactory = {
create: createRedisStore,
}
export default redisStoreFactory
- redis.server.ts 提供服务
import { CACHE_MANAGER, Inject, Injectable } from "@nestjs/common";
import { RedisCacheStore } from "./redis.store";
import { Cache } from 'cache-manager'
import logger from "@app/utils/logger";
@Injectable()
export class RedisServer {
public cacheStore!: RedisCacheStore
private isReadied = false
constructor(@Inject(CACHE_MANAGER) cacheManager: Cache) {
this.cacheStore = cacheManager.store as RedisCacheStore
this.cacheStore.client.on('connect', () => {
logger.info('[Redis]', 'connecting...')
})
this.cacheStore.client.on('reconnecting', () => {
logger.warn('[Redis]', 'reconnecting...')
})
this.cacheStore.client.on('ready', () => {
this.isReadied = true
logger.info('[Redis]', 'readied!')
})
this.cacheStore.client.on('end', () => {
this.isReadied = false
logger.error('[Redis]', 'Client End!')
})
this.cacheStore.client.on('error', (error) => {
this.isReadied = false
logger.error('[Redis]', `Client Error!`, error.message)
})
}
}
Session和守卫
Session是用存储在Redis中,这用可以在多台服务下共享Session。如果是Redis4版本配置有所不同。
import { Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
// redis and session
import { RedisModule } from '@app/processors/redis/redis.module';
import { RedisServer } from '@app/processors/redis/redis.server';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { SESSION } from '@app/config';
import RedisStore from 'connect-redis';
import session from 'express-session';
// middlewares
import { CorsMiddleware } from '@app/middlewares/core.middleware';
import { OriginMiddleware } from '@app/middlewares/origin.middleware';
@Module({
imports: [
RedisModule,
],
controllers: [AppController],
providers: [AppService, Logger],
})
export class AppModule implements NestModule {
private redis: any
constructor(private readonly redisStore: RedisServer) {
this.redis = this.redisStore.cacheStore.client
}
configure(consumer: MiddlewareConsumer) {
consumer
.apply(
CorsMiddleware,
OriginMiddleware,
session({
store: new (RedisStore(session))({ client: this.redis }),
...SESSION
}),
)
.forRoutes('*');
}
}
这里已经配置好了Session,可以通过打印request.session查看是否配置成功
守卫
API和路由守卫,同时需要开启白名单针对不需要验证的接口和路由进行放行。这里使用了passport
是目前最流行的 node.js
认证库,进行身份验证。
- auth.module.ts
import { Module } from "@nestjs/common";
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt'
import jwt from 'jsonwebtoken'
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { AUTH } from "@app/config";
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
privateKey: AUTH.jwtTokenSecret as jwt.Secret,
signOptions: {
expiresIn: AUTH.expiresIn as number,
},
})
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService]
})
export class AuthModule { }
- auth.controller.ts 提供了登录接口,和接口转发有所区别。
import { HttpRequest } from "@app/interfaces/request.interface";
import { TransformPipe } from "@app/pipes/transform.pipe";
import { Body, Controller, Get, Param, Post, Req, Res } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { Request, Response } from 'express'
import { ResponseStatus } from "@app/interfaces/response.interface";
import { Responsor } from "@app/decorators/responsor.decorator";
@Controller('api')
export class AuthController {
constructor(private readonly authService: AuthService) { }
/**
* 登录接口
* @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 {string} id
* @return {*}
* @memberof AuthController
*/
@Get('user')
@Responsor.api()
public async getUserInfo(@Param('id') id: string) {
return await this.authService.findById({ id })
}
}
- auth.service.ts
import { Injectable } from "@nestjs/common";
import { HttpRequest } from "@app/interfaces/request.interface";
import { AxiosService } from "@app/processors/axios/axios.service";
import { AUTH, config } from "@app/config";
import { JwtService } from '@nestjs/jwt'
@Injectable()
export class AuthService {
constructor(private readonly axiosService: AxiosService, private readonly jwtService: JwtService) { }
/**
* 生成token
* @param {*} data
* @return {*}
* @memberof AuthService
*/
creatToken(data: any) {
const token = {
access_token: this.jwtService.sign({ data }),
expires_in: AUTH.expiresIn as number,
}
return token
}
/**
* 验证用户
* @param {*} { id }
* @return {*}
* @memberof AuthService
*/
public async validateUser({ id, username }: any) {
// 获取用户
const user = await this.findById(id);
return user
}
/**
* 登录
* @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 }
}
/**
* 根据ID查询用户
* @param {*} id
* @return {*} {Promise<any>}
* @memberof AuthService
*/
public async findById(id): Promise<any> {
const url = config.apiPrefix.baseApi + '/user/info'
const res = await this.axiosService.get(url, { params: { id: id } })
return res
}
}
通过要求在请求时提供有效的 JWT
来保护端点。护照对我们也有帮助。它提供了用于用 JSON Web
标记保护 RESTful
端点的 passport-jwt
策略。在 auth
文件夹中 jwt.strategy.ts
。这里的token是通过Cookie
上获取验证
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) => {
return req.cookies['jwt']
},
secretOrKey: AUTH.jwtTokenSecret
})
}
async validate(payload: any) {
const res = await this.authService.validateUser(payload);
return res
}
}
分别对API和路由进行守卫。
import { ExecutionContext, Injectable } from "@nestjs/common";
import { LoggedInGuard } from "./logged-in.guard";
import { HttpUnauthorizedError } from "@app/errors/unauthorized.error";
import { Request } from 'express'
import { ApiWhiteList } from "@app/constants/api.contant";
@Injectable()
export class ApiGuard extends LoggedInGuard {
private apiUrl: string
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest<Request>()
this.apiUrl = req.body.transformUrl || req.query.transformUrl
return super.canActivate(context)
}
handleRequest(error, authInfo, errInfo) {
const validToken = Boolean(authInfo)
const emptyToken = !authInfo && errInfo?.message === 'No auth token'
if ((!error && (validToken || emptyToken)) || ApiWhiteList.includes(this.apiUrl)) {
return authInfo || {}
} else {
throw error || new HttpUnauthorizedError()
}
}
}
import { ExecutionContext, Injectable } from "@nestjs/common";
import { LoggedInGuard } from "./logged-in.guard";
import { HttpUnauthorizedError } from "@app/errors/unauthorized.error";
import { Request } from 'express'
import { RouterWhiteList } from "@app/constants/router.constant";
@Injectable()
export class RouterGuard extends LoggedInGuard {
private routeUrl: string
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest<Request>()
this.routeUrl = req.url
return super.canActivate(context)
}
handleRequest(error, authInfo, errInfo) {
if ((authInfo && !error && !errInfo) || RouterWhiteList.includes(this.routeUrl)) {
return authInfo
} else {
throw error || new HttpUnauthorizedError(errInfo?.message)
}
}
}
使用方式如下:
/**
* Post 接口转发 API守卫
* @param {HttpRequest} data
* @return {*}
* @memberof ApiConstroller
*/
@UseGuards(ApiGuard)
@Responsor.api()
@Post('transform')
postTransform(@Body(new TransformPipe()) data: HttpRequest) {
return this.apiService.post(data)
}
/**
* 渲染页面 路由守卫
* @param {Request} req
* @return {*}
* @memberof AppController
*/
@UseGuards(RouterGuard)
@Get()
@Render('index')
getTest(@Req() req: Request) {
return { data: 12 }
}
对接口和路由进行拦截
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Response, Request } from 'express'
import { HttpResponseSuccess, ResponseStatus } from '@app/interfaces/response.interface';
import { getResponsorOptions } from '@app/decorators/responsor.decorator';
/**
* 拦截
* @export
* @class TransformInterceptor
* @implements {NestInterceptor<T, HttpResponse<T>>}
* @template T
*/
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, T | HttpResponseSuccess<T>>
{
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<T | HttpResponseSuccess<T>> | any {
const req = context.switchToHttp().getRequest<Request>();
const res = context.switchToHttp().getResponse<Response>()
const target = context.getHandler()
const { isApi } = getResponsorOptions(target)
// 即时刷新session过期时间
req.session.touch();
if (!isApi) {
res.contentType('html')
}
return next.handle()
.pipe(
map((data: any) => {
if (data.redirectUrl) return res.status(301).redirect(data.redirectUrl)
const result = isApi ? {
status: ResponseStatus.Success,
message: '请求成功',
result: data,
} : ({ data })
return result
})
);
}
}
项目运行和注意
项目运行提供下:
- 需要提供redis服务
- 服务端请求接口
- 登录接口和获取用户接口信息接口,必须返回userId。 本地运行:
访问Node层IP地址
$ yarn start:dev
$ yarn dev
最后
目前还没完成微信扫码登录授权,后续h5完成后,把扫码的移植过来。代码地址。
如有不对地方,请指出。后续继续进行优化迭代。