API & SDK Design #1, 資料分頁的處理方式

API & SDK Design #1, 資料分頁的處理方式

範例 Data API Service: Server Side Data Paging

這篇的應用範例,我從內政部的 政府資料開放平台 找了一個 範例 來當資料庫, 示範這樣的 API service 該如何設計,以及能動之後,怎麼樣的設計才是良好的 API service ? 觀察過很多台灣的 團隊,往往在這些實作的層面沒有仔細考量,造成維護上的困難。

API 的生態,跟應用軟體的生態不大一樣。很多老闆都會講服務應該快速推出,快速驗證市場需求;這是對的。不過 API 這種東西 的訴求就完全不同,它的使用對象不是 End User, 而是 Developer. Developer 在意的不是 UX (User Experience, 使用者經驗), 而是 DX (Developer Experience - 開發者體驗) 啊.. DX 講求的是文件、API、SDK、及你提供的服務在 developer 眼裡看起來是否夠優雅? 效能夠好? 穩定可靠? 這篇我主要就是 要探討 DX,因此重點會在 API 的定義跟 SDK 的包裝方式。

前面提到的 data service, 我想會 .NET 的人應該都沒問題吧? 開個 ASP.NET MVC WebAPI 的專案就可以搞定了。這邊我就直接 跳到第一版,提供其他的開發人員查詢台灣鳥類生態觀察的資料。不多說,直接看 code:

接下來會有幾篇文章要延續這個範例,code 會不斷的修正。 若要參考這篇文章提到的 sample code, 請參考 dev-API 這個分支。 不介意的話,請給我個 star 鼓勵一下 :D

DATA FORMAT 說明

為了簡化問題,我沒有使用 database, 也沒使用 entity framework, 直接到政府的開放資料網站, 下載了這份 json 格式的 “特生中心102年繁殖鳥大調查資料集” 資料當作範例。檔案放在 ~/App_Data/birds.json, 我貼兩筆資料給大家看一下格式:

[
  {
    "SerialNo": "40298",
    "SurveyDate": "2013-06-21",
    "Location": "玉山西峰下",
    "WGS84Lon": "120.939592",
    "WGS84Lat": "23.468244",
    "FamilyName": "Paradoxornithidae",
    "ScienceName": "Fulvetta formosana",
    "TaiBNETCode": "425189",
    "CommonName": "灰頭花翼",
    "Quantity": "1",
    "BirdId": "B0364",
    "SiteId": "C37-02-06"
  },
  {
    "SerialNo": "40297",
    "SurveyDate": "2013-06-21",
    "Location": "玉山西峰下",
    "WGS84Lon": "120.939592",
    "WGS84Lat": "23.468244",
    "FamilyName": "Fringillidae",
    "ScienceName": "Pyrrhula erythaca",
    "TaiBNETCode": "380359",
    "CommonName": "灰鷽",
    "Quantity": "1",
    "BirdId": "B0516",
    "SiteId": "C37-02-06"
  }
  // 後面還有 998 筆資料...
]

API CODE (SERVER) 說明

我開了一個 “Azure Web APP” 類型的 ASP.NET Web Application, 其實就是拿掉大部分用不到的 code, 只保留 webapi 需要的部份而已。它的好處除了輕快之外,能夠在一般的 windows server 上面執行,也可以直接當成 Azure 的 API App 丟上雲端 Hosting。實際程式碼我有丟上 GitHub , 請參考 Demo.ApiWeb 這個 project. 其中關鍵的 ApiController: BirdsController.cs 內容如下:

public class BirdsController : ApiController
{
    protected override void Initialize(HttpControllerContext controllerContext)
    {
        BirdInfo.Init(File.ReadAllText(System.Web.HttpContext.Current.Server.MapPath("~/App_Data/birds.json")));
        base.Initialize(controllerContext);
    }

    public void Head()
    {
        System.Web.HttpContext.Current.Response.AddHeader("X-DATAINFO-TOTAL", BirdInfo.Data.Count().ToString());
        return;
    }

    private const int MaxTake = 10;

    public IEnumerable<BirdInfo> Get()
    {
        int start, take;
        if (int.TryParse(this.GetQueryString("$start"), out start) == false) start = 0;
        if (int.TryParse(this.GetQueryString("$take"), out take) == false) take = MaxTake;

        if (take > MaxTake) take = MaxTake;

        System.Web.HttpContext.Current.Response.AddHeader("X-DATAINFO-TOTAL", BirdInfo.Data.Count().ToString());
        System.Web.HttpContext.Current.Response.AddHeader("X-DATAINFO-START", start.ToString());
        System.Web.HttpContext.Current.Response.AddHeader("X-DATAINFO-TAKE", take.ToString());

        IEnumerable<BirdInfo> result = BirdInfo.Data;
        if (start > 0) result = result.Skip(start);
        result = result.Take(take);

        return result;
    }
    
    // GET api/values/5
    public BirdInfo Get(string id)
    {
        return BirdInfo.Get(id);
    }

    private string GetQueryString(string name)
    {
        foreach(var pair in this.Request.GetQueryNameValuePairs())
        {
            if (pair.Key == name) return pair.Value;
        }

        return null;
    }
}

API 呼叫方式說明

我示範的 API service, 只提供兩個功能,傳回格式統一為 JSON:

  1. URL:

    ~/api/birds?$start={start}&$take={take}

    列舉所有的資料

    :

    每次最多傳回 10 筆資料。可用兩個選用的參數指定傳回資料的範圍:

    • $start 從第幾筆開始回傳 (預設值 0)

    • $take 傳回幾筆 (預設值 10, 最大值 10) 除了傳回 JSON 格式的資料之外,也會在 HTTP response header 標註附加資訊:

    • X-DATAINFO-TOTAL: 標示所有的資料共有幾筆

    • X-DATAINFO-START: 標示傳回的資料是從第幾筆開始

    • X-DATAINFO-TAKE: 標示傳回的資料最大筆數 另外,除了 GET 之外,也支援 HEAD 這 verb, 不會傳回資料,但是會傳回 header, 這情況下只會傳回 X-DATAINFO-TOTAL

  2. URL: ~/api/birds/{birdid} 直接傳回指定 ID 的那筆資料: 其實這些功能,用 Entity Framework, 加上 OData 就全搞定了,不過這邊這樣做下去就沒意思了,所以我特地 簡化問題,讓大家看看怎麼自己刻出這功能。研究可以,正式上線的系統還是採用 OData 比較合適..

APP CODE 說明 (直接使用 HttpClient)

我寫了一個 Console Application, 請參考 Demo.Client.ConsoleApp 這個 project。 從 Server 用上面說明的 API,自己用 HttpClient 按照規格呼叫,寫了一個 把全部資料一頁一頁撈回來,自己過濾,只列出觀察地點是 “玉山排雲山莊” 的野生鳥類紀錄資料出來。由於沒有 server side query support, 所以過濾機制是在 client 做的,每次都必須從頭到尾掃描所有的資料。

我從內政部抓來的資料,剛剛好有 1000 筆,client / server 都在我 local pc 上面 (server 用 iis-express) 執行, 跑出來約要 3000 msec 左右。先來看 code:

static void Main(string[] args)
{
    Stopwatch timer = new Stopwatch();
    timer.Start();

    // 方法1: 直接用 HttpClient 呼叫 web api
    ListAll_DirectHttpCall();
    
    Console.WriteLine($"* Total Time: {timer.ElapsedMilliseconds} msec.");
}

static Dictionary<string, string> _columns_name = new Dictionary<string, string>()
{
    { "SerialNo",       "流水號" },
    { "SurveyDate",     "調查日期" },
    { "Location",       "調查地點" },
    { "WGS84Lon",       "經度" },
    { "WGS84Lat",       "緯度"},
    { "FamilyName",     "科名"},
    { "ScienceName",    "學名" },
    { "TaiBNETCode",    "中研院學名代碼" },
    { "CommonName",     "鳥中名"},
    { "Quantity",       "數量"},
    { "BirdId",         "鳥名代碼" },
    { "SiteId",         "調查站碼"}
};

/// <summary>
/// 一般寫法,直接呼叫 HttpClient 分多次讀取資料分頁
/// </summary>
static void ListAll_DirectHttpCall()
{
    HttpClient client = new HttpClient();
    client.BaseAddress = new Uri("http://localhost:56648");
    
    int current = 0;
    int pagesize = 5;

    do
    {
        Console.WriteLine($"[info] loading data... ({current} ~ {current + pagesize}) ---");
        HttpResponseMessage result = client.GetAsync($"/api/birds?$start={current}&$take={pagesize}").Result;

        var result_objs = JsonConvert.DeserializeObject<Dictionary<string, string>[]>(result.Content.ReadAsStringAsync().Result);


        foreach (var item in result_objs)
        {
            // filter: 調查地點=玉山排雲山莊
            if (item["Location"] != "玉山排雲山莊") continue;
            ShowBirdInfo(item);
        }

        if (result_objs.Length == 0) break;
        if (result_objs.Length < pagesize) break;

        current += pagesize;
    } while (true);
}

static void ShowBirdInfo(Dictionary<string, string> birdinfo)
{
    Console.WriteLine("[ID: {0}] -------------------------------------------------------------", birdinfo["BirdId"], birdinfo["CommonName"]);
    foreach (string name in _columns_name.Keys)
    {
        Console.WriteLine(
            "{0}: {1}",
            _columns_name[name].PadLeft(10, ' '),
            birdinfo.ContainsKey(name) ? (birdinfo[name]) : ("<NULL>"));
    }
    Console.WriteLine();
    Console.WriteLine();

}

主程式 ListAll_DirectHttpCall() 中,那個關鍵的 do while loop,共有 20 行,除了花 6 行是印出資料用的之外,其他其實都在處理 分頁的動作,以及篩選出符合條件的邏輯。老實說我最討厭這種 code style, 因為不同目的的 code 都被摻在一起了… 我不要做撒尿牛丸啊..

APP CODE 說明 (使用 C# yield return)

不知還有沒有讀者記得我好幾年前寫的 “C# yield, how it work?” 的文章? Orz, 看看日期已經有八年了… 我一直覺的 C# 很多語法甜頭是很實用的,yield return 就是 我最愛用的一個,不但可以解決多執行緒的一些困難,這次連這種遠端 server paging 的問題也能妥善處理.. 上述的主程式 ListAll_DirectHttpCall() 我換個方式重寫一次,來看看改寫過的 code:

static void ListAll_UseYield()
{
    // filter: 調查地點=玉山排雲山莊
    foreach (var item in (from x in GetBirdsData() where x["Location"] == "玉山排雲山莊" select x))
    {
        ShowBirdInfo(item);
    }
}

static IEnumerable<Dictionary<string, string>> GetBirdsData()
{
    HttpClient client = new HttpClient();
    client.BaseAddress = new Uri("http://localhost:56648");

    int current = 0;
    int pagesize = 5;

    do
    {
        Console.WriteLine($"--- loading data... ({current} ~ {current + pagesize}) ---");
        HttpResponseMessage result = client.GetAsync($"/api/birds?$start={current}&$take={pagesize}").Result;

        var result_objs = JsonConvert.DeserializeObject<Dictionary<string, string>[]>(result.Content.ReadAsStringAsync().Result);

        foreach (var item in result_objs)
        {
            //Console.WriteLine("ID: {0}", item["BirdId"]);
            yield return item;
        }

        if (result_objs.Length == 0) break;
        if (result_objs.Length < pagesize) break;

        current += pagesize;
    } while (true);

    yield break;
}

眼尖的讀者朋友們,看出差異了嗎? 用行數來看,其實沒省多少… 不過藉由 yield return, 我能夠很漂亮的實作 iterator patterns, 把物件巡覽及物件處理的邏輯清楚的切開..,GetBirdsData() 就只要專心負責取回所有的資料。然而資料該怎麼處理? 就留給 主程式 ListAll_UseYield() 就可以了。

主程式很簡短,只有一個 Linq Query, 查出符合條件的資料,然後用 for-each loop 印出來。乾淨又漂亮的 code,解決了同樣的問題。

改善過的 code, 一眼望去,看起來好像是先把所有資料傳回來,再去一筆一筆過濾? 事實上不是這樣,它依然是按照我們的期望, 一次讀 5 筆,讀完了也處理完了還有需要,才繼續讀後面五筆,直到完成為止。為了確認執行的順序,我做了幾個實驗:

結果觀察 - 觀察 API 呼叫與資料處理的交錯執行狀況

上述的 code 可以看到,在 GetBirdsData() 內每呼叫一次 server API, 就會印一次 “— loading data …”, 然而前端查到 一筆符合的資料,就會印出一筆。我節錄這個 console app 的輸出結果給大家參考:

--- loading data... (75 ~ 80) ---
--- loading data... (80 ~ 85) ---
--- loading data... (85 ~ 90) ---
--- loading data... (90 ~ 95) ---
--- loading data... (95 ~ 100) ---
[ID: B0368] -------------------------------------------------------------
[ID: B0368] -------------------------------------------------------------
--- loading data... (100 ~ 105) ---
[ID: B0364] -------------------------------------------------------------
[ID: B0443] -------------------------------------------------------------
[ID: B0425] -------------------------------------------------------------
[ID: B0404] -------------------------------------------------------------
[ID: B0404] -------------------------------------------------------------
--- loading data... (105 ~ 110) ---
[ID: B0386] -------------------------------------------------------------
[ID: B0511] -------------------------------------------------------------
[ID: B0405] -------------------------------------------------------------
[ID: B0443] -------------------------------------------------------------
[ID: B0368] -------------------------------------------------------------
--- loading data... (110 ~ 115) ---
[ID: B0368] -------------------------------------------------------------
[ID: B0386] -------------------------------------------------------------
[ID: B0405] -------------------------------------------------------------
[ID: B0425] -------------------------------------------------------------
[ID: B0405] -------------------------------------------------------------
--- loading data... (115 ~ 120) ---
[ID: B0386] -------------------------------------------------------------
[ID: B0404] -------------------------------------------------------------
[ID: B0443] -------------------------------------------------------------
[ID: B0425] -------------------------------------------------------------
[ID: B0404] -------------------------------------------------------------
--- loading data... (120 ~ 125) ---

為了節省篇幅,我只節錄部分 (75 ~ 125 的資料區間),同時我把顯示資料內容的部分都拿掉了,只顯示資料的 ID。 仔細看看這段 LOG:

--- loading data... (95 ~ 100) ---
[ID: B0368] -------------------------------------------------------------
[ID: B0368] -------------------------------------------------------------
--- loading data... (100 ~ 105) ---

我們可以看到,呼叫 API 跟處理資料,真的是交錯進行的。GetBirdsData() 查詢到 95 ~ 100 筆的時候,這五筆資料就立即 傳回 for-each loop 處理了,結果其中有兩筆資料符合,被列印了出來。之後處理完畢,接著又繼續 loading 後面五筆 100 ~ 105 的資料..

結果觀察 - 中斷迴圈,資料載入狀況觀察

如果我程式調整一下,只搜尋到我要的那一筆之後就離開 for-each loop,那它會聰明的立即停止後續的 server API 呼叫嗎?

主程式改成這樣再測試一次看看:

static void ListAll_UseYield()
{
    // filter: ID = B0368,找到之後離開 for-each loop
    foreach (var item in (from x in GetBirdsData() where x["SerialNo"] == "40250" select x).Take(1))
    {
        ShowBirdInfo(item);
    }
}

執行的結果:

--- loading data... (0 ~ 5) ---
--- loading data... (5 ~ 10) ---
--- loading data... (10 ~ 15) ---
--- loading data... (15 ~ 20) ---
--- loading data... (20 ~ 25) ---
--- loading data... (25 ~ 30) ---
--- loading data... (30 ~ 35) ---
--- loading data... (35 ~ 40) ---
--- loading data... (40 ~ 45) ---
--- loading data... (45 ~ 50) ---
[ID: B0443] -------------------------------------------------------------
* Total Time: 266 msec.
Press any key to continue . . .

看起來的確很精確的,逐頁讀取資料,逐頁過濾後,我用 Linq 要求只取前面 1 筆 ( .Take(1) ),真的後面的 API 就不會再呼叫了。 這是否是因為我 Linq Query 下的好的關係? 如果我 Query 一樣是查詢所有資料,但是是用 C# code, 在適當時間 break for-each loop, 結果是否會不同?

很簡單,實驗一下就知道了:

static void ListAll_UseYield()
{
    // filter: ID = B0368,找到之後離開 for-each loop
    foreach (var item in (from x in GetBirdsData() where x["SerialNo"] == "40250" select x))
    {
        ShowBirdInfo(item);
        break;
    }
}

結果跟上一段一模一樣,也是達成目的後就退出,沒有絲毫多於的浪費:

--- loading data... (0 ~ 5) ---
--- loading data... (5 ~ 10) ---
--- loading data... (10 ~ 15) ---
--- loading data... (15 ~ 20) ---
--- loading data... (20 ~ 25) ---
--- loading data... (25 ~ 30) ---
--- loading data... (30 ~ 35) ---
--- loading data... (35 ~ 40) ---
--- loading data... (40 ~ 45) ---
--- loading data... (45 ~ 50) ---
[ID: B0443] -------------------------------------------------------------
* Total Time: 271 msec.
Press any key to continue . . .

yield return 應用小結

這邊的案例,我覺得是 C# yield return 的應用上,很經典的一個使用案例。這也是我刻意不用 Microsoft OData 那套做法的目的。通通都包起來的話,各位可能永遠都不曉得原來 C# 的 yield return 這麼好用。而且雖然 OData 是 個標準,但是我相信你也不是每次都能用的到的。有太多 data api, 並沒有按照 OData 的規範去實作,很多例子 就像這次 sample code 一樣,API 有提供分頁功能,但是沒有按照 OData 標準來進行,於是你得像這樣自己實作..

其實這次的實作,原理已經跟 Microsoft 對 OData 的作法很類似了。有差異的部份是,Microsoft 在 Server 端 用的是 IQueryable interface, 而不是 IEnumerable interface. 兩者的差別是,一個可以取得 QueryProvider, 直接給它更明確的查詢條件,而 IEnumerable 則很單純,只能單向的巡覽資料而已,不會有甚麼建立索引,或是查詢 最佳化的機會。

我找兩篇文章給大家參考一下,有這兩個 interface 能力上的差別:

看的出來,實作 IEnumerable interface, 就只能 looping 掃描每一筆資料 (類似 SQL table scan)。而前端的 Linq, Microsoft 也有特別處理, 能夠把 Linq Query 的條件,透過 OData 定義的參數送到後端,直接在 server side query 就過濾掉不必要的 資料,連傳遞到前端都不用了,效率更佳! 因此要是你的使用環境能配合,有機會使用 OData, 不用考慮了, 用就對了!

Last updated