Wstęp

W tym artykule przedstawię Ci mechanizm własnego wiązania obiektów, czyli po ludzku „Custom model binding” w .NET.

.NET w standardzie ma sporo wbudowanych binderów, których zadaniem jest po prostu utworzenie jakiegoś konkretnego obiektu na podstawie danych z żądania HTTP. W zasadzie rzadko zdarza się potrzeba napisania własnego bindera, bo te domyślne załatwiają prawie 100% potrzeb. Prawie.

Postaram przedstawić Ci ten cały mechanizm od zupełnego początku. Jeśli szukasz konkretnego rozwiązania przejdź na koniec artykułu.

Do artykułu powstał przykładowy projekt. Możesz go pobrać z GitHuba.

Czym jest binder?

Wyobraź sobie taki kod w kontrolerze:

[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {

    }
}

Tutaj dzieje się dość sporo magii. Jak zapewne wiesz, żądanie HTTP to nic innego jak standaryzowany tekst wysłany z jednego komputera do drugiego.

W tym przypadku takie proste żądanie mogłoby wyglądać tak:

GET /api/user/5 HTTP/1.1
Host: localhost

Już pomijam w jaki sposób z czegoś takiego .NET umie wywołać konkretną metodę z konkretnej klasy. Ale spójrz, że w żądaniu masz ścieżkę /api/user/5. I tę piątkę dostaniesz w parametrze id w swojej metodzie GetById. Cały myk polega na tym, żeby Twoja nazwa parametru w procedurze (int id) była taka sama jak w atrybucie HttpGet ({id}).

Zanim wywołana zostanie metoda GetById, do roboty rusza specjalny binder, który patrzy i widzi:

„Aha, tutaj w atrybucie mam zmienną o nazwie id. W parametrze procedury też mam zmienną o nazwie id. To teraz wezmę sobie wartość z żądania („5”) i skonwertuję to na żądany typ (int) i zwrócę inta o odpowiedniej wartości (5)”.

Być może brzmi to nieco skomplikowanie, ale w rzeczywistości jest banalnie proste, ponieważ sam framework robi już dużo roboty za Ciebie. Spróbujmy napisać coś podobnego.

Binder działa dla konkretnego parametru. Spójrz na taki przykład:

[HttpPost("{id}")]
public IActionResult SaveData(int id, [FromBody]Author authorData)
{

}

Tutaj do pracy ruszą dwa różne bindery. Pierwszy – który powiąże id, drugi który odczyta jsona z ciała żądania i stworzy z niego obiekt Author.

Prosty binder

Opis zadania

Będziemy chcieli przesłać jakiś prosty rekord w żądaniu i po drugiej stronie odebrać to jako obiekt.

Model będzie wyglądał tak:

public class SimplePost
{
    public int Id { get; set; }
    public string Title { get; set; }
}

A metoda w kontrolerze:

[HttpPost("{post}")]
public IActionResult AddPost(SimplePost post)
{

}

Żądanie, które będzie wysłane to:

POST /api/posts/id:2;title:Tytuł postu

Weź pod uwagę, że to tylko przykład. W rzeczywistości prawdopodobnie przesłałbyś te dane po prostu jsonem. Ale chcę Ci pokazać, jak działa binder.

Zauważ, że tutaj analogicznie jak w pierwszym przykładzie, nazwa zmiennej w atrybucie jest taka sama jak nazwa zmiennej w parametrze metody. Do tej zmiennej dowiążemy te dane.

Wybór bindera

Musisz dać znać frameworkowi, jakiego bindera ma użyć dla konkretnego przypadku. Można to zrobić na dwa sposoby. Swój model możesz opatrzyć atrybutem ModelBinder, np:

[ModelBinder(typeof(SimplePostBinder))]
public class SimplePost
{
    public int Id { get; set; }
    public string Title { get; set; }
}

albo jeśli nie chcesz lub nie możesz posłużyć się atrybutem ModelBinder, zawsze możesz napisać swojego providera.

Provider dla bindera

UWAGA! SimplePostBinder przedstawiony w tym akapicie, to binder, którego piszemy w następnym akapicie 🙂

Wystarczy zaimplementować interfejs IModelBinderProvider:

public class SimplePostBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(SimplePost))
            return new BinderTypeModelBinder(typeof(SimplePostBinder));
        else
            return null;
    }
}

Teraz pewnie zachodzisz w głowę co to do cholery jest BinderTypeModelBinder i dlaczego nie możesz po prostu zrobić new SimplePostBinder().

Otóż możesz. Natomiast BinderTypeModelBinder to jest fabryka dla bindera. Umożliwia wykorzystanie dependency injection. Jeśli chciałbyś zwrócić swój obiekt: new SimplePostBinder, a konstruktor wymagałby różnych serwisów, no to wtedy mamy problem. Na szczęście BinderTypeModelBinder go rozwiązuje i sam się zatroszczy o wstrzyknięcie odpowiednich obiektów.

Na koniec musisz tego providera zarejestrować przy rejestracji kontrolerów:

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new SimplePostBinderProvider());
});

UWAGA!

Providerzy są sprawdzani wg kolejności na liście ModelBinderProviders. Jest prawie pewne, że jeśli dodasz swojego providera na końcu (metodą Add), wtedy framework wybierze jakiegoś innego, zarejestrowanego wcześniej, który spełni swój warunek. Dlatego też zawsze dodawaj swojego bindera na początek listy – metodą Insert.

Jeśli otrzymujesz błędy w stylu 415: Unsupported media type lub widzisz że Twój binder nie chodzi, upewnij się w pierwszej kolejności, że dodałeś go na początek listy.

Piszemy bindera

W pierwszym kroku musisz stworzyć klasę, która implementuje interfejs IModelBinder. Interfejs ma tylko jedną metodę do napisania:

public class SimplePostBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        
    }
}

Pierwsza uwaga – pisząc własnego bindera, jak najbardziej możesz posługiwać się dependency injection. Możesz do konstruktora wrzucić sobie to, co potrzebujesz.

Parametr ModelBindingContext to jest klasa abstrakcyjna, której implementacja (DefaultModelBindingContext) daje Ci sporo informacji.

Przede wszystkim musimy pobrać nazwę modelu – czyli parametr z atrybutu HttpPost:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;

    return Task.CompletedTask;
}

BindingContext ma jeszcze jedną podobną właściwość: FieldName – to jest nazwa parametru w Twojej metodzie.

Mając nazwę parametru z atrybutu (pamiętaj – z atrybutu), możemy teraz pobrać jego wartość:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;
    ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);

    return Task.CompletedTask;
}

ValueProviderResult to w pewnym uproszczeniu tablica stringów. ValueProvider, jeśli zwróci jakąś wartość, to zawsze będą to stringi. I dopiero te stringi możemy konwertować na poszczególne typy.

Tutaj dostaniemy stringa: „id:2;title:Tytuł postu” i teraz musimy go skonwertować na nasz typ. Tu już jest prosto:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;
    ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);

    if(value == ValueProviderResult.None)
        return Task.CompletedTask;

    var strValue = value.First();
    if(string.IsNullOrWhiteSpace(strValue))
        return Task.CompletedTask;

    SimplePost result = ConvertFromString(strValue);
    bindingContext.Result = ModelBindingResult.Success(result);

    return Task.CompletedTask;
}

Najpierw w linijce 6 upewniłem się, że faktycznie otrzymaliśmy jakąś wartość. Potem w linijce 10 upewniłem się, że wartość, którą mamy nie jest pustym stringiem. Na koniec konwertuję tego stringa do obiektu i zwracam za pomocą bindingContext.Result.

Domyślnie bindingContext.Result jest ustawiony na Failed, dlatego ustawiam go tylko, gdy konwersja się powiodła.

Jeśli z ciekawości chciałbyś zobaczyć metodę ConvertFromString, to może wyglądać tak:

private SimplePost ConvertFromString(string data)
{
    Dictionary<string, string> keyValues = new();

    string[] fields = data.Split(';');
    foreach(var field in fields)
    {
        string[] kv = field.Split(':');
        keyValues[kv[0]] = kv[1];   
    }

    SimplePost result = new SimplePost();
    result.Id = int.Parse(keyValues["id"]);
    result.Title = keyValues["title"];

    return result;
}

Walidacja modelu w binderze

W sytuacji, gdzie użytkownik przekazuje dane, wszystko może pójść nie tak. Tak samo w naszym przypadku. Dlatego zawsze warto jest zabezpieczyć się przed problemami.

Jeśli przekażemy złe dane, np.: „idik:2;title:Tytuł postu” – idik zamiast id, nie będziemy w stanie utworzyć obiektu. Po prostu metoda ConvertFromString się wykrzaczy. Najprościej poradzić sobie z tym w taki sposób:

try
{
    SimplePost result = ConvertFromString(strValue);
    bindingContext.Result = ModelBindingResult.Success(result);
}catch(Exception ex)
{
    bindingContext.ModelState.AddModelError(paramName, $"Could not convert from specified data, error: {ex.Message}");
}

return Task.CompletedTask;

W aplikacji webowej np. RazorPages musisz teraz sprawdzić stan modelu w standardowy sposób:

if(!ModelState.IsValid)
    return View()

W przypadku WebApi (klasa opatrzona atrybutem ApiController) stan modelu jest sprawdzany automatem przed uruchomieniem metody w kontrolerze. W naszym przykładzie po prostu dostaniesz taką zwrotkę:

400: BadRequest z przykładową zawartością:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-528c9aba1d62ac1e013a1b841d57c8b0-e13ad6c0c13efb6f-00",
    "errors": {
        "post": [
            "Could not convert from specified data, error: The given key 'id' was not present in the dictionary."
        ]
    }
}

Tak samo z automatu zadziała sprawdzenie pól, które mają dodatkowe wymagania np. za pomocą adnotacji. Przykładowo:

[Range(10, 1000)]
public int Id { get; set; }

Piszemy bindera dla Jsona w formularzu

To już właściwie formalność, bo poznałeś już zasadę działania bindera. I w tym punkcie będzie więcej zabawy refleksją niż binderem. Niemniej jednak uczulę Cię na jedną rzecz.

Jeśli chcesz przesłać plik wraz z jakimiś danymi w JSONie, prawdopodobnie chciałbyś mieć analogiczny model:

public class Data
{
    public IEnumerable<IFormFile> Files { get; set; }
    public Author AuthorData { get; set; }
}

Czyli plik i obiekt w jednej klasie. Da się to zrobić i pokażę Ci jak. Jednak uczulam Cię, że zdecydowanie lepiej jest mieć pliki i dane osobno. Dlaczego? Bo binder do pobierania plików już istnieje w standardzie i wcale nie jest taki oczywisty, jakby się mogło wydawać.

Model generyczny

Stworzymy model generyczny, który będzie mógł mieć dowolny obiekt, który będzie pochodził z JSONa. Model jest banalnie prosty:

[ModelBinder(typeof(FormJsonBinder))]
public class FormJsonData<T>
    where T: class, new()
{
    public T Model { get; set; }
}

Teraz przyjmiemy model i plik w osobnych parametrach:

[HttpPost]
public IActionResult AddAuthor(IEnumerable<IFormFile> files, FormJsonData<Author> author)
{
    return Ok();
}

Klasa Author niech wygląda tak:

public class Author
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTimeOffset Birthday { get; set; }
    public string Note { get; set; }
}

Jak już wiesz – binder działa na konkretnym parametrze. A więc w tym przypadku pliki z parametru files przyjdą ze standardowego bindera .NET. Więc zajmiemy się tylko FormJsonData.

Z PostMana można by wysłać takie żądanie:

Jak widzisz, wysyłamy formularz, gdzie w polu author mam JSONa z danymi autora, a w polu files mamy pliki. Zwróć uwagę, że te pola nazywają się tak samo jak parametry w naszej procedurze. To ważne, bo tak będziemy to wiązać.

Binder

Teraz napiszemy sobie bindera do tego FormJsonData. Początek już znasz:

public class FormJsonBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var fieldName = bindingContext.FieldName;
        var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
        if (fieldValue == ValueProviderResult.None)
            return Task.CompletedTask;

        return Task.CompletedTask;
    }
}

Teraz w fieldValue mamy całego przekazanego JSONa. Musimy stworzyć już tylko odpowiedni obiekt. No właśnie. Ale jaki? FormJsonData jest klasą generyczną. Dlatego musimy się nieco pobawić refleksją. Nie będę tego omawiał, bo to nie jest artykuł na ten temat:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var fieldName = bindingContext.FieldName;
    var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
    if (fieldValue == ValueProviderResult.None)
        return Task.CompletedTask;

    try
    {
        Type modelType = GetTypeForModel(bindingContext);
        var result = ConvertFromJson(modelType, fieldValue.FirstValue);
        bindingContext.Result = ModelBindingResult.Success(result);
    }catch(Exception ex)
    {
        bindingContext.ModelState.AddModelError(fieldName, $"Could not convert from specified data, error: {ex.Message}");
    }
    
    return Task.CompletedTask;
}

private Type GetTypeForModel(ModelBindingContext context)
{
    Type modelType = context.ModelType;
    Type[] genericArgs = modelType.GenericTypeArguments;
    if (genericArgs.Length == 0)
        throw new InvalidOperationException("Invalid class! Expected generic type!");

    return genericArgs[0];
}

private object ConvertFromJson(Type modelType, string jsonData)
{
    Type outputType = typeof(FormJsonData<>).MakeGenericType(modelType);

    JsonSerializerOptions opt = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    };

    var model = JsonSerializer.Deserialize(jsonData, modelType, opt);

    var result = Activator.CreateInstance(outputType);
    var modelProp = outputType.GetProperty("Model");
    modelProp.SetValue(result, model);

    return result;
}

Dzięki takiemu podejściu moglibyśmy mieć kilka różnych obiektów w jednym formularzu! W różnych polach formularza możesz umieścić różne Jsony i odbierać je w kontrolerze stosując odpowiednie nazwy parametrów.

Pobranie pliku w binderze

Tak naprawdę do pobierania plików w binderze służy… osobny binder. I ma on dużo więcej kodu niż, to co pokazuję Ci niżej:

private async Task GetFormFilesAsync(ModelBindingContext bindingContext,
    ICollection<IFormFile> postedFiles)
{
    var request = bindingContext.HttpContext.Request;
    if (request.HasFormContentType)
    {
        var form = await request.ReadFormAsync();

        foreach (var file in form.Files)
        {
            if (file.Length == 0 && string.IsNullOrEmpty(file.FileName))
                continue;

            postedFiles.Add(file);
        }
    }
}

Ten kod zadziała, ale zwracam uwagę po raz trzeci – binder do pobierania plików robi więcej rzeczy niż tylko to. Dlatego najlepiej pobieraj pliki osobnym parametrem, tak jak zrobiliśmy to wyżej.


Dzięki za przeczytanie tego artykułu. To tyle jeśli chodzi o custom model binding w .NET. Temat dość prosty i przyjemny, ale za to rzadko stosowany 🙂 Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu.

Podziel się artykułem na: