BFF 架构演进

单体服务

  • 单体服务是指一个独立的应用程序,包含了所有的功能和业务逻辑。这种架构方式在小型应用程序中很常见。
  • 随着应用程序的功能越来越多,代码库也会越来越大,维护起来也会变得更加困难。此外,单体服务的整体复杂度也会增加,这可能导致软件开发周期变长,质量下降,并且系统的扩展性也会受到限制。

单体服务

微服务

  • 为了应对这些问题,许多公司开始使用微服务架构。微服务是指将一个大型应用程序拆分成若干个小型服务,每个服务负责执行特定的任务。这种架构方式可以帮助公司更快地开发和部署新功能,并提高系统的可扩展性和可维护性。
  • 这种方式会有以下问题:
    • 域名开销增加。
    • 各个端有大量的个性化需求:
      • 数据聚合 某些功能可能需要调用多个微服务进行组合。
        • 数据裁剪 后端服务返回的数据可能需要过滤掉一些敏感数据,以保证安全性。
        • 数据适配 后端返回的数据可能需要针对不同端进行数据结构的适配,后端返回XML,但前端需要JSON
        • 数据鉴权 不同的客户端有不同的权限要求。

微服务

BFF

  • BFFBackend for Frontend的缩写,指的是专门为前端应用设计的后端服务。
  • 主要用来为各个端提供代理数据聚合、裁剪、适配和鉴权服务,方便各个端接入后端服务。
  • BFF 可以把前端和微服务进行解耦,各自可以独立演进。

BFF

网关

  • API 网关是一种用于在应用程序和 API 之间提供安全访问的中间层。
  • API 网关还可以用于监控 API 调用,路由请求,以及在请求和响应之间添加附加功能(例如身份验证,缓存,数据转换,压缩、流量控制、限流熔断、防爬虫等)。
  • 网关和 BFF 可能合二为一

网关

集群化

  • 单点服务器可能会存在以下几个问题:
    • 单点故障:单点服务器只有一台,如果这台服务器出现故障,整个系统都会停止工作,这会导致服务中断。
    • 计算能力有限:单点服务器的计算能力是有限的,无法应对大规模的计算需求。
    • 可扩展性差:单点服务器的扩展能力有限,如果想要提升计算能力,就必须改造或者替换现有的服务器。
  • 这些问题可以通过采用服务器集群的方式来解决。服务器集群是指将多台服务器组合在一起,共同完成一项任务。服务器集群可以提高系统的可用性和可扩展性,从而提升系统的整体性能。

集群化

环境搭建

微服务

  • 微服务是一种架构模式,它将单个应用程序划分为小的服务,每个服务都独立运行并且可以使用不同的语言开发。这种架构模式使得应用程序变得更容易开发、维护和扩展。
  • 微服务架构通常会有许多不同的服务,这些服务可能位于不同的机器上,因此需要使用某种通信协议来进行通信。
  • 因为RPC协议比HTTP协议具有更低的延迟和更高的性能,所以用的更多。

RPC

  • RPC(Remote Procedure Call) 是远程过程调用的缩写,是一种通信协议,允许程序在不同的计算机上相互调用远程过程,就像调用本地过程一样

sofa-rpc-node

  • sofa-rpc-node 是基于 Node.js 的一个 RPC 框架,支持多种协议。

Protocol Buffers

  • Protocol Buffers(简称 protobuf)是 Google 开发的一种数据序列化格式,可以将结构化数据序列化成二进制格式,并能够跨语言使用

Zookeeper

  • ZooKeeper 是一个分布式协调服务,提供了一些简单的分布式服务,如配置维护、名字服务、组服务等。它可以用于管理分布式系统中的数据。

缓存

  • 一般会使用多级缓存,本地做一层 LRU 缓存,再做一层远程服务器的 Redis 缓存。这些缓存层的优先级通常是依次递减的,即最快的缓存层位于最顶层,最慢的缓存层位于最底层。越上层的缓存缓存时间一般越短。

RabbitMQ

  • RabbitMQ 是一个开源的消息代理,用于在应用程序之间进行消息传递。它实现了高级消息队列协议(AMQP),并且支持多种消息协议。

    • 消息生产者将消息发送到 RabbitMQ 服务器。
    • RabbitMQ 服务器将消息存储在队列中。
    • 消息消费者从队列中获取消息并进行处理。
    • 当消息消费者处理完消息后 RabbitMQ 服务器将消息删除。

docker 容器安装

docker pull mysql
docker pull redis
docker pull zookeeper
docker pull rabbitmq:management


# mysql 的密码初始化为 123456
docker run -d --hostname localhost -e MYSQL_ROOT_PASSWORD=123456 --name mysql -p 3306:3306 mysql
docker run -d --hostname localhost --name redis -p 6379:6379 redis
docker run -d --hostname localhost --name zookeeper -p 2181:2181 zookeeper
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 docker.io/rabbitmq:management
  • -d 后台进程运行
  • hostname 主机名
  • name 容器名称
  • -p port: port 端口映射 本地端口:容器端口
  • -e 环境变量

执行完上面命令后执行 docker ps 看看我们是否所有都启动成功了。

# 在mysql中创建数据库 bff, 并且创建表 user
create table user (id varchar(64) comment '编号',username varchar(20) comment '用户名',phone varchar(15) comment '手机号码') comment '用户表'

项目初始化

  • 项目目录
├── bff                 用于和前端交互的 bff 胶水层
│   ├── index.js
│   ├── middleware
│   │   ├── cache.js
│   │   ├── mq.js
│   │   └── rpc.js
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── store
│   │   └── index.js
│   └── utils
│       └── log.js
├── user                用户相关的微服务函数
│   ├── client.js
│   ├── index.js
│   ├── package.json
│   └── pnpm-lock.yaml
└── write-logger        写入日志的服务
    ├── index.js
    ├── logger.txt
    ├── package.json
    └── pnpm-lock.yaml

用户微服务

在 user 中再新建一个 index.js 文件,写入如下代码,主要是在函数中连接数据库,然后创建 Zookeeper 注册中心,创建微服务服务器,将其中的函数注册到 Zookeeper 中,用于给外部进行调用,我们这里主要注册两个函数。

  • 创建有用户的函数
  • 获取用户的函数
// user package.json
{
  "name": "user",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "koa-logger": "^3.2.1",
    "koa-router": "^12.0.0",
    "mysql2": "^2.3.3",
    "sofa-rpc-node": "^2.8.0",
    "uuid": "^9.0.0"
  }
}
const {
  // 创建 rpc 服务器
  server: { RpcServer },
  // 创建注册中心,维护服务的注册信息,帮助节点和客户端找到对方
  registry: { ZookeeperRegistry },
} = require('sofa-rpc-node')
const mysql = require('mysql2/promise')
const { v4: uuidv4 } = require('uuid')
const logger = console

let connection

// 创建一个注册中心,用于注册微服务
const registry = new ZookeeperRegistry({
  // 记录日志
  logger,
  // zookeeper 的地址
  address: 'localhost:2181',
})

// 创建 rpc 服务器的实例
// 客户端连接 rpc 服务器的时候可以通过 zookeeper, 也可以直连 rpc 服务器
const server = new RpcServer({
  logger,
  registry,
  // 注意:这里端口要检查是否被占用
  port: 10000,
})

;(async function () {
  connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: '123456',
    database: 'bff',
  })

  // 添加服务接口
  server.addService(
    {
      // 约定格式为域名反转+领域模型的名称
      interfaceName: 'com.xmllein.user',
    },
    {
      async createUser(username) {
        const sql = `INSERT INTO user(id,username,phone) VALUES('${uuidv4()}','${username}',185);`
        const rows = await connection.execute(sql)
        return {
          message: '创建成功',
          data: rows,
          success: true,
        }
      },
      async getUserInfo(username) {
        const sql = `SELECT id,username,phone from user WHERE username='${username}' limit 1`
        const [rows] = await connection.execute(sql)

        return {
          message: '查询成功',
          data: rows[0],
          success: true,
        }
      },
    }
  )

  // 启动 rpc 服务
  await server.start()
  // 把启动好的 rpc 服务器注册到 zookeeper 中
  await server.publish()
  console.log('微服务启动')
})()
  • client.js 用于连接 rpc 服务器,然后调用其中的函数
const {
  client: { RpcClient },
  registry: { ZookeeperRegistry },
} = require('sofa-rpc-node')
// 设置日志记录器
const logger = console
// 创建 Zookeeper 注册中心
const registry = new ZookeeperRegistry({
  logger,
  address: '127.0.0.1:2181',
})
;(async function () {
  // 创建 RPC 客户端
  const client = new RpcClient({ logger, registry })
  // 创建 RPC 服务消费者
  const userConsumer = client.createConsumer({
    // 指定服务接口名称
    interfaceName: 'com.xmllein.user',
  })
  // 等待服务就绪
  await userConsumer.ready()
  // 调用服务方法
  const result = await userConsumer.invoke('createUser', ['test'], {
    responseTimeout: 3000,
  })
  // 输出结果
  console.log(result)
  process.exit(0)
})()

user微服务

日志微服务

// write-logger package.json
{
  "name": "write-logger",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "amqplib": "^0.10.3",
    "fs-extra": "^11.1.0"
  }
}

安装好依赖后,write-logger 目录下创建一个 index.js 文件,写入如下代码。

// 用于连接 RabbitMQ 服务器
const amqp = require('amqplib')

const fs = require('fs-extra')

;(async function () {
  // 连接 MQ 服务器
  const mqClient = await amqp.connect('amqp://localhost')
  // 创建一个通道
  const logger = await mqClient.createChannel()
  // 创建一个名称为 logger 的队列,如果已经存在了,不会重复创建
  await logger.assertQueue('logger')
  // 消费队列里的消息
  logger.consume('logger', async (event) => {
    // 往本地写入日志文件,到时候 bff 端会以 Buffer 二进制的形式发送数据过来
    await fs.appendFile('./logger.txt', event.content.toString() + '\n')
  })
})()

bff 端

// package.json
{
  "name": "bff",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "amqplib": "^0.10.3",
    "fs-extra": "^11.1.0",
    "koa": "^2.14.1",
    "koa-logger": "^3.2.1",
    "koa-router": "^12.0.0",
    "sofa-rpc-node": "^2.8.0"
  }
}
  • rpc 中间件
// bff/middleware/rpc.js
const {
  // 创建 rpc 服务器
  client: { RpcClient },
  // 创建注册中心,维护服务的注册信息,帮助节点和客户端找到对方
  registry: { ZookeeperRegistry },
} = require('sofa-rpc-node')
const logger = console

const rpcMiddleware = (options = {}) => {
  return async function (ctx, next) {
    // 创建一个注册中心,用于注册微服务
    const registry = new ZookeeperRegistry({
      // 记录日志
      logger,
      // zookeeper 的地址
      address: 'localhost:2181',
    })

    const client = new RpcClient({
      logger,
      registry,
    })

    const interfaceNames = options.interfaceNames || []
    const rpcConsumers = {}

    for (let i = 0; i < interfaceNames.length; i++) {
      const interfaceName = interfaceNames[i]

      // 创建 RPC 服务器的消费者,通过消费者调用 rpc 接口
      const consumer = client.createConsumer({ interfaceName })
      // 等待服务就绪
      await consumer.ready()

      rpcConsumers[interfaceName.split('.').pop()] = consumer
    }

    ctx.rpcConsumers = rpcConsumers
    await next()
  }
}

module.exports = rpcMiddleware
  • 缓存中间件
// bff/store/index.js
/*
CacheStore:管理 Store 容器的类,优先使用 LRU 算法调用本地内存缓存,命中不到再去调用 Redis 缓存
MemoryStore:本地内存 Store
RedisStore:远程 Redis 服务器 Store
*/
const LRUCache = require('lru-cache')
const Redis = require('ioredis')

// 一般越上层的缓存过期时间就越短
class CacheStore {
  constructor() {
    this.stores = []
  }

  add(store) {
    this.stores.push(store)
    return this
  }

  async set(key, value) {
    for (const store of this.stores) {
      await store.set(key, value)
    }
  }

  async get(key) {
    for (const store of this.stores) {
      const value = await store.get(key)
      if (value !== undefined) {
        return value
      }
    }
  }
}

class MemoryStore {
  constructor() {
    this.cache = new LRUCache({
      max: 100,
      // 一分钟过期
      ttl: 1000 * 60,
    })
  }

  async set(key, value) {
    this.cache.set(key, value)
  }

  async get(key) {
    return this.cache.get(key)
  }
}

class RedisStore {
  constructor(
    options = {
      host: 'localhost',
      port: '6379',
    }
  ) {
    this.client = new Redis(options)
  }

  async set(key, value) {
    this.client.set(key, JSON.stringify(value))
  }

  async get(key) {
    const val = await this.client.get(key)
    return val ? JSON.parse(val) : undefined
  }
}

module.exports = {
  CacheStore,
  MemoryStore,
  RedisStore,
}
  • bff/middleware/cache.js 中写入缓存中间件的代码。
const { RedisStore, CacheStore, MemoryStore } = require('../store')

const cacheMiddleware = (options) => {
  return async function (ctx, next) {
    // 创建一个缓存实例
    const cacheStore = new CacheStore()

    // 添加一些缓存层
    cacheStore.add(new MemoryStore())
    cacheStore.add(new RedisStore(options))

    ctx.cache = cacheStore
    await next()
  }
}

module.exports = cacheMiddleware
  • RabbitMQ 中间件
//bff/middleware/mq.js 中写入如下代码,用于连接 RabbitMQ 消息队列服务器
// RabbitMQ
const amqp = require('amqplib')

const MQMiddleware = (
  options = {
    url: 'amqp://localhost',
  }
) => {
  return async function (ctx, next) {
    // 连接 MQ 服务器
    const mqClient = await amqp.connect(options.url)
    // 创建一个通道
    const logger = await mqClient.createChannel()
    // 创建一个名称为 logger 的队列,如果已经存在了,不会重复创建
    await logger.assertQueue('logger')
    // 将其挂载到 ctx.channels 中,其它地方就能进行调用了
    ctx.channels = {
      logger,
    }

    await next()
  }
}

module.exports = MQMiddleware
  • log 日志工具
// 在 bff/utils/log 路径下,封装日志记录函数用于发送消息到 RabbitMQ 消息队列供其它服务消费来写入日志

module.exports = log = (ctx, data) => {
  // 把用户信息写入文件进行持久化,通过 Buffer 二进制发送
  ctx.channels.logger.sendToQueue(
    'logger',
    Buffer.from(
      JSON.stringify({
        method: ctx.method,
        path: ctx.path,
        ...data,
      })
    )
  )
}
  • bff/index.js 中写入如下代码,启动 BFF 服务
const Koa = require('koa')
const router = require('koa-router')()
const logger = require('koa-logger')
const cacheMiddleware = require('./middleware/cache')
const MQMiddleware = require('./middleware/mq')
const rpcMiddleware = require('./middleware/rpc')
const log = require('./utils/log')

const app = new Koa()

app.use(logger())
app.use(
  rpcMiddleware({
    interfaceNames: ['com.xmllein.user'],
  })
)
app.use(cacheMiddleware())
app.use(MQMiddleware())

app.use(router.routes()).use(router.allowedMethods())

router.get('/getUserInfo', async (ctx) => {
  const username = ctx.query.username

  const cacheKey = `${ctx.method}-${ctx.path}-${username}`
  let cacheData = await ctx.cache.get(cacheKey)

  // 把用户信息写入文件进行持久化,通过 Buffer 二进制发送
  log(ctx, {
    username,
  })

  if (cacheData) {
    ctx.body = cacheData
    return
  }

  const {
    rpcConsumers: { user },
  } = ctx

  const userInfo = await user.invoke('getUserInfo', [username])

  cacheData = {
    userInfo,
  }

  await ctx.cache.set(cacheKey, cacheData)

  ctx.body = cacheData
})

// 插入一条 user 数据
router.get('/createUser', async (ctx) => {
  const username = ctx.query.username
  log(ctx, {
    username,
  })

  const {
    rpcConsumers: { user },
  } = ctx
  const res = await user.invoke('createUser', [username])

  ctx.body = res
})

app.listen(3000, () => {
  console.log('启动成功')
})

然后我们把上面编写的三个服务通过命令行先跑起来,在各个目录下运行 npm run dev 即可

然后使用 rest-client 插件进行测试,postmane.http 文件如下

@BASE_URL = http://127.0.0.1:3000

### 添加用户
GET {{BASE_URL}}/createUser?username=test1 HTTP/1.1

### 获取用户信息
GET {{BASE_URL}}/getUserInfo?username=test1 HTTP/1.1

rabbitmqmysqlredis

参考资料