OData武裝你的WEBAPI-分頁查詢

波多爾斯基發表於2020-05-18

本文屬於OData系列

目錄


Introduction

分頁是資料請求避免不了的問題,資料很多的情況下,通過GET請求一次性返回所有的資料,不光效能底下,而且不好展示。

分頁的原理就是客戶端請求伺服器,伺服器返回的資料是有限的資料(限制於pageSize),同時返回一個資料的總量count,方便客戶端進行處理。也有另外一種實現,使用nextlink指示下一頁的位置。

傳統實現

傳統的實現,我比較喜歡LINQ的Skip和Take方法。

/// <summary>
/// 有參GET請求
/// </summary>
/// <returns></returns>
[HttpGet("page")]
[ProducesResponseType(typeof(ReturnData<Page<UserInfoModel>>), Status200OK)]
[ProducesResponseType(typeof(ReturnData<string>), Status404NotFound)]
public async Task<ActionResult> Get(string username, int pageNo, int pageSize)
{
    if (pageSize <= 0 || pageNo <= 0) return BadRequest(new ReturnData<string>("Error request"));
    IEnumerable<UserInfoModel> result;
    if (string.IsNullOrWhiteSpace(username))
        result = _userManager.Users.Select(w => ToUserInfoModel(w)).ToList();
    else
        result = _userManager.Users.Select(w => ToUserInfoModel(w)).ToList().Where(w => w.Username.Contains(username));
    var response = result.Skip((pageNo - 1) * pageSize).Take(pageSize);
    Page<UserInfoModel> page = new Page<UserInfoModel>() { PageNo = pageNo, PageSize = pageSize, Result = response, TotalCount = result.Count() };
    return Ok(new ReturnData<Page<UserInfoModel>>(page));
}

通過傳遞username、pageNo和pageSize即可實現分頁功能。

OData實現分頁

OData查詢不需要後端再自行設計接受引數、實現等內容,並且支援兩種方式實現分頁:客戶端模式和伺服器模式。首先我們需要補補幾個關鍵字的用法:(適用於OData V4)

$count

count關鍵字可以隨同查詢一起使用,使用$count=true的形式即可在查詢結果中追加返回符合查詢條件的所有的記錄的數量。

GET http://localhost:9000/api/devicedatas('ZW000001')?$count=true

注意這裡不是返回的當前結果的計數。

{
    "@odata.context": "http://localhost:9000/api/$metadata#DeviceDatas",
    "@odata.count": 80,
    "value": [
        {
            "id": "554b1ed8-6429-4ad3-83f9-45c7696547e6",
            "deviceId": "ZW000001",
            "timestamp": 1589544960000,
            "dataArray": []
        },
        ...

$skip

skip關鍵字可以指定跳過的記錄數量,使用$skip=10這種形式。

GET http://localhost:9000/api/devicedatas('ZW000001')?$skip=30

返回的結果是跳過了前面的N條記錄。

$top

top關鍵字指定擷取的符合查詢條件中的前n條記錄,使用top=10這種形式。

GET http://localhost:9000/api/devicedatas('ZW000001')?$top=10

$skiptoken

skiptoken這個東西和前面的東西都不一樣。skiptoken必須要伺服器返回,一般來說是伺服器根據主鍵的形式返回結果,然後呼叫方直接呼叫。經常出現在nextlink中,用於伺服器分頁。

GET http://localhost:9000/api/devicedatas('ZW000001')?$skiptoken='554b1ed8-6429-4ad3-83f9-45c7696547e6'

注意這裡不是返回的當前結果的計數。

{
    "@odata.context": "http://localhost:9000/api/$metadata#DeviceDatas",
    "value": [
        {
            "id": "554b1ed8-6429-4ad3-83f9-45c7696547e6",
            "deviceId": "ZW000001",
            "timestamp": 1589544960000,
            "dataArray": []
        },
        ...

客戶端模式

客戶端模式是客戶端主導的分頁實現,分頁的頁數數量之類的,都需要由客戶端指定,對客戶端來說,比較靈活。主要使用到count、skip和top三個關鍵字。

  1. 預設情況,伺服器返回所有的記錄。
  2. 假設按照每頁10條記錄進行分頁,那麼我們首次請求(請求第一頁)應該使用$count=true&$skip=0&$top=10獲取第一頁資料,同時帶有資料計數。
  3. 根據第一次請求獲得資料計數,可以快速計算總共的分頁數量。比如返回count=72,那麼總共的頁數應該是72/10 + 1 =8頁(最後一頁只有2個資料)
  4. 生成每個頁碼的連結,第二頁應該是$count=true&$skip=10&$top=10
GET http://localhost:9000/api/devicedatas('ZW000001')?$count=true&$skip=10&$top=10
  • 這幾條命令需要先啟用,可以在startup.cs中修改:
app.UseMvc(
    routeBuilder =>
    {
        // the following will not work as expected
        // BUG: https://github.com/OData/WebApi/issues/1837
        // routeBuilder.SetDefaultODataOptions( new ODataOptions() { UrlKeyDelimiter = Parentheses } );
        routeBuilder.ServiceProvider.GetRequiredService<ODataOptions>().UrlKeyDelimiter = Parentheses;

        // global odata query options
        //routeBuilder.EnableDependencyInjection();
        routeBuilder.Select().Expand().Filter().OrderBy().MaxTop(600).Count().SkipToken();

        routeBuilder.MapVersionedODataRoutes("odata", "api", modelBuilder.GetEdmModels());
    });

服務端模式

客戶端模式靈活,但是有一個問題不好處理:客戶端在兩次請求的過程中,資料發生了變化,那會遇到一些意想不到的問題,比如說資料刪除了其中的一些,那麼某條資料很有可能會同時出現在兩個頁。因此,可以讓伺服器幫我們做分頁,伺服器管理所有的資料,對兩次請求的資料變化也能及時感知,不會出現這個問題。

服務端模式需要使用到skiptoken和pagesize設定。

服務端模式,客戶端請求集合,伺服器返回部分資料,同時提供一個nextlink,客戶端直接請求這個連結,就可以獲得更多的資料。

skiptoken啟用可以參考上面客戶端模式的程式碼。pagesize是伺服器最多每頁返回多少條資料的設定,可以在上面全域性指定,也可以在具體的方法上面指定。

[ODataRoute]
[EnableQuery(PageSize = 1)]
[ProducesResponseType(typeof(ODataValue<IEnumerable<DeviceInfo>>), Status200OK)]
public IActionResult Get()
{
    return Ok(_context.DeviceInfoes.AsQueryable());
}

試著使用原始的方式進行請求。

GET http://localhost:9000/api/DeviceInfoes?$count=true

返回結果如下,能看到,返回的資料的結尾,多了一個@odata.nextLink,這個直接點選,就可以直接請求下一組資料。在下一組資料中又會有在下一組資料的地址,直到最後一組資料。

{
    "@odata.context": "http://localhost:9000/api/$metadata#DeviceInfoes",
    "@odata.count": 3,
    "value": [
        {
            "deviceId": "ZW000001",
            "name": null,
            "deviceType": null,
            "imagePath": null,
            "layout": []
        }
    ],
    "@odata.nextLink": "http://localhost:9000/api/DeviceInfoes?$count=true&$skiptoken=deviceId-'ZW000001'"
}

注意:

  • 我這裡主鍵使用的是字串型別,並且用的是EF CORE 3.0,直接請求會返回伺服器錯誤,需要自行指定string的比較模式,可以使用AsEnumerable()在System.Linq中處理。如果使用的主鍵是數值型,那麼應該不會有這個問題。參考這裡
  • 可以在請求中同時應用skip等客戶端模式的語法,構造自己需要的資料。

看完伺服器模式,感覺這模式有點僵硬啊,只能一條一條地獲取下一個連結,我要直接跳幾頁的時候怎麼辦呢?

首先你需要了解分頁的模式,我們請求http://services.odata.org/V4/TripPinService/People返回的nextlink會是這樣子的:

"@odata.nextLink": "https://services.odata.org/V4/TripPinService/People?%24skiptoken=8"

我這裡使用到了官方提供的一個地址,返回了8條資料,同時指示了下一個連結的位置,很明顯,這個skiptoken=8是從第9個開始的,因此指定的只是一個開頭的地址,我們可以自行修改成其他數字。(前面說到skiptoken必須要服務生成,指的是後面的查詢模式需要是由伺服器生成。)

那麼對於第三頁就是skiptoken=16。但是由於伺服器指定了分頁的大小8,我們查詢還是不方便,可以通過繼承EnableQueryAttribute實現,將這個[MyEnableQueryAttribute]替代剛剛的[EnableQuery]搬運

public class MyEnableQueryAttribute : EnableQueryAttribute
{
    public override IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions)
    {
        int pagesize = xxx;
        var result = queryOptions.ApplyTo(queryable, new ODataQuerySettings { PageSize = pagesize }); 
        return result;
    }
} 

總結

OData使用客戶端模式的分頁和服務端的分頁都能夠很方便地實現分頁查詢。一個GET查詢全部搞定,梭哈!不要問就是梭!

參考資料

相關文章