平平无奇的BFF实践记录

1,521 阅读8分钟

什么是BFF?为什么需要用BFF?什么情况下才适合使用BFF?看到标题之后想必灵魂三问会直上心头,下面笔者稍微记录一下前段时间的BFF踩坑实践,顺便聊一下这三个问题。

BFF的那些事

BFF --> Backends For Frontends,直译是给前端的后端(好像是一句废话?)与传统开发模式不同的是,这里的Backends一般也是由前端来负责。

BFF其实也不是什么新鲜玩意了,最早甚至能追溯到2015年,只是近期领域设计、微服务的风行,让BFF也有了发挥的空间。

为什么说领域设计、微服务能和BFF扯上关系呢?笔者简单画了一张图。

BFF.drawio.png

我们来看图说话,先看看图片的上半部分微服务本是应对复杂服务的分拆产物,对分拆目的以及边界并没有过多考虑。后续因领域设计的介入,微服务间的边界变得更加明确,一个巨型/复杂的服务可能会被分拆到多个域,变成域内的微服务。

这样一来原本服务间的交互也就变成了域之间的交互了,这可就没有之前微服务间互相调用那么简单,设计复杂点的话可能需要通过事件来跨域通信。所以,我们可以感觉到,域到端之间可能还缺了一些东西,导致我们的数据交互蹑手蹑脚的。

再来看看图片的下半部分,这就是我们常说的客户端,它可能是PC端也可能是移动端H5、小程序甚至是原生APP。它们背后大概率都是同一套后端逻辑,但是它们要求的数据结构可能又各有千秋。

这个时候,我们可以强烈的感觉到,域到端间少了很重要的东西,它可能是一种形态十分像适配器的东西,它可以收拢域间的数据请求、可以鉴别请求来源的载体甚至可以根据不同的载体输出不同的数据集合。

是的,这里缺少的东西就是本文的主角 -- Backends For Frontends,其实我们可以把它理解成是前后端的中间层,这样可能会更容易接受一点。利用BFF我们可以避免域间的复杂交互,精确的拿到每个域我们所需的数据,并能够按照前端需要的数据结构进行过滤,让前端可以拿到数据直接渲染,不用再做额外的数据处理。

接下来,笔者将会介绍BFF在团队中的实践思路。

BFF实践记录

目录总览

Central Topic (1).png

如上图,整个BFF项目的目录大概是这样,src外放的基本是项目工程文件,以及项目的env文件。关于环境变量注入有多种方案,我们可以在项目里面读取文件手动注入,也可以在项目编译时就注入到环境变量中,后者大概率需要运维同事的配合。

接下来我们看看src里面有什么,首先是两个ts文件,app.module/main这两个文件时每个nest项目都会有的,其中app.module主要负责项目模块的初始化,无论是业务模块还是基础模块都会在里面进行初始化,main则是整个项目的入口,全局的配置基本都会在这里进行。

/* main.ts */
import { join } from 'path'
import { NestFactory } from '@nestjs/core'
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as dotenv from 'dotenv'
import * as compression from 'compression'
import * as cookieParser from 'cookie-parser'

import AppModule from './app.module'
import StandardRespInterceptor from './common/interceptor/standardResp'
import ErrorFilter from './common/filter/errorResp'
import { logger } from './core/service/logger.service'

const { ENV = 'dev' } = process.env
const envFileName = `.env.${ENV}`

// 环境变量注入
dotenv.config({
  path: join(__dirname, `../../${envFileName}`)
})

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

  // 设定接口前缀
  app.setGlobalPrefix('bff')

  // 解析cookies
  app.use(cookieParser())

  // 允许跨域访问
  app.enableCors()

  // 设置全局过滤器
  app.useGlobalFilters(new ErrorFilter())

  // 设置全局拦截器
  app.useGlobalInterceptors(new StandardRespInterceptor())

  // 开启请求压缩
  app.use(compression())

  // 开启swagger文档(可选)
  if (process.env.NODE_ENV === 'development') {
    const options = new DocumentBuilder()
      .setTitle('BFF-SERVICE')
      .setDescription('BFF服务接口文档')
      .setVersion('1.0')
      .build();
    const document = SwaggerModule.createDocument(app, options);
    SwaggerModule.setup('api', app, document);
  }

  const PORT = process.env.NODE_ENV === 'development' ? process.env.DEV_PORT : process.env.PORT

  await app.listen(PORT, () => {
    logger.log(`Listening Port ${PORT}`, 'Main')
    logger.log('===========环境变量BEGIN===========', 'Main')
    logger.log(`APP_ENV     ${process.env.APP_ENV}`, 'Main')
    logger.log(`ENV         ${process.env.ENV}`, 'Main')
    logger.log(`SERVER_API  ${process.env.SERVER_API}`, 'Main')
    logger.log(`SERVER_HOST ${process.env.SERVER_HOST}`, 'Main')
    logger.log('===========环境变量END===========', 'Main')
  })
}

bootstrap();

完成main.tsapp.module.ts的编写后我们就已经可以把服务起起来了,成功启动之后控制台会打印这些信息,我们可以通过这些信息来判断预置的配置项是否符合预期。

WX20230315-111526@2x.png

整个接口试试

接下来我们试着写一个简单的接口,来体验一下实际的开发流程。

先创建业务模块,按照前面的目录结构,我们会在src/business中创建order文件夹,表示这里将会存放订单模块的逻辑,然后新建order.module.ts,完成订单模块的创建,记得要在app.module.ts中将模块引入。

/* order.module.ts */
import { Module } from '@nestjs/common';
import { OrderService } from './order.service';
import { OrderController } from './order.controller';

@Module({
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderModule {}
/* order.module.ts */

然后创建接口,笔者一般习惯将接口名和实际函数名保持一致,这样无论是接口使用者还是接口开发者都可以很快的对上号,并且可以根据命名大概理解其作用。

/* order.controller.ts */
import { Controller, Post, Body, Headers } from '@nestjs/common'
import { ApiTags, ApiResponse } from '@nestjs/swagger'
import { OrderService } from './order.service'

@ApiTags('订单相关接口')
@Controller('order')
export class OrderController {
  constructor(private readonly orderService: OrderService) { }
  @Post('getOrderDetail')
  @ApiResponse({
    status: 201,
    description: '获取订单详情',
  })
  async getOrderDetail(
    @Headers() headers: Record<string, string>,
    @Body('orderId') orderId: string,
  ) {
    const res = await this.orderService.getOrderDetail(orderId, headers)
    return res
  }
}
/* order.controller.ts */

如果希望能自动生成接口文档可以引入@nestjs/swagger,在@Controller上添加@ApiTags相当于给整个模块的接口打上标签,因为我们这个是订单模块,所以就写了订单相关接口,然后在每个接口中可以添加@ApiResponse用于记录接口的用途,最终可以生成下面这样的接口文档,供其他同事查看。

WX20230316-104318@2x.png

最后则是接口的具体实现,这里用一个订单详情接口为例。一个完整的订单详情,需要从三个域中获取数据,首先是在订单域中根据订单ID把订单信息拉取回来,然后在订单信息中提取productCodepolicyID,再分别从对应的域获取所需的信息,完成所有信息的获取后就可以按需拼接返回给前端。

其中productInfopolicyInfo接口是可以优化成并发获取,然后再统一处理返回的数据。不过接口少的话其实就没有太大的必要了,效率提升不会特别明显,而且分开来写异常情况也比较好处理。

/* order.service.ts */
import { Injectable, BadRequestException, InternalServerErrorException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InfraService } from '../../core/service/infra.service'

@Injectable()
export class OrderService {
    constructor(
        // 用于获取配置
        private readonly configService: ConfigService,
        // 调用基础方法
        private readonly infraService: InfraService,
    ) { }

    // 接口的具体实现
    public async getOrderDetail(orderId: string, headers: Record<string, string>) {
        if (!orderId) throw new BadRequestException('缺少订单ID')
        // 从订单域中根据订单ID获取指定订单信息
        const [orderInfoErr, orderInfo] = await this.infraService.fetch({
            url: `${this.configService.get<string>('server.Order')}/orderInfo`,
            data: { orderId },
            headers,
        })
        if (orderInfoErr) {
            throw new InternalServerErrorException(orderInfoErr.message)
        }

        // 从产品域中根据产品代码获取指定产品信息
        const { productCode, policyID } = orderInfo
        const [productInfoErr, productInfo] = await this.infraService.fetch({
            url: `${this.configService.get<string>('server.Product')}/productInfo`,
            data: { productCode },
            headers,
        })
        if (productInfoErr) {
            throw new InternalServerErrorException(productInfoErr.message)
        }

        // 从保险域中根据保单号获取指定保单信息
        const [policyInfoErr, policyInfo] = await this.infraService.fetch({
            url: `${this.configService.get<string>('server.Insure')}/policyInfo`,
            data: { policyID },
            headers,
        })
        if (policyInfoErr) {
            throw new InternalServerErrorException(policyInfoErr.message)
        }

        // 完成所有信息的获取后可以按需拼接返回给前端
        return {
            ...orderInfo,
            productInfo,
            policyInfo,
        }
    }
}
/* order.service.ts */

一个业务模块内的接口就已经完成了,前端只需要请求一次getOrderDetail接口就能把页面所需的所有数据都拿到,并且是开箱即用的数据。并且这么做还有一个好处,这是前端团队内部的沟通,我们可以在开发初期就把前端所需的数据结构定义好,不需要再和后端扯字段问题了。

项目拓展

请求方法封装

BFF服务最重要的职责就是接收、发起请求(废话),所以对请求的封装是必备的,Nest内置了Axios的支持,并且提供了可以直接使用的模块,我们需要做的是根据自身请求数据情况进行二次封装,让业务侧调用时更加便捷、明确。

import { lastValueFrom } from 'rxjs'

// HttpService/ConfigService都是全局模块暴露出的服务,所以可以直接引入使用
import { HttpService } from '@nestjs/axios'
import { ConfigService } from '@nestjs/config'

export class InfraService {
    constructor(
        private readonly httpService: HttpService,
        private readonly configService: ConfigService,
    ) { }
    
    // 其他基础方法...
    
    public async fetch(options: AxiosRequestConfig): Promise<[Record<string, string> | null, any]> {
        if (!options) return [{
            code: CODES.FETCH_OPTIONS_ERROR,
            message: ERROR_MESSAGE_MAP[CODES.FETCH_OPTIONS_ERROR]
        }, null];

        const { url, data, headers, method = 'POST' } = options
        /**
         * 忽略请求头中带来的content-length
         * 避免因content-length字节数不对导致的下游服务异常
         */
        delete headers['content-length']

        try {
            const res = await lastValueFrom(this.httpService.request({
                url,
                method,
                data,
                headers,
            }))
            const err = res.data.code === '0' ? null : { ...res.data, errUrl: url }
            const result = res.data || null;
            return [err, result];
        } catch (error) {
            const { status } = error.response
            switch (status) {
                case 401:
                    throw new UnauthorizedException()
                // ......
            }
            return [error.response.data, null];
        }
    }
}

因为infraService是从属于全局模块coreModule的,所以可以在业务模块的任意地方直接调用封装好的请求方法,具体用法如下:

const someFetch = async () => {
    // ...
    const [orderInfoErr, orderInfo] = await this.infraService.fetch({
        url: 'https://api.xxx.com/orderInfo',
        data: { orderId: 'xxxx' },
        headers,
    })
    if (orderInfoErr) {
        throw new InternalServerErrorException(orderInfoErr.message)
    }
    // ...
}

日志方案

作为一个偏后端的应用,日志是必不可少的,在笔者的BFF应用中的日志主要靠这两个文件logger.service.tslogger.middleware.ts

先说一下logger.service.ts,在这里主要改写了nest原有的日志逻辑,将内置日志输出控制台的同时也写入到本地文件中。

其中日志的落地用的是winston,日志翻转则是用winston-daily-rotate-file实现的,一般是按日期进行日志文件命名,文件大小超过100M后会自动进行切割。

// logger.service.ts
import { LoggerService, Logger } from '@nestjs/common'
import * as winston from 'winston'
import * as DailyRotateFile from 'winston-daily-rotate-file'

const transport = new DailyRotateFile({
    dirname: './logs',
    filename: '%DATE%.log',
    datePattern: 'YYYY-MM-DD',
    maxSize: '100m',
});

const winstonLogger = winston.createLogger({
    transports: [
        transport,
    ],
    format: winston.format.combine(
        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
        winston.format.printf(({ timestamp, level, message = '', stack = '', requestID = '', headers = '', body = '' }) => {
            level = level.toUpperCase();
            return JSON.stringify({
                timestamp,
                level,
                message,
                stack,
                requestID,
                headers,
                body,
            });
        }),
    ),
})

class MyLoggerService implements LoggerService {
    log(message: string, context?: string) {
        if (process.env.NODE_ENV === 'development') {
            Logger.log(message, context)
        }
        winstonLogger.info(context ? `${context}: ${message}` : message)
    }
    error(message: any, stack?: string, context?: string, extra?: any) {
        if (process.env.NODE_ENV === 'development') {
            Logger.error(message, stack || '', context || '')
        }
        const { headers, body, requestID } = extra
        winstonLogger.error(message, { stack, requestID, headers, body })
    }
    warn(message: string, context?: string) {
        if (process.env.NODE_ENV === 'development') {
            Logger.warn(message, context)
        }
        winstonLogger.log('warn', message, { context })
    }
}

const logger = new MyLoggerService()

export {
    MyLoggerService,
    logger
}

有了logger.service.ts之后,我们便可以在任意位置将日志写入本地,对于BFF应用而言,最重要的日志应该是请求日志,请求打进来的时候带了什么参数、来源是哪里,请求处理完成之后的输出包含了什么内容......这些对于业务运行时的监测和排错都十分重要,所以就有了下面的日志中间件,用于处理请求输入输出的日志。

// logger.middleware.ts
import { Injectable, NestMiddleware, Req, Res } from '@nestjs/common'
import { Request, Response } from 'express'
import * as bytes from 'bytes'
import { logger } from '../../core/service/logger.service'
import { HEADER_TRACE_ID } from '../constant/index'

@Injectable()
export class LoggerMiddleware implements NestMiddleware<any, any> {
    async use(@Req() req: Request, @Res() res: Response, next: Function) {
        const startTime: number = Date.now()
        const url: string = req.baseUrl
        const requestID: string = req.header(HEADER_TRACE_ID)
        logger.log(`[${requestID}] --> ${url}`, 'LoggerMiddleware')

        try {
            await next()
        } catch (error) {
            logger.error(`[${requestID}] <-- ${res.statusCode} ${url}`, error, 'LoggerMiddleware')
            throw error
        }

        const onFinish = () => {
            const { ip, url, path, headers, body } = req
            const finishTime: number = Date.now()
            const { statusCode } = res
            let length: string
            if (~[204, 205, 304].indexOf(statusCode)) {
                length = ''
            } else {
                const l = Number(res.getHeader('content-length'))
                length = bytes(l) ? bytes(l).toLowerCase() : ''
            }
            logger.log(`[${requestID}] <-- ${res.statusCode} ${url} ${(finishTime - startTime) + 'ms'}`, 'LoggerMiddleware')
        }

        res.once('finish', () => {
            onFinish()
            res.removeListener('finish', onFinish)
        })
    }
}

静态资源上传

因为团队的业务都是部署在腾讯云上的,所以下面会以腾讯云的COS为例。

静态资源的上传是每个前端项目都必不可少的业务之一,该怎么处理静态资源的上传其实在笔者的另一篇文章中有详细介绍,这里主要介绍的是上传临时密钥的生成方法。

腾讯云生成临时密钥官方文档

下面是以官方文档为基础,集成到nest中的服务。因为一个项目也可能会跨桶存储,所以添加了以bucket为维度的缓存策略。

import * as STS from 'qcloud-cos-sts'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { CacheService } from './cache.service'

@Injectable()
export class CosService {
    constructor(
        private readonly configService: ConfigService,
        private readonly cacheService: CacheService,
    ) {}

    public async getCredential(bucket: string = 'yourbucker-000000000') {
        const CACHE_KEY = bucket === 'yourbucker-000000000' ? 'STS-KEYS' : bucket
        const cache = this.cacheService.get(CACHE_KEY)
        if (cache) return cache

        const { secretId, secretKey, region } = this.configService.get('cos')
        const [shortBucketName, appId] = bucket.split('-')
        const allowActions = [
            'name/cos:PutObject',
            'name/cos:PostObject',
        ]
        const policy = {
            version: '2.0',
            statement: [{
                action: allowActions,
                effect: 'allow',
                principal: { 'qcs': ['*'] },
                resource: [
                    `qcs::cos:${region}:uid/${appId}:prefix//${appId}/${shortBucketName}/*`,
                ],
            }],
        }
        return new Promise((resolve, reject) => {
            STS.getCredential({
                secretId,
                secretKey,
                proxy: '',
                durationSeconds: 1800,
                policy,
            }, (err, tempKeys) => {
                if (err) reject(err)
                this.cacheService.set(CACHE_KEY, tempKeys, 1800)
                resolve(tempKeys)
            });
        })
    }
}

开箱即用

上述代码都已经打包成可以直接启动的项目,有需要的同学可以自取Github传送门

小结:我们真的需要BFF吗?

至此,一个可用的BFF服务就完成了。在笔者团队中,BFF服务最后会制成docker镜像,当做微服务来部署并通过Nginx来转发提供给前端调用。当然,也可以由前端来负责运维,通过PM2来进行部署,甚至可以用PM2的cluster模式来进行多实例部署。

最后,我们来讨论一个问题:我们真的需要BFF吗?

先抛出笔者的砖:我们真的不一定需要BFF

首先,大前提当然是后端服务的精细化,如果没有细分应用,我们何必再加一层冗余呢;其次,需要得到团队的支持,这个事情并不是说纯靠前端团队就能完成的,后端、运维的配置都是不可或缺的;再者,需要人力的支持,尽管前面说的天花乱坠,这件事情本身还是会增加前端的开发压力的,我们要确保当前人力能够cover住这种开发模块的变更......当你真的想推行一件事的时候,你会发现遇到的问题远不止上面那几个。

那么,如果我们真的想试试这种开发模式,有什么别的方式么?

有的,云函数。笔者认为它就是一个极简版的BFF服务,从运维角度来讲,云函数的优势甚至大于自研应用。

篇末,笔者多说两句,在团队内推行BFF是需要天时地利人和的,我们前端er不应该为炫技而徒增烦恼。但是,话又说回来,如果能在开发过程中时刻保有BFF这种意识,也不失为一件好事。

本文正在参加「金石计划」