問題描述
在使用自定義 open feign 進行遠端呼叫的時候,請求資料時候出現很神奇的問題
先上報錯
Invalid mime type "charset=utf-8": does not contain '/'
從報錯資訊我們可以知道,出錯的原因是出現了無效的mime型別,導致資料decoder的時候出現錯誤。
使用 postman 測試介面
這裡問題就一面瞭然了,第三方對接的介面返回的是一個錯誤的 mime type “charset=utf-8”。
確實在 content-type 中攜帶 charset=utf-8 是一種常見的做法,尤其是當 Content-Type 是 application/json 或 application/x-www-form-urlencoded 時,能明確告訴接收方使用的字元編碼格式
正確的使用情況
- application/json:使用 charset=utf-8。
Content-Type: application/json; charset=utf-8
JSON資料大多數情況使用的預設編碼就是UTF-8
- application/x-www-form-urlencoded:使用 charset=utf-8
Content-Type: application/x-www-form-urlencoded; charset=utf-8
所以到這裡我們就能很清楚,單獨攜帶charset=utf-8是錯誤的,因為在對資料decoder使用需要根據 mime type 進行相應的解析處理。
自定義 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());
這裡我們可以去看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方法
我們只需要知道當前呼叫了MimeTypeUtils,對value進行比對是否符合mine type格式,如果不符合丟擲 InvalidMediaTypeException
到這裡我們就知道具體的檢查流程
解決方法
實現 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));
進行單元測試,檢視是否會報錯,再此執行發現就不報錯了,到這裡問題就解決了