在企业级管理系统(尤其是律所案件、跨国合规等高要求领域)中,80% 以上的高频请求都是列表检索与多维筛选。传统企业架构往往在此处陷入两类典型的工程反模式:
- “大宽表内存反序列化”的反模式:在 Application 层直接注入仓储拉取完整聚合根(包含大文本、富文本与 JSON 审计快照),在内存中通过 AutoMapper 转换为 DTO,带来惊人的 GC 压力与数据库网络 I/O 浪费;
- “字符串别名硬绑定”的脆弱反模式:在多表联查场景中,上层 DTO 标注硬编码物理表别名(如
t.CaseNo、t4.OppositeName),一旦底层的JOIN顺序微调或插入新表,整个查询管道瞬间因别名错位而在运行时抛出 SQL 解析异常。
BitzOrcas.Modern 通过 ADR 0303(标准列表查询内核与 QueryShape) 建立了新一代声明式查询范式:由声明式元数据在 Roslyn 编译期生成强类型执行计划,实现 100% 类型安全、AOT 零运行时反射、多表物理别名彻底解耦、手写业务条件安全扩展,以及双 ORM(SqlSugar / EF Core)对齐的极致投影下推。
1. 核心架构:QueryShape 的设计哲学
1.1 为什么严禁在 Application 层手写 LINQ 链式调用?
这种写法在复杂企业级架构下存在三大致命硬伤:
- 破坏投影下推(Projection Pushdown):手写仓储极易拉取未投影的整个实体,导致宽列(如大型合同正文、批注流)全部落入内存;
- 破坏双 ORM 行为一致性:SqlSugar 与 EF Core 对某些复杂 LINQ 表达式(如特定的空值传播、字符串方法)的翻译细节不同,手写 LINQ 会引入跨 ORM 行为漂移;
- 混淆读写语义:仓储的
FindAsync属于写侧聚合根恢复通道(附带并发版本与状态跟踪),而高频列表查询属于无状态的只读操作,二者物理通道必须隔离。
1.2 QueryShape 运行全景图
QueryShape(查询形状) 是一个编译期元数据契约,它精确定义了:
- 数据源(Source / Root / Joins):主聚合根实体与关联从表的类型归属;
- 目标行(ReadModel Row):数据库层直接构造的扁平标量投影模型;
- 可查询字段(QueryFields):受白名单保护的筛选字段、操作符与源路径;
- 排序列(Sorts):公开排序列与底层的兜底稳定排序(Tie-breaker)。
2. 编译期元数据生成机制
在 src/Framework/BitzOrcas.QueryShape.Generator 中,Roslyn 增量源生成器会扫描所有标注了 [ReadModelQuery] 的输入类:
// 1. 声明数据源与行投影,锁定全局唯一 QueryShape 标识[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")]// 2. 声明默认多级稳定排序规则[ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)][ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]public sealed partial class CaseMatterListInput : IDeclareQueryShape{ // 3. 声明模糊搜索字段,映射为 SQL Contains 谓词 [QueryField(SourcePath = nameof(CaseMatter.Title), DefaultOperator = FilterOperator.Contains)] public string? SearchText { get; init; }
// 4. 声明精确匹配状态字段 [QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)] public MatterStatus? Status { get; init; }
// 5. 标准分页参数 public int PageIndex { get; init; } public int PageSize { get; init; }}2.1 编译期生成的四大核心保障
- 自动实现
IDeclareQueryShape: 生成器产出静态的QueryDescriptor。将字段属性映射转化为强类型 Lambda 表达式(如item => item.Title),运行时零反射查找属性。 - 表达式树单 Parameter 规约:
在组合多个
Where筛选条件时,框架通过参数替换访问器(ParameterReplacementVisitor)将所有子条件统一重写到同一个根ParameterExpression,输出纯净的AndAlso/OrElse节点,杜绝 ORM 无法翻译的Expression.Invoke节点。 - 全局租户与软删除基线自动下推:
对于实现
ITenantEntity的实体,ORM 底层全局过滤器(SqlSugarAddTableFilter与 EF CoreHasQueryFilter)会自动注入租户隔离与软删除条件。业务开发者在声明 QueryShape 时严禁手写!item.IsDeleted或item.TenantId == tenantId。 - 编译期安全边界保护:
未被
[QueryField]显式标注的属性不会进入白名单,防止恶意请求探测或注入未授权字段。
3. 多表联查与物理表别名彻底解耦
3.1 为什么 DTO 标注完全不需要物理表前缀?
在老一代动态查询中,由于底层依赖弱类型字符串拼接(如 SqlSugar 的 ConditionalModel),DTO 必须硬编码物理表别名:
// 危险:强耦合当前查询特定的 Join 顺序与别名 t4[AdvancedWhere(FieldNames = new[] { "t4", nameof(CaseMatterRelatedParty.OppositeName) })]public string? OppositeName { get; set; }这一模式存在致命脆弱性:一旦业务需求变更,服务层在第 2 个位置插入了一个新的关联表(如 Office),原来的关联方表从第 4 张表变成了第 5 张表,但 DTO 上的 t4 没改,线上查询就会立刻崩溃或查错字段。
3.2 新架构的解耦机制:从实体路径到自适应 AST
在 BitzOrcas.Modern 中,DTO 标注完全面向领域实体,严禁、也完全不需要写任何 t.、t1.、t4. 等前缀:
// 声明属于主表或关联实体属性,无需关心任何物理 SQL 别名[QueryField(SourcePath = nameof(CaseMatter.ClientName))]public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatterRelatedParty.OppositeName))]public string? OppositeName { get; init; }底层工作原理解析:
- 编译期:源生成器为字段生成面向实体类型的单源 Accessor:
// Accessor 只与实体类型绑定,完全独立于具体查询的 Join 位置new QueryFieldAccessor("matter", "ClientName", (Expression<Func<CaseMatter, string>>)(m => m.ClientName))
- 执行期组装(
QueryShapeExpressionBuilder): 执行器在构建双源查询时,创建类型化的形参root = Expression.Parameter(typeof(TRoot))和join = Expression.Parameter(typeof(TJoin)),根据字段的源归属精准编织为强类型双参数 Lambda:Expression<Func<TRoot, TJoin, bool>>。 - ORM 自动映射:
以 SqlSugar 为例,
SqlSugarJoinQueryShapeExecutor将该 Expression 直接传入ISugarQueryable<TRoot, TJoin>.Where(...)。SqlSugar 表达式解析引擎根据 形参类型与位置索引 自动解析主从表,并生成合法的物理表别名([t]、[t1])。 - 绝对解耦:无论后续如何调整 Join 顺序或增加表,DTO 和 AST 均按类型自适应映射,彻底根除了历史别名错位的严重痛点。
4. 手写复杂条件(basePredicate)与外挂逻辑支持
自动化 QueryShape 覆盖了 90% 的标准化列表筛选,但在实际企业级业务中,经常会遇到特殊的业务边界:
- 当前用户数据隔离(如只看分配给当前登录律师的案件);
- 复杂跨实体冲突排查(如对方当事人与历史禁用名单的特定外挂排查);
- 动态状态机组合(如排除草稿与归档案件)。
BitzOrcas.Modern 原生支持在执行期向 QueryShape 注入强类型的 basePredicate。
4.1 单源与双源 basePredicate 注入范例
public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchMattersAsync( CaseMatterListFields fields, PaginationParams paging, string currentLawyerId, bool includeRestrictedArchive, CancellationToken cancellationToken = default){ var input = CaseMatterListInput.From(fields, paging);
// 针对单源查询:注入强类型单表谓词 return await input.ExecutePageAsync( executorFactory, static row => new CaseMatterSummaryDto(row.MatterId, row.Title, row.ClientName, row.Status), basePredicate: matter => // 1. 用户所属案件范围隔离 matter.LeadLawyerId == currentLawyerId // 2. 状态机动态逻辑(按权限排除归档案件) && (includeRestrictedArchive || matter.Status != MatterStatus.Archived), cancellationToken: cancellationToken).ConfigureAwait(false);}// 针对双源联查:直接使用双参数 Lambda,享受完全的类型安全return await joinInput.ExecutePageAsync<CaseMatter, CaseMatterRelatedParty, CaseMatterSummaryDto>( joinExecutorFactory, static (matter, party) => new CaseMatterSummaryDto(matter.Id, matter.Title, party.OppositeName), // 手写跨表业务条件:完全强类型,无需任何 t / t1 别名 basePredicate: (matter, party) => matter.OfficeId == currentOfficeId && (party.IsActive || party.RiskLevel < RiskLevel.High), cancellationToken: cancellationToken).ConfigureAwait(false);4.2 为什么手写 basePredicate 依然完全安全?
- 类型安全与重构感知:使用 C# 强类型 Lambda,实体属性重命名或类型变更会在编译期直接报错,绝无运行时隐患;
- 双 ORM 完全对齐:
- SqlSugar:直接作为
ISugarQueryable<TRoot, TJoin>.Where(basePredicate)下推; - EF Core:通过
JoinRowParameterVisitor自动重绑定为row.Root.xxx与row.Joined.xxx下推;
- SqlSugar:直接作为
- 与 DTO 筛选条件自动合并:执行器会自动将 DTO 自动生成的动态条件与
basePredicate通过Expression.AndAlso编译为单一纯净的 SQLWHERE子句。
5. 企业级实战金样板:律所案件立案检索
以下展示一个包含 HTTP 组合信封、业务窄端口、QueryShape 声明、手写隔离谓词与标量投影 的完整高价值企业级切片:
5.1 Application 层:组合信封与校验规则
using BitzOrcas.Application.Abstractions;using BitzOrcas.Application.Authorization;using BitzOrcas.Case.Contracts;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Endpoint.Attributes;using Mediator;
namespace BitzOrcas.Case.Application.Queries;
/// <summary>/// 案件列表分页检索查询信封/// </summary>[GenerateEndpoint( HttpRoute.Query, "/api/cases/matters", Tag = "CaseMatter", QueryShapeName = "Case.MatterList")]public sealed record ListCaseMattersQuery( CaseMatterListFields Fields, PaginationParams Paging, SortRequest? Sort = null) : IQuery<Result<PagedResult<CaseMatterSummaryDto>>>, IAuthorizedRequest, IPaginatedRequest{ // 权限元数据绑定 public ResourceDescriptor Resource { get; } = new(CasePermissions.Module, CasePermissions.MatterResource); public AuthorizationAction Action { get; } = AuthorizationAction.Read;}
/// <summary>/// 列表入参业务规则校验器 (Tier 1 基础验证)/// </summary>public sealed class ListCaseMattersQueryRule : IRequestRule<ListCaseMattersQuery>{ public ValueTask<Result> ValidateAsync(ListCaseMattersQuery request, CancellationToken cancellationToken) { // 校验搜索关键字长度,防止超长文本导致慢查询 if (request.Fields.SearchText is { Length: > CaseMatterListFields.SearchTextMaxLength }) { return ValueTask.FromResult(Result.Failure(CaseErrors.SearchTextTooLong)); }
// 校验分页页码必须大于等于 1 if (request.Paging.PageIndex < 1) { return ValueTask.FromResult(Result.Failure(CaseErrors.InvalidPageIndex)); }
return ValueTask.FromResult(Result.Success()); }}
/// <summary>/// 案件列表查询处理器/// </summary>public sealed class ListCaseMattersQueryHandler(ICaseMatterReadModelStore store) : IQueryHandler<ListCaseMattersQuery, Result<PagedResult<CaseMatterSummaryDto>>>{ public async ValueTask<Result<PagedResult<CaseMatterSummaryDto>>> Handle( ListCaseMattersQuery request, CancellationToken cancellationToken) { // 委托给只读窄端口执行,业务 Handler 不碰任何 ORM return await store.SearchAsync( request.Fields, request.Paging, request.Sort, cancellationToken).ConfigureAwait(false); }}5.2 Contracts 层:只读窄端口与业务筛选模型
using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;
namespace BitzOrcas.Case.Contracts;
/// <summary>/// 案件只读检索窄端口(禁止暴露宽仓储或 IQueryable)/// </summary>public interface ICaseMatterReadModelStore{ Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync( CaseMatterListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default);}
/// <summary>/// 案件筛选字段业务包/// </summary>public sealed record CaseMatterListFields( string? SearchText = null, string? ClientName = null, MatterStatus? Status = null, DateRange? SubmittedRange = null){ public const int SearchTextMaxLength = 200;}
/// <summary>/// 案件列表只读 DTO/// </summary>public sealed record CaseMatterSummaryDto( string MatterId, string SerialId, string Title, string ClientName, MatterStatus Status, DateTimeOffset SubmittedOn);5.3 Infrastructure 层:QueryShape 与 ReadModelStore 实现
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.Case.Contracts;using BitzOrcas.Case.Domain;using BitzOrcas.DI.Attributes;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Infrastructure.Queries;
namespace BitzOrcas.Case.Infrastructure;
[RegisterPersistenceAdapter<ICaseMatterReadModelStore>]public sealed class CaseMatterReadModelStore( IQueryShapeExecutorFactory executorFactory, ICurrentTenant currentTenant) : ICaseMatterReadModelStore{ public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync( CaseMatterListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(fields); ArgumentNullException.ThrowIfNull(paging);
// 验证当前租户上下文 if (!currentTenant.Tenant.IsAvailable) { return Result.Failure<PagedResult<CaseMatterSummaryDto>>(CaseErrors.TenantRequired); }
// 1. 在 From 中完成唯一的单点分页归一化 var input = CaseMatterListInput.From(fields, paging);
// 2. 执行 QueryShape 投影下推查询,并注入业务基础谓词 return await input.ExecutePageAsync( executorFactory, static row => new CaseMatterSummaryDto( row.MatterId, row.SerialId, row.Title, row.ClientName, row.Status, row.SubmittedOn), basePredicate: matter => matter.Status != MatterStatus.DeletedDraft, sorts: sort.WithTieBreaker(nameof(CaseMatterListInput.MatterId)), cancellationToken: cancellationToken).ConfigureAwait(false); }}
/// <summary>/// 案件列表 QueryShape 声明模型/// </summary>[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")][ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)][ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]public sealed partial class CaseMatterListInput : IDeclareQueryShape{ public static CaseMatterListInput From(CaseMatterListFields fields, PaginationParams paging) { // 核心规范:单点安全归一化 var normalized = paging.Normalize(); return new CaseMatterListInput { SearchText = string.IsNullOrWhiteSpace(fields.SearchText) ? null : fields.SearchText.Trim(), ClientName = string.IsNullOrWhiteSpace(fields.ClientName) ? null : fields.ClientName.Trim(), Status = fields.Status, SubmittedFrom = fields.SubmittedRange?.From, SubmittedTo = fields.SubmittedRange?.To, PageIndex = normalized.PageIndex, PageSize = normalized.PageSize, }; }
[QueryField(SourcePath = nameof(CaseMatter.Title), Default = true, DefaultOperator = FilterOperator.Contains)] public string? SearchText { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.ClientName), DefaultOperator = FilterOperator.Contains)] public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)] public MatterStatus? Status { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.GreaterThanOrEqual)] public DateTimeOffset? SubmittedFrom { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.LessThanOrEqual)] public DateTimeOffset? SubmittedTo { get; init; }
[QueryField(Filterable = false, Visible = false)] public DateTimeOffset SubmittedOn { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Id), Filterable = false, Visible = false)] public string? MatterId { get; init; }
public int PageIndex { get; init; } public int PageSize { get; init; }}
/// <summary>/// 纯标量投影行模型(按需生成 SELECT 列表,杜绝宽实体拉取)/// </summary>internal sealed record CaseMatterListRow{ [ReadModelProjection(nameof(CaseMatter.Id))] public string MatterId { get; init; } = string.Empty;
public string SerialId { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ClientName { get; init; } = string.Empty;
public MatterStatus Status { get; init; }
public DateTimeOffset SubmittedOn { get; init; }}6. 配套单元测试与 HTTP 负载示例
6.1 单元测试金样板:验证 QueryShape 执行器与手写谓词
using System.Linq.Expressions;using BitzOrcas.Case.Contracts;using BitzOrcas.Case.Domain;using BitzOrcas.Case.Infrastructure;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Infrastructure.Queries;using Shouldly;using Xunit;
namespace BitzOrcas.Unit.Tests.Case;
public sealed class CaseMatterQueryShapeTests{ [Fact] public void CaseMatterListInput_Should_Declare_Accurate_QueryDescriptor_Without_Reflection() { // 1. 验证编译期生成的 IDeclareQueryShape 描述符 var descriptor = ((IDeclareQueryShape)new CaseMatterListInput()).QueryDescriptor; descriptor.ShouldNotBeNull(); descriptor.Shape.Name.ShouldBe("Case.MatterList");
// 2. 验证字段白名单与操作符生成 var titleField = descriptor.Shape.Fields.FirstOrDefault(f => f.InputField == nameof(CaseMatterListInput.SearchText)); titleField.ShouldNotBeNull(); titleField.Allows(FilterOperator.Contains).ShouldBeTrue();
// 3. 验证强类型访问器直接可用 var accessor = descriptor.ResolveAccessor("item", nameof(CaseMatter.Title)); accessor.IsSuccess.ShouldBeTrue(); }
[Fact] public void BasePredicate_Should_Correctly_Filter_Deleted_Draft_Matters() { // 验证手写条件 basePredicate 的表达式逻辑 Expression<Func<CaseMatter, bool>> basePredicate = matter => matter.Status != MatterStatus.DeletedDraft; var compiled = basePredicate.Compile();
var activeMatter = new CaseMatter { Status = MatterStatus.Active }; var draftMatter = new CaseMatter { Status = MatterStatus.DeletedDraft };
// 断言业务过滤行为正确 compiled(activeMatter).ShouldBeTrue(); compiled(draftMatter).ShouldBeFalse(); }}6.2 HTTP 请求与响应 JSON 负载
{ "fields": { "searchText": "知识产权诉讼", "status": "Active", "submittedRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-12-31T23:59:59Z" } }, "paging": { "pageIndex": 1, "pageSize": 20 }, "sort": { "field": "submittedOn", "descending": true }}{ "isSuccess": true, "data": { "items": [ { "matterId": "matter-01912a3b-4c5d-7e8f-9a0b-1c2d3e4f5a6b", "serialId": "CASE-2026-0889", "title": "跨国专利侵权知识产权诉讼一审", "clientName": "某高新科技股份有限公司", "status": "Active", "submittedOn": "2026-08-20T14:30:00Z" } ], "totalCount": 1, "pageIndex": 1, "pageSize": 20, "totalPages": 1, "hasPreviousPage": false, "hasNextPage": false }}7. 分页限制与防御性单点归一化矩阵
在实际生产中,前端页面偶尔可能会传出非法的分页参数(如 pageIndex = 0 或 pageSize = 5000)。BitzOrcas.Modern 确立了防损优先(Defense in Depth) 的分页归一化契约:
public sealed record PaginationParams( int PageIndex = 1, int PageSize = PagingLimits.DefaultPageSize){ // 执行安全截断与硬边界归一化 public PaginationParams Normalize() => new( PagingLimits.NormalizePageIndex(PageIndex), PagingLimits.NormalizePageSize(PageSize));}7.1 PagingLimits 规范矩阵
| 场景 | 输入值 | 归一化后结果 | 设计原因 |
|---|---|---|---|
| 页码非正 | pageIndex <= 0 | 1 | 页码从 1 开始计起,杜绝 SQL 偏移负数 |
| 页大小为空/非正 | pageSize <= 0 | 20 (DefaultPageSize) | 回退到全库统一默认值 |
| 正常页大小 | 1 <= pageSize <= 1000 | 原样保留 | 正常业务范围 |
| 超大页大小 | pageSize > 1000 | 1000 (MaxPageSize) | 自动截断到 1000,绝对不报 400 错误! |
8. 总结与最佳实践清单
- DTO 声明绝不带物理表前缀:严禁在
[QueryField]中填写t.、t1.等物理表别名,纯粹面向聚合根实体成员; - 读写分离与窄端口:查询 Handler 仅注入
I*ReadModelStore,严禁在 Application 层暴露IRepository或IQueryable; - 精准标量投影:使用
ReadModelProjection行模型直接按列生成 SELECT 语句,彻底杜绝全宽表物化; - 手写条件走强类型 Lambda:复杂业务与范围隔离通过
basePredicate注入,享受 100% 编译期重构检查与双 ORM 自动适配。