記錄下解決自定義 Feign 呼叫,響應content-type 格式不正確導致的解析錯誤

kexb發表於2024-11-03

問題描述

在使用自定義 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

相關文章