🚀 ABP VNext + OData:实现可查询的 REST API
📚 目录
一、版本说明 📦
组件 | 版本 |
---|---|
.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.8
dotnet add package Microsoft.OData.ModelBuilder
dotnet add package AutoMapper.Extensions.ExpressionMapping
dotnet add package Swashbuckle.AspNetCore.OData
三、模块化注册 OData 与跨域 🌐
下面展示模块化注册 OData 中间件、启用 CORS、Swagger 扩展的完整流程:
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;
}
/// <summary>
/// GET /api/odata/Products
/// 支持 $filter, $orderby, $select, $skip/$top, $count
/// </summary>
[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<IActionResult>
q = q.Where(p => !p.IsDeleted);
var projected = q.ProjectTo<ProductDto>(_mapper.ConfigurationProvider);
return Ok(projected);
}
/// <summary>
/// GET /api/odata/Products/MostExpensive(count=5)
/// 自定义 Function:MostExpensive
/// </summary>
[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);
}
/// <summary>
/// PATCH /api/odata/Products({id})
/// 启用 ETag 并发检查
/// </summary>
[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<IActionResult>
。
六、全局 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"); }
八、安全与性能最佳实践 🔒⚡
- 限流:
SetMaxTop(100)
、PageSize=50
防止一次性查询过大数据。 - 禁止高危选项:排除
$apply
、$search
,避免聚合或全文搜索滥用。 - ETag 并发:结合 PATCH + If-Match,失败返回
412 Precondition Failed
。 - 缓存:对静态或少变资源开启 Redis 缓存,并结合 ETag 实现
304 Not Modified
。 - 索引优化:为常用筛选字段(如
Price
、LastModified
)建立数据库索引。 - 慢查询监控:记录
$filter
/$orderby
参数与执行时长,设置多级告警阈值(200ms/500ms/1s)。
九、配置示例:appsettings.json 📝
{
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*",
"OData": {
"RoutePrefix": "api/odata",
"MaxTop": 100,
"PageSize": 50,
"MaxExpansionDepth": 3
}
}