diff --git a/src/main/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycle.java b/src/main/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycle.java new file mode 100644 index 0000000..7764964 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycle.java @@ -0,0 +1,110 @@ +package com.example.workutils.biz.apidoc.application; + +import com.example.workutils.biz.apidoc.generator.DubboDebugProxy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.function.Supplier; + +@Slf4j +@Component +public final class ApiDebugProxyLifecycle { + + public enum State { + NOT_STARTED, RUNNING, FAILED + } + + public record Status(State state, String endpoint, String errorMessage) { + public Status { + Objects.requireNonNull(state, "state"); + endpoint = Objects.requireNonNull(endpoint, "endpoint"); + errorMessage = Objects.requireNonNull(errorMessage, "errorMessage"); + } + } + + interface ProxyHandle { + String endpoint(); + + void stop(); + } + + private final Supplier proxyFactory; + private volatile Status status = new Status(State.NOT_STARTED, "", ""); + private ProxyHandle proxy; + + public ApiDebugProxyLifecycle() { + this(() -> { + DubboDebugProxy debugProxy = DubboDebugProxy.getInstance(); + return new ProxyHandle() { + @Override + public String endpoint() { + return debugProxy.getInvokeUrl(); + } + + @Override + public void stop() { + debugProxy.stop(); + } + }; + }); + } + + ApiDebugProxyLifecycle(Supplier proxyFactory) { + this.proxyFactory = Objects.requireNonNull(proxyFactory, "proxyFactory"); + } + + public synchronized Status start() { + if (status.state() != State.NOT_STARTED) { + return status; + } + try { + ProxyHandle startedProxy = Objects.requireNonNull( + proxyFactory.get(), "proxyFactory result"); + String endpoint = Objects.requireNonNull( + startedProxy.endpoint(), "proxy endpoint"); + proxy = startedProxy; + status = new Status(State.RUNNING, endpoint, ""); + log.info("API 本地调试代理已就绪: {}", endpoint); + } catch (RuntimeException error) { + status = new Status(State.FAILED, "", readableMessage(error)); + log.error("API 本地调试代理启动失败", error); + } + return status; + } + + public synchronized void stop() { + ProxyHandle activeProxy = proxy; + proxy = null; + if (activeProxy == null) { + return; + } + try { + activeProxy.stop(); + } catch (RuntimeException error) { + log.warn("API 本地调试代理关闭失败", error); + } + } + + public Status status() { + return status; + } + + public String endpoint() { + return status.endpoint(); + } + + private static String readableMessage(RuntimeException error) { + String message = error.getMessage(); + Throwable cause = error.getCause(); + if (cause != null && cause.getMessage() != null + && !cause.getMessage().isBlank()) { + return (message == null || message.isBlank()) + ? cause.getMessage() + : message + ": " + cause.getMessage(); + } + return message == null || message.isBlank() + ? error.getClass().getSimpleName() + : message; + } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/DubboDebugProxy.java b/src/main/java/com/example/workutils/biz/apidoc/generator/DubboDebugProxy.java index 29f0f84..408a2a6 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/generator/DubboDebugProxy.java +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/DubboDebugProxy.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; /** * 本地 HTTP 代理,解决浏览器直连内网网关不通的问题。 @@ -31,13 +32,16 @@ public class DubboDebugProxy { private final int port; private final HttpServer server; + private final ExecutorService executor; + private boolean stopped; private DubboDebugProxy() { try { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); this.port = server.getAddress().getPort(); server.createContext("/dubbo/invoke", this::handleInvoke); - server.setExecutor(Executors.newFixedThreadPool(4)); + executor = Executors.newFixedThreadPool(4); + server.setExecutor(executor); server.start(); log.info("DubboDebugProxy 已启动: http://127.0.0.1:{}/dubbo/invoke", port); } catch (IOException e) { @@ -64,6 +68,16 @@ public class DubboDebugProxy { return "http://127.0.0.1:" + port + "/dubbo/invoke"; } + public synchronized void stop() { + if (stopped) { + return; + } + stopped = true; + server.stop(0); + executor.shutdownNow(); + log.info("DubboDebugProxy 已停止"); + } + private void handleInvoke(HttpExchange exchange) throws IOException { // CORS 头 exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); diff --git a/src/test/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycleTest.java b/src/test/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycleTest.java new file mode 100644 index 0000000..11f9d33 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/apidoc/application/ApiDebugProxyLifecycleTest.java @@ -0,0 +1,79 @@ +package com.example.workutils.biz.apidoc.application; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApiDebugProxyLifecycleTest { + + @Test + void startsAndStopsTheOwnedProxyOnlyOnce() { + AtomicInteger starts = new AtomicInteger(); + AtomicInteger stops = new AtomicInteger(); + ApiDebugProxyLifecycle lifecycle = new ApiDebugProxyLifecycle(() -> { + starts.incrementAndGet(); + return new ApiDebugProxyLifecycle.ProxyHandle() { + @Override + public String endpoint() { + return "http://127.0.0.1:1234/dubbo/invoke"; + } + + @Override + public void stop() { + stops.incrementAndGet(); + } + }; + }); + + ApiDebugProxyLifecycle.Status first = lifecycle.start(); + ApiDebugProxyLifecycle.Status second = lifecycle.start(); + lifecycle.stop(); + lifecycle.stop(); + + assertEquals(1, starts.get()); + assertEquals(1, stops.get()); + assertEquals(ApiDebugProxyLifecycle.State.RUNNING, first.state()); + assertEquals(first, second); + assertEquals("http://127.0.0.1:1234/dubbo/invoke", lifecycle.endpoint()); + } + + @Test + void recordsStartupFailureWithoutPreventingTheApplicationFromContinuing() { + ApiDebugProxyLifecycle lifecycle = new ApiDebugProxyLifecycle( + () -> { throw new IllegalStateException("端口不可用"); }); + + ApiDebugProxyLifecycle.Status status = assertDoesNotThrow(lifecycle::start); + + assertEquals(ApiDebugProxyLifecycle.State.FAILED, status.state()); + assertEquals("", lifecycle.endpoint()); + assertTrue(status.errorMessage().contains("端口不可用")); + assertDoesNotThrow(lifecycle::stop); + } + + @Test + void shutdownFailureDoesNotEscapeOrRetryTheSameHandle() { + AtomicInteger stops = new AtomicInteger(); + ApiDebugProxyLifecycle lifecycle = new ApiDebugProxyLifecycle(() -> + new ApiDebugProxyLifecycle.ProxyHandle() { + @Override + public String endpoint() { + return "http://127.0.0.1:1234/dubbo/invoke"; + } + + @Override + public void stop() { + stops.incrementAndGet(); + throw new IllegalStateException("关闭失败"); + } + }); + lifecycle.start(); + + assertDoesNotThrow(lifecycle::stop); + assertDoesNotThrow(lifecycle::stop); + assertEquals(1, stops.get()); + } +}