写在前面
源码 。
在前面的文章中我们实际上已经完成了优惠券模块微服务化的改造,但是其中还是有比较多可以优化和增强的地方,本文就先来对服务间的通信方式进行优化,具体就是使用openfeign来替换调原来的webclient。下面我们就开始吧!
1:为什么要替换webclient
使用webclient进行服务间调用的方式可能如下:
java
webClientBuilder.build()
// 声明这是一个POST方法
.post()
// 声明服务名称和访问路径
.uri("http://coupon-calculation-serv/calculator/simulate")
// 传递请求参数的封装
.bodyValue(order)
.retrieve()
// 声明请求返回值的封装类型
.bodyToMono(SimulationResponse.class)
// 使用阻塞模式来获取结果
.block()
这段代码有如下的不足:
1:和业务代码耦合,如请求地址,请求方式这些其实和业务是没有任何关系的,不符合指责隔离的原则
2:每个接口调用都需要写类似的重复代码,编码的效率低
针对以上的问题,springcloud给出的解决方案是openfeign ,可以认为openfeign是一种rpc框架允许我们通过好像调用一个本地的方法一样来调用远端的服务。
2:实战改造
2.1:引入openfeign依赖
首先我们需要在coupon-customer-impl的pom中引入openfeign的基础依赖:
<!-- OpenFeign组件 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
2.2:定义服务的service
我们以调用template服务为例来进行改造,因此首先在coupon-customer-impl模块中定义如下的service:
java
@FeignClient(value = "coupon-template-serv-feign", path = "/template")
public interface TemplateService {
// 读取优惠券
@GetMapping("/getTemplate")
CouponTemplateInfo getTemplate(@RequestParam("id") Long id);
// 批量获取
@GetMapping("/getBatch")
Map<Long, CouponTemplateInfo> getTemplateInBatch(@RequestParam("ids") Collection<Long> ids);
}
在注解@FeignClient中定义了要访问的服务名称以及要web接口的基础路径这样就不用重复在方法上配置了
,通过注解@XxxMapping定义的接口的访问路径信息,通过方法的参数来定义入参信息,这样发起服务调用的完整信息就都全了。
2.3:改造接口调用
我们来修改接口/coupon-customer/simulateOrder 来执行试算,当前代码如下:
public SimulationResponse simulateOrderPrice(SimulationOrder order) {
...
return webClientBuilder.build().post()
// .uri("http://coupon-calculation-serv/calculator/simulate")
.uri("http://coupon-calculation-serv-feign/calculator/simulate")
.bodyValue(order)
.retrieve()
.bodyToMono(SimulationResponse.class)
.block();
}
修改为openfeign后如下:
@Autowired
private CalculationService calculationService;
public SimulationResponse simulateOrderPrice(SimulationOrder order) {
List<CouponInfo> couponInfos = Lists.newArrayList();
...
System.out.println("calculate by openfeign...");
return calculationService.simulate(order);
}
最后还需要在main函数上增加注解@EnableFeignClients(basePackages = { "dongshi.daddy" })
来设置需要扫描的openfeign服务接口所在的包路径。具体的大家可自行测试。效果是一样的。
3:openfeign原理分析
实战重要,但原理更重要,所以一起来看一波原理吧!
当我们在main上增加了@EnableFeignClients(basePackages = { "dongshi.daddy" })
注解后,就会扫描指定包路径下标注了@FeignClient
注解的接口,使用jdk的动态代理技术生成动态代理类,之后会将这个生成的动态代理类放到spring容器中,最后注入到需要的类中,这个过程如下:
看到这里不知道你有没有疑问,这个扫描包的过程是怎么开始的,其实秘密藏在@EnableFeignClients注解中,该注解如下:
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
...
}
注意在注解上使用了@Import注解,spring会调用类FeignClientsRegistrar的registerBeanDefinitions方法,如下:
java
org.springframework.cloud.openfeign.FeignClientsRegistrar#registerBeanDefinitions
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
...
// 注册feign客户端(重要!!!)
registerFeignClients(metadata, registry);
}
registerFeignClients方法如下:
java
public void registerFeignClients(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
// 最终存储所有openfeign的接口
LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();
Map<String, Object> attrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName());
final Class<?>[] clients = attrs == null ? null : (Class<?>[]) attrs.get("clients");
if (clients == null || clients.length == 0) {
ClassPathScanningCandidateComponentProvider scanner = getScanner();
...
Set<String> basePackages = getBasePackages(metadata);
for (String basePackage : basePackages) {
candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
}
}
else {
...
}
for (BeanDefinition candidateComponent : candidateComponents) {
if (candidateComponent instanceof AnnotatedBeanDefinition) {
// verify annotated class is an interface
...
// 注册feign客户端
registerFeignClient(registry, annotationMetadata, attributes);
}
}
}
registerFeignClients方法如下:
java
private void registerFeignClient(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata,
Map<String, Object> attributes) {
String className = annotationMetadata.getClassName();
...
FeignClientFactoryBean factoryBean = new FeignClientFactoryBean();
...
BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
...
// 获取基于jdk的动态代理类
return factoryBean.getObject();
});
...
}
factoryBean.getObject方法最终调用到如下方法:
java
feign.ReflectiveFeign#newInstance
public <T> T newInstance(Target<T> target) {
// 解析openfeign方法为MethodHandler,作为方法代理
Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();
for (Method method : target.type().getMethods()) {
...
}
// 封装methodToHandler创建动态代理要使用的InvocationHandler
InvocationHandler handler = factory.create(target, methodToHandler);
// 生成动态代理
T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
new Class<?>[] {target.type()}, handler);
...
// 返回动态代理
return proxy;
}
到这里就成功获取动态代理类了。总结这个过程如下:
1:项目加载:在项目的启动阶段,EnableFeignClients 注解扮演了"启动开关"的角色,它使用 Spring 框架的 Import 注解导入了 FeignClientsRegistrar 类,开始了 OpenFeign 组件的加载过程。
2:扫包:FeignClientsRegistrar 负责 FeignClient 接口的加载,它会在指定的包路径下扫描所有的 FeignClients 类,并构造 FeignClientFactoryBean 对象来解析 FeignClient 接口。
3:解析 FeignClient 注解:FeignClientFactoryBean 有两个重要的功能,一个是解析 FeignClient 接口中的请求路径和降级函数的配置信息;另一个是触发动态代理的构造过程。其中,动态代理构造是由更下一层的 ReflectiveFeign 完成的。
4:构建动态代理对象:ReflectiveFeign 包含了 OpenFeign 动态代理的核心逻辑,它主要负责创建出 FeignClient 接口的动态代理对象。ReflectiveFeign 在这个过程中有两个重要任务,一个是解析 FeignClient 接口上各个方法级别的注解,将其中的远程接口 URL、接口类型(GET、POST 等)、各个请求参数等封装成元数据,并为每一个方法生成一个对应的 MethodHandler 类作为方法级别的代理;另一个重要任务是将这些 MethodHandler 方法代理做进一步封装,通过 Java 标准的动态代理协议,构建一个实现了 InvocationHandler 接口的动态代理对象,并将这个动态代理对象绑定到 FeignClient 接口上。这样一来,所有发生在 FeignClient 接口上的调用,最终都会由它背后的动态代理对象来承接。
最后上述流程中解析接口中方法和注解信息为MethodHandler的过程在如下方法中完成:
java
// org.springframework.cloud.openfeign.support.SpringMvcContract#processAnnotationOnMethod
// 解析FeignClient接口方法级别上的RequestMapping注解
protected void processAnnotationOnMethod(MethodMetadata data, Annotation methodAnnotation, Method method) {
// 省略部分代码...
// 如果方法上没有使用RequestMapping注解,则不进行解析
// 其实GetMapping、PostMapping等注解都属于RequestMapping注解
if (!RequestMapping.class.isInstance(methodAnnotation)
&& !methodAnnotation.annotationType().isAnnotationPresent(RequestMapping.class)) {
return;
}
// 获取RequestMapping注解实例
RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class);
// 解析Http Method定义,即注解中的GET、POST、PUT、DELETE方法类型
RequestMethod[] methods = methodMapping.method();
// 如果没有定义methods属性则默认当前方法是个GET方法
if (methods.length == 0) {
methods = new RequestMethod[] { RequestMethod.GET };
}
checkOne(method, methods, "method");
data.template().method(Request.HttpMethod.valueOf(methods[0].name()));
// 解析Path属性,即方法上写明的请求路径
checkAtMostOne(method, methodMapping.value(), "value");
if (methodMapping.value().length > 0) {
String pathValue = emptyToNull(methodMapping.value()[0]);
if (pathValue != null) {
pathValue = resolve(pathValue);
// 如果path没有以斜杠开头,则补上/
if (!pathValue.startsWith("/") && !data.template().path().endsWith("/")) {
pathValue = "/" + pathValue;
}
data.template().uri(pathValue, true);
if (data.template().decodeSlash() != decodeSlash) {
data.template().decodeSlash(decodeSlash);
}
}
}
// 解析RequestMapping中定义的produces属性
parseProduces(data, method, methodMapping);
// 解析RequestMapping中定义的consumer属性
parseConsumes(data, method, methodMapping);
// 解析RequestMapping中定义的headers属性
parseHeaders(data, method, methodMapping);
data.indexToExpander(new LinkedHashMap<>());
}
4:openfeign的高级用法
本部分看下openfeign的高级用法,包括,请求日志打印(类似于chrome的network抓包)
,超时判定,服务降级 。
4.1:日志打印
在custom模块配置日志级别为debug:
logging:
level:
com.broadview.coupon: debug
dongshi.daddy.feign.rpc.CalculationService: debug
dongshi.daddy.feign.rpc.TemplateService: debug
在custom的configuration类中配置日志输出的详细程度:
@Bean
Logger.Level feignLogger() {
return Logger.Level.FULL;
}
这里配置为FULL,即打印最详细的信息,比较全的定义如下:
java
// feign.Logger.Level
/**
* Controls the level of logging.
*/
public enum Level {
/**
* No logging. 啥也不打印,也是一种特殊的打印,没毛病
*/
NONE,
/**
* Log only the request method and URL and the response status code and execution time. 打印请求方法,url,响应状态码,执行时间等
*/
BASIC,
/**
* Log the basic information along with request and response headers. 在BASIC的基础上增加头信息打印
*/
HEADERS,
/**
* Log the headers, body, and metadata for both requests and responses. 在HEADERS的基础上增加body打印,一般这种,因为post参数都是在body中的,而我们排查问题一般都要看参数的
*/
FULL
}
接着测试:
http://localhost:20001/coupon-customer/simulateOrder
{
"products": [
{
"price": 3000,
"count": 2,
"shopId": 3
},
{
"price": 1000,
"count": 10,
"shopId": 1
}
],
"couponIDs": [
10
],
"userId": 1
}
日志打印:
2024-01-02 10:50:57.255 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] ---> POST http://coupon-calculation-serv-feign/calculator/simulate HTTP/1.1
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] Content-Length: 456
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] Content-Type: application/json
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate]
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] {"products":[{"productId":null,"price":3000,"count":2,"shopId":3},{"productId":null,"price":1000,"count":10,"shopId":1}],"couponIDs":[10],"couponInfos":[{"id":10,"templateId":2,"userId":1,"shopId":null,"status":1,"template":{"id":2,"name":"晚间双倍立减券","desc":"满50随机立减最多5元,晚间减10元","type":"4","shopId":1000,"rule":{"discount":{"quota":500,"threshold":5000},"limitation":10,"deadline":null},"available":true}}],"userId":1}
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] ---> END HTTP (456-byte body)
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] <--- HTTP/1.1 200 (346ms)
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] connection: keep-alive
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] content-type: application/json
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] date: Tue, 02 Jan 2024 02:50:57 GMT
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] keep-alive: timeout=60
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] transfer-encoding: chunked
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate]
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] {"bestCouponId":10,"couponToOrderPrice":{"10":16000}}
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService : [CalculationService#simulate] <--- END HTTP (53-byte body)
4.2:超时判定
配置超过一定请求的等待时长后就按照超时处理。首先在custom模块中配置超时配置:
feign:
client:
config:
# 全局超时配置
default:
# 网络连接阶段1秒超时
connectTimeout: 1000
# 服务请求响应阶段5秒超时
readTimeout: 5000
# 针对某个特定服务的超时配置
coupon-template-serv-feign:
connectTimeout: 1000
readTimeout: 2000
注意单位是毫秒。
为了模拟超时,我们在template模块的接口dongshi.daddy.feign.controller.CouponTemplateController#getTemplate
中手动增加一个sleep:
java
// 读取优惠券
@GetMapping("/getTemplate")
public CouponTemplateInfo getTemplate(@RequestParam("id") Long id){
log.info("Load template, id={}", id);
try {
// 休眠二十秒模拟超时
TimeUnit.SECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
return couponTemplateService.loadTemplateInfo(id);
}
接着测试:
http://localhost:20001/coupon-customer/requestCoupon
{
"userId": 1,
"couponTemplateId": 2
}
{
"timestamp": "2024-01-02T03:22:48.683+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "Read timed out executing GET http://coupon-template-serv-feign/template/getTemplate?id=2",
"path": "/coupon-customer/requestCoupon"
}
4.3:降级
场景,超时后执行降级逻辑。首先在custom模块加入依赖:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<version>2.2.10.RELEASE</version>
<exclusions>
<!-- 移除Ribbon负载均衡器,避免冲突 -->
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
接着开启降级:
feign:
...
circuitbreaker:
enabled: true
实现远程调用服务接口定义降级类:
java
@Slf4j
@Component
public class TemplateServiceFallback implements TemplateService {
@Override
public CouponTemplateInfo getTemplate(Long id) {
log.info("fallback getTemplate");
return null;
}
@Override
public Map<Long, CouponTemplateInfo> getTemplateInBatch(Collection<Long> ids) {
log.info("fallback getTemplateInBatch");
return null;
}
}
配置降级类:
@FeignClient(value = "coupon-template-serv-feign", path = "/template",
fallback = TemplateServiceFallback.class)
// fallbackFactory = TemplateServiceFallbackFactory.class)
public interface TemplateService {
// 读取优惠券
@GetMapping("/getTemplate")
CouponTemplateInfo getTemplate(@RequestParam("id") Long id);
// 批量获取
@GetMapping("/getBatch")
Map<Long, CouponTemplateInfo> getTemplateInBatch(@RequestParam("ids") Collection<Long> ids);
}
测试:
http://localhost:20001/coupon-customer/requestCoupon
{
"userId": 1,
"couponTemplateId": 2
}
{
"timestamp": "2024-01-02T05:56:50.772+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "Invalid template id",
"path": "/coupon-customer/requestCoupon"
}
降级类还可以使用降级工厂,定义如下:
java
@Slf4j
@Component
public class TemplateServiceFallbackFactory implements FallbackFactory<TemplateService> {
@Override
public TemplateService create(Throwable cause) {
// 使用这种方法你可以捕捉到具体的异常cause
return new TemplateService() {
@Override
public CouponTemplateInfo getTemplate(Long id) {
log.info("fallback factory method test, cause: " + cause.getMessage());
return null;
}
@Override
public Map<Long, CouponTemplateInfo> getTemplateInBatch(Collection<Long> ids) {
log.info("fallback factory method test");
return Maps.newHashMap();
}
};
}
}
接着配置降级工厂:
@FeignClient(value = "coupon-template-serv-feign", path = "/template",
// fallback = TemplateServiceFallback.class)
fallbackFactory = TemplateServiceFallbackFactory.class)
public interface TemplateService {
...
}
测试:
http://localhost:20001/coupon-customer/requestCoupon
{
"userId": 1,
"couponTemplateId": 2
}
{
"timestamp": "2024-01-02T06:04:37.660+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "Invalid template id",
"path": "/coupon-customer/requestCoupon"
}