大纲
在构建 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)/ratePOST 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
1万+

被折叠的 条评论
为什么被折叠?



