接入 Web 层
soda-core 不含任何 Web 代码 —— 它对 Spring MVC 是完全无感的。Web 层的接入方式是:你自己写 Controller,用 soda 的 Result / PageResult 包装响应,用一个 @Aspect 切面把请求头里的身份信息搬进 Command / Query。
这一节讲的就是这个切面 —— 通常叫 ControllerAroundHandler。
常量:请求头名
soda 在 com.hibuka.soda.foundation.constant.BaseConstant 里定义了所有用到的请求头名:
public class BaseConstant {
public static final String X_REQUEST_ID = "X-Request-Id";
public static final String SODA_REQUEST_ID = "Soda-Request-Id";
public static final String X_UID = "X-UID";
public static final String X_USER_NAME = "X-User-Name";
public static final String SODA_USER_NAME = "Soda-User-Name";
public static final String X_AUTHORITIES = "X-Authorities";
public static final String X_JTI = "X-Jti";
public static final String X_CALLER_UID = "X-Caller-Uid";
public static final String SCADA = "scada";
public static final String X_TENANT_ID = "X-Tenant-Id";
}对应关系:
| 常量 | 请求头 | 含义 |
|---|---|---|
SODA_REQUEST_ID | Soda-Request-Id | soda 自己的链路 ID,由调用方或网关注入 |
SODA_USER_NAME | Soda-User-Name | soda 自己的用户名头 |
X_REQUEST_ID | X-Request-Id | 通用链路 ID |
X_UID | X-UID | 用户 UID |
X_USER_NAME | X-User-Name | 用户名 |
X_AUTHORITIES | X-Authorities | 权限串 |
X_JTI | X-Jti | JWT token id |
X_CALLER_UID | X-Caller-Uid | 调用方 UID |
X_TENANT_ID | X-Tenant-Id | 租户 ID |
SCADA | scada | 常量值,非请求头 |
Soda- 前缀的两个头是 soda 约定的「由框架自己消费」的头 —— 网关负责注入,业务方不需要关心。X- 前缀的是通用约定,你可以在自己的网关 / 拦截器里统一填。
统一响应:Result 与 PageResult
所有 Controller 都返回 Result<T> 或 PageResult<T>。
Result<T> 的字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
requestId | String | null | 链路 ID |
code | Integer | 200 | 响应码 |
message | String | "" | 响应消息,空串表示成功 |
data | T | null | 业务数据 |
success | Boolean | true | 成功标志 |
errorData | Map<String, Object> | 空 Map | 错误详情,仅失败时有 |
Result 的工厂方法:
| 方法 | 说明 |
|---|---|
Result.success(data) | 成功,message 为空串 |
Result.success(data, message) | 成功,带消息 |
Result.success(data, code, message) | 成功,自定义 code |
Result.successWithRequestId(data, requestId) | 成功,带 requestId |
Result.successWithRequestId(data, message, requestId) | 成功,带消息和 requestId |
Result.successWithRequestId(data, code, message, requestId) | 成功,全指定 |
Result.fail(code, message) | 失败,无数据 |
Result.fail(data, code, message) | 失败,带数据 |
Result.failWithRequestId(data, code, message, requestId) | 失败,带 requestId |
Result.fail(errorCode) | 失败,错误码是 IErrorCode |
Result.fail(data, errorCode) | 失败,带数据 |
Result.failWithRequestId(data, errorCode, requestId) | 失败,带 requestId |
Result.setRequestId(...) 返回 this,可以链式调用。
PageResult<T> 继承 Result<T>,额外带八个分页字段:total、pageNo、pageSize、pages、hasNext、hasPrevious、startIndex、endIndex。
PageResult.success 的参数顺序是 (data, total, pageNo, pageSize):
PageResult.success(dto.getList(), dto.getTotal(), query.getPageNumberWithDefault(), query.getPageSizeWithDefault());Controller
Controller 只做三件事:绑定参数、调应用服务、包装响应。
package com.example.demo.adapter.inbound.web;
import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.example.demo.application.cqrs.command.CreateProducerCommand;
import com.example.demo.application.cqrs.query.GetProducerQuery;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.application.sample.service.ProducerAppService;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.PageResult;
import com.hibuka.soda.foundation.io.PageVO;
import com.hibuka.soda.foundation.io.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@RestController
@RequestMapping("api")
@RequiredArgsConstructor
public class ProducerController {
private final ProducerAppService producerAppService;
@Operation(summary = "聚合跟创建", description = "creatProducer 创建接口描述")
@ApiResponse(responseCode = "200", description = "返回内容",
content = @Content(mediaType = "application/json", schema = @Schema(allOf = Result.class)))
@PostMapping("creatProducer")
public Result<ProducerDTO> creatProducer(@RequestBody CreateProducerCommand command) throws BaseException {
ProducerDTO dto = producerAppService.creatProducer(command);
return Result.success(dto);
}
@Operation(summary = "异步聚合跟创建")
@PostMapping("creatProducerSync")
public CompletableFuture<Result<ProducerDTO>> creatProducerSync(@RequestBody CreateProducerCommand command) throws BaseException {
return producerAppService.creatProducerSyn(command).thenApply(Result::success);
}
@Operation(summary = "获取聚合跟")
@PostMapping("getProducer")
public Result<ProducerDTO> getProducer(@RequestBody GetProducerQuery query) throws BaseException {
ProducerDTO dto = producerAppService.getProducer(query);
return Result.success(dto);
}
@Operation(summary = "获取聚合跟列表")
@PostMapping("listProducers")
public PageResult<List<ProducerDTO>> listProducer(@RequestBody ListProducerQuery query) throws BaseException {
PageVO<ProducerDTO> dto = producerAppService.listProducer(query);
return PageResult.success(dto.getList(), dto.getTotal(),
query.getPageNumberWithDefault(), query.getPageSizeWithDefault());
}
}两个要点:
- Command / Query 直接作为
@RequestBody绑定 —— 它们继承自BaseCommand/BaseQuery,Jackson 能正常反序列化。 throws BaseException写上。BaseException是RuntimeException子类,写不写都能编译,写上更清楚地表达「这个接口可能返回业务失败」。
ControllerAroundHandler:把请求头搬进 Command
这是 Web 层接入的核心。一个 @Aspect 切面,完成四件事:
- 从请求头取
requestId和userName - 写进 Command / Query 对象
- 写进 MDC,让日志带上链路信息
- 统一记录耗时日志,并把异常转成
Result
完整代码:
package com.example.demo.adapter.inbound.web.advice;
import com.alibaba.fastjson.JSON;
import com.hibuka.soda.foundation.constant.BaseConstant;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.BaseRequest;
import com.hibuka.soda.foundation.io.PageResult;
import com.hibuka.soda.foundation.io.Result;
import com.hibuka.soda.cqrs.command.BaseCommand;
import com.hibuka.soda.cqrs.query.BaseQuery;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.ArrayUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Aspect
@Component
public class ControllerAroundHandler {
private static final Logger logger = LoggerFactory.getLogger(ControllerAroundHandler.class);
private static final String LOG_PATTERN = "requestId:{},method:{}-{}, {}:{}, result:{}, duration:{},username:{}";
@Around(value = "execution(* com.example.demo.adapter.inbound.web..*.*(..))")
public Object handleController(ProceedingJoinPoint point) throws Throwable {
if (!shouldProcess(point)) {
return point.proceed();
}
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return point.proceed();
}
return processRequest(point, point.getArgs()[0], requestAttributes);
}
...
}切点 execution(* com.example.demo.adapter.inbound.web..*.*(..)) 里的包名必须和你的实际包名一致。写错了切面就完全不生效 —— 不会报错,只是 requestId 永远是随机 UUID,MDC 里也是空的。改包名后记得回来改这个切点。
筛选哪些方法要处理
不是所有 Controller 方法都该被这个切面处理。三道过滤:
private boolean shouldProcess(ProceedingJoinPoint point) {
// 1. 验证返回类型
MethodSignature signature = (MethodSignature) point.getSignature();
if (!isValidReturnType(signature.getReturnType())) {
return false;
}
// 2. 验证参数
Object[] args = point.getArgs();
if (ArrayUtils.isEmpty(args)) {
return false;
}
// 3. 验证第一个参数类型
return isValidArg(args[0]);
}
private boolean isValidReturnType(Class<?> returnType) {
if (Result.class.isAssignableFrom(returnType) || PageResult.class.isAssignableFrom(returnType)) {
return true;
}
// 处理泛型包装类型,如 CompletableFuture<Result<T>>
if (CompletableFuture.class.isAssignableFrom(returnType)) {
return true;
}
return false;
}
private boolean isValidArg(Object arg) {
return arg instanceof BaseCommand
|| arg instanceof BaseQuery
|| arg instanceof BaseRequest;
}BaseRequest 是 soda 提供的普通请求基类(requestId、jti、authorities、userName、callerUid 五个字段),用于那些既不是 Command 也不是 Query 的普通入参。
处理请求
private Object processRequest(ProceedingJoinPoint point, Object firstArg,
ServletRequestAttributes requestAttributes) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Class<?> returnType = signature.getReturnType();
HttpServletRequest request = requestAttributes.getRequest();
final String requestId;
String tempRequestId = request.getHeader(BaseConstant.SODA_REQUEST_ID);
if (tempRequestId == null || tempRequestId.isEmpty()) {
requestId = UUID.randomUUID().toString();
} else {
requestId = tempRequestId;
}
String username = request.getHeader(BaseConstant.SODA_USER_NAME);
// 设置请求信息
setRequestInfo(firstArg, requestId, username);
// 处理请求并记录日志
long startNanos = System.nanoTime();
Object result = null;
MDC.put("requestId", requestId);
MDC.put("username", username);
try {
result = point.proceed();
long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
logSuccess(requestId, signature, firstArg, result, durationMs, username);
// 处理 CompletableFuture 类型
if (result instanceof CompletableFuture) {
return ((CompletableFuture<?>) result).thenApply(res -> {
setRequestId(res, requestId);
return res;
});
}
} catch (Exception e) {
result = handleException(e, returnType);
long durationMs = (System.nanoTime() - startNanos) / 1_000_000L;
logError(requestId, signature, firstArg, result, durationMs, username, e);
} finally {
MDC.remove("requestId");
MDC.remove("username");
}
return setRequestId(result, requestId);
}关键点:
- requestId 优先取请求头。
Soda-Request-Id没有就生成一个随机 UUID。所以跨服务调用时,网关注入的 ID 能一路贯穿。 - MDC 在
finally里清理。不清理的话线程池复用线程时,上一个请求的 requestId 会串到下一个请求的日志里。 - 异步返回单独处理。
CompletableFuture的结果在另一个线程才产生,所以要thenApply里再补 requestId。
写入请求对象
private void setRequestInfo(Object firstArg, String requestId, String username) {
if (firstArg instanceof BaseCommand) {
BaseCommand cmd = (BaseCommand) firstArg;
cmd.setRequestId(requestId);
cmd.setUserName(username);
} else if (firstArg instanceof BaseQuery) {
BaseQuery query = (BaseQuery) firstArg;
query.setRequestId(requestId);
query.setUserName(username);
} else if (firstArg instanceof BaseRequest) {
BaseRequest request = (BaseRequest) firstArg;
request.setRequestId(requestId);
request.setUserName(username);
}
}写进去之后,soda 的 CqrsAroundHandler 会把这些值搬到 DomainEventContext,再由 RepositoryEventAspect 注入到领域事件里。链路就通了。
需要传更多身份信息(authorities、jti、callerUid、tenantId)时,在这里一并读出来写进去就行 —— BaseCommand 和 BaseQuery 都有这些字段。
异常映射
private Object handleException(Exception e, Class<?> returnType) {
String errorCode;
String message;
boolean showDetail = true;
if (e instanceof BaseException) {
// 业务异常:原样返回
BaseException baseException = (BaseException) e;
errorCode = String.valueOf(baseException.getCode());
message = baseException.getMessage();
} else {
// 系统异常:隐藏内部细节
errorCode = String.valueOf(BaseErrorCode.SYSTEM_ERROR.getCode());
message = "系统内部错误,请稍后重试";
showDetail = false;
}
if (Result.class.isAssignableFrom(returnType)) {
return Result.fail(Integer.parseInt(errorCode), message);
} else if (PageResult.class.isAssignableFrom(returnType)) {
return PageResult.fail(Integer.parseInt(errorCode), message);
} else {
return Result.fail(Integer.parseInt(errorCode), message);
}
}规则很简单:
| 异常类型 | code | message |
|---|---|---|
BaseException | 异常自带的 code | 异常自带的 message |
| 其它任何异常 | 10000(SYSTEM_ERROR) | "系统内部错误,请稍后重试" |
系统异常不向客户端暴露原始 message。这是硬性要求 —— 原始 message 里常有 SQL 语句、文件路径、类名。详细信息只写进服务端日志。
日志
private void logSuccess(String requestId, MethodSignature signature, Object firstArg,
Object result, long durationMs, String username) {
String argJson = safeJson(firstArg, true);
String resultJson = safeJson(result, true);
logger.info(LOG_PATTERN, requestId, signature.getDeclaringTypeName(), signature.getName(),
firstArg.getClass().getSimpleName(), argJson, resultJson, durationMs, username);
}
private void logError(String requestId, MethodSignature signature, Object firstArg,
Object result, long durationMs, String username, Exception e) {
String argJson = safeJson(firstArg, false);
String resultJson = safeJson(result, false);
logger.error(LOG_PATTERN, requestId, signature.getDeclaringTypeName(), signature.getName(),
firstArg.getClass().getSimpleName(), argJson, resultJson, durationMs, username, e);
}日志格式:requestId:{},method:{}-{}, {}:{}, result:{}, duration:{},username:{}。
safeJson 做了两件事:按日志级别短路(级别不开启就不序列化),以及截断超长内容:
private String safeJson(Object obj, boolean forInfo) {
if (obj == null) {
return "null";
}
boolean enabled = forInfo ? logger.isInfoEnabled() : logger.isErrorEnabled();
if (!enabled) {
return "";
}
String json = JSON.toJSONString(obj);
int maxLen = 8192;
if (json.length() <= maxLen) {
return json;
}
int head = 4096;
int tail = 256;
if (json.length() <= head + tail) {
return json.substring(0, Math.min(json.length(), head));
}
return json.substring(0, head) + "...<truncated>..." + json.substring(json.length() - tail);
}超过 8192 字符就只保留头 4096 和尾 256,中间标记 <truncated>。防止一个大的响应体把日志刷爆。
safeJson 用的是 com.alibaba.fastjson.JSON。fastjson 1.x 有已知的历史 CVE,如果你的安全扫描拦它,可以换成 Jackson 的 ObjectMapper。soda-core 本身依赖 fastjson 1.2.83(fastjson-version 属性),但在 Web 层你可以自由替换。
补上 requestId
private Object setRequestId(Object result, String requestId) {
if (result instanceof Result) {
((Result) result).setRequestId(requestId);
} else if (result instanceof PageResult) {
((PageResult) result).setRequestId(requestId);
}
return result;
}响应体里带上 requestId,客户端报障时可以直接把这个值给你。
MDC 日志格式
切面把 requestId 和 username 放进 MDC。你的 logback 配置里就能引用它们:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [reqId=%X{requestId}, user=%X{username}] %msg%n</pattern>
</encoder>
</appender>%X{requestId} 就是 MDC 里的值。soda 的 CQRS 切面本身也会打日志,格式是 requestId:{},method:{}-{}, ...,同样带 requestId。
GlobalExceptionHandler:切面之外的兜底
ControllerAroundHandler 已经在切面里处理了异常,但有些异常发生在切面之外(比如参数绑定失败)。@RestControllerAdvice 负责兜底:
package com.example.demo.adapter.inbound.web.advice;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice(basePackages = "com.example.demo.adapter.inbound.web")
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BaseException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Object> handleBaseException(BaseException e) {
logger.error("Business Exception: {}", e.getMessage(), e);
return Result.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage).collect(Collectors.toList());
String message = String.join(", ", errors);
logger.warn("Method Argument Not Valid: {}", message);
return Result.fail(HttpStatus.BAD_REQUEST.value(), message);
}
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleBindException(BindException e) { ... }
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { ... }
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Object> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { ... }
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public Result<Object> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { ... }
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public Result<Object> handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e) { ... }
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<Object> handleNoHandlerFoundException(NoHandlerFoundException e) { ... }
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Object> handleException(Exception e) {
logger.error("Unexpected Exception: {}", e.getMessage(), e);
// 系统异常不向客户端展示详细错误信息
return Result.fail(BaseErrorCode.SYSTEM_ERROR.getCode(), "系统内部错误,请稍后重试");
}
}注意这里的分层:
| 异常 | HTTP 状态 | 响应 code |
|---|---|---|
BaseException | 500 | 异常自带的 code |
MethodArgumentNotValidException / BindException | 400 | 400 |
MissingServletRequestParameterException | 400 | 400 |
MethodArgumentTypeMismatchException | 400 | 400 |
HttpRequestMethodNotSupportedException | 405 | 405 |
HttpMediaTypeNotSupportedException | 415 | 415 |
NoHandlerFoundException | 404 | 404 |
| 其它 | 500 | 10000(SYSTEM_ERROR) |
BaseException 也返回 HTTP 500。如果你希望业务错误返回 400,改 @ResponseStatus 即可 —— 但要清楚这会影响所有监控告警的分组。
basePackages 一定要写对,否则这个 Advice 完全不生效。
Spring Boot 4 的包名变化
如果你的工程用的是 Spring Boot 4.x,starter 的包名变了:
| Spring Boot 3 | Spring Boot 4 |
|---|---|
spring-boot-starter-web | spring-boot-starter-webmvc |
spring-boot-starter-aop | spring-boot-starter-aspectj |
spring-boot-starter-json | spring-boot-starter-jackson |
soda-core 17.0.1 已经改成依赖 spring-boot-starter-aspectj,所以你的工程如果还是 spring-boot-starter-aop,切面相关的依赖需要确认一下。
完整请求链路
HTTP POST /api/creatProducer
Header: Soda-Request-Id: abc-123
Soda-User-Name: alice
Body: {"name": "生产车间"}
│
├─ ControllerAroundHandler
│ ├─ 读 Soda-Request-Id → requestId = "abc-123"
│ ├─ 读 Soda-User-Name → username = "alice"
│ ├─ cmd.setRequestId("abc-123") / cmd.setUserName("alice")
│ ├─ MDC.put("requestId", ...) / MDC.put("username", ...)
│ └─ proceed()
│
├─ Controller:绑定 CreateProducerCommand,调应用服务
│
├─ AppService:busFacade.sendTransactCommand(command)
│
├─ CqrsAroundHandler(拦截 CommandBus.send)
│ ├─ 把 requestId / userName / jti / authorities 搬进 DomainEventContext
│ └─ proceed()
│
├─ CommandHandler.handle(command)
│ ├─ ParamChecker 校验
│ ├─ Producer.create(name) → addPendingEvent(ProducerEvent) ← 自动注入上下文
│ └─ writeRepository.save(domain)
│ └─ RepositoryEventAspect
│ ├─ 执行真正的 insert
│ ├─ 取走 domainEvents 并清空
│ └─ 事务提交后 publish(event)
│
├─ ProducerEventHandler.handle(event) ← 收到事件
│
└─ Controller 返回 Result.success(dto)
ControllerAroundHandler 补上 requestId,清理 MDC