> 技术文档 > ABP VNext + OData:实现可查询的 REST API

ABP VNext + OData:实现可查询的 REST API


🚀 ABP VNext + OData:实现可查询的 REST API


📚 目录

  • 🚀 ABP VNext + OData:实现可查询的 REST API
    • 一、版本说明 📦
    • 二、环境与依赖 ⚙️
    • 三、模块化注册 OData 与跨域 🌐
    • 四、实体 & DTO & MappingProfile 🗂️
    • 五、OData 控制器实现 🛠️
    • 六、全局 QuerySettings(可选简化方案) 🔄
    • 七、动态查询 & 导出示例 📈
    • 八、安全与性能最佳实践 🔒⚡
    • 九、配置示例:appsettings.json 📝
    • 十、端到端 Sequence 图 📊

一、版本说明 📦

组件 版本 .NET SDK .NET 6+ ABP VNext 6+ Microsoft.AspNetCore.OData 8.0.8 AutoMapper.Extensions.ExpressionMapping 12.0.x Swashbuckle.AspNetCore.OData 8.0.x

Tip:本文示例已在以上环境中验证通过,如有版本差异,请以官方文档为准。


二、环境与依赖 ⚙️

dotnet add package Microsoft.AspNetCore.OData --version 8.0.8dotnet add package Microsoft.OData.ModelBuilderdotnet add package AutoMapper.Extensions.ExpressionMappingdotnet add package Swashbuckle.AspNetCore.OData

三、模块化注册 OData 与跨域 🌐

下面展示模块化注册 OData 中间件、启用 CORS、Swagger 扩展的完整流程:

#mermaid-svg-WcszWp5XIZbsBUsn {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .error-icon{fill:#552222;}#mermaid-svg-WcszWp5XIZbsBUsn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WcszWp5XIZbsBUsn .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-WcszWp5XIZbsBUsn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WcszWp5XIZbsBUsn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WcszWp5XIZbsBUsn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WcszWp5XIZbsBUsn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WcszWp5XIZbsBUsn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WcszWp5XIZbsBUsn .marker.cross{stroke:#333333;}#mermaid-svg-WcszWp5XIZbsBUsn svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WcszWp5XIZbsBUsn .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .cluster-label text{fill:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .cluster-label span{color:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .label text,#mermaid-svg-WcszWp5XIZbsBUsn span{fill:#333;color:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .node rect,#mermaid-svg-WcszWp5XIZbsBUsn .node circle,#mermaid-svg-WcszWp5XIZbsBUsn .node ellipse,#mermaid-svg-WcszWp5XIZbsBUsn .node polygon,#mermaid-svg-WcszWp5XIZbsBUsn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WcszWp5XIZbsBUsn .node .label{text-align:center;}#mermaid-svg-WcszWp5XIZbsBUsn .node.clickable{cursor:pointer;}#mermaid-svg-WcszWp5XIZbsBUsn .arrowheadPath{fill:#333333;}#mermaid-svg-WcszWp5XIZbsBUsn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WcszWp5XIZbsBUsn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WcszWp5XIZbsBUsn .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-WcszWp5XIZbsBUsn .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-WcszWp5XIZbsBUsn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WcszWp5XIZbsBUsn .cluster text{fill:#333;}#mermaid-svg-WcszWp5XIZbsBUsn .cluster span{color:#333;}#mermaid-svg-WcszWp5XIZbsBUsn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WcszWp5XIZbsBUsn :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} Client Request CORS Middleware Routing & OData Middleware ProductsController GetQueryableAsync() Database Query Apply Filter & ProjectTo Serialize JSON + @odata.count Client Response

using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Cors.Infrastructure;using Microsoft.AspNetCore.OData;using Microsoft.Extensions.DependencyInjection;using Microsoft.OData.Edm;using Microsoft.OData.ModelBuilder;using Swashbuckle.AspNetCore.OData;using Volo.Abp.AspNetCore.Mvc;using Volo.Abp.Modularity;namespace YourProject.Web{ [DependsOn(typeof(AbpAspNetCoreMvcModule))] public class YourProjectWebModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { var configuration = context.Services.GetConfiguration(); var odataCfg = configuration.GetSection(\"OData\"); var prefix = odataCfg[\"RoutePrefix\"] ?? \"api/odata\"; var maxTop = odataCfg.GetValue<int>(\"MaxTop\", 100); var pageSize = odataCfg.GetValue<int>(\"PageSize\", 50); var maxDepth = odataCfg.GetValue<int>(\"MaxExpansionDepth\", 3); // 1️⃣ 跨域配置 context.Services.AddCors(options => { options.AddDefaultPolicy(builder =>  builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); }); // 2️⃣ 注册 OData + 属性路由 context.Services.AddControllers() .AddOData(opt => opt  .Select()  .Filter()  .OrderBy()  .Expand()  .Count()  .SetMaxTop(maxTop)  // 限制最大 $top  .MaxExpansionDepth(maxDepth) // 限制最大 $expand 深度  .AddRouteComponents( prefix, // 路由前缀 GetEdmModel(), services => services.EnableAttributeRouting = true  )); // 3️⃣ Swagger & OData 扩展 context.Services.AddSwaggerGen(c => { c.AddOData(prefix, GetEdmModel()); }); } public override void OnApplicationInitialization(ApplicationInitializationContext ctx) { var app = ctx.GetApplicationBuilder(); // 中间件执行顺序按 ASP.NET Core 最佳实践 app.UseRouting(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.UseSwagger(); app.UseSwaggerUI(c =>  c.SwaggerEndpoint(\"/swagger/v1/swagger.json\", \"Your API V1\")); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } // 构建 EDM 模型 public static IEdmModel GetEdmModel() { var builder = new ODataConventionModelBuilder(); // --- ProductDto EDM 定义 --- var productType = builder.EntityType<ProductDto>(); productType.HasKey(p => p.Id); productType.HasETag(p => p.LastModified); builder.EntitySet<ProductDto>(\"Products\"); // --- OrderDto EDM 定义 --- var orderType = builder.EntityType<OrderDto>(); orderType.HasKey(o => o.Id); builder.EntitySet<OrderDto>(\"Orders\"); // --- 自定义 Function:MostExpensive(count) --- var fn = builder.Function(\"MostExpensive\"); fn.Parameter<int>(\"count\"); fn.ReturnsCollectionFromEntitySet<ProductDto>(\"Products\"); return builder.GetEdmModel(); } }}

四、实体 & DTO & MappingProfile 🗂️

using System;using System.ComponentModel.DataAnnotations;using Volo.Abp.Domain.Entities.Auditing;namespace YourProject.Entities{ public class Product : AuditedAggregateRoot<Guid> { public string Name { get; set; } public decimal Price { get; set; } public bool IsDeleted { get; set; } [ConcurrencyCheck] // 用于 ETag 并发控制 public DateTimeOffset LastModified { get; set; } }}namespace YourProject.Dtos{ public class ProductDto { public Guid Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public DateTimeOffset LastModified { get; set; } // 用于 ETag }}using AutoMapper;namespace YourProject{ public class YourMappingProfile : Profile { public YourMappingProfile() { CreateMap<Product, ProductDto>(); // LastModified 同名映射,无需额外 ForMember } }}

五、OData 控制器实现 🛠️

using System;using System.Linq;using AutoMapper;using AutoMapper.QueryableExtensions;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.OData.Deltas;using Microsoft.AspNetCore.OData.Query;using Microsoft.AspNetCore.OData.Routing.Attributes;using Microsoft.AspNetCore.OData.Routing.Controllers;using Volo.Abp.Domain.Repositories;using YourProject.Dtos;using YourProject.Entities;namespace YourProject.Web.Controllers{ [ODataRoutePrefix(\"Products\")] [Authorize(AbpPermissions.Products.Default)] public class ProductsController : ODataController { private readonly IRepository<Product, Guid> _repo; private readonly IMapper _mapper; public ProductsController(IRepository<Product, Guid> repo, IMapper mapper) { _repo = repo; _mapper = mapper; } ///  /// GET /api/odata/Products /// 支持 $filter, $orderby, $select, $skip/$top, $count ///  [EnableQuery( PageSize = 50, MaxExpansionDepth = 3, // 排除 $apply, $search AllowedQueryOptions = AllowedQueryOptions.All & ~AllowedQueryOptions.Apply & ~AllowedQueryOptions.Search )] [ODataRoute] public IActionResult Get() { var q = _repo.GetQueryableAsync().Result; // 或使用 await/Task q = q.Where(p => !p.IsDeleted); var projected = q.ProjectTo<ProductDto>(_mapper.ConfigurationProvider); return Ok(projected); } ///  /// GET /api/odata/Products/MostExpensive(count=5) /// 自定义 Function:MostExpensive ///  [EnableQuery(PageSize = 50, AllowedQueryOptions = AllowedQueryOptions.Select)] [ODataRoute(\"MostExpensive(count={count})\")] public IActionResult MostExpensive([FromODataUri] int count) { var q = _repo.GetQueryableAsync().Result; var topN = q.Where(p => !p.IsDeleted) .OrderByDescending(p => p.Price) .Take(count) .ProjectTo<ProductDto>(_mapper.ConfigurationProvider); return Ok(topN); } ///  /// PATCH /api/odata/Products({id}) /// 启用 ETag 并发检查 ///  [EnableQuery] [AcceptVerbs(\"PATCH\")] [ODataRoute(\"({id})\")] public IActionResult Patch([FromODataUri] Guid id, Delta<Product> delta) { var entity = _repo.GetAsync(id).Result; delta.Patch(entity); // If-Match 校验失败会抛 412 _repo.UpdateAsync(entity).Wait(); return Updated(entity); } }}

💡Tips

  • 控制器继承自 ODataController,以获取 OData 原生的 Ok(), Updated() 等返回结果。
  • 若需异步完整,请将 .Result.Wait() 改为 async/await,并更改方法签名为 async Task

六、全局 QuerySettings(可选简化方案) 🔄

context.Services.AddOData(opt => opt .Select().Filter().OrderBy().Expand().Count() .QuerySettings(new DefaultQuerySettings { PageSize = 50, MaxExpansionDepth = 3, EnableFilter = true, EnableSelect = true, EnableOrderBy = true, EnableSkip = true, EnableTop = true }) .AddRouteComponents(\"api/odata\", GetEdmModel(), svc => svc.EnableAttributeRouting = true));

使用全局 QuerySettings 后,Controller 上可仅写 [EnableQuery]


七、动态查询 & 导出示例 📈

  • 筛选 & 排序

    GET /api/odata/Products? $filter=Price ge 100 and contains(Name,\'Pro\')& $orderby=Price desc
  • 分页 & 计数

    &$top=10&$skip=20&$count=true
  • 投影 & 展开

    &$select=Id,Name&$expand=Category($select=Name)

导出 CSV 示例

[HttpGet(\"export\")]public async Task<FileResult> ExportCsv([FromQuery] ODataQueryOptions<ProductDto> opts){ var q = await _repo.GetQueryableAsync(); var list = opts.ApplyTo(q).Cast<ProductDto>().ToList(); var csv = CsvHelper.Write(list); return File(Encoding.UTF8.GetBytes(csv), \"text/csv\", \"products.csv\");}

八、安全与性能最佳实践 🔒⚡

  1. 限流SetMaxTop(100)PageSize=50 防止一次性查询过大数据。
  2. 禁止高危选项:排除 $apply$search,避免聚合或全文搜索滥用。
  3. ETag 并发:结合 PATCH + If-Match,失败返回 412 Precondition Failed
  4. 缓存:对静态或少变资源开启 Redis 缓存,并结合 ETag 实现 304 Not Modified
  5. 索引优化:为常用筛选字段(如 PriceLastModified)建立数据库索引。
  6. 慢查询监控:记录 $filter / $orderby 参数与执行时长,设置多级告警阈值(200ms/500ms/1s)。

九、配置示例:appsettings.json 📝

{ \"Logging\": { \"LogLevel\": { \"Default\": \"Information\" } }, \"AllowedHosts\": \"*\", \"OData\": { \"RoutePrefix\": \"api/odata\", \"MaxTop\": 100, \"PageSize\": 50, \"MaxExpansionDepth\": 3 }}

十、端到端 Sequence 图 📊

#mermaid-svg-HJBagSo57KnBpGfD {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-HJBagSo57KnBpGfD .error-icon{fill:#552222;}#mermaid-svg-HJBagSo57KnBpGfD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HJBagSo57KnBpGfD .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-HJBagSo57KnBpGfD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HJBagSo57KnBpGfD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HJBagSo57KnBpGfD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HJBagSo57KnBpGfD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HJBagSo57KnBpGfD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HJBagSo57KnBpGfD .marker.cross{stroke:#333333;}#mermaid-svg-HJBagSo57KnBpGfD svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HJBagSo57KnBpGfD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HJBagSo57KnBpGfD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HJBagSo57KnBpGfD .actor-line{stroke:grey;}#mermaid-svg-HJBagSo57KnBpGfD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HJBagSo57KnBpGfD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HJBagSo57KnBpGfD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HJBagSo57KnBpGfD .sequenceNumber{fill:white;}#mermaid-svg-HJBagSo57KnBpGfD #sequencenumber{fill:#333;}#mermaid-svg-HJBagSo57KnBpGfD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HJBagSo57KnBpGfD .messageText{fill:#333;stroke:#333;}#mermaid-svg-HJBagSo57KnBpGfD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HJBagSo57KnBpGfD .labelText,#mermaid-svg-HJBagSo57KnBpGfD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HJBagSo57KnBpGfD .loopText,#mermaid-svg-HJBagSo57KnBpGfD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HJBagSo57KnBpGfD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HJBagSo57KnBpGfD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HJBagSo57KnBpGfD .noteText,#mermaid-svg-HJBagSo57KnBpGfD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HJBagSo57KnBpGfD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HJBagSo57KnBpGfD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HJBagSo57KnBpGfD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HJBagSo57KnBpGfD .actorPopupMenu{position:absolute;}#mermaid-svg-HJBagSo57KnBpGfD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-HJBagSo57KnBpGfD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HJBagSo57KnBpGfD .actor-man circle,#mermaid-svg-HJBagSo57KnBpGfD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HJBagSo57KnBpGfD :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} Client OData Middleware ProductsController Repository GET /api/odata/Products?... Invoke Get() GetQueryableAsync() IQueryable Where + ProjectTo IQueryable JSON + @odata.count Client OData Middleware ProductsController Repository