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.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, $searchAllowedQueryOptions =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}
}