Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apollo-mockserver/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
<groupId>com.ctrip.framework.apollo</groupId>
<artifactId>apollo-client</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public class ApolloTestingServer implements AutoCloseable {

private boolean closed;

private boolean bridgeAcquired;

static {
try {
System.setProperty("apollo.longPollingInitialDelayInMills", "0");
Expand All @@ -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<String> 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<String> 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() {
Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@
<artifactId>slf4j-api</artifactId>
<version>1.7.21</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.7.21</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
Expand Down
Loading