Powiązanie parametrów w interfejsie API sieci Web ASP.NET

Rozważ użycie ASP.NET Core internetowego interfejsu API. Ma ona następujące korzyści w porównaniu z interfejsem API sieci Web w wersji ASP.NET 4.x:

  • ASP.NET Core to platforma typu open source, międzyplatformowa do tworzenia nowoczesnych, opartych na chmurze aplikacji internetowych w systemach Windows, macOS i Linux.
  • Kontrolery MVC i kontrolery internetowego interfejsu API ASP.NET Core są ujednolicone.
  • Zaprojektowano pod kątem możliwości testowania.
  • Możliwość tworzenia i uruchamiania w systemach Windows, macOS i Linux.
  • Open source i koncentracja na społeczności.
  • Integracja nowoczesnych struktur po stronie klienta i programistycznych przepływów pracy.
  • Gotowy do pracy w chmurze, oparty na środowisku system konfiguracji.
  • Wbudowane wstrzykiwanie zależności.
  • Uproszczony, zapewniający wysoką wydajność, modułowy potok żądania HTTP.
  • Możliwość hostowania w usługach Kestrel, IIS, HTTP.sys, Nginx, Apache i Docker.
  • Przechowywanie wersji równoległych.
  • Narzędzia, które upraszczają tworzenie nowoczesnych aplikacji internetowych.

W tym artykule opisano sposób powiązania parametrów internetowego interfejsu API oraz sposób dostosowywania procesu powiązania. Gdy internetowy interfejs API wywołuje metodę na kontrolerze, musi ustawić wartości parametrów, czyli proces nazywany powiązaniem.

Domyślnie internetowy interfejs API używa następujących reguł do powiązania parametrów:

  • Jeśli parametr jest typem "prostym", internetowy interfejs API próbuje uzyskać wartość z identyfikatora URI. Proste typy obejmują typy pierwotne platformy .NET (int, bool, double i tak dalej), plusTimeSpan, DateTime, Guid, decimal i string oraz dowolny typ z konwerterem typów, który może konwertować z ciągu. (Więcej informacji na temat konwerterów typów później).
  • W przypadku typów złożonych internetowy interfejs API próbuje odczytać wartość z treści komunikatu przy użyciu formatnika typu nośnika.

Na przykład poniżej przedstawiono typową metodę kontrolera internetowego interfejsu API:

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

Parametr id jest typem "prostym", więc internetowy interfejs API próbuje uzyskać wartość z identyfikatora URI żądania. Parametr elementu jest typem złożonym, więc internetowy interfejs API używa formatnika typu nośnika do odczytywania wartości z treści żądania.

Aby uzyskać wartość z identyfikatora URI, internetowy interfejs API wyszukuje dane trasy i ciąg zapytania identyfikatora URI. Dane trasy są wypełniane, gdy system routingu analizuje identyfikator URI i dopasuje je do trasy. Aby uzyskać więcej informacji, zobacz Wybór routingu i akcji.

W pozostałej części tego artykułu pokażę, jak można dostosować proces powiązania modelu. W przypadku typów złożonych należy jednak rozważyć użycie formaterów typu nośnika, jeśli jest to możliwe. Kluczową zasadą protokołu HTTP jest to, że zasoby są wysyłane w treści komunikatu przy użyciu negocjacji zawartości w celu określenia reprezentacji zasobu. Formatery typu media zostały zaprojektowane do tego celu.

Używanie elementu [FromUri]

Aby wymusić odczytywanie typu złożonego z identyfikatora URI, dodaj atrybut [FromUri] do parametru. W poniższym przykładzie zdefiniowano GeoPoint typ wraz z metodą kontrolera, która pobiera GeoPoint wartość z identyfikatora URI.

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

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

Klient może umieścić wartości Szerokość geograficzna i Długość geograficzna w ciągu zapytania, a internetowy interfejs API będzie ich używać do konstruowania elementu GeoPoint. Na przykład:

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

Korzystanie z elementu [FromBody]

Aby wymusić odczytywanie prostego typu interfejsu API sieci Web z treści żądania, dodaj atrybut [FromBody] do parametru:

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

W tym przykładzie internetowy interfejs API użyje formatnika typu nośnika, aby odczytać wartość nazwy z treści żądania. Oto przykładowe żądanie klienta.

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

"Alice"

Gdy parametr ma wartość [FromBody], internetowy interfejs API używa nagłówka Content-Type do wybrania formatu. W tym przykładzie typ zawartości to "application/json", a treść żądania jest nieprzetworzonym ciągiem JSON (a nie obiektem JSON).

Co najwyżej jeden parametr może odczytywać treść komunikatu. Nie będzie to więc działać:

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

Przyczyną tej reguły jest to, że treść żądania może być przechowywana w strumieniu niebuforowym, który może być odczytywany tylko raz.

Konwertery typów

Internetowy interfejs API może traktować klasę jako prosty typ (dzięki czemu internetowy interfejs API spróbuje powiązać go z identyfikatorem URI), tworząc klasę TypeConverter i zapewniając konwersję ciągu.

Poniższy kod przedstawia klasę reprezentującą GeoPoint punkt geograficzny oraz typConverter , który konwertuje ciągi na GeoPoint wystąpienia. Klasa GeoPoint jest ozdobiona atrybutem [TypeConverter], aby określić konwerter typów. (Ten przykład został zainspirowany wpisem w blogu Mike'a Stalla Jak powiązać z obiektami niestandardowymi w podpisach akcji w formacie 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);
    }
}

Teraz internetowy interfejs API będzie traktować GeoPoint jako prosty typ, co oznacza, że spróbuje powiązać GeoPoint parametry z identyfikatora URI. Nie musisz uwzględniać parametru [FromUri].

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

Klient może wywołać metodę z identyfikatorem URI w następujący sposób:

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

Powiązania modelu

Bardziej elastyczną opcją niż konwerter typów jest utworzenie niestandardowego powiązania modelu. W przypadku powiązania modelu masz dostęp do takich elementów jak żądanie HTTP, opis akcji i nieprzetworzone wartości z danych trasy.

Aby utworzyć powiązanie modelu, zaimplementuj interfejs IModelBinder . Ten interfejs definiuje jedną metodę BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

Oto powiązanie modelu dla GeoPoint obiektów.

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;
    }
}

Powiązanie modelu pobiera nieprzetworzone wartości wejściowe od dostawcy wartości. Ten projekt oddziela dwie odrębne funkcje:

  • Dostawca wartości przyjmuje żądanie HTTP i wypełnia słownik par klucz-wartość.
  • Binder modelu używa tego słownika do wypełniania modelu.

Domyślny dostawca wartości w internetowym interfejsie API pobiera wartości z danych trasy i ciągu zapytania. Jeśli na przykład identyfikator URI to http://localhost/api/values/1?location=48,-122, dostawca wartości tworzy następujące pary klucz-wartość:

  • id = "1"
  • location = "48,-122"

(Zakładam, że domyślny szablon trasy to "api/{controller}/{id}".

Nazwa parametru do powiązania jest przechowywana we właściwości ModelBindingContext.ModelName . Binder modelu szuka klucza z tą wartością w słowniku. Jeśli wartość istnieje i można przekonwertować na klasę GeoPoint, binder modelu przypisuje powiązaną wartość do właściwości ModelBindingContext.Model .

Zwróć uwagę, że powiązanie modelu nie jest ograniczone do prostej konwersji typów. W tym przykładzie powiązanie modelu najpierw wygląda w tabeli znanych lokalizacji, a jeśli to się nie powiedzie, używa konwersji typu.

Ustawianie powiązania modelu

Istnieje kilka sposobów ustawiania powiązania modelu. Najpierw można dodać atrybut [ModelBinder] do parametru .

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

Można również dodać atrybut [ModelBinder] do typu. Internetowy interfejs API będzie używać określonego powiązania modelu dla wszystkich parametrów tego typu.

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

Na koniec możesz dodać dostawcę powiązania modelu do obiektu HttpConfiguration. Dostawca powiązania modelu to po prostu klasa fabryki, która tworzy powiązanie modelu. Dostawcę można utworzyć, korzystając z klasy ModelBinderProvider . Jeśli jednak binder modelu obsługuje pojedynczy typ, łatwiej jest użyć wbudowanego elementu SimpleModelBinderProvider, który jest przeznaczony do tego celu. Poniższy kod pokazuje, jak to zrobić.

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);

        // ...
    }
}

W przypadku dostawcy powiązania modelu nadal musisz dodać atrybut [ModelBinder] do parametru, aby poinformować internetowy interfejs API, że powinien używać powiązania modelu, a nie formatatora typu nośnika. Ale teraz nie musisz określać typu powiązania modelu w atrybucie:

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

Dostawcy wartości

Wspomniałem, że binder modelu pobiera wartości od dostawcy wartości. Aby napisać niestandardowego dostawcę wartości, zaimplementuj interfejs IValueProvider . Oto przykład, który pobiera wartości z plików cookie w żądaniu:

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;
    }
}

Należy również utworzyć fabrykę dostawcy wartości, korzystając z klasy ValueProviderFactory .

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

Dodaj fabrykę dostawcy wartości do obiektu HttpConfiguration w następujący sposób.

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

    // ...
}

Internetowy interfejs API komponuje wszystkich dostawców wartości, więc gdy binder modelu wywołuje wartość ValueProvider.GetValue, binder modelu odbiera wartość od pierwszego dostawcy wartości, który jest w stanie go wygenerować.

Alternatywnie możesz ustawić fabrykę dostawcy wartości na poziomie parametru przy użyciu atrybutu ValueProvider w następujący sposób:

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

Informuje to, że internetowy interfejs API używa powiązania modelu z określoną fabryką dostawcy wartości, a nie używa żadnego z innych zarejestrowanych dostawców wartości.

HttpParameterBinding

Powiązania modeli są konkretnym wystąpieniem bardziej ogólnego mechanizmu. Jeśli spojrzysz na atrybut [ModelBinder], zobaczysz, że pochodzi on z klasy abstract ParameterBindingAttribute . Ta klasa definiuje jedną metodę GetBinding, która zwraca obiekt HttpParameterBinding :

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

Właściwość HttpParameterBinding jest odpowiedzialna za powiązanie parametru z wartością. W przypadku elementu [ModelBinder] atrybut zwraca implementację HttpParameterBinding , która używa elementu IModelBinder do wykonania rzeczywistego powiązania. Możesz również zaimplementować własną właściwość HttpParameterBinding.

Załóżmy na przykład, że chcesz pobrać elementy ETag z if-match i if-none-match nagłówki w żądaniu. Zaczniemy od zdefiniowania klasy reprezentującej elementy ETag.

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

Zdefiniujemy również wyliczenie, aby wskazać, czy element ETag ma być pobierany z nagłówka if-match , czy nagłówka if-none-match .

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Oto element HttpParameterBinding , który pobiera element ETag z żądanego nagłówka i wiąże go z parametrem typu 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;
    }
}

Metoda ExecuteBindingAsync wykonuje powiązanie. W ramach tej metody dodaj wartość powiązanego parametru do słownika ActionArgument w obiekcie HttpActionContext.

Uwaga

Jeśli metoda ExecuteBindingAsync odczytuje treść komunikatu żądania, przesłoń właściwość WillReadBody , aby zwrócić wartość true. Treść żądania może być strumieniem niebuforowanym, który można odczytać tylko raz, więc internetowy interfejs API wymusza regułę, która może odczytywać treść komunikatu przez co najwyżej jedno powiązanie.

Aby zastosować niestandardowy element HttpParameterBinding, można zdefiniować atrybut pochodzący z atrybutu ParameterBindingAttribute. W przypadku ETagParameterBindingelementu zdefiniujemy dwa atrybuty: jeden dla if-match nagłówków i jeden dla if-none-match nagłówków. Oba pochodzą z abstrakcyjnej klasy bazowej.

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)
    {
    }
}

Oto metoda kontrolera, która używa atrybutu [IfNoneMatch] .

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

Oprócz parametru ParameterBindingAttribute istnieje inny punkt zaczepienia do dodawania niestandardowego elementu HttpParameterBinding. W obiekcie HttpConfiguration właściwość ParameterBindingRules jest kolekcją funkcji anonimowych typu (HttpParameterDescriptor ->HttpParameterBinding). Można na przykład dodać regułę, której używa dowolny parametr ETag w metodzie GET z parametrem 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;
    }
});

Funkcja powinna zwracać null parametry, w których powiązanie nie ma zastosowania.

IActionValueBinder

Cały proces powiązania parametrów jest kontrolowany przez usługę podłączaną IActionValueBinder. Domyślna implementacja interfejsu IActionValueBinder wykonuje następujące czynności:

  1. Wyszukaj parametr ParameterBindingAttribute w parametrze . Obejmuje to [FromBody], [FromUri] i [ModelBinder] lub atrybuty niestandardowe.

  2. W przeciwnym razie poszukaj w pliku HttpConfiguration.ParameterBindingRules dla funkcji zwracającej parametr HttpParameterBinding o wartości innej niż null.

  3. W przeciwnym razie użyj opisanych wcześniej reguł domyślnych.

    • Jeśli typ parametru to "simple" lub ma konwerter typów, powiąż z identyfikatora URI. Jest to równoważne umieszczeniu atrybutu [FromUri] w parametrze .
    • W przeciwnym razie spróbuj odczytać parametr z treści komunikatu. Jest to równoważne umieszczeniu parametru [FromBody] w parametrze .

Jeśli chcesz, możesz zastąpić całą usługę IActionValueBinder implementacją niestandardową.

Dodatkowe zasoby

Przykład powiązania parametrów niestandardowych

Mike Stall napisał dobrą serię wpisów w blogu dotyczących powiązania parametrów internetowego interfejsu API: