优化
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
|
||||
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
|
||||
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
|
||||
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
|
||||
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
||||
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
||||
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
|
||||
@@ -27,5 +28,6 @@
|
||||
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
|
||||
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
|
||||
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
|
||||
- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。
|
||||
- 从父目录运行 `mvn -pl common-web -am test`。
|
||||
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Immutable standard headers for one synchronous outbound child span. */
|
||||
public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) {
|
||||
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
/** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */
|
||||
public static OutboundTraceHeaders fromCurrentOrNew() {
|
||||
String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID));
|
||||
String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID))
|
||||
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||
return child(requestId, traceId);
|
||||
}
|
||||
|
||||
/** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */
|
||||
public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) {
|
||||
String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId)
|
||||
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||
return child(UUID.randomUUID().toString(), traceId);
|
||||
}
|
||||
|
||||
public String spanId() {
|
||||
return TraceParent.parse(traceParent).orElseThrow().parentId();
|
||||
}
|
||||
|
||||
private static OutboundTraceHeaders child(String requestId, String traceId) {
|
||||
TraceParent child = TraceParent.outboundChild(traceId);
|
||||
return new OutboundTraceHeaders(requestId, child.traceId(), child.value());
|
||||
}
|
||||
|
||||
private static String safeRequestId(String value) {
|
||||
return value != null && SAFE_REQUEST_ID.matcher(value).matches()
|
||||
? value : UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Installs the trace interceptor after ordinary RestClient customizers. */
|
||||
public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered {
|
||||
private final RestClientTraceInterceptor interceptor;
|
||||
|
||||
public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) {
|
||||
this.interceptor = interceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customize(RestClient.Builder builder) {
|
||||
builder.requestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Applies the trusted synchronous trace context to Spring RestClient requests. */
|
||||
public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor {
|
||||
@Override
|
||||
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
|
||||
request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId());
|
||||
request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
|
||||
request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
|
||||
return execution.execute(request, body);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Adds trace propagation to Spring-managed RestClient builders. */
|
||||
@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration")
|
||||
@ConditionalOnClass({RestClient.class, RestClientCustomizer.class})
|
||||
public class RestClientTracePropagationAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(RestClientTraceInterceptor.class)
|
||||
RestClientTraceInterceptor restClientTraceInterceptor() {
|
||||
return new RestClientTraceInterceptor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) {
|
||||
return new RestClientTraceCustomizer(interceptor);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.common.web;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -63,6 +64,24 @@ public record TraceParent(String traceId, String parentId, String traceFlags) {
|
||||
return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a trace ID from a trusted persisted/internal context.
|
||||
*
|
||||
* <p>Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because
|
||||
* existing event envelopes historically persisted UUID trace IDs.</p>
|
||||
*/
|
||||
public static Optional<String> normalizeTrustedTraceId(String value) {
|
||||
if (validLegacyTraceId(value)) {
|
||||
return Optional.of(value.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (value != null && value.matches(
|
||||
"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) {
|
||||
String compact = value.replace("-", "").toLowerCase(Locale.ROOT);
|
||||
return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean validLegacyTraceId(String value) {
|
||||
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
|
||||
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
com.baiyue.common.web.LongIdJacksonConfiguration
|
||||
com.baiyue.common.web.CommonWebAutoConfiguration
|
||||
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
|
||||
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OutboundTraceHeadersTest {
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentContextCreatesAValidChildWithoutUsingBusinessValues() {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
|
||||
OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew();
|
||||
|
||||
assertThat(headers.requestId()).isEqualTo("req-trusted");
|
||||
assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(headers.traceParent())
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() {
|
||||
OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||
"550e8400-e29b-41d4-a716-446655440000");
|
||||
OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||
"ORDER:customer-99:booking-123");
|
||||
|
||||
assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000");
|
||||
assertThat(invalid.traceId()).matches("[0-9a-f]{32}")
|
||||
.isNotEqualTo("ORDER:customer-99:booking-123");
|
||||
assertThat(invalid.requestId()).matches("[0-9a-f-]{36}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.mock.http.client.MockClientHttpRequest;
|
||||
import org.springframework.mock.http.client.MockClientHttpResponse;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RestClientTraceInterceptorTest {
|
||||
private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor();
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||
request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id");
|
||||
request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||
AtomicReference<org.springframework.http.HttpHeaders> captured = new AtomicReference<>();
|
||||
|
||||
interceptor.intercept(request, new byte[0], (outbound, body) -> {
|
||||
captured.set(outbound.getHeaders());
|
||||
return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK);
|
||||
});
|
||||
|
||||
assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.containsExactly("req-rest-client");
|
||||
assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER))
|
||||
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception {
|
||||
MDC.put(CorrelationContext.TRACE_ID, "business-order-123");
|
||||
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||
|
||||
interceptor.intercept(request, new byte[0], (outbound, body) ->
|
||||
new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK));
|
||||
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.matches("[0-9a-f-]{36}");
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER))
|
||||
.matches("[0-9a-f]{32}").isNotEqualTo("business-order-123");
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class RestClientTracePropagationAutoConfigurationTest {
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void registersInterceptorAndIndependentCustomizer() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(RestClientTraceInterceptor.class);
|
||||
assertThat(context).hasBean("baiyueTraceRestClientCustomizer");
|
||||
assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1);
|
||||
RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class);
|
||||
assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
|
||||
RestClient.Builder builder = mock(RestClient.Builder.class);
|
||||
customizer.customize(builder);
|
||||
verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesApplicationProvidedTraceInterceptor() {
|
||||
contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new)
|
||||
.run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class));
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,12 @@ class TraceParentTest {
|
||||
assertThat(child.value()).matches(
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() {
|
||||
assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000"))
|
||||
.contains("550e8400e29b41d4a716446655440000");
|
||||
assertThat(TraceParent.parse(
|
||||
"00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user