Nest + Emp2 构建BFF层

1,725 阅读5分钟

本文基于Nest+Emp2实现的Node中间层。能够开箱即用的框架。如果用户登录(包含微信扫码登录等)、鉴权、中间件、服务聚合、渲染、Session、Cache等功能,同时具备Emp微前端特性。

什么是BFF层

BFF(Back-end For Front-end)是中间层概念,就是一层nodejs,能做请求转发和数据转化即可。Node既配合了前端技术栈,也更适应向微服务的并发请求。也可以做成对前Restful、对后RPC的实现;还可以在BFF上加Cache、鉴权、中间件、服务聚合、渲染等等,具体可以根据自身需求改造。

image.png

初识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需要移动位置,且文件影响文件要进行排除。 image.png image.png

针对热更新问题,本地开发启动了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 }
    }
}

image.png image.png 页面就可以正常访问了。

接口聚合

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完成后,把扫码的移植过来。代码地址

如有不对地方,请指出。后续继续进行优化迭代。