ASP.NET Web API 中的參數繫結

考慮使用 ASP.NET Core Web API。 與 ASP.NET 4.x Web API 相比,它具有以下優點:

  • ASP.NET Core 是一個開源跨平台架構,用於在 Windows、macOS 和 Linux 上建立基於雲端的現代 Web 應用程式。
  • ASP.NET Core MVC 控制器和 Web API 控制器是統一的。
  • 可測試性架構。
  • 能夠在 Windows、macOS 和 Linux 上開發並執行。
  • 開放原始碼和社群導向。
  • 整合的用戶端架構和開發工作流程。
  • 雲端就緒、以環境為基礎的組態系統。
  • 內建的相依性插入。
  • 輕量型、高效能且模組化的 HTTP 要求管線。
  • 能夠在 KestrelIISHTTP.sysNginxApacheDocker 上裝載。
  • 並存版本。
  • 可簡化現代網頁程式開發的工具。

本文介紹 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 取得值。 item 參數是一個複雜類型,因此 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 將使用媒體類型格式器從請求本文中讀取 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 標頭來選擇格式器。 在此範例中,內容類型為「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 執行個體的 TypeConverterGeoPoint 類別以 [TypeConverter] 屬性修飾以指定類型轉換器。 (此範例的靈感來自 Mike Stall 的部落格文章「如何在 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」
  • 位置=「48,-122」

(我假設預設路由範本是「api/{controller}/{id}」。)

要繫結的參數名稱儲存在 ModelBindingContext.ModelName 屬性中。 模型繫結器會在字典中尋找具有該值的索引鍵。 如果該值存在且可以轉換為 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],該屬性會傳回一個 HttpParameterBinding 實作,該實作會使用 IModelBinder 來執行實際繫結。 您也可以實作自己的 HttpParameterBinding

例如,假設您想要從請求中的 if-matchif-none-match 標頭取得 ETag。 我們先定義一個類別來表示 ETag。

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

我們也將定義一個列舉來指示是從 if-match 標頭還是從 if-none-match 標頭取得 ETag。

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

以下是 HttpParameterBinding,它從所需標頭取得 ETag,並將其繫結到 ETag 類型的參數:

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 方法上的 ETag 參數使用 ETagParameterBindingif-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. 否則,請使用我之前描述的預設規則。

    • 如果參數類型為「簡單」或具有類型轉換器,則從 URI 繫結。 這相當於將 [FromUri] 屬性放在參數上。
    • 否則,請嘗試從訊息體中讀取參數。 這相當於將 [FromBody] 放在參數上。

如果需要,您可以用自訂實作取代整個 IActionValueBinder 服務。

其他資源

自訂參數繫結範例

Mike Stall 撰寫了一系列有關 Web API 參數繫結的精彩部落格文章: