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

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

![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/fb069c6f574a5d4e5223-201479869-7f699895-e81b-4698-b3d6-47cfc47be823.png)

### 分析資料來源

由於要介接[臺北市政府行政機關辦公日曆表](https://data.taipei/dataset/detail?id=c30ca421-d935-4faa-b523-9c175c8de738)資料，我發現了許多問題，前陣子在我的[粉絲團分享部分問題](https://www.facebook.com/will.fans/posts/pfbid02XVbTec1s78xTPvzjkcj3XZhHzrd3W1d2LfurWyt8neJiDaZwPfSiJMsXaDeQ9NSPl)，還獲得大家不少關注，留言蠻有趣的，大家可以看看。

[臺北市資料大平臺](https://data.taipei/)的[臺北市政府行政機關辦公日曆表](https://data.taipei/dataset/detail?id=c30ca421-d935-4faa-b523-9c175c8de738)資料，主要提供兩種格式，我分別針對這兩種格式的問題做出以下整理：

-   JSON
    
    官方文件提供的 API 參數有三個，其中 `q` 完全沒作用，沒有任何搜尋能力，也無法篩選日期。另外兩個 `limit` 與 `offset` 是可以運作的，而 `limit` 參數上限 `1,000` 筆資料，這份資料集總筆數為 `1,316` 筆，所以你用 1 個 HTTP Request 是無法拿到完整資料集的。
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/06583645f8c7af0390e4-201382849-09de7c05-6bd4-462a-a858-fa0f51b193bb.png)
    
    這份資料集並沒有明確的生命週期政策，從開發者的角度來看，並無法得知舊的資料是否會刪除，因此 `offset` 參數相當不可靠，他無法幫助我精準篩選出「當天」的資料，因此我被迫要下載所有資料，才有可能順利篩選出我要的結果，也就是「當天是否為放假日」的資訊。
    
    其實我最需要的功能，是要找到「當天」是否有放假而已，並不需要完整的資料集。但由於 API 的基本限制，我目前必須要發出 2 個 Requests 才能取得完整資料，而在幾年後，可能會變成要下載 3 個 Requests 才能取得完整資料，依此類推。整體來說，我覺得 DX (開發者體驗) 相當不好。
    
-   CSV
    
    官方提供的 CSV 資料並沒有任何搜尋能力，就是很單純的資料下載而已，但下載時只要用 1 個 Request 就可以下載到完整資料集，因此相當方便。
    
    不過，我發現官方提供的 CSV 資料集，在下載的時候，他們的 HTTP Headers 有出現一些錯誤的實作。我當天[在粉絲團分享的圖片](https://www.facebook.com/will.fans/posts/pfbid02XVbTec1s78xTPvzjkcj3XZhHzrd3W1d2LfurWyt8neJiDaZwPfSiJMsXaDeQ9NSPl)就有點出一些地方，北市府資訊局在當天晚上就已經將 `Content-Type` 的 `charset` 問題與顯示 Server 與 PHP 版本的問題修復，相當有效率。不過還有一些其他地方至今尚未修復，我特別再整理一下目前的問題：
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/c1e9b68cf6c65fe26f8b-201387721-58388dba-df09-4ab2-844b-8fbf3f108e13.png)
    

### 實作 CSV 解析程式

其實 CSV 是一種相當古老的格式，他甚至有一份 [RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://www.rfc-editor.org/rfc/rfc4180) 規格，清楚的定義出 CSV 應該如何呈現。

很多人不知道的是，網路上有太多不照規定走的 CSV 資料，像是資料中有逗號(`,`)，資料中有「斷行符號」怎麼辦，每一筆資料到底該用 `CRLF` 斷行，還是用 `LF` 斷行，不知道有這份 [RFC 4180](https://www.rfc-editor.org/rfc/rfc4180) 存在的人，基本上就是自己亂寫一通了，不符合規定的文件很容易遇到。因此，解析 CSV 文件並不是大家想像中的那麼容易。但對於「公開資料」的平台來說，輸出一份符合 [RFC 4180](https://www.rfc-editor.org/rfc/rfc4180) 標準的 CSV 文件就很重要了。

> [政府資料開放平臺](https://data.gov.tw/) 有明確的 [RFC4180格式](https://data.gov.tw/faqs/630) 文件指出資料品質提升機制，這點還不錯，但實際上有沒有照著規定走，可能還是要實際分析過才知道。至於[臺北市資料大平臺](https://data.taipei/)我就沒看到相關規範，這點就讓人有點擔心了。

本篇文章我會以 [.NET 7](https://get.dot.net/7) 搭配 [CsvHelper](https://www.nuget.org/packages/CsvHelper) 套件來實作，而且會用標準的 [RFC 4180](https://www.rfc-editor.org/rfc/rfc4180) 規範來解析 CSV 資料！

1.  先克服 `BIG5` 編碼問題
    
    由於從 .NET Core 1.0 開始，就沒有內建 BIG5 編碼的 `Encoding` 資料，所以你必須額外安裝 [System.Text.Encoding.CodePages](https://www.nuget.org/packages/System.Text.Encoding.CodePages) 套件才行。
    
    ```bash
    dotnet add package System.Text.Encoding.CodePages
    ```
    
    以下是主要程式碼：
    
    ```csharp
    Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
    var encoding = Encoding.GetEncoding("BIG5");
    ```
    
2.  安裝 [CsvHelper](https://www.nuget.org/packages/CsvHelper) 套件
    
    ```bash
    dotnet add package CsvHelper
    ```
    
3.  定義一份 `CsvConfiguration` 物件
    
    這個步驟相當重要，因為這是 [CsvHelper](https://www.nuget.org/packages/CsvHelper) 套件在對 CSV 資料進行讀取與寫入的重要設定！
    
    ```csharp
    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 物件！
    
    ```csharp
    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 資料](https://data.taipei/api/frontstage/tpeod/dataset/resource.download?rid=29d9771d-c0ee-40d4-8dfb-3866b0b7adaa)前幾行的內容：
    
    ```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](https://www.nuget.org/packages/CsvHelper) 套件的一個地方，就是他可以幫助我用「強型別」的方式解析 CSV 資料，最終的程式碼會非常漂亮，且完全不用面對複雜的 CSV 格式！
    
    我們先定義資料模型類別，但千萬不要想著將所有欄位都設定為 `string` 型別，我們把型別都定義清楚，不要管原始資料格式為何，那是之後才要煩惱的問題：
    
    ```csharp
    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](https://www.nuget.org/packages/CsvHelper) 套件有著非常強大的型別轉換架構，以支持強型別所需的型別轉換需求。除了已經內建許多 [Type Conversion](https://joshclose.github.io/CsvHelper/examples/type-conversion/) 類別外，針對一些特別的資料轉換需求，你可以透過繼承 `DefaultTypeConverter` 來實作自訂的型別轉換。
    
    我們需要將 `IsHoliday` 欄位的 "是" 與 "否" 等 `string` 型別，轉換為 `bool` 型別，所以我們可以這樣定義：
    
    ```csharp
    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` 型別，所以我們可以這樣定義：
    
    ```csharp
    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](https://www.nuget.org/packages/CsvHelper) 套件在做欄位對應時，所採用的寫法與 Entity Framework 極為相似，所有看到程式碼會有種相當熟悉的感覺。
    
    以下程式碼我想應該也是非常容易理解才對：
    
    ```csharp
    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>` 型別的資料，這段程式真的相當漂亮！👍
    
    ```csharp
    csv.Context.RegisterClassMap<HolidayMap>();
    
    IEnumerable<Holiday> data = csv.GetRecords<Holiday>();
    ```
    

### 總結

我有將本次文章的完整原始碼放到 [LINQPad](https://www.linqpad.net/) 的 Instant Share 平台，你可以用以下網址取得原始碼：[http://share.linqpad.net/r7q2on.linq](http://share.linqpad.net/r7q2on.linq)

我另外還用 .NET 7 做一個 [HolidayChecker](https://github.com/doggy8088/HolidayChecker) 工具程式，可以用命令列的方式查詢假期資訊！👍

### 相關連結

-   [臺北市政府行政機關辦公日曆表 | 臺北市資料大平臺](https://data.taipei/dataset/detail?id=c30ca421-d935-4faa-b523-9c175c8de738)
-   [RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://www.rfc-editor.org/rfc/rfc4180)
-   [CsvHelper | A .NET library for reading and writing CSV files. Extremely fast, flexible, and easy to use.](https://joshclose.github.io/CsvHelper/)
    -   [Getting Started | CsvHelper](https://joshclose.github.io/CsvHelper/getting-started/)
    -   GitHub: [https://github.com/JoshClose/CsvHelper](https://github.com/JoshClose/CsvHelper)
-   [Nullable reference types | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references?WT.mc_id=DT-MVP-4015686)
    -   [可為 Null 的參考型別](https://learn.microsoft.com/zh-tw/dotnet/csharp/nullable-references?WT.mc_id=DT-MVP-4015686)
    -   [C# 8 的 Nullable Reference Types - Huan-Lin 學習筆記](https://www.huanlintalk.com/2020/03/csharp-8-nullable-reference-types.html)
-   [DateOnly 結構 (System) | Microsoft Learn](https://learn.microsoft.com/zh-tw/dotnet/api/system.dateonly?view=net-7.0&WT.mc_id=DT-MVP-4015686)
-   [Configuring launch.json for C# debugging](https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md)
-   [RFC4180格式 | 政府資料開放平臺](https://data.gov.tw/faqs/630)
