2

问题描述

在使用自定义 open feign 进行远程调用的时候,请求数据时候出现很神奇的问题

先上报错

image.png

Invalid mime type "charset=utf-8": does not contain '/'

从报错信息我们可以知道,出错的原因是出现了无效的mime类型,导致数据decoder的时候出现错误。

使用 postman 测试接口

image.png

这里问题就一面了然了,第三方对接的接口返回的是一个错误的 mime type “charset=utf-8”。

确实在 content-type 中携带 charset=utf-8 是一种常见的做法,尤其是当 Content-Type 是 application/json 或 application/x-www-form-urlencoded 时,能明确告诉接收方使用的字符编码格式

正确的使用情况

  1. application/json:使用 charset=utf-8。
Content-Type: application/json; charset=utf-8

JSON数据大多数情况使用的默认编码就是UTF-8

  1. application/x-www-form-urlencoded:使用 charset=utf-8
Content-Type: application/x-www-form-urlencoded; charset=utf-8

所以到这里我们就能很清楚,单独携带charset=utf-8是错误的,因为在对数据decoder使用需要根据 mime type 进行相应的解析处理。

image.png

自定义 Feign 配置

@Configuration
@Component
public class FeignClientConfig {

    private final ObjectFactory<HttpMessageConverters> messageConverters;

    public FeignClientConfig(ObjectFactory<HttpMessageConverters> messageConverters) {
        this.messageConverters = messageConverters;
    }

    /**
     * 根据自定义的Feign请求接口,自定义请求的 URL 来创建请求客户端。
     * 适用于根据上下文自定义请求URL的场景
     * @param type 根据该类型,去解析这个类型的注解,根据注解来生成请求对象
     * @param url 自定义的请求 URL
     */
    public <T> T createClient(Class<T> type, String url) {
        Encoder encoder = new SpringEncoder(this.messageConverters);
        Decoder decoder = new SpringDecoder(this.messageConverters);

        return Feign.builder()
            .encoder(encoder)
            .decoder(decoder)
            .target(type, url);
    }
}

在使用 Feign 进行调用时,响应数据会通过解码器(Decoder)进行解析。在这个过程中,我们使用的是 SpringDecoder 对象。

SpringDecoder 的构造函数接收一个 Object<<HttpMessageConverters>> 作为参数,对于响应的 Content-Type 格式校验,通常是由 Spring 的 HttpMessageConverter 来完成的

通过查看 SpringDecoder 对象

public class SpringDecoder implements Decoder {
    private ObjectFactory<HttpMessageConverters> messageConverters;

    public SpringDecoder(ObjectFactory<HttpMessageConverters> messageConverters) {
        this.messageConverters = messageConverters;
    }

    public Object decode(final Response response, Type type) throws IOException, FeignException {
        if (!(type instanceof Class) && !(type instanceof ParameterizedType) && !(type instanceof WildcardType)) {
            throw new DecodeException(response.status(), "type is not an instance of Class or ParameterizedType: " + type, response.request());
        } else {
            HttpMessageConverterExtractor<?> extractor = new HttpMessageConverterExtractor(type, ((HttpMessageConverters)this.messageConverters.getObject()).getConverters());
            return extractor.extractData(new FeignResponseAdapter(response));
        }
    }
}

这里我们可以看到执行在进行decoder时候用到了传入HttpMessageConverters,用户对各种不同的转换

HttpMessageConverterExtractor<?> extractor = new HttpMessageConverterExtractor(type, ((HttpMessageConverters) this.messageConverters.getObject()).getConverters());

image.png

这里我们可以去看HttpMessageConverterExtractor的getContentType的实现

 protected MediaType getContentType(ClientHttpResponse response) {
        MediaType contentType = response.getHeaders().getContentType();
        if (contentType == null) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("No content-type, using 'application/octet-stream'");
            }

            contentType = MediaType.APPLICATION_OCTET_STREAM;
        }

        return contentType;
    }

这里又调用response.getHeaders().getContentType();点击方法查看

  @Nullable
    public MediaType getContentType() {
        // 是获取当中 "Content-Type" 头字段的第一个值
        String value = this.getFirst("Content-Type");
        return StringUtils.hasLength(value) ? MediaType.parseMediaType(value) : null;
    }

这里我们可以看到判断是否有值,如果有值就调用 MediaType.parseMediaType方法

image.png

image.png

我们只需要知道当前调用了MimeTypeUtils,对value进行比对是否符合mine type格式,如果不符合抛出 InvalidMediaTypeException

image.png

到这里我们就知道具体的检查流程

解决方法

image.png

实现 Feign Decoder

@Component
public class FeignResponseDecoder implements Decoder {
    private final Decoder springDecoder;
    private static final String CONTENT_TYPE = "Content-Type";
    private static final String JSON_CONTENT_TYPE = "application/json";
    private static final String CHARSET_UTF_8 = "charset=utf-8";

    public FeignResponseDecoder(@Lazy Decoder springDecoder) {
        this.springDecoder = springDecoder;
    }

    @Override
    public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
        if (isContentTypeModificationNeeded(response)) {
            Map<String, Collection<String>> newHeaders = new HashMap<>();

            // 注意这里不能直接 new HasMap<>(response.header()),
            // 如果这样的话put("content-type: application/json"), 变成了增加多一个content-type,而不是替换
            newHeaders.put(CONTENT_TYPE, Collections.singletonList(JSON_CONTENT_TYPE));
            response.headers().forEach((key, value) -> {
                if (!key.equalsIgnoreCase(CONTENT_TYPE)) {
                    newHeaders.put(key, value);
                }
            });

            response = Response.builder()
                .status(response.status())
                .reason(response.reason())
                .headers(newHeaders) // 使用新的头部映射
                .request(response.request())
                .body(response.body().asInputStream(), response.body().length())
                .build();
        }
        return springDecoder.decode(response, type);
    }

    /**
     * @param response 响应体
     * @return 判断是否需要进行修改
     */
    private boolean isContentTypeModificationNeeded(Response response) {
      // 获取第一个 Content-Type 头部的值
      String contentType = response.headers().getOrDefault(CONTENT_TYPE, List.of())
                             .stream()
                             .findFirst()
                             .orElse("")
                             .trim();

      // 判断是否需要修改 Content-Type
      return contentType.isEmpty() || !contentType.contains("/") || contentType.equalsIgnoreCase(CHARSET_UTF_8);
  }
}

由于我们知道当前只要第三方返回 content-type: charset:utf-8 都是JSON格式,所以我们只需要对这种请求进行处理,处理完之后返回一个SpringEncoder就能进解决这个问题

对 FeignConfig 进行修改

   public <T> T createClient(Class<T> type, String url) {
        // 使用 SpringFormEncoder 包装 SpringEncoder 以支持表单编码
        Encoder encoder = new SpringFormEncoder(new SpringEncoder(this.messageConverters));
        Decoder decoder = new FeignResponseDecoder(new SpringDecoder(this.messageConverters));

        return Feign.builder()
            .encoder(encoder)
            .decoder(decoder)
            .target(type, url);
    }

修改部分

Decoder decoder = new FeignResponseDecoder(new SpringDecoder(this.messageConverters));

进行单元测试,查看是否会报错,再此执行发现就不报错了,到这里问题就解决了

image.png


kexb
461 声望15 粉丝