最近在重寫我之前做的一個名叫「假日查詢系統」的小專案(side project),主要是給 Power Automate 與 Azure Logic App 呼叫的一個 Web API,因為我們我常把一些日常的工作自動化,經常需要判斷「當天」是否是放假日,藉此判斷式否要觸發工作,這才不會在一些特別的日子 Teams 還在亂叫。之前我是用 JSON 的 API 來介接,但這次我打算用 CSV 來當作主要資料源,箇中緣由請讓我娓娓道來。
分析資料來源
由於要介接臺北市政府行政機關辦公日曆表資料,我發現了許多問題,前陣子在我的粉絲團分享部分問題,還獲得大家不少關注,留言蠻有趣的,大家可以看看。
臺北市資料大平臺的臺北市政府行政機關辦公日曆表資料,主要提供兩種格式,我分別針對這兩種格式的問題做出以下整理:
-
JSON
官方文件提供的 API 參數有三個,其中 q
完全沒作用,沒有任何搜尋能力,也無法篩選日期。另外兩個 limit
與 offset
是可以運作的,而 limit
參數上限 1,000
筆資料,這份資料集總筆數為 1,316
筆,所以你用 1 個 HTTP Request 是無法拿到完整資料集的。
這份資料集並沒有明確的生命週期政策,從開發者的角度來看,並無法得知舊的資料是否會刪除,因此 offset
參數相當不可靠,他無法幫助我精準篩選出「當天」的資料,因此我被迫要下載所有資料,才有可能順利篩選出我要的結果,也就是「當天是否為放假日」的資訊。
其實我最需要的功能,是要找到「當天」是否有放假而已,並不需要完整的資料集。但由於 API 的基本限制,我目前必須要發出 2 個 Requests 才能取得完整資料,而在幾年後,可能會變成要下載 3 個 Requests 才能取得完整資料,依此類推。整體來說,我覺得 DX (開發者體驗) 相當不好。
-
CSV
官方提供的 CSV 資料並沒有任何搜尋能力,就是很單純的資料下載而已,但下載時只要用 1 個 Request 就可以下載到完整資料集,因此相當方便。
不過,我發現官方提供的 CSV 資料集,在下載的時候,他們的 HTTP Headers 有出現一些錯誤的實作。我當天在粉絲團分享的圖片就有點出一些地方,北市府資訊局在當天晚上就已經將 Content-Type
的 charset
問題與顯示 Server 與 PHP 版本的問題修復,相當有效率。不過還有一些其他地方至今尚未修復,我特別再整理一下目前的問題:
實作 CSV 解析程式
其實 CSV 是一種相當古老的格式,他甚至有一份 RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files 規格,清楚的定義出 CSV 應該如何呈現。
很多人不知道的是,網路上有太多不照規定走的 CSV 資料,像是資料中有逗號(,
),資料中有「斷行符號」怎麼辦,每一筆資料到底該用 CRLF
斷行,還是用 LF
斷行,不知道有這份 RFC 4180 存在的人,基本上就是自己亂寫一通了,不符合規定的文件很容易遇到。因此,解析 CSV 文件並不是大家想像中的那麼容易。但對於「公開資料」的平台來說,輸出一份符合 RFC 4180 標準的 CSV 文件就很重要了。
政府資料開放平臺 有明確的 RFC4180格式 文件指出資料品質提升機制,這點還不錯,但實際上有沒有照著規定走,可能還是要實際分析過才知道。至於臺北市資料大平臺我就沒看到相關規範,這點就讓人有點擔心了。
本篇文章我會以 .NET 7 搭配 CsvHelper 套件來實作,而且會用標準的 RFC 4180 規範來解析 CSV 資料!
-
先克服 BIG5
編碼問題
由於從 .NET Core 1.0 開始,就沒有內建 BIG5 編碼的 Encoding
資料,所以你必須額外安裝 System.Text.Encoding.CodePages 套件才行。
dotnet add package System.Text.Encoding.CodePages
以下是主要程式碼:
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding("BIG5");
-
安裝 CsvHelper 套件
dotnet add package CsvHelper
-
定義一份 CsvConfiguration
物件
這個步驟相當重要,因為這是 CsvHelper 套件在對 CSV 資料進行讀取與寫入的重要設定!
var config = new CsvConfiguration(CultureInfo.CurrentCulture)
{
// 採用標準的 RFC 4180 解析與寫入 CSV 資料
Mode = CsvMode.RFC4180,
// 用來讓 CSV 欄位標頭不區分大小寫
PrepareHeaderForMatch = args => args.Header.ToLower()
};
-
取得 CSV 資料
這個步驟真正的關鍵在於 new StreamReader
的第 2 個參數,這個 encoding
參數才能讓你讀到不是亂碼的字串值,而且傳入的必須是 BIG5
的 Encoding 物件!
var client = new HttpClient();
var url = "https://data.taipei/api/frontstage/tpeod/dataset/resource.download?rid=29d9771d-c0ee-40d4-8dfb-3866b0b7adaa";
var stream = await client.GetStreamAsync(url);
using (var reader = new StreamReader(stream, encoding))
using (var csv = new CsvReader(reader, config))
{
// 未來可使用 csv 變數來讀取資料
}
以下是這份 CSV 資料前幾行的內容:
date,name,isHoliday,holidayCategory,description
2013/1/1,中華民國開國紀念日,是,放假之紀念日及節日,全國各機關學校放假一日。
2013/1/5,,是,星期六、星期日,
2013/1/6,,是,星期六、星期日,
2013/1/12,,是,星期六、星期日,
2013/1/13,,是,星期六、星期日,
2013/1/19,,是,星期六、星期日,
-
定義資料模型類別
我喜歡 CsvHelper 套件的一個地方,就是他可以幫助我用「強型別」的方式解析 CSV 資料,最終的程式碼會非常漂亮,且完全不用面對複雜的 CSV 格式!
我們先定義資料模型類別,但千萬不要想著將所有欄位都設定為 string
型別,我們把型別都定義清楚,不要管原始資料格式為何,那是之後才要煩惱的問題:
public class Holiday
{
// 這個 Date 欄位,我們使用 .NET 6 才新增的 DateOnly 結構(Struct)
[Display(Name = "日期")]
public DateOnly Date { get; set; }
// 假日肯定為 string 型別,但名稱不見得會有資料,所以我們用 string? 語法
// string? 為 .NET 6 新增的 Nullable reference types (可為 Null 的參考型別)
[Display(Name = "假日名稱")]
public string? Name { get; set; }
// 是否為假日肯定為 bool 型別,但我們的資料來源為 "是" 與 "否"
[Display(Name = "是否為假日")]
public bool IsHoliday { get; set; }
// 假日種類肯定為 string 型別,但名稱不見得會有資料,所以我們用 string? 語法
[Display(Name = "假日種類")]
public string? HolidayCategory { get; set; }
// 備註肯定為 string 型別,但名稱不見得會有資料,所以我們用 string? 語法
[Display(Name = "備註")]
public string? Description { get; set; }
}
-
定義資料轉換類別
由於 CsvHelper 套件有著非常強大的型別轉換架構,以支持強型別所需的型別轉換需求。除了已經內建許多 Type Conversion 類別外,針對一些特別的資料轉換需求,你可以透過繼承 DefaultTypeConverter
來實作自訂的型別轉換。
我們需要將 IsHoliday
欄位的 "是" 與 "否" 等 string
型別,轉換為 bool
型別,所以我們可以這樣定義:
public class IsHolidayConverter : DefaultTypeConverter
{
public override object? ConvertFromString(string? text, IReaderRow row, MemberMapData memberMapData)
{
if (text == null) return false;
foreach (var nullValue in memberMapData.TypeConverterOptions.NullValues)
{
if (text == nullValue) return false;
}
return (text == "是");
}
}
我們需要將 Date
欄位的「日期」資料,轉換為 .NET 6 的 DateOnly
型別,所以我們可以這樣定義:
public class DateOnlyConverter : DefaultTypeConverter
{
public override object? ConvertFromString(string? text, IReaderRow row, MemberMapData memberMapData)
{
if (text == null) return default(DateOnly);
foreach (var nullValue in memberMapData.TypeConverterOptions.NullValues)
{
if (text == nullValue) return default(DateOnly);
}
DateOnly date;
if (DateOnly.TryParse(text, out date))
{
return date;
}
else
{
return default(DateOnly);
}
}
}
我覺得程式碼非常簡潔易懂,相當不錯!
-
定義欄位對應表
因為我過往相當熟悉 ORM 架構,所以看到 CsvHelper 套件在做欄位對應時,所採用的寫法與 Entity Framework 極為相似,所有看到程式碼會有種相當熟悉的感覺。
以下程式碼我想應該也是非常容易理解才對:
public class HolidayMap : ClassMap<Holiday>
{
public HolidayMap()
{
Map(m => m.Date).TypeConverter(new DateOnlyConverter());
Map(m => m.Name);
Map(m => m.IsHoliday).TypeConverter(new IsHolidayConverter());
Map(m => m.HolidayCategory);
Map(m => m.Description);
}
}
-
解析 CSV 資料
在前幾個步驟的精心準備下,終於要來解析 CSV 資料了!
我們只要將 HolidayMap
註冊進去,最後用 csv.GetRecords<Holiday>()
就可以取得一個 IEnumerable<Holiday>
型別的資料,這段程式真的相當漂亮!👍
csv.Context.RegisterClassMap<HolidayMap>();
IEnumerable<Holiday> data = csv.GetRecords<Holiday>();
總結
我有將本次文章的完整原始碼放到 LINQPad 的 Instant Share 平台,你可以用以下網址取得原始碼:http://share.linqpad.net/r7q2on.linq
我另外還用 .NET 7 做一個 HolidayChecker 工具程式,可以用命令列的方式查詢假期資訊!👍
相關連結