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