Feign吃掉了Exception Message

Feign吃掉了Exception Message

eat

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

返回的异常类型为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

可以看到这里的message其实已经是 2. 问题描述 中的样子了,被截取过了(一开始还以为是服务端的问题,跟服务端反馈说是他们返回的异常信息不完整。。。GG)

3.3 body

messge的构造,用到了response.body(),于是想看看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子类:

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"));
  }
}

5. 参考

  1. Spring Cloud OpenFeign 中文文档
  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值