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");
+ }
+}