soda docs v17.0.1

写一个查询

读链路比写链路简单:没有事务、没有事件、不改状态。但它同样有固定的形状 —— Query → QueryBus → QueryHandler → 读侧仓储 → 组装器 → DTO。

五个步骤

  1. 定义 Query(继承 BaseQuery<R> 或 BasePageQuery<R>)
  2. 定义读侧仓储接口(放在 domain 包)
  3. 实现 QueryHandler
  4. 在仓储实现里查数据并转成领域对象
  5. 用组装器把领域对象转成 DTO

第一步:定义 Query

单条查询

继承 BaseQuery<R>,R 是返回类型:

java
package com.example.demo.application.cqrs.query;

import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.cqrs.query.BaseQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "GetProducerQuery model")
public class GetProducerQuery extends BaseQuery<Producer> {
    @Schema(description = "uid", example = "pid-dkljkcdfd")
    private String uid;
}

BaseQuery<R> 自带这些上下文字段(都带 @Schema(hidden = true),不会出现在接口文档里):

字段类型说明
requestIdString链路追踪 ID
callerUidLong调用方 UID
userNameString用户名
authoritiesString权限串
jtiStringJWT token id
jwtStringJWT token
tenantIdString租户 ID
userInfoString用户信息
extensionString扩展字段

分页查询

继承 BasePageQuery<R>,额外带分页参数:

java
package com.example.demo.application.cqrs.query;

import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.hibuka.soda.cqrs.query.BasePageQuery;
import com.hibuka.soda.foundation.io.PageVO;
import lombok.Data;

@Data
public class ListProducerQuery extends BasePageQuery<PageVO<ProducerDTO>> {
}

BasePageQuery<R> 是消息基类,不是 Handler —— 它只是把分页参数和默认值预制好了。ListProducerQuery 仍然需要一个 QueryHandler 来处理,和普通 Query 没有任何区别。

它的常量与默认值:

项值
pageNumber 默认值1
pageSize 默认值20
MAX_PAGE_SIZE200
DEFAULT_PAGE_SIZE20
DEFAULT_PAGE_NUMBER1

两个「带默认值」的取值方法才是真正该用的:

java
public int getPageSizeWithDefault() {
    return (pageSize == null || pageSize <= 0 || pageSize > MAX_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : pageSize;
}

public int getPageNumberWithDefault() {
    return (pageNumber == null || pageNumber <= 0) ? DEFAULT_PAGE_NUMBER : pageNumber;
}

也就是说 pageSize 传 0、负数或超过 200,都会退回 20。传 null 也不会 NPE。

技巧

一定要用 getPageSizeWithDefault() 而不是 getPageSize()。后者拿到的是原始值,用户传 pageSize=9999 时会直接打到数据库上。

第二步:读侧仓储接口

soda 没有为读侧提供基类接口 —— 读侧各家差异太大,强行抽象反而碍事。自己在 domain 包里声明就行:

java
package com.example.demo.domain.producer.repository;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.foundation.error.BaseException;

public interface ProducerReadRepository {
    Producer getByUid(String uid) throws BaseException;

    Page<Producer> list(ListProducerQuery query) throws BaseException;
}

两个要点:

  • 接口放在 domain 包,和 ProducerWriteRepository 放在一起。
  • 返回领域对象,不是 DO。DO 到领域对象的转换在实现里做。
提示

读侧接口有个小争议:方法参数里用了 ListProducerQuery(应用层的类型),这让领域层依赖了应用层。严格的整洁架构会要求领域层不认识 Query。实践中两种做法都有 —— 要么把查询条件抽成领域层的参数对象,要么接受这个轻微的耦合。

第三步:实现 QueryHandler

java
package com.example.demo.application.cqrs.handler.query;

import com.example.demo.application.cqrs.query.GetProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.example.demo.domain.producer.repository.ProducerReadRepository;
import com.hibuka.soda.cqrs.query.QueryHandler;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.ParamChecker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class GetProducerQueryHandler implements QueryHandler<GetProducerQuery, Producer> {
    private final ProducerReadRepository producerReadRepository;

    @Override
    public Producer handle(GetProducerQuery query) throws BaseException {
        queryValidate(query);
        return producerReadRepository.getByUid(query.getUid());
    }

    private void queryValidate(GetProducerQuery query) throws BaseException {
        ParamChecker.notNull(query, "query");
        ParamChecker.notNull(query.getUid(), "uid");
    }
}

QueryHandler 的三段式结构是固定的:

  1. ParamChecker 校验入参
  2. 调仓储
  3. 返回结果

QueryHandler<Q extends Query<R>, R> 的 handle 方法签名是 R handle(Q query) throws BaseException。BaseException 是 RuntimeException 的子类,所以 throws 声明可以省,但保留它能让读者知道这个方法可能失败。

分页 QueryHandler

分页的 QueryHandler 多一步:从 MyBatis-Plus 的 Page 转成 PageVO:

java
@Component
@RequiredArgsConstructor
public class ListProducerQueryHandler implements QueryHandler<ListProducerQuery, PageVO<ProducerDTO>> {
    private final ProducerReadRepository producerReadRepository;

    @Override
    public PageVO<ProducerDTO> handle(ListProducerQuery query) throws BaseException {
        queryValidate(query);
        Page<Producer> page = producerReadRepository.list(query);
        PageVO<Producer> pageVo = new PageVO<>();
        pageVo.setTotal(page.getTotal());
        pageVo.setList(page.getRecords());
        return ProducerWebAssembler.toDTO(pageVo);
    }

    private void queryValidate(ListProducerQuery query) throws BaseException {
        ParamChecker.notNull(query, "query");
    }
}

第四步:仓储实现

java
package com.example.demo.infrastructure.repositoryImpl.producer;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.example.demo.domain.producer.repository.ProducerReadRepository;
import com.example.demo.infrastructure.acl.mysql.entity.ScodaProducer;
import com.example.demo.infrastructure.acl.mysql.mapper.ScodaProducerMapper;
import com.example.demo.infrastructure.assembler.ProducerAssembler;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ProducerReadRepositoryImpl implements ProducerReadRepository {
    private final ScodaProducerMapper scodaProducerMapper;

    @Override
    public Producer getByUid(String uid) throws BaseException {
        try {
            LambdaQueryWrapper<ScodaProducer> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(ScodaProducer::getUid, uid);
            ScodaProducer scodaProducer = scodaProducerMapper.selectOne(wrapper);
            if (null == scodaProducer) {
                return null;
            }
            return ProducerAssembler.toDomain(scodaProducer);
        } catch (Exception e) {
            throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
        }
    }
}

异常统一包成 BaseException,错误码用 BaseErrorCode.DATABASE_ERROR(10004)。这样 Web 层只需要识别 BaseException 一种异常类型。

第五步:组装器转 DTO

领域对象不能直接序列化给前端(会泄漏内部字段,也不好控制结构)。用 Web 组装器转:

java
package com.example.demo.adapter.inbound.web.assembler;

import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.foundation.io.PageVO;
import org.springframework.util.CollectionUtils;

import java.util.stream.Collectors;

public class ProducerWebAssembler {

    public static ProducerDTO toDTO(Producer producer) {
        if (producer == null) {
            return null;
        }
        ProducerDTO to = new ProducerDTO();
        to.setUid(producer.getUid());
        to.setName(producer.getName());
        return to;
    }

    public static PageVO<ProducerDTO> toDTO(PageVO<Producer> page) {
        if (page == null) {
            return null;
        }
        PageVO<ProducerDTO> to = new PageVO<>();
        to.setTotal(page.getTotal());
        if (CollectionUtils.isEmpty(page.getList())) {
            to.setList(null);
            return to;
        }
        to.setList(page.getList().stream().map(ProducerWebAssembler::toDTO).collect(Collectors.toList()));
        return to;
    }
}

PageVO<T> 继承 PageDTO<T>,字段:

字段类型说明
totalLong总记录数(继承自 PageDTO)
listList<T>数据列表(继承自 PageDTO)
pageNoInteger当前页码,从 1 开始
pageSizeInteger每页条数
pagesLong总页数
hasNextBoolean是否有下一页
hasPreviousBoolean是否有上一页
startIndexLong当前页起始索引
endIndexLong当前页结束索引

PageVO 的构造函数会自动算好分页元信息:

java
public PageVO(Long total, List<T> list, Integer pageNo, Integer pageSize) {
    super(total, list);
    this.pageNo = pageNo != null ? Math.max(pageNo, 1) : 1;
    this.pageSize = pageSize != null ? Math.min(Math.max(pageSize, 1), 100) : 10;
    calculatePaginationMetadata();
}
注意

PageVO 构造时会把 pageSize 夹在 1~100 之间,但 BasePageQuery.getPageSizeWithDefault() 的上限是 200。两者上限不一致 —— 如果你在 Controller 里用 query.getPageSizeWithDefault() 传给 PageVO,200 会被夹成 100。分页大小时保持一致,或者统一用其中一个上限。

在 Controller 里返回

分页响应用 PageResult<T>:

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

PageResult<T> 继承 Result<T>,在响应体上多带 total、pageNo、pageSize、pages、hasNext、hasPrevious、startIndex、endIndex 八个字段。

PageResult.success 的参数顺序是 (data, total, pageNo, pageSize) —— 注意 total 在第二位。

重要

在 4.0.0 之前,PageResult.success 的参数顺序是 (data, pageNo, pageSize, total)。如果你从旧版本升级,这里需要改。详见 从旧版本升级。

单条查询用 Result<T>:

java
@PostMapping("getProducer")
public Result<ProducerDTO> getProducer(@RequestBody GetProducerQuery query) throws BaseException {
    ProducerDTO dto = producerAppService.getProducer(query);
    return Result.success(dto);
}

通过 BusFacade 发查询

应用服务里用 BusFacade 把 Query 送进总线:

java
@Service
@RequiredArgsConstructor
public class ProducerAppServiceImpl implements ProducerAppService {
    private final BusFacade busFacade;

    @Override
    public ProducerDTO getProducer(GetProducerQuery query) throws BaseException {
        Producer domain = busFacade.sendQuery(query);
        return ProducerWebAssembler.toDTO(domain);
    }
}

BusFacade.sendQuery 的签名是 <Q extends BaseQuery<R>, R> R sendQuery(Q query) throws BaseException。

如果 QueryHandler 直接返回 DTO(像分页 QueryHandler 那样),应用服务就不用再转一次:

java
@Override
public PageVO<ProducerDTO> listProducer(ListProducerQuery query) throws BaseException {
    return busFacade.sendQuery(query);
}

完整链路一览

text
HTTP POST /api/getProducer
  │
  ├─ ControllerAroundHandler:从请求头取 requestId,写入 Query,设置 MDC
  │
  ├─ Controller:把请求体绑成 GetProducerQuery
  │
  ├─ ProducerAppService:busFacade.sendQuery(query)
  │
  ├─ QueryBus.send(query) → 找到 GetProducerQueryHandler
  │
  ├─ CqrsAroundHandler:记录耗时和入参出参
  │
  ├─ GetProducerQueryHandler.handle(query)
  │    ├─ ParamChecker 校验
  │    └─ producerReadRepository.getByUid(uid)
  │          └─ Mapper → ScodaProducer → ProducerAssembler.toDomain → Producer
  │
  ├─ ProducerWebAssembler.toDTO(producer) → ProducerDTO
  │
  └─ Result.success(dto)

常见问题

Q:查不到数据返回什么?

getByUid 返回 null,组装器返回 null,Controller 把它包进 Result.success(null)。响应体是 {"code":200,"message":"","data":null,"success":true}。如果希望 404,可以在 QueryHandler 里抛 BaseException(BaseErrorCode.NOT_FOUND.getCode(), ...)。

Q:分页查询要不要加事务?

不要。读链路没有状态变更,加事务只会白白占住数据库连接。

Q:QueryHandler 能注入 CommandHandler 吗?

技术上可以(它们都是 Spring Bean),但这会让读链路触发写操作,破坏 CQRS 的前提。如果确实需要(比如查询后触发一次写),走命令总线,不要在 QueryHandler 里直接调。

Q:同一个 Query 能有多个 QueryHandler 吗?

不能。SimpleQueryBus 用 ConcurrentHashMap 以 Query 类型为键,一个类型只能注册一个 QueryHandler。后注册的会覆盖前面的。

下一步