The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

使用 C# 與 CsvHelper 套件解析《臺北市政府行政機關辦公日曆表》公開資料

最近在重寫我之前做的一個名叫「假日查詢系統」的小專案(side project),主要是給 Power Automate 與 Azure Logic App 呼叫的一個 Web API,因為我們我常把一些日常的工作自動化,經常需要判斷「當天」是否是放假日,藉此判斷式否要觸發工作,這才不會在一些特別的日子 Teams 還在亂叫。之前我是用 JSON 的 API 來介接,但這次我打算用 CSV 來當作主要資料源,箇中緣由請讓我娓娓道來。

image

分析資料來源

由於要介接臺北市政府行政機關辦公日曆表資料,我發現了許多問題,前陣子在我的粉絲團分享部分問題,還獲得大家不少關注,留言蠻有趣的,大家可以看看。

臺北市資料大平臺臺北市政府行政機關辦公日曆表資料,主要提供兩種格式,我分別針對這兩種格式的問題做出以下整理:

  • JSON

    官方文件提供的 API 參數有三個,其中 q 完全沒作用,沒有任何搜尋能力,也無法篩選日期。另外兩個 limitoffset 是可以運作的,而 limit 參數上限 1,000 筆資料,這份資料集總筆數為 1,316 筆,所以你用 1 個 HTTP Request 是無法拿到完整資料集的。

    image

    這份資料集並沒有明確的生命週期政策,從開發者的角度來看,並無法得知舊的資料是否會刪除,因此 offset 參數相當不可靠,他無法幫助我精準篩選出「當天」的資料,因此我被迫要下載所有資料,才有可能順利篩選出我要的結果,也就是「當天是否為放假日」的資訊。

    其實我最需要的功能,是要找到「當天」是否有放假而已,並不需要完整的資料集。但由於 API 的基本限制,我目前必須要發出 2 個 Requests 才能取得完整資料,而在幾年後,可能會變成要下載 3 個 Requests 才能取得完整資料,依此類推。整體來說,我覺得 DX (開發者體驗) 相當不好。

  • CSV

    官方提供的 CSV 資料並沒有任何搜尋能力,就是很單純的資料下載而已,但下載時只要用 1 個 Request 就可以下載到完整資料集,因此相當方便。

    不過,我發現官方提供的 CSV 資料集,在下載的時候,他們的 HTTP Headers 有出現一些錯誤的實作。我當天在粉絲團分享的圖片就有點出一些地方,北市府資訊局在當天晚上就已經將 Content-Typecharset 問題與顯示 Server 與 PHP 版本的問題修復,相當有效率。不過還有一些其他地方至今尚未修復,我特別再整理一下目前的問題:

    image

實作 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 資料!

  1. 先克服 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");
    
  2. 安裝 CsvHelper 套件

    dotnet add package CsvHelper
    
  3. 定義一份 CsvConfiguration 物件

    這個步驟相當重要,因為這是 CsvHelper 套件在對 CSV 資料進行讀取與寫入的重要設定!

    var config = new CsvConfiguration(CultureInfo.CurrentCulture)
    {
        // 採用標準的 RFC 4180 解析與寫入 CSV 資料
        Mode = CsvMode.RFC4180,
        // 用來讓 CSV 欄位標頭不區分大小寫
        PrepareHeaderForMatch = args => args.Header.ToLower()
    };
    
  4. 取得 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,,是,星期六、星期日,
    
  5. 定義資料模型類別

    我喜歡 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; }
    }
    
  6. 定義資料轉換類別

    由於 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);
            }
        }
    }
    

    我覺得程式碼非常簡潔易懂,相當不錯!

  7. 定義欄位對應表

    因為我過往相當熟悉 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);
        }
    }
    
  8. 解析 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 工具程式,可以用命令列的方式查詢假期資訊!👍

相關連結