در این مقاله، میخواهیم یاد بگیریم که چطور صفحه بندی (paging) را در ASP.NET Core Web API انجام دهیم. صفحه بندی، یکی از مهمترین مفاهیم در RESTful API ها است.
در واقع، زمانیکه ما یک کوئری به سمت API میفرستیم، در پاسخ نمیخواهیم که یک کالکشن از منابع را برگردانیم. این میتواند در performance برنامه، مشکلاتی ایجاد کند. به هیچ وجه برای API های public یا private، راهکار بهینه ای نیست. این می تواند در موارد شدید باعث کندی شدید و حتی خرابی برنامه نیز شود.
اگر میخواهید ما را در این مقاله همراهی کنید، میتوانید از نقطه شروع پروژه استفاده کنید و اگر میخواهید solution نهایی را بگیرید یا اگر جایی در پروژه گیر کرده اید میتوانید نقطه انتهایی پروژه را ببینید.
ما میخواهیم در این مورد صحبت کنیم که صفحه بندی و آسانترین راه برای پیاده سازی آن چیست و سپس کمی بیشتر، راه حل مورد نظر را برای ایجاد یک کد خوانا و انعطاف پذیر، بهبود دهیم.
بنابراین، ببینیم دقیقا در مورد چه چیزی قرار است در این مقاله صحبت کنیم:
شروع کنیم.
صفحه بندی چیست؟
صفحه بندی یعنی دریافت نتایج از یک API به طور جزء به جزء. تصور کنید که شما میلیونها رکورد در دیتابیس دارید و برنامه شما میخواهد تمام آنها را در یک زمان برگرداند.
نه تنها این روش برای برگرداندن نتایج، بسیار ناکارآمد است ، بلکه همچنین می تواند تأثیرات مخربی بر روی خود برنامه یا سخت افزاری که آن را اجرا می کند داشته باشد. علاوه بر این، هر کلاینت، منابع حافظه محدودی دارد و نیاز است که بر روی تعداد نتایج برگردانده شده محدودیت اعمال شود.
بنابراین ، ما برای جلوگیری از این عواقب به راهی نیاز داریم که بتوانیم تعداد معینی از نتایج را به کلاینت برگردانیم.
ببینیم چطور میتوانیم این کار را انجام دهیم.
پیاده سازی اولیه
قبل از اینکه تغییری بر روی سورس انجام دهیم، اجازه دهید بررسی کنیم که ساده ترین حالت پیاده سازی به چه صورت است و چطور ممکن است که شما در هر پروژه ای، آن را انجام دهید.
در سناریو ما، ما OwnerController را داریم که شامل تمام اکشنهای لازم برای موجودیت Owner میباشد.
یک مورد ویژه که قابل توجه است و باید آن را تغییر دهیم، اکشن ()GetOwners میباشد:
1 2 3 4 5 6 7 8 9 |
[HttpGet] public IActionResult GetOwners() { var owners = _repository.Owner.GetOwners(); _logger.LogInfo($"Returned all owners from database."); return Ok(owners); } |
که ()GetOwners را از OwnerRepository فراخوانی میکند:
1 2 3 4 5 |
public IEnumerable<Owner> GetOwners() { return FindAll() .OrderBy(ow => ow.Name); } |
متد ()FindAll، فقط یک متد از کلاس Base Repository است که کل مجموعه owner ها را برمیگرداند.
1 2 3 4 |
public IQueryable<T> FindAll() { return this.RepositoryContext.Set<T>(); } |
همانطور که میبینید، این یک اقدام بی چون و چرا است. به این معنی که تمام owner ها را از دیتابیس به ترتیب نام برمیگرداند.
و این فقط همین کار را انجام میدهد.
اما در سناریوی ما فقط پنج owner وجود دارد. اگر هزاران و یا حتی میلیونها شخص در دیتابیس (هر موجودیتی که مدنظرتان است) وجود داشت چی؟ و در آخر، یک چند هزار استفاده کننده از API را هم به آن اضافه کنید.
آنوقت ما با کوئریهای زمانبر که مقدار زیادی داده برمیگرداند روبه رو میشویم.
بهترین حالت این است که شما کار خود را با تعداد کمی از owner ها شروع کنید که با گذشت زمان به آرامی افزایش می یابد سپس می توانید کم کم متوجه کاهش عملکرد در برنامه شوید. سناریوهای دیگر برای برنامه و دستگاه های شما غیرکارآمد هستند (تصور کنید میزبانی در فضای ابری باشد و حافظه کش مناسب نداشته باشد).
بنابراین با این اوصاف، این متد را برای حمایت از صفحه بندی تغییر میدهیم.
پیاده سازی صفحه بندی
توجه داشته باشید، ما قصد نداریم منطق base repository را تغییر دهیم یا هیچ business logic ای را در کنترلر پیاده سازی کنیم.
چیزی که میخواهیم بدست بیاوریم به این صورت است: https://localhost:5001/api/owners?pageNumber=2&pageSize=2. این باید دو owner از دسته دوم از owner ها را که در دیتابیس داریم را برگرداند.
همچنین میخواهیم API مان را محدود کنیم که تمام owner ها را با هم برنگرداند حتی زمانیکه کسی https://localhost:5001/api/owners را فراخوانی میکند.
حالا کارمان را با تغییر کنترلر شروع میکنیم:
1 2 3 4 5 6 7 8 9 |
[HttpGet] public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters) { var owners = _repository.Owner.GetOwners(ownerParameters); _logger.LogInfo($"Returned {owners.Count()} owners from database."); return Ok(owners); } |
چند نکته که باید در اینجا توجه داشته باشید:
- ما داریم متد GetOwners را از OwnerRepository فراخوانی میکنیم که هنوز وجود ندارد، اما به زودی آن را پیاده سازی میکنیم.
- ما داریم از [FromQuery] استفاده میکنیم که دلالت بر این دارد که ما میخواهیم از query parameter ها برای مشخص کردن اینکه کدام صفحه و چه تعداد owner را داریم مورد درخواست قرار میدهیم استفاده کنیم.
- کلاس OwnerParameters دربرگیرنده پارامترهای مورد نظر است.
بنابراین چون کلاس OwnerParameters را داریم به عنوان یک آرگومان به کنترلرمان پاس میدهیم باید در واقع آن را ایجاد کنیم. آن را در پوشه Models در پروژه Entities ایجاد میکنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class OwnerParameters { const int maxPageSize = 50; public int PageNumber { get; set; } = 1; private int _pageSize = 10; public int PageSize { get { return _pageSize; } set { _pageSize = (value > maxPageSize) ? maxPageSize : value; } } } |
ما داریم از مقدار ثابت maxPageSize برای محدود کردن API مان به حداکثر 50 owner استفاده میکنیم. ما دو خصوصیت public داریم – PageNumber و PageSize. اگر این دو خصوصیت توسط کسی که کوئری را فراخوانی میکند تعیین نشود، آنگاه PageNumber با 1 و PageSize با 10 ست میشود.
حال مهمترین قسمت یعنی منطق repository را پیاده سازی میکنیم.
ما باید متد ()GetOwners در اینترفیس IOwnerRepository و کلاس OwnerRepository را توسعه دهیم:
1 2 3 4 5 6 7 8 9 |
public interface IOwnerRepository : IRepositoryBase<Owner> { IEnumerable<Owner> GetOwners(OwnerParameters ownerParameters); Owner GetOwnerById(Guid ownerId); OwnerExtended GetOwnerWithDetails(Guid ownerId); void CreateOwner(Owner owner); void UpdateOwner(Owner dbOwner, Owner owner); void DeleteOwner(Owner owner); } |
و logic:
1 2 3 4 5 6 7 8 |
public IEnumerable<Owner> GetOwners(OwnerParameters ownerParameters) { return FindAll() .OrderBy(on => on.Name) .Skip((ownerParameters.PageNumber - 1) * ownerParameters.PageSize) .Take(ownerParameters.PageSize) .ToList(); } |
خب، راحترین راه برای تشریح کد، ذکر یک مثال میباشد.
فرض کنید که میخواهیم نتایج را از صفحه سوم وبسایتمان و به تعداد 20 تا بدست آوریم. این به این معنی است که میخواهیم ((3 – 1) * 20) = 40 نتیجه اول را رد کنیم و سپس 20 تای بعدی را گرفته و به caller برگردانیم.
آیا قابل درک است؟
تست solution
حالا در دیتابیسمان، فقط چند owner داریم. بنابراین اجازه بدید این Api زیر را فراخوانی کنیم:
1 |
https://localhost:5001/api/owners?pageNumber=2&pageSize=2 |
این باید دومین زیرمجموعه از owner ها را به تعداد 2 owner برگرداند:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[ { "id": "66774006-2371-4d5b-8518-2177bcf3f73e", "name": "Nick Somion", "dateOfBirth": "1998-12-15T00:00:00", "address": "North sunny address 102" }, { "id": "a3c1880c-674c-4d18-8f91-5d3608a2c937", "name": "Sam Query", "dateOfBirth": "1990-04-22T00:00:00", "address": "91 Western Roads" } ] |
اگر این همان چیزی است که شما هم دریافت کردید، پس کارتان را درست انجام داده اید.
حالا چه کاری میتوانیم برای بهبود این solution انجام دهیم؟
بهبود Solution
از آنجا که ما فقط زیرمجموعه ای از نتایج را به caller بر می گردانیم ، ممکن است به جای List، یک PagedList داشته باشیم.
PagedList از کلاس List ارث بری میکند و ویژگیهای دیگری نیز به آن اضافه میکنیم. همچنین میتوانیم منطق skip/take را به PagedList انتقال دهیم، زیرا این منطقیتر به نظر میرسد.
پس آن را پیاده سازی میکنیم.
پیاده سازی کلاس PagedList
ما نمیخواهیم منطق skip/take در repository مان پیاده سازی شود. پس یک کلاس برای آن ایجاد میکنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
public class PagedList<T> : List<T> { public int CurrentPage { get; private set; } public int TotalPages { get; private set; } public int PageSize { get; private set; } public int TotalCount { get; private set; } public bool HasPrevious => CurrentPage > 1; public bool HasNext => CurrentPage < TotalPages; public PagedList(List<T> items, int count, int pageNumber, int pageSize) { TotalCount = count; PageSize = pageSize; CurrentPage = pageNumber; TotalPages = (int)Math.Ceiling(count / (double)pageSize); AddRange(items); } public static PagedList<T> ToPagedList(IQueryable<T> source, int pageNumber, int pageSize) { var count = source.Count(); var items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList(); return new PagedList<T>(items, count, pageNumber, pageSize); } } |
همانطور که میبینید، ما منطق skip/take را به متد static داخل کلاس PagedList انتقال داده ایم. ما چند ویژگی دیگر اضافه کرده ایم که به عنوان metadata برای response مان مفید خواهد بود.
true ،HasPrevious است اگر CurrentPage بزرگتر از 1 باشد و اگر CurrentPage
نیز از total pages(تعداد کل صفحات) کوچکتر باشد، مقدار true ،HasNext میشود. همچنین TotalPages با تقسیم تعداد item ها بر page size و گرد شدن آن به عدد بزرگتر محاسبه میشود، زیرا تعداد 1 صفحه حتما باید وجود داشته باشد حتی اگر یک item فقط موجود باشد.
حال که این جریان را برای خودمان شفاف سازی کردیم، OwnerRepository و OwnerController را متناسب با آن تغییر میدهیم.
اول باید repo را تغییر دهیم (فراموش نکنید که اینترفیس را نیز تغییر دهید):
1 2 3 4 5 6 |
public PagedList<Owner> GetOwners(OwnerParameters ownerParameters) { return PagedList<Owner>.ToPagedList(FindAll().OrderBy(on => on.Name), ownerParameters.PageNumber, ownerParameters.PageSize); } |
و سپس کنترلر:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[HttpGet] public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters) { var owners = _repository.Owner.GetOwners(ownerParameters); var metadata = new { owners.TotalCount, owners.PageSize, owners.CurrentPage, owners.TotalPages, owners.HasNext, owners.HasPrevious }; Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata)); _logger.LogInfo($"Returned {owners.TotalCount} owners from database."); return Ok(owners); } |
حالا اگر این درخواست https://localhost:5001/api/owners?pageNumber=2&pageSize=2 که پیشتر فرستادیم را مجددا بفرستیم، دقیقا همان نتیجه را دریافت میکنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[ { "id": "f98e4d74-0f68-4aac-89fd-047f1aaca6b6", "name": "Martin Miller", "dateOfBirth": "1983-05-21T00:00:00", "address": "3 Edgar Buildings" }, { "id": "66774006-2371-4d5b-8518-2177bcf3f73e", "name": "Nick Somion", "dateOfBirth": "1998-12-15T00:00:00", "address": "North sunny address 102" } ] |
اما اینبار، کمی اطلاعات مفید دیگری در X-Pagination
response header نیز داریم:
همانطور که میبینید، کل metadata ما اینجاست. ما میتوانیم هنگام ایجاد صفحه بندی در frontend، از این اطلاعات استفاده کنیم. می توانید با درخواست های مختلف بازی کنید تا ببینید که در سناریوهای دیگر چگونه کار می کند.
یک چیز دیگر وجود دارد که می توانیم انجام دهیم تا این solution ما عمومی تر شود. ما کلاس OwnerParameters را داریم، اما اگر بخواهیم این solution را در AccountController مان میز استفاده کنیم چاره چیست؟ پارامترهایی که ما به سمت Account controller میفرستیم ممکن است متفاوت باشد. شاید نه برای صفحه بندی، اما بعدا یک سری پارامترهای مختلف را به سمت آن میفرستیم و باید parameter classe ها را از هم تفکیک کنیم.
ببینیم چطور آن را بهبود میدهیم.
ایجاد یک کلاس پارامتر والد
اول، یک کلاس abstract QueryStringParameters ایجاد میکنیم. ما از این کلاس برای پیاده سازی ویژگیهای مشترک برای تمام کلاس پارامترهایی که پیاده سازی میکنیم استفاده میکنیم و چون OwnerController
و AccountController
را داریم به این معنی است که باید کلاسهای OwnerParameters
و AccountParameters
را ایجاد کنیم.
با تعریف کلاس QueryStringParameters داخل پوشه Models در پروژه Entities شروع میکنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public abstract class QueryStringParameters { const int maxPageSize = 50; public int PageNumber { get; set; } = 1; private int _pageSize = 10; public int PageSize { get { return _pageSize; } set { _pageSize = (value > maxPageSize) ? maxPageSize : value; } } } |
ما همچنین منطق صفحه بندی خود را به داخل این کلاس منتقل کرده ایم زیرا این برای هر موجودیتی که بخواهیم از طریق repository برگردانیم معتبر خواهد بود.
اول باید کلاس AccountParameters را ایجاد کنیم و سپس کلاس QueryStringParameters را در هردو کلاسهای OwnerParameters و AccountParameters ارث بری کنیم.
logic را از OwnerParameters حذف کنید و از QueryStringParameters ارث بری کنید:
1 2 3 4 |
public class OwnerParameters : QueryStringParameters { } |
و کلاس AccountParameters را داخل پوشه Models نیز ایجاد کنید:
1 2 3 4 |
public class AccountParameters : QueryStringParameters { } |
اکنون ، این کلاس ها اکنون کمی خالی به نظر می رسند ، اما به زودی آنها را با سایر پارامترهای مفید تکمیل خواهیم کرد و خواهیم دید که مزیت واقعی چیست. در حال حاضر، این مهم است که راهی برای ارسال یک مجموع متفاوت از پارامترها برای AccountController
و OwnerController
داشته باشیم.
حالا همچین solution ای را داخل AccountController مان نیز میتوانیم انجام دهیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[HttpGet] public IActionResult GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters) { var accounts = _repository.Account.GetAccountsByOwner(ownerId, parameters); var metadata = new { accounts.TotalCount, accounts.PageSize, accounts.CurrentPage, accounts.TotalPages, accounts.HasNext, accounts.HasPrevious }; Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata)); _logger.LogInfo($"Returned {accounts.TotalCount} owners from database."); return Ok(accounts); } |
و به دلیل وراثت پارامترهای صفحه بندی از طریق کلاس QueryStringParameters ، رفتار مشابهی را اینجا داریم. همه چیز مرتب و عالی.
همه چیز در مورد صفحه بندی، همین است.
نتیجه گیری
صفحه بندی در ساخت هر API یک مفهوم مفید و مهم است. بدون آن، برنامه ما، احتمالا به طور قابل ملاحظه ای افت سرعت پیدا میکند یا کاملا از کار می افتد.
solution ای که ما پیاده سازی کردیم بی عیب و نقص نیست، به هیچ وجه، اما شما نکته را گرفتید. ما قسمتهای مختلف مکانیسم صفحه بندی را از هم جدا کردیم و می توانیم حتی فراتر برویم و آن را کمی عمومی تر کنیم. اما شما میتوانید آن را به عنوان یک تمرین انجام دهید و آن را در پروژه خودتان پیاده سازی نمایید.
در این مقاله، ما موضوعات زیر را پوشش داده ایم:
- آسانترین راه پیاده سازی صفحه بندی در ASP.NET Core Web API
- تست solution در یک سناریوی واقعی
- بهبود solution با معرفی موجودیت PagedList و جداسازی پارامترهایمان برای کنترلرهای مختلف
امیدوارم شما این مقاله را دوست داشته باشید و چیز جدید و مفیدی را از آن یاد گرفته باشید. در مقاله بعدی، ما فیلترینگ را پوشش میدهیم.