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

według Jan Wasson

Rozważ użycie ASP.NET Core Web API. Ma ona następujące zalety niż w przypadku interfejsu API sieci Web ASP.NET 4. x:

  • ASP.NET Core to międzyplatformowa platforma służąca do tworzenia nowoczesnych, opartych na chmurze aplikacji sieci Web w systemach Windows, macOS i Linux.
  • Kontrolery ASP.NET Core MVC i kontrolery internetowego interfejsu API są ujednolicone.
  • Zaprojektowane pod kątem testowania.
  • Możliwość programowania i uruchamiania w systemach Windows, macOS i Linux.
  • Open source i koncentracja na społeczności.
  • Integracja nowoczesnych struktur po stronie klienta i przepływów pracy projektowania.
  • System konfiguracji oparty na środowisku, który jest gotowy do chmury.
  • Wbudowane iniekcja zależności.
  • Uproszczony, zapewniający wysoką wydajność, modułowy potok żądania HTTP.
  • Możliwość hostowania w systemach Kestrel, IIS, http. sys, Nginx, Apachei Docker.
  • Przechowywanie wersji równoległych.
  • Narzędzia, które upraszczają tworzenie nowoczesnych aplikacji internetowych.

W tym artykule opisano, jak interfejs API sieci Web wiąże parametry i jak można dostosować proces powiązania. Gdy interfejs API sieci Web wywołuje metodę na kontrolerze, musi ustawić wartości parametrów, proces o nazwie Binding.

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

  • Jeśli parametr jest typu prostego, interfejs API sieci Web próbuje uzyskać wartość z identyfikatora URI. Typy proste obejmują typy pierwotne platformy .NET (int, bool, Double itd.), a także przedziały czasu, DateTime, GUID, Decimal i String oraz dowolny typ z konwerterem typów, który można przekonwertować na ciąg. (Więcej informacji na temat konwerterów typów w dalszej części).
  • W przypadku typów złożonych interfejs API sieci Web próbuje odczytać wartość z treści wiadomości przy użyciu programu formatującego typu nośnika.

Oto przykład typowej metody kontrolera API sieci Web:

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

Parametr ID jest " " typu prostego, więc interfejs API sieci Web próbuje uzyskać wartość z identyfikatora URI żądania. Parametr Item jest typem złożonym, więc interfejs API sieci Web używa programu formatującego typu nośnika do odczytywania wartości z treści żądania.

Aby uzyskać wartość z identyfikatora URI, interfejs API sieci Web przegląda dane trasy i ciąg zapytania identyfikatora URI. Dane trasy są wypełniane, gdy system routingu analizuje identyfikator URI i dopasowuje go do trasy. Aby uzyskać więcej informacji, zobacz Routing i wybór akcji.

W pozostałej części artykułu pokażę, jak można dostosować proces powiązania modelu. W przypadku typów złożonych należy jednak wziąć pod uwagę używanie programu formatującego typu nośnika, gdy jest to możliwe. Kluczową zasadą protokołu HTTP jest to, że zasoby są wysyłane w treści wiadomości, przy użyciu negocjacji zawartości, aby określić reprezentację zasobu. Elementy formatujące typy multimedialne zostały zaprojektowane do tego celu dokładnie.

Korzystanie z [FromUri]

Aby wymusić odczytywanie przez internetowy interfejs API 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 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ści i długości geograficznej w ciągu zapytania oraz w interfejsie API sieci Web, które będą używane do konstruowania GeoPoint . Na przykład:

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

Korzystanie z [FromBody]

Aby wymusić odczytanie typu prostego z treści żądania przez internetowy interfejs API, Dodaj atrybut [FromBody] do parametru:

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

W tym przykładzie interfejs API sieci Web użyje programu formatującego typ 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 [FromBody], interfejs API sieci Web używa nagłówka Content-Type do wybierania elementu formatującego. W tym przykładzie typ zawartości to " Application/JSON, " a treść żądania jest nieprzetworzonym ciągiem JSON (nie obiektem JSON).

Co najwyżej jeden parametr może zostać odczytany z treści komunikatu. W związku z tym nie będzie to zadziałało:

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

Przyczyną tej reguły jest fakt, że treść żądania może być przechowywana w niebuforowanym strumieniu, który można odczytać tylko raz.

Konwertery typów

Można sprawić, aby interfejs API sieci Web traktował klasę jako typ prosty (dzięki czemu interfejs API sieci Web spróbuje powiązać ją z identyfikatorem URI), tworząc obiekt TypeConverter i dostarczając konwersję ciągów.

Poniższy kod przedstawia GeoPoint klasę, która reprezentuje punkt geograficzny, oraz obiekt TypeConverter , który konwertuje ciągi na GeoPoint wystąpienia. GeoPointKlasa ma atrybut [TypeConverter] do określenia konwertera typów. (Ten przykład został inspirowany wpisem na blogu Jan, jak powiązać z obiektami niestandardowymi w sygnaturach akcji w 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 interfejs API sieci Web będzie traktowany GeoPoint jako typ prosty, co oznacza, że podejmie próbę powiązania GeoPoint parametrów z identyfikatora URI. Nie trzeba dołączać [FromUri] do parametru.

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

Klient może wywołać metodę z identyfikatorem URI podobnym do tego:

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

Powiązania modelu

Bardziej elastyczną opcją niż konwerter typu jest utworzenie spinacza modelu niestandardowego. Dzięki modelowi spinacza masz dostęp do elementów, takich jak żądanie HTTP, Opis akcji i wartości pierwotnych z danych trasy.

Aby utworzyć spinacz modelu, zaimplementuj Interfejs IModelBinder . Ten interfejs definiuje pojedynczą metodę, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

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

Spinacz modelu pobiera pierwotne wartości wejściowe od dostawcy wartości. Ten projekt oddziela dwie odrębne funkcje:

  • Dostawca wartości pobiera żądanie HTTP i wypełnia słownik par klucz-wartość.
  • Model spinacza używa tego słownika do wypełnienia modelu.

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

  • Identyfikator = " 1"
  • Lokalizacja = " 48,-122"

(Przy założeniu, że domyślnym szablonem trasy jest " interfejs API/{Controller}/{ID} " ).

Nazwa parametru do powiązania jest przechowywana we właściwości ModelBindingContext. ModelName . Spinacz modelu szuka klucza z tą wartością w słowniku. Jeśli wartość istnieje i można ją przekonwertować na obiekt GeoPoint , spinacz modelu przypisuje wartość powiązaną do właściwości ModelBindingContext. model .

Należy zauważyć, że spinacz modelu nie jest ograniczony do konwersji typu prostego. W tym przykładzie spinacz modelu najpierw szuka tabeli znanych lokalizacji, a jeśli to się nie powiedzie, używa konwersji typu.

Ustawianie spinacza modelu

Istnieje kilka sposobów na ustawienie spinacza 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. Interfejs API sieci Web będzie używać określonego spinacza modelu dla wszystkich parametrów tego typu.

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

Na koniec można dodać dostawcę modelu spinacza do HttpConfiguration. Dostawca modelu segregatorów jest po prostu klasą fabryki, która tworzy spinacz modelu. Dostawcę można utworzyć, wywodząc się z klasy ModelBinderProvider . Jeśli jednak spinacz modelu obsługuje pojedynczy typ, łatwiej jest użyć wbudowanej SimpleModelBinderProvider, która jest przeznaczona 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ązań modelu nadal trzeba dodać atrybut [ModelBinder] do parametru, aby poinformować internetowy interfejs API, że powinien on używać spinacza modelu, a nie do programu formatującego typu nośnika. Ale teraz nie musisz określać typu spinacza modelu w atrybucie:

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

Dostawcy wartości

Określono, że spinacz modelu pobiera wartości z 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ę dostawców wartości, wyprowadzając ją z klasy ValueProviderFactory .

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

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

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

    // ...
}

Interfejs API sieci Web składa wszystkie dostawców wartości, więc gdy spinacz modelu wywołuje ValueProvider. GetValue, spinacz modelu otrzymuje wartość od pierwszego dostawcy wartości, który może go utworzyć.

Alternatywnie można 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)

Oznacza to, że interfejs API sieci Web używa powiązania modelu z określoną fabryką dostawcy wartości, a nie do korzystania z innych zarejestrowanych dostawców wartości.

HttpParameterBinding

Powiązania modelu są konkretnym wystąpieniem bardziej ogólnego mechanizmu. Jeśli szukasz atrybutu [ModelBinder] , zobaczysz, że pochodzi on z klasy abstrakcyjnej ParameterBindingAttribute . Ta klasa definiuje pojedynczą metodę, GetBinding, która zwraca obiekt HttpParameterBinding :

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

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

Załóżmy na przykład, że chcesz uzyskać elementy ETag z if-match i if-none-match Headers w żądaniu. Zaczniemy od zdefiniowania klasy do reprezentowania elementów ETag.

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

Zdefiniujemy również Wyliczenie, aby wskazać, czy ma zostać pobrany element ETag z if-match nagłówka, czy z if-none-match nagłówka.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Poniżej znajduje się 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 powiązaną wartość parametru do słownika ActionArgument w HttpActionContext.

Note

Jeśli metoda ExecuteBindingAsync odczytuje treść komunikatu żądania, Przesłoń Właściwość WillReadBody , aby zwracała wartość true. Treść żądania może być niebufornym strumieniem, który można tylko raz odczytać, dzięki czemu interfejs API sieci Web wymusza zasadę, którą może odczytać treść komunikatu z najwyżej jednego powiązania.

Aby zastosować niestandardowe HttpParameterBinding, można zdefiniować atrybut pochodzący z ParameterBindingAttribute. Dla programu ETagParameterBinding 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 [IfNoneMatch] atrybutu.

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

Poza ParameterBindingAttribute istnieje inny element Hook służący do dodawania niestandardowego HttpParameterBinding. W obiekcie HttpConfiguration Właściwość ParameterBindingRules jest kolekcją funkcji anonimowych typu (HttpParameterDescriptor - > HttpParameterBinding). Można na przykład dodać regułę, która jest stosowana przez dowolny parametr ETag metody GET ETagParameterBinding z 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;
    }
});

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

IActionValueBinder

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

  1. Poszukaj ParameterBindingAttribute na parametrze. Obejmuje to [FromBody], [FromUri] i [ModelBinder] lub atrybuty niestandardowe.

  2. W przeciwnym razie poszukaj w HttpConfiguration. ParameterBindingRules funkcji, która zwraca wartość inną niż null HttpParameterBinding.

  3. W przeciwnym razie użyj reguł domyślnych, które zostały opisane wcześniej.

    • Jeśli typ parametru to "Simple" lub ma konwerter typu, powiąż z identyfikatorem URI. Jest to równoznaczne z umieszczeniem atrybutu [FromUri] na parametrze.
    • W przeciwnym razie spróbuj odczytać parametr z treści komunikatu. Jest to odpowiednik umieszczania [FromBody] na parametrze.

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

Dodatkowe zasoby

Przykład powiązania parametru niestandardowego

Jan zapisał dobrą serię wpisów w blogu na temat powiązania parametrów interfejsu API sieci Web: