Привязка параметра в веб-API ASP.NET

по Майк Уоссон

Рассмотрите возможность использования ASP.NET Core веб-API. Он имеет следующие преимущества по сравнению с веб-API ASP.NET 4. x:

  • ASP.NET Core — это кросс-платформенная платформа с открытым исходным кодом для создания современных облачных веб-приложений на базе Windows, macOS и Linux.
  • Контроллеры ASP.NET Core MVC и контроллеры веб-API объединены.
  • Разработано для тестируемости.
  • Возможность разработки и запуска в ОС Windows, macOS и Linux.
  • Открытый исходный код и ориентация на сообщество.
  • Интеграция современных клиентских платформ и рабочих процессов разработки.
  • Система настройки на основе среды, готовая к работе в облаке.
  • Встроенное внедрение зависимостей.
  • Упрощенный, высокопроизводительный и модульный конвейер HTTP-запросов.
  • Возможность размещения в Kestrel, IIS, http. sys, nginx, Apacheи DOCKER.
  • Управление параллельными версиями.
  • Инструментарий, упрощающий процесс современной веб-разработки.

В этой статье описывается, как веб-API привязывает параметры и как можно настроить процесс привязки. Когда веб-API вызывает метод на контроллере, он должен задать значения для параметров — процесс, называемый Binding.

По умолчанию для привязки параметров веб-API использует следующие правила:

  • Если параметр имеет тип Simple, веб-API пытается получить значение из универсального кода ресурса (URI). Простые типы включают в себя примитивные типы .NET (int, bool, Double и т. д.), а также TimeSpan, DateTime, GUID, Decimal и String , а также любой тип с преобразователем типов, который может выполнять преобразование из строки. (Дополнительные сведения о преобразователях типов см. ниже.)
  • Для сложных типов веб-API пытается считать значение из текста сообщения с помощью модуля форматирования типа мультимедиа.

Например, ниже приведен типичный метод контроллера веб-API.

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

Параметр ID является " простым " типом, поэтому веб-API пытается получить значение из универсального кода ресурса (URI) запроса. Параметр Item является сложным типом, поэтому веб-API использует модуль форматирования типа мультимедиа для считывания значения из текста запроса.

Чтобы получить значение из универсального кода ресурса (URI), веб-API ищет данные маршрута и строку запроса URI. Данные маршрута заполняются, когда система маршрутизации анализирует URI и сопоставляет его с маршрутом. Дополнительные сведения см. в разделе Маршрутизация и выбор действий.

В оставшейся части этой статьи я покажу, как можно настроить процесс привязки модели. Однако для сложных типов рекомендуется использовать модули форматирования типа мультимедиа везде, где это возможно. Ключевым принципом протокола HTTP является то, что ресурсы отправляются в тексте сообщения с помощью согласования содержимого для указания представления ресурса. Модули форматирования типа мультимедиа предназначены именно для этой цели.

Использование [Фромури]

Чтобы заставить веб-API считывать сложный тип из универсального кода ресурса (URI), добавьте атрибут [фромури] в параметр. В следующем примере определяется GeoPoint тип, а также метод контроллера, который получает GeoPoint из URI.

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

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

Клиент может разместить значения широты и долготы в строке запроса, а веб-API будет использовать их для создания GeoPoint . Пример:

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

Использование [FromBody]

Чтобы заставить веб-API считывать простой тип из текста запроса, добавьте атрибут [FromBody] в параметр:

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

В этом примере веб-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], веб-API использует заголовок Content-Type для выбора модуля форматирования. В этом примере тип содержимого — " Application/JSON, " а текст запроса — необработанная строка JSON (не объект JSON).

Для чтения текста сообщения может быть не более одного параметра. Поэтому это не будет работать:

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

Причина этого правила заключается в том, что текст запроса может храниться в небуферизованном потоке, который может быть считан только один раз.

Преобразователи типов

Веб-API может рассматривать класс как простой тип (поэтому веб-API будет пытаться привязать его из универсального кода ресурса (URI)), создав TypeConverter и добавив преобразование строки.

В следующем коде показан GeoPoint класс, представляющий географическую точку, а также TypeConverter , который преобразует строки в GeoPoint экземпляры. GeoPointКласс снабжен атрибутом [TypeConverter] для указания преобразователя типов. (Этот пример был связан с записью блога Mike в блоге о том, как выполнить привязку к пользовательским объектам в сигнатурах действий в 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);
    }
}

Теперь веб-API будет рассматриваться GeoPoint как простой тип, то есть будет пытаться привязать GeoPoint параметры из универсального кода ресурса (URI). В параметре не нужно включать [фромури] .

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

Клиент может вызвать метод с URI следующим образом:

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

Привязки моделей

Более гибкий вариант, чем преобразователь типов, заключается в создании пользовательского связывателя модели. При использовании связывателя модели у вас есть доступ к таким средствам, как HTTP-запрос, описание действия и необработанные значения из данных маршрута.

Чтобы создать связыватель модели, реализуйте интерфейс имоделбиндер . Этот интерфейс определяет единственный метод биндмодел:

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-запрос и заполняет словарь пар "ключ-значение".
  • Связыватель модели использует этот словарь для заполнения модели.

Поставщик значений по умолчанию в веб-API получает значения из данных маршрута и строки запроса. Например, если URI имеет http://localhost/api/values/1?location=48,-122 значение, поставщик значений создает следующие пары "ключ-значение":

  • ИД = " 1"
  • расположение = " 48,-122"

(Предполагается, что используется шаблон маршрута по умолчанию — " API/{Controller}/{ID} " .)

Имя параметра для привязки хранится в свойстве моделбиндингконтекст. ModelName . Связыватель модели ищет ключ с этим значением в словаре. Если значение существует и может быть преобразовано в GeoPoint , связыватель модели присваивает связанное значение свойству Моделбиндингконтекст. Model .

Обратите внимание, что связыватель модели не ограничивается простым преобразованием типов. В этом примере связыватель модели сначала выполняет поиск в таблице известных расположений, и в случае сбоя он использует преобразование типов.

Настройка связывателя модели

Существует несколько способов задать связыватель модели. Во-первых, в параметр можно добавить атрибут [моделбиндер] .

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

К типу можно также добавить атрибут [моделбиндер] . Веб-API будет использовать указанный связыватель модели для всех параметров этого типа.

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

Наконец, можно добавить поставщик привязки модели в HttpConfiguration. Поставщик привязки модели — это просто класс фабрики, который создает связыватель модели. Поставщик можно создать, создав производный от класса моделбиндерпровидер . Однако если связыватель модели обрабатывает один тип, проще использовать встроенный симплемоделбиндерпровидер, который предназначен для этой цели. В следующем примере кода показано, как это сделать:

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

        // ...
    }
}

При использовании поставщика привязки модели по-прежнему необходимо добавить атрибут [моделбиндер] в параметр, чтобы сообщить веб-API, что он должен использовать связыватель модели, а не модуль форматирования типа мультимедиа. Но теперь не нужно указывать тип связывателя модели в атрибуте:

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

Поставщики значений

Я упомянул, что связыватель модели получает значения от поставщика значений. Чтобы написать поставщик настраиваемого значения, реализуйте интерфейс ивалуепровидер . Ниже приведен пример, который извлекает значения из файлов 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;
    }
}

Кроме того, необходимо создать фабрику поставщика значений, производная от класса валуепровидерфактори .

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

    // ...
}

Веб-API формирует все поставщики значений, поэтому когда связыватель модели вызывает значение valueprovider. GetValue, связыватель модели получает значение от первого поставщика значений, который может его создать.

Кроме того, можно задать фабрику поставщика значений на уровне параметров с помощью атрибута значение valueprovider , как показано ниже.

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

Это указывает веб-API использовать привязку модели с заданной фабрикой поставщика значений, а не использовать другие зарегистрированные поставщики значений.

хттппараметербиндинг

Связыватели моделей — это конкретный экземпляр более общего механизма. Если взглянуть на атрибут [моделбиндер] , вы увидите, что он является производным от абстрактного класса параметербиндингаттрибуте . Этот класс определяет единственный метод- Binding, который возвращает объект хттппараметербиндинг :

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

Хттппараметербиндинг отвечает за привязку параметра к значению. В случае [моделбиндер] атрибут возвращает реализацию хттппараметербиндинг , которая использует имоделбиндер для выполнения фактической привязки. Вы также можете реализовать собственный хттппараметербиндинг.

Например, предположим, что необходимо получить Теги ETag из if-match и if-none-match заголовков в запросе. Начнем с определения класса для представления ETag.

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

Также будет определено перечисление, указывающее, следует ли получить ETag из if-match заголовка или if-none-match заголовка.

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

Вот хттппараметербиндинг , который получает 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;
    }
}

Метод ексекутебиндингасинк выполняет привязку. В этом методе добавьте связанное значение параметра в словарь актионаргумент в хттпактионконтекст.

Note

Если метод ексекутебиндингасинк считывает текст сообщения запроса, переопределите свойство виллреадбоди , чтобы оно возвращало значение true. Тело запроса может быть небуферизованным потоком, который может быть считан только один раз, поэтому веб-API принудительно применяет правило, которое может считывать только одна привязка к тексту сообщения.

Чтобы применить пользовательский хттппараметербиндинг, можно определить атрибут, производный от параметербиндингаттрибуте. Для 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) { ... }

Помимо параметербиндингаттрибуте, существует еще один обработчик для добавления пользовательского хттппараметербиндинг. В объекте HttpConfiguration свойство параметербиндингрулес является коллекцией анонимных функций типа (хттппараметердескриптор - > хттппараметербиндинг). Например, можно добавить правило, которое любой параметр ETag в методе GET использует ETagParameterBinding с 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 для параметров, в которых привязка неприменима.

иактионвалуебиндер

Весь процесс привязки параметров управляется подключаемой службой иактионвалуебиндер. Реализация иактионвалуебиндер по умолчанию выполняет следующие действия:

  1. Найдите параметербиндингаттрибуте в параметре. К ним относятся [FromBody], [фромури] и [моделбиндер], а также настраиваемые атрибуты.

  2. В противном случае найдите в HttpConfiguration. параметербиндингрулес функцию, которая возвращает значение хттппараметербиндинг, отличное от NULL.

  3. В противном случае используйте правила по умолчанию, описанные выше.

    • Если параметр имеет тип "Simple" или имеет преобразователь типов, выполните привязку из универсального кода ресурса (URI). Это эквивалентно размещению атрибута [фромури] в параметре.
    • В противном случае попробуйте прочитать параметр из текста сообщения. Это эквивалентно размещению [FromBody] в параметре.

При необходимости можно заменить всю службу иактионвалуебиндер собственной реализацией.

Дополнительные ресурсы

Пример пользовательской привязки параметра

Майк записал хорошие серии записей блога о привязке параметров веб-API: