说起写 BFF,很多人的第一反应是用 node 写啊,但要真正落地,就不得不考虑实现成本,比如技术选型、passport 鉴权、接入 nacos、日志管理、服务监控等等,哪个基建做不到位都会影响到服务稳定性。
这至少需要一个小技术团队长期迭代维护,一般中小厂很可能无法单独抽出人力做这个,但 BFF 的收益又是显而易见的。
BFF 也是服务端,因此公司的后端基建我们可以直接拿来用,其成本就是需要前端再学习一门后端语言呗,程序员最拿手不就是学习能力嘛,废话不多说开干!
这里会分几个方面介绍搭建 BFF 服务需要考虑哪些:
- 配置 IDE
- 创建项目
- 实现接口
- 打包构建
- 日志管理
- 服务监控及报警
配置 IDE
第一步先安装 java 环境,这里要注意公司项目所使用 jdk 的版本号不一定是最新版本,要保持一致。
第二步就是下载 IDE 了,我一开始使用 vscode,但是缺少一些插件,后来还是改回了 IDEA → 点击这里下载
使用 IDEA 打开项目后需要设置项目使用的 jdk 的版本
这里推荐一个 java 非常好用的插件:Lombok 详细介绍可以参考这篇文章 → 点击查看
其中用的比较多的是 @Setter
、@Getter
、@Slf4j
// Student.java
// 只需要在你的类或者字段上加上这两个注解,Lombok 就会自动为你生成 `getter` 和 `setter` 方法
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Student {
private String name;
private int age;
}
// Service.java
import lombok.extern.slf4j.Slf4j;
import Student;
// 使用 SLF4J 注解后,可以直接在类中使用 log 记录日志
@Slf4j
public class Service {
public setStudent {
Student student = new Student();
// 使用 getter 方法获取 name 属性
student.getName();
// 使用 setter 方法获取 age 属性
student.setAge(20);
}
public void doSomething() {
// 直接使用 log 记录日志
log.info("This is an information message.");
log.error("This is an error message.");
}
}
创建项目
这里主要介绍下我们项目的目录结构,仅供参考
|-- bff
|-- bff-api 入口层
| |-- xxx.bff.api
| | |-- fallback 接口报错处理
| | |-- request 请求定义
| | |-- response 响应定义
| | |-- feignApi 接口定义
|-- bff-core 逻辑层
| |-- xxx.bff
| | |-- constant 常量
| | |-- controller 接口类实现
| | |-- service 服务层
| | |-- utils 工具
| |-- resources 环境变量配置
|-- logs 日志
|-- pom.xml 包版本配置,类似 package.json
|-- build.sh 打包脚本
|-- Dockerfile docker 配置
其中:
bff-api
中定义 api 的接口路径、入参和响应的数据类型、接口报错后的自定义处理等bff-core
中定义接口具体逻辑,controller
层连接api
和service
,并且可以通过中间件实现登录鉴权、灰度处理等功能
实现接口
(ps: 下面的示例代码仅供参考,落地还是要看实际项目)
1. 定义接口
// feignApi
package bff.api;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "bff-fe-api", url = "默认路径", decode404 = true, fallback = BffFeignApiFallback.class)
@RequestMapping(path = "/bff")
public interface BffFeignApi {
@RequestMapping(method = RequestMethod.GET, path = "/test")
public BaseResponse<ResTest> test(@RequestParam(value = "id") Long id);
}
2. 定义接口返回类型
// response
package bff.api.response;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class ResTest {
private Integer id;
private Long type;
}
3. 定义接口入参类型
package bff.api.request;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class ReqTest {
private Integer id;
}
4. 接口报错逻辑处理
// fallback
package bff.api.fallback;
import bff.api.BffFeignApi;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class BffFeignApiFallback implements BffFeignApi {
private static final int _500 = 500;
@Value("${k8s.serverName:${spring.application.name}}")
private String applicationName;
public BaseResponse<String> test(Long id) {
log.warn("test fallback");
return new BaseResponse<String>(_500, "test Fallback", null);
}
}
5. Controller 层实现 API 接口并调用对应的 Service
// controller
package bff.controller;
import bff.api.BffFeignApi;
import bff.api.response.ResTest;
import bff.service.FeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class FeController implements BffFeignApi {
@Autowired
private FeService feService;
@Override
public BaseResponse<String> test(Long id) {
String str = "Fe" + id;
return new BaseResponse<ResTest>(feService.test(str));
}
}
6. 定义 Service 接口类型
// service
package bff.service;
import bff.api.response.ResTest;
public interface FeService {
ResTest test();
}
7. 实现 Service 接口
// serviceImpl
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import bff.api.response.ResTest;
@Slf4j
@Service
public class FeServiceImpl implements FeService {
public CenterGetCoreInfoRes centerGetCoreInfo() {
ResTest resTest = new ResTest();
resTest.setId(1);
resTest.setType(1);
return resTest;
}
}
RPC 调用
随着后端接口原子化越来越盛行,BFF 在很多场景中被用于聚合后端接口,因此 BFF 并发调用多个后端接口就变成了必要条件,在 node 中可以使用 promise.allSettled
并发调用,但是 Java 语言是没有事件循环机制的,需要使用到多线程实现接口并行调用,这里我使用了 ThreadPoolExecutor
创建一个线程池,通过 submit
方法提交任务,通过 get
方法获取结果数据,统一返回给前端。
这里详细介绍下 ThreadPoolExecutor
的使用方式
new ThreadPoolExecutor(
/*
* corePoolSize: 指定了线程池中的线程数量
* 它的数量决定了添加的任务是开辟新的线程去执行,
* 还是放到workQueue任务队列中去;
*/
10,
/*
* maximumPoolSize: 指定了线程池中的最大线程数量
* 这个参数会根据你使用的workQueue任务队列的类型,
* 决定线程池会开辟的最大线程数量
*/
32,
/*
* keepAliveTime: 当线程池中的空闲线程数量超过corePoolSize时,
* 多余的线程会在多长时间内被销毁
*/
60L,
// unit:keepAliveTime的单位
TimeUnit.SECONDS,
/*
* workQueue:任务队列,被添加到线程池中,但尚未被执行的任务;
* 它一般分为直接提交队列、有界任务队列、无界任务队列、优先任务队列几种;
* ArrayBlockingQueue 是有界任务队列,设置为 1 即队列长度为 1,任务多于 1 就会创建线程
* 如果线程数超过 maximumPoolSize,则会走到拒绝策略中
*/
new ArrayBlockingQueue<>(1, true),
/*
* handler:拒绝策略,当任务太多来不及处理时,如何拒绝任务。
* CallerRunsPolicy 策略:如果线程池的线程数量达到上限,
* 该策略会把任务队列中的任务放在调用者线程当中执行
*/
new ThreadPoolExecutor.CallerRunsPolicy()
)
这里核心需要考虑两个方面:
- 如何确定核心线程数和最大线程数,这个要依据接口实际QPS调用量和机器CPU配置,并不是越高越好,我用了三倍真实QPS调用量进行了压测,设置了 10 个常驻线程 + 32 的最大线程,接口耗时正常,CPU 占有率稳定没有突刺。
- 设置任务队列,我的思路是线程数尽量设置的多一些,尽可能不使用任务队列,因为一旦开启任务队列,并行请求就会降级为串行,用户体验就会下降,这是我不希望看到的。
在开启线程池前,两次 RPC 请求是串行执行的,一个接口响应后才发送下一个请求
可以看到,开启线程池后,有两个线程并行发送了请求。
实际落地效果:接口耗时从 1s 优化到了 300ms。
打包构建、日志管理、服务监控、报警监控
这些和后端的 Java 项目是一样的,就简单说下每个方面的核心思路,能力上和后端项目做拉齐
- 打包构建:这个比较简单,配置一下 Dockerfile 就 ok
- 日志管理:这里区分了
access.log
、rpc.log
、warn.log
、error.log
,格式上进行统一,方便后期做可视化的日志检索平台 - 服务监控:这里主要监控线上机器的运行情况、接口耗时情况等
- 报警监控:报警分成两种,一种是统一报警,比如接口 500、超时等情况;另一种是业务报错,比如调用外部 RPC 失败/超时的情况,都需要进行监控,有问题第一时间能够感知。
总结
搭建一个 BFF 服务本质上也是搭建一个简单的后端服务,麻雀虽小五脏俱全,要从项目、构建、监控等各个基建层着手建设,缺一不可。
最后再想聊下技术选型,正如标题所说「BFF 为啥要用 Java 做?作为一个前端 node 不该是首选嘛?」
我个人认为,这点还是要从公司技术底层的实际情况来看,我们前期也对 node 进行了大量的技术调研。因为技术上无论是 node 还是 Java 都没有技术壁垒,只是我们对 node 方面的基建线上目前还是缺省状态,而 Java 的基建都是现成的可以直接拿来用,因此从日常维护成本和人力投入成本上看,Java 是我们现阶段的最优解。
原文链接:https://juejin.cn/post/7355324594221711400 作者:重威