Feign吃掉了Exception Message
文章目录
0. 前言
最近开始使用Feign框架代替Resttemplate,Feign框架初衷是用于给Spring Cloud通讯使用的,虽然项目不是微服务,但我们也慢慢转为使用Feign,好处就是简洁方便,省去了很多的封装。
在开发中遇到一个问题,请求外部一个接口,对方服务抛出异常信息(json string),笔者捕获后将异常信息返回给前端提示用户,但却走到了兜底的异常信息处理逻辑,排查后发现是服务返回的 异常信息被截断 了,导致JSON格式化异常,没有正常解析异常信息。
1. 模拟
1.1 服务端
写一个简单的服务接口吧,如果入参是exception,抛出一个异常。
import cn.horizon.nile.dto.CommonResponseDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Copyright: Horizon
*
* @ClassName SimpleController
* @Description controller
* @Author Nile (Email:576109623@qq.com)
* @Date 16:50 2024-7-20
* @Version 1.0.0
*/
@Slf4j
@RequestMapping("/api/v1/simple")
@RestController
public class SimpleController {
/**
* 检索
* @author Nile (Email:576109623@qq.com)
* @date 17:11 2024-7-20
* @param keyword 关键词
* @return 结果
*/
@GetMapping("/search")
public CommonResponseDTO<Void> search(@RequestParam("keyword") String keyword) {
log.info(keyword);
// 如果入参是exception,抛出一个异常
if ("exception".equals(keyword)) {
throw new RuntimeException("Spring Cloud OpenFeign是一种基于Spring Cloud的声明式REST客户端,它简化了与HTTP服务交互的过程。它将REST客户端的定义转化为Java接口,并且可以通过注解的方式来声明请求参数、请求方式、请求头等信息,从而使得客户端的使用更加方便和简洁。同时,它还提供了负载均衡和服务发现等功能,可以与Eureka、Consul等注册中心集成使用。Spring Cloud OpenFeign能够提高应用程序的可靠性、可扩展性和可维护性,是构建微服务架构的重要工具之一。");
}
// 返回个成功码
return new CommonResponseDTO<>("SUC000");
}
}
响应体
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Copyright: Horizon
*
* @ClassName CommonResponseDTO
* @Description 响应体
* @Author Nile (Email:576109623@qq.com)
* @Date 0:12 2024-7-23
* @Version 1.0.0
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResponseDTO<T> {
/**
* 响应码
*/
private String code;
/**
* 提示信息
*/
private String message;
/**
* 数据
*/
private T data;
public CommonResponseDTO(String code) {
this.code = code;
}
public CommonResponseDTO(String code, String message) {
this.code = code;
this.message = message;
}
}
全局异常处理器,这里HTTP响应码返回500,发现如果异常情况下响应码是2XX,Feign其实是不会当做error处理的。
import cn.horizon.nile.dto.CommonResponseDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Copyright: Horizon
*
* @ClassName GlobalExceptionHandler
* @Description 全局异常处理器
*
* @Author Nile (Email:576109623@qq.com)
* @Date 0:06 2024-7-23
* @Version 1.0.0
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 异常处理
* @author Nile (Email:576109623@qq.com)
* @date 0:14 2024-7-23
* @param exception 异常
* @return 通用响应体
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public CommonResponseDTO<Void> exceptionHandler(Exception exception) {
log.error(exception.getMessage(), exception);
return new CommonResponseDTO<>("ERR999", exception.getMessage());
}
}
1.2 客户端
Feign的使用就很简单了,甚至可以让服务端直接提供controller文件,进行简单处理,毕竟它长得就跟controller差不多。
import cn.horizon.nile.dto.CommonResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* Copyright: Horizon
*
* @ClassName SimpleFeignClient
* @Description
* @Author Nile (Email:576109623@qq.com)
* @Date 16:57 2024-7-20
* @Version 1.0.0
*/
@FeignClient(value = "SimpleFeignClient", url = "localhost:8080/api/v1/simple")
public interface SimpleFeignClient {
/**
* 检索
* @author Nile (Email:576109623@qq.com)
* @date 17:11 2024-7-20
* @param keyword 关键词
* @return 结果
*/
@GetMapping("search")
CommonResponseDTO<Void> search(@RequestParam("keyword") String keyword);
}
1.3 执行
写个单元测试。。。
import cn.horizon.nile.dto.CommonResponseDTO;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
/**
* Copyright: Horizon
*
* @ClassName SimpleFeignClientTest
* @Description
* @Author Nile (Email:576109623@qq.com)
* @Date 17:14 2024-7-20
* @Version 1.0.0
*/
@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@RunWith(SpringRunner.class)
public class SimpleFeignClientTest {
@Autowired
private SimpleFeignClient simpleFeignClient;
@Test
public void search() {
// 异常情况
CommonResponseDTO<Void> exceptionResponse = null;
try {
exceptionResponse = simpleFeignClient.search("exception");
log.info(String.valueOf(exceptionResponse));
} catch (Exception e) {
String message = e.getMessage();
log.error(message);
}
// 正常情况
CommonResponseDTO<Void> commonResponse = simpleFeignClient.search("nile");
log.info(String.valueOf(commonResponse));
Assert.assertEquals("SUC000", commonResponse.getCode());
}
}
log记录
2024-07-25 00:02:27.763 INFO 9144 --- [ main] c.h.nile.cfg.FeignExceptionDecoder : [500] during [GET] to [http://localhost:8080/api/v1/simple/search?keyword=exception] [SimpleFeignClient#search(String)]: [{"code":"ERR999","message":"Spring Cloud OpenFeign是一种基于Spring Cloud的声明式REST客户端,它简化了与HTTP服务交互的过程。它将REST客户端的定义转化为Java接口,并且可以通过注解的方式来声明请求参数、请求方式、请求头等信息,从而使得客户端的使用更加方便和简洁。同时,它还提供了负载均衡和服务发现等功能,可以与Eureka、Co... (615 bytes)]
2024-07-25 00:02:28.019 INFO 9144 --- [ main] c.h.nile.feign.SimpleFeignClientTest : CommonResponseDTO(code=SUC000, message=null, data=null)
2. 问题描述
ok,问题来了,异常情况下,捕获到的exception的message是:
[500] during [GET] to [http://localhost:8080/api/v1/simple/search?keyword=exception] [SimpleFeignClient#search(String)]: [{“code”:“ERR999”,“message”:"Spring Cloud OpenFeign是一种基于Spring Cloud的声明式REST客户端,它简化了与HTTP服务交互的过程。它将REST客户端的定义转化为Java接口,并且可以通过注解的方式来声明请求参数、请求方式、请求头等信息,从而使得客户端的使用更加方便和简洁。同时,它还提供了负载均衡和服务发现等功能,可以与Eureka、Co… (615 bytes)]
以... (615 bytes)]
结尾,笔者一开始以为是这个异常类重写了toString(),所以后面的内容没有打印出来,debug调试了下,发现exception message其实就是上面这个。
3. 一点分析
3.1 异常构造
因为这个异常信息很重要,所以简单跟踪了下Feign响应序列化的代码。
返回的异常类型为FeignException.class
,来看看这个异常的构造方法,经过debug定位到了这个方法。
public static FeignException errorStatus(String methodKey, Response response) {
byte[] body = {};
try {
if (response.body() != null) {
body = Util.toByteArray(response.body().asInputStream());
}
} catch (IOException ignored) { // NOPMD
}
String message = new FeignExceptionMessageBuilder()
.withResponse(response)
.withMethodKey(methodKey)
.withBody(body).build();
return errorStatus(response.status(), message, response.request(), body, response.headers());
}
3.2 message
于是,笔者想看看这里message到底是什么
可以看到这里的message其实已经是 2. 问题描述 中的样子了,被截取过了(一开始还以为是服务端的问题,跟服务端反馈说是他们返回的异常信息不完整。。。GG)
3.3 body
messge的构造,用到了response.body(),于是想看看body到底长什么样子,对body进行转义了下
可以看到body还是包含了完整的异常信息嘛,不关服务端的事情(其实笔者用Postman调用了服务端的接口,发现异常也是完整返回的)
3.4 FeignExceptionMessageBuilder
这说明,异常信息是在构建的过程中被截取没的,来看看FeignExceptionMessageBuilder
// FeignException的一个内部类,用于构建message,见名知意
private static class FeignExceptionMessageBuilder {
// body最长字节数,400,这个就是原因了
private static final int MAX_BODY_BYTES_LENGTH = 400;
public String build() {
StringBuilder result = new StringBuilder();
// 拼接了http请求的响应码、请求方式、url等,这就是为什么打印出来的message前面还有其他信息
if (response.reason() != null) {
result.append(format("[%d %s]", response.status(), response.reason()));
} else {
result.append(format("[%d]", response.status()));
}
result.append(format(" during [%s] to [%s] [%s]", response.request().httpMethod(),
response.request().url(), methodKey));
// 这里放上了真正的message
result.append(format(": [%s]", getBodyAsString(body, response.headers())));
return result.toString();
}
private static String getBodyAsString(byte[] body, Map<String, Collection<String>> headers) {
Charset charset = getResponseCharset(headers);
if (charset == null) {
charset = Util.UTF_8;
}
return getResponseBody(body, charset);
}
private static String getResponseBody(byte[] body, Charset charset) {
if (body.length < MAX_BODY_BYTES_LENGTH) {
return new String(body, charset);
}
// body长度大于400
return getResponseBodyPreview(body, charset);
}
private static String getResponseBodyPreview(byte[] body, Charset charset) {
try {
// 这里对body进行了截取,取前400个字节
Reader reader = new InputStreamReader(new ByteArrayInputStream(body), charset);
CharBuffer result = CharBuffer.allocate(MAX_BODY_CHARS_LENGTH);
reader.read(result);
reader.close();
((Buffer) result).flip();
// 然后,然后还贴心的拼上了3个点和body长度,知道message的结尾为啥是那样了
return result.toString() + "... (" + body.length + " bytes)";
} catch (IOException e) {
return e.toString() + ", failed to parse response";
}
}
}
4. 重写异常解码器
Feign框架对message的处理,着实有点粗暴,按笔者理解,Spring是否会开放这个参数的配置,比如配置为-1,则不截取message,但实际没找到这样的配置。
只能是来重写异常解码器了。
import feign.Response;
import feign.Util;
import feign.codec.EncodeException;
import feign.codec.ErrorDecoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
/**
* Copyright: Horizon
*
* @ClassName FeignExceptionDecoder
* @Description
* @Author Nile (Email:576109623@qq.com)
* @Date 0:04 2024-7-21
* @Version 1.0.0
*/
@Slf4j
@Configuration
public class FeignExceptionDecoder extends ErrorDecoder.Default {
@Override
public Exception decode(String methodKey, Response response) {
byte[] body = {};
try {
if (response.body() != null) {
body = Util.toByteArray(response.body().asInputStream());
}
} catch (IOException ignored) {
// ignore
}
Exception exception = super.decode(methodKey, response.toBuilder().body(body).build());
log.info(exception.getMessage());
return new EncodeException(new String(body), exception);
}
}
给FeignClient加上配置
@FeignClient(value = "SimpleFeignClient", url = "localhost:8080/api/v1/simple", configuration = {FeignExceptionDecoder.class})
public interface SimpleFeignClient {
}
异常结果:
2024-07-25 00:21:49.523 ERROR 1764 --- [ main] c.h.nile.feign.SimpleFeignClientTest : {"code":"ERR999","message":"Spring Cloud OpenFeign是一种基于Spring Cloud的声明式REST客户端,它简化了与HTTP服务交互的过程。它将REST客户端的定义转化为Java接口,并且可以通过注解的方式来声明请求参数、请求方式、请求头等信息,从而使得客户端的使用更加方便和简洁。同时,它还提供了负载均衡和服务发现等功能,可以与Eureka、Consul等注册中心集成使用。Spring Cloud OpenFeign能够提高应用程序的可靠性、可扩展性和可维护性,是构建微服务架构的重要工具之一。","data":null}
这里简单使用了EncodeException返回,因为在FeignException的子类中,发现它构造简单,同时可以将原始FeignException放进去,传递了message,又保留了原始信息方便以后使用处理。
FeignException子类:
EncodeException.class:
/**
* Similar to {@code javax.websocket.EncodeException}, raised when a problem occurs encoding a
* message. Note that {@code EncodeException} is not an {@code IOException}, nor does it have one
* set as its cause.
*/
public class EncodeException extends FeignException {
private static final long serialVersionUID = 1L;
/**
* @param message the reason for the failure.
*/
public EncodeException(String message) {
super(-1, checkNotNull(message, "message"));
}
/**
* @param message possibly null reason for the failure.
* @param cause the cause of the error.
*/
public EncodeException(String message, Throwable cause) {
super(-1, message, checkNotNull(cause, "cause"));
}
}