ASP.NET Web API 中的参数绑定

请考虑使用 ASP.NET Core Web API。 它比 ASP.NET 4.x Web API 具有以下优势:

  • ASP.NET Core 是一个开源的跨平台框架,用于在 Windows、macOS 和 Linux 上构建基于云的新式 Web 应用。
  • ASP.NET 核心 MVC 控制器和 Web API 控制器是统一的。
  • 针对可测试性进行构建。
  • 能够在 Windows、macOS 和 Linux 上进行开发和运行。
  • 开放源代码和以社区为中心。
  • 集成新式客户端框架和开发工作流。
  • 基于环境的云就绪配置系统。
  • 内置依赖项注入。
  • 轻型的高性能模块化 HTTP 请求管道。
  • 能够在 Kestrel、IISHTTP.sys、NginxApacheDocker 上托管。
  • 并行版本控制。
  • 简化新式 Web 开发的工具。

本文介绍了 Web API 如何绑定参数,以及如何自定义绑定过程。 当 Web API 在控制器上调用方法时,它必须为参数(称为 绑定的进程)设置值。

默认情况下,Web API 使用以下规则来绑定参数:

  • 如果参数是“简单”类型,Web API 会尝试从 URI 获取值。 简单类型包括 .NET 基元类型intbooldouble 等),以及 TimeSpanDateTimeGuiddecimalstring以及 具有可从字符串转换的类型转换器的任何类型。 (有关稍后的类型转换器的详细信息。
  • 对于复杂类型,Web API 尝试使用 媒体类型格式化程序从消息正文读取值。

例如,下面是典型的 Web API 控制器方法:

HttpResponseMessage Put(int id, Product item) { ... }

ID 参数是一种“简单”类型,因此 Web API 会尝试从请求 URI 获取值。 项参数是一种复杂类型,因此 Web API 使用媒体类型格式化程序从请求正文读取值。

若要从 URI 获取值,Web API 会在路由数据和 URI 查询字符串中查找。 路由系统分析 URI 并将其与路由匹配时,将填充路由数据。 有关详细信息,请参阅 路由和操作选择

本文的其余部分介绍如何自定义模型绑定过程。 但是,对于复杂类型,请考虑尽可能使用媒体类型格式化程序。 HTTP 的主要原则是,资源在消息正文中发送,使用内容协商来指定资源的表示形式。 媒体类型格式化程序专为此目的而设计。

使用 [FromUri]

若要强制 Web API 从 URI 读取复杂类型,请将 [FromUri] 属性添加到参数。 以下示例定义一个 GeoPoint 类型,以及从 URI 获取 GeoPoint 的控制器方法。

public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }
}

public ValuesController : ApiController
{
    public HttpResponseMessage Get([FromUri] GeoPoint location) { ... }
}

客户端可以将纬度和经度值放入查询字符串中,Web API 将使用这些值来构造一个 GeoPoint。 例如:

http://localhost/api/values/?Latitude=47.678558&Longitude=-122.130989

使用 [FromBody]

若要强制 Web API 从请求正文读取简单类型,请将 [FromBody] 属性添加到参数:

public HttpResponseMessage Post([FromBody] string name) { ... }

在此示例中,Web API 将使用媒体类型格式化程序从请求正文中读取名称。 下面是一个示例客户端请求。

POST http://localhost:5076/api/values HTTP/1.1
User-Agent: Fiddler
Host: localhost:5076
Content-Type: application/json
Content-Length: 7

"Alice"

当参数具有 [FromBody]时,Web API 使用 Content-Type 标头来选择格式化程序。 在此示例中,内容类型为“application/json”,请求正文是原始 JSON 字符串(而不是 JSON 对象)。

最多允许一个参数从消息正文中读取。 因此,这不起作用:

// Caution: Will not work!    
public HttpResponseMessage Post([FromBody] int id, [FromBody] string name) { ... }

此规则的原因是请求正文可能存储在只能读取一次的非缓冲流中。

类型转换器

可以通过创建 TypeConverter 并提供字符串转换,使 Web API 将类视为简单类型(以便 Web API 将尝试从 URI 绑定)。

下面的代码演示表示GeoPoint地理点的类,以及从字符串转换为GeoPoint实例的 TypeConverter。 该 GeoPoint 类使用 [TypeConverter] 属性进行修饰,以指定类型转换器。 (此示例灵感来自迈克·斯塔尔的博客文章 如何在 MVC/WebAPI 中绑定到操作签名中的自定义对象。

[TypeConverter(typeof(GeoPointConverter))]
public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }

    public static bool TryParse(string s, out GeoPoint result)
    {
        result = null;

        var parts = s.Split(',');
        if (parts.Length != 2)
        {
            return false;
        }

        double latitude, longitude;
        if (double.TryParse(parts[0], out latitude) &&
            double.TryParse(parts[1], out longitude))
        {
            result = new GeoPoint() { Longitude = longitude, Latitude = latitude };
            return true;
        }
        return false;
    }
}

class GeoPointConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        if (sourceType == typeof(string))
        {
            return true;
        }
        return base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, 
        CultureInfo culture, object value)
    {
        if (value is string)
        {
            GeoPoint point;
            if (GeoPoint.TryParse((string)value, out point))
            {
                return point;
            }
        }
        return base.ConvertFrom(context, culture, value);
    }
}

现在,Web API 将 GeoPoint 被视为简单类型,这意味着它将尝试从 URI 绑定 GeoPoint 参数。 无需在参数中包含 [FromUri]。

public HttpResponseMessage Get(GeoPoint location) { ... }

客户端可以使用如下所示的 URI 调用方法:

http://localhost/api/values/?location=47.678558,-122.130989

模型绑定器

比类型转换器更灵活的选项是创建自定义模型绑定器。 使用模型绑定器,可以访问 HTTP 请求、操作说明和路由数据中的原始值等内容。

若要创建模型绑定器,请实现 IModelBinder 接口。 此接口定义单个方法 BindModel

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

下面是对象的 GeoPoint 模型绑定器。

public class GeoPointModelBinder : IModelBinder
{
    // List of known locations.
    private static ConcurrentDictionary<string, GeoPoint> _locations
        = new ConcurrentDictionary<string, GeoPoint>(StringComparer.OrdinalIgnoreCase);

    static GeoPointModelBinder()
    {
        _locations["redmond"] = new GeoPoint() { Latitude = 47.67856, Longitude = -122.131 };
        _locations["paris"] = new GeoPoint() { Latitude = 48.856930, Longitude = 2.3412 };
        _locations["tokyo"] = new GeoPoint() { Latitude = 35.683208, Longitude = 139.80894 };
    }

    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(GeoPoint))
        {
            return false;
        }

        ValueProviderResult val = bindingContext.ValueProvider.GetValue(
            bindingContext.ModelName);
        if (val == null)
        {
            return false;
        }

        string key = val.RawValue as string;
        if (key == null)
        {
            bindingContext.ModelState.AddModelError(
                bindingContext.ModelName, "Wrong value type");
            return false;
        }

        GeoPoint result;
        if (_locations.TryGetValue(key, out result) || GeoPoint.TryParse(key, out result))
        {
            bindingContext.Model = result;
            return true;
        }

        bindingContext.ModelState.AddModelError(
            bindingContext.ModelName, "Cannot convert value to GeoPoint");
        return false;
    }
}

模型绑定器从 值提供程序获取原始输入值。 此设计将两个不同的函数分开:

  • 值提供程序接受 HTTP 请求并填充键值对字典。
  • 模型绑定器使用此字典填充模型。

Web API 中的默认值提供程序从路由数据和查询字符串获取值。 例如,如果 URI 为 http://localhost/api/values/1?location=48,-122,则值提供程序将创建以下键值对:

  • id = “1”
  • location = “48,-122”

(假设默认路由模板为“api/{controller}/{id}”。

要绑定的参数的名称存储在 ModelBindingContext.ModelName 属性中。 模型绑定器在字典中查找具有此值的键。 如果值存在且可转换为 a GeoPoint,则模型绑定器会将绑定值分配给 ModelBindingContext.Model 属性。

请注意,模型绑定器不限于简单的类型转换。 在此示例中,模型绑定器首先在已知位置的表中查找,如果失败,则使用类型转换。

设置模型绑定器

可通过多种方式设置模型绑定器。 首先,可以将 [ModelBinder] 属性添加到参数。

public HttpResponseMessage Get([ModelBinder(typeof(GeoPointModelBinder))] GeoPoint location)

还可以将 [ModelBinder] 属性添加到该类型。 Web API 将为该类型的所有参数使用指定的模型绑定器。

[ModelBinder(typeof(GeoPointModelBinder))]
public class GeoPoint
{
    // ....
}

最后,可以将模型绑定器提供程序添加到 HttpConfiguration。 模型绑定器提供程序只是创建模型绑定程序的工厂类。 可以通过从 ModelBinderProvider 类派生来创建提供程序。 但是,如果模型绑定器处理单个类型,则使用专为此目的设计的内置 SimpleModelBinderProvider 更容易。 下面的代码演示如何执行此操作。

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        var provider = new SimpleModelBinderProvider(
            typeof(GeoPoint), new GeoPointModelBinder());
        config.Services.Insert(typeof(ModelBinderProvider), 0, provider);

        // ...
    }
}

使用模型绑定提供程序时,仍需将 [ModelBinder] 属性添加到参数中,以告知 Web API 它应使用模型绑定器,而不是媒体类型格式化程序。 但现在,无需在属性中指定模型绑定器的类型:

public HttpResponseMessage Get([ModelBinder] GeoPoint location) { ... }

值提供程序

我提到模型绑定器从值提供程序获取值。 若要编写自定义值提供程序,请实现 IValueProvider 接口。 下面是从请求中的 Cookie 中提取值的示例:

public class CookieValueProvider : IValueProvider
{
    private Dictionary<string, string> _values;

    public CookieValueProvider(HttpActionContext actionContext)
    {
        if (actionContext == null)
        {
            throw new ArgumentNullException("actionContext");
        }

        _values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var cookie in actionContext.Request.Headers.GetCookies())
        {
            foreach (CookieState state in cookie.Cookies)
            {
                _values[state.Name] = state.Value;
            }
        }
    }

    public bool ContainsPrefix(string prefix)
    {
        return _values.Keys.Contains(prefix);
    }

    public ValueProviderResult GetValue(string key)
    {
        string value;
        if (_values.TryGetValue(key, out value))
        {
            return new ValueProviderResult(value, value, CultureInfo.InvariantCulture);
        }
        return null;
    }
}

还需要通过派生自 ValueProviderFactory 类来创建值提供程序工厂。

public class CookieValueProviderFactory : ValueProviderFactory
{
    public override IValueProvider GetValueProvider(HttpActionContext actionContext)
    {
        return new CookieValueProvider(actionContext);
    }
}

将值提供程序工厂添加到 HttpConfiguration ,如下所示。

public static void Register(HttpConfiguration config)
{
    config.Services.Add(typeof(ValueProviderFactory), new CookieValueProviderFactory());

    // ...
}

Web API 构成所有值提供程序,因此,当模型绑定器调用 ValueProvider.GetValue 时,模型绑定器将从能够生成它的第一个值提供程序接收值。

或者,可以使用 ValueProvider 属性在参数级别设置值提供程序工厂,如下所示:

public HttpResponseMessage Get(
    [ValueProvider(typeof(CookieValueProviderFactory))] GeoPoint location)

这会告知 Web API 将模型绑定与指定的值提供程序工厂配合使用,而不使用任何其他已注册的值提供程序。

HttpParameterBinding

模型绑定器是更通用机制的特定实例。 如果查看 [ModelBinder] 属性,将看到它派生自抽象 ParameterBindingAttribute 类。 此类定义单个方法 GetBinding,该方法返回 HttpParameterBinding 对象:

public abstract class ParameterBindingAttribute : Attribute
{
    public abstract HttpParameterBinding GetBinding(HttpParameterDescriptor parameter);
}

HttpParameterBinding 负责将参数绑定到值。 对于 [ModelBinder],该属性返回使用 IModelBinder 执行实际绑定的 HttpParameterBinding 实现。 还可以实现自己的 HttpParameterBinding

例如,假设你想要从请求中获取 if-match ETag 和 if-none-match 标头。 首先,我们将定义一个类来表示 ETag。

public class ETag
{
    public string Tag { get; set; }
}

我们还将定义一个枚举,以指示是从 if-match 标头还是 if-none-match 标头获取 ETag。

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

下面是从 所需标头获取 ETag 并将其绑定到 ETag 类型的参数的 HttpParameterBinding

public class ETagParameterBinding : HttpParameterBinding
{
    ETagMatch _match;

    public ETagParameterBinding(HttpParameterDescriptor parameter, ETagMatch match) 
        : base(parameter)
    {
        _match = match;
    }

    public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, 
        HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        EntityTagHeaderValue etagHeader = null;
        switch (_match)
        {
            case ETagMatch.IfNoneMatch:
                etagHeader = actionContext.Request.Headers.IfNoneMatch.FirstOrDefault();
                break;

            case ETagMatch.IfMatch:
                etagHeader = actionContext.Request.Headers.IfMatch.FirstOrDefault();
                break;
        }

        ETag etag = null;
        if (etagHeader != null)
        {
            etag = new ETag { Tag = etagHeader.Tag };
        }
        actionContext.ActionArguments[Descriptor.ParameterName] = etag;

        var tsc = new TaskCompletionSource<object>();
        tsc.SetResult(null);
        return tsc.Task;
    }
}

ExecuteBindingAsync 方法执行绑定。 在此方法中,将绑定参数值添加到 HttpActionContext 中的 ActionArgument 字典。

注意

如果 ExecuteBindingAsync 方法读取请求消息的正文,请重写 WillReadBody 属性以返回 true。 请求正文可能是只能读取一次的无缓冲区流,因此 Web API 强制实施一个规则,即最多一个绑定可以读取消息正文。

若要应用自定义 HttpParameterBinding,可以定义派生自 ParameterBindingAttribute 的属性。 因此 ETagParameterBinding,我们将定义两个属性,一个用于 if-match 标头,一个用于 if-none-match 标头。 两者都派生自抽象基类。

public abstract class ETagMatchAttribute : ParameterBindingAttribute
{
    private ETagMatch _match;

    public ETagMatchAttribute(ETagMatch match)
    {
        _match = match;
    }

    public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter)
    {
        if (parameter.ParameterType == typeof(ETag))
        {
            return new ETagParameterBinding(parameter, _match);
        }
        return parameter.BindAsError("Wrong parameter type");
    }
}

public class IfMatchAttribute : ETagMatchAttribute
{
    public IfMatchAttribute()
        : base(ETagMatch.IfMatch)
    {
    }
}

public class IfNoneMatchAttribute : ETagMatchAttribute
{
    public IfNoneMatchAttribute()
        : base(ETagMatch.IfNoneMatch)
    {
    }
}

下面是使用特性的 [IfNoneMatch] 控制器方法。

public HttpResponseMessage Get([IfNoneMatch] ETag etag) { ... }

除了 ParameterBindingAttribute 之外,还有另一个用于添加自定义 HttpParameterBinding 的挂钩。 在 HttpConfiguration 对象上,ParameterBindingRules 属性是类型(HttpParameterDescriptor -HttpParameterBinding)>的匿名函数集合。 例如,可以添加 GET 方法 ETagParameterBinding 上使用的任何 ETag 参数的规则 if-none-match

config.ParameterBindingRules.Add(p =>
{
    if (p.ParameterType == typeof(ETag) && 
        p.ActionDescriptor.SupportedHttpMethods.Contains(HttpMethod.Get))
    {
        return new ETagParameterBinding(p, ETagMatch.IfNoneMatch);
    }
    else
    {
        return null;
    }
});

函数应返回 null 绑定不适用的参数。

IActionValueBinder

整个参数绑定过程由可插入服务 IActionValueBinder 控制。 IActionValueBinder 的默认实现执行以下操作:

  1. 在参数上查找 ParameterBindingAttribute 。 这包括 [FromBody][FromUri][ModelBinder]或自定义属性。

  2. 否则,在 HttpConfiguration.ParameterBindingRules查找返回非 null HttpParameterBinding 的函数。

  3. 否则,请使用前面描述的默认规则。

    • 如果参数类型为“simple”或具有类型转换器,请从 URI 绑定。 这相当于将 [FromUri] 属性置于参数上。
    • 否则,请尝试从消息正文中读取参数。 这相当于将 [FromBody] 放在参数上。

如果需要,可以将整个 IActionValueBinder 服务替换为自定义实现。

其他资源

自定义参数绑定示例

Mike Stall 撰写了一系列关于 Web API 参数绑定的博客文章: