soda docs v17.0.1

接入 Web 层

soda-core 不含任何 Web 代码 —— 它对 Spring MVC 是完全无感的。Web 层的接入方式是:你自己写 Controller,用 soda 的 Result / PageResult 包装响应,用一个 @Aspect 切面把请求头里的身份信息搬进 Command / Query。

这一节讲的就是这个切面 —— 通常叫 ControllerAroundHandler。

常量:请求头名

soda 在 com.hibuka.soda.foundation.constant.BaseConstant 里定义了所有用到的请求头名:

java
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_IDSoda-Request-Idsoda 自己的链路 ID,由调用方或网关注入
SODA_USER_NAMESoda-User-Namesoda 自己的用户名头
X_REQUEST_IDX-Request-Id通用链路 ID
X_UIDX-UID用户 UID
X_USER_NAMEX-User-Name用户名
X_AUTHORITIESX-Authorities权限串
X_JTIX-JtiJWT token id
X_CALLER_UIDX-Caller-Uid调用方 UID
X_TENANT_IDX-Tenant-Id租户 ID
SCADAscada常量值,非请求头
技巧

Soda- 前缀的两个头是 soda 约定的「由框架自己消费」的头 —— 网关负责注入,业务方不需要关心。X- 前缀的是通用约定,你可以在自己的网关 / 拦截器里统一填。

统一响应:Result 与 PageResult

所有 Controller 都返回 Result<T> 或 PageResult<T>。

Result<T> 的字段:

字段类型默认值说明
requestIdStringnull链路 ID
codeInteger200响应码
messageString""响应消息,空串表示成功
dataTnull业务数据
successBooleantrue成功标志
errorDataMap<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):

java
PageResult.success(dto.getList(), dto.getTotal(), query.getPageNumberWithDefault(), query.getPageSizeWithDefault());

Controller

Controller 只做三件事:绑定参数、调应用服务、包装响应。

java
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 切面,完成四件事:

  1. 从请求头取 requestId 和 userName
  2. 写进 Command / Query 对象
  3. 写进 MDC,让日志带上链路信息
  4. 统一记录耗时日志,并把异常转成 Result

完整代码:

java
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 方法都该被这个切面处理。三道过滤:

java
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 的普通入参。

处理请求

java
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。

写入请求对象

java
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 都有这些字段。

异常映射

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

规则很简单:

异常类型codemessage
BaseException异常自带的 code异常自带的 message
其它任何异常10000(SYSTEM_ERROR)"系统内部错误,请稍后重试"
重要

系统异常不向客户端暴露原始 message。这是硬性要求 —— 原始 message 里常有 SQL 语句、文件路径、类名。详细信息只写进服务端日志。

日志

java
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 做了两件事:按日志级别短路(级别不开启就不序列化),以及截断超长内容:

java
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

java
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 配置里就能引用它们:

xml
<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 负责兜底:

java
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
BaseException500异常自带的 code
MethodArgumentNotValidException / BindException400400
MissingServletRequestParameterException400400
MethodArgumentTypeMismatchException400400
HttpRequestMethodNotSupportedException405405
HttpMediaTypeNotSupportedException415415
NoHandlerFoundException404404
其它50010000(SYSTEM_ERROR)
提示

BaseException 也返回 HTTP 500。如果你希望业务错误返回 400,改 @ResponseStatus 即可 —— 但要清楚这会影响所有监控告警的分组。

basePackages 一定要写对,否则这个 Advice 完全不生效。

Spring Boot 4 的包名变化

如果你的工程用的是 Spring Boot 4.x,starter 的包名变了:

Spring Boot 3Spring Boot 4
spring-boot-starter-webspring-boot-starter-webmvc
spring-boot-starter-aopspring-boot-starter-aspectj
spring-boot-starter-jsonspring-boot-starter-jackson

soda-core 17.0.1 已经改成依赖 spring-boot-starter-aspectj,所以你的工程如果还是 spring-boot-starter-aop,切面相关的依赖需要确认一下。

完整请求链路

text
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

下一步