在上一篇文章中,老周介紹了用自定義 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": "一鍵三聯出版社" }
好了,今天老周的文章就水到這里了,改天再聊。