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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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)
* [Support `extendInfo`/`parentAppId` in OpenAPI `getNamespace(s)` client calls](https://github.com/apolloconfig/apollo-java/pull/144)
* [Add an opt-in JUL to SLF4J bridge for MockWebServer logs](https://github.com/apolloconfig/apollo-java/pull/147)

------------------
All issues and pull requests are [here](https://github.com/apolloconfig/apollo-java/milestone/6?closed=1)
53 changes: 53 additions & 0 deletions apollo-mockserver/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Apollo Mock Server

## Routing MockWebServer logs to SLF4J

MockWebServer uses `java.util.logging` (JUL). The bridge is opt-in: `start()` and
`close()` do not install or release it. Add `jul-to-slf4j` to your test classpath,
since the dependency in `apollo-mockserver` is optional:

```xml
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.7.21</version>
<scope>test</scope>
</dependency>
```

Explicitly acquire and release the bridge, ensuring the server closes even if
acquisition or release throws:

```java
ApolloTestingServer server = new ApolloTestingServer();
try {
server.start();
server.acquireJulBridge();
// ...
} finally {
try {
server.releaseJulBridge();
} finally {
server.close();
}
}
```

The bridge only reconfigures the `okhttp3.mockwebserver.MockWebServer` logger,
not the JUL root logger. Each server can own at most one acquisition; the bridge
is shared until the last owner releases it. Without `jul-to-slf4j`, or when the
SLF4J backend is `slf4j-jdk14`, acquisition logs a warning and leaves JUL logging
unchanged.

A failed acquisition creates no successful owner. It restores the original JUL
state immediately if possible. If rollback also fails, its exception is attached
to the acquisition exception as a suppressed exception, and the original state
is retained. A later acquisition must complete that recovery before installing a
new bridge; it throws if recovery is still blocked.

A failed final release throws and retains the last owner's acquisition and the
original JUL state for a retry. New owners cannot acquire during incomplete
teardown; the existing owner must retry `releaseJulBridge()` after the cause of
the failure is removed. The nested `finally` above guarantees server cleanup,
but handling or retrying a reported bridge-release failure remains the caller's
responsibility. Calling `close()` does not release bridge ownership.
6 changes: 6 additions & 0 deletions apollo-mockserver/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@
<groupId>com.ctrip.framework.apollo</groupId>
<artifactId>apollo-client</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.7.21</version>
<optional>true</optional>
</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 Down Expand Up @@ -143,6 +145,38 @@ public void close() {
}
}

/**
* Installs the JUL-to-SLF4J bridge for MockWebServer logs on the currently active server.
* Callers must pair every call with {@link #releaseJulBridge()}.
*
* <p>A failed installation propagates without acquiring ownership and attempts to restore the
* original JUL state. If rollback also fails, the cleanup exception is suppressed on the
* installation exception and recovery state is retained for a later acquisition to retry.
* Releasing an instance whose acquisition failed is a no-op.
*
* <p>If another owner's final release is incomplete, acquisition throws until that owner
* successfully retries {@link #releaseJulBridge()}.
*/
public void acquireJulBridge() {
if (!bridgeAcquired) {
JulSlf4jBridge.install();
bridgeAcquired = true;
}
}

/**
* Releases the JUL-to-SLF4J bridge acquired by {@link #acquireJulBridge()}.
*
* <p>A failed release keeps this instance owning the bridge, so calling this method again
* retries the teardown.
*/
public void releaseJulBridge() {
if (bridgeAcquired) {
JulSlf4jBridge.uninstall();
bridgeAcquired = false;
}
}

public boolean isClosed() {
return closed;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* 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;

/**
* 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 {@code 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.
*
* <p>The ownership count is only moved once the corresponding logger transition succeeded.
* Failed restoration retains the original JUL state for retry: a later installation recovers
* ownerless rollback, and a failed final release must be retried by its existing owner.
*
* <p>Uses reflection to instantiate {@code SLF4JBridgeHandler} so the class can load even when
* {@code jul-to-slf4j} is not on the classpath.
*/
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 String SLF4J_BRIDGE_HANDLER_CLASS = "org.slf4j.bridge.SLF4JBridgeHandler";
private static final AtomicInteger ACTIVE = new AtomicInteger(0);

private static Handler[] originalHandlers;
private static boolean originalUseParentHandlers;
private static boolean bridged;
private static boolean recoveryPending;
private static Logger mockWebServerLogger;
private static Handler bridgeHandler;

private JulSlf4jBridge() {}

static synchronized void install() {
if (recoveryPending) {
if (ACTIVE.get() > 0) {
throw new IllegalStateException(
"JUL bridge teardown is incomplete; the last owner must retry releaseJulBridge()");
}
// A failed acquisition has no owner to retry release. Recover its original baseline
// before taking a new snapshot, so a leftover bridge is never treated as an original.
teardown();
}
if (ACTIVE.get() == 0) {
installForFirstOwner();
}
// Only an owner whose install succeeded consumes a slot, so a failed install cannot make
// later servers skip their own installation.
ACTIVE.incrementAndGet();
}

private static void installForFirstOwner() {
if (!isBridgeHandlerAvailable()) {
LOG.warn("Skipping JUL-to-SLF4J bridge for MockWebServer logs because "
+ "jul-to-slf4j is not on the classpath");
return;
}
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 logger = Logger.getLogger(MOCK_WEB_SERVER_LOGGER_NAME);
Handler handler = newBridgeHandler();
mockWebServerLogger = logger;
originalHandlers = logger.getHandlers();
originalUseParentHandlers = logger.getUseParentHandlers();
bridgeHandler = handler;
recoveryPending = true;

try {
for (Handler original : originalHandlers) {
logger.removeHandler(original);
}
logger.addHandler(handler);
logger.setUseParentHandlers(false);
} catch (RuntimeException | Error e) {
try {
teardown();
} catch (RuntimeException | Error cleanupFailure) {
if (cleanupFailure != e) {
e.addSuppressed(cleanupFailure);
}
}
throw e;
}

bridged = true;
recoveryPending = false;
}

private static boolean isBridgeHandlerAvailable() {
try {
Class.forName(SLF4J_BRIDGE_HANDLER_CLASS);
return true;
} catch (ClassNotFoundException e) {
return false;
}
}

private static Handler newBridgeHandler() {
try {
return (Handler) Class.forName(SLF4J_BRIDGE_HANDLER_CLASS).newInstance();
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
throw new IllegalStateException(
"SLF4JBridgeHandler was available during check but not during instantiation", e);
}
}

static synchronized void uninstall() {
int active = ACTIVE.get();
if (active <= 0) {
// Nothing is acquired, so releasing must not drive the count below zero.
return;
}
if (active > 1 || !bridged) {
ACTIVE.decrementAndGet();
return;
}
// The last owner gives up its slot only once the JUL logger is restored, so a failed
// teardown stays retryable instead of leaving the count out of sync.
teardown();
ACTIVE.decrementAndGet();
}

private static void teardown() {
recoveryPending = true;
if (containsHandler(mockWebServerLogger.getHandlers(), bridgeHandler)) {
mockWebServerLogger.removeHandler(bridgeHandler);
}
restoreHandlers(mockWebServerLogger, originalHandlers, originalUseParentHandlers);
// Keep the snapshot, logger and last ownership until every restoration operation succeeds.
originalHandlers = null;
originalUseParentHandlers = false;
mockWebServerLogger = null;
bridgeHandler = null;
bridged = false;
recoveryPending = false;
}

/** Restores only missing original handlers, preserving handlers added by other components. */
private static void restoreHandlers(Logger logger, Handler[] handlers,
boolean useParentHandlers) {
for (Handler handler : handlers) {
if (!containsHandler(logger.getHandlers(), handler)) {
logger.addHandler(handler);
}
}
if (logger.getUseParentHandlers() != useParentHandlers) {
logger.setUseParentHandlers(useParentHandlers);
}
}

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);
}
}
Loading
Loading