From 47fb563efeefb2e9ef80db123a8b820633092778 Mon Sep 17 00:00:00 2001 From: teaho2015 Date: Sun, 12 Jul 2026 23:35:09 +0800 Subject: [PATCH 01/21] add: apollo opentelemetry metric. --- .../pom.xml | 57 ++++++ ...nTelemetryApolloClientMetricsExporter.java | 184 ++++++++++++++++++ ...ernal.exporter.ApolloClientMetricsExporter | 1 + ...emetryApolloClientMetricsExporterTest.java | 162 +++++++++++++++ ...emetryApolloClientMetricsExporterTest.java | 114 +++++++++++ apollo-plugin/pom.xml | 1 + 6 files changed, 519 insertions(+) create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml new file mode 100644 index 00000000..b6ee8eda --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + apollo-plugin + com.ctrip.framework.apollo + ${revision} + ../pom.xml + + + apollo-plugin-client-opentelemetry + Apollo Plugin OpenTelemetry + jar + + + + com.ctrip.framework.apollo + apollo-client + provided + + + io.opentelemetry + opentelemetry-api + 1.35.0 + + + + junit + junit + test + + + org.mockito + mockito-inline + test + + + + \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java new file mode 100644 index 00000000..62dcc1cc --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory; +import com.ctrip.framework.apollo.monitor.internal.exporter.AbstractApolloClientMetricsExporter; +import com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter; +import com.google.common.collect.Maps; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.DoubleGaugeBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongCounterBuilder; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.ObservableDoubleGauge; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; + +/** + * OpenTelemetry implementation of Apollo client metrics exporter. + * Only uses OpenTelemetry API layer (no SDK dependency). + * + * @author teaho2015@gmail.com + */ +public class OpenTelemetryApolloClientMetricsExporter extends + AbstractApolloClientMetricsExporter implements ApolloClientMetricsExporter { + + private static final String OPENTELEMETRY = "opentelemetry"; + private static final String METER_NAME = "apollo-client"; + private static final String COUNTER_UNIT = "1"; + private static final String GAUGE_UNIT = "1"; + + private final Logger logger = DeferredLoggerFactory.getLogger( + OpenTelemetryApolloClientMetricsExporter.class); + + private Meter meter; + private Map counterMap; + private Map gaugeMap; + private Map> gaugeValueMap; + private Map attributesCache; + + @Override + public void doInit() { + // Get meter from global OpenTelemetry + meter = GlobalOpenTelemetry.get().getMeter(METER_NAME); + // Initialize maps + counterMap = new ConcurrentHashMap<>(); + gaugeMap = new ConcurrentHashMap<>(); + gaugeValueMap = new ConcurrentHashMap<>(); + attributesCache = new ConcurrentHashMap<>(); + logger.info("OpenTelemetry metrics exporter initialized with meter: {}", METER_NAME); + } + + @Override + public boolean isSupport(String form) { + return OPENTELEMETRY.equals(form); + } + + @Override + public void registerOrUpdateCounterSample(String name, Map tags, + double incrValue) { + try { + if (meter == null) { + logger.warn("OpenTelemetry meter not initialized, skipping counter registration for '{}'", name); + return; + } + + LongCounter counter = counterMap.computeIfAbsent(name, this::createCounter); + + Attributes attributes = getOrCreateAttributes(tags); + counter.add((long) incrValue, attributes); + + logger.debug("Updated OpenTelemetry counter '{}' with value: {}, tags: {}", + name, incrValue, tags); + } catch (Exception e) { + logger.error("Failed to register or update OpenTelemetry counter '{}'", name, e); + } + } + + private LongCounter createCounter(String name) { + LongCounterBuilder builder = meter.counterBuilder(name) + .setDescription("Apollo counter metrics") + .setUnit(COUNTER_UNIT); + + // Build the counter + return builder.build(); + } + + @Override + public void registerOrUpdateGaugeSample(String name, Map tags, double value) { + if (meter == null) { + logger.warn("OpenTelemetry meter not initialized, skipping gauge registration for '{}'", name); + return; + } + + // Store the gauge value + String gaugeKey = getGaugeKey(name, tags); + gaugeValueMap.put(gaugeKey, new AtomicReference<>(value)); + + // Register gauge if not already registered + gaugeMap.computeIfAbsent(gaugeKey, key -> createGauge(name, tags, gaugeKey)); + + logger.debug("Updated OpenTelemetry gauge '{}' with value: {}, tags: {}", + name, value, tags); + } + + private ObservableDoubleGauge createGauge(String name, Map tags, String gaugeKey) { + Attributes attributes = getOrCreateAttributes(tags); + + DoubleGaugeBuilder gaugeBuilder = meter.gaugeBuilder(name) + .setDescription("Apollo gauge metrics") + .setUnit(GAUGE_UNIT); + + // Register callback for gauge value + return gaugeBuilder.buildWithCallback(measurement -> { + AtomicReference valueRef = gaugeValueMap.get(gaugeKey); + if (valueRef != null) { + Double value = valueRef.get(); + if (value != null) { + measurement.record(value, attributes); + } + } + }); + } + + private Attributes getOrCreateAttributes(Map tags) { + if (tags == null || tags.isEmpty()) { + return Attributes.empty(); + } + + // Create cache key from sorted tag entries for consistency + String cacheKey = createCacheKey(tags); + + return attributesCache.computeIfAbsent(cacheKey, key -> { + AttributesBuilder builder = Attributes.builder(); + tags.forEach(builder::put); + return builder.build(); + }); + } + + private String createCacheKey(Map tags) { + // Sort keys to ensure consistent cache key + return tags.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .reduce((a, b) -> a + ";" + b) + .orElse(""); + } + + private String getGaugeKey(String name, Map tags) { + return name + ":" + createCacheKey(tags); + } + + @Override + public String response() { + // Return simple status information since we're only using API layer + int counterCount = counterMap != null ? counterMap.size() : 0; + int gaugeCount = gaugeMap != null ? gaugeMap.size() : 0; + int attributesCount = attributesCache != null ? attributesCache.size() : 0; + + String meterStatus = (meter != null) ? METER_NAME : "not initialized"; + + return String.format( + "OpenTelemetry metrics exporter status - Counters: %d, Gauges: %d, Cached attributes: %d, Meter: %s", + counterCount, gaugeCount, attributesCount, meterStatus); + } +} \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter new file mode 100644 index 00000000..8e064550 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter @@ -0,0 +1 @@ +com.ctrip.framework.apollo.monitor.internal.exporter.impl.OpenTelemetryApolloClientMetricsExporter \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java new file mode 100644 index 00000000..2e7e8c64 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.HashMap; +import java.util.Map; + +public class OpenTelemetryApolloClientMetricsExporterTest { + + private OpenTelemetryApolloClientMetricsExporter exporter; + private MockedStatic globalOpenTelemetryMock; + private OpenTelemetry openTelemetry; + private Meter meter; + + @Before + public void setUp() { + // Mock GlobalOpenTelemetry + globalOpenTelemetryMock = mockStatic(GlobalOpenTelemetry.class); + openTelemetry = mock(OpenTelemetry.class); + meter = mock(Meter.class); + + when(GlobalOpenTelemetry.get()).thenReturn(openTelemetry); + when(openTelemetry.getMeter("apollo-client")).thenReturn(meter); + + exporter = new OpenTelemetryApolloClientMetricsExporter(); + exporter.doInit(); + } + + @After + public void tearDown() { + if (globalOpenTelemetryMock != null) { + globalOpenTelemetryMock.close(); + } + } + + @Test + public void testIsSupport() { + assertTrue(exporter.isSupport("opentelemetry")); + assertFalse(exporter.isSupport("prometheus")); + assertFalse(exporter.isSupport("other")); + } + + @Test + public void testDoInit() { + // Verify that meter was obtained from GlobalOpenTelemetry + globalOpenTelemetryMock.verify(() -> GlobalOpenTelemetry.get()); + verify(openTelemetry).getMeter("apollo-client"); + } + + @Test + public void testRegisterOrUpdateCounterSample() { + String name = "test_counter"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + tags.put("cluster", "default"); + + // Mock counter builder + io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class); + + when(meter.counterBuilder(name)).thenReturn(counterBuilder); + when(counterBuilder.setDescription("Apollo counter metrics")).thenReturn(counterBuilder); + when(counterBuilder.setUnit("1")).thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + + // This will create the counter on first call + exporter.registerOrUpdateCounterSample(name, tags, 1.0); + + // Verify counter was created + verify(meter).counterBuilder(name); + verify(counterBuilder).build(); + } + + @Test + public void testRegisterOrUpdateGaugeSample() { + String name = "test_gauge"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + tags.put("cluster", "default"); + + // Mock gauge builder + io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class); + + when(meter.gaugeBuilder(name)).thenReturn(gaugeBuilder); + when(gaugeBuilder.setDescription("Apollo gauge metrics")).thenReturn(gaugeBuilder); + when(gaugeBuilder.setUnit("1")).thenReturn(gaugeBuilder); + when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge); + + // This will create the gauge on first call + exporter.registerOrUpdateGaugeSample(name, tags, 3.14); + + // Verify gauge was created + verify(meter).gaugeBuilder(name); + verify(gaugeBuilder).buildWithCallback(any()); + } + + @Test + public void testResponse() { + String response = exporter.response(); + assertNotNull(response); + assertTrue(response.contains("OpenTelemetry metrics exporter status")); + assertTrue(response.contains("Counters: 0")); + assertTrue(response.contains("Gauges: 0")); + assertTrue(response.contains("Meter: apollo-client")); + } + + @Test + public void testResponseWithMetrics() { + // Register some metrics first + Map tags = new HashMap<>(); + tags.put("test", "value"); + + // Mock counter creation + io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class); + when(meter.counterBuilder("test_counter")).thenReturn(counterBuilder); + when(counterBuilder.setDescription(anyString())).thenReturn(counterBuilder); + when(counterBuilder.setUnit(anyString())).thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + + // Mock gauge creation + io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class); + when(meter.gaugeBuilder("test_gauge")).thenReturn(gaugeBuilder); + when(gaugeBuilder.setDescription(anyString())).thenReturn(gaugeBuilder); + when(gaugeBuilder.setUnit(anyString())).thenReturn(gaugeBuilder); + when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge); + + exporter.registerOrUpdateCounterSample("test_counter", tags, 1.0); + exporter.registerOrUpdateGaugeSample("test_gauge", tags, 2.0); + + String response = exporter.response(); + assertTrue(response.contains("Counters: 1")); + assertTrue(response.contains("Gauges: 1")); + } +} \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java new file mode 100644 index 00000000..671df0a8 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class SimpleOpenTelemetryApolloClientMetricsExporterTest { + + private OpenTelemetryApolloClientMetricsExporter exporter; + + @Before + public void setUp() { + exporter = new OpenTelemetryApolloClientMetricsExporter(); + // Note: We don't call doInit() because it requires GlobalOpenTelemetry + // In a real test environment, you would need to setup OpenTelemetry first + } + + @Test + public void testIsSupport() { + assertTrue(exporter.isSupport("opentelemetry")); + assertFalse(exporter.isSupport("prometheus")); + assertFalse(exporter.isSupport("other")); + assertFalse(exporter.isSupport(null)); + } + + @Test + public void testResponseWithoutInit() { + // Even without init, response should return a status message + String response = exporter.response(); + assertNotNull(response); + assertTrue(response.contains("OpenTelemetry metrics exporter status")); + } + + @Test + public void testRegisterOrUpdateCounterSampleWithoutInit() { + // This should not throw exception even without init + String name = "test_counter"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + + try { + exporter.registerOrUpdateCounterSample(name, tags, 1.0); + // If we get here, it means no exception was thrown + assertTrue(true); + } catch (Exception e) { + // It's okay if it throws exception when not initialized + // This depends on OpenTelemetry API behavior + } + } + + @Test + public void testRegisterOrUpdateGaugeSampleWithoutInit() { + // This should not throw exception even without init + String name = "test_gauge"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + + try { + exporter.registerOrUpdateGaugeSample(name, tags, 3.14); + // If we get here, it means no exception was thrown + assertTrue(true); + } catch (Exception e) { + // It's okay if it throws exception when not initialized + // This depends on OpenTelemetry API behavior + } + } + + @Test + public void testEmptyTags() { + String name = "test_metric"; + Map emptyTags = new HashMap<>(); + + try { + exporter.registerOrUpdateCounterSample(name, emptyTags, 1.0); + exporter.registerOrUpdateGaugeSample(name, emptyTags, 2.0); + assertTrue(true); + } catch (Exception e) { + // Acceptable if it throws exception + } + } + + @Test + public void testNullTags() { + String name = "test_metric"; + + try { + exporter.registerOrUpdateCounterSample(name, null, 1.0); + exporter.registerOrUpdateGaugeSample(name, null, 2.0); + assertTrue(true); + } catch (Exception e) { + // Acceptable if it throws exception for null tags + } + } +} \ No newline at end of file diff --git a/apollo-plugin/pom.xml b/apollo-plugin/pom.xml index f0d1b00f..b97e09f4 100644 --- a/apollo-plugin/pom.xml +++ b/apollo-plugin/pom.xml @@ -32,6 +32,7 @@ apollo-plugin-log4j2 apollo-plugin-client-prometheus + apollo-plugin-client-opentelemetry From 3ed4f2933759878483db58010d2edc9c657b0723 Mon Sep 17 00:00:00 2001 From: teaho2015 Date: Wed, 19 Aug 2026 04:12:51 +0800 Subject: [PATCH 02/21] add: apollo opentelemetry metric. --- .../pom.xml | 2 +- ...nTelemetryApolloClientMetricsExporter.java | 116 +++++------------- ...emetryApolloClientMetricsExporterTest.java | 33 +++-- 3 files changed, 54 insertions(+), 97 deletions(-) diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml index b6ee8eda..349bfdbb 100644 --- a/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml @@ -39,7 +39,7 @@ io.opentelemetry opentelemetry-api - 1.35.0 + 1.62.0 diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java index 62dcc1cc..117d7797 100644 --- a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java @@ -23,11 +23,8 @@ import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; -import io.opentelemetry.api.metrics.DoubleGaugeBuilder; -import io.opentelemetry.api.metrics.LongCounter; -import io.opentelemetry.api.metrics.LongCounterBuilder; -import io.opentelemetry.api.metrics.Meter; -import io.opentelemetry.api.metrics.ObservableDoubleGauge; +import io.opentelemetry.api.metrics.*; + import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; @@ -37,7 +34,7 @@ * OpenTelemetry implementation of Apollo client metrics exporter. * Only uses OpenTelemetry API layer (no SDK dependency). * - * @author teaho2015@gmail.com + * @author leon.he@walmart.com */ public class OpenTelemetryApolloClientMetricsExporter extends AbstractApolloClientMetricsExporter implements ApolloClientMetricsExporter { @@ -51,10 +48,8 @@ public class OpenTelemetryApolloClientMetricsExporter extends OpenTelemetryApolloClientMetricsExporter.class); private Meter meter; - private Map counterMap; + private Map counterMap; private Map gaugeMap; - private Map> gaugeValueMap; - private Map attributesCache; @Override public void doInit() { @@ -63,8 +58,6 @@ public void doInit() { // Initialize maps counterMap = new ConcurrentHashMap<>(); gaugeMap = new ConcurrentHashMap<>(); - gaugeValueMap = new ConcurrentHashMap<>(); - attributesCache = new ConcurrentHashMap<>(); logger.info("OpenTelemetry metrics exporter initialized with meter: {}", METER_NAME); } @@ -75,32 +68,19 @@ public boolean isSupport(String form) { @Override public void registerOrUpdateCounterSample(String name, Map tags, - double incrValue) { - try { - if (meter == null) { - logger.warn("OpenTelemetry meter not initialized, skipping counter registration for '{}'", name); - return; - } - - LongCounter counter = counterMap.computeIfAbsent(name, this::createCounter); - - Attributes attributes = getOrCreateAttributes(tags); - counter.add((long) incrValue, attributes); - - logger.debug("Updated OpenTelemetry counter '{}' with value: {}, tags: {}", - name, incrValue, tags); - } catch (Exception e) { - logger.error("Failed to register or update OpenTelemetry counter '{}'", name, e); + double incrValue) { + if (meter == null) { + logger.warn("OpenTelemetry meter not initialized, skipping counter registration for '{}'", name); + return; } - } - - private LongCounter createCounter(String name) { - LongCounterBuilder builder = meter.counterBuilder(name) - .setDescription("Apollo counter metrics") - .setUnit(COUNTER_UNIT); + DoubleCounter counter = counterMap.computeIfAbsent(name, + key -> meter.counterBuilder(name).setDescription("Apollo counter metrics").setUnit(COUNTER_UNIT).ofDoubles().build()); + Attributes attributes = getOrCreateAttributes(tags); + counter.add(incrValue, attributes); - // Build the counter - return builder.build(); + if (logger.isDebugEnabled()) { + logger.debug("Updated OpenTelemetry counter '{}' with value: {}, tags: {}", name, incrValue, tags); + } } @Override @@ -109,76 +89,40 @@ public void registerOrUpdateGaugeSample(String name, Map tags, d logger.warn("OpenTelemetry meter not initialized, skipping gauge registration for '{}'", name); return; } - - // Store the gauge value - String gaugeKey = getGaugeKey(name, tags); - gaugeValueMap.put(gaugeKey, new AtomicReference<>(value)); - - // Register gauge if not already registered - gaugeMap.computeIfAbsent(gaugeKey, key -> createGauge(name, tags, gaugeKey)); - - logger.debug("Updated OpenTelemetry gauge '{}' with value: {}, tags: {}", - name, value, tags); - } - - private ObservableDoubleGauge createGauge(String name, Map tags, String gaugeKey) { Attributes attributes = getOrCreateAttributes(tags); - - DoubleGaugeBuilder gaugeBuilder = meter.gaugeBuilder(name) + // Register gauge if not already registered + gaugeMap.computeIfAbsent(name, key -> meter.gaugeBuilder(name) .setDescription("Apollo gauge metrics") - .setUnit(GAUGE_UNIT); - - // Register callback for gauge value - return gaugeBuilder.buildWithCallback(measurement -> { - AtomicReference valueRef = gaugeValueMap.get(gaugeKey); - if (valueRef != null) { - Double value = valueRef.get(); - if (value != null) { + .setUnit(GAUGE_UNIT) + .buildWithCallback(measurement -> { measurement.record(value, attributes); - } - } - }); + })); + + if (logger.isDebugEnabled()) { + logger.debug("Updated OpenTelemetry gauge '{}' with value: {}, tags: {}", + name, value, tags); + } } private Attributes getOrCreateAttributes(Map tags) { if (tags == null || tags.isEmpty()) { return Attributes.empty(); } - - // Create cache key from sorted tag entries for consistency - String cacheKey = createCacheKey(tags); - - return attributesCache.computeIfAbsent(cacheKey, key -> { - AttributesBuilder builder = Attributes.builder(); - tags.forEach(builder::put); - return builder.build(); - }); - } - - private String createCacheKey(Map tags) { - // Sort keys to ensure consistent cache key - return tags.entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey() + "=" + entry.getValue()) - .reduce((a, b) -> a + ";" + b) - .orElse(""); + AttributesBuilder builder = Attributes.builder(); + tags.forEach(builder::put); + return builder.build(); } - private String getGaugeKey(String name, Map tags) { - return name + ":" + createCacheKey(tags); - } @Override public String response() { // Return simple status information since we're only using API layer int counterCount = counterMap != null ? counterMap.size() : 0; int gaugeCount = gaugeMap != null ? gaugeMap.size() : 0; - int attributesCount = attributesCache != null ? attributesCache.size() : 0; String meterStatus = (meter != null) ? METER_NAME : "not initialized"; - return String.format( - "OpenTelemetry metrics exporter status - Counters: %d, Gauges: %d, Cached attributes: %d, Meter: %s", - counterCount, gaugeCount, attributesCount, meterStatus); + "OpenTelemetry metrics exporter status - Counters: %d, Gauges: %d, Meter: %s", + counterCount, gaugeCount, meterStatus); } } \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java index 2e7e8c64..1b5d8395 100644 --- a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java @@ -80,20 +80,25 @@ public void testRegisterOrUpdateCounterSample() { tags.put("cluster", "default"); // Mock counter builder - io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); - io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class); + io.opentelemetry.api.metrics.LongCounterBuilder longCounterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.DoubleCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounter counter = mock(io.opentelemetry.api.metrics.DoubleCounter.class); - when(meter.counterBuilder(name)).thenReturn(counterBuilder); + when(meter.counterBuilder(name)).thenReturn(longCounterBuilder); + when(longCounterBuilder.setDescription("Apollo counter metrics")).thenReturn(longCounterBuilder); + when(longCounterBuilder.setUnit("1")).thenReturn(longCounterBuilder); + when(longCounterBuilder.ofDoubles()).thenReturn(counterBuilder); when(counterBuilder.setDescription("Apollo counter metrics")).thenReturn(counterBuilder); when(counterBuilder.setUnit("1")).thenReturn(counterBuilder); when(counterBuilder.build()).thenReturn(counter); // This will create the counter on first call - exporter.registerOrUpdateCounterSample(name, tags, 1.0); + exporter.registerOrUpdateCounterSample(name, tags, 1.25); // Verify counter was created verify(meter).counterBuilder(name); verify(counterBuilder).build(); + verify(counter).add(eq(1.25), any()); } @Test @@ -105,7 +110,9 @@ public void testRegisterOrUpdateGaugeSample() { // Mock gauge builder io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); - io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = + new io.opentelemetry.api.metrics.ObservableDoubleGauge() { + }; when(meter.gaugeBuilder(name)).thenReturn(gaugeBuilder); when(gaugeBuilder.setDescription("Apollo gauge metrics")).thenReturn(gaugeBuilder); @@ -137,16 +144,22 @@ public void testResponseWithMetrics() { tags.put("test", "value"); // Mock counter creation - io.opentelemetry.api.metrics.LongCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); - io.opentelemetry.api.metrics.LongCounter counter = mock(io.opentelemetry.api.metrics.LongCounter.class); - when(meter.counterBuilder("test_counter")).thenReturn(counterBuilder); + io.opentelemetry.api.metrics.LongCounterBuilder longCounterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.DoubleCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounter counter = mock(io.opentelemetry.api.metrics.DoubleCounter.class); + when(meter.counterBuilder("test_counter")).thenReturn(longCounterBuilder); + when(longCounterBuilder.setDescription(anyString())).thenReturn(longCounterBuilder); + when(longCounterBuilder.setUnit(anyString())).thenReturn(longCounterBuilder); + when(longCounterBuilder.ofDoubles()).thenReturn(counterBuilder); when(counterBuilder.setDescription(anyString())).thenReturn(counterBuilder); when(counterBuilder.setUnit(anyString())).thenReturn(counterBuilder); when(counterBuilder.build()).thenReturn(counter); // Mock gauge creation io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); - io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = mock(io.opentelemetry.api.metrics.ObservableDoubleGauge.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = + new io.opentelemetry.api.metrics.ObservableDoubleGauge() { + }; when(meter.gaugeBuilder("test_gauge")).thenReturn(gaugeBuilder); when(gaugeBuilder.setDescription(anyString())).thenReturn(gaugeBuilder); when(gaugeBuilder.setUnit(anyString())).thenReturn(gaugeBuilder); @@ -159,4 +172,4 @@ public void testResponseWithMetrics() { assertTrue(response.contains("Counters: 1")); assertTrue(response.contains("Gauges: 1")); } -} \ No newline at end of file +} From 7f5016c1df895eb7117a5bb2c3d45372ebc50cf8 Mon Sep 17 00:00:00 2001 From: teaho2015 Date: Sun, 12 Jul 2026 23:35:09 +0800 Subject: [PATCH 03/21] feat: apollo opentelemetry metrics. --- .../pom.xml | 57 ++++++ ...nTelemetryApolloClientMetricsExporter.java | 128 +++++++++++++ ...ernal.exporter.ApolloClientMetricsExporter | 1 + ...emetryApolloClientMetricsExporterTest.java | 175 ++++++++++++++++++ ...emetryApolloClientMetricsExporterTest.java | 114 ++++++++++++ apollo-plugin/pom.xml | 1 + 6 files changed, 476 insertions(+) create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java create mode 100644 apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml new file mode 100644 index 00000000..349bfdbb --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + apollo-plugin + com.ctrip.framework.apollo + ${revision} + ../pom.xml + + + apollo-plugin-client-opentelemetry + Apollo Plugin OpenTelemetry + jar + + + + com.ctrip.framework.apollo + apollo-client + provided + + + io.opentelemetry + opentelemetry-api + 1.62.0 + + + + junit + junit + test + + + org.mockito + mockito-inline + test + + + + \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java new file mode 100644 index 00000000..117d7797 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporter.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory; +import com.ctrip.framework.apollo.monitor.internal.exporter.AbstractApolloClientMetricsExporter; +import com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter; +import com.google.common.collect.Maps; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.*; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; + +/** + * OpenTelemetry implementation of Apollo client metrics exporter. + * Only uses OpenTelemetry API layer (no SDK dependency). + * + * @author leon.he@walmart.com + */ +public class OpenTelemetryApolloClientMetricsExporter extends + AbstractApolloClientMetricsExporter implements ApolloClientMetricsExporter { + + private static final String OPENTELEMETRY = "opentelemetry"; + private static final String METER_NAME = "apollo-client"; + private static final String COUNTER_UNIT = "1"; + private static final String GAUGE_UNIT = "1"; + + private final Logger logger = DeferredLoggerFactory.getLogger( + OpenTelemetryApolloClientMetricsExporter.class); + + private Meter meter; + private Map counterMap; + private Map gaugeMap; + + @Override + public void doInit() { + // Get meter from global OpenTelemetry + meter = GlobalOpenTelemetry.get().getMeter(METER_NAME); + // Initialize maps + counterMap = new ConcurrentHashMap<>(); + gaugeMap = new ConcurrentHashMap<>(); + logger.info("OpenTelemetry metrics exporter initialized with meter: {}", METER_NAME); + } + + @Override + public boolean isSupport(String form) { + return OPENTELEMETRY.equals(form); + } + + @Override + public void registerOrUpdateCounterSample(String name, Map tags, + double incrValue) { + if (meter == null) { + logger.warn("OpenTelemetry meter not initialized, skipping counter registration for '{}'", name); + return; + } + DoubleCounter counter = counterMap.computeIfAbsent(name, + key -> meter.counterBuilder(name).setDescription("Apollo counter metrics").setUnit(COUNTER_UNIT).ofDoubles().build()); + Attributes attributes = getOrCreateAttributes(tags); + counter.add(incrValue, attributes); + + if (logger.isDebugEnabled()) { + logger.debug("Updated OpenTelemetry counter '{}' with value: {}, tags: {}", name, incrValue, tags); + } + } + + @Override + public void registerOrUpdateGaugeSample(String name, Map tags, double value) { + if (meter == null) { + logger.warn("OpenTelemetry meter not initialized, skipping gauge registration for '{}'", name); + return; + } + Attributes attributes = getOrCreateAttributes(tags); + // Register gauge if not already registered + gaugeMap.computeIfAbsent(name, key -> meter.gaugeBuilder(name) + .setDescription("Apollo gauge metrics") + .setUnit(GAUGE_UNIT) + .buildWithCallback(measurement -> { + measurement.record(value, attributes); + })); + + if (logger.isDebugEnabled()) { + logger.debug("Updated OpenTelemetry gauge '{}' with value: {}, tags: {}", + name, value, tags); + } + } + + private Attributes getOrCreateAttributes(Map tags) { + if (tags == null || tags.isEmpty()) { + return Attributes.empty(); + } + AttributesBuilder builder = Attributes.builder(); + tags.forEach(builder::put); + return builder.build(); + } + + + @Override + public String response() { + // Return simple status information since we're only using API layer + int counterCount = counterMap != null ? counterMap.size() : 0; + int gaugeCount = gaugeMap != null ? gaugeMap.size() : 0; + + String meterStatus = (meter != null) ? METER_NAME : "not initialized"; + return String.format( + "OpenTelemetry metrics exporter status - Counters: %d, Gauges: %d, Meter: %s", + counterCount, gaugeCount, meterStatus); + } +} \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter new file mode 100644 index 00000000..8e064550 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/main/resources/META-INF/services/com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter @@ -0,0 +1 @@ +com.ctrip.framework.apollo.monitor.internal.exporter.impl.OpenTelemetryApolloClientMetricsExporter \ No newline at end of file diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java new file mode 100644 index 00000000..1b5d8395 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/OpenTelemetryApolloClientMetricsExporterTest.java @@ -0,0 +1,175 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.HashMap; +import java.util.Map; + +public class OpenTelemetryApolloClientMetricsExporterTest { + + private OpenTelemetryApolloClientMetricsExporter exporter; + private MockedStatic globalOpenTelemetryMock; + private OpenTelemetry openTelemetry; + private Meter meter; + + @Before + public void setUp() { + // Mock GlobalOpenTelemetry + globalOpenTelemetryMock = mockStatic(GlobalOpenTelemetry.class); + openTelemetry = mock(OpenTelemetry.class); + meter = mock(Meter.class); + + when(GlobalOpenTelemetry.get()).thenReturn(openTelemetry); + when(openTelemetry.getMeter("apollo-client")).thenReturn(meter); + + exporter = new OpenTelemetryApolloClientMetricsExporter(); + exporter.doInit(); + } + + @After + public void tearDown() { + if (globalOpenTelemetryMock != null) { + globalOpenTelemetryMock.close(); + } + } + + @Test + public void testIsSupport() { + assertTrue(exporter.isSupport("opentelemetry")); + assertFalse(exporter.isSupport("prometheus")); + assertFalse(exporter.isSupport("other")); + } + + @Test + public void testDoInit() { + // Verify that meter was obtained from GlobalOpenTelemetry + globalOpenTelemetryMock.verify(() -> GlobalOpenTelemetry.get()); + verify(openTelemetry).getMeter("apollo-client"); + } + + @Test + public void testRegisterOrUpdateCounterSample() { + String name = "test_counter"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + tags.put("cluster", "default"); + + // Mock counter builder + io.opentelemetry.api.metrics.LongCounterBuilder longCounterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.DoubleCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounter counter = mock(io.opentelemetry.api.metrics.DoubleCounter.class); + + when(meter.counterBuilder(name)).thenReturn(longCounterBuilder); + when(longCounterBuilder.setDescription("Apollo counter metrics")).thenReturn(longCounterBuilder); + when(longCounterBuilder.setUnit("1")).thenReturn(longCounterBuilder); + when(longCounterBuilder.ofDoubles()).thenReturn(counterBuilder); + when(counterBuilder.setDescription("Apollo counter metrics")).thenReturn(counterBuilder); + when(counterBuilder.setUnit("1")).thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + + // This will create the counter on first call + exporter.registerOrUpdateCounterSample(name, tags, 1.25); + + // Verify counter was created + verify(meter).counterBuilder(name); + verify(counterBuilder).build(); + verify(counter).add(eq(1.25), any()); + } + + @Test + public void testRegisterOrUpdateGaugeSample() { + String name = "test_gauge"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + tags.put("cluster", "default"); + + // Mock gauge builder + io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = + new io.opentelemetry.api.metrics.ObservableDoubleGauge() { + }; + + when(meter.gaugeBuilder(name)).thenReturn(gaugeBuilder); + when(gaugeBuilder.setDescription("Apollo gauge metrics")).thenReturn(gaugeBuilder); + when(gaugeBuilder.setUnit("1")).thenReturn(gaugeBuilder); + when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge); + + // This will create the gauge on first call + exporter.registerOrUpdateGaugeSample(name, tags, 3.14); + + // Verify gauge was created + verify(meter).gaugeBuilder(name); + verify(gaugeBuilder).buildWithCallback(any()); + } + + @Test + public void testResponse() { + String response = exporter.response(); + assertNotNull(response); + assertTrue(response.contains("OpenTelemetry metrics exporter status")); + assertTrue(response.contains("Counters: 0")); + assertTrue(response.contains("Gauges: 0")); + assertTrue(response.contains("Meter: apollo-client")); + } + + @Test + public void testResponseWithMetrics() { + // Register some metrics first + Map tags = new HashMap<>(); + tags.put("test", "value"); + + // Mock counter creation + io.opentelemetry.api.metrics.LongCounterBuilder longCounterBuilder = mock(io.opentelemetry.api.metrics.LongCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounterBuilder counterBuilder = mock(io.opentelemetry.api.metrics.DoubleCounterBuilder.class); + io.opentelemetry.api.metrics.DoubleCounter counter = mock(io.opentelemetry.api.metrics.DoubleCounter.class); + when(meter.counterBuilder("test_counter")).thenReturn(longCounterBuilder); + when(longCounterBuilder.setDescription(anyString())).thenReturn(longCounterBuilder); + when(longCounterBuilder.setUnit(anyString())).thenReturn(longCounterBuilder); + when(longCounterBuilder.ofDoubles()).thenReturn(counterBuilder); + when(counterBuilder.setDescription(anyString())).thenReturn(counterBuilder); + when(counterBuilder.setUnit(anyString())).thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + + // Mock gauge creation + io.opentelemetry.api.metrics.DoubleGaugeBuilder gaugeBuilder = mock(io.opentelemetry.api.metrics.DoubleGaugeBuilder.class); + io.opentelemetry.api.metrics.ObservableDoubleGauge gauge = + new io.opentelemetry.api.metrics.ObservableDoubleGauge() { + }; + when(meter.gaugeBuilder("test_gauge")).thenReturn(gaugeBuilder); + when(gaugeBuilder.setDescription(anyString())).thenReturn(gaugeBuilder); + when(gaugeBuilder.setUnit(anyString())).thenReturn(gaugeBuilder); + when(gaugeBuilder.buildWithCallback(any())).thenReturn(gauge); + + exporter.registerOrUpdateCounterSample("test_counter", tags, 1.0); + exporter.registerOrUpdateGaugeSample("test_gauge", tags, 2.0); + + String response = exporter.response(); + assertTrue(response.contains("Counters: 1")); + assertTrue(response.contains("Gauges: 1")); + } +} diff --git a/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java new file mode 100644 index 00000000..671df0a8 --- /dev/null +++ b/apollo-plugin/apollo-plugin-client-opentelemetry/src/test/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/SimpleOpenTelemetryApolloClientMetricsExporterTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.monitor.internal.exporter.impl; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class SimpleOpenTelemetryApolloClientMetricsExporterTest { + + private OpenTelemetryApolloClientMetricsExporter exporter; + + @Before + public void setUp() { + exporter = new OpenTelemetryApolloClientMetricsExporter(); + // Note: We don't call doInit() because it requires GlobalOpenTelemetry + // In a real test environment, you would need to setup OpenTelemetry first + } + + @Test + public void testIsSupport() { + assertTrue(exporter.isSupport("opentelemetry")); + assertFalse(exporter.isSupport("prometheus")); + assertFalse(exporter.isSupport("other")); + assertFalse(exporter.isSupport(null)); + } + + @Test + public void testResponseWithoutInit() { + // Even without init, response should return a status message + String response = exporter.response(); + assertNotNull(response); + assertTrue(response.contains("OpenTelemetry metrics exporter status")); + } + + @Test + public void testRegisterOrUpdateCounterSampleWithoutInit() { + // This should not throw exception even without init + String name = "test_counter"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + + try { + exporter.registerOrUpdateCounterSample(name, tags, 1.0); + // If we get here, it means no exception was thrown + assertTrue(true); + } catch (Exception e) { + // It's okay if it throws exception when not initialized + // This depends on OpenTelemetry API behavior + } + } + + @Test + public void testRegisterOrUpdateGaugeSampleWithoutInit() { + // This should not throw exception even without init + String name = "test_gauge"; + Map tags = new HashMap<>(); + tags.put("namespace", "application"); + + try { + exporter.registerOrUpdateGaugeSample(name, tags, 3.14); + // If we get here, it means no exception was thrown + assertTrue(true); + } catch (Exception e) { + // It's okay if it throws exception when not initialized + // This depends on OpenTelemetry API behavior + } + } + + @Test + public void testEmptyTags() { + String name = "test_metric"; + Map emptyTags = new HashMap<>(); + + try { + exporter.registerOrUpdateCounterSample(name, emptyTags, 1.0); + exporter.registerOrUpdateGaugeSample(name, emptyTags, 2.0); + assertTrue(true); + } catch (Exception e) { + // Acceptable if it throws exception + } + } + + @Test + public void testNullTags() { + String name = "test_metric"; + + try { + exporter.registerOrUpdateCounterSample(name, null, 1.0); + exporter.registerOrUpdateGaugeSample(name, null, 2.0); + assertTrue(true); + } catch (Exception e) { + // Acceptable if it throws exception for null tags + } + } +} \ No newline at end of file diff --git a/apollo-plugin/pom.xml b/apollo-plugin/pom.xml index f0d1b00f..b97e09f4 100644 --- a/apollo-plugin/pom.xml +++ b/apollo-plugin/pom.xml @@ -32,6 +32,7 @@ apollo-plugin-log4j2 apollo-plugin-client-prometheus + apollo-plugin-client-opentelemetry From 6f90d5647284a9e472250bbf6e3408d36c1c9b5c Mon Sep 17 00:00:00 2001 From: arrow <316166287@qq.com> Date: Fri, 24 Oct 2025 21:25:28 +0800 Subject: [PATCH 04/21] * Remove unused imports (#111) --- .../exporter/impl/NullApolloClientMetricsExporter.java | 1 - .../listener/impl/DefaultApolloClientBootstrapArgsApi.java | 1 - .../listener/impl/DefaultApolloClientExceptionApi.java | 1 - .../listener/impl/DefaultApolloClientNamespaceApi.java | 2 -- .../internal/tracer/ApolloClientMonitorMessageProducer.java | 2 -- .../com/ctrip/framework/apollo/util/yaml/YamlParser.java | 2 -- .../com/ctrip/framework/apollo/core/utils/StringUtils.java | 3 --- .../apollo/tracer/internals/cat/CatTransaction.java | 2 -- .../framework/foundation/internals/ServiceBootstrap.java | 1 - .../extend/ApolloStandardHttpRequestRetryHandler.java | 6 ------ .../impl/PrometheusApolloClientMetricsExporter.java | 2 -- 11 files changed, 23 deletions(-) diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/NullApolloClientMetricsExporter.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/NullApolloClientMetricsExporter.java index 3bc195d5..389a6f78 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/NullApolloClientMetricsExporter.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/NullApolloClientMetricsExporter.java @@ -17,7 +17,6 @@ package com.ctrip.framework.apollo.monitor.internal.exporter.impl; import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory; -import com.ctrip.framework.apollo.monitor.internal.exporter.AbstractApolloClientMetricsExporter; import com.ctrip.framework.apollo.monitor.internal.listener.ApolloClientMonitorEventListener; import com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter; import java.util.List; diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientBootstrapArgsApi.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientBootstrapArgsApi.java index 29f8c9eb..9d6e6d88 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientBootstrapArgsApi.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientBootstrapArgsApi.java @@ -34,7 +34,6 @@ import java.time.LocalDateTime; import java.util.Map; -import java.util.Optional; import org.slf4j.Logger; diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientExceptionApi.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientExceptionApi.java index ba650a12..64aa2e80 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientExceptionApi.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientExceptionApi.java @@ -20,7 +20,6 @@ import static com.ctrip.framework.apollo.monitor.internal.ApolloClientMonitorConstant.TAG_ERROR; import static com.ctrip.framework.apollo.monitor.internal.ApolloClientMonitorConstant.THROWABLE; -import com.ctrip.framework.apollo.build.ApolloInjector; import com.ctrip.framework.apollo.exceptions.ApolloConfigException; import com.ctrip.framework.apollo.monitor.api.ApolloClientExceptionMonitorApi; import com.ctrip.framework.apollo.monitor.internal.jmx.mbean.ApolloClientJmxExceptionMBean; diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientNamespaceApi.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientNamespaceApi.java index 91b1d28e..0be738fa 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientNamespaceApi.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/listener/impl/DefaultApolloClientNamespaceApi.java @@ -20,7 +20,6 @@ import static com.ctrip.framework.apollo.monitor.internal.ApolloClientMonitorConstant.*; import com.ctrip.framework.apollo.Config; -import com.ctrip.framework.apollo.ConfigFile; import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory; import com.ctrip.framework.apollo.internals.ConfigManager; import com.ctrip.framework.apollo.monitor.api.ApolloClientNamespaceMonitorApi; @@ -36,7 +35,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import org.slf4j.Logger; diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/tracer/ApolloClientMonitorMessageProducer.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/tracer/ApolloClientMonitorMessageProducer.java index 133d6671..71ceab5f 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/tracer/ApolloClientMonitorMessageProducer.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/monitor/internal/tracer/ApolloClientMonitorMessageProducer.java @@ -27,12 +27,10 @@ import com.ctrip.framework.apollo.tracer.spi.Transaction; import com.ctrip.framework.apollo.util.date.DateUtil; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Optional; /** * @author Rawven diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java index 00a681e7..b3e9e570 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java @@ -30,8 +30,6 @@ import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; -import org.yaml.snakeyaml.nodes.MappingNode; -import org.yaml.snakeyaml.parser.ParserException; import com.ctrip.framework.apollo.core.utils.StringUtils; import org.yaml.snakeyaml.representer.Representer; diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java index 73bdbfa1..a223374b 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java @@ -16,9 +16,6 @@ */ package com.ctrip.framework.apollo.core.utils; -import java.util.Collection; -import java.util.Iterator; - public class StringUtils { public static final String EMPTY = ""; diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/tracer/internals/cat/CatTransaction.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/tracer/internals/cat/CatTransaction.java index 0251c6f8..0d16b593 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/tracer/internals/cat/CatTransaction.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/tracer/internals/cat/CatTransaction.java @@ -18,8 +18,6 @@ import com.ctrip.framework.apollo.tracer.spi.Transaction; -import java.lang.reflect.Method; - /** * @author Jason Song(song_s@ctrip.com) */ diff --git a/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/ServiceBootstrap.java b/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/ServiceBootstrap.java index 27d62cf8..8baaf255 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/ServiceBootstrap.java +++ b/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/ServiceBootstrap.java @@ -18,7 +18,6 @@ import com.ctrip.framework.apollo.core.spi.Ordered; import com.google.common.collect.Lists; -import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; diff --git a/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/client/extend/ApolloStandardHttpRequestRetryHandler.java b/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/client/extend/ApolloStandardHttpRequestRetryHandler.java index 669f9a91..8bb6e079 100644 --- a/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/client/extend/ApolloStandardHttpRequestRetryHandler.java +++ b/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/client/extend/ApolloStandardHttpRequestRetryHandler.java @@ -16,13 +16,7 @@ */ package com.ctrip.framework.apollo.openapi.client.extend; -import com.google.common.collect.Collections2; -import com.google.common.collect.Lists; -import java.util.Collections; import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Optional; import java.util.Set; import org.apache.http.HttpRequest; import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; diff --git a/apollo-plugin/apollo-plugin-client-prometheus/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/PrometheusApolloClientMetricsExporter.java b/apollo-plugin/apollo-plugin-client-prometheus/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/PrometheusApolloClientMetricsExporter.java index 4acf5b37..be3f8f64 100644 --- a/apollo-plugin/apollo-plugin-client-prometheus/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/PrometheusApolloClientMetricsExporter.java +++ b/apollo-plugin/apollo-plugin-client-prometheus/src/main/java/com/ctrip/framework/apollo/monitor/internal/exporter/impl/PrometheusApolloClientMetricsExporter.java @@ -19,7 +19,6 @@ import com.ctrip.framework.apollo.core.utils.DeferredLoggerFactory; import com.ctrip.framework.apollo.monitor.internal.exporter.AbstractApolloClientMetricsExporter; import com.ctrip.framework.apollo.monitor.internal.exporter.ApolloClientMetricsExporter; -import com.ctrip.framework.apollo.monitor.internal.listener.impl.DefaultApolloClientNamespaceApi; import com.google.common.collect.Maps; import io.prometheus.client.Collector; import io.prometheus.client.CollectorRegistry; @@ -28,7 +27,6 @@ import io.prometheus.client.exporter.common.TextFormat; import java.io.IOException; import java.io.StringWriter; -import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; From d50cd485fc80f9b5038935994dba02d93078b714 Mon Sep 17 00:00:00 2001 From: arrow <316166287@qq.com> Date: Thu, 27 Nov 2025 12:35:32 +0800 Subject: [PATCH 05/21] * Remove redundant local variables (#112) --- .../com/ctrip/framework/apollo/spi/DefaultConfigFactory.java | 2 +- .../ctrip/framework/apollo/spi/DefaultConfigRegistry.java | 3 +-- .../spring/property/SpringValueDefinitionProcessor.java | 5 +---- .../foundation/internals/NetworkInterfaceManager.java | 4 +--- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java index e202fba8..6f896892 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java @@ -72,7 +72,7 @@ public Config create(String namespace) { public Config create(String appId, String namespace) { ConfigFileFormat format = determineFileFormat(namespace); - ConfigRepository configRepository = null; + ConfigRepository configRepository; // although ConfigFileFormat.Properties are compatible with themselves we // should not create a PropertiesCompatibleFileConfigRepository for them diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigRegistry.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigRegistry.java index f77d1e49..6c42fd59 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigRegistry.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigRegistry.java @@ -60,7 +60,6 @@ public ConfigFactory getFactory(String namespace) { @Override public ConfigFactory getFactory(String appId, String namespace) { - ConfigFactory config = m_instances.get(appId, namespace); - return config; + return m_instances.get(appId, namespace); } } diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/property/SpringValueDefinitionProcessor.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/property/SpringValueDefinitionProcessor.java index a24e5205..f98a614e 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/property/SpringValueDefinitionProcessor.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/property/SpringValueDefinitionProcessor.java @@ -73,10 +73,7 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) } public static Multimap getBeanName2SpringValueDefinitions(BeanDefinitionRegistry registry) { - Multimap springValueDefinitions = beanName2SpringValueDefinitions.computeIfAbsent( - registry, k -> LinkedListMultimap.create()); - - return springValueDefinitions; + return beanName2SpringValueDefinitions.computeIfAbsent(registry, k -> LinkedListMultimap.create()); } private void processPropertyValues(BeanDefinitionRegistry beanRegistry) { diff --git a/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/NetworkInterfaceManager.java b/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/NetworkInterfaceManager.java index b93610b5..d1f4fdfb 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/NetworkInterfaceManager.java +++ b/apollo-core/src/main/java/com/ctrip/framework/foundation/internals/NetworkInterfaceManager.java @@ -93,9 +93,7 @@ public String getLocalHostName() { } private String getProperty(String name) { - String value = null; - - value = System.getProperty(name); + String value = System.getProperty(name); if (value == null) { value = System.getenv(name); From ed3ee0f88e482d0175a71fb9359e865f53a3919b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:42:30 +0800 Subject: [PATCH 06/21] Support Spring Boot 4.0 bootstrap context package relocation (#115) --- CHANGES.md | 1 + ...polloClientExtensionInitializeFactory.java | 3 +- ...ClientLongPollingExtensionInitializer.java | 5 +- ...polloClientWebClientCustomizerFactory.java | 9 +- ...loClientWebsocketExtensionInitializer.java | 5 +- .../data/importer/ApolloConfigDataLoader.java | 30 +-- .../ApolloConfigDataLoaderInitializer.java | 5 +- ...olloSpringApplicationRegisterListener.java | 9 +- .../data/util/BootstrapRegistryHelper.java | 214 ++++++++++++++++++ 9 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelper.java diff --git a/CHANGES.md b/CHANGES.md index 718d9634..2060a6a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,7 @@ Apollo Java 2.5.0 * [Feature Provide a new open APl to return the organization list](https://github.com/apolloconfig/apollo-java/pull/102) * [Feature Added a new feature to get instance count by namespace.](https://github.com/apolloconfig/apollo-java/pull/103) * [Feature Support retry in open api client.](https://github.com/apolloconfig/apollo-java/pull/105) +* [Support Spring Boot 4.0 bootstrap context package relocation for apollo-client-config-data](https://github.com/apolloconfig/apollo-java/pull/115) ------------------ All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/5?closed=1) diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactory.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactory.java index 1405c3e3..1518aec8 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactory.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactory.java @@ -23,7 +23,6 @@ import com.ctrip.framework.apollo.config.data.extension.websocket.ApolloClientWebsocketExtensionInitializer; import com.ctrip.framework.apollo.config.data.util.Slf4jLogMessageFormatter; import org.apache.commons.logging.Log; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.logging.DeferredLogFactory; @@ -42,7 +41,7 @@ public class ApolloClientExtensionInitializeFactory { private final ApolloClientWebsocketExtensionInitializer apolloClientWebsocketExtensionInitializer; public ApolloClientExtensionInitializeFactory(DeferredLogFactory logFactory, - ConfigurableBootstrapContext bootstrapContext) { + Object bootstrapContext) { this.log = logFactory.getLog(ApolloClientExtensionInitializeFactory.class); this.apolloClientPropertiesFactory = new ApolloClientPropertiesFactory(); this.apolloClientLongPollingExtensionInitializer = new ApolloClientLongPollingExtensionInitializer( diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java index 337ae6ad..03185e56 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java @@ -24,7 +24,6 @@ import com.ctrip.framework.foundation.internals.ServiceBootstrap; import java.util.List; import org.apache.commons.logging.Log; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.logging.DeferredLogFactory; @@ -40,10 +39,10 @@ public class ApolloClientLongPollingExtensionInitializer implements private final Log log; - private final ConfigurableBootstrapContext bootstrapContext; + private final Object bootstrapContext; public ApolloClientLongPollingExtensionInitializer(DeferredLogFactory logFactory, - ConfigurableBootstrapContext bootstrapContext) { + Object bootstrapContext) { this.log = logFactory.getLog(ApolloClientLongPollingExtensionInitializer.class); this.bootstrapContext = bootstrapContext; } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java index 8e3448ed..f7feb1ff 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java @@ -19,7 +19,6 @@ import com.ctrip.framework.apollo.config.data.extension.properties.ApolloClientProperties; import com.ctrip.framework.apollo.core.spi.Ordered; import org.apache.commons.logging.Log; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; @@ -37,11 +36,15 @@ public interface ApolloClientWebClientCustomizerFactory extends Ordered { * @param binder properties binder * @param bindHandler properties binder Handler * @param log deferred log - * @param bootstrapContext bootstrapContext + * @param bootstrapContext bootstrapContext (can be either + * org.springframework.boot.ConfigurableBootstrapContext for + * Spring Boot 3.x or + * org.springframework.boot.bootstrap.ConfigurableBootstrapContext + * for Spring Boot 4.x) * @return WebClientCustomizer instance or null */ @Nullable WebClientCustomizer createWebClientCustomizer(ApolloClientProperties apolloClientProperties, Binder binder, BindHandler bindHandler, Log log, - ConfigurableBootstrapContext bootstrapContext); + Object bootstrapContext); } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/websocket/ApolloClientWebsocketExtensionInitializer.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/websocket/ApolloClientWebsocketExtensionInitializer.java index 17d1dca5..35a8f7ed 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/websocket/ApolloClientWebsocketExtensionInitializer.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/websocket/ApolloClientWebsocketExtensionInitializer.java @@ -19,7 +19,6 @@ import com.ctrip.framework.apollo.config.data.extension.initialize.ApolloClientExtensionInitializer; import com.ctrip.framework.apollo.config.data.extension.properties.ApolloClientProperties; import org.apache.commons.logging.Log; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.logging.DeferredLogFactory; @@ -31,10 +30,10 @@ public class ApolloClientWebsocketExtensionInitializer implements ApolloClientEx private final Log log; - private final ConfigurableBootstrapContext bootstrapContext; + private final Object bootstrapContext; public ApolloClientWebsocketExtensionInitializer(DeferredLogFactory logFactory, - ConfigurableBootstrapContext bootstrapContext) { + Object bootstrapContext) { this.log = logFactory.getLog(ApolloClientWebsocketExtensionInitializer.class); this.bootstrapContext = bootstrapContext; } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoader.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoader.java index 8e15007b..41c84b64 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoader.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoader.java @@ -18,6 +18,7 @@ import com.ctrip.framework.apollo.Config; import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.config.data.util.BootstrapRegistryHelper; import com.ctrip.framework.apollo.config.data.util.Slf4jLogMessageFormatter; import com.ctrip.framework.apollo.spring.config.ConfigPropertySource; import com.ctrip.framework.apollo.spring.config.ConfigPropertySourceFactory; @@ -26,8 +27,6 @@ import java.util.ArrayList; import java.util.List; import org.apache.commons.logging.Log; -import org.springframework.boot.BootstrapRegistry.InstanceSupplier; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.config.ConfigData; import org.springframework.boot.context.config.ConfigDataLoader; import org.springframework.boot.context.config.ConfigDataLoaderContext; @@ -55,22 +54,24 @@ public ApolloConfigDataLoader(DeferredLogFactory logFactory) { @Override public ConfigData load(ConfigDataLoaderContext context, ApolloConfigDataResource resource) throws IOException, ConfigDataResourceNotFoundException { - ConfigurableBootstrapContext bootstrapContext = context.getBootstrapContext(); - Binder binder = bootstrapContext.get(Binder.class); + Object bootstrapContext = BootstrapRegistryHelper.getBootstrapContext(context); + Binder binder = BootstrapRegistryHelper.get(bootstrapContext, Binder.class); BindHandler bindHandler = this.getBindHandler(context); - bootstrapContext.registerIfAbsent(ApolloConfigDataLoaderInitializer.class, InstanceSupplier - .from(() -> new ApolloConfigDataLoaderInitializer(this.logFactory, binder, bindHandler, - bootstrapContext))); - ApolloConfigDataLoaderInitializer apolloConfigDataLoaderInitializer = bootstrapContext - .get(ApolloConfigDataLoaderInitializer.class); + BootstrapRegistryHelper.registerIfAbsentFromSupplier(bootstrapContext, + ApolloConfigDataLoaderInitializer.class, + () -> new ApolloConfigDataLoaderInitializer(this.logFactory, binder, bindHandler, + bootstrapContext)); + ApolloConfigDataLoaderInitializer apolloConfigDataLoaderInitializer = + BootstrapRegistryHelper.get(bootstrapContext, ApolloConfigDataLoaderInitializer.class); // init apollo client List> initialPropertySourceList = apolloConfigDataLoaderInitializer .initApolloClient(); // load config - bootstrapContext.registerIfAbsent(ConfigPropertySourceFactory.class, - InstanceSupplier.from(() -> SpringInjector.getInstance(ConfigPropertySourceFactory.class))); - ConfigPropertySourceFactory configPropertySourceFactory = bootstrapContext - .get(ConfigPropertySourceFactory.class); + BootstrapRegistryHelper.registerIfAbsentFromSupplier(bootstrapContext, + ConfigPropertySourceFactory.class, + () -> SpringInjector.getInstance(ConfigPropertySourceFactory.class)); + ConfigPropertySourceFactory configPropertySourceFactory = + BootstrapRegistryHelper.get(bootstrapContext, ConfigPropertySourceFactory.class); String namespace = resource.getNamespace(); Config config = ConfigService.getConfig(namespace); ConfigPropertySource configPropertySource = configPropertySourceFactory @@ -83,7 +84,8 @@ public ConfigData load(ConfigDataLoaderContext context, ApolloConfigDataResource } private BindHandler getBindHandler(ConfigDataLoaderContext context) { - return context.getBootstrapContext().getOrElse(BindHandler.class, null); + Object bootstrapContext = BootstrapRegistryHelper.getBootstrapContext(context); + return BootstrapRegistryHelper.getOrElse(bootstrapContext, BindHandler.class, null); } @Override diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializer.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializer.java index f85e71fb..22f6acbb 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializer.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializer.java @@ -30,7 +30,6 @@ import java.util.List; import java.util.Map; import org.apache.commons.logging.Log; -import org.springframework.boot.ConfigurableBootstrapContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; @@ -54,11 +53,11 @@ class ApolloConfigDataLoaderInitializer { private final BindHandler bindHandler; - private final ConfigurableBootstrapContext bootstrapContext; + private final Object bootstrapContext; public ApolloConfigDataLoaderInitializer(DeferredLogFactory logFactory, Binder binder, BindHandler bindHandler, - ConfigurableBootstrapContext bootstrapContext) { + Object bootstrapContext) { this.logFactory = logFactory; this.log = logFactory.getLog(ApolloConfigDataLoaderInitializer.class); this.binder = binder; diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListener.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListener.java index 69ec5770..9578fc8c 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListener.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListener.java @@ -16,8 +16,7 @@ */ package com.ctrip.framework.apollo.config.data.listener; -import org.springframework.boot.BootstrapRegistry.InstanceSupplier; -import org.springframework.boot.ConfigurableBootstrapContext; +import com.ctrip.framework.apollo.config.data.util.BootstrapRegistryHelper; import org.springframework.boot.SpringApplication; import org.springframework.boot.context.event.ApplicationStartingEvent; import org.springframework.context.ApplicationListener; @@ -30,8 +29,8 @@ public class ApolloSpringApplicationRegisterListener implements @Override public void onApplicationEvent(ApplicationStartingEvent event) { - ConfigurableBootstrapContext bootstrapContext = event.getBootstrapContext(); - bootstrapContext.registerIfAbsent(SpringApplication.class, - InstanceSupplier.of(event.getSpringApplication())); + Object bootstrapContext = BootstrapRegistryHelper.getBootstrapContext(event); + BootstrapRegistryHelper.registerIfAbsent(bootstrapContext, SpringApplication.class, + event.getSpringApplication()); } } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelper.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelper.java new file mode 100644 index 00000000..bc0abb85 --- /dev/null +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelper.java @@ -0,0 +1,214 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.util; + +import java.lang.reflect.Method; +import java.util.function.Supplier; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.event.ApplicationStartingEvent; +import org.springframework.util.ClassUtils; + +/** + * Helper class to provide compatibility between Spring Boot 3.x and 4.x for bootstrap context + * operations. + *

+ * In Spring Boot 4.0, the bootstrap-related classes were moved from + * {@code org.springframework.boot} to {@code org.springframework.boot.bootstrap}. + * + * @author vdisk + */ +public class BootstrapRegistryHelper { + + private static final boolean SPRING_BOOT_4_PRESENT; + private static final Method GET_BOOTSTRAP_CONTEXT_FROM_EVENT_METHOD; + private static final Method GET_BOOTSTRAP_CONTEXT_FROM_LOADER_CONTEXT_METHOD; + private static final Method REGISTER_IF_ABSENT_METHOD; + private static final Method INSTANCE_SUPPLIER_OF_METHOD; + private static final Method INSTANCE_SUPPLIER_FROM_METHOD; + private static final Method GET_METHOD; + private static final Method GET_OR_ELSE_METHOD; + + static { + ClassLoader classLoader = BootstrapRegistryHelper.class.getClassLoader(); + SPRING_BOOT_4_PRESENT = ClassUtils.isPresent( + "org.springframework.boot.bootstrap.ConfigurableBootstrapContext", classLoader); + + try { + GET_BOOTSTRAP_CONTEXT_FROM_EVENT_METHOD = ApplicationStartingEvent.class.getMethod("getBootstrapContext"); + GET_BOOTSTRAP_CONTEXT_FROM_LOADER_CONTEXT_METHOD = ConfigDataLoaderContext.class.getMethod("getBootstrapContext"); + + Class bootstrapRegistryClass; + Class bootstrapContextClass; + String bootstrapPackage = SPRING_BOOT_4_PRESENT + ? "org.springframework.boot.bootstrap" + : "org.springframework.boot"; + bootstrapRegistryClass = ClassUtils.forName(bootstrapPackage + ".BootstrapRegistry", classLoader); + bootstrapContextClass = ClassUtils.forName(bootstrapPackage + ".BootstrapContext", classLoader); + + Class instanceSupplierClass = findInnerClass(bootstrapRegistryClass, "InstanceSupplier"); + REGISTER_IF_ABSENT_METHOD = bootstrapRegistryClass.getMethod("registerIfAbsent", + Class.class, instanceSupplierClass); + INSTANCE_SUPPLIER_OF_METHOD = instanceSupplierClass.getMethod("of", Object.class); + INSTANCE_SUPPLIER_FROM_METHOD = instanceSupplierClass.getMethod("from", Supplier.class); + GET_METHOD = bootstrapContextClass.getMethod("get", Class.class); + GET_OR_ELSE_METHOD = bootstrapContextClass.getMethod("getOrElse", Class.class, Object.class); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "Failed to initialize BootstrapRegistryHelper: Bootstrap classes not found. " + + "Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } catch (NoSuchMethodException e) { + throw new IllegalStateException( + "Failed to initialize BootstrapRegistryHelper: Required method not found. " + + "Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to initialize BootstrapRegistryHelper: Unexpected error during reflection setup. " + + "Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + private static Class findInnerClass(Class outerClass, String innerClassName) { + for (Class innerClass : outerClass.getDeclaredClasses()) { + if (innerClass.getSimpleName().equals(innerClassName)) { + return innerClass; + } + } + throw new IllegalStateException( + "Cannot find inner class " + innerClassName + " in " + outerClass.getName()); + } + + /** + * Get the bootstrap context from an ApplicationStartingEvent using reflection to support both + * Spring Boot 3.x and 4.x. + * + * @param event the ApplicationStartingEvent + * @return the bootstrap context (either from old or new package) + */ + public static Object getBootstrapContext(ApplicationStartingEvent event) { + try { + return GET_BOOTSTRAP_CONTEXT_FROM_EVENT_METHOD.invoke(event); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke ApplicationStartingEvent.getBootstrapContext() via reflection. " + + "Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Get the bootstrap context from a ConfigDataLoaderContext using reflection to support both + * Spring Boot 3.x and 4.x. + * + * @param context the ConfigDataLoaderContext + * @return the bootstrap context (either from old or new package) + */ + public static Object getBootstrapContext(ConfigDataLoaderContext context) { + try { + return GET_BOOTSTRAP_CONTEXT_FROM_LOADER_CONTEXT_METHOD.invoke(context); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke ConfigDataLoaderContext.getBootstrapContext() via reflection. " + + "Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Register an instance if absent in the bootstrap context using reflection. + * + * @param bootstrapContext the bootstrap context + * @param type the type to register + * @param instance the instance to register + * @param the type parameter + */ + public static void registerIfAbsent(Object bootstrapContext, Class type, T instance) { + try { + Object wrappedSupplier = INSTANCE_SUPPLIER_OF_METHOD.invoke(null, instance); + REGISTER_IF_ABSENT_METHOD.invoke(bootstrapContext, type, wrappedSupplier); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke BootstrapRegistry.registerIfAbsent() for type " + type.getName() + + " via reflection. Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Register an instance supplier if absent in the bootstrap context using reflection. + * + * @param bootstrapContext the bootstrap context + * @param type the type to register + * @param instanceSupplier supplier for the instance + * @param the type parameter + */ + public static void registerIfAbsentFromSupplier(Object bootstrapContext, Class type, + Supplier instanceSupplier) { + try { + Object wrappedSupplier = INSTANCE_SUPPLIER_FROM_METHOD.invoke(null, instanceSupplier); + REGISTER_IF_ABSENT_METHOD.invoke(bootstrapContext, type, wrappedSupplier); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke BootstrapRegistry.registerIfAbsent() with supplier for type " + + type.getName() + " via reflection. Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Get an instance from the bootstrap context using reflection. + * + * @param bootstrapContext the bootstrap context + * @param type the type to get + * @param the type parameter + * @return the instance + */ + @SuppressWarnings("unchecked") + public static T get(Object bootstrapContext, Class type) { + try { + return (T) GET_METHOD.invoke(bootstrapContext, type); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke BootstrapContext.get() for type " + type.getName() + + " via reflection. Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Get an instance from the bootstrap context or return a default value. + * + * @param bootstrapContext the bootstrap context + * @param type the type to get + * @param defaultValue the default value + * @param the type parameter + * @return the instance or default value + */ + @SuppressWarnings("unchecked") + public static T getOrElse(Object bootstrapContext, Class type, T defaultValue) { + try { + return (T) GET_OR_ELSE_METHOD.invoke(bootstrapContext, type, defaultValue); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to invoke BootstrapContext.getOrElse() for type " + type.getName() + + " via reflection. Spring Boot 4.x detected: " + SPRING_BOOT_4_PRESENT, e); + } + } + + /** + * Check if Spring Boot 4.x is present. + * + * @return true if Spring Boot 4.x classes are available + */ + public static boolean isSpringBoot4Present() { + return SPRING_BOOT_4_PRESENT; + } +} From 2ac6b31fda792bc66ea3b9dbf990330f0257afc1 Mon Sep 17 00:00:00 2001 From: arrow2020 <316166287@qq.com> Date: Tue, 30 Dec 2025 03:02:24 +0800 Subject: [PATCH 07/21] * Refactor: string checks with idiomatic alternatives --- .../apollo/internals/RemoteConfigLongPollService.java | 2 +- .../framework/apollo/internals/RemoteConfigRepository.java | 2 +- .../ctrip/framework/apollo/util/http/DefaultHttpClient.java | 2 +- .../com/ctrip/framework/apollo/util/yaml/YamlParser.java | 6 +++--- .../com/ctrip/framework/apollo/core/MetaDomainConsts.java | 4 +--- .../ctrip/framework/apollo/core/signature/Signature.java | 2 +- .../framework/apollo/core/utils/ApolloThreadFactory.java | 2 +- .../ctrip/framework/apollo/core/utils/ResourceUtils.java | 2 +- .../com/ctrip/framework/apollo/core/utils/StringUtils.java | 2 +- .../com/ctrip/framework/apollo/openapi/dto/OpenPageDTO.java | 2 +- 10 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigLongPollService.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigLongPollService.java index 4f52575b..1203b6de 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigLongPollService.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigLongPollService.java @@ -346,7 +346,7 @@ private ServiceDTO resolveConfigService() { private List getConfigServices() { List services = m_serviceLocator.getConfigServices(); - if (services.size() == 0) { + if (services.isEmpty()) { throw new ApolloConfigException("No available config service"); } diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigRepository.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigRepository.java index 3f6f794c..cdcf3d0b 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigRepository.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/RemoteConfigRepository.java @@ -379,7 +379,7 @@ public void run() { private List getConfigServices() { List services = m_serviceLocator.getConfigServices(); - if (services.size() == 0) { + if (services.isEmpty()) { throw new ApolloConfigException("No available config service"); } diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/http/DefaultHttpClient.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/http/DefaultHttpClient.java index 5d5fb40e..722035f9 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/http/DefaultHttpClient.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/http/DefaultHttpClient.java @@ -97,7 +97,7 @@ private HttpResponse doGetWithSerializeFunction(HttpRequest httpRequest, conn.setRequestMethod("GET"); Map headers = httpRequest.getHeaders(); - if (headers != null && headers.size() > 0) { + if (headers != null && !headers.isEmpty()) { for (Map.Entry entry : headers.entrySet()) { conn.setRequestProperty(entry.getKey(), entry.getValue()); } diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java index b3e9e570..e9fad541 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/util/yaml/YamlParser.java @@ -73,7 +73,7 @@ private Yaml createYaml() { private boolean process(MatchCallback callback, Yaml yaml, String content) { int count = 0; if (logger.isDebugEnabled()) { - logger.debug("Loading from YAML: " + content); + logger.debug("Loading from YAML: {}", content); } for (Object object : yaml.loadAll(content)) { if (object != null && process(asMap(object), callback)) { @@ -81,7 +81,7 @@ private boolean process(MatchCallback callback, Yaml yaml, String content) { } } if (logger.isDebugEnabled()) { - logger.debug("Loaded " + count + " document" + (count > 1 ? "s" : "") + " from YAML resource: " + content); + logger.debug("Loaded {} document{} from YAML resource: {}", count, count > 1 ? "s" : "", content); } return (count > 0); } @@ -118,7 +118,7 @@ private boolean process(Map map, MatchCallback callback) { properties.putAll(getFlattenedMap(map)); if (logger.isDebugEnabled()) { - logger.debug("Merging document (no matchers set): " + map); + logger.debug("Merging document (no matchers set): {}", map); } callback.process(properties, map); return true; diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/MetaDomainConsts.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/MetaDomainConsts.java index aa905654..da4ab4a4 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/MetaDomainConsts.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/MetaDomainConsts.java @@ -222,9 +222,7 @@ public void run() { updateMetaServerAddresses(metaServerAddresses); } } catch (Throwable ex) { - logger - .warn(String.format("Refreshing meta server address failed, will retry in %d seconds", - REFRESH_INTERVAL_IN_SECOND), ex); + logger.warn("Refreshing meta server address failed, will retry in {} seconds", REFRESH_INTERVAL_IN_SECOND, ex); } } }, REFRESH_INTERVAL_IN_SECOND, REFRESH_INTERVAL_IN_SECOND, TimeUnit.SECONDS); diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/signature/Signature.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/signature/Signature.java index 2c992423..9aad9078 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/signature/Signature.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/signature/Signature.java @@ -60,7 +60,7 @@ private static String url2PathWithQuery(String urlString) { String query = url.getQuery(); String pathWithQuery = path; - if (query != null && query.length() > 0) { + if (query != null && !query.isEmpty()) { pathWithQuery += "?" + query; } return pathWithQuery; diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ApolloThreadFactory.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ApolloThreadFactory.java index c4e67a79..dcbc0963 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ApolloThreadFactory.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ApolloThreadFactory.java @@ -60,7 +60,7 @@ public boolean satisfy(Thread thread) { return !thread.isAlive() || thread.isInterrupted() || thread.isDaemon(); } }); - if (alives.size() > 0) { + if (!alives.isEmpty()) { log.info("Alive apollo threads: {}", alives); try { TimeUnit.SECONDS.sleep(2); diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ResourceUtils.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ResourceUtils.java index 2c1dca0a..885da9c9 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ResourceUtils.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ResourceUtils.java @@ -65,7 +65,7 @@ public static Properties readConfigFile(String configPath, Properties defaults) } if (sb.length() > 0) { - logger.debug("Reading properties: \n" + sb); + logger.debug("Reading properties: \n{}", sb); } else { logger.warn("No available properties: {}", configPath); } diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java index a223374b..09bbb780 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/StringUtils.java @@ -41,7 +41,7 @@ public class StringUtils { * @return true if the String is empty or null */ public static boolean isEmpty(String str) { - return str == null || str.length() == 0; + return str == null || str.isEmpty(); } diff --git a/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/dto/OpenPageDTO.java b/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/dto/OpenPageDTO.java index 4b4678cb..a1755b8d 100644 --- a/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/dto/OpenPageDTO.java +++ b/apollo-openapi/src/main/java/com/ctrip/framework/apollo/openapi/dto/OpenPageDTO.java @@ -53,7 +53,7 @@ public List getContent() { } public boolean hasContent() { - return content != null && content.size() > 0; + return content != null && !content.isEmpty(); } } From 75385a57b4180dd1c497d41acd500cf67d7e48b5 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 1 Jan 2026 05:27:44 +0000 Subject: [PATCH 08/21] retry ut on error --- .github/workflows/build.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 752b3409..aa315c00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,8 +45,13 @@ jobs: ${{ runner.os }}-maven- - name: JDK 8 if: matrix.jdk == '8' - run: mvn -B clean package -P travis jacoco:report -Dmaven.gitcommitid.skip=true - - name: JDK 11 + uses: nick-fields/retry@v3 + with: + timeout_minutes: 3 + max_attempts: 3 + retry_wait_seconds: 1 + command: mvn -B clean package -P travis jacoco:report -Dmaven.gitcommitid.skip=true + - name: JDK 11 if: matrix.jdk == '11' run: mvn -B clean compile -Dmaven.gitcommitid.skip=true - name: JDK 17 From f4879c5e18e7c228c33c89fca14be52494d03707 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 8 Feb 2026 11:51:40 +0800 Subject: [PATCH 09/21] docs: add AGENTS.md for contribution workflow --- AGENTS.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1041ba84 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Multi-module Maven repository with root `pom.xml`. +- Core modules: + - `apollo-core`: shared constants/utilities. + - `apollo-client`: main Java client and Spring integration. + - `apollo-client-config-data`: Spring Boot ConfigData integration. + - `apollo-mockserver`: test/mock support. + - `apollo-openapi`: OpenAPI client. + - `apollo-plugin`: plugin modules. +- Tests follow standard Maven layout: `*/src/test/java` and `*/src/test/resources`. +- CI workflows are in `.github/workflows`. + +## Build, Test, and Development Commands +- Build all modules: `mvn -B clean package -Dmaven.gitcommitid.skip=true` +- Run all tests: `mvn clean test` +- Run one module tests: `mvn -pl apollo-client clean test` +- Run targeted tests: `mvn -pl apollo-client -Dtest=ClassNameTest test` +- Compile only: `mvn -B clean compile -Dmaven.gitcommitid.skip=true` +- Notes: + - This repository does **not** configure `spotless`; do not use `spotless:apply` here. + - Some integration tests log connection warnings in local/dev environments; focus on final Maven summary. + +## Coding Style & Naming Conventions +- Java style: follow existing codebase conventions (Google-style Java patterns in current files). +- Keep changes minimal and module-scoped. +- Preserve existing package/class naming conventions. +- Add/adjust tests for non-trivial behavior changes. + +## Testing Guidelines +- JUnit 4 + JUnit Vintage are both used in this repo. +- For bug fixes: + - Add a regression test first when feasible. + - Run module-level tests for changed modules. + - Prefer full `mvn clean test` before opening PR. + +## Commit & Pull Request Guidelines +- Use Conventional Commits, e.g. `fix: ...`, `feat: ...`, `docs: ...`. +- If applicable, include issue linkage in commit message body, e.g. `Fixes #88`. +- Keep PR commits clean: + - Require a single commit in the PR branch before review (squash locally if needed). +- Open PRs with `.github/PULL_REQUEST_TEMPLATE.md` and fill all sections with concrete content. +- PR description should include: + - purpose/root cause + - change summary + - test commands actually run +- PR submission flow: + - Push branch to personal fork remote first (for example `origin`). + - Open PR from `:` to `apolloconfig/apollo-java:main`. + - If history is rewritten for squash, update remote branch with `--force-with-lease`. + +## CHANGES.md Rules +- Update `CHANGES.md` for user-visible fixes/features. +- Use bullet style consistent with existing entries. +- Entry format should be a Markdown link: + - link text = the actual change description + - link target = the PR URL (not issue URL) +- Example: + - `[Fix ... detailed summary](https://github.com/apolloconfig/apollo-java/pull/123)` + +## Agent Workflow Hints +- Reproduce first, then fix. +- Prefer targeted module/test runs during iteration, then run broader tests before PR. +- When creating upstream PRs: + - use a branch in personal fork + - base repository is `apolloconfig/apollo-java` + - ensure PR branch is squashed to one commit From 21057a74eaf3eecc924ea9a3a5b8dd41c16759b6 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Wed, 18 Feb 2026 23:40:27 +0800 Subject: [PATCH 10/21] test: overhaul automated compatibility coverage (#123) * test: overhaul automated compatibility coverage * fix(ci): restore retry for unit integration tests * chore: fix license headers and update changelog * fix: address coderabbit stability and compatibility findings * fix: address new review findings from bots * fix: simplify customizer SPI and tighten CI retry timeout --- .github/workflows/build.yml | 169 +++++- CHANGES.md | 1 + apollo-client-config-data/pom.xml | 10 + .../ApolloClientPropertiesFactory.java | 7 - ...ClientLongPollingExtensionInitializer.java | 6 +- .../webclient/ApolloWebClientHttpClient.java | 54 +- ...polloClientWebClientCustomizerFactory.java | 9 +- ...ClientConfigDataAutoConfigurationTest.java | 72 +++ ...oClientExtensionInitializeFactoryTest.java | 104 ++++ .../ApolloWebClientHttpClientTest.java | 119 +++++ ...ApolloConfigDataLoaderInitializerTest.java | 124 +++++ .../importer/ApolloConfigDataLoaderTest.java | 222 ++++++++ .../ApolloConfigDataLocationResolverTest.java | 72 +++ .../ConfigDataIntegrationTest.java | 292 +++++++++++ ...DeferredLoggerApplicationListenerTest.java | 64 +++ ...SpringApplicationRegisterListenerTest.java | 67 +++ ...tstrapRegistryHelperCompatibilityTest.java | 98 ++++ .../mockdata-TEST1.apollo.properties | 17 + .../resources/mockdata-application.properties | 21 + .../mockdata-application.yaml.properties | 16 + .../annotation/ApolloAnnotationProcessor.java | 12 +- .../framework/apollo/BaseIntegrationTest.java | 10 + .../framework/apollo/MockedConfigService.java | 38 +- .../integration/ConfigIntegrationTest.java | 247 ++++++++- .../spring/JavaConfigAnnotationTest.java | 175 +++++++ .../apollo-api-compat-it/pom.xml | 64 +++ .../api/ApolloApiCompatibilityTest.java | 222 ++++++++ .../mockdata-100004459-application.properties | 17 + .../mockdata-TEST1.apollo.properties | 17 + .../resources/mockdata-application.properties | 17 + .../mockdata-application.yaml.properties | 16 + .../mockdata-datasources.xml.properties | 16 + .../apollo-spring-boot-compat-it/pom.xml | 100 ++++ .../ApolloSpringBootCompatibilityTest.java | 480 ++++++++++++++++++ .../mockdata-100004459-application.properties | 16 + .../mockdata-TEST1.apollo.properties | 16 + .../resources/mockdata-application.properties | 23 + .../mockdata-application.yaml.properties | 16 + .../apollo-spring-compat-it/pom.xml | 96 ++++ .../SpringAnnotationCompatibilityTest.java | 287 +++++++++++ .../SpringApolloEventListenerProbe.java | 40 ++ .../SpringCompatibilityTestSupport.java | 130 +++++ .../apollo/compat/spring/SpringXmlBean.java | 57 +++ .../spring/SpringXmlCompatibilityTest.java | 88 ++++ .../mockdata-100004459-application.properties | 16 + .../mockdata-TEST1.apollo.properties | 16 + .../resources/mockdata-application.properties | 19 + .../mockdata-application.yaml.properties | 16 + .../test/resources/spring/apollo-context.xml | 34 ++ apollo-compat-tests/pom.xml | 59 +++ .../mockserver/ApolloTestingServer.java | 154 ++++-- .../apollo/mockserver/EmbeddedApollo.java | 14 + .../mockserver/ApolloMockServerApiTest.java | 35 ++ .../ApolloOpenApiMockIntegrationTest.java | 257 ++++++++++ apollo-plugin/apollo-plugin-log4j2/pom.xml | 5 + .../ApolloClientConfigurationFactoryTest.java | 239 +++++++++ pom.xml | 7 +- 57 files changed, 4531 insertions(+), 84 deletions(-) create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/ApolloClientConfigDataAutoConfigurationTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactoryTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClientTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializerTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLocationResolverTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloDeferredLoggerApplicationListenerTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListenerTest.java create mode 100644 apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelperCompatibilityTest.java create mode 100644 apollo-client-config-data/src/test/resources/mockdata-TEST1.apollo.properties create mode 100644 apollo-client-config-data/src/test/resources/mockdata-application.properties create mode 100644 apollo-client-config-data/src/test/resources/mockdata-application.yaml.properties create mode 100644 apollo-compat-tests/apollo-api-compat-it/pom.xml create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/java/com/ctrip/framework/apollo/compat/api/ApolloApiCompatibilityTest.java create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-100004459-application.properties create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-TEST1.apollo.properties create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.properties create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.yaml.properties create mode 100644 apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-datasources.xml.properties create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/pom.xml create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/src/test/java/com/ctrip/framework/apollo/compat/springboot/ApolloSpringBootCompatibilityTest.java create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-100004459-application.properties create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-TEST1.apollo.properties create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.properties create mode 100644 apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.yaml.properties create mode 100644 apollo-compat-tests/apollo-spring-compat-it/pom.xml create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringCompatibilityTestSupport.java create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlBean.java create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlCompatibilityTest.java create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-100004459-application.properties create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-TEST1.apollo.properties create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.properties create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.yaml.properties create mode 100644 apollo-compat-tests/apollo-spring-compat-it/src/test/resources/spring/apollo-context.xml create mode 100644 apollo-compat-tests/pom.xml create mode 100644 apollo-openapi/src/test/java/com/ctrip/framework/apollo/openapi/client/ApolloOpenApiMockIntegrationTest.java create mode 100644 apollo-plugin/apollo-plugin-log4j2/src/test/java/com/ctrip/framework/apollo/plugin/log4j2/ApolloClientConfigurationFactoryTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa315c00..570714f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven name: build @@ -25,40 +23,173 @@ on: branches: [ main ] jobs: - build: + compile-matrix: runs-on: ubuntu-latest strategy: matrix: jdk: [8, 11, 17] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: temurin java-version: ${{ matrix.jdk }} - name: Cache Maven packages uses: actions/cache@v4 with: path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + key: ${{ runner.os }}-maven-compile-${{ matrix.jdk }}-${{ hashFiles('**/pom.xml') }} restore-keys: | + ${{ runner.os }}-maven-compile-${{ matrix.jdk }}- ${{ runner.os }}-maven- - - name: JDK 8 - if: matrix.jdk == '8' + - name: Compile + run: mvn -B clean compile -Dmaven.gitcommitid.skip=true + + unit-integration-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-unit-integration-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-unit-integration- + ${{ runner.os }}-maven- + - name: Run unit and integration tests uses: nick-fields/retry@v3 with: - timeout_minutes: 3 + timeout_minutes: 4 max_attempts: 3 retry_wait_seconds: 1 - command: mvn -B clean package -P travis jacoco:report -Dmaven.gitcommitid.skip=true - - name: JDK 11 - if: matrix.jdk == '11' - run: mvn -B clean compile -Dmaven.gitcommitid.skip=true - - name: JDK 17 - if: matrix.jdk == '17' - run: mvn -B clean compile -Dmaven.gitcommitid.skip=true + command: mvn -B clean test -P travis jacoco:report -Dmaven.gitcommitid.skip=true - name: Upload coverage to Codecov - if: matrix.jdk == '8' - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: - file: ${{ github.workspace }}/apollo-*/target/site/jacoco/jacoco.xml + files: ${{ github.workspace }}/apollo-*/target/site/jacoco/jacoco.xml + + compat-api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-compat-api-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-compat-api- + ${{ runner.os }}-maven- + - name: Build local artifacts for api compatibility + run: | + mvn -B -pl apollo-core,apollo-client,apollo-mockserver -am \ + -DskipTests install -Dmaven.gitcommitid.skip=true + - name: Run api compatibility tests + run: | + mvn -B -f apollo-compat-tests/pom.xml -pl apollo-api-compat-it \ + test -Dmaven.gitcommitid.skip=true + + compat-spring: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: spring-3.1.1-jdk8 + java: 8 + spring_framework: 3.1.1.RELEASE + java_version_prop: 1.8 + - name: spring-6.1-jdk17 + java: 17 + spring_framework: 6.1.18 + java_version_prop: 17 + name: compat-spring-${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-compat-spring-${{ matrix.name }}-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-compat-spring-${{ matrix.name }}- + ${{ runner.os }}-maven- + - name: Build local artifacts for compat tests + run: | + mvn -B -pl apollo-core,apollo-client,apollo-mockserver -am \ + -DskipTests install -Dmaven.gitcommitid.skip=true + - name: Run spring compatibility tests + run: | + mvn -B -f apollo-compat-tests/pom.xml -pl apollo-spring-compat-it \ + -Dspring.framework.version=${{ matrix.spring_framework }} \ + -Djava.version=${{ matrix.java_version_prop }} \ + test -Dmaven.gitcommitid.skip=true + + compat-spring-boot: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: spring-boot-2.7-jdk8 + java: 8 + spring_boot: 2.7.18 + java_version_prop: 1.8 + compat_slf4j: 1.7.36 + compat_vintage: 5.7.0 + - name: spring-boot-3.3-jdk17 + java: 17 + spring_boot: 3.3.10 + java_version_prop: 17 + compat_slf4j: 2.0.17 + compat_vintage: 5.10.5 + - name: spring-boot-4.0-jdk17 + java: 17 + spring_boot: 4.0.0 + java_version_prop: 17 + compat_slf4j: 2.0.17 + compat_vintage: 6.0.1 + name: compat-spring-boot-${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-compat-spring-boot-${{ matrix.name }}-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-compat-spring-boot-${{ matrix.name }}- + ${{ runner.os }}-maven- + - name: Build local artifacts for spring boot compatibility + run: | + mvn -B -pl apollo-core,apollo-client,apollo-mockserver,apollo-client-config-data -am \ + -DskipTests \ + install -Dmaven.gitcommitid.skip=true + - name: Run spring boot compatibility tests + run: | + mvn -B -f apollo-compat-tests/pom.xml -pl apollo-spring-boot-compat-it \ + -Dspring-boot.version=${{ matrix.spring_boot }} \ + -Djava.version=${{ matrix.java_version_prop }} \ + -Dcompat.slf4j.version=${{ matrix.compat_slf4j }} \ + -Dcompat.junit.vintage.version=${{ matrix.compat_vintage }} \ + test -Dmaven.gitcommitid.skip=true diff --git a/CHANGES.md b/CHANGES.md index 2060a6a5..00d7bcdd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ Apollo Java 2.5.0 * [Feature Added a new feature to get instance count by namespace.](https://github.com/apolloconfig/apollo-java/pull/103) * [Feature Support retry in open api client.](https://github.com/apolloconfig/apollo-java/pull/105) * [Support Spring Boot 4.0 bootstrap context package relocation for apollo-client-config-data](https://github.com/apolloconfig/apollo-java/pull/115) +* [Test Overhaul automated compatibility coverage across API/Spring/Spring Boot scenarios](https://github.com/apolloconfig/apollo-java/pull/123) ------------------ All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/5?closed=1) diff --git a/apollo-client-config-data/pom.xml b/apollo-client-config-data/pom.xml index acafc551..f56b74e4 100644 --- a/apollo-client-config-data/pom.xml +++ b/apollo-client-config-data/pom.xml @@ -73,5 +73,15 @@ + + com.ctrip.framework.apollo + apollo-mockserver + test + + + io.projectreactor.netty + reactor-netty-http + test + diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientPropertiesFactory.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientPropertiesFactory.java index cb008ae7..05b50076 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientPropertiesFactory.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientPropertiesFactory.java @@ -17,7 +17,6 @@ package com.ctrip.framework.apollo.config.data.extension.initialize; import com.ctrip.framework.apollo.config.data.extension.properties.ApolloClientProperties; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; @@ -35,10 +34,4 @@ public ApolloClientProperties createApolloClientProperties( return binder.bind(PROPERTIES_PREFIX, Bindable.of(ApolloClientProperties.class), bindHandler).orElse(null); } - - public OAuth2ClientProperties createOauth2ClientProperties(Binder binder, - BindHandler bindHandler) { - return binder.bind("spring.security.oauth2.client", Bindable.of(OAuth2ClientProperties.class), - bindHandler).orElse(null); - } } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java index 03185e56..9cc534c8 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloClientLongPollingExtensionInitializer.java @@ -23,11 +23,11 @@ import com.ctrip.framework.apollo.util.http.HttpClient; import com.ctrip.framework.foundation.internals.ServiceBootstrap; import java.util.List; +import java.util.function.Consumer; import org.apache.commons.logging.Log; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.logging.DeferredLogFactory; -import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.function.client.WebClient; @@ -55,11 +55,11 @@ public void initialize(ApolloClientProperties apolloClientProperties, Binder bin .loadAllOrdered(ApolloClientWebClientCustomizerFactory.class); if (!CollectionUtils.isEmpty(factories)) { for (ApolloClientWebClientCustomizerFactory factory : factories) { - WebClientCustomizer webClientCustomizer = factory + Consumer webClientCustomizer = factory .createWebClientCustomizer(apolloClientProperties, binder, bindHandler, this.log, this.bootstrapContext); if (webClientCustomizer != null) { - webClientCustomizer.customize(webClientBuilder); + webClientCustomizer.accept(webClientBuilder); } } } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClient.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClient.java index f36bcca4..33191142 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClient.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClient.java @@ -22,11 +22,15 @@ import com.ctrip.framework.apollo.util.http.HttpRequest; import com.ctrip.framework.apollo.util.http.HttpResponse; import com.google.gson.Gson; +import java.lang.reflect.Method; import java.lang.reflect.Type; import java.net.URI; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.springframework.http.HttpStatus; import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @@ -35,6 +39,10 @@ */ public class ApolloWebClientHttpClient implements HttpClient { + private static final Method CLIENT_RESPONSE_STATUS_CODE_METHOD = resolveClientResponseStatusCodeMethod(); + private static final ConcurrentMap, Method> STATUS_CODE_VALUE_METHOD_CACHE = + new ConcurrentHashMap, Method>(); + private final WebClient webClient; private final Gson gson; @@ -64,15 +72,16 @@ private HttpResponse doGetInternal(HttpRequest httpRequest, Type response } } return requestHeadersSpec.exchangeToMono(clientResponse -> { - if (HttpStatus.OK.equals(clientResponse.statusCode())) { + int statusCode = this.resolveStatusCode(clientResponse); + if (HttpStatus.OK.value() == statusCode) { return clientResponse.bodyToMono(String.class) .map(body -> new HttpResponse(HttpStatus.OK.value(), gson.fromJson(body, responseType))); } - if (HttpStatus.NOT_MODIFIED.equals(clientResponse.statusCode())) { + if (HttpStatus.NOT_MODIFIED.value() == statusCode) { return Mono.just(new HttpResponse(HttpStatus.NOT_MODIFIED.value(), null)); } - return Mono.error(new ApolloConfigStatusCodeException(clientResponse.rawStatusCode(), + return Mono.error(new ApolloConfigStatusCodeException(statusCode, String.format("Get operation failed for %s", httpRequest.getUrl()))); }).block(); } @@ -82,4 +91,43 @@ public HttpResponse doGet(HttpRequest httpRequest, Type responseType) throws ApolloConfigException { return this.doGetInternal(httpRequest, responseType); } + + /** + * Resolve HTTP status code across Spring WebFlux 5/6/7. + * + *

ClientResponse#statusCode has different return types across major versions + * (HttpStatus in Spring 5, HttpStatusCode in Spring 6/7). Calling it directly would bind + * to one method descriptor at compile time and could fail on another runtime version. + * Reflection keeps this bridge binary-compatible for Boot 2/3/4 compatibility tests. + */ + private int resolveStatusCode(Object clientResponse) { + try { + Object statusCode = CLIENT_RESPONSE_STATUS_CODE_METHOD.invoke(clientResponse); + if (statusCode == null) { + throw new ApolloConfigException("Failed to resolve response status code: statusCode is null"); + } + Method valueMethod = STATUS_CODE_VALUE_METHOD_CACHE.computeIfAbsent(statusCode.getClass(), + ApolloWebClientHttpClient::resolveStatusCodeValueMethod); + Object value = valueMethod.invoke(statusCode); + return ((Number) value).intValue(); + } catch (Exception ex) { + throw new ApolloConfigException("Failed to resolve response status code", ex); + } + } + + private static Method resolveClientResponseStatusCodeMethod() { + try { + return ClientResponse.class.getMethod("statusCode"); + } catch (NoSuchMethodException ex) { + throw new ExceptionInInitializerError(ex); + } + } + + private static Method resolveStatusCodeValueMethod(Class statusCodeType) { + try { + return statusCodeType.getMethod("value"); + } catch (NoSuchMethodException ex) { + throw new IllegalStateException("Failed to resolve value() method from " + statusCodeType, ex); + } + } } diff --git a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java index f7feb1ff..b02ef297 100644 --- a/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java +++ b/apollo-client-config-data/src/main/java/com/ctrip/framework/apollo/config/data/extension/webclient/customizer/spi/ApolloClientWebClientCustomizerFactory.java @@ -18,11 +18,12 @@ import com.ctrip.framework.apollo.config.data.extension.properties.ApolloClientProperties; import com.ctrip.framework.apollo.core.spi.Ordered; +import java.util.function.Consumer; import org.apache.commons.logging.Log; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.WebClient; /** * @author vdisk @@ -30,7 +31,7 @@ public interface ApolloClientWebClientCustomizerFactory extends Ordered { /** - * create a WebClientCustomizer instance + * create a webclient builder customizer * * @param apolloClientProperties apollo client binded properties * @param binder properties binder @@ -41,10 +42,10 @@ public interface ApolloClientWebClientCustomizerFactory extends Ordered { * Spring Boot 3.x or * org.springframework.boot.bootstrap.ConfigurableBootstrapContext * for Spring Boot 4.x) - * @return WebClientCustomizer instance or null + * @return customizer instance or null */ @Nullable - WebClientCustomizer createWebClientCustomizer(ApolloClientProperties apolloClientProperties, + Consumer createWebClientCustomizer(ApolloClientProperties apolloClientProperties, Binder binder, BindHandler bindHandler, Log log, Object bootstrapContext); } diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/ApolloClientConfigDataAutoConfigurationTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/ApolloClientConfigDataAutoConfigurationTest.java new file mode 100644 index 00000000..4ad4e050 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/ApolloClientConfigDataAutoConfigurationTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ctrip.framework.apollo.config.data.extension.properties.ApolloClientProperties; +import com.ctrip.framework.apollo.spring.config.ConfigPropertySourcesProcessor; +import com.ctrip.framework.apollo.spring.config.PropertySourcesProcessor; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.junit.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author vdisk + */ +public class ApolloClientConfigDataAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ApolloClientConfigDataAutoConfiguration.class)); + + @Test + public void testDefaultBeansLoaded() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(ApolloClientProperties.class); + assertThat(context).hasSingleBean(PropertySourcesProcessor.class); + assertThat(context.getBean(PropertySourcesProcessor.class)) + .isInstanceOf(ConfigPropertySourcesProcessor.class); + }); + } + + @Test + public void testConditionalOnMissingBean() { + contextRunner.withUserConfiguration(CustomBeansConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ApolloClientProperties.class); + assertThat(context.getBean(ApolloClientProperties.class)) + .isSameAs(context.getBean("customApolloClientProperties")); + assertThat(context.getBean(PropertySourcesProcessor.class)) + .isSameAs(context.getBean("customPropertySourcesProcessor")); + }); + } + + @Configuration + static class CustomBeansConfiguration { + + @Bean + public ApolloClientProperties customApolloClientProperties() { + return new ApolloClientProperties(); + } + + @Bean + public static PropertySourcesProcessor customPropertySourcesProcessor() { + return new ConfigPropertySourcesProcessor(); + } + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactoryTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactoryTest.java new file mode 100644 index 00000000..715dc172 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/initialize/ApolloClientExtensionInitializeFactoryTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.extension.initialize; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.ctrip.framework.apollo.config.data.injector.ApolloConfigDataInjectorCustomizer; +import com.ctrip.framework.apollo.util.http.HttpClient; +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.logging.DeferredLogFactory; + +/** + * @author vdisk + */ +public class ApolloClientExtensionInitializeFactoryTest { + + private final DeferredLogFactory logFactory = destination -> destination.get(); + + @Before + public void setUp() throws Exception { + clearInjectorCustomizerCaches(); + } + + @After + public void tearDown() throws Exception { + clearInjectorCustomizerCaches(); + } + + @Test + public void testInitializeExtensionDisabledByDefault() { + ApolloClientExtensionInitializeFactory factory = + new ApolloClientExtensionInitializeFactory(logFactory, new Object()); + Binder binder = new Binder(new MapConfigurationPropertySource(new LinkedHashMap<>())); + + factory.initializeExtension(binder, null); + + assertFalse(ApolloConfigDataInjectorCustomizer.isRegistered(HttpClient.class)); + } + + @Test + public void testInitializeLongPollingExtension() { + ApolloClientExtensionInitializeFactory factory = + new ApolloClientExtensionInitializeFactory(logFactory, new Object()); + Map map = new LinkedHashMap<>(); + map.put("apollo.client.extension.enabled", "true"); + map.put("apollo.client.extension.messaging-type", "long_polling"); + Binder binder = new Binder(new MapConfigurationPropertySource(map)); + + factory.initializeExtension(binder, null); + + assertTrue(ApolloConfigDataInjectorCustomizer.isRegistered(HttpClient.class)); + } + + @Test + public void testInitializeWebsocketExtensionThrowsException() { + ApolloClientExtensionInitializeFactory factory = + new ApolloClientExtensionInitializeFactory(logFactory, new Object()); + Map map = new LinkedHashMap<>(); + map.put("apollo.client.extension.enabled", "true"); + map.put("apollo.client.extension.messaging-type", "websocket"); + Binder binder = new Binder(new MapConfigurationPropertySource(map)); + + try { + factory.initializeExtension(binder, null); + fail("Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException ex) { + assertTrue(ex.getMessage().contains("websocket support is not complete yet")); + } + } + + private void clearInjectorCustomizerCaches() throws Exception { + Field instanceSuppliers = + ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCE_SUPPLIERS"); + instanceSuppliers.setAccessible(true); + ((Map) instanceSuppliers.get(null)).clear(); + + Field instances = ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCES"); + instances.setAccessible(true); + ((Map) instances.get(null)).clear(); + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClientTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClientTest.java new file mode 100644 index 00000000..ec912803 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/extension/webclient/ApolloWebClientHttpClientTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.extension.webclient; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.ctrip.framework.apollo.exceptions.ApolloConfigStatusCodeException; +import com.ctrip.framework.apollo.util.http.HttpRequest; +import com.ctrip.framework.apollo.util.http.HttpResponse; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * @author vdisk + */ +public class ApolloWebClientHttpClientTest { + + private HttpServer server; + private ApolloWebClientHttpClient httpClient; + + @Before + public void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + httpClient = new ApolloWebClientHttpClient(WebClient.builder().build()); + } + + @After + public void tearDown() { + server.stop(0); + } + + @Test + public void testDoGetWith200AndHeaders() { + AtomicReference headerValue = new AtomicReference<>(); + server.createContext("/ok", exchange -> { + headerValue.set(exchange.getRequestHeaders().getFirst("x-apollo-test")); + writeResponse(exchange, 200, "{\"value\":\"v1\"}"); + }); + + HttpRequest request = new HttpRequest(url("/ok")); + request.setHeaders(Collections.singletonMap("x-apollo-test", "header-value")); + HttpResponse response = httpClient.doGet(request, ResponseBody.class); + + assertEquals(200, response.getStatusCode()); + assertEquals("v1", response.getBody().value); + assertEquals("header-value", headerValue.get()); + } + + @Test + public void testDoGetWith304() { + server.createContext("/not-modified", exchange -> writeResponse(exchange, 304, "")); + + HttpRequest request = new HttpRequest(url("/not-modified")); + HttpResponse response = httpClient.doGet(request, ResponseBody.class); + + assertEquals(304, response.getStatusCode()); + assertNull(response.getBody()); + } + + @Test + public void testDoGetWithUnexpectedStatusCode() { + server.createContext("/error", exchange -> writeResponse(exchange, 500, "internal")); + + HttpRequest request = new HttpRequest(url("/error")); + + try { + httpClient.doGet(request, ResponseBody.class); + fail("Expected ApolloConfigStatusCodeException"); + } catch (ApolloConfigStatusCodeException ex) { + assertEquals(500, ex.getStatusCode()); + assertTrue(ex.getMessage().contains("Get operation failed")); + } + } + + private String url(String path) { + return "http://127.0.0.1:" + server.getAddress().getPort() + path; + } + + private void writeResponse(HttpExchange exchange, int code, String body) throws IOException { + byte[] data = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(code, data.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(data); + } + } + + private static class ResponseBody { + private String value; + } +} + diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializerTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializerTest.java new file mode 100644 index 00000000..bc4c0be3 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderInitializerTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.importer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.ctrip.framework.apollo.config.data.injector.ApolloConfigDataInjectorCustomizer; +import com.ctrip.framework.apollo.spring.config.PropertySourcesConstants; +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; + +/** + * @author vdisk + */ +public class ApolloConfigDataLoaderInitializerTest { + + private final DeferredLogFactory logFactory = destination -> destination.get(); + + @Before + public void setUp() throws Exception { + resetInitializedFlag(); + clearInjectorCustomizerCaches(); + } + + @After + public void tearDown() throws Exception { + resetInitializedFlag(); + clearInjectorCustomizerCaches(); + } + + @Test + public void testInitApolloClientOnlyOnce() throws Exception { + Object bootstrapContext = newDefaultBootstrapContext(); + Binder binder = new Binder(new MapConfigurationPropertySource(new LinkedHashMap<>())); + ApolloConfigDataLoaderInitializer initializer = + new ApolloConfigDataLoaderInitializer(logFactory, binder, null, bootstrapContext); + + List> firstPropertySources = initializer.initApolloClient(); + List> secondPropertySources = initializer.initApolloClient(); + + assertEquals(2, firstPropertySources.size()); + assertTrue(secondPropertySources.isEmpty()); + } + + @Test + public void testForceDisableBootstrapWhenBootstrapEnabledInConfigDataMode() throws Exception { + Object bootstrapContext = newDefaultBootstrapContext(); + Map properties = new LinkedHashMap<>(); + properties.put(PropertySourcesConstants.APOLLO_BOOTSTRAP_ENABLED, "true"); + Binder binder = new Binder(new MapConfigurationPropertySource(properties)); + ApolloConfigDataLoaderInitializer initializer = + new ApolloConfigDataLoaderInitializer(logFactory, binder, null, bootstrapContext); + + List> propertySources = initializer.initApolloClient(); + + assertEquals(2, propertySources.size()); + assertTrue(propertySources.get(1) instanceof MapPropertySource); + MapPropertySource mapPropertySource = (MapPropertySource) propertySources.get(1); + assertEquals("false", + mapPropertySource.getProperty(PropertySourcesConstants.APOLLO_BOOTSTRAP_ENABLED)); + assertEquals("false", + mapPropertySource.getProperty(PropertySourcesConstants.APOLLO_BOOTSTRAP_EAGER_LOAD_ENABLED)); + } + + private void resetInitializedFlag() throws Exception { + Field initializedField = ApolloConfigDataLoaderInitializer.class.getDeclaredField("INITIALIZED"); + initializedField.setAccessible(true); + initializedField.setBoolean(null, false); + } + + private void clearInjectorCustomizerCaches() throws Exception { + Field instanceSuppliers = + ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCE_SUPPLIERS"); + instanceSuppliers.setAccessible(true); + ((Map) instanceSuppliers.get(null)).clear(); + + Field instances = ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCES"); + instances.setAccessible(true); + ((Map) instances.get(null)).clear(); + } + + private Object newDefaultBootstrapContext() throws Exception { + String className = "org.springframework.boot.DefaultBootstrapContext"; + if (isClassPresent("org.springframework.boot.bootstrap.DefaultBootstrapContext")) { + className = "org.springframework.boot.bootstrap.DefaultBootstrapContext"; + } + Class bootstrapContextClass = Class.forName(className); + return bootstrapContextClass.getConstructor().newInstance(); + } + + private boolean isClassPresent(String className) { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException ex) { + return false; + } + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderTest.java new file mode 100644 index 00000000..354ac722 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLoaderTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.importer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.config.data.injector.ApolloMockInjectorCustomizer; +import com.ctrip.framework.apollo.config.data.util.BootstrapRegistryHelper; +import com.ctrip.framework.apollo.internals.ConfigManager; +import com.ctrip.framework.apollo.spring.config.PropertySourcesConstants; +import com.ctrip.framework.apollo.spi.ConfigFactory; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.google.common.collect.Table; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.PropertySource; + +/** + * @author vdisk + */ +public class ApolloConfigDataLoaderTest { + + private final DeferredLogFactory logFactory = destination -> destination.get(); + + @Before + public void setUp() throws Exception { + clearApolloClientCaches(); + resetInitializer(); + resetConfigService(); + ApolloMockInjectorCustomizer.clear(); + System.setProperty("app.id", "loader-test-app"); + System.setProperty("env", "local"); + } + + @After + public void tearDown() throws Exception { + ApolloMockInjectorCustomizer.clear(); + resetInitializer(); + resetConfigService(); + clearApolloClientCaches(); + System.clearProperty("app.id"); + System.clearProperty("env"); + } + + @Test + public void testLoadPropertySourceOrderAndInitializerReuse() throws Exception { + ApolloMockInjectorCustomizer.register(ConfigFactory.class, this::newConfigFactory); + + Object bootstrapContext = newDefaultBootstrapContext(); + Binder binder = new Binder(new MapConfigurationPropertySource(new LinkedHashMap<>())); + BootstrapRegistryHelper.registerIfAbsent(bootstrapContext, Binder.class, binder); + ConfigDataLoaderContext context = newContextWithBootstrapContext(bootstrapContext); + + ApolloConfigDataLoader loader = new ApolloConfigDataLoader(logFactory); + + ConfigData firstConfigData = loader.load(context, new ApolloConfigDataResource("application")); + assertEquals(3, firstConfigData.getPropertySources().size()); + assertEquals("application", firstConfigData.getPropertySources().get(0).getName()); + assertEquals(PropertySourcesConstants.APOLLO_PROPERTY_SOURCE_NAME, + firstConfigData.getPropertySources().get(1).getName()); + assertEquals(PropertySourcesConstants.APOLLO_BOOTSTRAP_PROPERTY_SOURCE_NAME, + firstConfigData.getPropertySources().get(2).getName()); + + ConfigData secondConfigData = loader.load(context, new ApolloConfigDataResource("TEST1.apollo")); + assertEquals(1, secondConfigData.getPropertySources().size()); + PropertySource secondPropertySource = secondConfigData.getPropertySources().get(0); + assertEquals("TEST1.apollo", secondPropertySource.getName()); + assertEquals("v2", secondPropertySource.getProperty("key2")); + } + + private ConfigFactory newConfigFactory() { + Map configMap = new HashMap<>(); + configMap.put("application", mockConfig(singletonConfig("key1", "v1"))); + configMap.put("TEST1.apollo", mockConfig(singletonConfig("key2", "v2"))); + return new ConfigFactory() { + @Override + public Config create(String namespace) { + return create("loader-test-app", namespace); + } + + @Override + public Config create(String appId, String namespace) { + Config config = configMap.get(namespace); + return config != null ? config : mockConfig(new HashMap<>()); + } + + @Override + public com.ctrip.framework.apollo.ConfigFile createConfigFile(String namespace, + com.ctrip.framework.apollo.core.enums.ConfigFileFormat configFileFormat) { + return null; + } + + @Override + public com.ctrip.framework.apollo.ConfigFile createConfigFile(String appId, String namespace, + com.ctrip.framework.apollo.core.enums.ConfigFileFormat configFileFormat) { + return null; + } + }; + } + + private Config mockConfig(Map properties) { + Config config = mock(Config.class); + Set propertyNames = properties.keySet(); + when(config.getPropertyNames()).thenReturn(propertyNames); + when(config.getProperty(anyString(), nullable(String.class))).thenAnswer(invocation -> { + String key = invocation.getArgument(0, String.class); + String defaultValue = invocation.getArgument(1); + return properties.getOrDefault(key, defaultValue); + }); + return config; + } + + private Map singletonConfig(String key, String value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + private ConfigDataLoaderContext newContextWithBootstrapContext(Object bootstrapContext) { + return (ConfigDataLoaderContext) Proxy.newProxyInstance( + ConfigDataLoaderContext.class.getClassLoader(), + new Class[]{ConfigDataLoaderContext.class}, + (proxy, method, args) -> { + if ("getBootstrapContext".equals(method.getName())) { + return bootstrapContext; + } + throw new UnsupportedOperationException("Unexpected method: " + method.getName()); + }); + } + + private void resetInitializer() throws Exception { + Field initializedField = ApolloConfigDataLoaderInitializer.class.getDeclaredField("INITIALIZED"); + initializedField.setAccessible(true); + initializedField.setBoolean(null, false); + } + + private void resetConfigService() throws Exception { + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + } + + private void clearApolloClientCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + @SuppressWarnings("unchecked") + private void clearField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object container = field.get(target); + if (container instanceof Map) { + ((Map) container).clear(); + return; + } + if (container instanceof Table) { + ((Table) container).clear(); + return; + } + Method clearMethod = container.getClass().getDeclaredMethod("clear"); + clearMethod.setAccessible(true); + clearMethod.invoke(container); + } + + private Object newDefaultBootstrapContext() throws Exception { + String className = "org.springframework.boot.DefaultBootstrapContext"; + if (isClassPresent("org.springframework.boot.bootstrap.DefaultBootstrapContext")) { + className = "org.springframework.boot.bootstrap.DefaultBootstrapContext"; + } + Class bootstrapContextClass = Class.forName(className); + return bootstrapContextClass.getConstructor().newInstance(); + } + + private boolean isClassPresent(String className) { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException ex) { + return false; + } + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLocationResolverTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLocationResolverTest.java new file mode 100644 index 00000000..67e53124 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/importer/ApolloConfigDataLocationResolverTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.importer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import org.junit.Test; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.Profiles; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.Ordered; + +/** + * @author vdisk + */ +public class ApolloConfigDataLocationResolverTest { + + private final DeferredLogFactory logFactory = destination -> destination.get(); + + @Test + public void testResolveDefaultNamespaceWhenLocationWithoutNamespace() { + ApolloConfigDataLocationResolver resolver = new ApolloConfigDataLocationResolver(logFactory); + ConfigDataLocation location = ConfigDataLocation.of("apollo://"); + ConfigDataLocationResolverContext context = null; + Profiles profiles = null; + + List resources = + resolver.resolveProfileSpecific(context, location, profiles); + + assertEquals(1, resources.size()); + assertEquals("application", resources.get(0).getNamespace()); + } + + @Test + public void testResolveExplicitNamespace() { + ApolloConfigDataLocationResolver resolver = new ApolloConfigDataLocationResolver(logFactory); + ConfigDataLocation location = ConfigDataLocation.of("apollo://TEST1.apollo"); + ConfigDataLocationResolverContext context = null; + Profiles profiles = null; + + List resources = + resolver.resolveProfileSpecific(context, location, profiles); + + assertEquals(1, resources.size()); + assertEquals("TEST1.apollo", resources.get(0).getNamespace()); + } + + @Test + public void testOrderAndResolvable() { + ApolloConfigDataLocationResolver resolver = new ApolloConfigDataLocationResolver(logFactory); + + assertEquals(Ordered.HIGHEST_PRECEDENCE + 100, resolver.getOrder()); + assertTrue(resolver.isResolvable(null, ConfigDataLocation.of("apollo://application"))); + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java new file mode 100644 index 00000000..69bbc8cb --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java @@ -0,0 +1,292 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.core.ConfigConsts; +import com.ctrip.framework.apollo.config.data.injector.ApolloConfigDataInjectorCustomizer; +import com.ctrip.framework.apollo.config.data.injector.ApolloMockInjectorCustomizer; +import com.ctrip.framework.apollo.internals.ConfigManager; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener; +import com.google.common.collect.Table; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.rules.ExternalResource; +import org.junit.rules.RuleChain; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author vdisk + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = ConfigDataIntegrationTest.TestConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "app.id=someAppId", + "env=local", + "spring.config.import=apollo://application,apollo://TEST1.apollo,apollo://application.yaml", + "listeners=application,TEST1.apollo,application.yaml" + }) +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +public class ConfigDataIntegrationTest { + + private static final String TEST_APP_ID = "someAppId"; + private static final String TEST_ENV = "local"; + + private static final EmbeddedApollo embeddedApollo = new EmbeddedApollo(); + + private static final ExternalResource apolloStateResource = new ExternalResource() { + private String originalAppId; + private String originalEnv; + + @Override + protected void before() throws Throwable { + originalAppId = System.getProperty("app.id"); + originalEnv = System.getProperty("env"); + System.setProperty("app.id", TEST_APP_ID); + System.setProperty("env", TEST_ENV); + resetApolloStaticState(); + } + + @Override + protected void after() { + try { + resetApolloStaticState(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } finally { + restoreOrClear("app.id", originalAppId); + restoreOrClear("env", originalEnv); + } + } + }; + + @ClassRule + public static final RuleChain apolloRuleChain = RuleChain + .outerRule(apolloStateResource) + .around(embeddedApollo); + + @Before + public void beforeEach() { + embeddedApollo.resetOverriddenProperties(); + } + + @After + public void afterEach() throws Exception { + resetApolloStaticState(); + } + + @Autowired + private Environment environment; + + @Autowired(required = false) + private FeatureEnabledBean featureEnabledBean; + + @Autowired + private ListenerProbe listenerProbe; + + @Autowired + private RedisCacheProperties redisCacheProperties; + + @Test + public void testImportMultipleNamespacesAndConditionalOnProperty() { + assertEquals("ok", environment.getProperty("application.only")); + assertEquals("ok", environment.getProperty("test1.only")); + assertEquals("ok", environment.getProperty("yaml.only")); + assertEquals("from-yaml", environment.getProperty("priority.value")); + assertNotNull(featureEnabledBean); + assertTrue(redisCacheProperties.isEnabled()); + assertEquals(35, redisCacheProperties.getCommandTimeout()); + } + + @Test + public void testApolloConfigChangeListenerWithInterestedKeyPrefixes() throws Exception { + assertEquals("35", environment.getProperty("redis.cache.commandTimeout")); + + addOrModifyForAllAppIds("application", "redis.cache.commandTimeout", "45"); + ConfigChangeEvent interestedEvent = listenerProbe.pollEvent(10, TimeUnit.SECONDS); + assertNotNull(interestedEvent); + assertTrue(interestedEvent.changedKeys().contains("redis.cache.commandTimeout")); + assertEquals(45, ConfigService.getConfig("application") + .getIntProperty("redis.cache.commandTimeout", -1).intValue()); + + addOrModifyForAllAppIds("application.yaml", ConfigConsts.CONFIG_FILE_CONTENT_KEY, + "priority:\n value: from-yaml\nyaml:\n only: ok\nredis:\n cache:\n commandTimeout: 55\n"); + ConfigChangeEvent yamlInterestedEvent = listenerProbe.pollEvent(10, TimeUnit.SECONDS); + assertNotNull(yamlInterestedEvent); + assertTrue(yamlInterestedEvent.changedKeys().contains("redis.cache.commandTimeout")); + assertEquals("55", environment.getProperty("redis.cache.commandTimeout")); + + addOrModifyForAllAppIds("application", "apollo.unrelated.key", "value"); + ConfigChangeEvent unrelatedEvent = listenerProbe.pollEvent(3000, TimeUnit.MILLISECONDS); + assertNull(unrelatedEvent); + } + + @EnableAutoConfiguration + @EnableConfigurationProperties(RedisCacheProperties.class) + @Configuration + static class TestConfiguration { + + @Bean + @ConditionalOnProperty(value = "feature.enabled", havingValue = "true") + public FeatureEnabledBean featureEnabledBean() { + return new FeatureEnabledBean(); + } + + @Bean + public ListenerProbe listenerProbe() { + return new ListenerProbe(); + } + } + + static class FeatureEnabledBean { + } + + @ConfigurationProperties(prefix = "redis.cache") + static class RedisCacheProperties { + + private boolean enabled; + private int commandTimeout; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getCommandTimeout() { + return commandTimeout; + } + + public void setCommandTimeout(int commandTimeout) { + this.commandTimeout = commandTimeout; + } + } + + static class ListenerProbe { + + private final BlockingQueue queue = new ArrayBlockingQueue<>(10); + + @ApolloConfigChangeListener(value = "${listeners}", + interestedKeyPrefixes = {"redis.cache."}) + private void onChange(ConfigChangeEvent changeEvent) { + queue.offer(changeEvent); + } + + ConfigChangeEvent pollEvent(long timeout, TimeUnit unit) throws InterruptedException { + return queue.poll(timeout, unit); + } + } + + private static void resetApolloStaticState() throws Exception { + ApolloMockInjectorCustomizer.clear(); + + Field instanceSuppliers = + ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCE_SUPPLIERS"); + instanceSuppliers.setAccessible(true); + ((Map) instanceSuppliers.get(null)).clear(); + + Field instances = ApolloConfigDataInjectorCustomizer.class.getDeclaredField("INSTANCES"); + instances.setAccessible(true); + ((Map) instances.get(null)).clear(); + + Class initializerClass = Class.forName( + "com.ctrip.framework.apollo.config.data.importer.ApolloConfigDataLoaderInitializer"); + Field initialized = initializerClass.getDeclaredField("INITIALIZED"); + initialized.setAccessible(true); + initialized.setBoolean(null, false); + + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + clearApolloClientCaches(); + } + + private static void clearApolloClientCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + @SuppressWarnings("unchecked") + private static void clearField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object container = field.get(target); + if (container instanceof Map) { + ((Map) container).clear(); + return; + } + if (container instanceof Table) { + ((Table) container).clear(); + return; + } + Method clearMethod = container.getClass().getDeclaredMethod("clear"); + clearMethod.setAccessible(true); + clearMethod.invoke(container); + } + + private static void restoreOrClear(String key, String originalValue) { + if (originalValue == null) { + System.clearProperty(key); + return; + } + System.setProperty(key, originalValue); + } + + private static void addOrModifyForAllAppIds(String namespace, String key, String value) { + embeddedApollo.addOrModifyProperty(TEST_APP_ID, namespace, key, value); + embeddedApollo.addOrModifyProperty( + ConfigConsts.NO_APPID_PLACEHOLDER, namespace, key, value); + } + +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloDeferredLoggerApplicationListenerTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloDeferredLoggerApplicationListenerTest.java new file mode 100644 index 00000000..60aadece --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloDeferredLoggerApplicationListenerTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.listener; + +import static org.mockito.Mockito.mock; + +import com.ctrip.framework.apollo.core.utils.DeferredLogger; +import org.junit.After; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationContextInitializedEvent; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * @author vdisk + */ +public class ApolloDeferredLoggerApplicationListenerTest { + + @After + public void tearDown() { + DeferredLogger.disable(); + } + + @Test + public void testReplayDeferredLogsOnApplicationContextInitialized() { + DeferredLogger.enable(); + ApolloDeferredLoggerApplicationListener listener = + new ApolloDeferredLoggerApplicationListener(); + ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class); + ApplicationContextInitializedEvent event = + new ApplicationContextInitializedEvent(new SpringApplication(Object.class), new String[0], + context); + + listener.onApplicationEvent(event); + } + + @Test + public void testReplayDeferredLogsOnApplicationFailed() { + DeferredLogger.enable(); + ApolloDeferredLoggerApplicationListener listener = + new ApolloDeferredLoggerApplicationListener(); + ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class); + ApplicationFailedEvent event = + new ApplicationFailedEvent(new SpringApplication(Object.class), new String[0], context, + new IllegalStateException("test")); + + listener.onApplicationEvent(event); + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListenerTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListenerTest.java new file mode 100644 index 00000000..9823be54 --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/listener/ApolloSpringApplicationRegisterListenerTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.listener; + +import static org.junit.Assert.assertSame; + +import com.ctrip.framework.apollo.config.data.util.BootstrapRegistryHelper; +import java.lang.reflect.Constructor; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationStartingEvent; +import org.springframework.util.ClassUtils; + +/** + * @author vdisk + */ +public class ApolloSpringApplicationRegisterListenerTest { + + @Test + public void testRegisterSpringApplicationToBootstrapContext() throws Exception { + Object bootstrapContext = newDefaultBootstrapContext(); + SpringApplication springApplication = new SpringApplication(Object.class); + + ApplicationStartingEvent event = newApplicationStartingEvent(bootstrapContext, springApplication); + + ApolloSpringApplicationRegisterListener listener = new ApolloSpringApplicationRegisterListener(); + listener.onApplicationEvent(event); + + SpringApplication registered = BootstrapRegistryHelper.get(bootstrapContext, SpringApplication.class); + assertSame(springApplication, registered); + } + + private ApplicationStartingEvent newApplicationStartingEvent( + Object bootstrapContext, SpringApplication springApplication) throws Exception { + for (Constructor constructor : ApplicationStartingEvent.class.getConstructors()) { + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 3 && SpringApplication.class.isAssignableFrom(parameterTypes[1])) { + return (ApplicationStartingEvent) constructor + .newInstance(bootstrapContext, springApplication, new String[0]); + } + } + throw new IllegalStateException("Unsupported ApplicationStartingEvent constructor signature"); + } + + private Object newDefaultBootstrapContext() throws Exception { + String className = "org.springframework.boot.DefaultBootstrapContext"; + if (ClassUtils.isPresent("org.springframework.boot.bootstrap.DefaultBootstrapContext", + getClass().getClassLoader())) { + className = "org.springframework.boot.bootstrap.DefaultBootstrapContext"; + } + return Class.forName(className).getConstructor().newInstance(); + } +} diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelperCompatibilityTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelperCompatibilityTest.java new file mode 100644 index 00000000..1c9c68ab --- /dev/null +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/util/BootstrapRegistryHelperCompatibilityTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.config.data.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.event.ApplicationStartingEvent; +import org.springframework.util.ClassUtils; + +/** + * @author vdisk + */ +public class BootstrapRegistryHelperCompatibilityTest { + + @Test + public void testRegisterAndGetFromBootstrapContext() throws Exception { + Object bootstrapContext = newDefaultBootstrapContext(); + + BootstrapRegistryHelper.registerIfAbsent(bootstrapContext, String.class, "apollo"); + BootstrapRegistryHelper.registerIfAbsentFromSupplier(bootstrapContext, Integer.class, () -> 100); + + assertEquals("apollo", BootstrapRegistryHelper.get(bootstrapContext, String.class)); + assertEquals(Integer.valueOf(100), BootstrapRegistryHelper.get(bootstrapContext, Integer.class)); + assertEquals(Boolean.TRUE, BootstrapRegistryHelper.getOrElse(bootstrapContext, Boolean.class, + Boolean.TRUE)); + } + + @Test + public void testGetBootstrapContextFromEventAndLoaderContext() throws Exception { + Object bootstrapContext = newDefaultBootstrapContext(); + ApplicationStartingEvent event = + newApplicationStartingEvent(bootstrapContext, new SpringApplication(Object.class)); + + Object eventBootstrapContext = BootstrapRegistryHelper.getBootstrapContext(event); + assertSame(bootstrapContext, eventBootstrapContext); + + ConfigDataLoaderContext loaderContext = (ConfigDataLoaderContext) Proxy.newProxyInstance( + ConfigDataLoaderContext.class.getClassLoader(), + new Class[]{ConfigDataLoaderContext.class}, + (proxy, method, args) -> { + if ("getBootstrapContext".equals(method.getName())) { + return bootstrapContext; + } + throw new UnsupportedOperationException("Unexpected method: " + method.getName()); + }); + Object loaderBootstrapContext = BootstrapRegistryHelper.getBootstrapContext(loaderContext); + assertSame(bootstrapContext, loaderBootstrapContext); + } + + @Test + public void testSpringBoot4PresenceDetection() { + boolean expected = ClassUtils + .isPresent("org.springframework.boot.bootstrap.ConfigurableBootstrapContext", + BootstrapRegistryHelperCompatibilityTest.class.getClassLoader()); + assertEquals(expected, BootstrapRegistryHelper.isSpringBoot4Present()); + } + + private ApplicationStartingEvent newApplicationStartingEvent( + Object bootstrapContext, SpringApplication springApplication) throws Exception { + for (Constructor constructor : ApplicationStartingEvent.class.getConstructors()) { + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 3 && SpringApplication.class.isAssignableFrom(parameterTypes[1])) { + return (ApplicationStartingEvent) constructor + .newInstance(bootstrapContext, springApplication, new String[0]); + } + } + throw new IllegalStateException("Unsupported ApplicationStartingEvent constructor signature"); + } + + private Object newDefaultBootstrapContext() throws Exception { + String className = "org.springframework.boot.DefaultBootstrapContext"; + if (ClassUtils.isPresent("org.springframework.boot.bootstrap.DefaultBootstrapContext", + getClass().getClassLoader())) { + className = "org.springframework.boot.bootstrap.DefaultBootstrapContext"; + } + return Class.forName(className).getConstructor().newInstance(); + } +} diff --git a/apollo-client-config-data/src/test/resources/mockdata-TEST1.apollo.properties b/apollo-client-config-data/src/test/resources/mockdata-TEST1.apollo.properties new file mode 100644 index 00000000..5a235fc1 --- /dev/null +++ b/apollo-client-config-data/src/test/resources/mockdata-TEST1.apollo.properties @@ -0,0 +1,17 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +priority.value=from-test1 +test1.only=ok diff --git a/apollo-client-config-data/src/test/resources/mockdata-application.properties b/apollo-client-config-data/src/test/resources/mockdata-application.properties new file mode 100644 index 00000000..8f3a4cca --- /dev/null +++ b/apollo-client-config-data/src/test/resources/mockdata-application.properties @@ -0,0 +1,21 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +application.only=ok +feature.enabled=true +listeners=application,TEST1.apollo,application.yaml +priority.value=from-application +redis.cache.enabled=true +redis.cache.commandTimeout=30 diff --git a/apollo-client-config-data/src/test/resources/mockdata-application.yaml.properties b/apollo-client-config-data/src/test/resources/mockdata-application.yaml.properties new file mode 100644 index 00000000..f1073f69 --- /dev/null +++ b/apollo-client-config-data/src/test/resources/mockdata-application.yaml.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +content=priority:\n value: from-yaml\nyaml:\n only: ok\nredis:\n cache:\n commandTimeout: 35\n diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/annotation/ApolloAnnotationProcessor.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/annotation/ApolloAnnotationProcessor.java index e5d8685b..1247fd26 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/annotation/ApolloAnnotationProcessor.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/spring/annotation/ApolloAnnotationProcessor.java @@ -108,7 +108,7 @@ private void processApolloConfig(Object bean, Field field) { final String appId = StringUtils.defaultIfBlank(annotation.appId(), configUtil.getAppId()); final String namespace = annotation.value(); - final String resolvedAppId = this.environment.resolveRequiredPlaceholders(appId); + final String resolvedAppId = resolveAppId(appId); final String resolvedNamespace = this.environment.resolveRequiredPlaceholders(namespace); Config config = ConfigService.getConfig(resolvedAppId, resolvedNamespace); @@ -132,6 +132,7 @@ private void processApolloConfigChangeListener(final Object bean, final Method m ReflectionUtils.makeAccessible(method); String appId = StringUtils.defaultIfBlank(annotation.appId(), configUtil.getAppId()); + String resolvedAppId = resolveAppId(appId); String[] namespaces = annotation.value(); String[] annotatedInterestedKeys = annotation.interestedKeys(); String[] annotatedInterestedKeyPrefixes = annotation.interestedKeyPrefixes(); @@ -146,7 +147,7 @@ private void processApolloConfigChangeListener(final Object bean, final Method m Set resolvedNamespaces = processResolveNamespaceValue(namespaces); for (String namespace : resolvedNamespaces) { - Config config = ConfigService.getConfig(appId, namespace); + Config config = ConfigService.getConfig(resolvedAppId, namespace); if (interestedKeys == null && interestedKeyPrefixes == null) { config.addChangeListener(configChangeListener); @@ -270,6 +271,13 @@ private Gson buildGson(String datePattern) { return new GsonBuilder().setDateFormat(datePattern).create(); } + private String resolveAppId(String appId) { + if (appId == null) { + return null; + } + return this.environment.resolveRequiredPlaceholders(appId); + } + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.configurableBeanFactory = (ConfigurableBeanFactory) beanFactory; diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/BaseIntegrationTest.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/BaseIntegrationTest.java index 04fa4d31..98364f7f 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/BaseIntegrationTest.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/BaseIntegrationTest.java @@ -103,6 +103,16 @@ public void mockConfigs( ); } + public void mockConfigs( + String appId, + String cluster, + String namespace, + int mockedStatusCode, + ApolloConfig apolloConfig + ) { + this.mockedConfigService.mockConfigs(appId, cluster, namespace, mockedStatusCode, apolloConfig); + } + @BeforeEach public void setUp() throws Exception { someAppId = "1003171"; diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/MockedConfigService.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/MockedConfigService.java index 5ae9b33f..3b196095 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/MockedConfigService.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/MockedConfigService.java @@ -22,6 +22,7 @@ import com.google.common.collect.Lists; import com.google.gson.Gson; import java.util.List; +import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; import javax.servlet.http.HttpServletResponse; import org.mockserver.integration.ClientAndServer; @@ -156,9 +157,42 @@ public void mockConfigs( boolean failedAtFirstTime, int mockedStatusCode, ApolloConfig apolloConfig + ) { + mockConfigs(failedAtFirstTime, mockedStatusCode, apolloConfig, "/configs/.*"); + } + + public void mockConfigs( + String appId, + String cluster, + String namespace, + int mockedStatusCode, + ApolloConfig apolloConfig + ) { + mockConfigs(false, appId, cluster, namespace, mockedStatusCode, apolloConfig); + } + + public void mockConfigs( + boolean failedAtFirstTime, + String appId, + String cluster, + String namespace, + int mockedStatusCode, + ApolloConfig apolloConfig + ) { + String path = String.format("/configs/%s/%s/%s.*", + Pattern.quote(appId), + Pattern.quote(cluster), + Pattern.quote(namespace)); + mockConfigs(failedAtFirstTime, mockedStatusCode, apolloConfig, path); + } + + private void mockConfigs( + boolean failedAtFirstTime, + int mockedStatusCode, + ApolloConfig apolloConfig, + String path ) { // cannot use /configs/* as the path, because mock server will treat * as a wildcard - final String path = "/configs/.*"; RequestDefinition requestDefinition = HttpRequest.request("GET").withPath(path); // need clear @@ -222,7 +256,7 @@ public void mockLongPollNotifications( } @Override - public void close() throws Exception { + public void close() { if (this.server.isRunning()) { this.server.stop(); } diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/integration/ConfigIntegrationTest.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/integration/ConfigIntegrationTest.java index cc6aeced..5c6a5bdd 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/integration/ConfigIntegrationTest.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/integration/ConfigIntegrationTest.java @@ -17,29 +17,35 @@ package com.ctrip.framework.apollo.integration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.ctrip.framework.apollo.MockedConfigService; -import com.ctrip.framework.apollo.util.OrderedProperties; -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import javax.servlet.http.HttpServletResponse; - -import org.junit.jupiter.api.Test; - import com.ctrip.framework.apollo.BaseIntegrationTest; import com.ctrip.framework.apollo.Config; import com.ctrip.framework.apollo.ConfigChangeListener; +import com.ctrip.framework.apollo.ConfigFile; import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.MockedConfigService; +import com.ctrip.framework.apollo.PropertiesCompatibleConfigFile; +import com.ctrip.framework.apollo.core.ConfigConsts; import com.ctrip.framework.apollo.core.dto.ApolloConfig; import com.ctrip.framework.apollo.core.dto.ApolloConfigNotification; +import com.ctrip.framework.apollo.core.enums.ConfigFileFormat; +import com.ctrip.framework.apollo.enums.PropertyChangeType; import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.model.ConfigFileChangeEvent; +import com.ctrip.framework.apollo.util.OrderedProperties; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.util.concurrent.SettableFuture; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; /** * @author Jason Song(song_s@ctrip.com) @@ -49,6 +55,11 @@ public class ConfigIntegrationTest extends BaseIntegrationTest { private final String someReleaseKey = "1"; private final String someOtherNamespace = "someOtherNamespace"; + private static final String FALLBACK_ANOTHER_APP_ID = "100004459"; + private static final String MULTI_APP_ANOTHER_APP_ID = "200000001"; + private static final String PUBLIC_NAMESPACE = "TEST1.apollo"; + private static final String DEFAULT_VALUE = "undefined"; + private static final String MULTI_APP_KEY = "someKey"; @Test public void testGetConfigWithNoLocalFileButWithRemoteConfig() throws Exception { @@ -66,6 +77,181 @@ public void testGetConfigWithNoLocalFileButWithRemoteConfig() throws Exception { assertEquals(someDefaultValue, config.getProperty(someNonExistedKey, someDefaultValue)); } + @Test + public void testFallbackOrderAcrossNamespaces() { + newMockedConfigService(); + + mockConfigs(someAppId, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, defaultNamespace, + ImmutableMap.of("key.from.default", "value-default"))); + mockConfigs(FALLBACK_ANOTHER_APP_ID, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(FALLBACK_ANOTHER_APP_ID, defaultNamespace, + ImmutableMap.of("key.from.another", "value-another"))); + mockConfigs(someAppId, someClusterName, PUBLIC_NAMESPACE, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, PUBLIC_NAMESPACE, + ImmutableMap.of("key.from.public", "value-public"))); + + Config appConfig = ConfigService.getAppConfig(); + Config anotherAppConfig = ConfigService.getConfig(FALLBACK_ANOTHER_APP_ID, defaultNamespace); + Config publicConfig = ConfigService.getConfig(PUBLIC_NAMESPACE); + + assertEquals("value-default", + resolveValueByFallbackOrder("key.from.default", appConfig, anotherAppConfig, publicConfig)); + assertEquals("value-another", + resolveValueByFallbackOrder("key.from.another", appConfig, anotherAppConfig, publicConfig)); + assertEquals("value-public", + resolveValueByFallbackOrder("key.from.public", appConfig, anotherAppConfig, publicConfig)); + assertEquals(DEFAULT_VALUE, + resolveValueByFallbackOrder("key.unknown", appConfig, anotherAppConfig, publicConfig)); + } + + @Test + public void testGetConfigWithSameNamespaceButDifferentAppIds() { + newMockedConfigService(); + + mockConfigs(someAppId, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, defaultNamespace, + ImmutableMap.of(MULTI_APP_KEY, "value-from-default-app"))); + mockConfigs(MULTI_APP_ANOTHER_APP_ID, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(MULTI_APP_ANOTHER_APP_ID, defaultNamespace, + ImmutableMap.of(MULTI_APP_KEY, "value-from-another-app"))); + + Config defaultAppConfig = ConfigService.getConfig(someAppId, defaultNamespace); + Config anotherAppConfig = ConfigService.getConfig(MULTI_APP_ANOTHER_APP_ID, defaultNamespace); + + assertEquals("value-from-default-app", defaultAppConfig.getProperty(MULTI_APP_KEY, null)); + assertEquals("value-from-another-app", anotherAppConfig.getProperty(MULTI_APP_KEY, null)); + } + + @Test + public void testConfigChangeShouldOnlyAffectSpecifiedAppId() throws Exception { + MockedConfigService mockedConfigService = newMockedConfigService(); + + mockConfigs(someAppId, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, defaultNamespace, + ImmutableMap.of(MULTI_APP_KEY, "default-v1"))); + mockConfigs(MULTI_APP_ANOTHER_APP_ID, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(MULTI_APP_ANOTHER_APP_ID, defaultNamespace, + ImmutableMap.of(MULTI_APP_KEY, "another-v1"))); + mockedConfigService.mockLongPollNotifications(50, HttpServletResponse.SC_OK, + Lists.newArrayList( + new ApolloConfigNotification(defaultNamespace, 1L))); + + Config defaultAppConfig = ConfigService.getConfig(someAppId, defaultNamespace); + Config anotherAppConfig = ConfigService.getConfig(MULTI_APP_ANOTHER_APP_ID, defaultNamespace); + + assertEquals("default-v1", defaultAppConfig.getProperty(MULTI_APP_KEY, null)); + assertEquals("another-v1", anotherAppConfig.getProperty(MULTI_APP_KEY, null)); + + SettableFuture defaultAppFuture = SettableFuture.create(); + SettableFuture anotherAppFuture = SettableFuture.create(); + + defaultAppConfig.addChangeListener(futureListener(defaultAppFuture)); + anotherAppConfig.addChangeListener(futureListener(anotherAppFuture)); + + mockConfigs(MULTI_APP_ANOTHER_APP_ID, someClusterName, defaultNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(MULTI_APP_ANOTHER_APP_ID, defaultNamespace, + ImmutableMap.of(MULTI_APP_KEY, "another-v2"))); + + mockedConfigService.mockLongPollNotifications(50, HttpServletResponse.SC_OK, + Lists.newArrayList( + new ApolloConfigNotification(defaultNamespace, 2L))); + + ConfigChangeEvent anotherAppChangeEvent = anotherAppFuture.get(5, TimeUnit.SECONDS); + assertNotNull(anotherAppChangeEvent); + assertEquals("another-v1", anotherAppChangeEvent.getChange(MULTI_APP_KEY).getOldValue()); + assertEquals("another-v2", anotherAppChangeEvent.getChange(MULTI_APP_KEY).getNewValue()); + + assertEquals("default-v1", defaultAppConfig.getProperty(MULTI_APP_KEY, null)); + assertEquals("another-v2", anotherAppConfig.getProperty(MULTI_APP_KEY, null)); + assertNull(pollFuture(defaultAppFuture, 1000)); + } + + @Test + public void testConfigFileWithPropertiesXmlAndYamlFormats() throws Exception { + MockedConfigService mockedConfigService = newMockedConfigService(); + + String propertiesNamespace = "application.properties"; + String xmlNamespace = "datasources.xml"; + String yamlNamespace = "application.yaml"; + + mockConfigs(someAppId, someClusterName, propertiesNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, propertiesNamespace, + ImmutableMap.of("timeout", "200", "batch", "100"))); + mockConfigs(someAppId, someClusterName, xmlNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, xmlNamespace, + ImmutableMap.of(ConfigConsts.CONFIG_FILE_CONTENT_KEY, + "db-v1"))); + mockConfigs(someAppId, someClusterName, yamlNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, yamlNamespace, + ImmutableMap.of(ConfigConsts.CONFIG_FILE_CONTENT_KEY, + "redis:\n cache:\n enabled: true\n commandTimeout: 30\n"))); + mockedConfigService.mockLongPollNotifications(50, HttpServletResponse.SC_OK, + Lists.newArrayList( + new ApolloConfigNotification(propertiesNamespace, 1L), + new ApolloConfigNotification(xmlNamespace, 1L), + new ApolloConfigNotification(yamlNamespace, 1L))); + + ConfigFile propertiesFile = ConfigService.getConfigFile("application", ConfigFileFormat.Properties); + ConfigFile xmlFile = ConfigService.getConfigFile("datasources", ConfigFileFormat.XML); + ConfigFile yamlFile = ConfigService.getConfigFile("application", ConfigFileFormat.YAML); + + assertTrue(propertiesFile instanceof PropertiesCompatibleConfigFile); + Properties properties = ((PropertiesCompatibleConfigFile) propertiesFile).asProperties(); + assertEquals("200", properties.getProperty("timeout")); + assertEquals("100", properties.getProperty("batch")); + + assertTrue(xmlFile.hasContent()); + assertEquals("db-v1", xmlFile.getContent()); + + assertTrue(yamlFile instanceof PropertiesCompatibleConfigFile); + Properties yamlProperties = ((PropertiesCompatibleConfigFile) yamlFile).asProperties(); + assertEquals("true", yamlProperties.getProperty("redis.cache.enabled")); + assertEquals("30", yamlProperties.getProperty("redis.cache.commandTimeout")); + + SettableFuture xmlChangeFuture = SettableFuture.create(); + SettableFuture yamlChangeFuture = SettableFuture.create(); + + xmlFile.addChangeListener(changeEvent -> { + if (!xmlChangeFuture.isDone()) { + xmlChangeFuture.set(changeEvent); + } + }); + yamlFile.addChangeListener(changeEvent -> { + if (!yamlChangeFuture.isDone()) { + yamlChangeFuture.set(changeEvent); + } + }); + + mockConfigs(someAppId, someClusterName, xmlNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, xmlNamespace, + ImmutableMap.of(ConfigConsts.CONFIG_FILE_CONTENT_KEY, + "db-v2"))); + mockConfigs(someAppId, someClusterName, yamlNamespace, HttpServletResponse.SC_OK, + assembleApolloConfigForApp(someAppId, yamlNamespace, + ImmutableMap.of(ConfigConsts.CONFIG_FILE_CONTENT_KEY, + "redis:\n cache:\n enabled: false\n commandTimeout: 45\n"))); + + mockedConfigService.mockLongPollNotifications(50, HttpServletResponse.SC_OK, + Lists.newArrayList( + new ApolloConfigNotification(xmlNamespace, 2L), + new ApolloConfigNotification(yamlNamespace, 2L))); + + ConfigFileChangeEvent xmlChange = xmlChangeFuture.get(5, TimeUnit.SECONDS); + ConfigFileChangeEvent yamlChange = yamlChangeFuture.get(5, TimeUnit.SECONDS); + + assertEquals("datasources.xml", xmlChange.getNamespace()); + assertEquals(PropertyChangeType.MODIFIED, xmlChange.getChangeType()); + assertEquals("db-v2", xmlFile.getContent()); + + assertEquals("application.yaml", yamlChange.getNamespace()); + assertEquals(PropertyChangeType.MODIFIED, yamlChange.getChangeType()); + Properties yamlPropertiesAfterRefresh = ((PropertiesCompatibleConfigFile) yamlFile) + .asProperties(); + assertEquals("false", yamlPropertiesAfterRefresh.getProperty("redis.cache.enabled")); + assertEquals("45", yamlPropertiesAfterRefresh.getProperty("redis.cache.commandTimeout")); + } + @Test public void testOrderGetConfigWithNoLocalFileButWithRemoteConfig() throws Exception { setPropertiesOrderEnabled(true); @@ -437,12 +623,47 @@ public void onChange(ConfigChangeEvent changeEvent) { } private ApolloConfig assembleApolloConfig(Map configurations) { - ApolloConfig apolloConfig = - new ApolloConfig(someAppId, someClusterName, defaultNamespace, someReleaseKey); + return assembleApolloConfigForApp(someAppId, defaultNamespace, configurations); + } + private ApolloConfig assembleApolloConfigForApp( + String appId, String namespace, Map configurations) { + ApolloConfig apolloConfig = + new ApolloConfig(appId, someClusterName, namespace, someReleaseKey); apolloConfig.setConfigurations(configurations); - return apolloConfig; } + private String resolveValueByFallbackOrder( + String key, + Config appConfig, + Config anotherAppConfig, + Config publicConfig) { + String value = appConfig.getProperty(key, DEFAULT_VALUE); + if (DEFAULT_VALUE.equals(value)) { + value = anotherAppConfig.getProperty(key, DEFAULT_VALUE); + } + if (DEFAULT_VALUE.equals(value)) { + value = publicConfig.getProperty(key, DEFAULT_VALUE); + } + return value; + } + + private ConfigChangeListener futureListener(SettableFuture future) { + return changeEvent -> { + if (!future.isDone()) { + future.set(changeEvent); + } + }; + } + + private ConfigChangeEvent pollFuture(SettableFuture future, long timeoutInMs) + throws Exception { + try { + return future.get(timeoutInMs, TimeUnit.MILLISECONDS); + } catch (TimeoutException ignore) { + return null; + } + } + } diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/spring/JavaConfigAnnotationTest.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/spring/JavaConfigAnnotationTest.java index 146a3e98..c70dfbc6 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/spring/JavaConfigAnnotationTest.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/spring/JavaConfigAnnotationTest.java @@ -58,6 +58,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; @@ -77,6 +79,7 @@ public class JavaConfigAnnotationTest extends AbstractSpringIntegrationTest { private static final String FX_APOLLO_NAMESPACE = "FX.apollo"; private static final String APPLICATION_YAML_NAMESPACE = "application.yaml"; + private static final String ANOTHER_APP_ID = "someAppId2"; private static T getBean(Class beanClass, Class... annotatedClasses) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(annotatedClasses); @@ -98,6 +101,8 @@ public void tearDown() throws Exception { System.clearProperty(SystemPropertyKeyConstants.FROM_NAMESPACE_APPLICATION_KEY); System.clearProperty(SystemPropertyKeyConstants.FROM_NAMESPACE_APPLICATION_KEY_YAML); System.clearProperty(SystemPropertyKeyConstants.DELIMITED_NAMESPACES); + System.clearProperty(SystemPropertyKeyConstants.LISTENER_APP_ID); + System.clearProperty(SystemPropertyKeyConstants.LISTENER_NAMESPACE); System.clearProperty(ApolloClientSystemConsts.APOLLO_PROPERTY_NAMES_CACHE_ENABLE); super.tearDown(); } @@ -676,6 +681,87 @@ public void testApolloMultipleConfig() throws IOException { } + @Test + public void testApolloConfigChangeListenerWithAppId() { + Config applicationConfig = mock(Config.class); + Config anotherAppConfig = mock(Config.class); + + mockConfig(someAppId, ConfigConsts.NAMESPACE_APPLICATION, applicationConfig); + mockConfig("someAppId2", "namespace2", anotherAppConfig); + + getBean(TestApolloConfigChangeListenerWithAppIdBean.class, AppConfig12.class); + + verify(anotherAppConfig, times(1)).addChangeListener(any(ConfigChangeListener.class)); + } + + @Test + public void testApolloConfigChangeListenerWithInterestedKeyPrefixesAndAppId() { + Config applicationConfig = mock(Config.class); + Config anotherAppConfig = mock(Config.class); + + mockConfig(someAppId, ConfigConsts.NAMESPACE_APPLICATION, applicationConfig); + mockConfig("someAppId2", "namespace2", anotherAppConfig); + + getBean(TestApolloConfigChangeListenerWithInterestedKeyPrefixesAndAppIdBean.class, AppConfig13.class); + + final ArgumentCaptor interestedKeyPrefixesArgumentCaptor = ArgumentCaptor.forClass(Set.class); + + verify(anotherAppConfig, times(1)) + .addChangeListener(any(ConfigChangeListener.class), Mockito.nullable(Set.class), + interestedKeyPrefixesArgumentCaptor.capture()); + + assertEquals(1, interestedKeyPrefixesArgumentCaptor.getAllValues().size()); + assertEquals(Sets.newHashSet("redis.cache.", "logging."), + interestedKeyPrefixesArgumentCaptor.getValue()); + } + + @Test + public void testApolloConfigChangeListenerWithAppIdRuntimeRefresh() throws Exception { + SimpleConfig defaultAppConfig = prepareConfig(someAppId, ConfigConsts.NAMESPACE_APPLICATION, + assembleProperties("runtime.default.timeout", "30")); + SimpleConfig anotherAppConfig = prepareConfig(ANOTHER_APP_ID, ConfigConsts.NAMESPACE_APPLICATION, + assembleProperties("runtime.another.timeout", "66")); + + TestApolloRuntimeListenerRoutingBean bean = getBean( + TestApolloRuntimeListenerRoutingBean.class, TestApolloRuntimeListenerRoutingConfiguration.class); + + assertEquals(30, bean.getTimeout()); + + defaultAppConfig.onRepositoryChange(someAppId, ConfigConsts.NAMESPACE_APPLICATION, + assembleProperties("runtime.default.timeout", "45")); + + ConfigChangeEvent defaultEvent = bean.pollDefaultEvent(5, TimeUnit.SECONDS); + assertNotNull(defaultEvent); + assertEquals("45", defaultEvent.getChange("runtime.default.timeout").getNewValue()); + assertNull(bean.pollAnotherAppEvent(200, TimeUnit.MILLISECONDS)); + TimeUnit.MILLISECONDS.sleep(100); + assertEquals(45, bean.getTimeout()); + + anotherAppConfig.onRepositoryChange(ANOTHER_APP_ID, ConfigConsts.NAMESPACE_APPLICATION, + assembleProperties("runtime.another.timeout", "77")); + + ConfigChangeEvent anotherEvent = bean.pollAnotherAppEvent(5, TimeUnit.SECONDS); + assertNotNull(anotherEvent); + assertEquals("77", anotherEvent.getChange("runtime.another.timeout").getNewValue()); + assertNull(bean.pollDefaultEvent(200, TimeUnit.MILLISECONDS)); + } + + @Test + public void testApolloConfigChangeListenerResolveAppIdFromSystemProperty() { + Config applicationConfig = mock(Config.class); + mockConfig(someAppId, ConfigConsts.NAMESPACE_APPLICATION, applicationConfig); + + System.setProperty(SystemPropertyKeyConstants.LISTENER_APP_ID, "someAppId2"); + System.setProperty(SystemPropertyKeyConstants.LISTENER_NAMESPACE, "namespace2"); + + Config anotherAppConfig = mock(Config.class); + mockConfig("someAppId2", "namespace2", anotherAppConfig); + + getSimpleBean(TestApolloConfigChangeListenerResolveAppIdFromSystemPropertyConfiguration.class); + + verify(anotherAppConfig, times(1)).addChangeListener(any(ConfigChangeListener.class)); + } + private static class SystemPropertyKeyConstants { static final String SIMPLE_NAMESPACE = "simple.namespace"; @@ -685,6 +771,8 @@ private static class SystemPropertyKeyConstants { static final String FROM_NAMESPACE_APPLICATION_KEY = "from.namespace.application.key"; static final String FROM_NAMESPACE_APPLICATION_KEY_YAML = "from.namespace.application.key.yaml"; static final String DELIMITED_NAMESPACES = "delimited.namespaces"; + static final String LISTENER_APP_ID = "listener.appid"; + static final String LISTENER_NAMESPACE = "listener.namespace"; } @EnableApolloConfig @@ -933,6 +1021,24 @@ public TestApolloConfigChangeListenerWithInterestedKeyPrefixesBean1 bean() { } } + @Configuration + @EnableApolloConfig + static class AppConfig12 { + @Bean + public TestApolloConfigChangeListenerWithAppIdBean bean() { + return new TestApolloConfigChangeListenerWithAppIdBean(); + } + } + + @Configuration + @EnableApolloConfig + static class AppConfig13 { + @Bean + public TestApolloConfigChangeListenerWithInterestedKeyPrefixesAndAppIdBean bean() { + return new TestApolloConfigChangeListenerWithInterestedKeyPrefixesAndAppIdBean(); + } + } + static class TestApolloConfigBean1 { @ApolloConfig private Config config; @@ -1093,6 +1199,75 @@ public Config getYamlConfig() { } } + static class TestApolloConfigChangeListenerWithAppIdBean { + + @ApolloConfigChangeListener(appId = "someAppId2", value = "namespace2") + private void onChange(ConfigChangeEvent changeEvent) { + } + } + + static class TestApolloConfigChangeListenerWithInterestedKeyPrefixesAndAppIdBean { + + @ApolloConfigChangeListener(appId = "someAppId2", value = "namespace2", + interestedKeyPrefixes = {"redis.cache.", "logging."}) + private void onChange(ConfigChangeEvent changeEvent) { + } + } + + @Configuration + @EnableApolloConfig(multipleConfigs = { + @MultipleConfig(appId = ANOTHER_APP_ID, namespaces = {ConfigConsts.NAMESPACE_APPLICATION}, order = 9)}) + static class TestApolloRuntimeListenerRoutingConfiguration { + + @Bean + public TestApolloRuntimeListenerRoutingBean bean() { + return new TestApolloRuntimeListenerRoutingBean(); + } + } + + static class TestApolloRuntimeListenerRoutingBean { + + private final BlockingQueue defaultEvents = new ArrayBlockingQueue<>(4); + private final BlockingQueue anotherAppEvents = new ArrayBlockingQueue<>(4); + private volatile int timeout; + + @Value("${runtime.default.timeout:0}") + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + @ApolloConfigChangeListener(interestedKeyPrefixes = {"runtime.default."}) + private void onDefaultAppChange(ConfigChangeEvent event) { + defaultEvents.offer(event); + } + + @ApolloConfigChangeListener(appId = ANOTHER_APP_ID, interestedKeyPrefixes = {"runtime.another."}) + private void onAnotherAppChange(ConfigChangeEvent event) { + anotherAppEvents.offer(event); + } + + int getTimeout() { + return timeout; + } + + ConfigChangeEvent pollDefaultEvent(long timeout, TimeUnit unit) throws InterruptedException { + return defaultEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollAnotherAppEvent(long timeout, TimeUnit unit) throws InterruptedException { + return anotherAppEvents.poll(timeout, unit); + } + } + + @Configuration + @EnableApolloConfig + static class TestApolloConfigChangeListenerResolveAppIdFromSystemPropertyConfiguration { + + @ApolloConfigChangeListener(appId = "${listener.appid}", value = "${listener.namespace}") + private void onChange(ConfigChangeEvent changeEvent) { + } + } + @Configuration @EnableApolloConfig(value = {"FX_APOLLO_NAMESPACE", "APPLICATION_YAML_NAMESPACE"}, multipleConfigs = {@MultipleConfig(appId = "someAppId2", namespaces = {"namespace2"})}) diff --git a/apollo-compat-tests/apollo-api-compat-it/pom.xml b/apollo-compat-tests/apollo-api-compat-it/pom.xml new file mode 100644 index 00000000..6f6fb11f --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/pom.xml @@ -0,0 +1,64 @@ + + + + + com.ctrip.framework.apollo + apollo-compat-tests + ${revision} + ../pom.xml + + 4.0.0 + + apollo-api-compat-it + Apollo API Compatibility IT + + + 1.8 + + + + + com.ctrip.framework.apollo + apollo-client + ${revision} + + + com.ctrip.framework.apollo + apollo-mockserver + ${revision} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.springframework:* + org.springframework.boot:* + + + + + + diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/java/com/ctrip/framework/apollo/compat/api/ApolloApiCompatibilityTest.java b/apollo-compat-tests/apollo-api-compat-it/src/test/java/com/ctrip/framework/apollo/compat/api/ApolloApiCompatibilityTest.java new file mode 100644 index 00000000..fbd87baf --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/java/com/ctrip/framework/apollo/compat/api/ApolloApiCompatibilityTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigFile; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.PropertiesCompatibleConfigFile; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.core.enums.ConfigFileFormat; +import com.ctrip.framework.apollo.internals.ConfigManager; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.google.common.collect.Table; +import com.google.common.util.concurrent.SettableFuture; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +public class ApolloApiCompatibilityTest { + + @ClassRule + public static final EmbeddedApollo EMBEDDED_APOLLO = new EmbeddedApollo(); + + private static final String SOME_APP_ID = "someAppId"; + private static final String ANOTHER_APP_ID = "100004459"; + private static final String DEFAULT_VALUE = "undefined"; + + private static final String ORIGINAL_APP_ID = System.getProperty("app.id"); + private static final String ORIGINAL_ENV = System.getProperty("env"); + + static { + System.setProperty("app.id", SOME_APP_ID); + System.setProperty("env", "local"); + } + + @Before + public void setUp() throws Exception { + EMBEDDED_APOLLO.resetOverriddenProperties(); + resetApolloState(); + } + + @AfterClass + public static void afterClass() throws Exception { + resetApolloState(); + restoreOrClear("app.id", ORIGINAL_APP_ID); + restoreOrClear("env", ORIGINAL_ENV); + } + + @Test + public void shouldLoadConfigsWithFallbackInNoSpringRuntime() { + assertClassNotPresent("org.springframework.context.ApplicationContext"); + + Config appConfig = ConfigService.getAppConfig(); + Config anotherAppConfig = ConfigService.getConfig(ANOTHER_APP_ID, "application"); + Config publicConfig = ConfigService.getConfig("TEST1.apollo"); + Config yamlConfig = ConfigService.getConfig("application.yaml"); + + assertEquals("from-default-app", + resolveValueByFallback("primary.key", appConfig, anotherAppConfig, publicConfig, yamlConfig)); + assertEquals("from-another-app", + resolveValueByFallback("fallback.only", appConfig, anotherAppConfig, publicConfig, yamlConfig)); + assertEquals("from-public-namespace", + resolveValueByFallback("public.only", appConfig, anotherAppConfig, publicConfig, yamlConfig)); + assertEquals("from-yaml-namespace", + resolveValueByFallback("yaml.only", appConfig, anotherAppConfig, publicConfig, yamlConfig)); + assertEquals(DEFAULT_VALUE, + resolveValueByFallback("missing.key", appConfig, anotherAppConfig, publicConfig, yamlConfig)); + } + + @Test + public void shouldIsolateListenersForDifferentAppIdsInNoSpringRuntime() throws Exception { + Config defaultConfig = ConfigService.getConfig(SOME_APP_ID, "application"); + Config anotherAppConfig = ConfigService.getConfig(ANOTHER_APP_ID, "application"); + + SettableFuture defaultFuture = SettableFuture.create(); + SettableFuture anotherFuture = SettableFuture.create(); + + defaultConfig.addChangeListener(changeEvent -> { + if (!defaultFuture.isDone()) { + defaultFuture.set(changeEvent); + } + }); + anotherAppConfig.addChangeListener(changeEvent -> { + if (!anotherFuture.isDone()) { + anotherFuture.set(changeEvent); + } + }); + + EMBEDDED_APOLLO.addOrModifyProperty(ANOTHER_APP_ID, "application", "fallback.only", "another-updated"); + + ConfigChangeEvent anotherChangeEvent = anotherFuture.get(5, TimeUnit.SECONDS); + assertNotNull(anotherChangeEvent.getChange("fallback.only")); + assertEquals("from-another-app", anotherChangeEvent.getChange("fallback.only").getOldValue()); + assertEquals("another-updated", anotherChangeEvent.getChange("fallback.only").getNewValue()); + + assertNull(pollFuture(defaultFuture, 300)); + assertEquals("from-default-app", defaultConfig.getProperty("primary.key", null)); + assertEquals("another-updated", anotherAppConfig.getProperty("fallback.only", null)); + } + + @Test + public void shouldLoadConfigFilesInNoSpringRuntime() { + ConfigFile xmlConfigFile = ConfigService.getConfigFile("datasources", ConfigFileFormat.XML); + ConfigFile yamlConfigFile = ConfigService.getConfigFile("application", ConfigFileFormat.YAML); + + assertEquals("db-v1", xmlConfigFile.getContent()); + + Properties yamlProperties = ((PropertiesCompatibleConfigFile) yamlConfigFile).asProperties(); + assertEquals("from-yaml-namespace", yamlProperties.getProperty("yaml.only")); + assertEquals("35", yamlProperties.getProperty("redis.cache.commandTimeout")); + } + + private static String resolveValueByFallback(String key, Config appConfig, Config anotherAppConfig, + Config publicConfig, Config yamlConfig) { + String value = appConfig.getProperty(key, DEFAULT_VALUE); + if (!DEFAULT_VALUE.equals(value)) { + return value; + } + + value = anotherAppConfig.getProperty(key, DEFAULT_VALUE); + if (!DEFAULT_VALUE.equals(value)) { + return value; + } + + value = publicConfig.getProperty(key, DEFAULT_VALUE); + if (!DEFAULT_VALUE.equals(value)) { + return value; + } + + return yamlConfig.getProperty(key, DEFAULT_VALUE); + } + + private static T pollFuture(SettableFuture future, long timeoutMillis) throws Exception { + try { + return future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + return null; + } + } + + private static void assertClassNotPresent(String className) { + try { + Class.forName(className); + fail("Class should not be present: " + className); + } catch (ClassNotFoundException ignored) { + // ignore + } + } + + private static void resetApolloState() throws Exception { + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + clearApolloClientCaches(); + } + + private static void clearApolloClientCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + private static void clearField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object container = field.get(target); + if (container == null) { + return; + } + if (container instanceof Map) { + ((Map) container).clear(); + return; + } + if (container instanceof Table) { + ((Table) container).clear(); + return; + } + Method clearMethod = container.getClass().getMethod("clear"); + clearMethod.setAccessible(true); + clearMethod.invoke(container); + } + + private static void restoreOrClear(String key, String originalValue) { + if (originalValue == null) { + System.clearProperty(key); + return; + } + System.setProperty(key, originalValue); + } +} diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-100004459-application.properties b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-100004459-application.properties new file mode 100644 index 00000000..60093afa --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-100004459-application.properties @@ -0,0 +1,17 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +fallback.only=from-another-app +shared.order=another-second diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-TEST1.apollo.properties b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-TEST1.apollo.properties new file mode 100644 index 00000000..c1b4e2ea --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-TEST1.apollo.properties @@ -0,0 +1,17 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +public.only=from-public-namespace +shared.order=public-third diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.properties b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.properties new file mode 100644 index 00000000..538be071 --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.properties @@ -0,0 +1,17 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +primary.key=from-default-app +shared.order=app-first diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.yaml.properties b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.yaml.properties new file mode 100644 index 00000000..6d148bfd --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-application.yaml.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +content=yaml:\n only: from-yaml-namespace\nredis:\n cache:\n commandTimeout: 35\n diff --git a/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-datasources.xml.properties b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-datasources.xml.properties new file mode 100644 index 00000000..094903d9 --- /dev/null +++ b/apollo-compat-tests/apollo-api-compat-it/src/test/resources/mockdata-datasources.xml.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +content=db-v1 diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/pom.xml b/apollo-compat-tests/apollo-spring-boot-compat-it/pom.xml new file mode 100644 index 00000000..5a8efa46 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/pom.xml @@ -0,0 +1,100 @@ + + + + + com.ctrip.framework.apollo + apollo-compat-tests + ${revision} + ../pom.xml + + 4.0.0 + + apollo-spring-boot-compat-it + Apollo Spring Boot Compatibility Tests + + + 1.7.21 + 5.7.0 + + + + + + org.slf4j + slf4j-api + ${compat.slf4j.version} + + + + + + + com.ctrip.framework.apollo + apollo-client-config-data + ${revision} + + + org.slf4j + slf4j-api + + + + + com.ctrip.framework.apollo + apollo-mockserver + ${revision} + test + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-logging + + + com.jayway.jsonpath + json-path + + + + + org.junit.vintage + junit-vintage-engine + ${compat.junit.vintage.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/java/com/ctrip/framework/apollo/compat/springboot/ApolloSpringBootCompatibilityTest.java b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/java/com/ctrip/framework/apollo/compat/springboot/ApolloSpringBootCompatibilityTest.java new file mode 100644 index 00000000..8fb77ca0 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/java/com/ctrip/framework/apollo/compat/springboot/ApolloSpringBootCompatibilityTest.java @@ -0,0 +1,480 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.springboot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.internals.ConfigManager; +import com.ctrip.framework.apollo.internals.DefaultConfig; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.ctrip.framework.apollo.spring.annotation.ApolloConfig; +import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener; +import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue; +import com.ctrip.framework.apollo.spring.annotation.EnableApolloConfig; +import com.ctrip.framework.apollo.spring.annotation.MultipleConfig; +import com.ctrip.framework.apollo.spring.events.ApolloConfigChangeEvent; +import com.google.common.collect.Table; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = ApolloSpringBootCompatibilityTest.TestConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "app.id=someAppId", + "env=local", + "spring.config.import=apollo://application,apollo://TEST1.apollo,apollo://application.yaml", + "listeners=application,TEST1.apollo,application.yaml", + "org.springframework.boot.logging.LoggingSystem=none" + }) +@DirtiesContext +public class ApolloSpringBootCompatibilityTest { + + private static final String SOME_APP_ID = "someAppId"; + private static final String ANOTHER_APP_ID = "100004459"; + + @ClassRule + public static final EmbeddedApollo EMBEDDED_APOLLO = new EmbeddedApollo(); + + @Autowired + private Environment environment; + + @Autowired(required = false) + private FeatureBean featureBean; + + @Autowired + private RedisCacheProperties redisCacheProperties; + + @Autowired + private CompatAnnotatedBean compatAnnotatedBean; + + @Autowired + private ApolloApplicationListenerProbe applicationListenerProbe; + + @BeforeClass + public static void beforeClass() throws Exception { + EMBEDDED_APOLLO.resetOverriddenProperties(); + resetApolloState(); + } + + @AfterClass + public static void afterClass() throws Exception { + resetApolloState(); + } + + @Test + public void shouldCoverSpringBootDemoScenarios() throws Exception { + assertEquals("boot-compat", environment.getProperty("yaml.marker")); + assertNotNull(featureBean); + assertTrue(redisCacheProperties.isEnabled()); + assertEquals(40, redisCacheProperties.getCommandTimeout()); + + assertEquals(800, compatAnnotatedBean.getTimeout()); + assertEquals("from-public-boot", compatAnnotatedBean.getPublicOnly()); + assertEquals("from-another-app-boot", + compatAnnotatedBean.getAnotherAppConfig().getProperty("compat.origin", null)); + assertEquals("from-public-boot", + compatAnnotatedBean.getPublicNamespaceConfig().getProperty("public.only", null)); + assertEquals("boot-compat", + compatAnnotatedBean.getYamlNamespaceConfig().getProperty("yaml.marker", null)); + assertEquals("800", + compatAnnotatedBean.getApplicationConfig().getProperty("compat.timeout", null)); + assertEquals(2, compatAnnotatedBean.getJsonBeans().size()); + assertEquals("alpha-boot", compatAnnotatedBean.getJsonBeans().get(0).getSomeString()); + + Config applicationConfig = ConfigService.getConfig("application"); + Properties applicationProperties = copyConfigProperties(applicationConfig); + applicationProperties.setProperty("compat.timeout", "801"); + applicationProperties.setProperty("jsonBeanProperty", + "[{\"someString\":\"gamma-boot\",\"someInt\":303}]"); + applyConfigChange(applicationConfig, SOME_APP_ID, "application", applicationProperties); + + Config publicConfig = ConfigService.getConfig("TEST1.apollo"); + Properties publicProperties = copyConfigProperties(publicConfig); + publicProperties.setProperty("public.only", "from-public-boot-updated"); + applyConfigChange(publicConfig, SOME_APP_ID, "TEST1.apollo", publicProperties); + + Config yamlConfig = ConfigService.getConfig("application.yaml"); + Properties yamlProperties = copyConfigProperties(yamlConfig); + yamlProperties.setProperty("yaml.marker", "boot-compat-updated"); + applyConfigChange(yamlConfig, SOME_APP_ID, "application.yaml", yamlProperties); + + Properties anotherAppProperties = copyConfigProperties(compatAnnotatedBean.getAnotherAppConfig()); + anotherAppProperties.setProperty("compat.origin", "changed-origin-boot"); + applyConfigChange(compatAnnotatedBean.getAnotherAppConfig(), ANOTHER_APP_ID, "application", + anotherAppProperties); + + ConfigChangeEvent defaultChange = compatAnnotatedBean.pollDefaultEvent(5, TimeUnit.SECONDS); + assertNotNull(defaultChange); + assertNotNull(defaultChange.getChange("compat.timeout")); + + ConfigChangeEvent publicChange = compatAnnotatedBean.pollPublicNamespaceEvent(5, TimeUnit.SECONDS); + assertNotNull(publicChange); + assertNotNull(publicChange.getChange("public.only")); + + ConfigChangeEvent yamlChange = compatAnnotatedBean.pollYamlNamespaceEvent(5, TimeUnit.SECONDS); + assertNotNull(yamlChange); + assertNotNull(yamlChange.getChange("yaml.marker")); + + ConfigChangeEvent anotherAppChange = compatAnnotatedBean.pollAnotherAppEvent(5, TimeUnit.SECONDS); + assertNotNull(anotherAppChange); + assertNotNull(anotherAppChange.getChange("compat.origin")); + + waitForCondition("another app config should be updated", + () -> "changed-origin-boot".equals( + compatAnnotatedBean.getAnotherAppConfig().getProperty("compat.origin", null))); + waitForCondition("public namespace config should be updated", + () -> "from-public-boot-updated".equals( + compatAnnotatedBean.getPublicNamespaceConfig().getProperty("public.only", null))); + waitForCondition("yaml namespace config should be updated", + () -> "boot-compat-updated".equals( + compatAnnotatedBean.getYamlNamespaceConfig().getProperty("yaml.marker", null))); + waitForCondition("application namespace config should be updated", + () -> "801".equals( + compatAnnotatedBean.getApplicationConfig().getProperty("compat.timeout", null))); + waitForCondition("json value should be updated", + () -> compatAnnotatedBean.getJsonBeans().size() == 1 + && "gamma-boot".equals(compatAnnotatedBean.getJsonBeans().get(0).getSomeString())); + + waitForCondition("ApplicationListener should receive namespace updates", + () -> applicationListenerProbe.hasNamespace("application") + && applicationListenerProbe.hasNamespace("TEST1.apollo") + && applicationListenerProbe.hasNamespace("application.yaml")); + } + + @EnableAutoConfiguration + @EnableApolloConfig(value = {"application", "TEST1.apollo", "application.yaml"}, + multipleConfigs = { + @MultipleConfig(appId = ANOTHER_APP_ID, namespaces = {"application"}, order = 9) + }) + @EnableConfigurationProperties(RedisCacheProperties.class) + @Configuration + static class TestConfiguration { + + @Bean + @ConditionalOnProperty(value = "feature.enabled", havingValue = "true") + public FeatureBean featureBean() { + return new FeatureBean(); + } + + @Bean + public CompatAnnotatedBean compatAnnotatedBean() { + return new CompatAnnotatedBean(); + } + + @Bean + public ApolloApplicationListenerProbe apolloApplicationListenerProbe() { + return new ApolloApplicationListenerProbe(); + } + + } + + static class FeatureBean { + } + + @ConfigurationProperties(prefix = "redis.cache") + static class RedisCacheProperties { + + private boolean enabled; + private int commandTimeout; + private int expireSeconds; + private String clusterNodes; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getCommandTimeout() { + return commandTimeout; + } + + public void setCommandTimeout(int commandTimeout) { + this.commandTimeout = commandTimeout; + } + + public int getExpireSeconds() { + return expireSeconds; + } + + public void setExpireSeconds(int expireSeconds) { + this.expireSeconds = expireSeconds; + } + + public String getClusterNodes() { + return clusterNodes; + } + + public void setClusterNodes(String clusterNodes) { + this.clusterNodes = clusterNodes; + } + } + + public static class CompatAnnotatedBean { + + private final BlockingQueue defaultEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue publicNamespaceEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue yamlNamespaceEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue anotherAppEvents = + new ArrayBlockingQueue(8); + + private volatile int timeout; + private volatile String publicOnly; + private volatile List jsonBeans = Collections.emptyList(); + + @ApolloConfig + private Config applicationConfig; + + @ApolloConfig("TEST1.apollo") + private Config publicNamespaceConfig; + + @ApolloConfig("application.yaml") + private Config yamlNamespaceConfig; + + @ApolloConfig(appId = ANOTHER_APP_ID) + private Config anotherAppConfig; + + @Value("${compat.timeout:0}") + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + @Value("${public.only:missing}") + public void setPublicOnly(String publicOnly) { + this.publicOnly = publicOnly; + } + + @ApolloJsonValue("${jsonBeanProperty:[]}") + public void setJsonBeans(List jsonBeans) { + this.jsonBeans = jsonBeans; + } + + @ApolloConfigChangeListener(value = "application", interestedKeyPrefixes = {"compat."}) + private void onDefaultNamespaceChange(ConfigChangeEvent event) { + defaultEvents.offer(event); + } + + @ApolloConfigChangeListener(value = "TEST1.apollo", interestedKeyPrefixes = {"public."}) + private void onPublicNamespaceChange(ConfigChangeEvent event) { + publicNamespaceEvents.offer(event); + } + + @ApolloConfigChangeListener(value = "application.yaml", interestedKeyPrefixes = {"yaml."}) + private void onYamlNamespaceChange(ConfigChangeEvent event) { + yamlNamespaceEvents.offer(event); + } + + @ApolloConfigChangeListener(appId = ANOTHER_APP_ID, + interestedKeyPrefixes = {"compat.origin"}) + private void onAnotherAppChange(ConfigChangeEvent event) { + anotherAppEvents.offer(event); + } + + int getTimeout() { + return timeout; + } + + String getPublicOnly() { + return publicOnly; + } + + List getJsonBeans() { + return jsonBeans; + } + + Config getAnotherAppConfig() { + return anotherAppConfig; + } + + Config getApplicationConfig() { + return applicationConfig; + } + + Config getPublicNamespaceConfig() { + return publicNamespaceConfig; + } + + Config getYamlNamespaceConfig() { + return yamlNamespaceConfig; + } + + ConfigChangeEvent pollDefaultEvent(long timeout, TimeUnit unit) throws InterruptedException { + return defaultEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollPublicNamespaceEvent(long timeout, TimeUnit unit) throws InterruptedException { + return publicNamespaceEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollYamlNamespaceEvent(long timeout, TimeUnit unit) throws InterruptedException { + return yamlNamespaceEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollAnotherAppEvent(long timeout, TimeUnit unit) throws InterruptedException { + return anotherAppEvents.poll(timeout, unit); + } + } + + static class ApolloApplicationListenerProbe implements ApplicationListener { + + private final Set changes = Collections.synchronizedSet(new HashSet()); + private final Set namespaces = Collections.synchronizedSet(new HashSet()); + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (event instanceof ApolloConfigChangeEvent) { + ConfigChangeEvent configChangeEvent = ((ApolloConfigChangeEvent) event).getConfigChangeEvent(); + changes.add(configChangeEvent.getAppId() + "#" + configChangeEvent.getNamespace()); + namespaces.add(configChangeEvent.getNamespace()); + } + } + + boolean hasReceived(String marker) { + return changes.contains(marker); + } + + boolean hasNamespace(String namespace) { + return namespaces.contains(namespace); + } + } + + public static class JsonBean { + + private String someString; + private int someInt; + + public String getSomeString() { + return someString; + } + + public int getSomeInt() { + return someInt; + } + } + + private static void resetApolloState() throws Exception { + Class initializerClass = Class.forName( + "com.ctrip.framework.apollo.config.data.importer.ApolloConfigDataLoaderInitializer"); + Field initialized = initializerClass.getDeclaredField("INITIALIZED"); + initialized.setAccessible(true); + initialized.setBoolean(null, false); + + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + clearApolloClientCaches(); + } + + private static void clearApolloClientCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + private static void clearField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object container = field.get(target); + if (container == null) { + return; + } + if (container instanceof Map) { + ((Map) container).clear(); + return; + } + if (container instanceof Table) { + ((Table) container).clear(); + return; + } + Method clearMethod = container.getClass().getMethod("clear"); + clearMethod.setAccessible(true); + clearMethod.invoke(container); + } + + private static Properties copyConfigProperties(Config config) { + Properties properties = new Properties(); + for (String key : config.getPropertyNames()) { + properties.setProperty(key, config.getProperty(key, "")); + } + return properties; + } + + private static void applyConfigChange(Config config, String appId, String namespace, + Properties properties) { + Assert.assertTrue(config instanceof DefaultConfig); + ((DefaultConfig) config).onRepositoryChange(appId, namespace, properties); + } + + private static void waitForCondition(String message, Callable condition) throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(10); + while (System.currentTimeMillis() < deadline) { + if (Boolean.TRUE.equals(condition.call())) { + return; + } + TimeUnit.MILLISECONDS.sleep(100); + } + throw new AssertionError(message); + } + +} diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-100004459-application.properties b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-100004459-application.properties new file mode 100644 index 00000000..3955d17c --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-100004459-application.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +compat.origin=from-another-app-boot diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-TEST1.apollo.properties b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-TEST1.apollo.properties new file mode 100644 index 00000000..d1d85ce7 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-TEST1.apollo.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +public.only=from-public-boot diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.properties b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.properties new file mode 100644 index 00000000..5df7e520 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.properties @@ -0,0 +1,23 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +feature.enabled=true +redis.cache.enabled=true +redis.cache.commandTimeout=40 +redis.cache.expireSeconds=100 +redis.cache.clusterNodes=1,2 +listeners=application,TEST1.apollo,application.yaml +compat.timeout=800 +jsonBeanProperty=[{"someString":"alpha-boot","someInt":101},{"someString":"beta-boot","someInt":202}] diff --git a/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.yaml.properties b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.yaml.properties new file mode 100644 index 00000000..fbe6a7fd --- /dev/null +++ b/apollo-compat-tests/apollo-spring-boot-compat-it/src/test/resources/mockdata-application.yaml.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +content=yaml:\n marker: boot-compat\n diff --git a/apollo-compat-tests/apollo-spring-compat-it/pom.xml b/apollo-compat-tests/apollo-spring-compat-it/pom.xml new file mode 100644 index 00000000..5b5b3761 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/pom.xml @@ -0,0 +1,96 @@ + + + + + com.ctrip.framework.apollo + apollo-compat-tests + ${revision} + ../pom.xml + + 4.0.0 + + apollo-spring-compat-it + Apollo Spring Compatibility IT + + + 1.8 + 3.1.1.RELEASE + + + + + com.ctrip.framework.apollo + apollo-client + ${revision} + + + com.ctrip.framework.apollo + apollo-mockserver + ${revision} + test + + + org.springframework + spring-context + ${spring.framework.version} + + + org.springframework + spring-aop + ${spring.framework.version} + + + org.springframework + spring-beans + ${spring.framework.version} + + + org.springframework + spring-core + ${spring.framework.version} + + + org.springframework + spring-expression + ${spring.framework.version} + + + org.springframework + spring-test + ${spring.framework.version} + test + + + cglib + cglib-nodep + 3.3.0 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java new file mode 100644 index 00000000..fb1ddcae --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java @@ -0,0 +1,287 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.spring; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.spring.annotation.ApolloConfig; +import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener; +import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue; +import com.ctrip.framework.apollo.spring.annotation.EnableApolloConfig; +import com.ctrip.framework.apollo.spring.annotation.MultipleConfig; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = SpringAnnotationCompatibilityTest.TestConfiguration.class) +public class SpringAnnotationCompatibilityTest { + + private static final String ANOTHER_APP_ID = "100004459"; + + @ClassRule + public static final EmbeddedApollo EMBEDDED_APOLLO = new EmbeddedApollo(); + + @Autowired + private AnnotationProbe probe; + + @Autowired + private SpringApolloEventListenerProbe apolloEventListenerProbe; + + @BeforeClass + public static void beforeClass() throws Exception { + SpringCompatibilityTestSupport.beforeClass(EMBEDDED_APOLLO); + } + + @AfterClass + public static void afterClass() throws Exception { + SpringCompatibilityTestSupport.afterClass(); + } + + @Test + public void shouldSupportAnnotationAndMultipleConfig() throws Exception { + assertEquals(5001, probe.getTimeout()); + assertEquals("from-public", probe.getPublicValue()); + assertEquals("from-yaml", probe.getYamlMarker()); + assertEquals("from-another-app", probe.getAnotherAppConfig().getProperty("compat.origin", null)); + assertEquals("5001", probe.getApplicationConfig().getProperty("compat.timeout", null)); + assertEquals("from-public", probe.getPublicNamespaceConfig().getProperty("public.key", null)); + assertEquals("from-yaml", probe.getYamlNamespaceConfig().getProperty("yaml.marker", null)); + assertEquals(2, probe.getJsonBeans().size()); + assertEquals("alpha", probe.getJsonBeans().get(0).getSomeString()); + + Config applicationConfig = ConfigService.getConfig("application"); + Properties applicationProperties = + SpringCompatibilityTestSupport.copyConfigProperties(applicationConfig); + applicationProperties.setProperty("compat.timeout", "5002"); + SpringCompatibilityTestSupport.applyConfigChange(applicationConfig, "application", + applicationProperties); + + Config publicConfig = ConfigService.getConfig("TEST1.apollo"); + Properties publicProperties = SpringCompatibilityTestSupport.copyConfigProperties(publicConfig); + publicProperties.setProperty("public.key", "from-public-updated"); + SpringCompatibilityTestSupport.applyConfigChange(publicConfig, "TEST1.apollo", publicProperties); + + Config yamlConfig = ConfigService.getConfig("application.yaml"); + Properties yamlProperties = SpringCompatibilityTestSupport.copyConfigProperties(yamlConfig); + yamlProperties.setProperty("yaml.marker", "from-yaml-updated"); + SpringCompatibilityTestSupport.applyConfigChange(yamlConfig, "application.yaml", yamlProperties); + + Properties anotherAppProperties = + SpringCompatibilityTestSupport.copyConfigProperties(probe.getAnotherAppConfig()); + anotherAppProperties.setProperty("compat.origin", "changed-origin"); + SpringCompatibilityTestSupport.applyConfigChange(probe.getAnotherAppConfig(), ANOTHER_APP_ID, + "application", anotherAppProperties); + + ConfigChangeEvent defaultChange = probe.pollDefaultEvent(10, TimeUnit.SECONDS); + assertNotNull(defaultChange); + assertNotNull(defaultChange.getChange("compat.timeout")); + + ConfigChangeEvent publicNamespaceChange = probe.pollPublicNamespaceEvent(10, TimeUnit.SECONDS); + assertNotNull(publicNamespaceChange); + assertNotNull(publicNamespaceChange.getChange("public.key")); + + ConfigChangeEvent yamlNamespaceChange = probe.pollYamlNamespaceEvent(10, TimeUnit.SECONDS); + assertNotNull(yamlNamespaceChange); + assertEquals("application.yaml", yamlNamespaceChange.getNamespace()); + + ConfigChangeEvent anotherAppChange = probe.pollAnotherAppEvent(10, TimeUnit.SECONDS); + assertNotNull(anotherAppChange); + assertNotNull(anotherAppChange.getChange("compat.origin")); + + String namespace = apolloEventListenerProbe.pollNamespace(10, TimeUnit.SECONDS); + assertEquals("application", namespace); + + SpringCompatibilityTestSupport.waitForCondition("public value should be updated", + () -> "from-public-updated".equals( + probe.getPublicNamespaceConfig().getProperty("public.key", null))); + SpringCompatibilityTestSupport.waitForCondition("yaml marker should be updated", + () -> "from-yaml-updated".equals( + probe.getYamlNamespaceConfig().getProperty("yaml.marker", null))); + SpringCompatibilityTestSupport.waitForCondition("application config should be updated", + () -> "5002".equals(probe.getApplicationConfig().getProperty("compat.timeout", null))); + SpringCompatibilityTestSupport.waitForCondition("another app config should be updated", + () -> "changed-origin".equals(probe.getAnotherAppConfig().getProperty("compat.origin", null))); + } + + @Configuration + @EnableApolloConfig(value = {"application", "TEST1.apollo", "application.yaml"}, + multipleConfigs = {@MultipleConfig(appId = ANOTHER_APP_ID, namespaces = {"application"}, order = 9)}) + static class TestConfiguration { + + @Bean + public AnnotationProbe annotationProbe() { + return new AnnotationProbe(); + } + + @Bean + public SpringApolloEventListenerProbe apolloEventListenerProbe() { + return new SpringApolloEventListenerProbe(); + } + } + + static class AnnotationProbe { + + private final BlockingQueue defaultEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue publicNamespaceEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue yamlNamespaceEvents = + new ArrayBlockingQueue(8); + private final BlockingQueue anotherAppEvents = + new ArrayBlockingQueue(8); + + private volatile int timeout; + private volatile String publicValue; + private volatile String yamlMarker; + private volatile List jsonBeans = Collections.emptyList(); + + @ApolloConfig + private Config applicationConfig; + + @ApolloConfig("TEST1.apollo") + private Config publicNamespaceConfig; + + @ApolloConfig("application.yaml") + private Config yamlNamespaceConfig; + + @ApolloConfig(appId = ANOTHER_APP_ID) + private Config anotherAppConfig; + + @Value("${compat.timeout:0}") + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + @Value("${public.key:missing}") + public void setPublicValue(String publicValue) { + this.publicValue = publicValue; + } + + @Value("${yaml.marker:missing}") + public void setYamlMarker(String yamlMarker) { + this.yamlMarker = yamlMarker; + } + + @ApolloJsonValue("${jsonBeanProperty:[]}") + public void setJsonBeans(List jsonBeans) { + this.jsonBeans = jsonBeans; + } + + @ApolloConfigChangeListener + private void onDefaultNamespaceChange(ConfigChangeEvent event) { + defaultEvents.offer(event); + } + + @ApolloConfigChangeListener("TEST1.apollo") + private void onPublicNamespaceChange(ConfigChangeEvent event) { + publicNamespaceEvents.offer(event); + } + + @ApolloConfigChangeListener("application.yaml") + private void onYamlNamespaceChange(ConfigChangeEvent event) { + yamlNamespaceEvents.offer(event); + } + + @ApolloConfigChangeListener(appId = ANOTHER_APP_ID, + interestedKeyPrefixes = {"compat.origin"}) + private void onAnotherAppChange(ConfigChangeEvent event) { + anotherAppEvents.offer(event); + } + + int getTimeout() { + return timeout; + } + + String getPublicValue() { + return publicValue; + } + + String getYamlMarker() { + return yamlMarker; + } + + List getJsonBeans() { + return jsonBeans; + } + + Config getAnotherAppConfig() { + return anotherAppConfig; + } + + Config getApplicationConfig() { + return applicationConfig; + } + + Config getPublicNamespaceConfig() { + return publicNamespaceConfig; + } + + Config getYamlNamespaceConfig() { + return yamlNamespaceConfig; + } + + ConfigChangeEvent pollDefaultEvent(long timeout, TimeUnit unit) throws InterruptedException { + return defaultEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollPublicNamespaceEvent(long timeout, TimeUnit unit) throws InterruptedException { + return publicNamespaceEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollYamlNamespaceEvent(long timeout, TimeUnit unit) throws InterruptedException { + return yamlNamespaceEvents.poll(timeout, unit); + } + + ConfigChangeEvent pollAnotherAppEvent(long timeout, TimeUnit unit) throws InterruptedException { + return anotherAppEvents.poll(timeout, unit); + } + } + + static class JsonBean { + + private String someString; + private int someInt; + + public String getSomeString() { + return someString; + } + + public int getSomeInt() { + return someInt; + } + } +} diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java new file mode 100644 index 00000000..b614a91e --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.spring; + +import com.ctrip.framework.apollo.spring.events.ApolloConfigChangeEvent; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; + +public class SpringApolloEventListenerProbe implements ApplicationListener { + + private final BlockingQueue namespaces = new LinkedBlockingQueue(); + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (event instanceof ApolloConfigChangeEvent) { + namespaces.offer(((ApolloConfigChangeEvent) event).getConfigChangeEvent().getNamespace()); + } + } + + public String pollNamespace(long timeout, TimeUnit unit) throws InterruptedException { + return namespaces.poll(timeout, unit); + } +} diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringCompatibilityTestSupport.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringCompatibilityTestSupport.java new file mode 100644 index 00000000..0f2024d9 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringCompatibilityTestSupport.java @@ -0,0 +1,130 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.spring; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.internals.DefaultConfig; +import com.ctrip.framework.apollo.internals.ConfigManager; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.google.common.collect.Table; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; + +final class SpringCompatibilityTestSupport { + + private static final String ORIGINAL_APP_ID = System.getProperty("app.id"); + private static final String ORIGINAL_ENV = System.getProperty("env"); + + private SpringCompatibilityTestSupport() { + } + + static void beforeClass(EmbeddedApollo embeddedApollo) throws Exception { + System.setProperty("app.id", "someAppId"); + System.setProperty("env", "local"); + embeddedApollo.resetOverriddenProperties(); + resetApolloState(); + } + + static void afterClass() throws Exception { + restoreOrClear("app.id", ORIGINAL_APP_ID); + restoreOrClear("env", ORIGINAL_ENV); + resetApolloState(); + } + + static void waitForCondition(String failureMessage, Callable condition) throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(15); + while (System.currentTimeMillis() < deadline) { + if (Boolean.TRUE.equals(condition.call())) { + return; + } + TimeUnit.MILLISECONDS.sleep(100); + } + throw new AssertionError(failureMessage); + } + + static Properties copyConfigProperties(Config config) { + Properties properties = new Properties(); + for (String key : config.getPropertyNames()) { + properties.setProperty(key, config.getProperty(key, "")); + } + return properties; + } + + static void applyConfigChange(Config config, String namespace, Properties properties) { + Assert.assertTrue(config instanceof DefaultConfig); + ((DefaultConfig) config).onRepositoryChange(namespace, properties); + } + + static void applyConfigChange(Config config, String appId, String namespace, + Properties properties) { + Assert.assertTrue(config instanceof DefaultConfig); + ((DefaultConfig) config).onRepositoryChange(appId, namespace, properties); + } + + private static void restoreOrClear(String key, String originalValue) { + if (originalValue == null) { + System.clearProperty(key); + return; + } + System.setProperty(key, originalValue); + } + + private static void resetApolloState() throws Exception { + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + clearApolloClientCaches(); + } + + private static void clearApolloClientCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + private static void clearField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object container = field.get(target); + if (container == null) { + return; + } + if (container instanceof Map) { + ((Map) container).clear(); + return; + } + if (container instanceof Table) { + ((Table) container).clear(); + return; + } + Method clearMethod = container.getClass().getMethod("clear"); + clearMethod.setAccessible(true); + clearMethod.invoke(container); + } +} diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlBean.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlBean.java new file mode 100644 index 00000000..c77328ae --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlBean.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.spring; + +public class SpringXmlBean { + + private int timeout; + private int batch; + private String publicKey; + private String yamlMarker; + + public int getTimeout() { + return timeout; + } + + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + public int getBatch() { + return batch; + } + + public void setBatch(int batch) { + this.batch = batch; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + public String getYamlMarker() { + return yamlMarker; + } + + public void setYamlMarker(String yamlMarker) { + this.yamlMarker = yamlMarker; + } +} diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlCompatibilityTest.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlCompatibilityTest.java new file mode 100644 index 00000000..31c9a00f --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringXmlCompatibilityTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.compat.spring; + +import static org.junit.Assert.assertEquals; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.mockserver.EmbeddedApollo; +import java.util.Properties; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(locations = "classpath:/spring/apollo-context.xml") +public class SpringXmlCompatibilityTest { + + @ClassRule + public static final EmbeddedApollo EMBEDDED_APOLLO = new EmbeddedApollo(); + + @Autowired + private SpringXmlBean xmlBean; + + @BeforeClass + public static void beforeClass() throws Exception { + SpringCompatibilityTestSupport.beforeClass(EMBEDDED_APOLLO); + } + + @AfterClass + public static void afterClass() throws Exception { + SpringCompatibilityTestSupport.afterClass(); + } + + @Test + public void shouldSupportXmlConfig() throws Exception { + assertEquals(5099, xmlBean.getTimeout()); + assertEquals(51, xmlBean.getBatch()); + assertEquals("from-public", xmlBean.getPublicKey()); + assertEquals("from-yaml", xmlBean.getYamlMarker()); + + Config applicationConfig = ConfigService.getConfig("application"); + Properties applicationProperties = + SpringCompatibilityTestSupport.copyConfigProperties(applicationConfig); + applicationProperties.setProperty("compat.xml.timeout", "5199"); + applicationProperties.setProperty("compat.xml.batch", "61"); + SpringCompatibilityTestSupport.applyConfigChange(applicationConfig, "application", + applicationProperties); + + Config publicConfig = ConfigService.getConfig("TEST1.apollo"); + Properties publicProperties = SpringCompatibilityTestSupport.copyConfigProperties(publicConfig); + publicProperties.setProperty("public.key", "from-public-xml-updated"); + SpringCompatibilityTestSupport.applyConfigChange(publicConfig, "TEST1.apollo", publicProperties); + + Config yamlConfig = ConfigService.getConfig("application.yaml"); + Properties yamlProperties = SpringCompatibilityTestSupport.copyConfigProperties(yamlConfig); + yamlProperties.setProperty("yaml.marker", "from-yaml-xml-updated"); + SpringCompatibilityTestSupport.applyConfigChange(yamlConfig, "application.yaml", yamlProperties); + + SpringCompatibilityTestSupport.waitForCondition("xml timeout should be updated", + () -> xmlBean.getTimeout() == 5199); + SpringCompatibilityTestSupport.waitForCondition("xml batch should be updated", + () -> xmlBean.getBatch() == 61); + SpringCompatibilityTestSupport.waitForCondition("xml public key should be updated", + () -> "from-public-xml-updated".equals(xmlBean.getPublicKey())); + SpringCompatibilityTestSupport.waitForCondition("xml yaml marker should be updated", + () -> "from-yaml-xml-updated".equals(xmlBean.getYamlMarker())); + } +} diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-100004459-application.properties b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-100004459-application.properties new file mode 100644 index 00000000..cef7d1fe --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-100004459-application.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +compat.origin=from-another-app diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-TEST1.apollo.properties b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-TEST1.apollo.properties new file mode 100644 index 00000000..cab0be75 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-TEST1.apollo.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +public.key=from-public diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.properties b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.properties new file mode 100644 index 00000000..f6ce58f4 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.properties @@ -0,0 +1,19 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +compat.timeout=5001 +compat.xml.timeout=5099 +compat.xml.batch=51 +jsonBeanProperty=[{"someString":"alpha","someInt":11},{"someString":"beta","someInt":22}] diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.yaml.properties b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.yaml.properties new file mode 100644 index 00000000..ac64a146 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/mockdata-application.yaml.properties @@ -0,0 +1,16 @@ +# +# Copyright 2022 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +content=yaml:\n marker: from-yaml\n diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/spring/apollo-context.xml b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/spring/apollo-context.xml new file mode 100644 index 00000000..d0e44792 --- /dev/null +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/resources/spring/apollo-context.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + diff --git a/apollo-compat-tests/pom.xml b/apollo-compat-tests/pom.xml new file mode 100644 index 00000000..aea08501 --- /dev/null +++ b/apollo-compat-tests/pom.xml @@ -0,0 +1,59 @@ + + + + + com.ctrip.framework.apollo + apollo-java + ${revision} + ../pom.xml + + 4.0.0 + + apollo-compat-tests + Apollo Compatibility Tests + pom + + + apollo-api-compat-it + apollo-spring-compat-it + apollo-spring-boot-compat-it + + + + true + true + 3.2.5 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${compat.surefire.version} + + false + + + + + + diff --git a/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/ApolloTestingServer.java b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/ApolloTestingServer.java index cb9b7400..d4f63852 100644 --- a/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/ApolloTestingServer.java +++ b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/ApolloTestingServer.java @@ -17,15 +17,18 @@ package com.ctrip.framework.apollo.mockserver; import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.ConfigService; import com.ctrip.framework.apollo.core.ApolloClientSystemConsts; import com.ctrip.framework.apollo.core.dto.ApolloConfig; import com.ctrip.framework.apollo.core.dto.ApolloConfigNotification; import com.ctrip.framework.apollo.core.utils.ResourceUtils; import com.ctrip.framework.apollo.internals.ConfigServiceLocator; import com.ctrip.framework.apollo.internals.LocalFileConfigRepository; +import com.ctrip.framework.apollo.internals.RemoteConfigLongPollService; import com.ctrip.framework.apollo.util.ConfigUtil; import com.google.common.collect.Maps; import com.google.common.collect.Sets; +import com.google.common.collect.Table; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import okhttp3.mockwebserver.Dispatcher; @@ -45,6 +48,7 @@ import java.util.Properties; import java.util.Set; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; public class ApolloTestingServer implements AutoCloseable { @@ -52,8 +56,10 @@ public class ApolloTestingServer implements AutoCloseable { private static final Type notificationType = new TypeToken>() { }.getType(); - private static String someAppId = "someAppId"; + private static final String DEFAULT_APP_ID = "someAppId"; private static Method CONFIG_SERVICE_LOCATOR_CLEAR; + private static Method CONFIG_SERVICE_RESET; + private static Method REMOTE_CONFIG_LONG_POLL_STOP; private static ConfigServiceLocator CONFIG_SERVICE_LOCATOR; private static ConfigUtil CONFIG_UTIL; @@ -62,8 +68,10 @@ public class ApolloTestingServer implements AutoCloseable { private static ResourceUtils RESOURCES_UTILS; private static final Gson GSON = new Gson(); - private final Map> addedOrModifiedPropertiesOfNamespace = Maps.newConcurrentMap(); - private final Map> deletedKeysOfNamespace = Maps.newConcurrentMap(); + private final Map>> addedOrModifiedPropertiesOfAppAndNamespace = + Maps.newConcurrentMap(); + private final Map>> deletedKeysOfAppAndNamespace = + Maps.newConcurrentMap(); private MockWebServer server; @@ -77,6 +85,11 @@ public class ApolloTestingServer implements AutoCloseable { CONFIG_SERVICE_LOCATOR = ApolloInjector.getInstance(ConfigServiceLocator.class); CONFIG_SERVICE_LOCATOR_CLEAR = ConfigServiceLocator.class.getDeclaredMethod("initConfigServices"); CONFIG_SERVICE_LOCATOR_CLEAR.setAccessible(true); + CONFIG_SERVICE_RESET = ConfigService.class.getDeclaredMethod("reset"); + CONFIG_SERVICE_RESET.setAccessible(true); + REMOTE_CONFIG_LONG_POLL_STOP = + RemoteConfigLongPollService.class.getDeclaredMethod("stopLongPollingRefresh"); + REMOTE_CONFIG_LONG_POLL_STOP.setAccessible(true); CONFIG_UTIL = ApolloInjector.getInstance(ConfigUtil.class); @@ -90,7 +103,7 @@ public class ApolloTestingServer implements AutoCloseable { } public void start() throws IOException { - clear(); + clearForStart(); server = new MockWebServer(); final Dispatcher dispatcher = new Dispatcher() { @Override @@ -105,7 +118,7 @@ public MockResponse dispatch(RecordedRequest request) throws InterruptedExceptio String appId = pathSegments.get(1); String cluster = pathSegments.get(2); String namespace = pathSegments.get(3); - return new MockResponse().setResponseCode(200).setBody(loadConfigFor(namespace)); + return new MockResponse().setResponseCode(200).setBody(loadConfigFor(appId, namespace)); } return new MockResponse().setResponseCode(404); } @@ -120,7 +133,7 @@ public MockResponse dispatch(RecordedRequest request) throws InterruptedExceptio public void close() { try { - clear(); + clearForClose(); server.close(); } catch (Exception e) { logger.error("stop apollo server error", e); @@ -137,7 +150,13 @@ public boolean isStarted() { return started; } - private void clear() { + private void clearForStart() { + resetApolloClientState(false); + resetOverriddenProperties(); + } + + private void clearForClose() { + resetApolloClientState(true); resetOverriddenProperties(); } @@ -151,32 +170,46 @@ private void mockConfigServiceUrl(String url) { } } - private String loadConfigFor(String namespace) { - final Properties prop = loadPropertiesOfNamespace(namespace); + private String loadConfigFor(String appId, String namespace) { + final Properties prop = loadPropertiesOfNamespace(appId, namespace); Map configurations = Maps.newHashMap(); for (String propertyName : prop.stringPropertyNames()) { configurations.put(propertyName, prop.getProperty(propertyName)); } - ApolloConfig apolloConfig = new ApolloConfig("someAppId", "someCluster", namespace, "someReleaseKey"); + ApolloConfig apolloConfig = new ApolloConfig(appId, "someCluster", namespace, "someReleaseKey"); - Map mergedConfigurations = mergeOverriddenProperties(namespace, configurations); + Map mergedConfigurations = mergeOverriddenProperties(appId, namespace, configurations); apolloConfig.setConfigurations(mergedConfigurations); return GSON.toJson(apolloConfig); } - private Properties loadPropertiesOfNamespace(String namespace) { + private Properties loadPropertiesOfNamespace(String appId, String namespace) { + String appSpecificFilename = String.format("mockdata-%s-%s.properties", appId, namespace); + Properties appSpecific = loadPropertiesFromResource(appSpecificFilename, appId, namespace); + if (appSpecific != null) { + return appSpecific; + } + String filename = String.format("mockdata-%s.properties", namespace); - Object mockdataPropertiesExits = null; + Properties genericProperties = loadPropertiesFromResource(filename, appId, namespace); + if (genericProperties != null) { + return genericProperties; + } + return new LocalFileConfigRepository(appId, namespace).getConfig(); + } + + private Properties loadPropertiesFromResource(String filename, String appId, String namespace) { + Object mockdataPropertiesExists = null; try { - mockdataPropertiesExits = RESOURCES_UTILS_CLEAR.invoke(RESOURCES_UTILS, filename); + mockdataPropertiesExists = RESOURCES_UTILS_CLEAR.invoke(RESOURCES_UTILS, filename); } catch (IllegalAccessException | InvocationTargetException e) { logger.error("invoke resources util locator clear failed.", e); } - if (!Objects.isNull(mockdataPropertiesExits)) { - logger.debug("load {} from {}", namespace, filename); + if (!Objects.isNull(mockdataPropertiesExists)) { + logger.debug("load appId [{}] namespace [{}] from {}", appId, namespace, filename); return ResourceUtils.readConfigFile(filename, new Properties()); } - return new LocalFileConfigRepository(someAppId, namespace).getConfig(); + return null; } private String mockLongPollBody(String notificationsStr) { @@ -192,11 +225,16 @@ private String mockLongPollBody(String notificationsStr) { /** * 合并用户对namespace的修改 */ - private Map mergeOverriddenProperties(String namespace, Map configurations) { - if (addedOrModifiedPropertiesOfNamespace.containsKey(namespace)) { + private Map mergeOverriddenProperties(String appId, String namespace, + Map configurations) { + Map> addedOrModifiedPropertiesOfNamespace = + addedOrModifiedPropertiesOfAppAndNamespace.get(appId); + if (addedOrModifiedPropertiesOfNamespace != null + && addedOrModifiedPropertiesOfNamespace.containsKey(namespace)) { configurations.putAll(addedOrModifiedPropertiesOfNamespace.get(namespace)); } - if (deletedKeysOfNamespace.containsKey(namespace)) { + Map> deletedKeysOfNamespace = deletedKeysOfAppAndNamespace.get(appId); + if (deletedKeysOfNamespace != null && deletedKeysOfNamespace.containsKey(namespace)) { for (String k : deletedKeysOfNamespace.get(namespace)) { configurations.remove(k); } @@ -208,33 +246,89 @@ private Map mergeOverriddenProperties(String namespace, Map> addedOrModifiedPropertiesOfNamespace = + addedOrModifiedPropertiesOfAppAndNamespace.computeIfAbsent(appId, key -> Maps.newConcurrentMap()); if (addedOrModifiedPropertiesOfNamespace.containsKey(namespace)) { addedOrModifiedPropertiesOfNamespace.get(namespace).put(someKey, someValue); - } else { - Map m = Maps.newConcurrentMap(); - m.put(someKey, someValue); - addedOrModifiedPropertiesOfNamespace.put(namespace, m); + return; } + Map properties = Maps.newConcurrentMap(); + properties.put(someKey, someValue); + addedOrModifiedPropertiesOfNamespace.put(namespace, properties); } /** * Delete existed property */ public void deleteProperty(String namespace, String someKey) { + deleteProperty(DEFAULT_APP_ID, namespace, someKey); + } + + /** + * Delete existed property for the specified appId and namespace. + */ + public void deleteProperty(String appId, String namespace, String someKey) { + Map> deletedKeysOfNamespace = + deletedKeysOfAppAndNamespace.computeIfAbsent(appId, key -> Maps.newConcurrentMap()); if (deletedKeysOfNamespace.containsKey(namespace)) { deletedKeysOfNamespace.get(namespace).add(someKey); - } else { - Set m = Sets.newConcurrentHashSet(); - m.add(someKey); - deletedKeysOfNamespace.put(namespace, m); + return; } + Set keys = Sets.newConcurrentHashSet(); + keys.add(someKey); + deletedKeysOfNamespace.put(namespace, keys); } /** * reset overridden properties */ public void resetOverriddenProperties() { - addedOrModifiedPropertiesOfNamespace.clear(); - deletedKeysOfNamespace.clear(); + addedOrModifiedPropertiesOfAppAndNamespace.clear(); + deletedKeysOfAppAndNamespace.clear(); + } + + private void resetApolloClientState(boolean stopLongPolling) { + try { + RemoteConfigLongPollService longPollService = + ApolloInjector.getInstance(RemoteConfigLongPollService.class); + if (stopLongPolling) { + REMOTE_CONFIG_LONG_POLL_STOP.invoke(longPollService); + } else { + prepareLongPollingService(); + } + clearLongPollingState(longPollService); + CONFIG_SERVICE_RESET.invoke(null); + } catch (Throwable ex) { + logger.warn("reset apollo client state failed.", ex); + } + } + + private static void prepareLongPollingService() throws Exception { + RemoteConfigLongPollService longPollService = + ApolloInjector.getInstance(RemoteConfigLongPollService.class); + AtomicBoolean stopped = (AtomicBoolean) getLongPollField(longPollService, "m_longPollingStopped"); + stopped.set(false); + } + + @SuppressWarnings("unchecked") + private static void clearLongPollingState(RemoteConfigLongPollService longPollService) throws Exception { + ((Map) getLongPollField(longPollService, "m_longPollStarted")).clear(); + ((Map) getLongPollField(longPollService, "m_longPollNamespaces")).clear(); + ((Table) getLongPollField(longPollService, "m_notifications")).clear(); + ((Map) getLongPollField(longPollService, "m_remoteNotificationMessages")).clear(); + } + + private static Object getLongPollField(RemoteConfigLongPollService longPollService, String fieldName) + throws Exception { + java.lang.reflect.Field field = RemoteConfigLongPollService.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(longPollService); } } diff --git a/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/EmbeddedApollo.java b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/EmbeddedApollo.java index 1f045843..bb502aaf 100644 --- a/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/EmbeddedApollo.java +++ b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/EmbeddedApollo.java @@ -43,6 +43,13 @@ public void addOrModifyProperty(String namespace, String someKey, String someVal apollo.addOrModifyProperty(namespace, someKey, someValue); } + /** + * Add new property or update existed property for the specified appId and namespace. + */ + public void addOrModifyProperty(String appId, String namespace, String someKey, String someValue) { + apollo.addOrModifyProperty(appId, namespace, someKey, someValue); + } + /** * Delete existed property */ @@ -50,6 +57,13 @@ public void deleteProperty(String namespace, String someKey) { apollo.deleteProperty(namespace, someKey); } + /** + * Delete existed property for the specified appId and namespace. + */ + public void deleteProperty(String appId, String namespace, String someKey) { + apollo.deleteProperty(appId, namespace, someKey); + } + /** * reset overridden properties */ diff --git a/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/ApolloMockServerApiTest.java b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/ApolloMockServerApiTest.java index b1290606..24e7b428 100644 --- a/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/ApolloMockServerApiTest.java +++ b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/ApolloMockServerApiTest.java @@ -146,4 +146,39 @@ public void onChange(ConfigChangeEvent changeEvent) { assertNull(otherConfig.getProperty("key6", null)); assertEquals(0, changes.availablePermits()); } + + @Test + public void testUpdatePropertiesWithDifferentAppIds() throws Exception { + String appIdA = "appIdA"; + String appIdB = "appIdB"; + String updatedValue = "value-only-for-app-b"; + + Config configA = ConfigService.getConfig(appIdA, anotherNamespace); + Config configB = ConfigService.getConfig(appIdB, anotherNamespace); + + assertEquals("otherValue1", configA.getProperty("key1", null)); + assertEquals("otherValue1", configB.getProperty("key1", null)); + + embeddedApollo.addOrModifyProperty(appIdB, anotherNamespace, "key1", updatedValue); + + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5); + while (System.currentTimeMillis() < deadline + && !updatedValue.equals(configB.getProperty("key1", null))) { + Thread.sleep(100); + } + + assertEquals("otherValue1", configA.getProperty("key1", null)); + assertEquals(updatedValue, configB.getProperty("key1", null)); + + embeddedApollo.deleteProperty(appIdB, anotherNamespace, "key1"); + + deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5); + while (System.currentTimeMillis() < deadline + && configB.getProperty("key1", null) != null) { + Thread.sleep(100); + } + + assertNull(configB.getProperty("key1", null)); + assertEquals("otherValue1", configA.getProperty("key1", null)); + } } diff --git a/apollo-openapi/src/test/java/com/ctrip/framework/apollo/openapi/client/ApolloOpenApiMockIntegrationTest.java b/apollo-openapi/src/test/java/com/ctrip/framework/apollo/openapi/client/ApolloOpenApiMockIntegrationTest.java new file mode 100644 index 00000000..b12a98de --- /dev/null +++ b/apollo-openapi/src/test/java/com/ctrip/framework/apollo/openapi/client/ApolloOpenApiMockIntegrationTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.openapi.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.ctrip.framework.apollo.openapi.dto.OpenAppDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenCreateAppDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenNamespaceDTO; +import com.ctrip.framework.apollo.openapi.dto.OpenOrganizationDto; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Mock integration tests that verify OpenAPI client request/response chain. + */ +public class ApolloOpenApiMockIntegrationTest { + + private HttpServer server; + private MockPortalHandler handler; + + @Before + public void setUp() throws Exception { + handler = new MockPortalHandler(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/openapi/v1/", handler); + server.start(); + } + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + public void shouldCallFindAppsWithAuthorizationHeaderAndQuery() throws Exception { + handler.mock("GET", "/openapi/v1/apps", 200, + "[{\"appId\":\"SampleApp\",\"name\":\"SampleApp\",\"ownerName\":\"apollo\"}]"); + String token = "ci-openapi-token"; + ApolloOpenApiClient client = newClient(token); + + List apps = client.getAppsByIds(Collections.singletonList("SampleApp")); + CapturedRequest request = handler.awaitRequest(5, TimeUnit.SECONDS); + + assertNotNull(apps); + assertEquals(1, apps.size()); + assertEquals("SampleApp", apps.get(0).getAppId()); + assertEquals("GET", request.method); + assertEquals("/openapi/v1/apps", request.path); + assertEquals("appIds=SampleApp", request.query); + assertEquals(token, request.authorization); + } + + @Test + public void shouldSerializeCreateAppRequestBody() throws Exception { + handler.mock("POST", "/openapi/v1/apps", 200, ""); + ApolloOpenApiClient client = newClient("create-token"); + + OpenAppDTO app = new OpenAppDTO(); + app.setAppId("SampleApp"); + app.setName("SampleApp"); + app.setOwnerName("apollo"); + + OpenCreateAppDTO request = new OpenCreateAppDTO(); + request.setApp(app); + request.setAdmins(Collections.singleton("apollo")); + request.setAssignAppRoleToSelf(true); + client.createApp(request); + + CapturedRequest capturedRequest = handler.awaitRequest(5, TimeUnit.SECONDS); + assertEquals("POST", capturedRequest.method); + assertEquals("/openapi/v1/apps", capturedRequest.path); + assertEquals("create-token", capturedRequest.authorization); + assertTrue(capturedRequest.body.contains("\"appId\":\"SampleApp\"")); + assertTrue(capturedRequest.body.contains("\"assignAppRoleToSelf\":true")); + assertTrue(capturedRequest.body.contains("\"admins\":[\"apollo\"]")); + } + + @Test + public void shouldUseDefaultClusterAndNamespace() throws Exception { + handler.mock("GET", "/openapi/v1/envs/DEV/apps/SampleApp/clusters/default/namespaces/application", + 200, + "{\"appId\":\"SampleApp\",\"clusterName\":\"default\",\"namespaceName\":\"application\"}"); + ApolloOpenApiClient client = newClient("namespace-token"); + + OpenNamespaceDTO namespaceDTO = client.getNamespace("SampleApp", "DEV", null, null, true); + CapturedRequest request = handler.awaitRequest(5, TimeUnit.SECONDS); + + assertNotNull(namespaceDTO); + assertEquals("SampleApp", namespaceDTO.getAppId()); + assertEquals("default", namespaceDTO.getClusterName()); + assertEquals("application", namespaceDTO.getNamespaceName()); + assertEquals("GET", request.method); + assertEquals("/openapi/v1/envs/DEV/apps/SampleApp/clusters/default/namespaces/application", + request.path); + assertEquals("fillItemDetail=true", request.query); + assertEquals("namespace-token", request.authorization); + } + + @Test + public void shouldParseOrganizations() throws Exception { + handler.mock("GET", "/openapi/v1/organizations", 200, + "[{\"orgId\":\"100001\",\"orgName\":\"Apollo Team\"}]"); + ApolloOpenApiClient client = newClient("org-token"); + + List organizations = client.getOrganizations(); + CapturedRequest request = handler.awaitRequest(5, TimeUnit.SECONDS); + + assertNotNull(organizations); + assertEquals(1, organizations.size()); + assertEquals("100001", organizations.get(0).getOrgId()); + assertEquals("Apollo Team", organizations.get(0).getOrgName()); + assertEquals("GET", request.method); + assertEquals("/openapi/v1/organizations", request.path); + assertEquals("org-token", request.authorization); + } + + @Test + public void shouldWrapServerErrorsAsRuntimeException() { + handler.mock("GET", "/openapi/v1/apps", 500, "internal error"); + ApolloOpenApiClient client = newClient("error-token"); + + try { + client.getAllApps(); + fail("Expected RuntimeException to be thrown"); + } catch (RuntimeException ex) { + assertTrue(ex.getMessage().contains("Load app information")); + assertNotNull(ex.getCause()); + } + } + + private ApolloOpenApiClient newClient(String token) { + return ApolloOpenApiClient.newBuilder() + .withPortalUrl(String.format("http://127.0.0.1:%d", server.getAddress().getPort())) + .withToken(token) + .build(); + } + + private static class MockPortalHandler implements HttpHandler { + + private final Map responses = new ConcurrentHashMap<>(); + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + + void mock(String method, String path, int statusCode, String body) { + responses.put(key(method, path), new MockResponse(statusCode, body)); + } + + CapturedRequest awaitRequest(long timeout, TimeUnit unit) throws InterruptedException { + CapturedRequest request = requests.poll(timeout, unit); + assertNotNull(request); + return request; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + String query = exchange.getRequestURI().getQuery(); + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + String requestBody = readRequestBody(exchange.getRequestBody()); + requests.offer(new CapturedRequest(method, path, query, authorization, requestBody)); + + MockResponse response = responses.get(key(method, path)); + if (response == null) { + response = new MockResponse(404, ""); + } + byte[] responseBody = response.body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json;charset=UTF-8"); + exchange.sendResponseHeaders(response.statusCode, responseBody.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(responseBody); + } + } + + private String readRequestBody(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[256]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } + + private String key(String method, String path) { + return method + " " + path; + } + } + + private static class MockResponse { + private final int statusCode; + private final String body; + + private MockResponse(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + } + + private static class CapturedRequest { + + private final String method; + private final String path; + private final String query; + private final String authorization; + private final String body; + + private CapturedRequest( + String method, + String path, + String query, + String authorization, + String body) { + this.method = method; + this.path = path; + this.query = query; + this.authorization = authorization; + this.body = body; + } + } +} diff --git a/apollo-plugin/apollo-plugin-log4j2/pom.xml b/apollo-plugin/apollo-plugin-log4j2/pom.xml index b6461d67..b775941f 100644 --- a/apollo-plugin/apollo-plugin-log4j2/pom.xml +++ b/apollo-plugin/apollo-plugin-log4j2/pom.xml @@ -40,5 +40,10 @@ log4j-core provided + + com.ctrip.framework.apollo + apollo-mockserver + test + diff --git a/apollo-plugin/apollo-plugin-log4j2/src/test/java/com/ctrip/framework/apollo/plugin/log4j2/ApolloClientConfigurationFactoryTest.java b/apollo-plugin/apollo-plugin-log4j2/src/test/java/com/ctrip/framework/apollo/plugin/log4j2/ApolloClientConfigurationFactoryTest.java new file mode 100644 index 00000000..a7997b62 --- /dev/null +++ b/apollo-plugin/apollo-plugin-log4j2/src/test/java/com/ctrip/framework/apollo/plugin/log4j2/ApolloClientConfigurationFactoryTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2022 Apollo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.ctrip.framework.apollo.plugin.log4j2; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.ctrip.framework.apollo.Config; +import com.ctrip.framework.apollo.ConfigFile; +import com.ctrip.framework.apollo.ConfigFileChangeListener; +import com.ctrip.framework.apollo.ConfigService; +import com.ctrip.framework.apollo.build.ApolloInjector; +import com.ctrip.framework.apollo.core.enums.ConfigFileFormat; +import com.ctrip.framework.apollo.enums.ConfigSourceType; +import com.ctrip.framework.apollo.spi.ConfigFactory; +import com.ctrip.framework.apollo.spi.ConfigFactoryManager; +import com.ctrip.framework.apollo.spi.ConfigRegistry; +import com.ctrip.framework.apollo.internals.ConfigManager; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import com.google.common.collect.Table; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class ApolloClientConfigurationFactoryTest { + + private static final String ORIGINAL_APP_ID = System.getProperty("app.id"); + private static final String ORIGINAL_ENV = System.getProperty("env"); + private static final String ORIGINAL_ENABLED = System.getProperty("apollo.log4j2.enabled"); + private static final String LOG4J2_NAMESPACE = "log4j2.xml"; + + static { + System.setProperty("app.id", "someAppId"); + System.setProperty("env", "local"); + } + + @Before + public void setUp() throws Exception { + resetConfigService(); + clearApolloCaches(); + } + + @AfterClass + public static void afterClass() throws Exception { + restoreOrClear("app.id", ORIGINAL_APP_ID); + restoreOrClear("env", ORIGINAL_ENV); + restoreOrClear("apollo.log4j2.enabled", ORIGINAL_ENABLED); + resetConfigService(); + clearApolloCaches(); + } + + @Test + public void test1ShouldReturnNullWhenPluginIsDisabled() { + System.setProperty("apollo.log4j2.enabled", "false"); + + ApolloClientConfigurationFactory factory = new ApolloClientConfigurationFactory(); + Configuration configuration = factory.getConfiguration(new LoggerContext("disabled"), null); + + assertNull(configuration); + } + + @Test + public void test2ShouldReturnNullWhenNoLog4j2NamespaceContent() throws Exception { + System.setProperty("apollo.log4j2.enabled", "true"); + registerConfigFile(null); + ConfigFile configFile = ConfigService.getConfigFile("log4j2", ConfigFileFormat.XML); + assertNotNull(configFile); + assertNull(configFile.getContent()); + + ApolloClientConfigurationFactory factory = new ApolloClientConfigurationFactory(); + Configuration configuration = factory.getConfiguration(new LoggerContext("empty"), null); + + assertNull(configuration); + } + + @Test + public void test3ShouldBuildXmlConfigurationWhenContentExists() throws Exception { + System.setProperty("apollo.log4j2.enabled", "true"); + registerConfigFile( + ""); + + ApolloClientConfigurationFactory factory = new ApolloClientConfigurationFactory(); + Configuration configuration = factory.getConfiguration(new LoggerContext("apollo"), null); + + assertNotNull(configuration); + } + + private static void registerConfigFile(String content) throws Exception { + ConfigFactory factory = new StaticConfigFactory(content); + Method setFactoryMethod = + ConfigService.class.getDeclaredMethod("setConfigFactory", String.class, ConfigFactory.class); + setFactoryMethod.setAccessible(true); + setFactoryMethod.invoke(null, LOG4J2_NAMESPACE, factory); + } + + private static void resetConfigService() throws Exception { + Method resetMethod = ConfigService.class.getDeclaredMethod("reset"); + resetMethod.setAccessible(true); + resetMethod.invoke(null); + } + + private static void clearApolloCaches() throws Exception { + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configs"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configLocks"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFiles"); + clearField(ApolloInjector.getInstance(ConfigManager.class), "m_configFileLocks"); + clearField(ApolloInjector.getInstance(ConfigFactoryManager.class), "m_factories"); + clearField(ApolloInjector.getInstance(ConfigRegistry.class), "m_instances"); + } + + private static void clearField(Object instance, String fieldName) throws Exception { + Field field = instance.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(instance); + if (value == null) { + return; + } + if (value instanceof Map) { + ((Map) value).clear(); + return; + } + if (value instanceof Table) { + ((Table) value).clear(); + } + } + + private static void restoreOrClear(String key, String originalValue) { + if (originalValue == null) { + System.clearProperty(key); + return; + } + System.setProperty(key, originalValue); + } + + private static class StaticConfigFactory implements ConfigFactory { + + private final String content; + + private StaticConfigFactory(String content) { + this.content = content; + } + + @Override + public Config create(String namespace) { + return null; + } + + @Override + public Config create(String appId, String namespace) { + return null; + } + + @Override + public ConfigFile createConfigFile(String namespace, ConfigFileFormat configFileFormat) { + return new StaticConfigFile(null, namespace, configFileFormat, content); + } + + @Override + public ConfigFile createConfigFile(String appId, String namespace, + ConfigFileFormat configFileFormat) { + return new StaticConfigFile(appId, namespace, configFileFormat, content); + } + } + + private static class StaticConfigFile implements ConfigFile { + + private final String appId; + private final String namespace; + private final ConfigFileFormat format; + private final String content; + + private StaticConfigFile(String appId, String namespace, ConfigFileFormat format, String content) { + this.appId = appId; + this.namespace = namespace; + this.format = format; + this.content = content; + } + + @Override + public String getContent() { + return content; + } + + @Override + public boolean hasContent() { + return content != null && !content.isEmpty(); + } + + @Override + public String getAppId() { + return appId; + } + + @Override + public String getNamespace() { + return namespace; + } + + @Override + public ConfigFileFormat getConfigFileFormat() { + return format; + } + + @Override + public void addChangeListener(ConfigFileChangeListener listener) { + } + + @Override + public boolean removeChangeListener(ConfigFileChangeListener listener) { + return false; + } + + @Override + public ConfigSourceType getSourceType() { + return ConfigSourceType.REMOTE; + } + } +} diff --git a/pom.xml b/pom.xml index f1061ce2..4ea6a708 100644 --- a/pom.xml +++ b/pom.xml @@ -98,6 +98,11 @@ apollo-client ${project.version} + + com.ctrip.framework.apollo + apollo-mockserver + ${project.version} + org.slf4j @@ -467,4 +472,4 @@ ${snapshots.repo} - \ No newline at end of file + From 9347c2eb180b69ff8b1f4e98deba4a247e8e8395 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 11:21:45 +0800 Subject: [PATCH 11/21] fix: deduplicate config listeners by identity (#121) --- CHANGES.md | 1 + .../apollo/internals/AbstractConfig.java | 19 ++- .../apollo/internals/AbstractConfigTest.java | 141 +++++++++++++++++- 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 00d7bcdd..26b56795 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ Apollo Java 2.5.0 * [Feature Added a new feature to get instance count by namespace.](https://github.com/apolloconfig/apollo-java/pull/103) * [Feature Support retry in open api client.](https://github.com/apolloconfig/apollo-java/pull/105) * [Support Spring Boot 4.0 bootstrap context package relocation for apollo-client-config-data](https://github.com/apolloconfig/apollo-java/pull/115) +* [Fix change listener de-duplication by identity to avoid stale property names cache in Spring Cloud bootstrap dual-context initialization](https://github.com/apolloconfig/apollo-java/pull/121) * [Test Overhaul automated compatibility coverage across API/Spring/Spring Boot scenarios](https://github.com/apolloconfig/apollo-java/pull/123) ------------------ diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java index 3afddcbe..196a6079 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/internals/AbstractConfig.java @@ -56,8 +56,10 @@ public abstract class AbstractConfig implements Config { protected static final ExecutorService m_executorService; private final List m_listeners = Lists.newCopyOnWriteArrayList(); - private final Map> m_interestedKeys = Maps.newConcurrentMap(); - private final Map> m_interestedKeyPrefixes = Maps.newConcurrentMap(); + private final Map> m_interestedKeys = + Collections.synchronizedMap(new IdentityHashMap<>()); + private final Map> m_interestedKeyPrefixes = + Collections.synchronizedMap(new IdentityHashMap<>()); private final ConfigUtil m_configUtil; private volatile Cache m_integerCache; private volatile Cache m_longCache; @@ -99,7 +101,7 @@ public void addChangeListener(ConfigChangeListener listener, Set interes @Override public void addChangeListener(ConfigChangeListener listener, Set interestedKeys, Set interestedKeyPrefixes) { - if (!m_listeners.contains(listener)) { + if (!containsListenerInstance(listener)) { m_listeners.add(listener); if (interestedKeys != null && !interestedKeys.isEmpty()) { m_interestedKeys.put(listener, Sets.newHashSet(interestedKeys)); @@ -114,7 +116,7 @@ public void addChangeListener(ConfigChangeListener listener, Set interes public boolean removeChangeListener(ConfigChangeListener listener) { m_interestedKeys.remove(listener); m_interestedKeyPrefixes.remove(listener); - return m_listeners.remove(listener); + return m_listeners.removeIf(addedListener -> addedListener == listener); } @Override @@ -608,4 +610,13 @@ List calcPropertyChanges(String appId, String namespace, Propertie return changes; } + + private boolean containsListenerInstance(ConfigChangeListener listener) { + for (ConfigChangeListener configChangeListener : m_listeners) { + if (configChangeListener == listener) { + return true; + } + } + return false; + } } diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java index 32499ae5..0f6494d0 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/internals/AbstractConfigTest.java @@ -17,6 +17,9 @@ package com.ctrip.framework.apollo.internals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.spy; @@ -28,6 +31,7 @@ import com.ctrip.framework.apollo.enums.PropertyChangeType; import com.ctrip.framework.apollo.model.ConfigChange; import com.ctrip.framework.apollo.model.ConfigChangeEvent; +import com.ctrip.framework.apollo.spring.config.CachedCompositePropertySource; import com.google.common.util.concurrent.SettableFuture; import java.util.Collections; import java.util.HashMap; @@ -122,6 +126,88 @@ public void onChange(ConfigChangeEvent changeEvent) { verify(configChangeListener2, times(1)).onChange(eq(configChangeEvent)); } + @Test + public void testFireConfigChange_twoCachedCompositePropertySourcesWithSameName_shouldBothBeNotified() + throws ExecutionException, InterruptedException, TimeoutException { + AbstractConfig abstractConfig = new ErrorConfig(); + final String namespace = "app-namespace-listener-equals"; + final String key = "great-key"; + + ListenerPair listenerPair = createSameNameListeners(); + final CountingCachedCompositePropertySource listener1 = listenerPair.listener1; + final CountingCachedCompositePropertySource listener2 = listenerPair.listener2; + + abstractConfig.addChangeListener(listener1, Collections.singleton(key)); + abstractConfig.addChangeListener(listener2, Collections.singleton(key)); + + Map changes = createSingleKeyChanges(namespace, key); + + abstractConfig.fireConfigChange(someAppId, namespace, changes); + + assertEquals(Collections.singleton(key), listener1.awaitChange(500, TimeUnit.MILLISECONDS).changedKeys()); + assertEquals(Collections.singleton(key), listener2.awaitChange(500, TimeUnit.MILLISECONDS).changedKeys()); + + assertEquals(1, listener1.changeCount.get()); + assertEquals(1, listener2.changeCount.get()); + } + + @Test + public void testFireConfigChange_twoCachedCompositePropertySourcesWithSameNameAndDifferentInterestedKeys_shouldNotConflict() + throws ExecutionException, InterruptedException, TimeoutException { + AbstractConfig abstractConfig = new ErrorConfig(); + final String namespace = "app-namespace-listener-interested-keys"; + final String key1 = "great-key-1"; + final String key2 = "great-key-2"; + + ListenerPair listenerPair = createSameNameListeners(); + final CountingCachedCompositePropertySource listener1 = listenerPair.listener1; + final CountingCachedCompositePropertySource listener2 = listenerPair.listener2; + + abstractConfig.addChangeListener(listener1, Collections.singleton(key1)); + abstractConfig.addChangeListener(listener2, Collections.singleton(key2)); + + abstractConfig.fireConfigChange(someAppId, namespace, createSingleKeyChanges(namespace, key1)); + + assertEquals(Collections.singleton(key1), listener1.awaitChange(500, TimeUnit.MILLISECONDS).changedKeys()); + assertThrows(TimeoutException.class, () -> listener2.awaitChange(200, TimeUnit.MILLISECONDS)); + + listener1.resetChangeFuture(); + listener2.resetChangeFuture(); + + abstractConfig.fireConfigChange(someAppId, namespace, createSingleKeyChanges(namespace, key2)); + + assertThrows(TimeoutException.class, () -> listener1.awaitChange(200, TimeUnit.MILLISECONDS)); + assertEquals(Collections.singleton(key2), listener2.awaitChange(500, TimeUnit.MILLISECONDS).changedKeys()); + + assertEquals(1, listener1.changeCount.get()); + assertEquals(1, listener2.changeCount.get()); + } + + @Test + public void testRemoveChangeListener_twoCachedCompositePropertySourcesWithSameName_shouldRemoveSpecifiedInstance() + throws ExecutionException, InterruptedException, TimeoutException { + AbstractConfig abstractConfig = new ErrorConfig(); + final String namespace = "app-namespace-listener-remove"; + final String key = "great-key"; + + ListenerPair listenerPair = createSameNameListeners(); + final CountingCachedCompositePropertySource listener1 = listenerPair.listener1; + final CountingCachedCompositePropertySource listener2 = listenerPair.listener2; + + abstractConfig.addChangeListener(listener1, Collections.singleton(key)); + abstractConfig.addChangeListener(listener2, Collections.singleton(key)); + + assertTrue(abstractConfig.removeChangeListener(listener2)); + + abstractConfig.fireConfigChange(someAppId, namespace, createSingleKeyChanges(namespace, key)); + + assertEquals(Collections.singleton(key), listener1.awaitChange(500, TimeUnit.MILLISECONDS).changedKeys()); + assertThrows(TimeoutException.class, () -> listener2.awaitChange(200, TimeUnit.MILLISECONDS)); + + assertEquals(1, listener1.changeCount.get()); + assertEquals(0, listener2.changeCount.get()); + } + @Test public void testFireConfigChange_changes_notify_once() throws ExecutionException, InterruptedException, TimeoutException { @@ -188,4 +274,57 @@ public ConfigSourceType getSourceType() { throw new UnsupportedOperationException(); } } -} \ No newline at end of file + + private static class CountingCachedCompositePropertySource extends CachedCompositePropertySource { + private final AtomicInteger changeCount = new AtomicInteger(); + private volatile SettableFuture changeFuture = SettableFuture.create(); + + private CountingCachedCompositePropertySource(String name) { + super(name); + } + + @Override + public void onChange(ConfigChangeEvent changeEvent) { + changeCount.incrementAndGet(); + changeFuture.set(changeEvent); + super.onChange(changeEvent); + } + + private void resetChangeFuture() { + changeFuture = SettableFuture.create(); + } + + private ConfigChangeEvent awaitChange(long timeout, TimeUnit unit) + throws ExecutionException, InterruptedException, TimeoutException { + return changeFuture.get(timeout, unit); + } + } + + private static ListenerPair createSameNameListeners() { + CountingCachedCompositePropertySource listener1 = + new CountingCachedCompositePropertySource("ApolloBootstrapPropertySources"); + CountingCachedCompositePropertySource listener2 = + new CountingCachedCompositePropertySource("ApolloBootstrapPropertySources"); + assertNotSame(listener1, listener2); + assertEquals(listener1, listener2); + return new ListenerPair(listener1, listener2); + } + + private static class ListenerPair { + private final CountingCachedCompositePropertySource listener1; + private final CountingCachedCompositePropertySource listener2; + + private ListenerPair(CountingCachedCompositePropertySource listener1, + CountingCachedCompositePropertySource listener2) { + this.listener1 = listener1; + this.listener2 = listener2; + } + } + + private static Map createSingleKeyChanges(String namespace, String key) { + Map changes = new HashMap<>(); + changes.put(key, + new ConfigChange(someAppId, namespace, key, "old-value", "new-value", PropertyChangeType.MODIFIED)); + return changes; + } +} From 8e8191956dca481e73ac6b1391251f4c9e0eb505 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 12:45:33 +0800 Subject: [PATCH 12/21] ci: externalize release workflow helper scripts (#128) * ci: externalize release workflow helper scripts * ci: fix release workflow review findings * ci: improve sonatype workflow error reporting * ci: surface repository list api failures * ci: harden sonatype publish status handling --- .github/scripts/github_actions_utils.py | 27 ++ .../scripts/release_extract_upload_context.py | 59 ++++ .../release_resolve_repository_context.py | 160 ++++++++++ .github/scripts/release_write_summary.py | 68 +++++ .github/scripts/sonatype_publish.py | 285 ++++++++++++++++++ .github/workflows/release.yml | 92 ++++-- .github/workflows/sonatype-publish.yml | 85 ++++++ 7 files changed, 758 insertions(+), 18 deletions(-) create mode 100644 .github/scripts/github_actions_utils.py create mode 100644 .github/scripts/release_extract_upload_context.py create mode 100644 .github/scripts/release_resolve_repository_context.py create mode 100644 .github/scripts/release_write_summary.py create mode 100644 .github/scripts/sonatype_publish.py create mode 100644 .github/workflows/sonatype-publish.yml diff --git a/.github/scripts/github_actions_utils.py b/.github/scripts/github_actions_utils.py new file mode 100644 index 00000000..8b90c6c4 --- /dev/null +++ b/.github/scripts/github_actions_utils.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Shared helpers for GitHub Actions scripts.""" + +from __future__ import annotations + +import os + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "").strip() + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"{key}={value}\n") diff --git a/.github/scripts/release_extract_upload_context.py b/.github/scripts/release_extract_upload_context.py new file mode 100644 index 00000000..52981515 --- /dev/null +++ b/.github/scripts/release_extract_upload_context.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Extract uploaded artifact URLs from Maven deploy logs.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +from github_actions_utils import write_output + + +def main() -> int: + repository_name = os.environ.get("TARGET_REPOSITORY", "").strip() + log_file = Path(os.environ.get("DEPLOY_LOG", "maven-deploy.log")) + context_file = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) + + log_text = log_file.read_text(encoding="utf-8") + pattern = re.compile(r"Uploaded to (\S+):\s+(\S+)") + + uploaded_urls: list[str] = [] + for target_repo, url in pattern.findall(log_text): + if target_repo == repository_name: + uploaded_urls.append(url) + + deduped_urls = sorted(set(uploaded_urls)) + jar_urls = [url for url in deduped_urls if url.endswith(".jar")] + pom_urls = [url for url in deduped_urls if url.endswith(".pom")] + + payload = { + "target_repository": repository_name, + "uploaded_urls": deduped_urls, + "jar_urls": jar_urls, + "pom_urls": pom_urls, + } + context_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + write_output("uploaded_urls_count", str(len(deduped_urls))) + write_output("jar_urls_count", str(len(jar_urls))) + write_output("pom_urls_count", str(len(pom_urls))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py new file mode 100644 index 00000000..226bb99a --- /dev/null +++ b/.github/scripts/release_resolve_repository_context.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Resolve Sonatype repository context for release deployments.""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +from github_actions_utils import write_output + +OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" + + +def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[str, Any]]: + request = urllib.request.Request(url=url, method="GET", headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + if not body: + return response.status, {} + try: + return response.status, json.loads(body) + except json.JSONDecodeError: + return response.status, {"raw": body} + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8") + try: + payload = json.loads(body) if body else {} + except json.JSONDecodeError: + payload = {"raw": body} + payload.setdefault("error", f"HTTP {error.code}") + return error.code, payload + except Exception as error: # noqa: BLE001 + return None, {"error": str(error)} + + +def main() -> int: + target_repository = os.environ.get("TARGET_REPOSITORY", "").strip() + namespace = os.environ.get("TARGET_NAMESPACE", "").strip() + username = os.environ.get("MAVEN_USERNAME", "") + password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") + context_path = Path( + os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") + ) + + context: dict[str, Any] = { + "target_repository": target_repository, + "namespace": namespace, + "status": "not_applicable", + "reason": "repository input is not releases", + "repository_key": "", + "portal_deployment_id": "", + "search_candidates": [], + } + + if target_repository == "releases": + if not username or not password: + context["status"] = "manual_required" + context["reason"] = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN" + else: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + searches = [ + ("open", "client"), + ("closed", "client"), + ("open", "any"), + ("closed", "any"), + ] + selected: dict[str, Any] | None = None + last_error = "" + + for state, ip in searches: + url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"profile_id={urllib.parse.quote(namespace)}" + f"&state={urllib.parse.quote(state)}" + f"&ip={urllib.parse.quote(ip)}" + ) + status, payload = request_json(url, headers) + if status is None: + last_error = payload.get("error", "unknown error") + context["search_candidates"].append( + { + "state": state, + "ip": ip, + "status": None, + "count": 0, + "error": last_error, + } + ) + continue + + if status < 200 or status >= 300: + http_error = payload.get("error", f"HTTP {status}") + last_error = http_error + context["search_candidates"].append( + { + "state": state, + "ip": ip, + "status": status, + "count": 0, + "error": http_error, + } + ) + continue + + repositories = ( + payload.get("repositories", []) if isinstance(payload, dict) else [] + ) + context["search_candidates"].append( + {"state": state, "ip": ip, "status": status, "count": len(repositories)} + ) + if repositories: + selected = repositories[0] + break + + if selected: + context["status"] = "resolved" + context["reason"] = "" + context["repository_key"] = selected.get("key", "") or "" + context["portal_deployment_id"] = ( + selected.get("portal_deployment_id", "") or "" + ) + else: + context["status"] = "manual_required" + context["reason"] = last_error or "No staging repository key found" + + context_path.write_text(json.dumps(context, indent=2) + "\n", encoding="utf-8") + write_output("repository_key", context.get("repository_key", "")) + write_output("portal_deployment_id", context.get("portal_deployment_id", "")) + write_output("status", context.get("status", "")) + write_output("reason", context.get("reason", "")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_write_summary.py b/.github/scripts/release_write_summary.py new file mode 100644 index 00000000..4558e5db --- /dev/null +++ b/.github/scripts/release_write_summary.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Write release publish context summary for GitHub Actions.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +def read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def main() -> int: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "").strip() + if not summary_path: + return 0 + + deploy_path = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) + repository_path = Path( + os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") + ) + + deploy = read_json(deploy_path) + repository = read_json(repository_path) + + lines = [ + "## Publish Context", + "", + f"- target repository: {deploy.get('target_repository', '')}", + f"- uploaded URLs: {len(deploy.get('uploaded_urls', []))}", + f"- jar URLs: {len(deploy.get('jar_urls', []))}", + f"- pom URLs: {len(deploy.get('pom_urls', []))}", + f"- staging key status: {repository.get('status', '')}", + f"- repository_key: {repository.get('repository_key', '')}", + f"- portal_deployment_id: {repository.get('portal_deployment_id', '')}", + ] + reason = repository.get("reason", "") + if reason: + lines.append(f"- reason: {reason}") + + with open(summary_path, "a", encoding="utf-8") as output: + output.write("\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py new file mode 100644 index 00000000..559c57d5 --- /dev/null +++ b/.github/scripts/sonatype_publish.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +"""Trigger and monitor Sonatype portal publish flow.""" + +from __future__ import annotations + +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from github_actions_utils import write_output + +OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" +PORTAL_BASE = "https://central.sonatype.com" + + +def request_json( + method: str, + url: str, + headers: dict[str, str], +) -> tuple[int | None, dict[str, Any]]: + request = urllib.request.Request(url=url, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + if not body: + return response.status, {} + try: + return response.status, json.loads(body) + except json.JSONDecodeError: + return response.status, {"raw": body} + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8") + try: + payload = json.loads(body) if body else {} + except json.JSONDecodeError: + payload = {"raw": body} + payload.setdefault("error", f"HTTP {error.code}") + return error.code, payload + except Exception as error: # noqa: BLE001 + return None, {"error": str(error)} + + +def extract_deployment_state(payload: dict[str, Any]) -> str: + for key in ("deploymentState", "deployment_state", "state"): + value = payload.get(key) + if isinstance(value, str): + return value + return "unknown" + + +def to_int(value: str, default: int) -> int: + try: + return int(value) + except ValueError: + return default + + +def main() -> int: + namespace = os.environ.get("INPUT_NAMESPACE", "com.ctrip.framework.apollo").strip() + repository_key = os.environ.get("INPUT_REPOSITORY_KEY", "").strip() + timeout_minutes = to_int(os.environ.get("INPUT_TIMEOUT_MINUTES", "60"), 60) + mode = os.environ.get("INPUT_MODE", "portal_api").strip().lower() + + username = os.environ.get("MAVEN_USERNAME", "") + password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") + + result = "manual_required" + final_state = "unknown" + deployment_id = "" + deployment_url = "" + reason = "" + + if not username or not password: + reason = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN secrets" + else: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + if not repository_key: + searches = ("open", "closed") + search_errors: list[str] = [] + successful_search = False + for state in searches: + search_url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"ip=any&profile_id={urllib.parse.quote(namespace)}" + f"&state={urllib.parse.quote(state)}" + ) + search_status, payload = request_json("GET", search_url, headers) + if search_status is None or search_status < 200 or search_status >= 300: + search_error = payload.get("error") if isinstance(payload, dict) else "" + if not search_error: + search_error = ( + f"HTTP {search_status}" + if search_status is not None + else "HTTP unknown" + ) + search_errors.append( + f"Repository search failed ({state}): {search_error}" + ) + continue + + successful_search = True + repositories = payload.get("repositories", []) if isinstance(payload, dict) else [] + if repositories: + repository_key = repositories[0].get("key", "") or "" + break + + if not repository_key: + if search_errors and not successful_search: + reason = "; ".join(search_errors) + else: + reason = "No staging repository key found" + else: + upload_url = ( + f"{OSSRH_BASE}/manual/upload/repository/{urllib.parse.quote(repository_key)}" + f"?publishing_type={urllib.parse.quote(mode)}" + ) + upload_status, upload_payload = request_json("POST", upload_url, headers) + if upload_status is None: + reason = f"Upload API failed: {upload_payload.get('error', 'unknown error')}" + elif upload_status >= 400: + upload_error = upload_payload.get("error") + if not upload_error: + upload_error = f"HTTP {upload_status}" + reason = ( + f"Upload API failed: {upload_error}" + ) + + if not reason: + list_url = ( + f"{OSSRH_BASE}/manual/search/repositories?" + f"ip=any&profile_id={urllib.parse.quote(namespace)}" + ) + list_status, list_payload = request_json("GET", list_url, headers) + if list_status is None or list_status < 200 or list_status >= 300: + list_error = list_payload.get("error") if isinstance(list_payload, dict) else "" + if not list_error: + list_error = ( + f"HTTP {list_status}" + if list_status is not None + else "HTTP unknown" + ) + reason = f"Repository list API failed after upload: {list_error}" + else: + repositories = ( + list_payload.get("repositories", []) + if isinstance(list_payload, dict) + else [] + ) + for item in repositories: + if item.get("key") == repository_key and item.get("portal_deployment_id"): + deployment_id = item.get("portal_deployment_id") + break + + if deployment_id: + deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" + publish_triggered = False + deadline = time.time() + timeout_minutes * 60 + + while time.time() <= deadline: + status_url = ( + f"{PORTAL_BASE}/api/v1/publisher/status?" + f"id={urllib.parse.quote(deployment_id)}" + ) + poll_status, status_payload = request_json( + "POST", + status_url, + headers, + ) + if poll_status is None or poll_status < 200 or poll_status >= 300: + poll_error = ( + status_payload.get("error") + if isinstance(status_payload, dict) + else "" + ) + if not poll_error: + poll_error = ( + f"HTTP {poll_status}" + if poll_status is not None + else "HTTP unknown" + ) + reason = f"Status polling API failed: {poll_error}" + break + + final_state = extract_deployment_state(status_payload) + + if final_state == "PUBLISHED": + result = "published" + reason = "" + break + + if final_state in {"FAILED", "BROKEN", "ERROR"}: + reason = f"Deployment entered terminal state: {final_state}" + break + + if ( + mode == "portal_api" + and final_state == "VALIDATED" + and not publish_triggered + ): + publish_url = ( + f"{PORTAL_BASE}/api/v1/publisher/deployment/" + f"{urllib.parse.quote(deployment_id)}" + ) + publish_status, publish_payload = request_json( + "POST", + publish_url, + headers, + ) + if publish_status is None or publish_status >= 400: + publish_error = publish_payload.get("error") + if not publish_error: + publish_error = ( + f"HTTP {publish_status}" + if publish_status is not None + else "HTTP unknown" + ) + reason = f"Publish API failed: {publish_error}" + break + publish_triggered = True + + if mode == "user_managed" and final_state == "VALIDATED": + reason = "Mode user_managed requires manual publish in portal" + break + + time.sleep(10) + + if result != "published" and not reason: + reason = ( + "Timed out waiting for deployment status. " + f"Latest state={final_state}" + ) + else: + reason = "No portal deployment id found for repository" + + if result != "published" and not reason: + reason = "Automatic publish did not complete" + + write_output("result", result) + write_output("repository_key", repository_key) + write_output("deployment_id", deployment_id) + write_output("deployment_url", deployment_url) + write_output("final_state", final_state) + write_output("reason", reason) + + display_key = repository_key or "-" + display_deployment = deployment_id or "-" + display_url = deployment_url or "-" + print( + "SONATYPE_RESULT " + f"result={result} " + f"repository_key={display_key} " + f"deployment_id={display_deployment} " + f"final_state={final_state} " + f"deployment_url={display_url}" + ) + if reason: + print(f"SONATYPE_REASON {reason}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd02a1ac..5e466865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven +# This workflow publishes Apollo Java artifacts. name: publish sdks @@ -25,24 +24,81 @@ on: description: 'Maven Repository(snapshots or releases)' required: true default: 'snapshots' + namespace: + description: 'Sonatype namespace used to search staging repositories' + required: true + default: 'com.ctrip.framework.apollo' jobs: publish: runs-on: ubuntu-latest + outputs: + repository_key: ${{ steps.repository_context.outputs.repository_key }} + portal_deployment_id: ${{ steps.repository_context.outputs.portal_deployment_id }} + uploaded_urls_count: ${{ steps.upload_context.outputs.uploaded_urls_count }} + jar_urls_count: ${{ steps.upload_context.outputs.jar_urls_count }} + pom_urls_count: ${{ steps.upload_context.outputs.pom_urls_count }} steps: - - uses: actions/checkout@v2 - - name: Set up Maven Central Repository - uses: actions/setup-java@v1 - with: - java-version: 8 - server-id: ${{ github.event.inputs.repository }} - server-username: MAVEN_USERNAME - server-password: MAVEN_CENTRAL_TOKEN - gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - - name: Publish to Apache Maven Central - run: mvn clean deploy -DskipTests=true -Prelease "-Dreleases.repo=https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" "-Dsnapshots.repo=https://central.sonatype.com/repository/maven-snapshots/" - env: - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + - uses: actions/checkout@v4 + + - name: Set up Maven Central Repository + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + server-id: ${{ github.event.inputs.repository }} + server-username: MAVEN_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Publish to Apache Maven Central + run: | + set -eo pipefail + mvn clean deploy -DskipTests=true -Prelease \ + "-Dreleases.repo=https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" \ + "-Dsnapshots.repo=https://central.sonatype.com/repository/maven-snapshots/" \ + 2>&1 | tee maven-deploy.log + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Extract uploaded artifact URLs + id: upload_context + env: + TARGET_REPOSITORY: ${{ github.event.inputs.repository }} + DEPLOY_LOG: maven-deploy.log + DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json + run: | + python3 .github/scripts/release_extract_upload_context.py + + - name: Resolve staging repository key + id: repository_context + env: + TARGET_REPOSITORY: ${{ github.event.inputs.repository }} + TARGET_NAMESPACE: ${{ github.event.inputs.namespace }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + REPOSITORY_CONTEXT_FILE: repository-context.json + run: | + python3 .github/scripts/release_resolve_repository_context.py + + - name: Upload release publish context + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-deploy-context + if-no-files-found: warn + path: | + maven-deploy.log + deploy-artifacts.json + repository-context.json + + - name: Publish summary + if: always() + env: + DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json + REPOSITORY_CONTEXT_FILE: repository-context.json + run: | + python3 .github/scripts/release_write_summary.py diff --git a/.github/workflows/sonatype-publish.yml b/.github/workflows/sonatype-publish.yml new file mode 100644 index 00000000..a96cd600 --- /dev/null +++ b/.github/workflows/sonatype-publish.yml @@ -0,0 +1,85 @@ +# +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# + +name: sonatype publish + +on: + workflow_dispatch: + inputs: + namespace: + description: 'Sonatype namespace (e.g. com.ctrip.framework.apollo)' + required: true + default: 'com.ctrip.framework.apollo' + repository_key: + description: 'Optional staging repository key; leave blank to auto-detect' + required: false + default: '' + timeout_minutes: + description: 'Max minutes to wait for deployment status polling' + required: true + default: '60' + mode: + description: 'manual upload mode: portal_api|automatic|user_managed' + required: true + default: 'portal_api' + type: choice + options: + - portal_api + - automatic + - user_managed + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Publish via Sonatype API + id: publish + env: + INPUT_NAMESPACE: ${{ github.event.inputs.namespace }} + INPUT_REPOSITORY_KEY: ${{ github.event.inputs.repository_key }} + INPUT_TIMEOUT_MINUTES: ${{ github.event.inputs.timeout_minutes }} + INPUT_MODE: ${{ github.event.inputs.mode }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + run: | + python3 .github/scripts/sonatype_publish.py + + - name: Sonatype publish summary + if: always() + env: + PUBLISH_RESULT: ${{ steps.publish.outputs.result }} + PUBLISH_REPOSITORY_KEY: ${{ steps.publish.outputs.repository_key }} + PUBLISH_DEPLOYMENT_ID: ${{ steps.publish.outputs.deployment_id }} + PUBLISH_FINAL_STATE: ${{ steps.publish.outputs.final_state }} + PUBLISH_DEPLOYMENT_URL: ${{ steps.publish.outputs.deployment_url }} + PUBLISH_REASON: ${{ steps.publish.outputs.reason }} + run: | + { + echo "## Sonatype Publish Result" + echo "" + echo "- result: ${PUBLISH_RESULT}" + echo "- repository_key: ${PUBLISH_REPOSITORY_KEY}" + echo "- deployment_id: ${PUBLISH_DEPLOYMENT_ID}" + echo "- final_state: ${PUBLISH_FINAL_STATE}" + echo "- deployment_url: ${PUBLISH_DEPLOYMENT_URL}" + if [ -n "${PUBLISH_REASON}" ]; then + echo "- reason: ${PUBLISH_REASON}" + echo "" + echo "Manual fallback: open https://central.sonatype.com/publishing/deployments and complete publish by deployment id." + fi + } >> "$GITHUB_STEP_SUMMARY" From bfa8b6eea9f528ff1030513d2e91c4464de609e4 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 14:13:17 +0800 Subject: [PATCH 13/21] chore: bump version to 2.5.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4ea6a708..70ff7174 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ - 2.5.0-SNAPSHOT + 2.5.0 1.8 UTF-8 2.7.18 From e610057719f0a2b0066930f3b19629cd79af7b11 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 16:09:49 +0800 Subject: [PATCH 14/21] ci: migrate release publishing to central plugin --- .github/scripts/github_actions_utils.py | 27 -- .../scripts/release_extract_upload_context.py | 59 ---- .../release_resolve_repository_context.py | 160 ---------- .github/scripts/release_write_summary.py | 68 ----- .github/scripts/sonatype_publish.py | 285 ------------------ .github/workflows/release.yml | 65 +--- .github/workflows/sonatype-publish.yml | 85 ------ pom.xml | 22 +- 8 files changed, 15 insertions(+), 756 deletions(-) delete mode 100644 .github/scripts/github_actions_utils.py delete mode 100644 .github/scripts/release_extract_upload_context.py delete mode 100644 .github/scripts/release_resolve_repository_context.py delete mode 100644 .github/scripts/release_write_summary.py delete mode 100644 .github/scripts/sonatype_publish.py delete mode 100644 .github/workflows/sonatype-publish.yml diff --git a/.github/scripts/github_actions_utils.py b/.github/scripts/github_actions_utils.py deleted file mode 100644 index 8b90c6c4..00000000 --- a/.github/scripts/github_actions_utils.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -"""Shared helpers for GitHub Actions scripts.""" - -from __future__ import annotations - -import os - - -def write_output(key: str, value: str) -> None: - output_path = os.environ.get("GITHUB_OUTPUT", "").strip() - if not output_path: - return - with open(output_path, "a", encoding="utf-8") as output: - output.write(f"{key}={value}\n") diff --git a/.github/scripts/release_extract_upload_context.py b/.github/scripts/release_extract_upload_context.py deleted file mode 100644 index 52981515..00000000 --- a/.github/scripts/release_extract_upload_context.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -"""Extract uploaded artifact URLs from Maven deploy logs.""" - -from __future__ import annotations - -import json -import os -import re -from pathlib import Path - -from github_actions_utils import write_output - - -def main() -> int: - repository_name = os.environ.get("TARGET_REPOSITORY", "").strip() - log_file = Path(os.environ.get("DEPLOY_LOG", "maven-deploy.log")) - context_file = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) - - log_text = log_file.read_text(encoding="utf-8") - pattern = re.compile(r"Uploaded to (\S+):\s+(\S+)") - - uploaded_urls: list[str] = [] - for target_repo, url in pattern.findall(log_text): - if target_repo == repository_name: - uploaded_urls.append(url) - - deduped_urls = sorted(set(uploaded_urls)) - jar_urls = [url for url in deduped_urls if url.endswith(".jar")] - pom_urls = [url for url in deduped_urls if url.endswith(".pom")] - - payload = { - "target_repository": repository_name, - "uploaded_urls": deduped_urls, - "jar_urls": jar_urls, - "pom_urls": pom_urls, - } - context_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - write_output("uploaded_urls_count", str(len(deduped_urls))) - write_output("jar_urls_count", str(len(jar_urls))) - write_output("pom_urls_count", str(len(pom_urls))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/scripts/release_resolve_repository_context.py b/.github/scripts/release_resolve_repository_context.py deleted file mode 100644 index 226bb99a..00000000 --- a/.github/scripts/release_resolve_repository_context.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -"""Resolve Sonatype repository context for release deployments.""" - -from __future__ import annotations - -import base64 -import json -import os -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path -from typing import Any - -from github_actions_utils import write_output - -OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" - - -def request_json(url: str, headers: dict[str, str]) -> tuple[int | None, dict[str, Any]]: - request = urllib.request.Request(url=url, method="GET", headers=headers) - try: - with urllib.request.urlopen(request, timeout=30) as response: - body = response.read().decode("utf-8") - if not body: - return response.status, {} - try: - return response.status, json.loads(body) - except json.JSONDecodeError: - return response.status, {"raw": body} - except urllib.error.HTTPError as error: - body = error.read().decode("utf-8") - try: - payload = json.loads(body) if body else {} - except json.JSONDecodeError: - payload = {"raw": body} - payload.setdefault("error", f"HTTP {error.code}") - return error.code, payload - except Exception as error: # noqa: BLE001 - return None, {"error": str(error)} - - -def main() -> int: - target_repository = os.environ.get("TARGET_REPOSITORY", "").strip() - namespace = os.environ.get("TARGET_NAMESPACE", "").strip() - username = os.environ.get("MAVEN_USERNAME", "") - password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") - context_path = Path( - os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") - ) - - context: dict[str, Any] = { - "target_repository": target_repository, - "namespace": namespace, - "status": "not_applicable", - "reason": "repository input is not releases", - "repository_key": "", - "portal_deployment_id": "", - "search_candidates": [], - } - - if target_repository == "releases": - if not username or not password: - context["status"] = "manual_required" - context["reason"] = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN" - else: - token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } - - searches = [ - ("open", "client"), - ("closed", "client"), - ("open", "any"), - ("closed", "any"), - ] - selected: dict[str, Any] | None = None - last_error = "" - - for state, ip in searches: - url = ( - f"{OSSRH_BASE}/manual/search/repositories?" - f"profile_id={urllib.parse.quote(namespace)}" - f"&state={urllib.parse.quote(state)}" - f"&ip={urllib.parse.quote(ip)}" - ) - status, payload = request_json(url, headers) - if status is None: - last_error = payload.get("error", "unknown error") - context["search_candidates"].append( - { - "state": state, - "ip": ip, - "status": None, - "count": 0, - "error": last_error, - } - ) - continue - - if status < 200 or status >= 300: - http_error = payload.get("error", f"HTTP {status}") - last_error = http_error - context["search_candidates"].append( - { - "state": state, - "ip": ip, - "status": status, - "count": 0, - "error": http_error, - } - ) - continue - - repositories = ( - payload.get("repositories", []) if isinstance(payload, dict) else [] - ) - context["search_candidates"].append( - {"state": state, "ip": ip, "status": status, "count": len(repositories)} - ) - if repositories: - selected = repositories[0] - break - - if selected: - context["status"] = "resolved" - context["reason"] = "" - context["repository_key"] = selected.get("key", "") or "" - context["portal_deployment_id"] = ( - selected.get("portal_deployment_id", "") or "" - ) - else: - context["status"] = "manual_required" - context["reason"] = last_error or "No staging repository key found" - - context_path.write_text(json.dumps(context, indent=2) + "\n", encoding="utf-8") - write_output("repository_key", context.get("repository_key", "")) - write_output("portal_deployment_id", context.get("portal_deployment_id", "")) - write_output("status", context.get("status", "")) - write_output("reason", context.get("reason", "")) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/scripts/release_write_summary.py b/.github/scripts/release_write_summary.py deleted file mode 100644 index 4558e5db..00000000 --- a/.github/scripts/release_write_summary.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -"""Write release publish context summary for GitHub Actions.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import Any - - -def read_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - return json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return {} - - -def main() -> int: - summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "").strip() - if not summary_path: - return 0 - - deploy_path = Path(os.environ.get("DEPLOY_ARTIFACTS_FILE", "deploy-artifacts.json")) - repository_path = Path( - os.environ.get("REPOSITORY_CONTEXT_FILE", "repository-context.json") - ) - - deploy = read_json(deploy_path) - repository = read_json(repository_path) - - lines = [ - "## Publish Context", - "", - f"- target repository: {deploy.get('target_repository', '')}", - f"- uploaded URLs: {len(deploy.get('uploaded_urls', []))}", - f"- jar URLs: {len(deploy.get('jar_urls', []))}", - f"- pom URLs: {len(deploy.get('pom_urls', []))}", - f"- staging key status: {repository.get('status', '')}", - f"- repository_key: {repository.get('repository_key', '')}", - f"- portal_deployment_id: {repository.get('portal_deployment_id', '')}", - ] - reason = repository.get("reason", "") - if reason: - lines.append(f"- reason: {reason}") - - with open(summary_path, "a", encoding="utf-8") as output: - output.write("\n".join(lines) + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/scripts/sonatype_publish.py b/.github/scripts/sonatype_publish.py deleted file mode 100644 index 559c57d5..00000000 --- a/.github/scripts/sonatype_publish.py +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -"""Trigger and monitor Sonatype portal publish flow.""" - -from __future__ import annotations - -import base64 -import json -import os -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Any - -from github_actions_utils import write_output - -OSSRH_BASE = "https://ossrh-staging-api.central.sonatype.com" -PORTAL_BASE = "https://central.sonatype.com" - - -def request_json( - method: str, - url: str, - headers: dict[str, str], -) -> tuple[int | None, dict[str, Any]]: - request = urllib.request.Request(url=url, method=method, headers=headers) - try: - with urllib.request.urlopen(request, timeout=30) as response: - body = response.read().decode("utf-8") - if not body: - return response.status, {} - try: - return response.status, json.loads(body) - except json.JSONDecodeError: - return response.status, {"raw": body} - except urllib.error.HTTPError as error: - body = error.read().decode("utf-8") - try: - payload = json.loads(body) if body else {} - except json.JSONDecodeError: - payload = {"raw": body} - payload.setdefault("error", f"HTTP {error.code}") - return error.code, payload - except Exception as error: # noqa: BLE001 - return None, {"error": str(error)} - - -def extract_deployment_state(payload: dict[str, Any]) -> str: - for key in ("deploymentState", "deployment_state", "state"): - value = payload.get(key) - if isinstance(value, str): - return value - return "unknown" - - -def to_int(value: str, default: int) -> int: - try: - return int(value) - except ValueError: - return default - - -def main() -> int: - namespace = os.environ.get("INPUT_NAMESPACE", "com.ctrip.framework.apollo").strip() - repository_key = os.environ.get("INPUT_REPOSITORY_KEY", "").strip() - timeout_minutes = to_int(os.environ.get("INPUT_TIMEOUT_MINUTES", "60"), 60) - mode = os.environ.get("INPUT_MODE", "portal_api").strip().lower() - - username = os.environ.get("MAVEN_USERNAME", "") - password = os.environ.get("MAVEN_CENTRAL_TOKEN", "") - - result = "manual_required" - final_state = "unknown" - deployment_id = "" - deployment_url = "" - reason = "" - - if not username or not password: - reason = "Missing MAVEN_USERNAME/MAVEN_CENTRAL_TOKEN secrets" - else: - token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } - - if not repository_key: - searches = ("open", "closed") - search_errors: list[str] = [] - successful_search = False - for state in searches: - search_url = ( - f"{OSSRH_BASE}/manual/search/repositories?" - f"ip=any&profile_id={urllib.parse.quote(namespace)}" - f"&state={urllib.parse.quote(state)}" - ) - search_status, payload = request_json("GET", search_url, headers) - if search_status is None or search_status < 200 or search_status >= 300: - search_error = payload.get("error") if isinstance(payload, dict) else "" - if not search_error: - search_error = ( - f"HTTP {search_status}" - if search_status is not None - else "HTTP unknown" - ) - search_errors.append( - f"Repository search failed ({state}): {search_error}" - ) - continue - - successful_search = True - repositories = payload.get("repositories", []) if isinstance(payload, dict) else [] - if repositories: - repository_key = repositories[0].get("key", "") or "" - break - - if not repository_key: - if search_errors and not successful_search: - reason = "; ".join(search_errors) - else: - reason = "No staging repository key found" - else: - upload_url = ( - f"{OSSRH_BASE}/manual/upload/repository/{urllib.parse.quote(repository_key)}" - f"?publishing_type={urllib.parse.quote(mode)}" - ) - upload_status, upload_payload = request_json("POST", upload_url, headers) - if upload_status is None: - reason = f"Upload API failed: {upload_payload.get('error', 'unknown error')}" - elif upload_status >= 400: - upload_error = upload_payload.get("error") - if not upload_error: - upload_error = f"HTTP {upload_status}" - reason = ( - f"Upload API failed: {upload_error}" - ) - - if not reason: - list_url = ( - f"{OSSRH_BASE}/manual/search/repositories?" - f"ip=any&profile_id={urllib.parse.quote(namespace)}" - ) - list_status, list_payload = request_json("GET", list_url, headers) - if list_status is None or list_status < 200 or list_status >= 300: - list_error = list_payload.get("error") if isinstance(list_payload, dict) else "" - if not list_error: - list_error = ( - f"HTTP {list_status}" - if list_status is not None - else "HTTP unknown" - ) - reason = f"Repository list API failed after upload: {list_error}" - else: - repositories = ( - list_payload.get("repositories", []) - if isinstance(list_payload, dict) - else [] - ) - for item in repositories: - if item.get("key") == repository_key and item.get("portal_deployment_id"): - deployment_id = item.get("portal_deployment_id") - break - - if deployment_id: - deployment_url = f"{PORTAL_BASE}/publishing/deployments/{deployment_id}" - publish_triggered = False - deadline = time.time() + timeout_minutes * 60 - - while time.time() <= deadline: - status_url = ( - f"{PORTAL_BASE}/api/v1/publisher/status?" - f"id={urllib.parse.quote(deployment_id)}" - ) - poll_status, status_payload = request_json( - "POST", - status_url, - headers, - ) - if poll_status is None or poll_status < 200 or poll_status >= 300: - poll_error = ( - status_payload.get("error") - if isinstance(status_payload, dict) - else "" - ) - if not poll_error: - poll_error = ( - f"HTTP {poll_status}" - if poll_status is not None - else "HTTP unknown" - ) - reason = f"Status polling API failed: {poll_error}" - break - - final_state = extract_deployment_state(status_payload) - - if final_state == "PUBLISHED": - result = "published" - reason = "" - break - - if final_state in {"FAILED", "BROKEN", "ERROR"}: - reason = f"Deployment entered terminal state: {final_state}" - break - - if ( - mode == "portal_api" - and final_state == "VALIDATED" - and not publish_triggered - ): - publish_url = ( - f"{PORTAL_BASE}/api/v1/publisher/deployment/" - f"{urllib.parse.quote(deployment_id)}" - ) - publish_status, publish_payload = request_json( - "POST", - publish_url, - headers, - ) - if publish_status is None or publish_status >= 400: - publish_error = publish_payload.get("error") - if not publish_error: - publish_error = ( - f"HTTP {publish_status}" - if publish_status is not None - else "HTTP unknown" - ) - reason = f"Publish API failed: {publish_error}" - break - publish_triggered = True - - if mode == "user_managed" and final_state == "VALIDATED": - reason = "Mode user_managed requires manual publish in portal" - break - - time.sleep(10) - - if result != "published" and not reason: - reason = ( - "Timed out waiting for deployment status. " - f"Latest state={final_state}" - ) - else: - reason = "No portal deployment id found for repository" - - if result != "published" and not reason: - reason = "Automatic publish did not complete" - - write_output("result", result) - write_output("repository_key", repository_key) - write_output("deployment_id", deployment_id) - write_output("deployment_url", deployment_url) - write_output("final_state", final_state) - write_output("reason", reason) - - display_key = repository_key or "-" - display_deployment = deployment_id or "-" - display_url = deployment_url or "-" - print( - "SONATYPE_RESULT " - f"result={result} " - f"repository_key={display_key} " - f"deployment_id={display_deployment} " - f"final_state={final_state} " - f"deployment_url={display_url}" - ) - if reason: - print(f"SONATYPE_REASON {reason}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e466865..e750b8ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,86 +19,29 @@ name: publish sdks on: workflow_dispatch: - inputs: - repository: - description: 'Maven Repository(snapshots or releases)' - required: true - default: 'snapshots' - namespace: - description: 'Sonatype namespace used to search staging repositories' - required: true - default: 'com.ctrip.framework.apollo' jobs: publish: runs-on: ubuntu-latest - outputs: - repository_key: ${{ steps.repository_context.outputs.repository_key }} - portal_deployment_id: ${{ steps.repository_context.outputs.portal_deployment_id }} - uploaded_urls_count: ${{ steps.upload_context.outputs.uploaded_urls_count }} - jar_urls_count: ${{ steps.upload_context.outputs.jar_urls_count }} - pom_urls_count: ${{ steps.upload_context.outputs.pom_urls_count }} steps: - uses: actions/checkout@v4 - - name: Set up Maven Central Repository + - name: Set up Maven Central publishing credentials uses: actions/setup-java@v4 with: distribution: temurin java-version: 8 - server-id: ${{ github.event.inputs.repository }} + server-id: central server-username: MAVEN_USERNAME server-password: MAVEN_CENTRAL_TOKEN gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE - - name: Publish to Apache Maven Central + - name: Publish to Sonatype Central run: | set -eo pipefail - mvn clean deploy -DskipTests=true -Prelease \ - "-Dreleases.repo=https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" \ - "-Dsnapshots.repo=https://central.sonatype.com/repository/maven-snapshots/" \ - 2>&1 | tee maven-deploy.log + mvn clean deploy -DskipTests=true -Prelease env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} - - - name: Extract uploaded artifact URLs - id: upload_context - env: - TARGET_REPOSITORY: ${{ github.event.inputs.repository }} - DEPLOY_LOG: maven-deploy.log - DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json - run: | - python3 .github/scripts/release_extract_upload_context.py - - - name: Resolve staging repository key - id: repository_context - env: - TARGET_REPOSITORY: ${{ github.event.inputs.repository }} - TARGET_NAMESPACE: ${{ github.event.inputs.namespace }} - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - REPOSITORY_CONTEXT_FILE: repository-context.json - run: | - python3 .github/scripts/release_resolve_repository_context.py - - - name: Upload release publish context - if: always() - uses: actions/upload-artifact@v4 - with: - name: release-deploy-context - if-no-files-found: warn - path: | - maven-deploy.log - deploy-artifacts.json - repository-context.json - - - name: Publish summary - if: always() - env: - DEPLOY_ARTIFACTS_FILE: deploy-artifacts.json - REPOSITORY_CONTEXT_FILE: repository-context.json - run: | - python3 .github/scripts/release_write_summary.py diff --git a/.github/workflows/sonatype-publish.yml b/.github/workflows/sonatype-publish.yml deleted file mode 100644 index a96cd600..00000000 --- a/.github/workflows/sonatype-publish.yml +++ /dev/null @@ -1,85 +0,0 @@ -# -# Copyright 2026 Apollo Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. -# - -name: sonatype publish - -on: - workflow_dispatch: - inputs: - namespace: - description: 'Sonatype namespace (e.g. com.ctrip.framework.apollo)' - required: true - default: 'com.ctrip.framework.apollo' - repository_key: - description: 'Optional staging repository key; leave blank to auto-detect' - required: false - default: '' - timeout_minutes: - description: 'Max minutes to wait for deployment status polling' - required: true - default: '60' - mode: - description: 'manual upload mode: portal_api|automatic|user_managed' - required: true - default: 'portal_api' - type: choice - options: - - portal_api - - automatic - - user_managed - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Publish via Sonatype API - id: publish - env: - INPUT_NAMESPACE: ${{ github.event.inputs.namespace }} - INPUT_REPOSITORY_KEY: ${{ github.event.inputs.repository_key }} - INPUT_TIMEOUT_MINUTES: ${{ github.event.inputs.timeout_minutes }} - INPUT_MODE: ${{ github.event.inputs.mode }} - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - run: | - python3 .github/scripts/sonatype_publish.py - - - name: Sonatype publish summary - if: always() - env: - PUBLISH_RESULT: ${{ steps.publish.outputs.result }} - PUBLISH_REPOSITORY_KEY: ${{ steps.publish.outputs.repository_key }} - PUBLISH_DEPLOYMENT_ID: ${{ steps.publish.outputs.deployment_id }} - PUBLISH_FINAL_STATE: ${{ steps.publish.outputs.final_state }} - PUBLISH_DEPLOYMENT_URL: ${{ steps.publish.outputs.deployment_url }} - PUBLISH_REASON: ${{ steps.publish.outputs.reason }} - run: | - { - echo "## Sonatype Publish Result" - echo "" - echo "- result: ${PUBLISH_RESULT}" - echo "- repository_key: ${PUBLISH_REPOSITORY_KEY}" - echo "- deployment_id: ${PUBLISH_DEPLOYMENT_ID}" - echo "- final_state: ${PUBLISH_FINAL_STATE}" - echo "- deployment_url: ${PUBLISH_DEPLOYMENT_URL}" - if [ -n "${PUBLISH_REASON}" ]; then - echo "- reason: ${PUBLISH_REASON}" - echo "" - echo "Manual fallback: open https://central.sonatype.com/publishing/deployments and complete publish by deployment id." - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/pom.xml b/pom.xml index 70ff7174..0c35c8a5 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,7 @@ 3.2.2 2.5.2 2.8.2 + 0.10.0 3.4.0 3.0.1 @@ -457,19 +458,18 @@ org.apache.maven.plugins maven-gpg-plugin + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + true + PUBLISHED + + - - - - releases - ${releases.repo} - - - snapshots - ${snapshots.repo} - - From 2223b0113be911f0f86d81d3573561b5055dfffc Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 16:26:24 +0800 Subject: [PATCH 15/21] chore: bump version to 2.6.0-SNAPSHOT --- CHANGES.md | 11 +++-------- changes/changes-2.5.0.md | 17 +++++++++++++++++ pom.xml | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 changes/changes-2.5.0.md diff --git a/CHANGES.md b/CHANGES.md index 26b56795..737a4b00 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,16 +2,11 @@ Changes by Version ================== Release Notes. -Apollo Java 2.5.0 +Apollo Java 2.6.0 ------------------ -* [Feature Provide a new open APl to return the organization list](https://github.com/apolloconfig/apollo-java/pull/102) -* [Feature Added a new feature to get instance count by namespace.](https://github.com/apolloconfig/apollo-java/pull/103) -* [Feature Support retry in open api client.](https://github.com/apolloconfig/apollo-java/pull/105) -* [Support Spring Boot 4.0 bootstrap context package relocation for apollo-client-config-data](https://github.com/apolloconfig/apollo-java/pull/115) -* [Fix change listener de-duplication by identity to avoid stale property names cache in Spring Cloud bootstrap dual-context initialization](https://github.com/apolloconfig/apollo-java/pull/121) -* [Test Overhaul automated compatibility coverage across API/Spring/Spring Boot scenarios](https://github.com/apolloconfig/apollo-java/pull/123) +* ------------------ -All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/5?closed=1) +All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/6?closed=1) diff --git a/changes/changes-2.5.0.md b/changes/changes-2.5.0.md new file mode 100644 index 00000000..26b56795 --- /dev/null +++ b/changes/changes-2.5.0.md @@ -0,0 +1,17 @@ +Changes by Version +================== +Release Notes. + +Apollo Java 2.5.0 + +------------------ + +* [Feature Provide a new open APl to return the organization list](https://github.com/apolloconfig/apollo-java/pull/102) +* [Feature Added a new feature to get instance count by namespace.](https://github.com/apolloconfig/apollo-java/pull/103) +* [Feature Support retry in open api client.](https://github.com/apolloconfig/apollo-java/pull/105) +* [Support Spring Boot 4.0 bootstrap context package relocation for apollo-client-config-data](https://github.com/apolloconfig/apollo-java/pull/115) +* [Fix change listener de-duplication by identity to avoid stale property names cache in Spring Cloud bootstrap dual-context initialization](https://github.com/apolloconfig/apollo-java/pull/121) +* [Test Overhaul automated compatibility coverage across API/Spring/Spring Boot scenarios](https://github.com/apolloconfig/apollo-java/pull/123) + +------------------ +All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/5?closed=1) diff --git a/pom.xml b/pom.xml index 0c35c8a5..69b50c38 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ - 2.5.0 + 2.6.0-SNAPSHOT 1.8 UTF-8 2.7.18 From c9cd490ddf7836c7a252283dbe216883b9e72ece Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 16:42:03 +0800 Subject: [PATCH 16/21] ci: simplify release workflow publish command --- .github/workflows/release.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e750b8ca..84b68892 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,9 +38,7 @@ jobs: gpg-passphrase: MAVEN_GPG_PASSPHRASE - name: Publish to Sonatype Central - run: | - set -eo pipefail - mvn clean deploy -DskipTests=true -Prelease + run: mvn clean deploy -DskipTests=true -Prelease env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} From 5afe313ca21ac07e8b0485291d6eeaa36eb8c271 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Thu, 19 Feb 2026 17:04:06 +0800 Subject: [PATCH 17/21] ci: add mergify merge queue config --- .mergify.yml | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 00000000..ed09b1f7 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,79 @@ +# +# Copyright 2026 Apollo Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. +# +merge_queue: + max_parallel_checks: 1 + +queue_rules: + - name: single-commit + autoqueue: true + batch_size: 1 + merge_method: rebase + queue_conditions: &single_commit_conditions + - "base = main" + - "-draft" + - "-closed" + - "-conflict" + - "#approved-reviews-by >= 1" + - "#changes-requested-reviews-by = 0" + - "#commits = 1" + - "check-success = compile-matrix (8)" + - "check-success = compile-matrix (11)" + - "check-success = compile-matrix (17)" + - "check-success = unit-integration-pr" + - "check-success = compat-api" + - "check-success = compat-spring-spring-3.1.1-jdk8" + - "check-success = compat-spring-spring-6.1-jdk17" + - "check-success = compat-spring-boot-spring-boot-2.7-jdk8" + - "check-success = compat-spring-boot-spring-boot-3.3-jdk17" + - "check-success = compat-spring-boot-spring-boot-4.0-jdk17" + - "check-success = license" + - "check-success = CLAssistant" + merge_conditions: *single_commit_conditions + + - name: multi-commit + autoqueue: true + batch_size: 1 + merge_method: squash + queue_conditions: &multi_commit_conditions + - "base = main" + - "-draft" + - "-closed" + - "-conflict" + - "#approved-reviews-by >= 1" + - "#changes-requested-reviews-by = 0" + - "#commits > 1" + - "check-success = compile-matrix (8)" + - "check-success = compile-matrix (11)" + - "check-success = compile-matrix (17)" + - "check-success = unit-integration-pr" + - "check-success = compat-api" + - "check-success = compat-spring-spring-3.1.1-jdk8" + - "check-success = compat-spring-spring-6.1-jdk17" + - "check-success = compat-spring-boot-spring-boot-2.7-jdk8" + - "check-success = compat-spring-boot-spring-boot-3.3-jdk17" + - "check-success = compat-spring-boot-spring-boot-4.0-jdk17" + - "check-success = license" + - "check-success = CLAssistant" + merge_conditions: *multi_commit_conditions + +pull_request_rules: + - name: notify author when PR has conflicts + conditions: + - "conflict" + - "-closed" + actions: + comment: + message: "@{{author}} This pull request has conflicts with the target branch. Please resolve them and update the branch before merging." From 9ad5f55cae56fc8720e3cf4cce7a7db50e701902 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Fri, 10 Apr 2026 10:14:47 +0800 Subject: [PATCH 18/21] fix: handle nested jar class path fallback Fixes apolloconfig/apollo#5592 --- CHANGES.md | 2 +- .../ConfigDataIntegrationTest.java | 33 ++++- .../apollo/core/utils/ClassLoaderUtil.java | 34 +++-- .../core/utils/ClassLoaderUtilTest.java | 138 +++++++++++++++++- 4 files changed, 189 insertions(+), 18 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 737a4b00..a3a9554e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ Apollo Java 2.6.0 ------------------ -* +* [Fix Apollo client local cache fallback for Spring Boot 3 executable JARs](https://github.com/apolloconfig/apollo-java/pull/136) ------------------ All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/6?closed=1) diff --git a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java index 69bbc8cb..26c33b83 100644 --- a/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java +++ b/apollo-client-config-data/src/test/java/com/ctrip/framework/apollo/config/data/integration/ConfigDataIntegrationTest.java @@ -32,9 +32,11 @@ import com.ctrip.framework.apollo.spi.ConfigFactoryManager; import com.ctrip.framework.apollo.spi.ConfigRegistry; import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener; +import com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer; import com.google.common.collect.Table; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; @@ -80,13 +82,14 @@ public class ConfigDataIntegrationTest { private static final EmbeddedApollo embeddedApollo = new EmbeddedApollo(); private static final ExternalResource apolloStateResource = new ExternalResource() { - private String originalAppId; private String originalEnv; + private Map originalApolloSystemProperties = new HashMap<>(); @Override protected void before() throws Throwable { - originalAppId = System.getProperty("app.id"); + originalApolloSystemProperties = snapshotApolloSystemProperties(); originalEnv = System.getProperty("env"); + clearApolloSystemProperties(); System.setProperty("app.id", TEST_APP_ID); System.setProperty("env", TEST_ENV); resetApolloStaticState(); @@ -99,7 +102,7 @@ protected void after() { } catch (Exception ex) { throw new RuntimeException(ex); } finally { - restoreOrClear("app.id", originalAppId); + restoreApolloSystemProperties(originalApolloSystemProperties); restoreOrClear("env", originalEnv); } } @@ -118,6 +121,9 @@ public void beforeEach() { @After public void afterEach() throws Exception { resetApolloStaticState(); + clearApolloSystemProperties(); + System.setProperty("app.id", TEST_APP_ID); + System.setProperty("env", TEST_ENV); } @Autowired @@ -283,6 +289,27 @@ private static void restoreOrClear(String key, String originalValue) { System.setProperty(key, originalValue); } + private static Map snapshotApolloSystemProperties() { + Map originalProperties = new HashMap<>(); + for (String propertyName : ApolloApplicationContextInitializer.APOLLO_SYSTEM_PROPERTIES) { + originalProperties.put(propertyName, System.getProperty(propertyName)); + } + return originalProperties; + } + + private static void clearApolloSystemProperties() { + for (String propertyName : ApolloApplicationContextInitializer.APOLLO_SYSTEM_PROPERTIES) { + System.clearProperty(propertyName); + } + } + + private static void restoreApolloSystemProperties(Map originalProperties) { + clearApolloSystemProperties(); + for (Map.Entry entry : originalProperties.entrySet()) { + restoreOrClear(entry.getKey(), entry.getValue()); + } + } + private static void addOrModifyForAllAppIds(String namespace, String key, String value) { embeddedApollo.addOrModifyProperty(TEST_APP_ID, namespace, key, value); embeddedApollo.addOrModifyProperty( diff --git a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtil.java b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtil.java index 2528e482..5791b4dd 100644 --- a/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtil.java +++ b/apollo-core/src/main/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtil.java @@ -17,10 +17,8 @@ package com.ctrip.framework.apollo.core.utils; import com.google.common.base.Strings; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import java.net.URL; import java.net.URLDecoder; @@ -40,19 +38,12 @@ public class ClassLoaderUtil { } try { - URL url = loader.getResource(""); - // get class path - if (url != null) { - classPath = url.getPath(); - classPath = URLDecoder.decode(classPath, "utf-8"); - } - - // 如果是jar包内的,则返回当前路径 - if (Strings.isNullOrEmpty(classPath) || classPath.contains(".jar!")) { - classPath = System.getProperty("user.dir"); + classPath = resolveClassPath(loader, null); + if (Strings.isNullOrEmpty(classPath)) { + classPath = getDefaultClassPath(); } } catch (Throwable ex) { - classPath = System.getProperty("user.dir"); + classPath = getDefaultClassPath(); logger.warn("Failed to locate class path, fallback to user.dir: {}", classPath, ex); } } @@ -65,6 +56,23 @@ public static String getClassPath() { return classPath; } + static String resolveClassPath(ClassLoader classLoader, String defaultClassPath) throws Exception { + URL url = classLoader.getResource(""); + if (url == null || !"file".equalsIgnoreCase(url.getProtocol())) { + return defaultClassPath; + } + + String resolvedClassPath = URLDecoder.decode(url.getPath(), "utf-8"); + if (Strings.isNullOrEmpty(resolvedClassPath)) { + return defaultClassPath; + } + return resolvedClassPath; + } + + private static String getDefaultClassPath() { + return System.getProperty("user.dir"); + } + public static boolean isClassPresent(String className) { try { Class.forName(className); diff --git a/apollo-core/src/test/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtilTest.java b/apollo-core/src/test/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtilTest.java index 8635d5c4..6e7ebef3 100644 --- a/apollo-core/src/test/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtilTest.java +++ b/apollo-core/src/test/java/com/ctrip/framework/apollo/core/utils/ClassLoaderUtilTest.java @@ -18,15 +18,79 @@ import static org.junit.Assert.*; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.nio.file.Files; +import java.nio.file.Path; import org.junit.Test; public class ClassLoaderUtilTest { private static boolean shouldFailInInitialization = false; + @Test public void testGetClassLoader() { assertNotNull(ClassLoaderUtil.getLoader()); } + @Test + public void testResolveClassPathWithFileUrl() throws Exception { + Path tempDir = Files.createTempDirectory("apollo class path"); + try { + ClassLoader classLoader = classLoaderReturning(tempDir.toUri().toURL()); + + assertEquals(URLDecoder.decode(tempDir.toUri().toURL().getPath(), "utf-8"), + ClassLoaderUtil.resolveClassPath(classLoader, "fallback")); + } finally { + Files.deleteIfExists(tempDir); + } + } + + @Test + public void testResolveClassPathFallsBackForNestedJarUrl() throws Exception { + String fallback = "/tmp/fallback"; + URL nestedJarUrl = createUrl("jar:nested:/tmp/apollo-app.jar/!BOOT-INF/classes/!/"); + ClassLoader classLoader = classLoaderReturning(nestedJarUrl); + + assertEquals(fallback, ClassLoaderUtil.resolveClassPath(classLoader, fallback)); + } + + @Test + public void testResolveClassPathPreservesWindowsStyleFileUrlFormat() throws Exception { + URL windowsFileUrl = new URL("file:/C:/Program%20Files/apollo/classes/"); + ClassLoader classLoader = classLoaderReturning(windowsFileUrl); + + assertEquals("/C:/Program Files/apollo/classes/", + ClassLoaderUtil.resolveClassPath(classLoader, "fallback")); + } + + @Test + public void testGetClassPathFallsBackToUserDirForNestedJarUrl() throws Exception { + String expectedClassPath = System.getProperty("user.dir"); + ClassLoader contextClassLoader = + classLoaderReturning(createUrl("jar:nested:/tmp/apollo-app.jar/!BOOT-INF/classes/!/")); + + assertEquals(expectedClassPath, isolatedClassPath(contextClassLoader)); + } + + @Test + public void testGetClassPathFallsBackToUserDirWhenLookupFails() throws Exception { + String expectedClassPath = System.getProperty("user.dir"); + ClassLoader contextClassLoader = new ClassLoader(null) { + @Override + public URL getResource(String name) { + throw new RuntimeException("lookup failed"); + } + }; + + assertEquals(expectedClassPath, isolatedClassPath(contextClassLoader)); + } + @Test public void testIsClassPresent() { assertTrue(ClassLoaderUtil.isClassPresent("java.lang.String")); @@ -50,4 +114,76 @@ public static class ClassWithInitializationError { } } } -} \ No newline at end of file + + private ClassLoader classLoaderReturning(URL resource) { + return new ClassLoader(null) { + @Override + public URL getResource(String name) { + return resource; + } + }; + } + + private URL createUrl(String spec) throws Exception { + return new URL(null, spec, new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) { + throw new UnsupportedOperationException(); + } + }); + } + + private String isolatedClassPath(ClassLoader contextClassLoader) throws Exception { + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(contextClassLoader); + Class isolatedClassLoaderUtil = newIsolatedClassLoader().loadClass( + ClassLoaderUtil.class.getName()); + Method getClassPath = isolatedClassLoaderUtil.getMethod("getClassPath"); + return (String) getClassPath.invoke(null); + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + private ClassLoader newIsolatedClassLoader() throws IOException { + String className = ClassLoaderUtil.class.getName(); + String classFile = className.replace('.', '/') + ".class"; + byte[] classBytes = readClassBytes(classFile); + + return new ClassLoader(ClassLoaderUtil.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!className.equals(name)) { + return super.loadClass(name, resolve); + } + + synchronized (getClassLoadingLock(name)) { + Class loadedClass = findLoadedClass(name); + if (loadedClass == null) { + loadedClass = defineClass(name, classBytes, 0, classBytes.length); + } + if (resolve) { + resolveClass(loadedClass); + } + return loadedClass; + } + } + }; + } + + private byte[] readClassBytes(String classFile) throws IOException { + try (InputStream inputStream = ClassLoaderUtil.class.getClassLoader().getResourceAsStream( + classFile); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + assertNotNull(inputStream); + + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return outputStream.toByteArray(); + } + } +} From d079c41e0776db1cc0d9c6563b0acb27e818b833 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:45:14 +0000 Subject: [PATCH 19/21] ci(mergify): upgrade configuration to current format --- .mergify.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index ed09b1f7..c96d7fe1 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -18,7 +18,6 @@ merge_queue: queue_rules: - name: single-commit - autoqueue: true batch_size: 1 merge_method: rebase queue_conditions: &single_commit_conditions @@ -44,7 +43,6 @@ queue_rules: merge_conditions: *single_commit_conditions - name: multi-commit - autoqueue: true batch_size: 1 merge_method: squash queue_conditions: &multi_commit_conditions @@ -77,3 +75,6 @@ pull_request_rules: actions: comment: message: "@{{author}} This pull request has conflicts with the target branch. Please resolve them and update the branch before merging." +merge_protections_settings: + auto_merge_conditions: true + reporting_method: check-runs From f0ef96375698edad7f7a1e762ae8dcc79f1d7734 Mon Sep 17 00:00:00 2001 From: Yike Xiao Date: Thu, 11 Jun 2026 18:55:16 +0800 Subject: [PATCH 20/21] test: fix flaky SpringAnnotationCompatibilityTest The test asserted that the first ApolloConfigChangeEvent received by the ApplicationListener probe is for namespace 'application'. However, config change listeners are notified asynchronously on a shared thread pool (AbstractConfig#notifyAsync), so events from different namespaces may arrive in any order, occasionally failing CI with: expected: but was: Replace the order-sensitive FIFO assertion with an order-independent check that all expected namespaces are eventually observed, matching the approach already used in ApolloSpringBootCompatibilityTest. Co-Authored-By: Claude Fable 5 --- .../spring/SpringAnnotationCompatibilityTest.java | 9 +++++++-- .../spring/SpringApolloEventListenerProbe.java | 14 +++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java index fb1ddcae..fa41c92d 100644 --- a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringAnnotationCompatibilityTest.java @@ -122,8 +122,13 @@ public void shouldSupportAnnotationAndMultipleConfig() throws Exception { assertNotNull(anotherAppChange); assertNotNull(anotherAppChange.getChange("compat.origin")); - String namespace = apolloEventListenerProbe.pollNamespace(10, TimeUnit.SECONDS); - assertEquals("application", namespace); + // config change listeners are notified asynchronously, so events from different + // namespaces may arrive in any order + SpringCompatibilityTestSupport.waitForCondition( + "ApplicationListener should receive namespace updates", + () -> apolloEventListenerProbe.hasNamespace("application") + && apolloEventListenerProbe.hasNamespace("TEST1.apollo") + && apolloEventListenerProbe.hasNamespace("application.yaml")); SpringCompatibilityTestSupport.waitForCondition("public value should be updated", () -> "from-public-updated".equals( diff --git a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java index b614a91e..99641fd8 100644 --- a/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java +++ b/apollo-compat-tests/apollo-spring-compat-it/src/test/java/com/ctrip/framework/apollo/compat/spring/SpringApolloEventListenerProbe.java @@ -17,24 +17,24 @@ package com.ctrip.framework.apollo.compat.spring; import com.ctrip.framework.apollo.spring.events.ApolloConfigChangeEvent; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; public class SpringApolloEventListenerProbe implements ApplicationListener { - private final BlockingQueue namespaces = new LinkedBlockingQueue(); + private final Set namespaces = Collections.synchronizedSet(new HashSet()); @Override public void onApplicationEvent(ApplicationEvent event) { if (event instanceof ApolloConfigChangeEvent) { - namespaces.offer(((ApolloConfigChangeEvent) event).getConfigChangeEvent().getNamespace()); + namespaces.add(((ApolloConfigChangeEvent) event).getConfigChangeEvent().getNamespace()); } } - public String pollNamespace(long timeout, TimeUnit unit) throws InterruptedException { - return namespaces.poll(timeout, unit); + public boolean hasNamespace(String namespace) { + return namespaces.contains(namespace); } } From 426cf8e1c76be94b221ee4d048a3bbf892e8dc8d Mon Sep 17 00:00:00 2001 From: Yike Xiao Date: Sun, 21 Jun 2026 15:16:12 +0800 Subject: [PATCH 21/21] fix: ConfigService.getConfig(appId, namespace) returns wrong app's config (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: appId dropped when creating PropertiesCompatibleFileConfigRepository for non-default appId DefaultConfigFactory.createPropertiesCompatibleFileConfigRepository() received an appId parameter but called ConfigService.getConfigFile(namespace, format) — the two-arg overload that ignores appId and resolves against the default app.id from app.properties. Fixes the bug by: 1. Adding ConfigService.getConfigFile(appId, namespace, format) that delegates to the already-correct ConfigManager.getConfigFile(appId, namespace, format). 2. Updating DefaultConfigFactory to call the new three-arg overload so the caller-specified appId is preserved. Adds tests: - DefaultConfigFactoryTest.testCreatePropertiesCompatibleFileConfigRepositoryForwardsCustomAppId: verifies ConfigManager is invoked with the supplied appId, never the default. - ConfigServiceTest.testGetConfigFileWithCustomAppId: verifies the new ConfigService.getConfigFile(appId, ns, format) overload returns a ConfigFile whose getAppId() equals the requested appId. Co-Authored-By: Claude Sonnet 4.6 * test: add regression test for custom appId on properties-compatible namespace Add ConfigServiceTest.testGetConfigWithCustomAppIdForPropertiesCompatibleNamespace, which drives the real DefaultConfigFactory path (create -> createPropertiesCompatibleFileConfigRepository -> ConfigService.getConfigFile(appId, namespace, format)) for a .yml namespace. The existing custom-appId tests either used a properties namespace or called the new getConfigFile overload directly, so neither would catch DefaultConfigFactory.createPropertiesCompatibleFileConfigRepository dropping the custom appId again. The new test stubs only createConfigFile and echoes the received appId into the resulting Config, so it fails if the appId is dropped. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../ctrip/framework/apollo/ConfigService.java | 13 ++ .../apollo/spi/DefaultConfigFactory.java | 2 +- .../framework/apollo/ConfigServiceTest.java | 117 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/ConfigService.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/ConfigService.java index 2886823b..97127f43 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/ConfigService.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/ConfigService.java @@ -101,6 +101,19 @@ public static ConfigFile getConfigFile(String namespace, ConfigFileFormat config return s_instance.getManager().getConfigFile(namespace, configFileFormat); } + /** + * Get the config file instance for the appId and namespace. + * + * @param appId the appId of the config + * @param namespace the namespace of the config without file extension, e.g. "application" + * @param configFileFormat the config file format + * @return config file instance + */ + public static ConfigFile getConfigFile(String appId, String namespace, + ConfigFileFormat configFileFormat) { + return s_instance.getManager().getConfigFile(appId, namespace, configFileFormat); + } + public static ConfigMonitor getConfigMonitor(){ return s_instance.getMonitor(); } diff --git a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java index 6f896892..9e392810 100644 --- a/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java +++ b/apollo-client/src/main/java/com/ctrip/framework/apollo/spi/DefaultConfigFactory.java @@ -163,7 +163,7 @@ PropertiesCompatibleFileConfigRepository createPropertiesCompatibleFileConfigRep String appId, String namespace, ConfigFileFormat format) { String actualNamespaceName = trimNamespaceFormat(namespace, format); PropertiesCompatibleConfigFile configFile = (PropertiesCompatibleConfigFile) ConfigService - .getConfigFile(actualNamespaceName, format); + .getConfigFile(appId, actualNamespaceName, format); return new PropertiesCompatibleFileConfigRepository(configFile); } diff --git a/apollo-client/src/test/java/com/ctrip/framework/apollo/ConfigServiceTest.java b/apollo-client/src/test/java/com/ctrip/framework/apollo/ConfigServiceTest.java index 65f0c2f4..09c3e4a8 100644 --- a/apollo-client/src/test/java/com/ctrip/framework/apollo/ConfigServiceTest.java +++ b/apollo-client/src/test/java/com/ctrip/framework/apollo/ConfigServiceTest.java @@ -20,6 +20,8 @@ import com.ctrip.framework.apollo.core.MetaDomainConsts; import com.ctrip.framework.apollo.enums.ConfigSourceType; +import com.ctrip.framework.apollo.spi.DefaultConfigFactory; +import java.util.Properties; import java.util.Set; import org.junit.After; @@ -103,6 +105,60 @@ public void testMockConfigFactoryForConfigFile() throws Exception { assertEquals(someNamespaceFileName + ":" + someConfigFileFormat.getValue(), configFile.getContent()); } + @Test + public void testGetConfigWithCustomAppId() throws Exception { + String customAppId = "customAppId"; + String someNamespace = "mock"; + String someKey = "someKey"; + MockInjector.setInstance(ConfigFactory.class, someNamespace, new MockConfigFactory()); + + Config config = ConfigService.getConfig(customAppId, someNamespace); + + assertEquals(customAppId + ConfigConsts.CLUSTER_NAMESPACE_SEPARATOR + someNamespace + ":" + someKey, + config.getProperty(someKey, null)); + } + + @Test + public void testGetConfigFileWithCustomAppId() throws Exception { + String customAppId = "customAppId"; + String someNamespace = "mock"; + ConfigFileFormat someConfigFileFormat = ConfigFileFormat.YML; + String someNamespaceFileName = + String.format("%s.%s", someNamespace, someConfigFileFormat.getValue()); + MockInjector.setInstance(ConfigFactory.class, someNamespaceFileName, new MockConfigFactory()); + + ConfigFile configFile = ConfigService.getConfigFile(customAppId, someNamespace, someConfigFileFormat); + + assertEquals(customAppId, configFile.getAppId()); + assertEquals(someNamespaceFileName, configFile.getNamespace()); + } + + @Test + public void testGetConfigWithCustomAppIdForPropertiesCompatibleNamespace() throws Exception { + String customAppId = "customAppId"; + String someNamespace = "mock"; + ConfigFileFormat someConfigFileFormat = ConfigFileFormat.YML; + String someNamespaceFileName = + String.format("%s.%s", someNamespace, someConfigFileFormat.getValue()); + + // Exercise the real DefaultConfigFactory path for a non-properties namespace, i.e. + // create(appId, "mock.yml") -> createPropertiesCompatibleFileConfigRepository(...) -> + // ConfigService.getConfigFile(appId, "mock", YML). Only createConfigFile is stubbed (to avoid + // hitting a remote repository); it echoes the appId it receives into the resulting properties so + // the assertion below fails if DefaultConfigFactory ever drops the custom appId again. + MockInjector.setInstance(ConfigFactory.class, someNamespaceFileName, new DefaultConfigFactory() { + @Override + public ConfigFile createConfigFile(String appId, String namespace, + ConfigFileFormat configFileFormat) { + return new MockPropertiesCompatibleConfigFile(appId, namespace, configFileFormat); + } + }); + + Config config = ConfigService.getConfig(customAppId, someNamespaceFileName); + + assertEquals(customAppId, config.getProperty("appId", null)); + } + private static class MockConfig extends AbstractConfig { private final String m_appId; private final String m_namespace; @@ -213,6 +269,67 @@ public ConfigFile createConfigFile(String appId, String namespace, ConfigFileFor } } + private static class MockPropertiesCompatibleConfigFile implements PropertiesCompatibleConfigFile { + private final String m_appId; + private final String m_namespace; + private final ConfigFileFormat m_configFileFormat; + + public MockPropertiesCompatibleConfigFile(String appId, String namespace, + ConfigFileFormat configFileFormat) { + m_appId = appId; + m_namespace = namespace; + m_configFileFormat = configFileFormat; + } + + @Override + public Properties asProperties() { + Properties properties = new Properties(); + // echo the appId so it is observable through the resulting Config + properties.setProperty("appId", m_appId); + return properties; + } + + @Override + public String getContent() { + return null; + } + + @Override + public boolean hasContent() { + return true; + } + + @Override + public String getAppId() { + return m_appId; + } + + @Override + public String getNamespace() { + return m_namespace; + } + + @Override + public ConfigFileFormat getConfigFileFormat() { + return m_configFileFormat; + } + + @Override + public void addChangeListener(ConfigFileChangeListener listener) { + + } + + @Override + public boolean removeChangeListener(ConfigFileChangeListener listener) { + return false; + } + + @Override + public ConfigSourceType getSourceType() { + return ConfigSourceType.REMOTE; + } + } + public static class MockConfigUtil extends ConfigUtil { @Override public String getAppId() {