筆者有遇過一個情況,某個 API 會把資料搜尋出來後全部吐到前端,不管資料庫需要查詢多久,也不管 API 資料傳輸需要多久,反正就是通通都給你了。而且因為是 log 紀錄,系統上線越久,資料量越來越大,最後資料庫 timeout,無法取得資料。
這個案例最大的問題就是沒有限制單次取回的資料筆數,很容易被攻擊,也很浪費系統效能,甚至讓直接無法正常取得資料。最好的方式就是做後端分頁,每頁要顯示多少我就給你幾筆資料,而不是在前端取得資料後才為了顯示方便做前端分頁。
一般做後端分頁的 API 除了原本查詢出的資料清單外,還會給予資料總筆數、總頁數、當前頁碼等,方便使用者接續取得資料。
原始範例資料:
加上分頁資訊後的範例資料:
如果每次要回應時都需要加上這幾個屬性很麻煩,這些最好抽取出來,讓這些屬性能夠一直復用,所以我們建立一個回應訊息的泛型類別,用來儲存分頁資訊:
而實際的內容在本範例不太重要,我們就用個簡單的類別:
那在呼叫 API 時要使用哪些資訊查詢呢?基本上就只要第幾頁和每頁的數量即可。只是我們這裡多加兩個限制: 使用者最多只能取得 1,000 筆資料,避免造成過大的伺服器負擔。頁碼也不能小於 1 。並且如果沒有輸入每頁數量,就會自動視為請求 100 筆。
本文為了偷懶,沒有 Service 類別,所有查詢內容都直接放在 Controller 中,如果時間允許請記得分層。
這裡是使用 Entity Framework Core 做示範,假設有個 users 的資料表,並且資料庫連接物件名稱叫做 ApplicationDbContext ,如果讀者在測試時沒有找到 _context.Users 就代表你沒有這個資料表,請替換為自己的資料表對應的物件
下方的程式碼中很奇怪的限制了 id 需要相等才會查出來資料,這是為了示範有限制搜尋條件的時候程式碼該如何撰寫,因為我們需要計算總筆數和實際取得資料,難道要寫兩次 where ?只要善用 queryable 就可以只寫一次條件了,於是本範例才會加上這個 where。
這個案例最大的問題就是沒有限制單次取回的資料筆數,很容易被攻擊,也很浪費系統效能,甚至讓直接無法正常取得資料。最好的方式就是做後端分頁,每頁要顯示多少我就給你幾筆資料,而不是在前端取得資料後才為了顯示方便做前端分頁。
一般做後端分頁的 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
}
);
}
}
留言
張貼留言
如果有任何問題、建議、想說的話或文章題目推薦,都歡迎留言或來信: a@ruyut.com