写一个查询
读链路比写链路简单:没有事务、没有事件、不改状态。但它同样有固定的形状 —— Query → QueryBus → QueryHandler → 读侧仓储 → 组装器 → DTO。
五个步骤
- 定义 Query(继承
BaseQuery<R>或BasePageQuery<R>) - 定义读侧仓储接口(放在
domain包) - 实现 QueryHandler
- 在仓储实现里查数据并转成领域对象
- 用组装器把领域对象转成 DTO
第一步:定义 Query
单条查询
继承 BaseQuery<R>,R 是返回类型:
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),不会出现在接口文档里):
| 字段 | 类型 | 说明 |
|---|---|---|
requestId | String | 链路追踪 ID |
callerUid | Long | 调用方 UID |
userName | String | 用户名 |
authorities | String | 权限串 |
jti | String | JWT token id |
jwt | String | JWT token |
tenantId | String | 租户 ID |
userInfo | String | 用户信息 |
extension | String | 扩展字段 |
分页查询
继承 BasePageQuery<R>,额外带分页参数:
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_SIZE | 200 |
DEFAULT_PAGE_SIZE | 20 |
DEFAULT_PAGE_NUMBER | 1 |
两个「带默认值」的取值方法才是真正该用的:
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 包里声明就行:
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
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 的三段式结构是固定的:
ParamChecker校验入参- 调仓储
- 返回结果
QueryHandler<Q extends Query<R>, R> 的 handle 方法签名是 R handle(Q query) throws BaseException。BaseException 是 RuntimeException 的子类,所以 throws 声明可以省,但保留它能让读者知道这个方法可能失败。
分页 QueryHandler
分页的 QueryHandler 多一步:从 MyBatis-Plus 的 Page 转成 PageVO:
@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");
}
}第四步:仓储实现
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 组装器转:
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>,字段:
| 字段 | 类型 | 说明 |
|---|---|---|
total | Long | 总记录数(继承自 PageDTO) |
list | List<T> | 数据列表(继承自 PageDTO) |
pageNo | Integer | 当前页码,从 1 开始 |
pageSize | Integer | 每页条数 |
pages | Long | 总页数 |
hasNext | Boolean | 是否有下一页 |
hasPrevious | Boolean | 是否有上一页 |
startIndex | Long | 当前页起始索引 |
endIndex | Long | 当前页结束索引 |
PageVO 的构造函数会自动算好分页元信息:
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>:
@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>:
@PostMapping("getProducer")
public Result<ProducerDTO> getProducer(@RequestBody GetProducerQuery query) throws BaseException {
ProducerDTO dto = producerAppService.getProducer(query);
return Result.success(dto);
}通过 BusFacade 发查询
应用服务里用 BusFacade 把 Query 送进总线:
@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 那样),应用服务就不用再转一次:
@Override
public PageVO<ProducerDTO> listProducer(ListProducerQuery query) throws BaseException {
return busFacade.sendQuery(query);
}完整链路一览
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。后注册的会覆盖前面的。