65.9K
CodeProject 正在变化。 阅读更多。
Home

我使用 OData 的经验

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (7投票s)

2022年11月21日

CPOL

10分钟阅读

viewsIcon

15036

ASP.NET 应用程序中对 OData 的支持

最简单的用法

首先,我们需要一个 Web 服务。我将使用 ASP.NET Core 创建它。要使用 OData,我们需要安装 Microsoft.AspNetCore.OData NuGet 包。现在我们必须配置它。这是 Program.cs 文件的内容:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services
    .AddControllers()
    .AddOData(opts =>
    {
        opts
            .Select()
            .Expand()
            .Filter()
            .Count()
            .OrderBy()
            .SetMaxTop(1000);
    });

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseAuthorization();

app.MapControllers();

app.Run();

AddOData 方法中,我们指定了 OData 中所有可能的操作中允许哪些。

当然,OData 旨在处理数据。让我们向应用程序添加一些数据。数据定义非常简单:

public class Author
{
    [Key]
    public int Id { get; set; }

    [Required]
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }

    public string? ImageUrl { get; set; }

    public string? HomePageUrl { get; set; }

    public ICollection<Article> Articles { get; set; }
}

public class Article
{
    [Key]
    public int Id { get; set; }

    public int AuthorId { get; set; }

    [Required]
    public string Title { get; set; }
}

我将使用 Entity Framework 来处理它。测试数据是使用 Bogus 创建的。

public class AuthorsContext : DbContext
{
    public DbSet<Author> Authors { get; set; } = null!;

    public AuthorsContext(DbContextOptions<AuthorsContext> options)
        : base(options)
    { }

    public async Task Initialize()
    {
        await Database.EnsureDeletedAsync();
        await Database.EnsureCreatedAsync();

        var rnd = Random.Shared;

        Authors.AddRange(
            Enumerable
                .Range(0, 10)
                .Select(_ =>
                {
                    var faker = new Faker();

                    var person = faker.Person;

                    return new Author
                    {
                        FirstName = person.FirstName,
                        LastName = person.LastName,
                        ImageUrl = person.Avatar,
                        HomePageUrl = person.Website,
                        Articles = new List<Article>(
                            Enumerable
                                .Range(0, rnd.Next(1, 5))
                                .Select(_ => new Article
                                {
                                    Title = faker.Lorem.Slug(rnd.Next(3, 5))
                                })
                        )
                    };
                })
        );

        await SaveChangesAsync();
    }
}

作为数据存储,我将使用内存中的 Sqlite。这是 Program.cs 中的配置:

...

var inMemoryDatabaseConnection = new SqliteConnection("DataSource=:memory:");
inMemoryDatabaseConnection.Open();

builder.Services.AddDbContext<AuthorsContext>(optionsBuilder =>
    {
        optionsBuilder.UseSqlite(inMemoryDatabaseConnection);
    }
);

...

using (var scope = app.Services.CreateScope())
{
    await scope.ServiceProvider.GetRequiredService<AuthorsContext>().Initialize();
}

...

现在存储已准备就绪。让我们创建一个简单的控制器,将数据返回给客户端:

[ApiController]
[Route("/api/v1/authors")]
public class AuthorsController : ControllerBase
{
    private readonly AuthorsContext _db;

    public AuthorsController(
        AuthorsContext db
        )
    {
        _db = db ?? throw new ArgumentNullException(nameof(db));
    }

    [HttpGet("no-odata")]
    public ActionResult GetWithoutOData()
    {
        return Ok(_db.Authors);
    }
}

现在在 /api/v1/authors/no-odata,我们可以得到以下结果:

[
  {
    "id": 1,
    "firstName": "Fred",
    "lastName": "Kuhlman",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg",
    "homePageUrl": "donald.com"
  },
  {
    "id": 2,
    "firstName": "Darrel",
    "lastName": "Armstrong",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg",
    "homePageUrl": "angus.org"
  },
  ...
]

当然,目前还没有 OData 支持。但是添加它有多难呢?

OData 的基本支持

这很容易。让我们再创建一个端点:

[HttpGet("odata")]
[EnableQuery]
public IQueryable<Author> GetWithOData()
{
    return _db.Authors;
}

如您所见,差异很小。但是现在,您可以在查询中使用 OData。例如,查询 /api/v1/authors/odata?$filter=id lt 3&$orderby=firstName 给出以下结果:

[
  {
    "id": 2,
    "firstName": "Darrel",
    "lastName": "Armstrong",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg",
    "homePageUrl": "angus.org"
  },
  {
    "id": 1,
    "firstName": "Fred",
    "lastName": "Kuhlman",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg",
    "homePageUrl": "donald.com"
  }
]

太棒了!但是有一个小缺点。我们的方法或控制器返回 IQueryable<> 对象。实际上,我们通常希望返回几种响应变体(例如,NotFoundBadRequest,...)。我们能做些什么?

事实证明,OData 实现与将 IQueryable<> 对象包装到 Ok 中配合得很好:

[HttpGet("odata")]
[EnableQuery]
public IActionResult GetWithOData()
{
    return Ok(_db.Authors);
}

这意味着您可以在控制器操作中添加任何验证逻辑。

分页

如您所知,OData 允许您仅获取完整结果的特定页面。这可以通过使用 skiptop 运算符来完成(例如,/api/v1/authors/odata?$skip=3&$top=2)。在 Program.cs 中配置 OData 时,您必须记住调用 SetMaxTop 方法。否则,使用 top 运算符可能会导致以下错误:

The query specified in the URI is not valid. 
The limit of '0' for Top query has been exceeded.

但是,为了充分利用分页机制,了解总共有多少页非常有用。我们需要我们的端点额外返回与给定筛选器对应的项目总数。OData 支持 count 运算符用于此目的:(/api/v1/authors/odata?$skip=3&$top=2&$count=true)。但是,如果我们将 $count=true 简单地添加到查询中,那将不起作用。为了获得所需的结果,我们需要配置 EDM(实体数据模型)。但首先,我们必须知道端点的地址。

假设我们希望我们的数据可以通过 /api/v1/authors/edm 访问。此端点将返回 Author 类型的对象。在这种情况下,Program.cs 文件中的 OData 配置将如下所示:

builder.Services
    .AddControllers()
    .AddOData(opts =>
    {
        opts.AddRouteComponents("api/v1/authors", GetAuthorsEdm());

        IEdmModel GetAuthorsEdm()
        {
            ODataConventionModelBuilder edmBuilder = new();

            edmBuilder.EntitySet<Author>("edm");

            return edmBuilder.GetEdmModel();
        }

        opts
            .Select()
            .Expand()
            .Filter()
            .Count()
            .OrderBy()
            .SetMaxTop(1000);
    });

请注意,我们组件的路由(api/v1/authors)等于我们端点地址的前缀,并且实体集的名称等于此地址的其余部分(edm)。

最后一步是将 ODataAttributeRouting 属性添加到控制器的相应方法中:

[HttpGet("edm")]
[ODataAttributeRouting]
[EnableQuery]
public IQueryable<Author> GetWithEdm()
{
    return _db.Authors;
}

现在,对于请求 /api/v1/authors/edm?$top=2&$count=true,此端点将返回以下数据:

{
  "@odata.context": "https://:5293/api/v1/authors/$metadata#edm",
  "@odata.count": 10,
  "value": [
    {
      "Id": 1,
      "FirstName": "Steve",
      "LastName": "Schaefer",
      "ImageUrl": "https://cloudflare-ipfs.com/ipfs/
                   Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/670.jpg",
      "HomePageUrl": "kylie.info"
    },
    {
      "Id": 2,
      "FirstName": "Stella",
      "LastName": "Ankunding",
      "ImageUrl": "https://cloudflare-ipfs.com/ipfs/
                   Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/884.jpg",
      "HomePageUrl": "allen.name"
    }
  ]
}

如您所见,@odata.count 字段包含与查询筛选器对应的数据项数。这正是我们想要的。

总的来说,EDM 与特定端点之间的对应关系对我来说似乎相当复杂。如果您愿意,可以尝试 通过文档通过示例 自己研究它。

您可以从调试页面获得一些帮助,可以如下启用:

if (app.Environment.IsDevelopment())
{
    app.UseODataRouteDebug();
}

现在在 /$odata,您可以看到您拥有的端点以及与它们关联的模型。

 JSON 序列化

您是否注意到在添加 EDM 后,我们返回的数据发生了什么样的变化?所有属性名称现在都以大写字母开头(以前是 firstName,现在是 FirstName)。对于 JavaScript 客户端来说,这可能是一个大问题,因为它们区分大小写字母。我们必须以某种方式控制属性的名称。OData 使用 System.Text.Json 命名空间中的类进行数据序列化。不幸的是,使用此命名空间中的属性没有任何作用:

[JsonPropertyName("firstName")]
public string FirstName { get; set; }

看起来 OData 从 EDM 中获取属性名称,而不是从类定义中获取。

OData 实现建议两种方法来解决在使用 EDM 的情况下此问题。第一种方法允许通过调用 EnableLowerCamelCase 方法为整个模型启用“小驼峰命名法”:

IEdmModel GetAuthorsEdm()
{
    ODataConventionModelBuilder edmBuilder = new();

    edmBuilder.EnableLowerCamelCase();

    edmBuilder.EntitySet<Author>("edm");

    return edmBuilder.GetEdmModel();
}

现在我们有以下数据:

{
  "@odata.context": "https://:5293/api/v1/authors/$metadata#edm",
  "@odata.count": 10,
  "value": [
    {
      "id": 1,
      "firstName": "Troy",
      "lastName": "Gottlieb",
      "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                   Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/228.jpg",
      "homePageUrl": "avery.net"
    },
    {
      "id": 2,
      "firstName": "Mathew",
      "lastName": "Schiller",
      "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                   Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/401.jpg",
      "homePageUrl": "marion.biz"
    }
  ]
}

这很好。但是,如果我们需要对 JSON 属性名称进行更细粒度的控制呢?如果我们需要 JSON 中的某个属性具有 C# 属性名称不允许的名称(例如,@odata.count)怎么办?

这可以通过 EDM 完成。让我们将 homePageUrl 重命名为 @url.home

IEdmModel GetAuthorsEdm()
{
    ODataConventionModelBuilder edmBuilder = new();

    edmBuilder.EnableLowerCamelCase();

    edmBuilder.EntitySet<Author>("edm");

    edmBuilder.EntityType<Author>()
        .Property(a => a.HomePageUrl).Name = "@url.home";

    return edmBuilder.GetEdmModel();
}

在这里,我们将面临一个不愉快的惊喜:

Microsoft.OData.ODataException: The property name '@url.home' is invalid; 
property names must not contain any of the reserved characters ':', '.', '@'.

让我们尝试一些更简单的:

edmBuilder.EntityType<Author>()
        .Property(a => a.HomePageUrl).Name = "url_home";

现在它可以工作了:

{
    "url_home": "danielle.info",
    "id": 1,
    "firstName": "Armando",
    "lastName": "Hammes",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/956.jpg"
},

当然,这令人不快,但您能做什么呢?

数据转换

到目前为止,我们已直接从数据库向用户提供数据。但通常在大型应用程序中,习惯于将负责存储信息的类与负责向用户提供数据的类分开。至少,这允许相对独立地更改这些类。让我们看看这种机制如何与 OData 配合使用。

我将为我们的类创建简单的包装器:

public class AuthorDto
{
    public int Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string? ImageUrl { get; set; }

    public string? HomePageUrl { get; set; }

    public ICollection<ArticleDto> Articles { get; set; }
}

public class ArticleDto
{
    public string Title { get; set; }
}

我将使用 AutoMapper 进行转换。我对 Mapster 不太熟悉,但我知道它也可以与 Entity Framework 配合使用。

对于 AutoMapper,我们必须配置相应的转换:

public class DefaultProfile : Profile
{
    public DefaultProfile()
    {
        CreateMap<Article, ArticleDto>();
        CreateMap<Author, AuthorDto>();
    }
}

并在我们的应用程序启动时注册它(我这里使用 AutoMapper.Extensions.Microsoft.DependencyInjection NuGet 包):

builder.Services.AddAutoMapper(typeof(Program).Assembly);

现在我可以在控制器中添加另一个端点:

...

private readonly IMapper _mapper;
private readonly AuthorsContext _db;

public AuthorsController(
    IMapper mapper,
    AuthorsContext db
    )
{
    _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    _db = db ?? throw new ArgumentNullException(nameof(db));
}

...

[HttpGet("mapping")]
[EnableQuery]
public IQueryable<AuthorDto> GetWithMapping()
{
    return _db.Authors.ProjectTo<AuthorDto>(_mapper.ConfigurationProvider);
}

如您所见,应用转换很容易。不幸的是,结果包含展开的文章列表:

[
  {
    "id": 1,
    "firstName": "Edward",
    "lastName": "O'Kon",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1162.jpg",
    "homePageUrl": "zachariah.info",
    "articles": [
      {
        "title": "animi-sint-atque"
      },
      {
        "title": "aut-eum-iure"
      }
    ]
  },
  ...
]

这意味着我们无法应用 expand OData 操作。但很容易修复。让我们更改 AuthorDto 的 AutoMapper 配置:

CreateMap<Author, AuthorDto>()
    .ForMember(a => a.Articles, o => o.ExplicitExpansion());

现在对于 /api/v1/authors/mapping,我们得到正确的结果:

[
  {
    "id": 1,
    "firstName": "Spencer",
    "lastName": "Cummerata",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/286.jpg",
    "homePageUrl": "woodrow.info"
  },
  ...
]

对于 /api/v1/authors/mapping?$expand=articles

InvalidOperationException: The LINQ expression '$it => new SelectAll<ArticleDto>{
Model = __TypedProperty_1,
Instance = $it,
UseInstanceForProperties = True
}
' could not be translated.

是的,一个问题。但是 AutoMapper 为我们提供了另一种使用 OData 的方法。有一个 AutoMapper.AspNetCore.OData.EFCore NuGet 包。有了它,我可以像这样实现我的端点:

[HttpGet("automapper")]
public IQueryable<AuthorDto> GetWithAutoMapper(ODataQueryOptions<AuthorDto> query)
{
    return _db.Authors.GetQuery(_mapper, query);
}

请注意,我们没有使用 EnableQuery 属性来增强我们的方法。相反,我们将所有 OData 查询参数收集到 ODataQueryOptions 对象中,并“手动”应用所有所需的转换。

这次一切正常:没有扩展的请求:

[
  {
    "id": 1,
    "firstName": "Nathan",
    "lastName": "Heller",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg",
    "homePageUrl": "jamarcus.biz",
    "articles": null
  },
  ...
]

和带有扩展的请求:

[
  {
    "id": 1,
    "firstName": "Nathan",
    "lastName": "Heller",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg",
    "homePageUrl": "jamarcus.biz",
    "articles": [
      {
        "title": "quidem-nulla-et"
      }
    ]
  },
  ...
]

此外,这种方法还有一个优点。它允许使用标准 JSON 工具来控制我们对象的序列化。例如,我们可以像这样从结果中删除 null 值:

builder.Services
    .AddJsonOptions(configure =>
    {
        configure.JsonSerializerOptions.DefaultIgnoreCondition = 
                                        JsonIgnoreCondition.WhenWritingNull;
        configure.JsonSerializerOptions.PropertyNamingPolicy = 
                                        JsonNamingPolicy.CamelCase;
    });

此外,我们可以通过常用属性设置 JSON 属性名称:

[JsonPropertyName("@url.home")]
public string? HomePageUrl { get; set; }

现在我们可以使用这样的名称:

[
  {
    "id": 1,
    "firstName": "Edward",
    "lastName": "Schmidt",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/
                 Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1046.jpg",
    "@url.home": "justen.com"
  },
  ...
]

附加数据

如果我们的数据库字段与我们结果数据的字段完全匹配,我们将没有问题。但情况并非总是如此。通常,我们希望从存储中返回转换和处理过的数据。在这种情况下,我们可能会遇到几种情况。

首先,转换可能很简单。例如,我不想单独返回作者的名字和姓氏,而是返回作者的全名:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }
}

我们可以像这样为这个类配置 AutoMapper:

CreateMap<Author, ComplexAuthor>()
    .ForMember(d => d.FullName,
        opt => opt.MapFrom(s => s.FirstName + " " + s.LastName));

在这种情况下,我们得到想要的结果:

[
  {
    "id": 1,
    "fullName": "Lance Rice"
  },
  ...
]

此外,我们仍然可以通过我们的新字段(/api/v1/authors/nonsql?$filter=startswith(fullName,'A'))过滤和排序数据:

[
  {
    "id": 4,
    "fullName": "Andre Medhurst"
  },
  {
    "id": 6,
    "fullName": "Amber Terry"
  }
]

我们仍然可以这样做是因为我们简单的表达式(s.FirstName + " " + s.LastName)可以很容易地转换为 SQL。这是在这种情况下 Entity Framework 为我生成的查询:

SELECT "a"."Id", ("a"."FirstName" || ' ') || "a"."LastName"
      FROM "Authors" AS "a"
      WHERE (@__TypedProperty_0 = '') OR (((("a"."FirstName" || ' ') || 
      "a"."LastName" LIKE @__TypedProperty_0 || '%') AND 
      (substr(("a"."FirstName" || ' ') || "a"."LastName", 1, 
      length(@__TypedProperty_0)) = @__TypedProperty_0)) OR (@__TypedProperty_0 = ''))

这就是为什么过滤和排序仍然有效的原因。

但显然,并非所有转换都可以翻译成 SQL。假设由于某种原因,我们想要计算全名的哈希值:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }

    public string NameHash { get; set; }
}

现在我们的 AutoMapper 配置如下所示:

CreateMap<Author, ComplexAuthor>()
    .ForMember(d => d.FullName,
        opt => opt.MapFrom(s => s.FirstName + " " + s.LastName))
    .ForMember(
        d => d.NameHash,
        opt => opt.MapFrom(a => string.Join(",", SHA256.HashData
               (Encoding.UTF32.GetBytes(a.FirstName + " " + a.LastName))))
    );

让我们尝试获取我们的数据:

[
  {
    "id": 1,
    "fullName": "Julius Haag",
    "nameHash": "66,19,82,19,233,224,181,226,111,125,241,228,81,6,
                 200,47,5,112,248,30,186,26,173,91,83,73,9,137,6,158,138,115"
  },
  {
    "id": 2,
    "fullName": "Anita Wilderman",
    "nameHash": "196,131,191,35,182,3,174,193,196,91,70,199,22,173,72,54,
                 123,73,110,83,254,178,19,129,219,24,137,197,83,158,76,209"
  },
  ...
]

有趣。尽管结果表达式无法用 SQL 术语表达,但系统仍然继续工作。看起来 Entity Framework 知道哪些可以在服务器端评估。

现在让我们尝试通过这个新字段(nameHash)过滤我们的数据:/api/v1/authors/nonsql?$filter=nameHash eq '1'

InvalidOperationException: The LINQ expression 'DbSet<Author>()
.Where(a => (string)string.Join<byte>(
separator: ",",
values: SHA256.HashData(__UTF32_0.GetBytes(a.FirstName + " " + 
a.LastName))) == __TypedProperty_1)' could not be translated.

在这里,我们无法再避免将表达式转换为 SQL。而且,由于无法完成,我们收到错误消息。

在这种情况下,我们无法以可以转换为 SQL 的方式重写表达式。但是我们可以禁止按此字段进行过滤和排序。有几个属性可以做到这一点:NonFilterableNotFilterableNotSortableUnsortable。您可以使用其中任何一个:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }

    [NonFilterable]
    [Unsortable]
    public string NameHash { get; set; }
}

如果用户尝试按此字段进行过滤,我宁愿返回 Bad Request。但仅仅添加这些属性没有任何作用。按 nameHash 过滤会导致相同的错误。我们必须手动验证我们的请求:

[HttpGet("nonsql")]
public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    return Ok(_db.Authors.GetQuery(_mapper, options));
}

现在,当我们尝试过滤时,我们会收到以下消息:

The property 'NameHash' cannot be used in the $filter query option.

这更好。尽管返回给用户的属性名称以小写字母(nameHash)开头,而不是大写字母(NameHash)。

我想知道使用 JsonPropertyName 属性更改属性名称通常会如何?例如,我希望我的属性名为 name

[JsonPropertyName("name")]
public string FullName { get; set; }

我现在可以按 name 过滤吗(/api/v1/authors/nonsql?$filter=startswith(name,'A'))?事实证明我不能:

Could not find a property named 'name' on type 'ODataJourney.Models.ComplexAuthor'.

如果我们回到 EDM 呢?为此,只需将 ODataAttributeRouting 属性添加到控制器方法:

[HttpGet("nonsql")]
[ODataAttributeRouting]
public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options)

并更新我们的模型:

...

edmBuilder.EntitySet<ComplexAuthor>("nonsql");

edmBuilder.EntityType<ComplexAuthor>()
    .Property(a => a.FullName).Name = "name";

...

现在我们可以按 name 过滤:

{
  "@odata.context": "https://:5293/api/v1/authors/$metadata#nonsql",
  "value": [
    {
      "name": "Leona Bauch",
      "id": 3,
      "nameHash": "56,114,131,251,22,63,188,105,37,55,74,232,36,181,152,
                   24,9,111,131,55,229,89,164,181,230,158,109,163,206,137,147,173"
    },
    {
      "name": "Leo Schimmel",
      "id": 7,
      "nameHash": "78,48,88,216,170,3,241,99,96,251,10,176,45,187,250,58,
                   240,215,104,159,26,158,217,244,93,219,183,119,206,40,130,102"
    }
  ]
}

但如您所见,数据结构已更改。我们得到了 OData 包装器。此外,我们已回到上面描述的属性名称限制。

最后,让我们看另一种数据转换。到目前为止,我们使用 AutoMapper 转换数据。但在这种情况下,我们无法使用请求上下文。AutoMapper 转换在没有访问请求信息的单独文件中描述。但有时,这可能非常重要。例如,我们可能希望根据请求中收到的数据进行另一个 Web 请求,并使用响应更改我们的结果数据。在以下示例中,我使用简单的 foreach 循环来表示一些服务器端数据处理:

[HttpGet("add")]
public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    var query = _db.Authors.ProjectTo<ComplexAuthor>(_mapper.ConfigurationProvider);

    var authors = query.ToArray();

    foreach (var author in authors)
    {
        author.FullName += " (Mr)";
    }

    return Ok(authors);
}

当然,这里没有 OData 支持。但是我们如何添加它呢?我们不想失去过滤、排序和分页的能力。

这是一种可能的方法。我们可以应用除 select 之外的所有 OData 操作。在这种情况下,我们仍然使用完整的 ComplexAuthor 对象。之后,我们转换这些对象,然后如果请求了 select 操作,我们再应用它。这将允许我们仅从数据库中获取少量与我们的筛选器和页面对应的记录:

[HttpGet("add")]
public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    var query = _db.Authors.ProjectTo<ComplexAuthor>(
        _mapper.ConfigurationProvider);

    var authors = options
        .ApplyTo(query, AllowedQueryOptions.Select)
        .Cast<ComplexAuthor>()
        .ToArray();

    foreach (var author in authors)
    {
        author.FullName += " (Mr)";
    }

    var result = options.ApplyTo(
        authors.AsQueryable(),
        AllowedQueryOptions.All & ~AllowedQueryOptions.Select
    );

    return Ok(result);
}

ODataQueryOptions 对象允许我们指定应该应用哪些 OData 操作。利用这个机会,我们将 OData 操作的应用分为两个阶段,在这两个阶段之间插入我们的处理。

这种方法有其缺点。首先,我们失去了使用 JSON 属性更改属性名称的能力。这可以通过 EDM 修复,但在这种情况下,我们将更改数据形状并获得 OData 包装器。

此外,expand 操作的问题再次出现。我们的 ComplexAuthor 类非常简单,但我们可以为其添加一个返回文章的属性:

public ICollection<ArticleDto> Articles { get; set; }

我们之前从 AutoMapper.AspNetCore.OData.EFCore NuGet 包中使用的 GetQuery 方法不允许部分应用 OData 操作。没有它,我无法使系统正确展开 Articles 属性。最后,我得到了这个难以理解的错误:

ODataException: Property 'articles' on type 'ODataJourney.Models.ComplexAuthor' 
is not a navigation property or complex property. Only navigation properties 
can be expanded.

也许有人能够克服它。

结论

尽管 OData 提供了一种相当简单的方法来为您的 Web API 添加强大的数据过滤操作,但事实证明,从当前的 Microsoft 实现中获得您想要的一切非常困难。看起来当你实现一件事时,另一件事就会出问题。

希望我只是在这里不理解一些东西,并且有一种可靠的方法可以克服所有这些困难。祝你好运!

附注:您可以在 GitHub 上找到本文的源代码。

你可以在我的博客上阅读更多我的文章。

历史

  • 2022年11月21日:初始版本
© . All rights reserved.