【ASP.NET Core】MVC模型繫結——實現同一個API方法相容JSON和Form-data輸入

東邪獨孤發表於2022-03-24

在上一篇文章中,老周給大夥伴們大致說了下 MVC 下的模型繫結,今天我們們進行一下細化,先聊聊模型繫結中涉及到的一些元件物件。

------------------------------------------------------------------------------

一、ValueProvider——提取繫結源的值

首先登場的小帥哥是 ValueProvider,即實現 IValueProvider 介面。

public interface IValueProvider
{ 
    bool ContainsPrefix(string prefix);
    ValueProviderResult GetValue(string key);
}

提取繫結源的值在操作上類似字典物件的訪問,通過一個指定的 key 來檢索。這個主要針對資料結構類似字典的資料來源,比如

1、HTTP Header,它的結構就是 name: value;

2、Form 物件,比如 HTML 頁上的<form>元素,或者客戶端直接提交的 form-data,當然包括用 JQuery 等方式提交的 form;

3、Route Value,也就是路由引數。比如我們們在寫MVC時很熟悉的那個 {controller}/{action},若訪問的是 Home/Index,那麼這裡面就是兩個資料項。第一個 key 是 controller,value 是 Home;第二個 key 是 action,value 是 Index。

於是,.net core 中很自然地會內建一些已實現的 provider。

FormValueProvider 
FormFileValueProvider
JQueryFormValueProvider 
RouteValueProvider 
HeaderValueProvider

看著它們的大名,估計你也能猜到它們的作用。你會問:咦,HeaderValueProvider 在哪,我咋沒看到?這廝藏得比大 Boss 還深!它是在 HeaderModelBinder 檔案中定義的私有類,所以我們根本訪問不到它。

    private class HeaderValueProvider : IValueProvider

許多 ValueProvider 型別還有專配的 ValueProviderFactory,實現 IValueProviderFactory 介面。該介面只需實現一個方法:CreateValueProviderAsync。

public Task CreateValueProviderAsync(ValueProviderFactoryContext context)

返回值只是個 Task?那建立的 ValueProvider 例項放到哪?注意它有個引數是個上下文物件(ValueProviderFactoryContext),此物件有個屬性叫 ValueProviders。建立的 ValueProvider 例項被新增到這個列表中。

也就是說,可以呼叫各個 Factory 物件建立多種 ValueProvider,然後都新增進 ValueProviders  列表中。當我們自己編寫 ModelBinder 時,就可以從這個列表中的 N 個 ValueProvider 物件中提取值。這個 ValueProvider 的值會自動被合併,我們不需要關心它來自哪個 ValueProvider 物件。

比如,列表中有 QueryString 和 RouteValue 兩個值來源,其中 key1 來自 QueryString,key2 來自 RouteValue,我在自定義 binder 時,不必管它從哪裡來,我只要知道有兩個鍵叫 key1、key2 就行。

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ……

        var modelName = bindingContext.ModelName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
        if (valueProviderResult == ValueProviderResult.None)
        {
            ……
            return Task.CompletedTask;
        }

        var modelState = bindingContext.ModelState;
        modelState.SetModelValue(modelName, valueProviderResult);

        var metadata = bindingContext.ModelMetadata;
        var type = metadata.UnderlyingOrModelType;
        try
        {
            var value = valueProviderResult.FirstValue;

            object? model;
            if (string.IsNullOrWhiteSpace(value))
            {
                // Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
                model = null;
            }
            else if (type == typeof(float))
            {
                model = float.Parse(value, _supportedStyles, valueProviderResult.Culture);
            }
            else
            {
                // unreachable
                throw new NotSupportedException();
            }

            // When converting value, a null model may indicate a failed conversion for an otherwise required
            // model (can't set a ValueType to null). This detects if a null model value is acceptable given the
            // current bindingContext. If not, an error is logged.
            if (model == null && !metadata.IsReferenceOrNullableType)
            {
                modelState.TryAddModelError(
                    modelName,
                    metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
                        valueProviderResult.ToString()));
            }
            else
            {
                bindingContext.Result = ModelBindingResult.Success(model);
            }
        }
        catch (Exception exception)
        {
            ……
            
        }

        _logger.DoneAttemptingToBindModel(bindingContext);
        return Task.CompletedTask;
    }

以上是從原始碼中抄來的一段,用於繫結 float 型別的 binder。

內部已經提供基礎型別和複合型別的 Binder,所以一般情況下我們不需要自己花時間去寫 Binder。

 

二、Binder 物件

binder 物件必須實現 IModelBinder 介面。這個介面只要求實現一個方法。

    Task BindModelAsync(ModelBindingContext bindingContext);

在這個方法的實現在,你要完成從資料來源中提取值,然後產生繫結目標物件的過程。

例如,你的控制器類中有這麼個方法(API方法):

public Task UpdateStudent(Student stu)
{
    ……
}

假設這個 Student 類有 Name、Age、Email 三個屬性,不管資料是通過 URL 查詢字串傳遞還是 form 提交,都需要提供這些值:

name=小明

age=21

email=abc@163.com

於是,你的自定義 Binder 可以這樣寫

public class StudentBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // 提取值
        var valname = bindingContext.ValueProvider.GetValue("name");
        var valemail = bindingContext.ValueProvider.GetValue("email");
        var valage = bindingContext.ValueProvider.GetValue("age");
        // 剝出真實的值
        string? name;
        if(valname != ValueProviderResult.None)
        {
            name = valname.FirstValue;
        }
        int age;
        if(valage != ValueProviderResult.None)
        {
            _ = int.TryParse(valage.FirstValue, out age);
        }
        string? email;
        if(valemail != ValueProviderResult.None)
        {
            email = valemail.FirstValue;
        }
        // 例項化目標物件
        Student s = new()
        {
            Name = name,
            Age = age,
            Email = email
        };
        // 如果繫結成功,必須設定 Result
        bindingContext.Result = ModelBindingResult.Success(s);
    }
}

繫結的過程可以很簡單,也可以弄得很複雜,主要看你的需求。bindingContext 物件的 Result 屬性一定要設定,預設是繫結失敗。傳遞給 ModelBindingResult.Success(s) 方法的引數就是目標物件。比如上面的,通過模型繫結的目標是給 Student 物件的屬性賦值,所以傳遞的就是 Student 例項的引用。控制器方法 UpdateStudent 的引數就會引用這個 Student 例項,繫結完成。

其實上面的 Binder 我只是寫著玩的,而且它只侷限在 Student 類上,不能通用於所有型別。實際上我們們也不需寫,我只是做演示。內建的 ComplexObjectModelBinder 類就能完成這項工作,而且它是通用於所有複合型別。

 

binder 寫好了怎麼用呢?這分為全域性區域性。像上面例子這種特定於 Student 型別的 binder,最好還是區域性應用——在 Student 類上通過特性類來關聯。

[ModelBinder(typeof(StudentBinder))]
public class Student
{
    ……
}

不想寫在類,也可以寫在控制器方法的引數上。

public Task UpdateStudent([ModelBinder(typeof(StudentBinder))] Student stu)

 

三、ModelBinderProvider

如果你希望自定義的 binder 可以應用於全域性,那你得實現 IModelBinderProvider 介面。這個介面只有一個方法:

IModelBinder? GetBinder(ModelBinderProviderContext context);

通過這個方法你會發現,ModelBinderProvider 的作用就是獲取 binder 物件。

所以我們們上面那個例子,可以寫一個 StudentBinderProvider 類,實現 GetBinder 方法,返回一個 binder 例項。

    return new StudentBinder();

既然是全域性的,當然要在應用程式初始化時完成。在 Program.cs 檔案中,通過 MvcOptions 物件來配置。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers(opt =>
{
    opt.ModelBinderProviders.Insert(0, new StudentBinderProvider());
});
var app = builder.Build();

app.MapControllers();

app.Run();

不過,上面寫的那個例子,應用到全域性後容易翻車。因為所有 MVC 請求都會呼叫,而上面程式碼中我們們是把 StudentBinderProvider 物件放在列表的首位,只要有 MVC 請求,都會呼叫它來獲取 binder,結果所有型別的繫結目標都會用 StudentBinder 來做繫結,不是 Student 型別的物件就無法繫結,獲取不到資料來源的值。

當然你可以做一下型別判斷,不是 Student 的值接返回 null。這樣執行時會轉而嘗試其他 ModelBinderProvider。

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {

         if(context.Metadata.ModelType == typeof(Student))
         {
                 return new .....     
          }

          return null;
    }        

ModelBinderProvider 不需要放到依賴注入容器中,在配置 MVC 功能時會預設新增,自定義的可以用上面的方法通過 MvcOptions 新增。

        // Set up ModelBinding
        options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider());
        options.ModelBinderProviders.Add(new ServicesModelBinderProvider());
        options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options));
        options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
        options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider());
        options.ModelBinderProviders.Add(new EnumTypeModelBinderProvider(options));
        options.ModelBinderProviders.Add(new DateTimeModelBinderProvider());
        options.ModelBinderProviders.Add(new TryParseModelBinderProvider());
        options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
        options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());
        options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider());
        options.ModelBinderProviders.Add(new FormFileModelBinderProvider());
        options.ModelBinderProviders.Add(new FormCollectionModelBinderProvider());
        options.ModelBinderProviders.Add(new KeyValuePairModelBinderProvider());
        options.ModelBinderProviders.Add(new DictionaryModelBinderProvider());
        options.ModelBinderProviders.Add(new ArrayModelBinderProvider());
        options.ModelBinderProviders.Add(new CollectionModelBinderProvider());
        options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider());
        // Set up ValueProviders
        options.ValueProviderFactories.Add(new FormValueProviderFactory());
        options.ValueProviderFactories.Add(new RouteValueProviderFactory());
        options.ValueProviderFactories.Add(new QueryStringValueProviderFactory());
        options.ValueProviderFactories.Add(new JQueryFormValueProviderFactory());
        options.ValueProviderFactories.Add(new FormFileValueProviderFactory());

 

四、ModelBinderFactory

這個主要是實現  IModelBinderFactory 介面,此介面也要實現一個方法來建立 binder。

    IModelBinder CreateBinder(ModelBinderFactoryContext context);

內部預設的實現類為 ModelBinderFactory。這個 factory 是註冊到依賴注入容器中的(單例項模式),它建立 binder 的依據是我們上面提到的各種 ModelBinderProvider,它就是呼叫了它們的 GetBinder 方法來獲得 binder 的例項引用。

它會迴圈訪問所有 provider,只要有一個 GetBinder 方法不返回 null,就算完成,然後就用這個 binder 來完成模型繫結。

        for (var i = 0; i < _providers.Length; i++)
        {
            var provider = _providers[i];
            result = provider.GetBinder(providerContext);
            if (result != null)
            {
                break;
            }
        }

這個類在獲取 binder 例項後會把 binder 快取,當下一次再用到時就直接從快取裡面獲取,免去了多次 new 的時間消耗。請大夥伴們記住它的快取功能,因為後文我們們在實現 API 同時支援 JSON 和 Form 提交時會因為它導致問題。

 

五、實現同一個API支援 JSON 和 form-data 正文

前面四個標題都是準備理論,現在我們們才正式開始幹活。

老周想要這樣一個功能:假設某API是 /demo/product/edit,呼叫時要 POST 資料,然後被 Product 型別的引數接收。客戶端使用 json 提交可以,使用 form-data 提交也可以。

要實現這個,可以用自定義 Binder 的方式。我們不需要自己編寫複雜的 binder,因為內建的有現成的:

1、當呼叫 API 時提交的是 JSON,使用 BodyModelBinder 就可以讀出內容;

2、當呼叫 API 時提交的是 form-data,使用 ComplexObjectModelBinder 就能讀出。

綜合上述兩條,老周有個大膽的想法,於是付諸大膽的行動。我們們自己寫個 ModelBinderProvider。

public class CustMtFmtBinderProvider : IModelBinderProvider
{
    private readonly IModelBinderProvider bodybinderProvd;
    private readonly IModelBinderProvider complexobjbinderProvd;

    public CustMtFmtBinderProvider(BodyModelBinderProvider bdp, ComplexObjectModelBinderProvider cmplxprd)
    {
        bodybinderProvd = bdp;
        complexobjbinderProvd = cmplxprd;
    }

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        HttpContext httpctx = context.Services.GetRequiredService<IHttpContextAccessor>()?.HttpContext;
        var request = httpctx.Request;
        IModelBinder binder;
        if(request.ContentType.StartsWith("multipart/form-data"))
        {
            binder = complexobjbinderProvd.GetBinder(context);
        }
        else
        {
            binder = bodybinderProvd.GetBinder(context);
        }
        return binder;
    }
}

兩個型別的 binder 可以通過建構函式來傳,因為是用 MvcOptions 來新增的,不是依賴住入,所以我們可以自己傳參。原理老周相信大夥能懂,就是判斷 HTTP 請求的 Content-Type,如果是 multipart/form-data,就用複合型別的 binder,否則,用 Body binder,它預設支援JSON,不,是隻支援JSON。

上一段 BodyModelBinder 的原始碼:

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ……var formatter = (IInputFormatter?)null;
        for (var i = 0; i < _formatters.Count; i++)
        {
            if (_formatters[i].CanRead(formatterContext))
            {
                formatter = _formatters[i];
                ……
                break;
            }
            else
            {
                Log.InputFormatterRejected(_logger, _formatters[i], formatterContext);
            }
        }

        ……try
        {
            var result = await formatter.ReadAsync(formatterContext);

            if (result.HasError)
            {
                // Formatter encountered an error. Do not use the model it returned.
                _logger.DoneAttemptingToBindModel(bindingContext);
                return;
            }

            ……
    }

喲!這傢伙並不是用 ValueProvider 來獲取值的,而是使用 InputFormatter 讀取的。上一篇水文中老周提過,當我們讓控制器類用於 API 時,應用 [ApiController] 特性,它會把HTTP請求的整個 body 作為繫結源,並把模型繫結交給 InputFormatter 去處理。這裡我們就看到真相了,就是 BodyModelBinder 搞的鬼,它呼叫了 Inputformatter。

Inputformatter 實現 IInputFormatter 介面,而執行庫預設給應用註冊的是 SystemTextJsonInputFormatter ,對於返回資料,則用的是 SystemTextJsonOutputFormatter 。關於返回的資料格式,老周前面也寫過相關文章。

好了,回到主題,現在我們們的 BinderProvider 寫好了,把它配置到 MvcOptions 物件中,成為全域性的 binder 提供者。

var builder = WebApplication.CreateBuilder();
// 一定要註冊這個
builder.Services.AddHttpContextAccessor();
builder.Services.AddControllers();
builder.Services.Configure<MvcOptions>(opt =>
{
    BodyModelBinderProvider bp = opt.ModelBinderProviders.OfType<BodyModelBinderProvider>().FirstOrDefault()!;
    ComplexObjectModelBinderProvider cp = opt.ModelBinderProviders.OfType<ComplexObjectModelBinderProvider>().FirstOrDefault()!;
    opt.ModelBinderProviders.Insert(0, new CustMtFmtBinderProvider(bp, cp));
});
var app = builder.Build();

我們那個 Provider 中用到 HttpContext ,要在服務容器上呼叫 AddHttpContextAccessor 方法註冊一下,不然會報錯(因為在 Provider 中預設沒有引用 Httpcontext 相關的物件,但 ModelBinder 的上下文中可以訪問)。

嗯,思路是沒錯的,這 Job 看起來很完美。下面我們們定義個模型類。

public class Cat
{
    public string Nickname { get; set; } = "";
    public string? Category { get; set; }
    public string Owner { get; set; } = "";
}

老周比較喜歡的兩種動物:一種是喵喵,另一種是兔崽子。這裡就定義個 Cat 類吧。

隨後是 Controller。

[Route("cat")]
[ApiController]
public class CatController : Controller
{
    [HttpPost]
    [Route("new")]
    [Consumes("application/json", "multipart/form-data")]
public string NewCat(Cat cc)
    {
        if (cc.Nickname == "")
            return "你養了個寂寞";

        string m = $"你新養了一隻貓,它叫 {cc.Nickname}";
        m += $"\n主人:{cc.Owner}\n品種:{cc.Category}";
        return m;
    }
}

Consumes 特性可以指定 API 方法支援哪些 content-type,當某個 action 有多個匹配方法時,還可以作為篩選方法的輔助依據。這裡我就明確了兩個型別—— application/json 和 multipart/form-data

然而,但是,意外,沒想到,執行之後就讓人傻眼了。果然理想是花容月貌,現實是鬼妖當道。問題如下:

A、執行後,選用 form-data,測試成功;但改為 JSON 提交就不行了;

B、執行後,選用 JSON 測試,成功;但改為 form-data 提交就不行了。

總結一下,就是執行後,第一次呼叫是正確的,無論是 JSON 還是 FORM 都是可以的,但之後再呼叫 API 就不正確了。其實我們們這個示例的思路是沒有錯的,但這時候你怎麼 debug 都無法解決。問題出在哪呢?

前文老周提示了一下,記得乎?在介紹 ModelBinderFactory 時強調了一下,這傢伙在呼叫某個 ModelBinderProvider 成功獲得 ModelBinder 後會將其快取。本示例的問題就是出在這兒了。快取物件是字典型別(ConcurrentDictionary),Key 的組成要素之一(另一個可能是 ControllerParameterDescriptor,描述方法的引數資訊)就是模型繫結的後設資料(ModelMetadata),而後設資料是從模型型別產生的。在這個示例中,模型無論要使用 BodyModelBinder 還是 ComplexObjectModelBinder,它的模型都是 Cat 類(也是一樣的引數),因此後設資料是不變的。

假設使用 JSON 方式提交,當第一次呼叫後,自定義 ModelBinderProvider 返回的 binder (假設叫 X)會被快取;然後改為用 form data 提交,呼叫時,會優先從快取中查詢,然後找到 X,最後又用了 X 來進行模型繫結,於是就錯了(此次應該用 Y)。

幸好,這個問題是可以解決的。我們們調整一下思路,先自定義一個 binder,在這個 binder 裡封裝 BodyModelBinder 和 ComplexObjectModelBinder 物件,然後在執行繫結時,再動態選擇用 body 還是用 complexobject。

於是,示例做以下調整:

先編寫一個自定義的 binder。

public class CustBinder : IModelBinder
{
    private readonly BodyModelBinder _bodybinder;
    private readonly ComplexObjectModelBinder _objectbinder;

    public CustBinder(BodyModelBinder bodyb, ComplexObjectModelBinder objb)
    {
        _bodybinder = bodyb;
        _objectbinder = objb;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // 通過 Content-Type 來區分
        var request = bindingContext.HttpContext.Request;
        if(request.ContentType!.StartsWith("multipart/form-data"))
        {
            await _objectbinder.BindModelAsync(bindingContext);
        }
        else
        {
            await _bodybinder.BindModelAsync(bindingContext);
        }
    }
}

這個自定義 Binder 中用到了另兩個 Binder:Body 和 ComplexObject。通過建構函式兩傳遞。在執行繫結時,通過請求的 ContentType 來判斷,如果是 form-data,就用 ComplexObjectModelBinder,否則用 BodyModelBinder(直接呼叫它們的 BindModelAsync 方法就行了)。

補充:程式碼中在物件後面出現的“!”運算子,是告訴分析器“這個物件不會是null的,請放心”。在執行階段無用處,只是在編譯時不會有警告。

然後,再寫一個 ModelBinderProvider。

public class CustFmtBinderProviderV2 : IModelBinderProvider
{
    private readonly MvcOptions options;
    public CustFmtBinderProviderV2(MvcOptions o)
    {
        options = o;
    }

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (context.BindingInfo.BindingSource == null || context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Body) == false)
            return null;

        BodyModelBinderProvider bodyprvd = options.ModelBinderProviders.OfType<BodyModelBinderProvider>().FirstOrDefault()!;
        ComplexObjectModelBinderProvider objprvd = options.ModelBinderProviders.OfType<ComplexObjectModelBinderProvider>().FirstOrDefault()!;
        // 建立 binder 例項
        IModelBinder? binder1 = bodyprvd.GetBinder(context);
        IModelBinder? binder2 = objprvd.GetBinder(context);
        // 兩個 binder 都要用到,均不能為 null
        if(binder1 != null && binder2 != null)
        {
            return new CustBinder((BodyModelBinder)binder1, (ComplexObjectModelBinder)binder2);
        }

return null;
    }
}

BodyModelBinderProvider 和 ComplexObjectModelBinderProvider 從 MvcOptions 物件的 ModelBinderProviders 列表中獲取。建立 CustBinder 例項時,把這兩個 binder 傳給它的建構函式。

經過這樣處理之後,被 ModelBinderFactory 快取的是 CustBinder 例項,哪怕在第二次以上呼叫時,都能正確進行 Content-Type 的分析,因為篩選的程式碼是寫在 CustBinder 內部的,就算呼叫的是已快取的例項也不影響其邏輯。

 

最後,Program.cs 檔案那裡也改一下。

var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers();
builder.Services.Configure<MvcOptions>(opt =>
{
    opt.ModelBinderProviders.Insert(0, new CustFmtBinderProviderV2(opt));
});
var app = builder.Build();

 

測試一下,執行後,先以 form-data 輸入。

POST /cat/new HTTP/1.1
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5168
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------031532983200066969593455
Content-Length: 395
 
----------------------------031532983200066969593455
Content-Disposition: form-data; name="nickname"
豆豆
----------------------------031532983200066969593455
Content-Disposition: form-data; name="owner"
小王
----------------------------031532983200066969593455
Content-Disposition: form-data; name="category"
大狸花
----------------------------031532983200066969593455--
 
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 24 Mar 2022 08:57:14 GMT
Server: Kestrel
Transfer-Encoding: chunked
 
你新養了一隻貓,它叫 豆豆
主人:小王
品種:大狸花

通過,沒問題。

接著,改為用 JSON 方式提交。

POST /cat/new HTTP/1.1
Content-Type: application/json
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5168
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 84
 
{
"nickname": "豆豆",
"category": "大橘",
"owner": "賽冬瓜"
}
 
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 24 Mar 2022 08:58:57 GMT
Server: Kestrel
Transfer-Encoding: chunked
 
你新養了一隻貓,它叫 豆豆
主人:賽冬瓜
品種:大橘

嗯嗯嗯,效果不錯吧。現在這個 API 既可以用 form-data 輸入資料,也能用 JSON 輸入資料了。我們們就不必把同一個 API 寫兩個版本了。

 

相關文章