مدتی است در اکثر سایتها و طراحیهای جدید، به جای استفاده از روش متداول نمایش انتخاب صفحه 1، 2 ... 100، برای صفحه بندی اطلاعات، از روش اسکرول نامحدود یا infinite scroll استفاده میکنند. نمونهای از آنرا هم در سایت جاری با دکمه «بیشتر» در ذیل اکثر صفحات و مطالب سایت مشاهده میکنید.
در ادامه قصد داریم نحوه پیاده سازی آنرا در ASP.NET MVC به کمک امکانات jQuery بررسی کنیم.
مدل برنامه
در این برنامه و مثال، قصد داریم لیستی از مطالب را توسط اسکرول نامحدود، نمایش دهیم. هر آیتم نمایش داده شده، ساختاری همانند کلاس BlogPost دارد.
منبع داده فرضی برنامه
برای اینکه برنامه نهایی را به سادگی بتوانید اجرا کنید، به عمد از بانک اطلاعاتی خاصی استفاده نشده و صرفا یک منبع داده فرضی تشکیل شده در حافظه، در اینجا مورد استفاده قرار گرفته است. بدیهی است قسمت cachedItems را به سادگی میتوانید با یک ORM جایگزین کنید.
تنها نکته مهم آن، نحوه تعریف متد GetLatestBlogPosts میباشد که برای صفحه بندی اطلاعات بهینه سازی شده است. در اینجا توسط متدهای Skip و Take، تنها بازهای از اطلاعات که قرار است نمایش داده شوند، دریافت میگردد. خوشبختانه این متدها معادلهای مناسبی را در اکثر بانکهای اطلاعاتی داشته و استفاده از آنها بر روی یک بانک اطلاعاتی واقعی نیز بدون مشکل کار میکند و تنها بازه محدودی از اطلاعات را واکشی خواهد کرد که از لحاظ مصرف حافظه و سرعت کار بسیار مقرون به صرفه و سریع است.
کنترلر برنامه
کنترلر برنامه را در اینجا ملاحظه میکنید. برای کار با اسکرول نامحدود، به ازای هر صفحه، نیاز به دو متد است:
الف) یک متد که بر اساس HttpGet کار میکند. این متد در اولین بار نمایش صفحه فراخوانی میگردد و اطلاعات صفحه آغازین را نمایش میدهد.
ب) متد دومی که بر اساس HttpPost کار کرده و محدود است به درخواستیهای AjaxOnly همانند متد PagedIndex.
از این متد دوم برای پردازش کلیکهای کاربر بر روی دکمه «بیشتر» استفاده میگردد. بنابراین تنها کاری که افزونه جیکوئری تدارک دیده شده ما باید انجام دهد، ارسال شماره صفحه است. سپس با استفاده از این شماره، بازه مشخصی از اطلاعات دریافت و نهایتا یک PartialView رندر شده برای افزوده شدن به صفحه بازگشت داده میشود.
دو View برنامه
همانطور که برای بازگشت اطلاعات نیاز به دو اکشن متد است، برای رندر اطلاعات نیز به دو View نیاز داریم:
الف) یک PartialView که صرفا لیستی از اطلاعات را مطابق سلیقه ما رندر میکند. از این PartialView در متد PagedIndex استفاده خواهد شد:
ب) یک View کامل که در بار اول نمایش صفحه، مورد استفاده قرار میگیرد:
چند نکته در اینجا حائز اهمیت است:
1) مسیر دقیق اکشن متد PagedIndex توسط متد Url.Action تهیه شده است.
2) در ابتدای نمایش صفحه، متد Html.RenderPartial کار نمایش اولیه اطلاعات را انجام خواهد داد.
3) از div خالی MoreInfoDiv، به عنوان محل افزوده شدن اطلاعات Ajax ایی دریافتی استفاده میکنیم.
4) دکمه بیشتر در اینجا تنها یک span ساده است که توسط css به شکل یک دکمه نمایش داده خواهد شد (فایلهای آن در پروژه پیوست موجود است).
5) ProgressDiv در ابتدای نمایش صفحه مخفی است. زمانیکه کاربر بر روی دکمه بیشتر کلیک میکند، توسط افزونه جیکوئری ما نمایان شده و در پایان کار مجددا مخفی میگردد.
6) section JavaScript کار استفاده از افزونه InfiniteScroll را انجام میدهد.
و کدهای افزونه اسکرول نامحدود
ساختار افزونه اسکرول نامحدود به این شرح است:
هربار که کاربر بر روی دکمه بیشتر کلیک میکند، progress div ظاهر میگردد. سپس توسط امکانات jQuery Ajax، شماره صفحه (بازه انتخابی) به اکشن متد صفحه بندی اطلاعات ارسال میگردد. در نهایت اطلاعات را از کنترلر دریافت و به moreInfoDiv اضافه میکند. در آخر هم شماره صفحه را یکی افزایش داده و سپس progress div را مخفی میکند.
دریافت مثال و پروژه کامل این قسمت
jQueryMvcSample02.zip
در ادامه قصد داریم نحوه پیاده سازی آنرا در ASP.NET MVC به کمک امکانات jQuery بررسی کنیم.
مدل برنامه
namespace jQueryMvcSample02.Models { public class BlogPost { public int Id { set; get; } public string Title { set; get; } public string Body { set; get; } } }
منبع داده فرضی برنامه
using System.Collections.Generic; using System.Linq; using jQueryMvcSample02.Models; namespace jQueryMvcSample02.DataSource { public static class BlogPostDataSource { private static IList<BlogPost> _cachedItems; /// <summary> /// با توجه به استاتیک بودن سازنده کلاس، تهیه کش، پیش از سایر فراخوانیها صورت خواهد گرفت /// باید دقت داشت که این فقط یک مثال است و چنین کشی به معنای /// تهیه یک لیست برای تمام کاربران سایت است /// </summary> static BlogPostDataSource() { _cachedItems = createBlogPostsInMemoryDataSource(); } /// <summary> /// هدف صرفا تهیه یک منبع داده آزمایشی ساده تشکیل شده در حافظه است /// </summary> private static IList<BlogPost> createBlogPostsInMemoryDataSource() { var results = new List<BlogPost>(); for (int i = 1; i < 30; i++) { results.Add(new BlogPost { Id = i, Title = "عنوان " + i, Body = "متن ... متن ... متن " + i }); } return results; } /// <summary> /// پارامترهای شماره صفحه و تعداد رکورد به ازای یک صفحه برای صفحه بندی نیاز هستند /// شماره صفحه از یک شروع میشود /// </summary> public static IList<BlogPost> GetLatestBlogPosts(int pageNumber, int recordsPerPage = 4) { var skipRecords = pageNumber * recordsPerPage; return _cachedItems .OrderByDescending(x => x.Id) .Skip(skipRecords) .Take(recordsPerPage) .ToList(); } } }
تنها نکته مهم آن، نحوه تعریف متد GetLatestBlogPosts میباشد که برای صفحه بندی اطلاعات بهینه سازی شده است. در اینجا توسط متدهای Skip و Take، تنها بازهای از اطلاعات که قرار است نمایش داده شوند، دریافت میگردد. خوشبختانه این متدها معادلهای مناسبی را در اکثر بانکهای اطلاعاتی داشته و استفاده از آنها بر روی یک بانک اطلاعاتی واقعی نیز بدون مشکل کار میکند و تنها بازه محدودی از اطلاعات را واکشی خواهد کرد که از لحاظ مصرف حافظه و سرعت کار بسیار مقرون به صرفه و سریع است.
کنترلر برنامه
using System.Linq; using System.Web.Mvc; using System.Web.UI; using jQueryMvcSample02.DataSource; using jQueryMvcSample02.Security; namespace jQueryMvcSample02.Controllers { public class HomeController : Controller { [HttpGet] public ActionResult Index() { //آغاز کار با صفحه صفر است var list = BlogPostDataSource.GetLatestBlogPosts(pageNumber: 0); return View(list); //نمایش ابتدایی صفحه } [HttpPost] [AjaxOnly] [OutputCache(Location = OutputCacheLocation.None, NoStore = true)] public virtual ActionResult PagedIndex(int? page) { var pageNumber = page ?? 0; var list = BlogPostDataSource.GetLatestBlogPosts(pageNumber); if (list == null || !list.Any()) return Content("no-more-info"); //این شرط ما است برای نمایش عدم یافتن رکوردها return PartialView("_ItemsList", list); } [HttpGet] public ActionResult Post(int? id) { if (id == null) return Redirect("/"); //todo: show the content here return Content("Post " + id.Value); } } }
الف) یک متد که بر اساس HttpGet کار میکند. این متد در اولین بار نمایش صفحه فراخوانی میگردد و اطلاعات صفحه آغازین را نمایش میدهد.
ب) متد دومی که بر اساس HttpPost کار کرده و محدود است به درخواستیهای AjaxOnly همانند متد PagedIndex.
از این متد دوم برای پردازش کلیکهای کاربر بر روی دکمه «بیشتر» استفاده میگردد. بنابراین تنها کاری که افزونه جیکوئری تدارک دیده شده ما باید انجام دهد، ارسال شماره صفحه است. سپس با استفاده از این شماره، بازه مشخصی از اطلاعات دریافت و نهایتا یک PartialView رندر شده برای افزوده شدن به صفحه بازگشت داده میشود.
دو View برنامه
همانطور که برای بازگشت اطلاعات نیاز به دو اکشن متد است، برای رندر اطلاعات نیز به دو View نیاز داریم:
الف) یک PartialView که صرفا لیستی از اطلاعات را مطابق سلیقه ما رندر میکند. از این PartialView در متد PagedIndex استفاده خواهد شد:
@model IList<jQueryMvcSample02.Models.BlogPost> <ul> @foreach (var item in Model) { <li> <h5> @Html.ActionLink(linkText: item.Title, actionName: "Post", controllerName: "Home", routeValues: new { id = item.Id }, htmlAttributes: null) </h5> @item.Body </li> } </ul>
@model IList<jQueryMvcSample02.Models.BlogPost> @{ ViewBag.Title = "Index"; var loadInfoUrl = Url.Action(actionName: "PagedIndex", controllerName: "Home"); } <h2> اسکرول نامحدود</h2> @{ Html.RenderPartial("_ItemsList", Model); } <div id="MoreInfoDiv"> </div> <div align="center" style="margin-bottom: 9px;"> <span id="moreInfoButton" style="width: 90%;" class="btn btn-info">بیشتر</span> </div> <div id="ProgressDiv" align="center" style="display: none"> <br /> <img src="@Url.Content("~/Content/images/loadingAnimation.gif")" alt="loading..." /> </div> @section JavaScript { <script type="text/javascript"> $(document).ready(function () { $("#moreInfoButton").InfiniteScroll({ moreInfoDiv: '#MoreInfoDiv', progressDiv: '#ProgressDiv', loadInfoUrl: '@loadInfoUrl', loginUrl: '/login', errorHandler: function () { alert('خطایی رخ داده است'); }, completeHandler: function () { // اگر قرار است روی اطلاعات نمایش داده شده پردازش ثانوی صورت گیرد }, noMoreInfoHandler: function () { alert('اطلاعات بیشتری یافت نشد'); } }); }); </script> }
1) مسیر دقیق اکشن متد PagedIndex توسط متد Url.Action تهیه شده است.
2) در ابتدای نمایش صفحه، متد Html.RenderPartial کار نمایش اولیه اطلاعات را انجام خواهد داد.
3) از div خالی MoreInfoDiv، به عنوان محل افزوده شدن اطلاعات Ajax ایی دریافتی استفاده میکنیم.
4) دکمه بیشتر در اینجا تنها یک span ساده است که توسط css به شکل یک دکمه نمایش داده خواهد شد (فایلهای آن در پروژه پیوست موجود است).
5) ProgressDiv در ابتدای نمایش صفحه مخفی است. زمانیکه کاربر بر روی دکمه بیشتر کلیک میکند، توسط افزونه جیکوئری ما نمایان شده و در پایان کار مجددا مخفی میگردد.
6) section JavaScript کار استفاده از افزونه InfiniteScroll را انجام میدهد.
و کدهای افزونه اسکرول نامحدود
// <![CDATA[ (function ($) { $.fn.InfiniteScroll = function (options) { var defaults = { moreInfoDiv: '#MoreInfoDiv', progressDiv: '#Progress', loadInfoUrl: '/', loginUrl: '/login', errorHandler: null, completeHandler: null, noMoreInfoHandler: null }; var options = $.extend(defaults, options); var showProgress = function () { $(options.progressDiv).css("display", "block"); } var hideProgress = function () { $(options.progressDiv).css("display", "none"); } return this.each(function () { var moreInfoButton = $(this); var page = 1; $(moreInfoButton).click(function (event) { showProgress(); $.ajax({ type: "POST", url: options.loadInfoUrl, data: JSON.stringify({ page: page }), contentType: "application/json; charset=utf-8", dataType: "json", complete: function (xhr, status) { var data = xhr.responseText; if (xhr.status == 403) { window.location = options.loginUrl; } else if (status === 'error' || !data) { if (options.errorHandler) options.errorHandler(this); } else { if (data == "no-more-info") { if (options.noMoreInfoHandler) options.noMoreInfoHandler(this); } else { var $boxes = $(data); $(options.moreInfoDiv).append($boxes); } page++; } hideProgress(); if (options.completeHandler) options.completeHandler(this); } }); }); }); }; })(jQuery); // ]]>
هربار که کاربر بر روی دکمه بیشتر کلیک میکند، progress div ظاهر میگردد. سپس توسط امکانات jQuery Ajax، شماره صفحه (بازه انتخابی) به اکشن متد صفحه بندی اطلاعات ارسال میگردد. در نهایت اطلاعات را از کنترلر دریافت و به moreInfoDiv اضافه میکند. در آخر هم شماره صفحه را یکی افزایش داده و سپس progress div را مخفی میکند.
دریافت مثال و پروژه کامل این قسمت
jQueryMvcSample02.zip
The two most important topics I want to focus on in this post are:
- Repository Management
- Versioning Strategy
We have different options for each of them: You can go with Monorepos or Polyrepos for hosting your code, and you can pick different versioning methods like Semantic Release, Manual Versioning, Global Versioning, etc. I'll talk about each of them in the next sections.
مدتی است که حالت READ_COMMITTED_SNAPSHOT بسیار مورد توجه واقع شده:
- در سایت Stack overflow از آن استفاده میشود (^).
- در SQL Server Azure حالت پیش فرض ایجاد دیتابیسها و تراکنشهای جدید است (^).
- در Entity framework 6 حالت پیش فرض تراکنشهای ایجاد شده، قرار گرفته است (^ ).
و ... در Oracle، تنها حالت مدیریت مسایل همزمانی است! (البته به نام MVCC، اما با همین عملکرد)
اما READ_COMMITTED_SNAPSHOT در SQL Server چیست و کاربرد آن کجا است؟
اگر استفاده گسترده و سنگینی از SQL Server داشته باشید، حتما به پیغامهای خطای deadlock آن برخوردهاید:
روش پیش فرض مدیریت مسایل همزمانی در SQL Server، حالت READ COMMITTED است. به این معنا که اگر در طی یک تراکنش مشغول به تغییر اطلاعاتی باشیم، سایر کاربران از خواندن نتیجه آن (اصطلاحا به آن Dirty read گفته میشود) منع خواهند شد؛ تا زمانیکه این تراکنش با موفقیت به پایان برسد. هرچند در این حالت سایر تراکنشها امکان ویرایش یا حذف اطلاعات را خواهند داشت. به علاوه اگر در طی این تراکنش، اطلاعاتی خوانده شوند، سایر تراکنشها تا پایان تراکنش جاری، قادر به تغییر این اطلاعات خوانده شده نخواهند بود (منشاء بروز خطاهای deadlock یاد شده در سیستمهای پرترافیک).
در SQL Server 2005 برای بهبود مقیاس پذیری SQL Server و کاهش خطاهای deadlock، مکانیزم READ_COMMITTED_SNAPSHOT معرفی گشت.
به صورت خلاصه زمانیکه که تراکنش مورد نظر تحت حالت READ COMMITTED SNAPSHOT انجام میشود، optimistic reads and pessimistic writes خواهیم داشت (خواندنهای خوشبینانه و نوشتنهای بدبینانه). در این حالت تضمین میشود که خواندن اطلاعات داخل یک تراکنش، شامل اطلاعات تغییر یافته توسط سایر تراکنشهای همزمان نخواهد بود. همچنین زمانیکه در این بین، اطلاعاتی خوانده میشود، بر روی این اطلاعات برخلاف حالت READ COMMITTED قفل قرار داده نمیشود. بنابراین تراکنشهایی که درحال خواندن اطلاعات هستند، تراکنشهای همزمانی را که در حال نوشتن اطلاعات میباشند، قفل نخواهد کرد و برعکس.
نحوه فعال سازی READ_COMMITTED_SNAPSHOT
فعال سازی READ_COMMITTED_SNAPSHOT باید ابتدا در سطح یک بانک اطلاعاتی SQL Server انجام شود:
کاری که در اینجا انجام خواهد شد، ایجاد یک snapshot یا یک کپی فقط خواندنی، از بانک اطلاعاتی کاری شما میباشد. بنابراین در این حالت، زمانیکه یک عبارت Select را فراخوانی میکنید، این خواندن، از بانک اطلاعاتی فقط خواندنی تشکیل شده، صورت خواهد گرفت. اما تغییرات بر روی دیتابیس اصلی کاری درج شده و سپس این snapshot به روز میشود.
حالت READ_COMMITTED_SNAPSHOT خصوصا برای برنامههای وبی که تعداد بالایی Read در مقابل تعداد کمی Write دارند، به شدت بر روی کارآیی و بالا رفتن سرعت و مقیاس پذیری آنها تاثیر خواهد داشت؛ به همراه حداقل تعداد deadlockهای حاصل شده.
در Entity framework وضعیت به چه صورتی است؟
EF از حالت پیش فرض مدیریت مسایل همزمانی در SQL Server یا همان حالت READ COMMITTED در زمان فراخوانی متد SaveChanges استفاده میکند.
در EF 6 این حالت پیش فرض به READ_COMMITTED_SNAPSHOT تغییر کرده است. البته همانطور که عنوان شد، پیشتر باید بانک اطلاعاتی را نیز جهت پذیرش این نوع تراکنشها آماده ساخت.
اگر از نگارشهای پایینتر از EF 6 استفاده میکنید، برای استفاده از حالت READ_COMMITTED_SNAPSHOT باید صراحتا IsolationLevel را مشخص ساخت:
- در سایت Stack overflow از آن استفاده میشود (^).
- در SQL Server Azure حالت پیش فرض ایجاد دیتابیسها و تراکنشهای جدید است (^).
- در Entity framework 6 حالت پیش فرض تراکنشهای ایجاد شده، قرار گرفته است (^ ).
و ... در Oracle، تنها حالت مدیریت مسایل همزمانی است! (البته به نام MVCC، اما با همین عملکرد)
اما READ_COMMITTED_SNAPSHOT در SQL Server چیست و کاربرد آن کجا است؟
اگر استفاده گسترده و سنگینی از SQL Server داشته باشید، حتما به پیغامهای خطای deadlock آن برخوردهاید:
Transaction (Process ID 54) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
در SQL Server 2005 برای بهبود مقیاس پذیری SQL Server و کاهش خطاهای deadlock، مکانیزم READ_COMMITTED_SNAPSHOT معرفی گشت.
به صورت خلاصه زمانیکه که تراکنش مورد نظر تحت حالت READ COMMITTED SNAPSHOT انجام میشود، optimistic reads and pessimistic writes خواهیم داشت (خواندنهای خوشبینانه و نوشتنهای بدبینانه). در این حالت تضمین میشود که خواندن اطلاعات داخل یک تراکنش، شامل اطلاعات تغییر یافته توسط سایر تراکنشهای همزمان نخواهد بود. همچنین زمانیکه در این بین، اطلاعاتی خوانده میشود، بر روی این اطلاعات برخلاف حالت READ COMMITTED قفل قرار داده نمیشود. بنابراین تراکنشهایی که درحال خواندن اطلاعات هستند، تراکنشهای همزمانی را که در حال نوشتن اطلاعات میباشند، قفل نخواهد کرد و برعکس.
نحوه فعال سازی READ_COMMITTED_SNAPSHOT
فعال سازی READ_COMMITTED_SNAPSHOT باید ابتدا در سطح یک بانک اطلاعاتی SQL Server انجام شود:
ALTER DATABASE testDatabase SET ALLOW_SNAPSHOT_ISOLATION ON; ALTER DATABASE testDatabase SET READ_COMMITTED_SNAPSHOT ON;
حالت READ_COMMITTED_SNAPSHOT خصوصا برای برنامههای وبی که تعداد بالایی Read در مقابل تعداد کمی Write دارند، به شدت بر روی کارآیی و بالا رفتن سرعت و مقیاس پذیری آنها تاثیر خواهد داشت؛ به همراه حداقل تعداد deadlockهای حاصل شده.
در Entity framework وضعیت به چه صورتی است؟
EF از حالت پیش فرض مدیریت مسایل همزمانی در SQL Server یا همان حالت READ COMMITTED در زمان فراخوانی متد SaveChanges استفاده میکند.
در EF 6 این حالت پیش فرض به READ_COMMITTED_SNAPSHOT تغییر کرده است. البته همانطور که عنوان شد، پیشتر باید بانک اطلاعاتی را نیز جهت پذیرش این نوع تراکنشها آماده ساخت.
اگر از نگارشهای پایینتر از EF 6 استفاده میکنید، برای استفاده از حالت READ_COMMITTED_SNAPSHOT باید صراحتا IsolationLevel را مشخص ساخت:
using (var transactionScope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel= IsolationLevel.Snapshot })) { // update some tables using entity framework context.SaveChanges(); transactionScope.Complete(); }
یکی از مواردی که حین کار کردن با iTextSharp واقعا اعصاب خردکن است، طراحی نامناسب ثوابت این کتابخانه میباشد. برای مثال:
public class PdfWriter
{
/** A viewer preference */
public const int PageLayoutSinglePage = 1;
/** A viewer preference */
public const int PageLayoutOneColumn = 2;
/** A viewer preference */
public const int PageLayoutTwoColumnLeft = 4;
/** A viewer preference */
public const int PageLayoutTwoColumnRight = 8;
/** A viewer preference */
public const int PageLayoutTwoPageLeft = 16;
/** A viewer preference */
public const int PageLayoutTwoPageRight = 32;
// page mode (section 13.1.2 of "iText in Action")
/** A viewer preference */
public const int PageModeUseNone = 64;
/** A viewer preference */
public const int PageModeUseOutlines = 128;
/** A viewer preference */
public const int PageModeUseThumbs = 256;
/** A viewer preference */
public const int PageModeFullScreen = 512;
/** A viewer preference */
public const int PageModeUseOC = 1024;
/** A viewer preference */
public const int PageModeUseAttachments = 2048;
//...
//...
}
6 ثابت اول مربوط به گروه PageLayout هستند و 6 ثابت دوم به گروه PageMode ارتباط دارند و این کلاس پر است از این نوع ثوابت (این کلاس نزدیک به 3200 سطر است!). این نوع طراحی نامناسب است. بجای گروه بندی خواص یا ثوابت با یک پیشوند، مثلا PageLayout یا PageMode، اینها را به کلاسها یا در اینجا (حین کار با ثوابت عددی) به enumهای متناظر خود منتقل و Refactor کنید. مثلا:
public enum ViewerPageLayout
{
SinglePage = 1,
OneColumn = 2,
TwoColumnLeft = 4,
TwoColumnRight = 8,
TwoPageLeft = 16,
TwoPageRight = 32
}
مزیتها:
- طبقه بندی منطقی ثوابت در یک enum و گروه بندی صحیح آنها، بجای گروه بندی توسط یک پیشوند
- استفاده بهینه از intellisense در visual studio
- منسوخ سازی استفاده از اعداد بجای معرفی ثوابت خصوصا عددی (در این کتابخانه شما میتوانید بنویسید PdfWriter.PageLayoutSinglePage و یا 1 و هر دو صحیح هستند؛ این خوب نیست. ترویج استفاده از اصطلاحا magic numbers هم حین طراحی یک کتابخانه مذموم است.)
- کم شدن حجم کلاس اولیه (مثلا کلاس PdfWriter در اینجا) و در نتیجه نگهداری سادهتر آن در طول زمان
اشتراکها
سوالات مصاحبه ای ASP.NET Core
NuGet 5.8 is included in Visual Studio 16.8 and .NET 5.0 out of the box. You can also download NuGet 5.8 for Windows, macOS, and Linux as a standalone executable.
.NET Core 2.1.6 is available for download and usage in your environment. This release includes .NET Core 2.1.6, ASP.NET Core 2.1.6 and .NET Core SDK 2.1.500. All fixes of note can be seen in the 2.1.6 commits list.