ASP.NET Core 6 API 後端分頁示範(Entity Framework Core)

筆者有遇過一個情況,某個 API 會把資料搜尋出來後全部吐到前端,不管資料庫需要查詢多久,也不管 API 資料傳輸需要多久,反正就是通通都給你了。而且因為是 log 紀錄,系統上線越久,資料量越來越大,最後資料庫 timeout,無法取得資料。
這個案例最大的問題就是沒有限制單次取回的資料筆數,很容易被攻擊,也很浪費系統效能,甚至讓直接無法正常取得資料。最好的方式就是做後端分頁,每頁要顯示多少我就給你幾筆資料,而不是在前端取得資料後才為了顯示方便做前端分頁。

一般做後端分頁的 API 除了原本查詢出的資料清單外,還會給予資料總筆數、總頁數、當前頁碼等,方便使用者接續取得資料。

原始範例資料:
    
[
  {
    "Id": 1,
    "Name": "Ruyut"
  },
  {
    "Id": 2,
    "Name": "小明"
  }
]
    

加上分頁資訊後的範例資料:
    
{
  "TotalCount": 5,
  "PageCount": 2,
  "CurrentPage": 1,
  "Data": [
    {
      "Id": 1,
      "Name": "Ruyut"
    },
    {
      "Id": 2,
      "Name": "小明"
    }
  ]
}
    

如果每次要回應時都需要加上這幾個屬性很麻煩,這些最好抽取出來,讓這些屬性能夠一直復用,所以我們建立一個回應訊息的泛型類別,用來儲存分頁資訊:
    
/// <summary>
/// 分頁資料
/// </summary>
public class PagedList<T>
{
    /// <summary>
    /// 總筆數
    /// </summary>
    public int TotalCount { get; set; }

    /// <summary>
    /// 總頁數
    /// </summary>
    public int PageCount { get; set; }

    /// <summary>
    /// 當前頁碼
    /// </summary>
    public int CurrentPage { get; set; }

    public List<T> Data { get; set; } = new();
}
    

而實際的內容在本範例不太重要,我們就用個簡單的類別:
    

public class UserDto
{
    public int Id { get; set; }
    public int Name { get; set; }
}
    

那在呼叫 API 時要使用哪些資訊查詢呢?基本上就只要第幾頁和每頁的數量即可。只是我們這裡多加兩個限制: 使用者最多只能取得 1,000 筆資料,避免造成過大的伺服器負擔。頁碼也不能小於 1 。並且如果沒有輸入每頁數量,就會自動視為請求 100 筆。
    
/// <summary>
/// 分頁資料 傳入參數
/// </summary>
public class PaginationDto
{
    private const int MaxPageSize = 1000;
    private const int DefaultPageSize = 100;

    private int _page = 1;
    private int _pageSize = DefaultPageSize;

    /// <summary>
    /// 頁碼
    /// </summary>
    public int Page
    {
        get => _page;
        set => _page = value < 1 ? 1 : value;
    }

    /// <summary>
    /// 每頁數量
    /// </summary>
    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = value > MaxPageSize ? MaxPageSize : value < 1 ? DefaultPageSize : value;
    }
}
    

本文為了偷懶,沒有 Service 類別,所有查詢內容都直接放在 Controller 中,如果時間允許請記得分層。
這裡是使用 Entity Framework Core 做示範,假設有個 users 的資料表,並且資料庫連接物件名稱叫做 ApplicationDbContext ,如果讀者在測試時沒有找到 _context.Users 就代表你沒有這個資料表,請替換為自己的資料表對應的物件
下方的程式碼中很奇怪的限制了 id 需要相等才會查出來資料,這是為了示範有限制搜尋條件的時候程式碼該如何撰寫,因為我們需要計算總筆數和實際取得資料,難道要寫兩次 where ?只要善用 queryable 就可以只寫一次條件了,於是本範例才會加上這個 where。
    
using ly_odi.Data.Contexts;
using ly_odi.Dto;
using Microsoft.AspNetCore.Mvc;


[ApiController]
[Route("user")]
public class UserController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public UsersController(ApplicationDbContext context)
    {
        _context = context;
    }
    
    [HttpGet("{id:int}")]
    public ActionResult<PagedList<UserDto>> Get(
        [FromRoute] int id, [FromQuery] PaginationDto dto)
    {
        // 假設有要限制查詢條件的檢查範例
        if (!_context.Users.Any(x => x.Id == id)) throw new Exception("id 不存在");

        var queryable = _context.Users
            .Where(x => x.Id == id)
            // 如果有其他搜尋條件請寫在這裡
            ;
        
        var totalCount = queryable.Count(); // 計算總筆數

        // 計算總頁數
        var pageCount = (int)Math.Ceiling((double)totalCount / dto.PageSize);

        var list = queryable
            .OrderByDescending(x => x.Id) // 依照 Id 降冪排序
            .Skip((dto.Page - 1) * dto.PageSize) // 跳過前面頁數的筆數
            .Take(dto.PageSize) // 限制單次存取的筆數
            .Select(x => new UserDto // 將資料庫查詢結果轉換成 DTO
            {
                Id = x.Id,
                Name = x.Name,
            })
            .ToList();

        return Ok(
            new PagedList<UserDto> // 回傳分頁資訊和查詢結果
            {
                TotalCount = totalCount,
                PageCount = pageCount,
                CurrentPage = dto.Page,
                Data = list
            }
        );
    }
}
    

留言