原文地址:Parameter Binding in ASP.NET Web API
譯文如下:
當Web API相應Controller的一個方法時,它必定存在一個設置參數的過程,叫作數據綁定。這篇文章描述了Web API如何綁定參數以及如何自定義綁定過程。
一般情況下,Web API綁定參數符合如下規則:
-
- 如果參數為簡單類型,Web API 嘗試從URI中獲取。簡單參數類型包含.Net源生類型(int,bool,double...),加上TimeSpan,DateTime,Guid,decimal和string.加上任何包含string轉化器的類型。(More about type converters later.)
- 對復雜類型來說,Web API 試圖從message body 中讀取,使用media-type 類型。
典型Web API Controller方法的例子:
HttpResponseMessage Put(int id, Product item) { ... }
參數id是簡單類型,所以webapi試圖從URI中獲取值。參數item為復雜類型,所以web api 使用 media-type 類型從request body中讀取。
從URI中獲取值,Web API會從路由或者URI的查詢參數中獲取。路由數據例子如下:"api/{controller}/public/{category}/{id}",更多細節可參考:Routing and Action Selection.
在余下的文章中,我將給你介紹的是如何自定義參數綁定的過程。對於復雜類型,當然最好盡可能考慮media-type類型轉化。一個http的關鍵原理是:資源是保存在message body 中被傳遞的,使用內容協商來指定資源如何表示。media-type 類型就是被設計來實現這個目標的。
使用[FromUri]
在參數前添加[FromUri]屬性可以強制Web API 從URI中讀取復雜類型。下面的例子定義了GeoPoint類型,以及一個從URI中獲取GeoPoint的controller方法。
public class GeoPoint { public double Latitude { get; set; } public double Longitude { get; set; } } public ValuesController : ApiController { public HttpResponseMessage Get([FromUri] GeoPoint location) { ... } }
客戶端可以通過查詢字符將兩個參數傳遞給Web API。例子如下:
http://localhost/api/values/?Latitude=47.678558&Longitude=-122.130989
使用【FromBody】
給參數添加[FromBody]屬性可以迫使Web API從request body 中讀取簡單參數。
public HttpResponseMessage Post([FromBody] string name) { ... }
在這個例子中WebAPI將使用一個 media-type 轉化器從Request body 中讀取name的值。
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 header選擇格式。在這個例子中,content type 是“application/json” 並且request body是原始的Json字符串(不是Json對象)。
有而且只有一個參數允許從message body 中讀取,下面的例子將不會成功:
// Caution: Will not work! public HttpResponseMessage Post([FromBody] int id, [FromBody] string name) { ... }
這條規則的原因為:request body 可能存儲在一個只能讀取一次的非緩沖的數據流中。
Type Converters
創建一個TypeConverter並提供一個字符串轉化,你就可以使Web API像對待一個簡單類型那樣對待一個類(所以Web API會嘗試從URI中獲取綁定的數據)。
下面的代碼展示了一個GeoPoint類代表地理坐標,添加TypeConvert方法將字符串轉化為GeoPoint實例。GeoPoint類添加了[TypeConvert]屬性,制定類型轉化器。(這個例子取自Mike Stall的博客文章How to bind to custom objects in action signatures in 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視為簡單類型,也就是說他會試圖從URL中獲取GeoPoint參數,而不用在參數前添加[FromUri]。
public HttpResponseMessage Get(GeoPoint location) { ... }
客戶端請求的URI就像這樣:
http://localhost/api/values/?location=47.678558,-122.130989
Model Binders
操控類型轉化更強的是創建自定義對象綁定Model Binder。使用Model Binder,你可以接收一個Http請求,一個Action和一個路由數據的原始值。
創建一個Model Binder,需要繼承IModelBinder接口,這個接口只定義了一個方法BindModel:
bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);
下面是GeoPoint對象的Model Binder
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 Location"); return false; } }
Model Binder 從Value Provider獲取原始輸入,這個設計中區分了兩個單獨的功能:
- Value Provider提供了一個Http請求並填充到一個鍵值字典中。
- Model Binder使用這個字典填充Model。
WebAPI默認的Value Provider通過路由數據和URI的查詢參數獲取數據。如果URI是http://localhost/api/values/1?location=48,-122,Value Provider創建一個鍵值對:
- id="1"
- location="48,122"
(我假設默認的路由模板是"api/{controller}/{id}")
綁定參數的名稱存儲在ModelBindingContext.ModelName屬性中,Model Binder在字典中查找Key和它的值。如果值存在而且可以被轉化成GeoPoint,Model Binder將綁定的值分配給ModelBindingContext.Model屬性。
注意:Model Binder並不只限定於簡單類型。在這個例子中,Model Binder首先尋找location類型的列表,如果失敗了才使用type Conversion。
設置 Model Binder
有多種路徑可以設置Model Binder。首先,你可以給參數添加[ModelBinder]屬性。
public HttpResponseMessage Get([ModelBinder(typeof(GeoPointModelBinder))] GeoPoint location)
你也可以在類型上添加[ModelBinder]屬性。Web API會對所有此類型的參數指派Model Binder。
[ModelBinder(typeof(GeoPointModelBinder))] public class GeoPoint { // .... }
最后,你也可以在HttpConfiguration中添加Model-binder Provider。model-binder provider是一個創建Model binder的簡單的工廠類。你可以通過繼承 ModelBinderProvider類來創建一個Provider.然而,如果你的model binder只處理一個類型,簡單的方法是嵌入SimpleModelBinderProvider,這也是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); // ... } }
使用model-binding provider,你仍然需要給參數添加[ModelBinder]屬性,去告訴WebAPI它應該使用model binder並且不是一個media-type類型。但是現在你不用在屬性中指定model binder的類型:
public HttpResponseMessage Get([ModelBinder] GeoPoint location) { ... }
Value Providers
我提到了model binder從Value provider中獲取值。通過實現IValueProvider接口創建自定義value provider。下面的列子展示了如何從一個Request請求的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類的Value Provider工廠。
public class CookieValueProviderFactory : ValueProviderFactory { public override IValueProvider GetValueProvider(HttpActionContext actionContext) { return new CookieValueProvider(actionContext); } }
把Value Provider添加到HttpConfiguration
public static void Register(HttpConfiguration config) { config.Services.Add(typeof(ValueProviderFactory), new CookieValueProviderFactory()); // ... }
Web API中包含所有的Value Provider,當我們調用ValueProvider.GetValue方法時,model binder接收到的是第一個可以產生它的Provider。
另外一種方法是,你可以使用ValueProvider屬性,在參數級下設置ValueProvider 工廠,就像下面所示:
public HttpResponseMessage Get( [ValueProvider(typeof(CookieValueProviderFactory))] GeoPoint location)
這就會告訴Web API使用指定的value provider factory工廠,而不是其他注冊的value provider。
HttpParameterBinding
模型綁定是一個普通機制中更常用的一個具體實例。如果你查看[ModelBinder]屬性,你就會發現他繼承於一個靜態類ParameterBindingAttribute。這個靜態類只定義了一個方法:GetBinding。這個方法返回一個HttpParameterBinding對象。
public abstract class ParameterBindingAttribute : Attribute { public abstract HttpParameterBinding GetBinding(HttpParameterDescriptor parameter); }
HttpParameterBinding 負責將綁定參數轉化為值。如果設置了 [ModelBinder],屬性會返回一個實現了IModelBinder 接口的HttpParameterBinding 去實現實際的綁定。你也可以實現你自己的HttpParameterBinding。
舉例來說:假設你想從一個請求的headers中的if-match和if-none-match中獲取ETags.首先我們需要定義ETag類。
public class ETag { public string Tag { get; set; } }
再次我們需要定義一個枚舉來確定是從header中的if-match還是if-none-match中取得ETag。
public enum ETagMatch { IfMatch, IfNoneMatch }
下面是一個從我們需要的header中獲取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 字典中。
NOTE:如果你的ExecuteBindingAsync方法從請求連接中的Body讀取值。需要重寫WillReadBody 屬性並讓他返回true。因為請求的Body可能是只能讀一次的無緩沖數據流。所以Web API定義了強制執行的規則:只有一個綁定可以讀取請求的body。
為了實現一個自定義HttpParameterBinding。你可以定義一個繼承自ParameterBindingAttribute的屬性。在ETagParameterBinding中定義了兩個屬性,一個是為if-match headers 另一個是為if-not-match headers。兩個都繼承自靜態基類。
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) { } }
下例說明了在controller的方法中如何使用[IfNoneMatch]屬性:
public HttpResponseMessage Get([IfNoneMatch] ETag etag) { ... }
除了ParameterBindingAttribute,這里還有另外一種關聯自定義HttpParameterBinding的方法。在HttpConfiguration 對象中,ParameterBindingRules 屬性是一個(HttpParameterDescriptor -> HttpParameterBinding)類型的匿名函數的集合。舉例來說,你可以添加一個規則,所有的包含ETag參數的Get方法使用if-none-match的ETagParameterBinding:
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的默認實現方式遵循以下步驟:
-
- 在參數中尋找ParameterBindingAttribute 。包含[FromBody], [FromUri], 和[ModelBinder]或自定義的屬性。
- 否則,從HttpConfiguration.ParameterBindingRules中尋找一個返回非空的HttpParameterBinding的函數。
- 否則,使用上面所描述的默認規則。
- 如果參數簡單或者有類型轉化器,綁定在URI上。這就相當於給參數添加了 [FromUri]屬性。
- 否則,嘗試從消息體中讀取參數。相當於給參數添加了[FromBody] 屬性。
如果你想,你可以使用自定義的實現代替整個IActionValueBinder 服務。
相關資源:
Custom Parameter Binding Sample
Mike Stall謝了非常好的一系列的關於Web API 參數綁定的blog文章: