【ASP.NET Core】MVC模型繫結:自定義InputFormatter讀取CSV內容

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

在上一篇文章中,老周介紹了用自定義 ModelBinder 的方式實現一個 API(或MVC操作方法)可以同時支援 JSON 格式和 Form-data 格式的資料正文。今天該輪到 InputFormatter 了——接下來老週會演示如何實現自定義的 InputFormatter,使其可以讀取 CSV 格式的正文。

CSV 的格式比較簡單,一般是一行文字一條資料記錄,每條記錄的欄位值用逗號隔開(英文逗號)。

CSV 的妙處就是格式簡單,一次性提交多條記錄時體積較小。比如,我要提交一批員工資訊。這個在客戶端必須先知道員工物件中各屬性的順序,因為 CSV 是逗號分隔的文字,順序不要打亂。

有時候我們可以這樣規範一下:CSV 的第一行寫欄位標題,從第二行開始才是資料記錄。就像這樣的員工資訊:

emp_id, emp_name, emp_age, emp_part
050025, 小張, 31, 開發部
050130, 小謝, 29, 市場部
038012, 小李, 37, 財務部
045211, 小劉, 36, 討債部

其實,就算第一行寫上欄位名,這種規範也是沒什麼用處的,客戶端可以瞎傳,比如,它可以傳成這樣:

emp_id, emp_name, emp_age, emp_part
土坑部, 523014, 34, 小明
酸菜部, 301027, 28, 小何
牛肉部, 621143, 32, 老高

你瞧,這不全亂套了嗎?所以,提不提供欄位名一行其實不關鍵,關鍵是客戶端在提交 CSV 資料時,你得按規矩來,不然這戲就演不下去了。

 

OK,現在我們們開始今天的表演吧。

首先,我們定義兩個模型類。

public sealed class Album
{
    public string? Title { get; set; } = string.Empty;
    public int Year { get; set; }
    public string? Artist { get; set; }
}

public sealed class Book
{
    public string? Name { get; set; } = string.Empty;
    public string? Author { get; set; }
    public int? Year { get; set; }
    public string? Publisher { get; set; }
}

 之所以定義了兩個類,是為了稍驗證一下自定義的 InputFormatter 是否能通用。Album 類表示一張音樂專輯,有標題、發行年份、藝術家三個屬性;Book 表示一本書的資訊,有書名、作者、出版年份、出版社四個屬性。注意 Book 類的 Year 屬性的型別,我故意弄成了 int?,即可以 null 的整數值。稍後用於驗證自定義的 InputFormatter 是否能處理這樣的值型別。

 

接著,寫一個控制器類和兩個操作方法。

public class TestController : ControllerBase
{
    [HttpPost, ActionName("buyalbums")]
    public IActionResult NewAlbums([FromBody]IEnumerable<Album> albums)
    {
        return Ok(albums);
    }

    [HttpPost, ActionName("buybooks")]
    public IActionResult NewBooks([FromBody] Book[] books)
    {
        return Ok(books);
    }
}

這個控制器類沒有應用 [ApiController] 特性,所以,要讓其能從 body 讀取資料,引數上要應用 [FromBody] 特性。這時候,兩個操作方法的引數並不是單個模型物件,而是集合。NewAlbums 方法宣告為 IEnumerable<T> 介面型別,所以在模型繫結時,你為它分配的物件例項只要實現了 IEnumerable<T> 介面就 OK,如 List<T> 例項。NewBooks 方法的引數是一個 Book 陣列。

這也說明,我們們待會兒要編寫的 Formatter 要考慮引數是 IEnumerable<> 泛型物件還是陣列。

另外,操作方法上應用了 [ActionName] 特性,表示給操作方法分配一個別名,呼叫時 Url 上要寫 buyalbums 和 buybooks。

 

好,重頭戲上面,下面我們們來自定義 Formatter。

    public class CSVInputFormatter : InputFormatter
    {
        public CSVInputFormatter()
        {
            SupportedMediaTypes.Add("text/csv");
        }

        // 只有陣列或實現 IEnumerable<> 的型別可用
        protected override bool CanReadType(Type type)
        {
            if (type.IsArray)
                return true;
            if(type.IsGenericType)
            {
                Type genparmtype = type.GenericTypeArguments[0];
                Type enumerabletype = typeof(IEnumerable<>).MakeGenericType(genparmtype);
                return type == enumerabletype;
            }
            return false;
        }

        public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
        {
            // 看看使用什麼編碼方式
            var request = context.HttpContext.Request;
            var contentType = request.ContentType;
            MediaType mdtype = new(contentType!);
            Encoding contentEncoding = mdtype.Encoding ?? Encoding.UTF8;
            // 建立 reader
            var reader = context.ReaderFactory(request.Body, contentEncoding);
            // 模型的後設資料
            var metadata = context.Metadata;
            // 模型裡面單個元素的後設資料
            var itemMeta = metadata.ElementMetadata;

            // 先臨時弄個List來存放元素
            Type listtype = typeof(List<>).MakeGenericType(itemMeta!.ModelType);
            IList itemList = (IList)Activator.CreateInstance(listtype)!;

            // 一行一行地讀
            var line = await reader.ReadLineAsync();
            for (; line != null; line = await reader.ReadLineAsync())
            {
                // 建立子元素物件例項
                object? itemObject = Activator.CreateInstance(itemMeta.ModelType);
                // CSV 一行用逗號分隔各個值
                string[] parts = line.Split(",");
                
                // 每一行分割出來的段數應該與物件的
                // 屬性個數一致,不然沒法準確還原物件資料
                if (itemMeta!.Properties.Count != parts.Length)
                    continue;
                // 為屬性賦值
                for (int i = 0; i < parts.Length; i++)
                {
                    var property = itemMeta.Properties[i];
                    // 看能不能賦值
                    if (property.IsReadOnly || property.PropertySetter is null)
                    {
                        continue;   //不能賦值,有請下一個
                    }
                    // 屬性值的型別
                    Type propertyType = property.ModelType;
                    object? propertyValue = null;
                    if(propertyType == typeof(string))
                    {
                        // 如果是字串,直接賦值
                        propertyValue = parts[i];
                    }
                    else
                    {
                        // 不是字串要進行型別轉換
                        if(property.IsNullableValueType)
                        {
                            // 如果是 Nullable 的值型別
                            // int?、long? 等型別直接賦值會報錯
                            // UnderlyingOrModelType獲取到裡面的真實型別
                            // 例如,int? 它能獲取到 int
                            propertyType = property.UnderlyingOrModelType;
                        }
                        try
                        {
                            propertyValue = Convert.ChangeType(parts[i], propertyType);
                        }
                        catch
                        {
                            // 忽略
                        }
                    }
                    if (propertyValue != null)
                    {
                        // 如果值有效,賦值
                        property.PropertySetter(itemObject!, propertyValue);
                    }
                }
                // 把物件例項加入到列表物件中
                itemList.Add(itemObject);
            }

            // 建立最終的模型物件
            if (metadata.ModelType.IsArray)
            {
                // 它是陣列型別
                var arr = Array.CreateInstance(itemMeta.ModelType, itemList.Count);
                // 為元素賦值
                for (int i = 0; i < arr.Length; i++)
                {
                    arr.SetValue(itemList[i], i);
                }
                return InputFormatterResult.Success(arr);
            }
            // 如果不是直接把列表物件返回即可
            return InputFormatterResult.Success(itemList);
        }
    }

程式碼又長又坑爹,下面老周解釋一下。

1、這裡我不實現 IInputFormatter 介面,而是實現 InputFormatter 抽象類。因為在抽象類中已經為我們做了一些前提工作,比如判斷客戶端的 HTTP 請求有沒有 body。我們可以省了不少功夫。當然,直接實現 TextInputFormatter  抽象類也不錯的,它為我們做了一些根文字編碼有關的處理,比如選擇 Encoding。不過,此處老周覺得實現 InputFormatter 抽象類就足夠了。

2、在這個類的建構函式中,向 SupportedMediaTypes 列表新增受支援的 MIME Type。比如本例,我們們處理 CSV 文字,可以讓它只支援 text/csv 格式,這樣執行時在處理時會自動選擇我們們自定義的這個 Formatter 來讀資料。

3、重寫 CanReadType 方法。它有個 Type 型別的引數,表示模型繫結的目標型別相關的資訊。在本例中,可能是 Book 類或 Album 類的 Type。老周是這樣判斷的:

        protected override bool CanReadType(Type type)
        {
            if (type.IsArray)
                return true;
            if(type.IsGenericType)
            {
                Type genparmtype = type.GenericTypeArguments[0];
                Type enumerabletype = typeof(IEnumerable<>).MakeGenericType(genparmtype);
                return type == enumerabletype;
            }
            return false;
        }

  a、如果是陣列型別,Pass。好像不太嚴謹,但此處我們不需要太複雜的驗證。

  b、如果是泛型類(就是針對 IEnumerable<T>的),獲取型別引數T的 Type,然後用 MakeGenericType 方法建立一個 Type 物件。為什麼要這樣做呢?因為只有這樣建立的 Type 才表示 IEnumerable<T>,直接用 typeof(IEnumerable<>) 是不行的。

3、實現 ReadRequestBodyAsync 抽象方法。這是核心,我們們在這個方法中讀取資料並還原模型物件。這個方法的處理中,我們不需要去驗證 HttpRequest 有沒有 Body,因為 InputFormatter 基類已經幫我們做了,能呼叫 ReadRequestBodyAsync 方法說明是有 Body 的。

4、根據請求的 Content-Type 頭,獲取文字編碼,如果獲取不到,預設 UTF-8。

            MediaType mdtype = new(contentType!);
            Encoding contentEncoding = mdtype.Encoding ?? Encoding.UTF8;

5、讀 body 用的 TextReader 我們不用自己找,呼叫 context 引數(InputFormatterContext)的 ReaderFactory 委託就能獲取到,它會幫我們自動建立。

6、Metadata 屬性表示的是頂層的物件,比如我們這裡是 IEnumerable<X> 或 X[]。而 ElementMetadata 屬性表示的是集合中元素的模型後設資料,比如 Book 的,Album 的。

7、因為控制器類中的方法引數可能是 IEnumerable<T> 型別的,也可能是 T[] 型別的。我們暫時不管它。我們臨時建立一個 List<T> 例項,用來儲存從 body 中讀到的物件。

            Type listtype = typeof(List<>).MakeGenericType(itemMeta!.ModelType);
            IList itemList = (IList)Activator.CreateInstance(listtype)!;

itemList 變數宣告為 IList 型別,這樣我們可以呼叫它的 Add 方法,動態新增物件。

8、接下來是一個迴圈,一行一行地讀入。每一行就是一個元素物件(Book 或 Album 或其他)。

9、讀到一行後,以逗號為分隔符拆解字串,然後迴圈訪問元素型別的屬性列表(ModelMetadata的 Properties 集合)。

                for (int i = 0; i < parts.Length; i++)
                {
                    var property = itemMeta.Properties[i];
    
                    ……
                }

10、在獲取到屬性值的型別後,我們要做幾個判斷:

  a、字串,好辦,直接賦值;

  b、非字串。看看是不是 Nullable<T>,如果是,取出 T 的 Type 再用。 Convert.ChangeType 方法遇到 int?、byte? 等型別是無法轉換的,會發生異常;

  c、型別轉換。

11、得到屬性值後,用 PropertySetter 委託來設定屬性。

        if (propertyValue != null)
        {
              // 如果值有效,賦值
              property.PropertySetter(itemObject!, propertyValue);
        }

12、此時,一個元素物件還原完畢,新增到剛才宣告的那個 IList 型別變數中。

   itemList.Add(itemObject);

13、下一輪迴圈,過程一樣,直到讀完整 body。

14、等所有元素都搞定後,就剩下容器類了。這裡要分情況:

  a、如果是陣列,先建立陣列例項,再按索引把 IList 型別變數中的元素引用傳過去;

  b、如果是 IEnumerable<T>,可以直接使用 IList 型別的變數,因為它的例項型別是 List<T>,已經實現了 IEnumerable<T> 介面,直接賦值是相容的。

            if (metadata.ModelType.IsArray)
            {
                // 它是陣列型別
                var arr = Array.CreateInstance(itemMeta.ModelType, itemList.Count);
// 為元素賦值
                for (int i = 0; i < arr.Length; i++)
                {
                    arr.SetValue(itemList[i], i);
                }
                return InputFormatterResult.Success(arr);
            }
            // 如果不是直接把列表物件返回即可
            return InputFormatterResult.Success(itemList);

 

自定義 Formatter 完工後,要在 MvcOptions 中配置一下。

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

app.MapControllerRoute("main", "{controller}/{action}");

app.Run();

 

來來來,測試一下。

POST /test/buyalbums HTTP/1.1
Content-Type: text/csv
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5031
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 92
 
高大上,1999,老杜
瘋子村,2002,小饅頭
風雨同路,,老周
放大招合集,2017,
 
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 28 Mar 2022 11:06:51 GMT
Server: Kestrel
Transfer-Encoding: chunked
 
[{"title":"高大上","year":1999,"artist":"老杜"},{"title":"瘋子村","year":2002,"artist":"小饅頭"},{"title":"風雨同路","year":0,"artist":"老周"},{"title":"放大招合集","year":2017,"artist":""}]

注意提交的最後一行,老周故意搞了個鬼,缺少了 Artist 屬性的值(所以最後一行是逗號結尾)。

放大招合集,2017,(缺)

於是,產生的物件列表中,最後一個 Album 物件的 Artist 屬性就是空字串。

{
        "title": "放大招合集",
        "year": 2017,
        "artist": ""
}

 

再測試一個。

POST /test/buybooks HTTP/1.1
Content-Type: text/csv
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5031
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 224
 
VB從入門到跳河,張智慧,2021,半個腦袋出版社
PHP釣魚網站開發,王小三,2023,天國白日夢傳媒
和尚與女魔頭,二麻子,2009,一刀切綜合出版社
離離原上譜,老甘,,一鍵三聯出版社
 
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 28 Mar 2022 11:13:41 GMT
Server: Kestrel
Transfer-Encoding: chunked
 
[{"name":"VB從入門到跳河","author":"張智慧","year":2021,"publisher":"半個腦袋出版社"},{"name":"PHP釣魚網站開發","author":"王小三","year":2023,"publisher":"天國白日夢傳媒"},{"name":"和尚與女魔頭","author":"二麻子","year":2009,"publisher":"一刀切綜合出版社"},{"name":"離離原上譜","author":"老甘","year":null,"publisher":"一鍵三聯出版社"}]

最後一行,缺了 Year 屬性的值。

離離原上譜,老甘,(缺),一鍵三聯出版社

所以得到的物件中 Year 屬性為 null,因為它是 int?,可為 null,不會分配預設的 0 。

 {
        "name": "離離原上譜",
        "author": "老甘",
        "year": null,
"publisher": "一鍵三聯出版社"
 }

 

好了,今天老周的文章就水到這裡了,改天再聊。

 

相關文章