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

用于将复杂对象与查询字符串传递到 Web API 方法的自定义模型绑定器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.74/5 (20投票s)

2013年12月26日

CPOL

13分钟阅读

viewsIcon

164533

downloadIcon

3129

自定义模型绑定器,用于将查询字符串作为嵌套对象或集合传递到 Web API 的 GET 或 POST 方法,也已更新支持 ASP.NET Core。

引言

更新说明 (2018/1/18):已附带 ASP.NET Core 2.0 的 FieldValueModelBinder 源代码。请参阅 迁移到 ASP.NET Core 部分了解详情。

包含字段-值数据对的查询字符串是 URI 中传递消息的标准形式,或者是带有默认 application/x-www-form-urlencoded 内容类型的请求正文。最新的 Web API 2 和 ASP.NET MVC 5 在使用 URI 中的查询字符串数据源或请求正文时,仅支持传递仅包含原始类型、非类类型或 System.String 类型属性的简单对象。对于包含嵌套对象或集合的任何复杂对象,唯一的选择是将序列化的 JSON 或 XML 数据传递到请求正文中。

当我在将搜索、分页和排序请求的现有嵌套对象模型从 WCF Web 服务移植到 Web API 应用程序时,我曾希望通过查询字符串将此复杂对象传递给 GET 方法,但未能找到任何可行的解决方案。我最终创建了自己的模型绑定器,它有效地支持通过查询字符串在 URI 或请求正文中传递所有实际的复杂对象模式。

复杂对象的查询字符串字段

为了清晰描述,我定义了这些术语并在文章中贯穿使用。

  • 简单属性:任何类型为原始类型、非类类型或 System.String 的属性。
  • 复杂属性:任何类型为类但排除 System.String 的属性。
  • 简单对象:仅由简单属性组成的任何对象。
  • 嵌套对象:包含一个或多个复杂属性,但不包含集合的任何对象。
  • 嵌套集合对象:包含一个或多个集合的任何嵌套对象。

对于嵌套对象,查询字符串可以与简单对象的查询字符串看起来相同。字段名可能不会以父对象名作为前缀,因为对象树中没有集合。模型绑定器还应解析所有嵌套对象中的简单属性名,即使不同对象具有相同的简单属性名。

下面是用于搜索和分页请求的嵌套对象模型的示例,以及相应的查询字符串源数据。这可能是 Web 应用程序中最常用的场景之一。该结构还包括一个具有 enum 类型的嵌套对象。为了简化演示,我将 CategoryId 用作硬编码的搜索字段。实际的搜索请求可能是另一个嵌套对象,其中包含一个 enum SearchField,包含 CategoryNameProductNameProductStatus 等附加项,以及一个 string SearchText 属性。

请求模型类的示例

public class NestSearchRequest
{
    public int CategoryId { get; set; }
    public PagingRequest PagingRequest { get; set; }        
}
public class PagingRequest
{        
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
    public Sort Sort { get; set; }
}
public class Sort
{
    public string SortBy { get; set; }
    public SortDirection SortDirection { get; set; }  
}
public enum SortDirection
{
    Ascending,
    Descending
}

以上请求模型的查询字符串

CategoryId=3&PageIndex=0&PageSize=8&SortBy=ProductName&SortDirection=Descending

对于嵌套集合对象,字段名应以复杂属性名和索引作为前缀。我们也不希望将类似 JSON 或 XML 的对象结构嵌入到任何值中。相反,字段-值对中每个字段名的最后一部分应始终指向一个简单属性。

用于测试和演示嵌套集合对象的请求模型类

public class ComplexSearchRequest
{
    public int CategoryId { get; set; }
    public List<PagingSortRequest> PagingRequest { get; set; }        
    public string Test { get; set; }
}  

public class PagingSortRequest
{
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
    public Sort[] Sort { get; set; }               
}

以上请求对象的查询字符串数据

CategoryId=3&PagingRequest[0]PageIndex=1&PagingRequest[0]PageSize=8&PagingRequest[0]
Sort[0]SortBy=ProductName&PagingRequest[0]Sort[0]SortDirection=descending&PagingRequest[0]
Sort[1]SortBy=CategoryID&PagingRequest[0]Sort[1]SortDirection=0&PagingRequest[1]
PageIndex=2&PagingRequest[1]PageSize=5&PagingRequest[1]Sort[0]
SortBy=CategoryID&PagingRequest[1]Sort[0]SortDirection=0&PagingRequest[1]Sort[1]
SortBy=ProductName&PagingRequest[1]Sort[1]SortDirection=Descending&Test=OK

下面显示了检索到模型绑定器的字段名对列表。

使用和测试 FieldValueModelBinder 类

要查看自定义模型绑定器的工作情况,您需要下载 代码,并使用 Visual Studio 2012 或 2013 重新编译解决方案。请确保您的机器已连接互联网,因为所有包文件都需要从 NuGet 自动下载。要将 FieldValueModelBinder 类用于其他项目,您可以复制 SM.General.Api 项目中的类文件,或者使用程序集 SM.General.Api.dll。在 Web API 控制器代码中,只需将 GETPUT 方法的 [FromUri][FromBody] 属性替换为以下设置:

[ModelBinder(typeof(SM.General.Api.FieldValueModelBinder))]

测试应用程序是通过本地 IIS Express 托管的 Web API 类库。当您运行测试应用以打开 HTML 页面时,在参数输入文本框中输入查询字符串,然后点击链接将 string 传递给其中一个 API 方法,模型绑定器将根据模型结构将查询字符串转换为对象树。然后,对象将从响应中返回并在页面上显示。调用测试方法的代码非常直接。

JQuery 代码

var input = $("#txaInput").val();
$.ajax({
    url: 'api/nvpstonestcollectionget?' + input,
    type: "GET",
    dataType: "json",    
    success: function (data) {
        //Display data on HTML page
        ... 
    },
    ...
});

或 AngularJS 代码

$scope.nvpsToNestCollectionGet = function () {
    $http({
       url: 'api/nvpstonestcollectionget?' + $scope.txaInput,
       method: "GET"
    }).
    success(function (data, status, headers, config) {
        //Display data on HTML page
        ...             
    }).
    error(function (data, status, headers, config) {
        ...
    });
}

服务器端 API 方法

[Route("~/api/nvpstonestcollectionget")]
public ComplexSearchRequest Get_NvpsToNestCollection
       ([ModelBinder(typeof(FieldValueModelBinder))] ComplexSearchRequest request)
{
    return request;
}

输入文本框中默认包含一个用于嵌套对象的测试数据 string。通过点击加载默认测试输入字符串链接,可以将用于嵌套集合对象的默认测试字符串加载到框中。输入字符串中的数据必须与 API 输入参数设置的模型类型匹配。否则,只有与字段和属性名匹配的数据部分会被填充到模型中。例如,如果您使用嵌套集合对象的默认数据字符串,但点击传递给嵌套对象 Get 链接,您将获得一个模型,其中仅包含集合中的第一个对象项,因为模型类中定义的属性没有集合类型。

下面是用于将查询字符串传递给 API 方法 Get_NvpsToNestCollection() 的演示屏幕截图。

我们也可以在 Visual Studio 2012/2013 的调试窗口中检查 .NET 模型对象详情。

FieldValueModelBinder 如何工作?

自定义模型绑定器反序列化输入数据,并使用 API 方法参数中定义的类型填充对象。我们需要在代码中实现 System.Web.Http.ModelBinding.IModelBinder 的唯一成员 BindModel 方法。该方法接收两种类型的类对象,这两种类型对于数据反序列化是必需的。

  1. System.Web.Http.Controllers.HttpActionContext 包含所有源 HTTP 数据信息。
  2. System.Web.Http.ModelBinding.ModelBindingContext 包含所有目标对象模型信息。它还有一个 ValueProvider 属性,用于访问已注册的任何源数据值提供程序。

这是 FieldValueModelBinder 类中的主要工作流程:

  • 获取源字段-值对字符串,并将数据转换为可操作的键值对项列表。
  • 遍历层次结构中对象的每个属性。
  • 如果当前迭代项是复杂属性,则递归地遍历其属性。
  • 如果复杂属性是集合类型,则为源数据创建一个工作组列表。否则,使用单个工作列表处理源数据。
  • 迭代源数据的可操作列表。
  • 如果源字段名与属性名匹配,则为简单属性或复杂属性设置值。
  • 在每次迭代成功完成后,从原始数据源列表中删除已处理的项,并刷新可操作的源数据列表。
  • 最后,将顶层对象设置为目标模型并返回。

有关详细信息,请参阅 下载源中的代码和注释行。后续章节将进一步讨论一些特殊情况。

获取源字段和值

FieldValueModelBinder 类直接调用 HttpActionContext 来获取源数据,而不使用值提供程序,因为默认的 QueryStringValueProvider 仅从 URI 获取数据,而不从请求正文获取。它也无法处理集合,因为需要递归迭代。更重要的是,我需要为任何实际的迭代过程使用可操作的源数据列表(见下文)。尽管我可以创建一个自定义值提供程序,但使用我自己的 List<KeyValuePair<string, string>> 更灵活且效率更高。

这是获取原始源数据的代码:

//Define original source data list
List<KeyValuePair<string, string>> kvps;

//Check and get source data from uri 
if (!string.IsNullOrEmpty(actionContext.Request.RequestUri.Query))
{    
    kvps = actionContext.Request.GetQueryNameValuePairs().ToList(); 
}
//Check and get source data from body
else if (actionContext.Request.Content.IsFormData())
{                
    var bodyString = actionContext.Request.Content.ReadAsStringAsync().Result;
    kvps = ConvertToKVP(bodyString);
}
...

将为每次迭代过程创建一个 kvps 的工作副本。对于嵌套集合对象,还需要创建一个集合中的对象项列表。

//Set KV Work List for each real iteration process
List<KeyValuePair<string, string>> kvpsWork = new List<KeyValuePair<string, string>>(kvps);

//KV Work For each object item in collection
List<KeyValueWork> kvwsGroup = new List<KeyValueWork>();            

//KV Work for collection
List<List<KeyValueWork>> kvwsGroups = new List<List<KeyValueWork>>();

使用可操作的源列表可以使原始源列表在迭代循环中不受干扰,因此在处理完某个项后可以从原始列表中删除该项,只留下未处理的项供后续处理。在开始下一次属性迭代之前,任何可操作列表都将从原始列表中刷新。

kvps.Remove(item.SourceKvp); 

将字段部分与对象属性匹配

如前所述,对于嵌套对象,我们可以使用不带对象前缀的字段名。模型绑定器也处理这种情况的两种模式。

  1. 正确匹配项,当层次结构中不同对象存在相同的属性名时。此功能得益于使用刷新后的可操作源数据列表。由于已处理的源字段-值对已被删除,候选列表中只有一个未处理的项可用于与下一个迭代属性进行匹配。要测试此功能,请在 SM.Store.Api.Sort 类中将 SortDirection 属性行更改为:
    public int PageIndex { get; set; }

    运行测试应用程序后,在源查询字符串输入框中将 &SortDirection=Descending 替换为 &PageIndex=2,然后点击传递给嵌套对象 Get 链接。结果如下所示:

  2. 忽略字段名部分中可能存在的任何父名称前缀,例如“PagingRequest[0]Sort[0]SortBy=ProductName”。代码使用正则表达式 Split() 函数仅获取字段名的最后一部分。

    //Ignore any bracket in a name key 
    var key = item.Key;
    var keyParts = Regex.Split(key, @"\[\d*\]");
    if (keyParts.Length > 1) key = keyParts[keyParts.Length - 1];

对于嵌套集合对象,正则表达式 Match() 方法用于提取最后一个父名称的括号和索引值。然后,基于父括号拆分字段名字符串,以获取前缀字段名的最后一部分。

//Get parts from current KV Work
regex = new Regex(parentProp.Name + @"\[([^}])\]");
match = regex.Match(item.Key);
var brackets = match.Value.Replace(parentProp.Name, "");
var objIdx = match.Groups[1].Value;

//Get parts array from Key
var keyParts = item.Key.Split(new string[] { brackets }, 
               StringSplitOptions.RemoveEmptyEntries);

//Get last part from prefixed name
Key = keyParts[keyParts.Length - 1]; 

仅知道字段名的最后一部分对于嵌套集合对象来说还不够。如果未正确传递索引到子级别,或者在子级别未进行检查,则字段-值对将不会映射到正确的子对象属性。因此,pParentObjIndex 参数中的父对象索引值将被传递给递归方法以处理子对象。

RecurseNestedObj(tempObj, prop, pParentName: group[0].ParentName, 
                 pParentObjIndex: group[0].ObjIndex);

然后,处理子对象的该方法将刷新可操作的源列表,该列表仅包含与当前迭代的父索引值匹配的项。pParentObjIndex 值。

//Get data only from parent-parent for linked child KV Work
if (pParentName != "" & pParentObjIndex != "")
{
    regex = new Regex(pParentName + RexSearchBracket);
    match = regex.Match(item.Key);
    if (match.Groups[1].Value != pParentObjIndex)
        break;
}

解析枚举类型

使用 enum 关键字的枚举类型是一种特殊类型,包含一系列常量枚举值。具有 enum 类型的属性也是简单属性,不需要递归处理。代码首先搜索 enum 项的值。如果不匹配,则通过匹配 enum 索引位置搜索默认的 int 类型值。因此,输入数据可以接受 enum 值文本或整数索引位置。代码还将输入的 enum 值文本设置为不区分大小写。

if (prop.PropertyType.IsEnum)
{
    var enumValues = prop.PropertyType.GetEnumValues();
    object enumValue = null;
    bool isFound = false;
                
    //Try to match enum item name first
    for (int i = 0; i < enumValues.Length; i++)
    {                    
        if (item.Value.ToLower() == enumValues.GetValue(i).ToString().ToLower())
        {
            enumValue = enumValues.GetValue(i);
            isFound = true;
            break;
        }
    }
    //Try to match enum default underlying int value if not matched with enum item name
    if(!isFound)
    {
        for (int i = 0; i < enumValues.Length; i++)
        {
            if (item.Value == i.ToString())
            {
                enumValue = i;                            
                break;
            }
        }
    }                
    prop.SetValue(obj, enumValue, null);
}

支持的集合类型

NameValueModelBinder 类支持通用的 List<>System.Array 类型。在测试示例中,模型中具有复杂类型 PagingSortRequests 的集合可以使用以下任一形式定义:

  1. 直接声明泛型 List<> 类型。
    public List<PagingSortRequest> PagingRequest { get; set; }
  2. 声明继承 List<> 类型基类的类对象。
    public PagingSortRequests PagingRequest { get; set; }

    类的代码

    public class PagingSortRequests : List<PagingSortRequest> {}
  3. 声明一个对象数组

    public PagingSortRequest[] PagingRequest { get; set; };

当模型绑定器处理集合类型时,它需要动态实例化集合对象。对于数组类型,我们还需要在实例化数组之前知道元素数量。在我们的例子中,数量信息可以从可操作组源列表的项目数量中获得。这里是相关的代码行:

//Initiate List or Array
IList listObj = null;
Array arrayObj = null;
if (parentProp.PropertyType.IsGenericType || parentProp.PropertyType.BaseType.IsGenericType)
{
    listObj = (IList)Activator.CreateInstance(parentProp.PropertyType);
}
else if (parentProp.PropertyType.IsArray)
{
    arrayObj = Array.CreateInstance(parentProp.PropertyType.GetElementType(), kvwsGroups.Count);
} 

最大递归限制

模型绑定器在类级别将默认最大递归限制设置为 100。

private int maxRecursionLimit = 100;

在模型绑定器中,对象树中的任何复杂属性都会使递归计数器加一。父对象下的任何嵌套集合都将使用一次递归,而不管集合中的项目数量如何,因为所有集合项都在同一个 PropertyInfo 数组下进行处理,并在一次递归中完成。然而,如果父对象是集合,则该父对象下的集合对象将根据父集合中的项目数量进行多次递归。前面描述的嵌套集合对象的测试示例将有三个递归计数,一次用于 PagingRequest 集合,两次用于 Sort 集合,因为 PagingRequest 集合中有两个项目对象。

您可以通过在调用项目的 Web.configApp.config 文件中设置项来更改最大限制值。

<appSettings>
   <add key="MaxRecursionLimit" value="120"/> 
   . . .
</appSettings>

默认的最大递归限制设置通常能满足常见应用程序的需求。增加限制数量并处理过多的嵌套对象或集合可能会耗尽机器内存并导致系统故障。此外,当通过 URI 将数据传递给 GET 方法时,输入字符串的大小也将受到限制。因此,对于 URI 中的查询字符串,处理大量嵌套对象或集合是不可能且不合适的。

解决传递字符串列表或数组的问题

从原始帖子下载的源代码在传递任何字符串列表或数组到 Web API 时会产生“未定义无参数构造函数”的错误。原因是模型绑定器为任何没有无参数构造函数的类类型动态创建 List<>Array 对象,而 System.String 类没有无参数构造函数。为了解决这个问题,模型绑定器会创建一个临时的物理 List<string>string[] 对象,因为字符串是已知的类类型,然后直接将字符串项值添加到 List<>Array 对象中。需要将类似的代码段放在顶层和递归迭代中,以使字符串列表或数组在根级别和/或嵌套级别工作。

//Check if List<string> or string[] type and 
//assign string value directly to list or array item.    
if (prop.ToString().Contains("[System.String]") || 
    prop.ToString().Contains("System.String[]"))
{
    var strList = new List<string>();
    foreach (var item in kvpsWork)
    {
        //Remove any brackets and enclosure from Key.
        var itemKey = Regex.Replace(item.Key, RexBrackets, "");
        if (itemKey == prop.Name)
        {
            strList.Add(item.Value);
            kvps.Remove(item);
        }
    }
    //Add list to parent property.                        
    if (prop.PropertyType.IsGenericType) prop.SetValue(obj, strList);
    else if (prop.PropertyType.IsArray) prop.SetValue(obj, strList.ToArray()); 
} 

测试请求对象模型可以演示如下:

//For test of passing string list or array.
public class PagingSortRequest2
{
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
    public string[] RootStrings { get; set; }
    public Sort2[] Sort2 { get; set; }
}
public class Sort2
{
    public string SortBy { get; set; }
    public SortDirection SortDirection { get; set; }
    public List<string> InStrings { get; set; }        
}

然后,测试输入字段-值对参数应如下所示(显示为换行):

 PageIndex=1
&PageSize=8
&RootStrings[0]=OK
&RootStrings[1]=Yes
&RootStrings[2]=456
&Sort2[0]SortBy=ProductName
&Sort2[0]SortDirection=descending
&Sort2[0]InStrings[0]=Search
&Sort2[0]InStrings[1]=Find
&Sort2[1]SortBy=CategoryID
&Sort2[1]SortDirection=0
&Sort2[1]InStrings[0]=Here
&Sort2[1]InStrings[1]=Also

运行测试项传递字符串列表或数组对象 Get 的结果如下所示:

请注意,只有 System.String,而不是原始类型,被支持作为 List<>Array 的类类型。如果您尝试传递值到带有 List<int> 的请求模型,它不会报错,但传递的值是不正确的。这也可以修复,但传递字符串列表或数组足以满足目标目的。如果需要,您可以将字符串类类型(对于 List<>Array 中的任何其他类类型)传递到 Web API,然后在那里进行类型转换。

迁移到 ASP.NET Core

此帖附带了 FieldValueModelBinder 源代码的 ASP.NET Core 2.0 版本。在 ASP.NET Core 中,IModelBinder 接口类型来自 Microsoft.AspNetCore.Mvc.ModelBinding 命名空间,而在 ASP.NET Web API 2.0 中,它是 System.Web.Http.ModelBinding 的成员。这是一个重大的改变,因为 HttpContext 现在通过 Kestrel Web 服务器由一系列请求特性组成,这破坏了与先前版本的兼容性。您可以将 FieldValueModelBinder.cs 文件添加到您的 ASP.NET Core 2.0 项目中,更改命名空间,然后使用与方法参数相同的属性类型。

在我另一篇文章 ASP.NET Core:从 ASP.NET Web API 迁移的多层数据服务应用程序 中,有详细的描述、模型绑定器测试用例,甚至是一个完整的示例应用程序。您可以在那里下载源代码以及测试用例文件 TestCasesForModelBinder.txt,并在一个完整的 ASP.NET Core 数据服务应用程序中运行这些测试用例。

摘要

本文介绍的自定义 FieldValueModelBinder 类可以有效地用于将复杂对象与查询字符串一起传递到 Web API 方法。它易于使用,特别是对于接收嵌套对象作为查询字符串的 GET 方法。对于嵌套集合对象,FieldValueModelBinder 类在将查询字符串用作任何 GETPOSTPUT 方法的源时提供了一个选项。

历史

  • 2013 年 12 月 26 日
    • 原始帖子
  • 2015 年 5 月 4 日
  • 2018 年 1 月 18 日
    • 添加了模型绑定器源代码文件和 ASP.NET Core 2.0 部分。
© . All rights reserved.