ASP.NET Core OData 实践——Lesson9绑定和未绑定的Function和Action(C#)

在构建 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 MethodRoute 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,以便拥有如下能力:

  1. OData 路由支持
    继承 ODataController 后,控制器自动支持 OData 路由(如 /odata/Shapes(1)),可以直接响应 OData 标准的 URL 路径和操作。
  2. OData 查询参数支持
    可以使用 [EnableQuery] 特性,自动支持 $filter、$select、$orderby、$expand 等 OData 查询参数,无需手动解析。
  3. OData 响应格式
    返回的数据会自动序列化为 OData 标准格式(如 JSON OData),方便前端或其他系统消费。
  4. OData Delta 支持
    支持 Delta<T>、DeltaSet<T> 等类型,便于实现 PATCH、批量 PATCH 等 OData 特有的部分更新操作。
  5. 更丰富的 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),但需遵循严格的规范和约束。

  1. 参数签名必须不同
    同一 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>
  1. 返回类型不可单独作为区分依据
    仅返回类型不同的重载会导致元数据冲突,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
  1. 仅允许通过绑定类型区分重载
    同名 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
  1. 禁止同一绑定类型下的参数重载
    若绑定类型相同(如均为 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

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

breaksoftware

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值