From fc8a4dbcd1ed5492af2c8bb52d54e6ca45f37a41 Mon Sep 17 00:00:00 2001 From: shedfreewu <49236872+shedfreewu@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:24:19 +0800 Subject: [PATCH] feat: refactor Feign eager load, add LoadBalancer warm-up, and fix gateway trailing slash compatibility (#1800) Signed-off-by: Haotian Zhang <928016560@qq.com> --- CHANGELOG.md | 1 + .../pom.xml | 6 + .../PolarisEagerLoadAutoConfiguration.java | 23 +- .../FeignEagerLoadContextInitializer.java | 164 ++++++++++++ .../feign/FeignEagerLoadSmartLifecycle.java | 128 --------- .../LoadBalancerEagerLoadProperties.java | 47 ++++ .../loadbalancer/LoadBalancerWarmUpUtils.java | 64 +++++ ...isLoadBalancerEagerContextInitializer.java | 66 +++++ .../framework/JdkDynamicAopProxyUtils.java | 50 ++++ .../FeignEagerLoadContextInitializerTest.java | 249 ++++++++++++++++++ ...adBalancerEagerContextInitializerTest.java | 164 ++++++++++++ .../gateway/context/ContextGatewayFilter.java | 72 ++++- .../context/ContextGatewayFilterTest.java | 134 ++++++++++ .../unit/config/UnitBeanPostProcessor.java | 14 +- ...UnitFeignEagerLoadContextInitializer.java} | 16 +- ...itLoadBalancerEagerContextInitializer.java | 42 +++ 16 files changed, 1089 insertions(+), 151 deletions(-) create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializer.java delete mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadSmartLifecycle.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerEagerLoadProperties.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerWarmUpUtils.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializer.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxyUtils.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializerTest.java create mode 100644 spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializerTest.java rename spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/{UnitFeignEagerLoadSmartLifecycle.java => UnitFeignEagerLoadContextInitializer.java} (69%) create mode 100644 spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitLoadBalancerEagerContextInitializer.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f11c3265c..ba09f1e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ - [feat: Support Polaris config env value and add related tests](https://github.com/Tencent/spring-cloud-tencent/pull/1797) - [refactor: modify the initialization of ApplicationContextAwareUtils.](https://github.com/Tencent/spring-cloud-tencent/pull/1798) - [feat: support enable/disable cloud location provider via configuration.](https://github.com/Tencent/spring-cloud-tencent/pull/1799) +- [feat: refactor Feign eager load, add LoadBalancer warm-up, and fix gateway trailing slash compatibility](https://github.com/Tencent/spring-cloud-tencent/pull/1800) diff --git a/spring-cloud-starter-tencent-polaris-discovery/pom.xml b/spring-cloud-starter-tencent-polaris-discovery/pom.xml index 67bdae3ea..c03de3430 100644 --- a/spring-cloud-starter-tencent-polaris-discovery/pom.xml +++ b/spring-cloud-starter-tencent-polaris-discovery/pom.xml @@ -62,5 +62,11 @@ spring-boot-actuator-autoconfigure true + + + io.github.openfeign + feign-slf4j + test + diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/config/PolarisEagerLoadAutoConfiguration.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/config/PolarisEagerLoadAutoConfiguration.java index f0a900e29..4a78d20f4 100644 --- a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/config/PolarisEagerLoadAutoConfiguration.java +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/config/PolarisEagerLoadAutoConfiguration.java @@ -19,27 +19,33 @@ package com.tencent.cloud.polaris.eager.config; import com.tencent.cloud.polaris.discovery.PolarisDiscoveryClient; import com.tencent.cloud.polaris.discovery.reactive.PolarisReactiveDiscoveryClient; -import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadSmartLifecycle; +import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadContextInitializer; +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.LoadBalancerEagerLoadProperties; +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.PolarisLoadBalancerEagerContextInitializer; import com.tencent.cloud.polaris.eager.instrument.services.ServicesEagerLoadSmartLifecycle; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration +@EnableConfigurationProperties(LoadBalancerEagerLoadProperties.class) @ConditionalOnProperty(name = "spring.cloud.polaris.discovery.eager-load.enabled", havingValue = "true", matchIfMissing = true) public class PolarisEagerLoadAutoConfiguration { @Bean @ConditionalOnClass(name = "feign.Feign") @ConditionalOnProperty(name = "spring.cloud.polaris.discovery.eager-load.feign.enabled", havingValue = "true", matchIfMissing = true) - public FeignEagerLoadSmartLifecycle feignEagerLoadSmartLifecycle( - ApplicationContext applicationContext, @Autowired(required = false) PolarisDiscoveryClient polarisDiscoveryClient, - @Autowired(required = false) PolarisReactiveDiscoveryClient polarisReactiveDiscoveryClient) { - return new FeignEagerLoadSmartLifecycle(applicationContext, polarisDiscoveryClient, polarisReactiveDiscoveryClient); + public FeignEagerLoadContextInitializer feignEagerLoadContextInitializer( + ApplicationContext applicationContext, + LoadBalancerClientFactory loadBalancerClientFactory, + LoadBalancerEagerLoadProperties loadBalancerEagerLoadProperties) { + return new FeignEagerLoadContextInitializer(applicationContext, loadBalancerClientFactory, loadBalancerEagerLoadProperties); } @Bean @ConditionalOnProperty(name = "spring.cloud.polaris.discovery.eager-load.services.enabled", havingValue = "true", matchIfMissing = true) @@ -48,5 +54,12 @@ public class PolarisEagerLoadAutoConfiguration { @Autowired(required = false) PolarisReactiveDiscoveryClient polarisReactiveDiscoveryClient) { return new ServicesEagerLoadSmartLifecycle(polarisDiscoveryClient, polarisReactiveDiscoveryClient); } + + @Bean + @ConditionalOnProperty(value = "spring.cloud.loadbalancer.eager-load.enabled", matchIfMissing = true) + public PolarisLoadBalancerEagerContextInitializer polarisLoadBalancerEagerContextInitializer( + LoadBalancerClientFactory loadBalancerClientFactory, LoadBalancerEagerLoadProperties properties) { + return new PolarisLoadBalancerEagerContextInitializer(loadBalancerClientFactory, properties.getClients()); + } } diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializer.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializer.java new file mode 100644 index 000000000..c0dcda70f --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializer.java @@ -0,0 +1,164 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.feign; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.net.URI; +import java.util.HashSet; +import java.util.Set; + +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.LoadBalancerEagerLoadProperties; +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.LoadBalancerWarmUpUtils; +import com.tencent.polaris.api.utils.StringUtils; +import feign.Target; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.aop.framework.AopProxy; +import org.springframework.aop.framework.JdkDynamicAopProxyUtils; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; + +/** + * Feign eager load context initializer. + * Implements ApplicationListener<ApplicationReadyEvent> to warm up FeignClient services + * after the application is ready. + * + * @author Yuwei Fu + */ +public class FeignEagerLoadContextInitializer implements ApplicationListener { + + private static final Logger LOG = LoggerFactory.getLogger(FeignEagerLoadContextInitializer.class); + + private final ApplicationContext applicationContext; + + private final LoadBalancerClientFactory loadBalancerClientFactory; + + private final LoadBalancerEagerLoadProperties loadBalancerEagerLoadProperties; + + public FeignEagerLoadContextInitializer(ApplicationContext applicationContext, + LoadBalancerClientFactory loadBalancerClientFactory, + LoadBalancerEagerLoadProperties loadBalancerEagerLoadProperties) { + this.applicationContext = applicationContext; + this.loadBalancerClientFactory = loadBalancerClientFactory; + this.loadBalancerEagerLoadProperties = loadBalancerEagerLoadProperties; + } + + public static Target.HardCodedTarget getHardCodedTarget(Object proxy) { + try { + int count = 0; + Object invocationHandler = proxy; + // Avoid infinite loop + while (count++ < 100) { + invocationHandler = Proxy.getInvocationHandler(invocationHandler); + if (invocationHandler instanceof AopProxy) { + invocationHandler = JdkDynamicAopProxyUtils.getTarget(invocationHandler); + continue; + } + break; + } + + for (Field field : invocationHandler.getClass().getDeclaredFields()) { + field.setAccessible(true); + Object fieldValue = field.get(invocationHandler); + if (fieldValue instanceof Target.HardCodedTarget) { + return (Target.HardCodedTarget) fieldValue; + } + } + } + catch (Exception e) { + if (LOG.isDebugEnabled()) { + LOG.debug("proxy:{}, getTarget failed.", proxy, e); + } + } + return null; + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + LOG.info("feign eager-load start"); + + // Get services that are already warmed by PolarisLoadBalancerEagerContextInitializer + Set skipServices = getLoadBalancerEagerLoadServices(); + + // Set to track already warmed services + Set warmedServices = new HashSet<>(); + + // Warm up FeignClient services + for (Object bean : applicationContext.getBeansWithAnnotation(FeignClient.class).values()) { + try { + if (Proxy.isProxyClass(bean.getClass())) { + Target.HardCodedTarget hardCodedTarget = getHardCodedTarget(bean); + if (hardCodedTarget != null) { + FeignClient feignClient = hardCodedTarget.type().getAnnotation(FeignClient.class); + // if feignClient contains url, it doesn't need to eager load. + if (StringUtils.isEmpty(feignClient.url())) { + // support variables and default values. + String url = hardCodedTarget.name(); + // refer to FeignClientFactoryBean, convert to URL, then take the host as the service name. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url; + } + String serviceName = URI.create(url).getHost(); + + // Skip if already warmed by PolarisLoadBalancerEagerContextInitializer + if (skipServices.contains(serviceName)) { + LOG.debug("[{}] skip eager-load, already configured in LoadBalancerEagerLoadProperties.clients", serviceName); + continue; + } + + // Skip if already warmed in this round + if (warmedServices.contains(serviceName)) { + LOG.debug("[{}] already warmed, skip.", serviceName); + continue; + } + + LOG.info("[{}] eager-load start, feign name: {}", serviceName, hardCodedTarget.name()); + LoadBalancerWarmUpUtils.warmUp(loadBalancerClientFactory, serviceName); + + warmedServices.add(serviceName); + } + } + } + } + catch (Exception e) { + LOG.debug("[{}] eager-load failed.", bean, e); + } + } + LOG.info("feign eager-load end"); + } + + /** + * Get services configured in LoadBalancerEagerLoadProperties. + * These services are warmed by PolarisLoadBalancerEagerContextInitializer. + * @return set of service names to skip + */ + private Set getLoadBalancerEagerLoadServices() { + Set services = new HashSet<>(); + if (loadBalancerEagerLoadProperties != null + && loadBalancerEagerLoadProperties.isEnabled() + && loadBalancerEagerLoadProperties.getClients() != null) { + services.addAll(loadBalancerEagerLoadProperties.getClients()); + } + return services; + } +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadSmartLifecycle.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadSmartLifecycle.java deleted file mode 100644 index 327edb59b..000000000 --- a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadSmartLifecycle.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Tencent is pleased to support the open source community by making spring-cloud-tencent available. - * - * Copyright (C) 2021 Tencent. All rights reserved. - * - * Licensed under the BSD 3-Clause License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://opensource.org/licenses/BSD-3-Clause - * - * Unless required by applicable law or agreed to in writing, software distributed - * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - * CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ - -package com.tencent.cloud.polaris.eager.instrument.feign; - -import java.lang.reflect.Field; -import java.lang.reflect.Proxy; -import java.net.URI; - -import com.tencent.cloud.polaris.discovery.PolarisDiscoveryClient; -import com.tencent.cloud.polaris.discovery.reactive.PolarisReactiveDiscoveryClient; -import com.tencent.polaris.api.utils.StringUtils; -import feign.Target; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.context.ApplicationContext; -import org.springframework.context.SmartLifecycle; - -public class FeignEagerLoadSmartLifecycle implements SmartLifecycle { - - private static final Logger LOG = LoggerFactory.getLogger(FeignEagerLoadSmartLifecycle.class); - - private final ApplicationContext applicationContext; - - private final PolarisDiscoveryClient polarisDiscoveryClient; - - private final PolarisReactiveDiscoveryClient polarisReactiveDiscoveryClient; - - public FeignEagerLoadSmartLifecycle(ApplicationContext applicationContext, PolarisDiscoveryClient polarisDiscoveryClient, - PolarisReactiveDiscoveryClient polarisReactiveDiscoveryClient) { - this.applicationContext = applicationContext; - this.polarisDiscoveryClient = polarisDiscoveryClient; - this.polarisReactiveDiscoveryClient = polarisReactiveDiscoveryClient; - } - - @Override - public void start() { - LOG.info("feign eager-load start"); - for (Object bean : applicationContext.getBeansWithAnnotation(FeignClient.class).values()) { - try { - if (Proxy.isProxyClass(bean.getClass())) { - Target.HardCodedTarget hardCodedTarget = getHardCodedTarget(bean); - if (hardCodedTarget != null) { - FeignClient feignClient = hardCodedTarget.type().getAnnotation(FeignClient.class); - // if feignClient contains url, it doesn't need to eager load. - if (StringUtils.isEmpty(feignClient.url())) { - // support variables and default values. - String url = hardCodedTarget.name(); - // refer to FeignClientFactoryBean, convert to URL, then take the host as the service name. - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "http://" + url; - } - String serviceName = URI.create(url).getHost(); - - LOG.info("[{}] eager-load start", serviceName); - if (polarisDiscoveryClient != null) { - polarisDiscoveryClient.getInstances(serviceName); - } - else if (polarisReactiveDiscoveryClient != null) { - polarisReactiveDiscoveryClient.getInstances(serviceName).subscribe(); - } - else { - LOG.warn("[{}] no discovery client found.", serviceName); - } - LOG.info("[{}] eager-load end", serviceName); - } - } - } - } - catch (Exception e) { - LOG.debug("[{}] eager-load failed.", bean, e); - } - } - LOG.info("feign eager-load end"); - - } - - public static Target.HardCodedTarget getHardCodedTarget(Object proxy) { - try { - Object invocationHandler = Proxy.getInvocationHandler(proxy); - - for (Field field : invocationHandler.getClass().getDeclaredFields()) { - field.setAccessible(true); - Object fieldValue = field.get(invocationHandler); - if (fieldValue instanceof Target.HardCodedTarget) { - return (Target.HardCodedTarget) fieldValue; - } - } - } - catch (Exception e) { - if (LOG.isDebugEnabled()) { - LOG.debug("proxy:{}, getTarget failed.", proxy, e); - } - } - return null; - } - - @Override - public void stop() { - - } - - @Override - public boolean isRunning() { - return false; - } - - @Override - public int getPhase() { - return 10; - } -} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerEagerLoadProperties.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerEagerLoadProperties.java new file mode 100644 index 000000000..93c27aae8 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerEagerLoadProperties.java @@ -0,0 +1,47 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.loadbalancer; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("spring.cloud.loadbalancer.eager-load") +public class LoadBalancerEagerLoadProperties { + + private List clients; + + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getClients() { + return clients; + } + + public void setClients(List clients) { + this.clients = clients; + } + +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerWarmUpUtils.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerWarmUpUtils.java new file mode 100644 index 000000000..ed289b26d --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/LoadBalancerWarmUpUtils.java @@ -0,0 +1,64 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.loadbalancer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; + +/** + * Utility class for load balancer warm-up operations. + * Provides common warm-up logic for eager loading services. + * + * @author Yuwei Fu + */ +public final class LoadBalancerWarmUpUtils { + + private static final Logger LOG = LoggerFactory.getLogger(LoadBalancerWarmUpUtils.class); + + private LoadBalancerWarmUpUtils() { + } + + /** + * Warm up a service by triggering load balancer initialization. + * @param factory the LoadBalancerClientFactory + * @param serviceName the service name to warm up + * @return true if warm-up succeeded, false otherwise + */ + public static boolean warmUp(LoadBalancerClientFactory factory, String serviceName) { + try { + ReactiveLoadBalancer loadBalancer = factory.getInstance(serviceName); + if (loadBalancer != null) { + loadBalancer.choose(); + LOG.info("[{}] eager-load end", serviceName); + return true; + } + else { + LOG.warn("[{}] no loadBalancer found.", serviceName); + return false; + } + } + catch (Exception e) { + LOG.debug("[{}] eager-load failed.", serviceName, e); + return false; + } + } +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializer.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializer.java new file mode 100644 index 000000000..370efcbcc --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializer.java @@ -0,0 +1,66 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.loadbalancer; + + +import java.util.List; + +import com.tencent.polaris.api.utils.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.ApplicationListener; + +/** + * @author Yuwei Fu + */ +public class PolarisLoadBalancerEagerContextInitializer implements ApplicationListener { + + + private static final Logger LOG = LoggerFactory.getLogger(PolarisLoadBalancerEagerContextInitializer.class); + + private final LoadBalancerClientFactory factory; + + private final List serviceNames; + + public PolarisLoadBalancerEagerContextInitializer(LoadBalancerClientFactory factory, List serviceNames) { + this.factory = factory; + this.serviceNames = serviceNames; + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + + LOG.info("spring cloud eager-load start"); + try { + if (!CollectionUtils.isEmpty(serviceNames)) { + for (String serviceName : serviceNames) { + LoadBalancerWarmUpUtils.warmUp(factory, serviceName); + } + } + LOG.info("spring cloud eager-load end"); + } + catch (Exception e) { + if (LOG.isDebugEnabled()) { + LOG.debug("spring cloud eager-load failed.", e); + } + } + } +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxyUtils.java b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxyUtils.java new file mode 100644 index 000000000..380d84f38 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxyUtils.java @@ -0,0 +1,50 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package org.springframework.aop.framework; + +import java.lang.reflect.Field; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class JdkDynamicAopProxyUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(JdkDynamicAopProxyUtils.class); + + private JdkDynamicAopProxyUtils() { + } + + public static Object getTarget(Object invocationHandler) { + if (invocationHandler instanceof JdkDynamicAopProxy) { + try { + JdkDynamicAopProxy jdkDynamicAopProxy = (JdkDynamicAopProxy) invocationHandler; + Field advisedField = JdkDynamicAopProxy.class.getDeclaredField("advised"); + advisedField.setAccessible(true); + AdvisedSupport advisedSupport = (AdvisedSupport) advisedField.get(jdkDynamicAopProxy); + + if (advisedSupport != null && advisedSupport.getTargetSource() != null) { + return advisedSupport.getTargetSource().getTarget(); + } + } + catch (Exception e) { + LOGGER.error("Unexpected error occurred while getting target from JdkDynamicAopProxy", e); + } + } + return null; + } +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializerTest.java b/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializerTest.java new file mode 100644 index 000000000..243de0f6d --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/feign/FeignEagerLoadContextInitializerTest.java @@ -0,0 +1,249 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.feign; + +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.LoadBalancerEagerLoadProperties; +import com.tencent.cloud.polaris.registry.PolarisAutoServiceRegistration; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +/** + * Test for {@link FeignEagerLoadContextInitializer}. + * + * @author Test Author + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, + classes = FeignEagerLoadContextInitializerTest.TestApplication.class, + properties = { + "server.port=48085", + "spring.config.location = classpath:application-test.yml", + "spring.main.web-application-type = servlet", + "spring.cloud.gateway.enabled = false", + "spring.cloud.polaris.discovery.eager-load.enabled = true", + "spring.cloud.polaris.discovery.eager-load.feign.enabled = true", + "spring.cloud.loadbalancer.eager-load.enabled = true", + "spring.cloud.loadbalancer.eager-load.clients = test-service-1,test-service-2,test-feign-service-skip" + }) +public class FeignEagerLoadContextInitializerTest { + + @MockBean + private LoadBalancerClientFactory loadBalancerClientFactory; + + @Autowired + private FeignEagerLoadContextInitializer feignEagerLoadContextInitializer; + + @Autowired + private LoadBalancerEagerLoadProperties loadBalancerEagerLoadProperties; + + @Autowired + private ConfigurableApplicationContext applicationContext; + + @BeforeEach + public void setUp() { + reset(loadBalancerClientFactory); + } + + @Test + public void testLoadBalancerEagerLoadPropertiesLoaded() { + // Verify LoadBalancerEagerLoadProperties is loaded correctly + assertThat(loadBalancerEagerLoadProperties.getClients()) + .isNotNull() + .containsExactlyInAnyOrder("test-service-1", "test-service-2", "test-feign-service-skip"); + assertThat(loadBalancerEagerLoadProperties.isEnabled()).isTrue(); + } + + @Test + public void testFeignClient() { + // Prepare mock + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance(anyString())).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + feignEagerLoadContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify services in LoadBalancerEagerLoadProperties.clients are NOT warmed (skipped) + // because they are handled by PolarisLoadBalancerEagerContextInitializer + verify(loadBalancerClientFactory, never()).getInstance("test-service-1"); + verify(loadBalancerClientFactory, never()).getInstance("test-service-2"); + + // Verify FeignClient services are warmed (without url) + verify(loadBalancerClientFactory, times(1)).getInstance("test-feign-service"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-feign-service-http"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-feign-service-https"); + + // Verify FeignClient with url is NOT warmed + verify(loadBalancerClientFactory, never()).getInstance("localhost:48085"); + + // Verify choose method is called + verify(mockLoadBalancer, times(3)).choose(); + } + + @Test + public void testSkipServicesInLoadBalancerEagerLoadProperties() { + // Prepare mock + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance(anyString())).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + feignEagerLoadContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify services configured in LoadBalancerEagerLoadProperties.clients are skipped + // test-feign-service-skip is configured in LoadBalancerEagerLoadProperties.clients + verify(loadBalancerClientFactory, never()).getInstance("test-feign-service-skip"); + } + + @Test + public void testFeignClientWithHttpPrefix() { + // Prepare mock + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance(anyString())).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + feignEagerLoadContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify service name is correctly extracted from http:// prefix + verify(loadBalancerClientFactory, times(1)).getInstance("test-feign-service-http"); + } + + @Test + public void testFeignClientWithHttpsPrefix() { + // Prepare mock + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance(anyString())).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + feignEagerLoadContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify service name is correctly extracted from https:// prefix + verify(loadBalancerClientFactory, times(1)).getInstance("test-feign-service-https"); + } + + @SpringBootApplication + @EnableFeignClients + @RestController + protected static class TestApplication { + + @Bean + public TestBeanPostProcessor testBeanPostProcessor() { + return new TestBeanPostProcessor(); + } + + @Bean + public FeignAspect feignAspect() { + return new FeignAspect(); + } + + @RequestMapping("/test") + public String test() { + return "test"; + } + + // Normal FeignClient (without url) + @FeignClient(name = "test-feign-service") + public interface TestFeignClient { + @RequestMapping("/test") + String test(); + } + + // FeignClient with http:// prefix + @FeignClient(name = "http://test-feign-service-http") + public interface TestFeignClientWithHttp { + @RequestMapping("/test") + String test(); + } + + // FeignClient with https:// prefix + @FeignClient(name = "https://test-feign-service-https") + public interface TestFeignClientWithHttps { + @RequestMapping("/test") + String test(); + } + + // FeignClient with url (should skip warm-up) + @FeignClient(name = "test-feign-with-url", url = "http://localhost:48085") + public interface TestFeignClientWithUrl { + @RequestMapping("/test") + String test(); + } + + // FeignClient that is also in LoadBalancerEagerLoadProperties.clients + // This service should be skipped in FeignEagerLoadContextInitializer + @FeignClient(name = "test-feign-service-skip") + public interface TestFeignClientSkip { + @RequestMapping("/test") + String test(); + } + } + + static class TestBeanPostProcessor implements BeanPostProcessor { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof PolarisAutoServiceRegistration) { + return org.mockito.Mockito.mock(PolarisAutoServiceRegistration.class); + } + return bean; + } + } + + @Aspect + static class FeignAspect { + + private static final Logger LOG = LoggerFactory.getLogger(FeignAspect.class); + + @Around("@within(org.springframework.cloud.openfeign.FeignClient)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + LOG.info("FeignAspect around"); + return joinPoint.proceed(); + } + } +} diff --git a/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializerTest.java b/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializerTest.java new file mode 100644 index 000000000..36d34fdaa --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-discovery/src/test/java/com/tencent/cloud/polaris/eager/instrument/loadbalancer/PolarisLoadBalancerEagerContextInitializerTest.java @@ -0,0 +1,164 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.polaris.eager.instrument.loadbalancer; + +import com.tencent.cloud.polaris.registry.PolarisAutoServiceRegistration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +/** + * Test for {@link PolarisLoadBalancerEagerContextInitializer}. + * + * @author Test Author + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = DEFINED_PORT, + classes = PolarisLoadBalancerEagerContextInitializerTest.TestApplication.class, + properties = { + "server.port=48086", + "spring.config.location = classpath:application-test.yml", + "spring.main.web-application-type = servlet", + "spring.cloud.gateway.enabled = false", + "spring.cloud.polaris.discovery.eager-load.enabled = true", + "spring.cloud.loadbalancer.eager-load.enabled = true", + "spring.cloud.loadbalancer.eager-load.clients = test-service-1,test-service-2,test-service-3" + }) +public class PolarisLoadBalancerEagerContextInitializerTest { + + @MockBean + private LoadBalancerClientFactory loadBalancerClientFactory; + + @Autowired + private PolarisLoadBalancerEagerContextInitializer polarisLoadBalancerEagerContextInitializer; + + @Autowired + private LoadBalancerEagerLoadProperties loadBalancerEagerLoadProperties; + + @BeforeEach + public void setUp() { + reset(loadBalancerClientFactory); + } + + @Test + public void testLoadBalancerEagerLoadPropertiesLoaded() { + // Verify LoadBalancerEagerLoadProperties is loaded correctly + assertThat(loadBalancerEagerLoadProperties.getClients()) + .isNotNull() + .containsExactlyInAnyOrder("test-service-1", "test-service-2", "test-service-3"); + assertThat(loadBalancerEagerLoadProperties.isEnabled()).isTrue(); + } + + @Test + public void testWarmUpServices() { + // Prepare mock + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance(anyString())).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + polarisLoadBalancerEagerContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify all services in LoadBalancerEagerLoadProperties.clients are warmed + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-1"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-2"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-3"); + + // Verify choose method is called for each service + verify(mockLoadBalancer, times(3)).choose(); + } + + @Test + public void testWarmUpWithNullLoadBalancer() { + // Prepare mock - return null for some services + when(loadBalancerClientFactory.getInstance("test-service-1")).thenReturn(null); + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance("test-service-2")).thenReturn(mockLoadBalancer); + when(loadBalancerClientFactory.getInstance("test-service-3")).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + polarisLoadBalancerEagerContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify all services are attempted + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-1"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-2"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-3"); + + // Verify choose method is only called for non-null load balancers + verify(mockLoadBalancer, times(2)).choose(); + } + + @Test + public void testWarmUpWithException() { + // Prepare mock - throw exception for some services + when(loadBalancerClientFactory.getInstance("test-service-1")) + .thenThrow(new RuntimeException("Test exception")); + ReactiveLoadBalancer mockLoadBalancer = mock(ReactiveLoadBalancer.class); + when(loadBalancerClientFactory.getInstance("test-service-2")).thenReturn(mockLoadBalancer); + when(loadBalancerClientFactory.getInstance("test-service-3")).thenReturn(mockLoadBalancer); + + // Execute warm-up by triggering ApplicationReadyEvent + polarisLoadBalancerEagerContextInitializer.onApplicationEvent(mock(ApplicationReadyEvent.class)); + + // Verify all services are attempted (exception should not stop the loop) + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-1"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-2"); + verify(loadBalancerClientFactory, times(1)).getInstance("test-service-3"); + } + + @SpringBootApplication + protected static class TestApplication { + + @Bean + public TestBeanPostProcessor testBeanPostProcessor() { + return new TestBeanPostProcessor(); + } + + } + + static class TestBeanPostProcessor implements BeanPostProcessor { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof PolarisAutoServiceRegistration) { + return org.mockito.Mockito.mock(PolarisAutoServiceRegistration.class); + } + return bean; + } + } +} diff --git a/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/main/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilter.java b/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/main/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilter.java index 288b60502..bed93917a 100644 --- a/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/main/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilter.java +++ b/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/main/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilter.java @@ -250,6 +250,14 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { ServerHttpRequest request = exchange.getRequest(); String[] apis = rebuildExternalApi(request, path.value()); GroupContext.ContextRoute contextRoute = manager.getGroupPathRoute(config.getGroup(), apis[0]); + // Before Spring 6.0, trailing slashes were matched by default (e.g. "/api/test/" could match "/api/test"). + // Retry without trailing slashes to maintain backward compatibility. + if (contextRoute == null) { + String trimmedMatchPath = stripTrailingSlashes(apis[0]); + if (!trimmedMatchPath.equals(apis[0])) { + contextRoute = manager.getGroupPathRoute(config.getGroup(), trimmedMatchPath); + } + } if (contextRoute == null) { String msg = String.format("[externalFilter] Can't find context route for group: %s, path: %s, origin path: %s", config.getGroup(), apis[0], path.value()); logger.warn(msg); @@ -272,6 +280,14 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { logger.debug("[msFilter] path:{}, apis: {}", path, apis); // check api GroupContext.ContextRoute contextRoute = manager.getGroupPathRoute(config.getGroup(), apis[0]); + // Before Spring 6.0, trailing slashes were matched by default (e.g. "/api/test/" could match "/api/test"). + // Retry without trailing slashes to maintain backward compatibility. + if (contextRoute == null) { + String trimmedMatchPath = stripTrailingSlashes(apis[0]); + if (!trimmedMatchPath.equals(apis[0])) { + contextRoute = manager.getGroupPathRoute(config.getGroup(), trimmedMatchPath); + } + } if (contextRoute == null) { String msg = String.format("[msFilter] Can't find context route for group: %s, path: %s, origin path: %s", config.getGroup(), apis[0], path.value()); logger.warn(msg); @@ -327,6 +343,14 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { logger.debug("[unitFilter] path:{}, apis: {}", path, apis); // check api GroupContext.ContextRoute contextRoute = manager.getGroupUnitPathRoute(config.getGroup(), apis[0]); + // Before Spring 6.0, trailing slashes were matched by default (e.g. "/api/test/" could match "/api/test"). + // Retry without trailing slashes to maintain backward compatibility. + if (contextRoute == null) { + String trimmedMatchPath = stripTrailingSlashes(apis[0]); + if (!trimmedMatchPath.equals(apis[0])) { + contextRoute = manager.getGroupUnitPathRoute(config.getGroup(), trimmedMatchPath); + } + } if (contextRoute == null) { String msg = String.format("[unitFilter] Can't find context route for group: %s, path: %s, origin path: %s", config.getGroup(), apis[0], path.value()); logger.warn(msg); @@ -367,9 +391,10 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { matchPath.append("/").append(pathSegments[i]); realPath.append("/").append(pathSegments[i]); } - if (path.endsWith("/")) { - matchPath.append("/"); - realPath.append("/"); + String trailingSlashes = getTrailingSlashes(path); + if (StringUtils.isNotEmpty(trailingSlashes)) { + matchPath.append(trailingSlashes); + realPath.append(trailingSlashes); } return new String[] {matchPath.toString(), realPath.toString()}; } @@ -387,9 +412,10 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { matchPath.append("/").append(pathSegments[i]); realPath.append("/").append(pathSegments[i]); } - if (path.endsWith("/")) { - matchPath.append("/"); - realPath.append("/"); + String trailingSlashes = getTrailingSlashes(path); + if (StringUtils.isNotEmpty(trailingSlashes)) { + matchPath.append(trailingSlashes); + realPath.append(trailingSlashes); } return new String[] {matchPath.toString(), realPath.toString()}; } @@ -443,9 +469,10 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { matchPath.append("/").append(pathSegments[i]); realPath.append("/").append(pathSegments[i]); } - if (path.endsWith("/")) { - matchPath.append("/"); - realPath.append("/"); + String trailingSlashes = getTrailingSlashes(path); + if (StringUtils.isNotEmpty(trailingSlashes)) { + matchPath.append(trailingSlashes); + realPath.append(trailingSlashes); } return new String[] {matchPath.toString(), realPath.toString()}; @@ -601,4 +628,31 @@ public class ContextGatewayFilter implements GatewayFilter, Ordered { MetadataContextUtils.putMetadataObjectValue(ContextConstant.Trace.EXTRA_TRACE_ATTRIBUTES, traceAttributes); } + + /** + * Returns all trailing slashes of the given path, e.g. "/api/test///" → "///" + */ + private String getTrailingSlashes(String path) { + int end = path.length(); + int start = end; + while (start > 0 && path.charAt(start - 1) == '/') { + start--; + } + return path.substring(start, end); + } + + /** + * Strips trailing slashes from the given path, e.g. "GET|/api/test/" → "GET|/api/test". + * Returns the original string if no trailing slashes are present. + *

+ * Used to maintain backward compatibility with pre-Spring-6.0 behavior, where trailing slashes + * were matched by default (e.g. "/api/test/" could match a route configured as "/api/test"). + */ + private String stripTrailingSlashes(String path) { + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == '/') { + end--; + } + return path.substring(0, end); + } } diff --git a/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/test/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilterTest.java b/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/test/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilterTest.java index 84d44ec80..b75e62553 100644 --- a/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/test/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilterTest.java +++ b/spring-cloud-tencent-plugin-starters/spring-cloud-starter-tencent-gateway-plugin/src/test/java/com/tencent/cloud/plugin/gateway/context/ContextGatewayFilterTest.java @@ -17,6 +17,7 @@ package com.tencent.cloud.plugin.gateway.context; +import java.lang.reflect.Method; import java.net.URI; import java.util.Collections; @@ -119,6 +120,139 @@ class ContextGatewayFilterTest { } + // Test getTrailingSlashes via reflection + @Test + void shouldGetTrailingSlashesCorrectly() throws Exception { + Method method = ContextGatewayFilter.class.getDeclaredMethod("getTrailingSlashes", String.class); + method.setAccessible(true); + + assertThat(method.invoke(filter, "/api/test///")).isEqualTo("///"); + assertThat(method.invoke(filter, "/api/test/")).isEqualTo("/"); + assertThat(method.invoke(filter, "/api/test")).isEqualTo(""); + assertThat(method.invoke(filter, "/")).isEqualTo("/"); + assertThat(method.invoke(filter, "///")).isEqualTo("///"); + assertThat(method.invoke(filter, "")).isEqualTo(""); + } + + // Test stripTrailingSlashes via reflection + @Test + void shouldStripTrailingSlashesCorrectly() throws Exception { + Method method = ContextGatewayFilter.class.getDeclaredMethod("stripTrailingSlashes", String.class); + method.setAccessible(true); + + assertThat(method.invoke(filter, "GET|/api/test/")).isEqualTo("GET|/api/test"); + assertThat(method.invoke(filter, "GET|/api/test///")).isEqualTo("GET|/api/test"); + assertThat(method.invoke(filter, "GET|/api/test")).isEqualTo("GET|/api/test"); + assertThat(method.invoke(filter, "/api/test/")).isEqualTo("/api/test"); + assertThat(method.invoke(filter, "/")).isEqualTo(""); + assertThat(method.invoke(filter, "///")).isEqualTo(""); + assertThat(method.invoke(filter, "")).isEqualTo(""); + } + + // Test trailing slash backward compatibility: request "/api/test/" should match route configured as "/api/test" + @Test + void shouldMatchRouteWithTrailingSlashCompatibility() { + // Setup group context with route configured WITHOUT trailing slash + GroupContext.ContextPredicate predicate = createPredicate(ApiType.EXTERNAL, Position.PATH, Position.PATH); + groupContext.setPredicate(predicate); + groupContext.setRoutes(Collections.singletonList( + createContextRoute("GET|/external/api", "GET", "testNS", "testSvc") + )); + + // Create test request WITH trailing slash + MockServerHttpRequest request = MockServerHttpRequest.get("/context/external/api/").build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + // Exact match (with trailing slash) returns null; stripped match returns the route + GroupContext.ContextRoute route = createContextRoute("GET|/external/api", "GET", "http://test.com"); + when(mockManager.getGroupPathRoute("testGroup", "GET|/external/api/")).thenReturn(null); + when(mockManager.getGroupPathRoute("testGroup", "GET|/external/api")).thenReturn(route); + + // Execute filter — should not throw NotFoundException + Mono result = filter.filter(exchange, mockChain); + + // Verify the request URL was resolved correctly (path preserved with trailing slash) + URI finalUri = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(finalUri.toString()).isEqualTo("http://test.com/external/api/"); + } + + // Test that exact match (without trailing slash) still works normally + @Test + void shouldMatchRouteExactlyWithoutTrailingSlash() { + // Setup group context + GroupContext.ContextPredicate predicate = createPredicate(ApiType.EXTERNAL, Position.PATH, Position.PATH); + groupContext.setPredicate(predicate); + groupContext.setRoutes(Collections.singletonList( + createContextRoute("GET|/external/api", "GET", "testNS", "testSvc") + )); + + // Create test request WITHOUT trailing slash + MockServerHttpRequest request = MockServerHttpRequest.get("/context/external/api").build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + GroupContext.ContextRoute route = createContextRoute("GET|/external/api", "GET", "http://test.com"); + when(mockManager.getGroupPathRoute("testGroup", "GET|/external/api")).thenReturn(route); + + // Execute filter + filter.filter(exchange, mockChain); + + // Verify the request URL was resolved correctly (path preserved without trailing slash) + URI finalUri = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(finalUri.toString()).isEqualTo("http://test.com/external/api"); + } + + // Test MS API trailing slash backward compatibility: request "/context/ns/svc/api/test/" should match route configured as "GET|/ns/svc/api/test" + @Test + void shouldMatchMsRouteWithTrailingSlashCompatibility() { + // Setup group context with MS API predicate and route configured WITHOUT trailing slash + GroupContext.ContextPredicate predicate = createPredicate(ApiType.MS, Position.PATH, Position.PATH); + groupContext.setPredicate(predicate); + groupContext.setRoutes(Collections.singletonList( + createContextRoute("GET|/testNS/testSvc/api/test", "GET", "testNS", "testSvc") + )); + + // Create test request WITH trailing slash: /context/namespace/service/api/path/ + MockServerHttpRequest request = MockServerHttpRequest.get("/context/testNS/testSvc/api/test/").build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + // Exact match (with trailing slash) returns null; stripped match returns the route + GroupContext.ContextRoute route = createContextRoute("GET|/testNS/testSvc/api/test", "GET", "testNS", "testSvc"); + when(mockManager.getGroupPathRoute("testGroup", "GET|/testNS/testSvc/api/test/")).thenReturn(null); + when(mockManager.getGroupPathRoute("testGroup", "GET|/testNS/testSvc/api/test")).thenReturn(route); + + // Execute filter — should not throw NotFoundException + Mono result = filter.filter(exchange, mockChain); + + // Verify the request URL was resolved correctly (path preserved with trailing slash) + URI finalUri = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(finalUri.toString()).isEqualTo("lb://testSvc/api/test/"); + } + + // Test that exact match for MS API (without trailing slash) still works normally + @Test + void shouldMatchMsRouteExactlyWithoutTrailingSlash() { + // Setup group context with MS API predicate + GroupContext.ContextPredicate predicate = createPredicate(ApiType.MS, Position.PATH, Position.PATH); + groupContext.setPredicate(predicate); + groupContext.setRoutes(Collections.singletonList( + createContextRoute("GET|/testNS/testSvc/api/test", "GET", "testNS", "testSvc") + )); + + // Create test request WITHOUT trailing slash + MockServerHttpRequest request = MockServerHttpRequest.get("/context/testNS/testSvc/api/test").build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + GroupContext.ContextRoute route = createContextRoute("GET|/testNS/testSvc/api/test", "GET", "testNS", "testSvc"); + when(mockManager.getGroupPathRoute("testGroup", "GET|/testNS/testSvc/api/test")).thenReturn(route); + + // Execute filter + filter.filter(exchange, mockChain); + + // Verify the request URL was resolved correctly (path preserved without trailing slash) + URI finalUri = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(finalUri.toString()).isEqualTo("lb://testSvc/api/test"); + } + // Helper method to create test predicate private GroupContext.ContextPredicate createPredicate(ApiType apiType, Position nsPos, Position svcPos) { GroupContext.ContextPredicate predicate = new GroupContext.ContextPredicate(); diff --git a/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/config/UnitBeanPostProcessor.java b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/config/UnitBeanPostProcessor.java index 3ac8db172..e2346b115 100644 --- a/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/config/UnitBeanPostProcessor.java +++ b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/config/UnitBeanPostProcessor.java @@ -17,10 +17,12 @@ package com.tencent.cloud.plugin.unit.config; -import com.tencent.cloud.plugin.unit.discovery.UnitFeignEagerLoadSmartLifecycle; +import com.tencent.cloud.plugin.unit.discovery.UnitFeignEagerLoadContextInitializer; +import com.tencent.cloud.plugin.unit.discovery.UnitLoadBalancerEagerContextInitializer; import com.tencent.cloud.plugin.unit.discovery.UnitPolarisDiscoveryClient; import com.tencent.cloud.polaris.discovery.PolarisDiscoveryClient; -import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadSmartLifecycle; +import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadContextInitializer; +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.PolarisLoadBalancerEagerContextInitializer; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; @@ -34,8 +36,12 @@ public class UnitBeanPostProcessor implements BeanPostProcessor { return new UnitPolarisDiscoveryClient(discoveryClient); } - if (bean instanceof FeignEagerLoadSmartLifecycle) { - return new UnitFeignEagerLoadSmartLifecycle(); + if (bean instanceof FeignEagerLoadContextInitializer) { + return new UnitFeignEagerLoadContextInitializer(); + } + + if (bean instanceof PolarisLoadBalancerEagerContextInitializer) { + return new UnitLoadBalancerEagerContextInitializer(); } return bean; diff --git a/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadSmartLifecycle.java b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadContextInitializer.java similarity index 69% rename from spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadSmartLifecycle.java rename to spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadContextInitializer.java index 60cf1f5d6..c53e4dcb5 100644 --- a/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadSmartLifecycle.java +++ b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitFeignEagerLoadContextInitializer.java @@ -17,21 +17,27 @@ package com.tencent.cloud.plugin.unit.discovery; -import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadSmartLifecycle; +import com.tencent.cloud.polaris.eager.instrument.feign.FeignEagerLoadContextInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class UnitFeignEagerLoadSmartLifecycle extends FeignEagerLoadSmartLifecycle { +import org.springframework.boot.context.event.ApplicationReadyEvent; - private static final Logger LOG = LoggerFactory.getLogger(UnitFeignEagerLoadSmartLifecycle.class); +/** + * Unit Feign eager load context initializer. + * Ignores feign eager load in unit mode. + */ +public class UnitFeignEagerLoadContextInitializer extends FeignEagerLoadContextInitializer { + + private static final Logger LOG = LoggerFactory.getLogger(UnitFeignEagerLoadContextInitializer.class); - public UnitFeignEagerLoadSmartLifecycle() { + public UnitFeignEagerLoadContextInitializer() { super(null, null, null); } @Override - public void start() { + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { LOG.info("ignore feign eager load in unit mode"); } } diff --git a/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitLoadBalancerEagerContextInitializer.java b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitLoadBalancerEagerContextInitializer.java new file mode 100644 index 000000000..00aeea97f --- /dev/null +++ b/spring-cloud-tencent-plugin-starters/spring-cloud-tencent-unit-plugin/src/main/java/com/tencent/cloud/plugin/unit/discovery/UnitLoadBalancerEagerContextInitializer.java @@ -0,0 +1,42 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package com.tencent.cloud.plugin.unit.discovery; + +import com.tencent.cloud.polaris.eager.instrument.loadbalancer.PolarisLoadBalancerEagerContextInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.context.event.ApplicationReadyEvent; + +/** + * Unit LoadBalancer eager load context initializer. + * Ignores loadbalancer eager load in unit mode. + */ +public class UnitLoadBalancerEagerContextInitializer extends PolarisLoadBalancerEagerContextInitializer { + + private static final Logger LOG = LoggerFactory.getLogger(UnitLoadBalancerEagerContextInitializer.class); + + public UnitLoadBalancerEagerContextInitializer() { + super(null, null); + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + LOG.info("ignore loadbalancer eager load in unit mode"); + } +}