From e80b4f0b28b5f8e97f63aaa6d7d4584a76ace1ac Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Fri, 1 May 2026 22:14:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(feign):=20=E4=BF=AE=E5=A4=8D=20HttpMessageC?= =?UTF-8?q?onverterCustomizer=20=E7=9A=84=20UnsupportedOperationException?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feign/PigxFeignAutoConfiguration.java | 99 ++++- .../PigxFeignLanguageInterceptor.java | 31 ++ .../openfeign/PigxFeignClientsRegistrar.java | 375 +++++++++--------- 3 files changed, 312 insertions(+), 193 deletions(-) create mode 100644 pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/interceptor/PigxFeignLanguageInterceptor.java diff --git a/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/PigxFeignAutoConfiguration.java b/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/PigxFeignAutoConfiguration.java index caa136012..6895a1e97 100644 --- a/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/PigxFeignAutoConfiguration.java +++ b/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/PigxFeignAutoConfiguration.java @@ -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 客户端自动化配置 + *

+ * 提供 Feign 客户端的核心配置,包括: + *

+ *

+ * 重要说明:本项目使用 Jackson 2.x 而非 Spring Boot 4 默认的 Jackson 3.x, + * 因此必须显式配置 Feign 使用 Jackson 2 的 ObjectMapper,否则会导致服务间调用时的序列化/反序列化错误。 + * * @author lengleng * @date 2020/2/8 - *

- * 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 客户端监控端点 + *

+ * 提供 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 消息转换器 + *

+ * 问题背景:Spring Boot 4 默认使用 Jackson 3.x,但本项目为了兼容性使用 Jackson 2.x。 + * 如果不显式配置,Feign 客户端会使用默认的 Jackson 3 进行序列化/反序列化, + * 导致与使用 Jackson 2 的 Spring MVC 之间出现数据格式不一致的问题。 + *

+ * 典型错误: + *

+     * Cannot deserialize value of type `java.time.LocalDateTime` from String "2019-05-15 00:00:00"
+     * 
+ *

+ * 解决方案:通过 {@link HttpMessageConverterCustomizer} 在 Feign 初始化时: + *

    + *
  1. 原地把 Jackson 3 的 {@link JacksonJsonHttpMessageConverter} 替换成 + * 使用 Spring 装配好的 ObjectMapper 的 Jackson 2 转换器
  2. + *
  3. 找不到 Jackson 3 转换器时把 Jackson 2 转换器插入队首作为兜底
  4. + *
  5. 保留 byte[]、String、multipart 等其它默认转换器,避免破坏文件下载/上传等非 JSON 调用
  6. + *
  7. 注入的 ObjectMapper 已被所有 {@code Jackson2ObjectMapperBuilderCustomizer} + * (包括 pigx-common-xss 的反序列化清洗)处理过,Feign 与 Spring MVC 序列化行为完全一致
  8. + *
+ *

+ * 注意:此配置使用了已标记为 removal 的 API({@code MappingJackson2HttpMessageConverter}), + * 这是有意为之,因为项目需要使用 Jackson 2 而非 Jackson 3。 + *

+ * 不使用 {@link ConditionalOnMissingBean}:{@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> 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(); + } } diff --git a/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/interceptor/PigxFeignLanguageInterceptor.java b/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/interceptor/PigxFeignLanguageInterceptor.java new file mode 100644 index 000000000..aa89c074f --- /dev/null +++ b/pigx-common/pigx-common-feign/src/main/java/com/pig4cloud/pigx/common/feign/interceptor/PigxFeignLanguageInterceptor.java @@ -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 + *

+ * 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); + } + } + +} diff --git a/pigx-common/pigx-common-feign/src/main/java/org/springframework/cloud/openfeign/PigxFeignClientsRegistrar.java b/pigx-common/pigx-common-feign/src/main/java/org/springframework/cloud/openfeign/PigxFeignClientsRegistrar.java index cc4889e8f..150350550 100644 --- a/pigx-common/pigx-common-feign/src/main/java/org/springframework/cloud/openfeign/PigxFeignClientsRegistrar.java +++ b/pigx-common/pigx-common-feign/src/main/java/org/springframework/cloud/openfeign/PigxFeignClientsRegistrar.java @@ -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 feignClients = new ArrayList<>(); + private void registerFeignClients(BeanDefinitionRegistry registry) { + List 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 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 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 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 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 attributes) { + String contextId = (String) attributes.get("contextId"); + if (!StringUtils.hasText(contextId)) { + return getName(attributes); + } - private String getContextId(Map 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 attributes) { + // 如果是单体项目自动注入 & url 为空 + Boolean isMicro = environment.getProperty("spring.cloud.nacos.discovery.enabled", Boolean.class, true); - private String getUrl(Map 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 attributes) { + String path = resolve((String) attributes.get("path")); + return FeignClientsRegistrar.getPath(path); + } - return FeignClientsRegistrar.getUrl(url); - } + @Nullable + private String getQualifier(@Nullable Map client) { + if (client == null) { + return null; + } + String qualifier = (String) client.get("qualifier"); + if (StringUtils.hasText(qualifier)) { + return qualifier; + } + return null; + } - private String getPath(Map attributes) { - String path = resolve((String) attributes.get("path")); - return FeignClientsRegistrar.getPath(path); - } + @Nullable + private String getClientName(@Nullable Map 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 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 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; + } }