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


在上一篇文章中,老周介紹了用自定義 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": "一鍵三聯出版社"
 }

 

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM