diff --git a/apollo-mockserver/pom.xml b/apollo-mockserver/pom.xml index 9dec2d50..15dbf32a 100644 --- a/apollo-mockserver/pom.xml +++ b/apollo-mockserver/pom.xml @@ -53,6 +53,10 @@ com.ctrip.framework.apollo apollo-client + + org.slf4j + jul-to-slf4j + com.squareup.okhttp3 mockwebserver 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 e69acc45..a2f30ae2 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 @@ -79,6 +79,8 @@ public class ApolloTestingServer implements AutoCloseable { private boolean closed; + private boolean bridgeAcquired; + static { try { System.setProperty("apollo.longPollingInitialDelayInMills", "0"); @@ -104,32 +106,38 @@ public class ApolloTestingServer implements AutoCloseable { } public void start() throws IOException { - clearForStart(); - server = new MockWebServer(); - final Dispatcher dispatcher = new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest request) throws InterruptedException { - if (request.getPath().startsWith("/notifications/v2")) { - String notifications = request.getRequestUrl().queryParameter("notifications"); - return new MockResponse().setResponseCode(200).setBody(mockLongPollBody(notifications)); - } - if (request.getPath().startsWith("/configs")) { - List pathSegments = request.getRequestUrl().pathSegments(); - // appId and cluster might be used in the future - String appId = pathSegments.get(1); - String cluster = pathSegments.get(2); - String namespace = pathSegments.get(3); - return new MockResponse().setResponseCode(200).setBody(loadConfigFor(appId, namespace)); + acquireJulBridge(); + try { + clearForStart(); + server = new MockWebServer(); + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + if (request.getPath().startsWith("/notifications/v2")) { + String notifications = request.getRequestUrl().queryParameter("notifications"); + return new MockResponse().setResponseCode(200).setBody(mockLongPollBody(notifications)); + } + if (request.getPath().startsWith("/configs")) { + List pathSegments = request.getRequestUrl().pathSegments(); + // appId and cluster might be used in the future + String appId = pathSegments.get(1); + String cluster = pathSegments.get(2); + String namespace = pathSegments.get(3); + return new MockResponse().setResponseCode(200).setBody(loadConfigFor(appId, namespace)); + } + return new MockResponse().setResponseCode(404); } - return new MockResponse().setResponseCode(404); - } - }; + }; - server.setDispatcher(dispatcher); - server.start(); + server.setDispatcher(dispatcher); + server.start(); - mockConfigServiceUrl("http://localhost:" + server.getPort()); - started = true; + mockConfigServiceUrl("http://localhost:" + server.getPort()); + started = true; + } catch (RuntimeException | IOException e) { + releaseJulBridge(); + throw e; + } } public void close() { @@ -140,6 +148,21 @@ public void close() { logger.error("stop apollo server error", e); } finally { closed = true; + releaseJulBridge(); + } + } + + private void acquireJulBridge() { + if (!bridgeAcquired) { + JulSlf4jBridge.install(); + bridgeAcquired = true; + } + } + + private void releaseJulBridge() { + if (bridgeAcquired) { + JulSlf4jBridge.uninstall(); + bridgeAcquired = false; } } diff --git a/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridge.java b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridge.java new file mode 100644 index 00000000..9fc94f48 --- /dev/null +++ b/apollo-mockserver/src/main/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridge.java @@ -0,0 +1,102 @@ +/* + * 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.mockserver; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.bridge.SLF4JBridgeHandler; + +/** + * Routes okhttp's MockWebServer logs (which go through java.util.logging directly) to + * slf4j/logback for the lifetime of active {@link ApolloTestingServer} instances. + * + *

Unlike {@link SLF4JBridgeHandler#install()}, this only touches the JUL logger used by + * MockWebServer, not the JUL root logger, so unrelated JUL handlers elsewhere in the JVM are + * never removed. It also skips installing when the SLF4J binding is slf4j-jdk14, since bridging + * JUL to an SLF4J binding that itself delegates back to JUL creates an infinite loop. Calls are + * reference-counted so concurrently running mock servers don't uninstall the bridge out from + * under each other. + */ +final class JulSlf4jBridge { + + private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(JulSlf4jBridge.class); + private static final String MOCK_WEB_SERVER_LOGGER_NAME = "okhttp3.mockwebserver.MockWebServer"; + private static final AtomicInteger ACTIVE = new AtomicInteger(0); + + private static Handler[] originalHandlers; + private static boolean originalUseParentHandlers; + private static boolean bridged; + private static SLF4JBridgeHandler bridgeHandler; + + private JulSlf4jBridge() {} + + static synchronized void install() { + if (ACTIVE.getAndIncrement() > 0) { + return; + } + bridged = false; + if (isJdk14Binding(LoggerFactory.getILoggerFactory().getClass().getName())) { + LOG.warn("Skipping JUL-to-SLF4J bridge for MockWebServer logs because the SLF4J binding " + + "is slf4j-jdk14; bridging it would create an infinite JUL<->SLF4J logging loop."); + return; + } + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + originalHandlers = mockWebServerLogger.getHandlers(); + originalUseParentHandlers = mockWebServerLogger.getUseParentHandlers(); + for (Handler handler : originalHandlers) { + mockWebServerLogger.removeHandler(handler); + } + bridgeHandler = new SLF4JBridgeHandler(); + mockWebServerLogger.addHandler(bridgeHandler); + mockWebServerLogger.setUseParentHandlers(false); + bridged = true; + } + + static synchronized void uninstall() { + if (ACTIVE.decrementAndGet() > 0 || !bridged) { + return; + } + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + mockWebServerLogger.removeHandler(bridgeHandler); + for (Handler handler : originalHandlers) { + if (!containsHandler(mockWebServerLogger.getHandlers(), handler)) { + mockWebServerLogger.addHandler(handler); + } + } + mockWebServerLogger.setUseParentHandlers(originalUseParentHandlers); + originalHandlers = null; + bridgeHandler = null; + bridged = false; + } + + private static boolean containsHandler(Handler[] handlers, Handler target) { + for (Handler handler : handlers) { + if (handler == target) { + return true; + } + } + return false; + } + + /** Package-visible for testing without depending on the actual runtime SLF4J binding. */ + static boolean isJdk14Binding(String iLoggerFactoryClassName) { + return "org.slf4j.impl.JDK14LoggerFactory".equals(iLoggerFactoryClassName) + || "org.slf4j.jul.JDK14LoggerFactory".equals(iLoggerFactoryClassName); + } +} diff --git a/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java new file mode 100644 index 00000000..bbce63f8 --- /dev/null +++ b/apollo-mockserver/src/test/java/com/ctrip/framework/apollo/mockserver/JulSlf4jBridgeTest.java @@ -0,0 +1,139 @@ +/* + * 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.mockserver; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.logging.Handler; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.slf4j.bridge.SLF4JBridgeHandler; + +class JulSlf4jBridgeTest { + + private static final String MOCK_WEB_SERVER_LOGGER_NAME = "okhttp3.mockwebserver.MockWebServer"; + + @Test + void isJdk14BindingDetectsKnownFactoryClassNames() { + assertTrue(JulSlf4jBridge.isJdk14Binding("org.slf4j.impl.JDK14LoggerFactory")); + assertTrue(JulSlf4jBridge.isJdk14Binding("org.slf4j.jul.JDK14LoggerFactory")); + assertFalse(JulSlf4jBridge.isJdk14Binding("ch.qos.logback.classic.LoggerContext")); + } + + @Test + void installOnlyTouchesMockWebServerLoggerNotRoot() { + Logger root = Logger.getLogger(""); + Handler[] rootHandlersBefore = root.getHandlers(); + + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + Handler[] mwsHandlersBefore = mockWebServerLogger.getHandlers(); + boolean useParentBefore = mockWebServerLogger.getUseParentHandlers(); + + JulSlf4jBridge.install(); + try { + assertArrayEquals(rootHandlersBefore, root.getHandlers(), + "installing the bridge must not touch the JUL root logger's handlers"); + assertTrue(hasBridgeHandler(mockWebServerLogger), + "MockWebServer's own logger should get the bridge handler"); + assertFalse(mockWebServerLogger.getUseParentHandlers()); + } finally { + JulSlf4jBridge.uninstall(); + } + + assertArrayEquals(rootHandlersBefore, root.getHandlers()); + assertArrayEquals(mwsHandlersBefore, mockWebServerLogger.getHandlers()); + assertEquals(useParentBefore, mockWebServerLogger.getUseParentHandlers()); + } + + @Test + void closingSameServerTwiceDoesNotDisruptAnotherActiveServer() throws Exception { + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + ApolloTestingServer serverA = new ApolloTestingServer(); + ApolloTestingServer serverB = new ApolloTestingServer(); + try { + serverA.start(); + serverB.start(); + assertTrue(hasBridgeHandler(mockWebServerLogger)); + + serverA.close(); + serverA.close(); + + assertTrue(hasBridgeHandler(mockWebServerLogger), + "bridge must remain installed while server B is still running"); + } finally { + serverA.close(); + serverB.close(); + } + + assertFalse(hasBridgeHandler(mockWebServerLogger)); + } + + @Test + void closingNeverStartedServerDoesNotDisruptActiveServer() throws Exception { + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + ApolloTestingServer active = new ApolloTestingServer(); + ApolloTestingServer neverStarted = new ApolloTestingServer(); + try { + active.start(); + assertTrue(hasBridgeHandler(mockWebServerLogger)); + + neverStarted.close(); + + assertTrue(hasBridgeHandler(mockWebServerLogger), + "closing a server that was never started must not release a bridge it never acquired"); + } finally { + active.close(); + neverStarted.close(); + } + + assertFalse(hasBridgeHandler(mockWebServerLogger)); + } + + @Test + void startupFailureAfterBridgeAcquisitionReleasesBridge() throws Exception { + Logger mockWebServerLogger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME); + ApolloTestingServer server = new ApolloTestingServer(); + + Field locatorField = ApolloTestingServer.class.getDeclaredField("CONFIG_SERVICE_LOCATOR"); + locatorField.setAccessible(true); + Object originalLocator = locatorField.get(null); + locatorField.set(null, null); + try { + assertThrows(IllegalStateException.class, server::start, + "start() must surface failures that happen after the JUL bridge is acquired"); + assertFalse(hasBridgeHandler(mockWebServerLogger), + "a failed start() must release the JUL bridge it acquired"); + } finally { + locatorField.set(null, originalLocator); + server.close(); + } + } + + private static boolean hasBridgeHandler(Logger logger) { + for (Handler handler : logger.getHandlers()) { + if (handler instanceof SLF4JBridgeHandler) { + return true; + } + } + return false; + } +} diff --git a/pom.xml b/pom.xml index a20f7096..bd64c6f1 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,11 @@ slf4j-api 1.7.21 + + org.slf4j + jul-to-slf4j + 1.7.21 + com.google.guava guava