fix(feign): 修复 HttpMessageConverterCustomizer 的 UnsupportedOperationException

This commit is contained in:
PIGCLOUD committed 2026-05-01 22:14:18 +08:00
1 parent e88b2818fd
commit e80b4f0b28
3 files changed
+312 -193

No files matched your search

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018-2026, lengleng All rights reserved.
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
@@ -17,8 +17,10 @@
package com.pig4cloud.pigx.common.feign;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.common.feign.endpoint.FeignClientEndpoint;
import com.pig4cloud.pigx.common.feign.interceptor.PigxFeignInnerRequestInterceptor;
import com.pig4cloud.pigx.common.feign.interceptor.PigxFeignLanguageInterceptor;
import com.pig4cloud.pigx.common.feign.interceptor.PigxFeignRequestCloseInterceptor;
import feign.Feign;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
@@ -27,27 +29,49 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.PigxFeignClientsRegistrar;
import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import java.util.ListIterator;
/**
* Feign 客户端自动化配置
* <p>
* 提供 Feign 客户端的核心配置,包括:
* <ul>
* <li>Jackson 2 消息转换器配置 - 确保与 Spring MVC 使用相同的序列化机制</li>
* <li>请求拦截器 - 添加内部调用标识、语言标头、连接关闭标头</li>
* <li>Actuator 端点 - 提供 Feign 客户端监控能力</li>
* </ul>
* <p>
* <b>重要说明</b>:本项目使用 Jackson 2.x 而非 Spring Boot 4 默认的 Jackson 3.x,
* 因此必须显式配置 Feign 使用 Jackson 2 的 ObjectMapper,否则会导致服务间调用时的序列化/反序列化错误。
*
* @author lengleng
* @date 2020/2/8
* <p>
* feign 自动化配置
* @see HttpMessageConverterCustomizer
* @see PigxFeignInnerRequestInterceptor
*/
@Configuration
@ConditionalOnClass(Feign.class)
@Import(PigxFeignClientsRegistrar.class)
@AutoConfigureAfter(EnableFeignClients.class)
@SuppressWarnings("removal")
public class PigxFeignAutoConfiguration {
/**
* feign actuator endpoint
* @param context
* @return FeignClientEndpoint
* Feign 客户端监控端点
* <p>
* 提供 Actuator 端点用于监控和管理 Feign 客户端,可以查看已注册的 Feign 客户端列表及其配置信息。
* 仅在 Actuator 可用且端点启用时才会创建此 Bean。
*
* @param context Spring 应用上下文
* @return FeignClientEndpoint 实例
*/
@Bean
@ConditionalOnMissingBean
@@ -56,6 +80,59 @@ public class PigxFeignAutoConfiguration {
return new FeignClientEndpoint(context);
}
/**
* 配置 Feign 使用 Jackson 2 消息转换器
* <p>
* <b>问题背景</b>:Spring Boot 4 默认使用 Jackson 3.x,但本项目为了兼容性使用 Jackson 2.x。
* 如果不显式配置,Feign 客户端会使用默认的 Jackson 3 进行序列化/反序列化,
* 导致与使用 Jackson 2 的 Spring MVC 之间出现数据格式不一致的问题。
* <p>
* <b>典型错误</b>:
* <pre>
* Cannot deserialize value of type `java.time.LocalDateTime` from String "2019-05-15 00:00:00"
* </pre>
* <p>
* <b>解决方案</b>:通过 {@link HttpMessageConverterCustomizer} 在 Feign 初始化时:
* <ol>
* <li>原地把 Jackson 3 的 {@link JacksonJsonHttpMessageConverter} 替换成
* 使用 Spring 装配好的 ObjectMapper 的 Jackson 2 转换器</li>
* <li>找不到 Jackson 3 转换器时把 Jackson 2 转换器插入队首作为兜底</li>
* <li>保留 byte[]、String、multipart 等其它默认转换器,避免破坏文件下载/上传等非 JSON 调用</li>
* <li>注入的 ObjectMapper 已被所有 {@code Jackson2ObjectMapperBuilderCustomizer}
* (包括 pigx-common-xss 的反序列化清洗)处理过,Feign 与 Spring MVC 序列化行为完全一致</li>
* </ol>
* <p>
* <b>注意</b>:此配置使用了已标记为 removal 的 API({@code MappingJackson2HttpMessageConverter}),
* 这是有意为之,因为项目需要使用 Jackson 2 而非 Jackson 3。
* <p>
* <b>不使用 {@link ConditionalOnMissingBean}</b>:{@code HttpMessageConverterCustomizer}
* 是可叠加的扩展点,OpenFeign 等模块可能已注册其它 customizer;如果加上 missing-bean 条件,
* 本 Bean 会被静默跳过,Jackson 2 修复消失。
*
* @param objectMapper Spring 装配好的 Jackson 2 ObjectMapper(来自 JacksonConfiguration 的 customizer 链)
* @return HttpMessageConverterCustomizer 实例
* @see com.pig4cloud.pigx.common.core.config.JacksonConfiguration
* @see com.pig4cloud.pigx.common.core.jackson.PigxJavaTimeModule
*/
@Bean
public HttpMessageConverterCustomizer pigxFeignJackson2Customizer(ObjectMapper objectMapper) {
return converters -> {
org.springframework.http.converter.json.MappingJackson2HttpMessageConverter jackson2Converter =
new org.springframework.http.converter.json.MappingJackson2HttpMessageConverter(objectMapper);
ListIterator<HttpMessageConverter<?>> it = converters.listIterator();
boolean replaced = false;
while (it.hasNext()) {
if (it.next() instanceof JacksonJsonHttpMessageConverter) {
it.set(jackson2Converter);
replaced = true;
}
}
if (!replaced) {
converters.add(0, jackson2Converter);
}
};
}
/**
* add http connection close header
*
@@ -75,4 +152,14 @@ public class PigxFeignAutoConfiguration {
public PigxFeignInnerRequestInterceptor pigFeignInnerRequestInterceptor() {
return new PigxFeignInnerRequestInterceptor();
}
/**
* add accept-language header
*
* @return PigxFeignLanguageInterceptor
*/
@Bean
public PigxFeignLanguageInterceptor pigxFeignLanguageInterceptor() {
return new PigxFeignLanguageInterceptor();
}
}
@@ -0,0 +1,31 @@
package com.pig4cloud.pigx.common.feign.interceptor;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
/**
* @author lengleng
* @date 2025/3/26
* <p>
* feign 语言环境透传拦截器
*/
public class PigxFeignLanguageInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
HttpServletRequest request = WebUtils.getRequest();
if (request == null) {
return;
}
String language = request.getHeader(HttpHeaders.ACCEPT_LANGUAGE);
if (StringUtils.hasText(language)) {
template.header(HttpHeaders.ACCEPT_LANGUAGE, language);
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018-2026, lengleng All rights reserved.
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
@@ -46,229 +46,230 @@ import java.util.Map;
* feign 自动配置功能 from mica
*/
public class PigxFeignClientsRegistrar
implements ImportBeanDefinitionRegistrar, BeanClassLoaderAware, EnvironmentAware {
implements ImportBeanDefinitionRegistrar, BeanClassLoaderAware, EnvironmentAware {
private final static String BASE_URL = "http://127.0.0.1:${server.port}${server.servlet.context-path}";
private final static String BASE_URL = "http://127.0.0.1:${server.port}${server.servlet.context-path}";
@Getter
private ClassLoader beanClassLoader;
@Getter
private ClassLoader beanClassLoader;
@Getter
private Environment environment;
@Getter
private Environment environment;
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
registerFeignClients(registry);
}
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
registerFeignClients(registry);
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
private void registerFeignClients(BeanDefinitionRegistry registry) {
List<String> feignClients = new ArrayList<>();
private void registerFeignClients(BeanDefinitionRegistry registry) {
List<String> feignClients = new ArrayList<>();
// 支持 springboot 2.7 + 最新版本的配置方式
ImportCandidates.load(FeignClient.class, getBeanClassLoader()).forEach(feignClients::add);
// 支持 springboot 2.7 + 最新版本的配置方式
ImportCandidates.load(FeignClient.class, getBeanClassLoader()).forEach(feignClients::add);
// 如果 spring.factories 里为空
if (feignClients.isEmpty()) {
return;
}
for (String className : feignClients) {
try {
Class<?> clazz = beanClassLoader.loadClass(className);
AnnotationAttributes attributes = AnnotatedElementUtils.getMergedAnnotationAttributes(clazz,
FeignClient.class);
if (attributes == null) {
continue;
}
// 如果 spring.factories 里为空
if (feignClients.isEmpty()) {
return;
}
for (String className : feignClients) {
try {
Class<?> clazz = beanClassLoader.loadClass(className);
AnnotationAttributes attributes = AnnotatedElementUtils.getMergedAnnotationAttributes(clazz,
FeignClient.class);
if (attributes == null) {
continue;
}
// 如果是单体项目自动注入 & url 为空
Boolean isMicro = environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true);
// 如果已经存在该 bean,支持原生的 Feign
if (registry.containsBeanDefinition(className) && isMicro) {
continue;
}
// 如果是单体项目自动注入 & url 为空
Boolean isMicro = environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true);
// 如果已经存在该 bean,支持原生的 Feign
if (registry.containsBeanDefinition(className) && isMicro) {
continue;
}
registerClientConfiguration(registry, getClientName(attributes), className,
attributes.get("configuration"));
registerClientConfiguration(registry, getClientName(attributes), className,
attributes.get("configuration"));
validate(attributes);
BeanDefinitionBuilder definition = BeanDefinitionBuilder
.genericBeanDefinition(FeignClientFactoryBean.class);
definition.addPropertyValue("url", getUrl(attributes));
definition.addPropertyValue("path", getPath(attributes));
String name = getName(attributes);
definition.addPropertyValue("name", name);
validate(attributes);
BeanDefinitionBuilder definition = BeanDefinitionBuilder
.genericBeanDefinition(FeignClientFactoryBean.class);
definition.addPropertyValue("url", getUrl(attributes));
definition.addPropertyValue("path", getPath(attributes));
String name = getName(attributes);
definition.addPropertyValue("name", name);
// 兼容最新版本的 spring-cloud-openfeign,尚未发布
StringBuilder aliasBuilder = new StringBuilder(18);
if (attributes.containsKey("contextId")) {
String contextId = getContextId(attributes);
aliasBuilder.append(contextId);
definition.addPropertyValue("contextId", contextId);
} else {
aliasBuilder.append(name);
}
// 兼容最新版本的 spring-cloud-openfeign,尚未发布
StringBuilder aliasBuilder = new StringBuilder(18);
if (attributes.containsKey("contextId")) {
String contextId = getContextId(attributes);
aliasBuilder.append(contextId);
definition.addPropertyValue("contextId", contextId);
}
else {
aliasBuilder.append(name);
}
definition.addPropertyValue("type", className);
definition.addPropertyValue("dismiss404",
Boolean.parseBoolean(String.valueOf(attributes.get("dismiss404"))));
Object fallbackFactory = attributes.get("fallbackFactory");
if (fallbackFactory != null) {
definition.addPropertyValue("fallbackFactory", fallbackFactory instanceof Class ? fallbackFactory
: ClassUtils.resolveClassName(fallbackFactory.toString(), null));
}
definition.addPropertyValue("fallbackFactory", attributes.get("fallbackFactory"));
definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
definition.addPropertyValue("type", className);
definition.addPropertyValue("dismiss404",
Boolean.parseBoolean(String.valueOf(attributes.get("dismiss404"))));
Object fallbackFactory = attributes.get("fallbackFactory");
if (fallbackFactory != null) {
definition.addPropertyValue("fallbackFactory", fallbackFactory instanceof Class ? fallbackFactory
: ClassUtils.resolveClassName(fallbackFactory.toString(), null));
}
definition.addPropertyValue("fallbackFactory", attributes.get("fallbackFactory"));
definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
// alias
String alias = aliasBuilder.append("FeignClient").toString();
// alias
String alias = aliasBuilder.append("FeignClient").toString();
// has a default, won't be null
boolean primary = (Boolean) attributes.get("primary");
// has a default, won't be null
boolean primary = (Boolean) attributes.get("primary");
beanDefinition.setPrimary(primary);
beanDefinition.setPrimary(primary);
String qualifier = getQualifier(attributes);
if (StringUtils.hasText(qualifier)) {
alias = qualifier;
}
String qualifier = getQualifier(attributes);
if (StringUtils.hasText(qualifier)) {
alias = qualifier;
}
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
new String[]{alias});
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
new String[] { alias });
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}
catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
private void validate(Map<String, Object> attributes) {
AnnotationAttributes annotation = AnnotationAttributes.fromMap(attributes);
// This blows up if an aliased property is overspecified
FeignClientsRegistrar.validateFallback(annotation.getClass("fallback"));
FeignClientsRegistrar.validateFallbackFactory(annotation.getClass("fallbackFactory"));
}
private void validate(Map<String, Object> attributes) {
AnnotationAttributes annotation = AnnotationAttributes.fromMap(attributes);
// This blows up if an aliased property is overspecified
FeignClientsRegistrar.validateFallback(annotation.getClass("fallback"));
FeignClientsRegistrar.validateFallbackFactory(annotation.getClass("fallbackFactory"));
}
private String getName(Map<String, Object> attributes) {
String name = (String) attributes.get("serviceId");
if (!StringUtils.hasText(name)) {
name = (String) attributes.get("name");
}
if (!StringUtils.hasText(name)) {
name = (String) attributes.get("value");
}
name = resolve(name);
return FeignClientsRegistrar.getName(name);
}
private String getName(Map<String, Object> attributes) {
String name = (String) attributes.get("serviceId");
if (!StringUtils.hasText(name)) {
name = (String) attributes.get("name");
}
if (!StringUtils.hasText(name)) {
name = (String) attributes.get("value");
}
name = resolve(name);
return FeignClientsRegistrar.getName(name);
}
private String getContextId(Map<String, Object> attributes) {
String contextId = (String) attributes.get("contextId");
if (!StringUtils.hasText(contextId)) {
return getName(attributes);
}
private String getContextId(Map<String, Object> attributes) {
String contextId = (String) attributes.get("contextId");
if (!StringUtils.hasText(contextId)) {
return getName(attributes);
}
contextId = resolve(contextId);
return FeignClientsRegistrar.getName(contextId);
}
contextId = resolve(contextId);
return FeignClientsRegistrar.getName(contextId);
}
private String resolve(String value) {
if (StringUtils.hasText(value)) {
return this.environment.resolvePlaceholders(value);
}
return value;
}
private String resolve(String value) {
if (StringUtils.hasText(value)) {
return this.environment.resolvePlaceholders(value);
}
return value;
}
private String getUrl(Map<String, Object> attributes) {
// 如果是单体项目自动注入 & url 为空
Boolean isMicro = environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true);
private String getUrl(Map<String, Object> attributes) {
if (isMicro) {
return null;
}
// 如果是单体项目自动注入 & url 为空
Boolean isMicro = environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true);
Object objUrl = attributes.get("url");
if (isMicro) {
return null;
}
String url = "";
if (StringUtils.hasText(objUrl.toString())) {
url = resolve(objUrl.toString());
}
else {
url = resolve(BASE_URL);
}
Object objUrl = attributes.get("url");
return FeignClientsRegistrar.getUrl(url);
}
String url = "";
if (StringUtils.hasText(objUrl.toString())) {
url = resolve(objUrl.toString());
} else {
url = resolve(BASE_URL);
}
private String getPath(Map<String, Object> attributes) {
String path = resolve((String) attributes.get("path"));
return FeignClientsRegistrar.getPath(path);
}
return FeignClientsRegistrar.getUrl(url);
}
@Nullable
private String getQualifier(@Nullable Map<String, Object> client) {
if (client == null) {
return null;
}
String qualifier = (String) client.get("qualifier");
if (StringUtils.hasText(qualifier)) {
return qualifier;
}
return null;
}
private String getPath(Map<String, Object> attributes) {
String path = resolve((String) attributes.get("path"));
return FeignClientsRegistrar.getPath(path);
}
@Nullable
private String getClientName(@Nullable Map<String, Object> client) {
if (client == null) {
return null;
}
String value = (String) client.get("contextId");
if (!StringUtils.hasText(value)) {
value = (String) client.get("value");
}
if (!StringUtils.hasText(value)) {
value = (String) client.get("name");
}
if (!StringUtils.hasText(value)) {
value = (String) client.get("serviceId");
}
if (StringUtils.hasText(value)) {
return value;
}
@Nullable
private String getQualifier(@Nullable Map<String, Object> client) {
if (client == null) {
return null;
}
String qualifier = (String) client.get("qualifier");
if (StringUtils.hasText(qualifier)) {
return qualifier;
}
return null;
}
throw new IllegalStateException(
"Either 'name' or 'value' must be provided in @" + FeignClient.class.getSimpleName());
}
@Nullable
private String getClientName(@Nullable Map<String, Object> client) {
if (client == null) {
return null;
}
String value = (String) client.get("contextId");
if (!StringUtils.hasText(value)) {
value = (String) client.get("value");
}
if (!StringUtils.hasText(value)) {
value = (String) client.get("name");
}
if (!StringUtils.hasText(value)) {
value = (String) client.get("serviceId");
}
if (StringUtils.hasText(value)) {
return value;
}
private void registerClientConfiguration(BeanDefinitionRegistry registry, Object name, Object configuration) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(FeignClientSpecification.class);
builder.addConstructorArgValue(name);
builder.addConstructorArgValue(configuration);
registry.registerBeanDefinition(name + "." + FeignClientSpecification.class.getSimpleName(),
builder.getBeanDefinition());
}
throw new IllegalStateException(
"Either 'name' or 'value' must be provided in @" + FeignClient.class.getSimpleName());
}
private void registerClientConfiguration(BeanDefinitionRegistry registry, Object name, Object className,
Object configuration) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(FeignClientSpecification.class);
builder.addConstructorArgValue(name);
builder.addConstructorArgValue(className);
builder.addConstructorArgValue(configuration);
registry.registerBeanDefinition(name + "." + FeignClientSpecification.class.getSimpleName(),
builder.getBeanDefinition());
}
private void registerClientConfiguration(BeanDefinitionRegistry registry, Object name, Object configuration) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(FeignClientSpecification.class);
builder.addConstructorArgValue(name);
builder.addConstructorArgValue(configuration);
registry.registerBeanDefinition(name + "." + FeignClientSpecification.class.getSimpleName(),
builder.getBeanDefinition());
}
private void registerClientConfiguration(BeanDefinitionRegistry registry, Object name, Object className,
Object configuration) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(FeignClientSpecification.class);
builder.addConstructorArgValue(name);
builder.addConstructorArgValue(className);
builder.addConstructorArgValue(configuration);
registry.registerBeanDefinition(name + "." + FeignClientSpecification.class.getSimpleName(),
builder.getBeanDefinition());
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}