大纲
- 概念
- 支持的接口
- 主要模型设计
- 控制器设计
- 数据源
- Function
- Bound Function
- Unbound Function
- 重载(overload)
- Action
- Bound Action
- Unbound Action
- 重载(overload)
- Bound Action
- Unbound Action
- 主程序
- 服务文档
- 模型元文档
- 代码地址
- 参考资料
在构建 OData 服务时,Actions 和 Functions 是扩展服务器端行为的核心机制,尤其适用于无法通过传统 CRUD(创建、读取、更新、删除)操作实现的业务逻辑。本文基于 ASP.NET Core OData v8.x,深入解析两者的差异,并通过实战示例演示如何在 OData v4 端点中使用它们。
概念
Function 是 OData 服务中暴露的一种 幂等性操作,用于封装 查询
、计算
或转换逻辑
。
Action 是 OData 服务中暴露的一种 非幂等性操作,用于封装命令式逻辑
。
它们的区别如下表所示
特性 | Functions(函数) | Actions(动作) |
---|---|---|
本质 | 幂等的查询 / 计算操作(无副作用) | 非幂等的命令操作(可修改服务器状态) |
入参 | 基本类型(int、string) | 复杂类型(ComplexType/Entity) |
返回值 | 必须返回数据(实体、集合或基本类型) | 可选返回值(成功响应可能无内容) |
HTTP 方法 | GET(强制要求,符合查询语义) | POST(唯一合法方法,确保状态变更安全性) |
组合能力 | 支持与 $filter、$expand 等查询参数组合 | 不支持组合(避免因多次调用导致不确定结果) |
典型场景 | 数据查询、动态计算(如统计销售额、生成折扣价) | 状态变更、复杂业务逻辑(如提交订单、审批流程) |
核心区别可概括为:
- Functions 是 “只读的计算器”,专注于数据查询与计算;
- Actions 是 “状态的改变者”,负责执行命令与修改资源。
在下面的项目中,我们对其进行实践。
支持的接口
Request Method | Route Template | 说明 |
---|---|---|
GET | ~/{entityset}|{singleton}/{function} | 调用绑定到EntitySet或者Singleton的Function |
GET | ~/{entityset}|{singleton}/{cast}/{function} | 调用绑定到EntitySet或者Singleton的特定派生类型的Function |
GET | ~/{entityset}/{key}/{function} | 通过指定Key的Entity使用Function |
GET | ~/{entityset}/{key}/{cast}/{function} | 通过指定Key的特定派生类型Entity使用Function |
GET | ~/{function}(param1={value},param2={value}) | 调用非绑定Function |
POST | ~/{entityset}|{singleton}/{action} | 调用绑定到EntitySet或者Singleton的Action |
POST | ~/{entityset}|{singleton}/{cast}/{action} | 调用绑定到EntitySet或者Singleton的特定派生类型的Action |
POST | ~/{entityset}/{key}/{action} | 通过指定Key调用绑定到Entity的Action |
POST | ~/{entityset}/{key}/{cast}/{action} | 通过指定Keyd调用绑定到特定派生类型的Action |
POST | ~/{action} | 调用非绑定Action |
主要模型设计
在项目下新增Models文件夹,并添加Book和BookRating类。
Book 类用于表示一本图书的数据结构。其Property包含:
- Id:图书唯一标识,类型为 string,必须赋值(required)。
- Title:图书标题,类型为 string,必须赋值。
- Author:作者,类型为 string,必须赋值。
- ForKids:是否为儿童书籍,类型为 bool,必须赋值。
- Year:出版年份,类型为可空整型(int?),默认值为 0。可空表示该字段可以不赋值。
namespace Lesson9.Models
{public class Book{public required string Id { get; set; }public required string Title { get; set; }public required string Author { get; set; }public required bool ForKids { get; set; }public int? Year { get; set; } = 0;}
}
BookRating 类用于表示一本书的评分信息。其Property包含:
- Id:评分的唯一标识,类型为可空字符串(string?)。可以为空,通常用于数据库主键或唯一标识。
- Rating:评分值,类型为 int。用于记录对图书的评分分数。
- BookID:被评分图书的唯一标识,类型为 string,并且是 required,即对象初始化时必须赋值。用于关联到具体的 Book。
namespace Lesson9.Models
{public class BookRating{public string? Id { get; set; }public int Rating { get; set; }public required string BookID { get; set; }}
}
控制器设计
在项目中新增Controller文件夹,然后BooksController类。该类注册于ODataController,以便拥有如下能力:
- OData 路由支持
继承 ODataController 后,控制器自动支持 OData 路由(如 /odata/Shapes(1)),可以直接响应 OData 标准的 URL 路径和操作。 - OData 查询参数支持
可以使用 [EnableQuery] 特性,自动支持 $filter、$select、$orderby、$expand 等 OData 查询参数,无需手动解析。 - OData 响应格式
返回的数据会自动序列化为 OData 标准格式(如 JSON OData),方便前端或其他系统消费。 - OData Delta 支持
支持 Delta<T>、DeltaSet<T> 等类型,便于实现 PATCH、批量 PATCH 等 OData 特有的部分更新操作。 - 更丰富的 OData 语义
继承后可方便实现实体集、实体、导航属性、复杂类型等 OData 语义,提升 API 的表达能力。
using Lesson9.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Deltas;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;namespace Lesson9.Controllers
{public class BooksController: ODataController{}
}
下面我们在该类中填充逻辑。
数据源
定义了一个静态的 Book 类型列表,命名为 books,用于存储所有图书数据。
private static List<Book> books = [new Book { Id = "1", Title = "Book 1", Author = "Author 1", ForKids = true },new Book { Id = "2", Title = "Book 2", Author = "Author 2", ForKids = false },new Book { Id = "3", Title = "Book 3", Author = "Author 3", ForKids = true },new Book { Id = "4", Title = "Book 4", Author = "Author 4", ForKids = false }];
Function
我们先定义相应的Function逻辑。
Bound Function
[HttpGet("odata/Books/mostRecent()")]public IActionResult MostRecent(){var maxBookId = books.Max(x => x.Id);return Ok(maxBookId);}
Function可以bound到Entity、EntitySet和SIngleton上。本例中,我们通过URI可以看到MostRecent
映射到Books这个EntitySet上了。这也预示着,该方法只能通过EntitySet来访问,所以它不用暴露在服务文档
中。对它的调用是需要先在服务文档
中发现Books这个EntitySet,然后在元数据文档中查询到该EntitySet下绑定了哪些Function
需要注意的是,此处只是声明其实现,并设置解释URI,实际的Bound操作是在主函数中设置的。
Unbound Function
[HttpGet("odata/ReturnAllForKidsBooks")]public IActionResult ReturnAllForKidsBooks(){var forKidsBooks = books.Where(m => m.ForKids == true);return Ok(forKidsBooks);}
我们通过URI可以看到ReturnAllForKidsBooks
并没有绑定任何类型。而设置该Function是否绑定则是在主函数中。
重载(overload)
在 OData 中,Function 支持重载(Overloading),但需遵循严格的规范和约束。
- 参数签名必须不同
同一 Function 名称下的不同重载版本,必须通过参数数量
、类型
或顺序
区分。
<Function Name="GetProducts" IsBound="false"><ReturnType Type="Collection(ns.Product)" />
</Function><Function Name="GetProducts" IsBound="false"><Parameter Name="category" Type="Edm.String" /><ReturnType Type="Collection(ns.Product)" />
</Function>
两个 GetProducts 函数通过是否包含 category 参数区分。
2. 绑定类型可不同
同一 Function 名称可绑定到不同类型(如Entity、EntitySet或者SIngleton)
<Function Name="GetDemo" IsBound="true"><Parameter Name="bindingParameter" Type="Collection(Lesson9.Models.Book)" /><ReturnType Type="Edm.String" />
</Function>
<Function Name="GetDemo" IsBound="true"><Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /><ReturnType Type="Edm.String" />
</Function>
- 返回类型不可单独作为区分依据
仅返回类型不同的重载会导致元数据冲突,OData 处理器无法根据请求路径确定具体调用哪个版本。
<!-- 错误:仅返回类型不同,无法重载 -->
<Function Name="GetData"><ReturnType Type="Edm.String" />
</Function><Function Name="GetData"><ReturnType Type="Edm.Int32" />
</Function>
Action
Bound Action
[HttpPost("odata/Books({key})/Rate")]public IActionResult Rate([FromODataUri] string key, ODataActionParameters parameters){if (!ModelState.IsValid){return BadRequest();}int rating = (int)parameters["rating"];if (rating < 0){return BadRequest();}return Ok(new BookRating() { BookID = key, Rating = rating });}
Action也可以绑定到Entity、EntitySet和SIngleton。通过URI我们可以发现,Rate
被绑定到Book这个Entity上,所以我们要访问该Action就需要通过Book Entity资源来定位。
Unbound Action
[HttpPost("odata/incrementBookYear")]public IActionResult IncrementBookYear(ODataActionParameters parameters){if (!ModelState.IsValid){return BadRequest();}int increment = (int)parameters["increment"];string bookId = (string)parameters["id"];var book = books.Where(m => m.Id == bookId).FirstOrDefault();if (book != null){book.Year = book.Year + increment;}return Ok(book);}
Unbound Action的URI上是没有资源名称的,但是它并不能直接在服务文档
上暴露出来。这是因为Action是有状态插座,而服务文档
上默认暴露的是无状态操作。
重载(overload)
Action 支持重载(Overloading),但需遵循严格的规范和约束。
Bound Action
- 仅允许通过绑定类型区分重载
同名 Action 必须绑定到 不同的目标类型(Entity、EntitySet或者SIngleton)。
<Action Name="rate" IsBound="true"><Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /><Parameter Name="rating" Type="Edm.Int32" Nullable="false" /></Action><Action Name="rate" IsBound="true"><Parameter Name="bindingParameter" Type="Collection(Lesson9.Models.Book)" /><Parameter Name="rating" Type="Edm.Int32" Nullable="false" /></Action>
这是因为在访问时,服务可以通过不同URI来进行区分:
POST odata/Books(1)/rate
POST odata/Books/rate
- 禁止同一绑定类型下的参数重载
若绑定类型相同(如均为 ns.Order),即使参数列表不同,也不允许重载。
<!-- 非法:绑定类型均为 Lesson9.Models.Book,参数不同但无法重载 -->
<Action Name="rate" IsBound="true"><Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /><Parameter Name="rating" Type="Edm.Int32" Nullable="false" />
</Action>
<Action Name="rate" IsBound="true"><Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /><Parameter Name="rating" Type="Edm.String" Nullable="false" />
</Action>
Unbound Action
不支持重载。
主程序
using Lesson9.Models;
using Microsoft.AspNetCore.OData;
using Microsoft.OData.Edm;
using Microsoft.OData.ModelBuilder;
using System;var builder = WebApplication.CreateBuilder(args);// 提取 OData EDM 模型构建为方法,便于维护和扩展
static IEdmModel GetEdmModel()
{var modelBuilder = new ODataConventionModelBuilder();modelBuilder.EntitySet<Book>("books");modelBuilder.EntityType<Book>().Collection.Function("mostRecent").Returns<string>();modelBuilder.Function("returnAllForKidsBooks").ReturnsFromEntitySet<Book>("books");modelBuilder.EntityType<Book>().Action("rate").Parameter<int>("rating");var action = modelBuilder.Action("incrementBookYear").ReturnsFromEntitySet<Book>("books");action.Parameter<int>("increment");action.Parameter<string>("id");return modelBuilder.GetEdmModel();
}// 添加 OData 服务和配置
builder.Services.AddControllers().AddOData(options =>{options.AddRouteComponents("odata", GetEdmModel()).Count().OrderBy().Filter().Select().Expand();options.RouteOptions.EnableNonParenthesisForEmptyParameterFunction = true;});var app = builder.Build();app.UseRouting();app.MapControllers();app.Run();
这段代码将Function mostRecent
绑定到Book的Collection上,即Books这个EntitySet;将Action rate
绑定到Book这个Entity上。returnAllForKidsBook
是Unbound Function;incrementBookYear
是Unbound Action。
options.RouteOptions.EnableNonParenthesisForEmptyParameterFunction
的设置是针对没有入参的 mostRecent
。这样我们就可以通过GET odata/Books/mostRecent
请求到数据,而不用像GET odata/Books/mostRecent()
这样一定要加上括号。
服务文档
- Request
curl --location 'http://localhost:5119/odata'
- Response
{"@odata.context": "http://localhost:5119/odata/$metadata","value": [{"name": "books","kind": "EntitySet","url": "books"},{"name": "returnAllForKidsBooks","kind": "FunctionImport","url": "returnAllForKidsBooks"}]
}
在本案例中,我们定义了一个Unbound Function和一个Unbound Action。但是只有Unbound Function以FunctionImport形式在服务文档
中可见,但是ActionImport并不可见。
这是因为服务文档
主要是以 JSON 或 Atom 格式描述服务的可寻址资源
和可调用的无状态操作
。而Action是有状态的操作,所以并不会默认出现在服务文档
中。
模型元文档
- Request
curl --location 'http://localhost:5119/odata/$metadata'
- Response
<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"><edmx:DataServices><Schema Namespace="Lesson9.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm"><EntityType Name="Book"><Key><PropertyRef Name="Id" /></Key><Property Name="Id" Type="Edm.String" Nullable="false" /><Property Name="Title" Type="Edm.String" Nullable="false" /><Property Name="Author" Type="Edm.String" Nullable="false" /><Property Name="ForKids" Type="Edm.Boolean" Nullable="false" /><Property Name="Year" Type="Edm.Int32" /></EntityType></Schema><Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"><Function Name="mostRecent" IsBound="true"><Parameter Name="bindingParameter" Type="Collection(Lesson9.Models.Book)" /><ReturnType Type="Edm.String" /></Function><Function Name="returnAllForKidsBooks"><ReturnType Type="Lesson9.Models.Book" /></Function><Action Name="rate" IsBound="true"><Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /><Parameter Name="rating" Type="Edm.Int32" Nullable="false" /></Action><Action Name="incrementBookYear"><Parameter Name="increment" Type="Edm.Int32" Nullable="false" /><Parameter Name="id" Type="Edm.String" /><ReturnType Type="Lesson9.Models.Book" /></Action><EntityContainer Name="Container"><EntitySet Name="books" EntityType="Lesson9.Models.Book" /><FunctionImport Name="returnAllForKidsBooks" Function="Default.returnAllForKidsBooks" EntitySet="books" IncludeInServiceDocument="true" /><ActionImport Name="incrementBookYear" Action="Default.incrementBookYear" EntitySet="books" /></EntityContainer></Schema></edmx:DataServices>
</edmx:Edmx>
mostRecent
是Bound到EntitySet(Collection of Books )上的Function,所以IsBound="true"
,同时第一个参数命名为Name="bindingParameter"
。(\<Parameter Name="bindingParameter" Type="Collection(Lesson9.Models.Book)" /\>
).
returnAllForKidsBooks
是Unbound Function。在Container里,它被通过FunctionImport 描述,同时IncludeInServiceDocument="true"
表示它会出现在服务文档
中。
rate
是Bound到Entity(Book)上的Action,所以IsBound="true"
,同时第一个参数命名为Name="bindingParameter"
。(\<Parameter Name="bindingParameter" Type="Lesson9.Models.Book" /\>
)
incrementBookYear
是Unbound Action。在Container里,它被通过ActionImport 描述。由于Action是有状态的操作,所以它不会出现在服务文档
中,即IncludeInServiceDocument
取了默认值false。
代码地址
https://github.com/f304646673/odata/tree/main/csharp/Lesson/Lesson9
参考资料
- https://learn.microsoft.com/en-us/odata/webapi-8/fundamentals/actions-functions