译者荐语:如何在RESTFul APIs中进行集合结果分页?还是用客户端来拼接链接地址么?
原文来自互联网,由长沙DotNET技术社区【邹溪源】翻译。如译文侵犯您的版权,请联系小编,小编将在24小时内删除。
在ASP.NET Core WebApi项目中分页响应数据
REST API的分页响应和通过REST API端点筛选返回的数据(它们经常一起出现)同样重要。
就像过滤一样,分页会限制从端点返回的数据量,从而节省了客户端和服务器端资源。想象一下,如果你想返回一个客户的数据,但是却返回了所有客户的数据,或者你返回了所有的分页数据,而你搜索的数据实际上就在前几条记录中。
这将仅导致服务器上处理能力和网络带宽的浪费,也给客户端的实现带来了不必要的复杂度。
有许多技术可以解决这两个问题,例如OData[1]或 GraphQL[2]。但是,也可以仅使用依赖于过滤和分页API数据的定制解决方案来解决这些问题。
为什么要考虑使用定制解决方案而不是现有技术?原因很简单,因为它可以避免您的团队浪费时间学习新技术,而他们已经可以在没有新技术的情况下解决这些问题。而且,这些技术将强加于客户端的适应能力并限制它们的相应客户端的使用。依赖于过滤和分页技术也不是没有复杂性。有时这些简单的方法会变得非常复杂,最终会成为消费者/客户的一个问题。
我将提到REST API设计中自定义筛选和分页的一些支柱,特别是在使用.NET Core WEB API在REST服务中实现筛选和分页时。
使过滤器易于扩展
我在WEB API项目中实现自定义筛选和分页时发现的常见错误之一是将值作为单独的参数传递给MVC Controller Action方法。且别说他是不是易于扩展,这样做使得方法签名变得更加复杂,并且有向端点添加更多过滤选项的趋势,从而变得更加复杂。
[HttpGet] # public IActionResult Get(String term, int page, int limit) # { # //Handle filtering and paging # } #
假设一段时间后,您必须扩展端点以接收DateTime和Boolean参数,这些参数将参与过滤。方法签名将更改并变为:
[HttpGet] # public IActionResult Get(String term, DateTime minDate, Boolean includeInactive, int page, int limit) # { # //Handle filtering and paging # } #
您已经看到,一次更新后,您的方法签名变得更加复杂,而且为了提高阅读这段代码,你还得将其分成两行显示。除非您有版本控制,否则将很难与现有端点使用者保持一致,而且由于开发者不知道增加了新的参数,而由于MVC不知道如何路由请求,MVC将不会匹配方法签名只会自动给出404响应。这意味着您必须将新的参数设置为可选参数,并将其移动到参数列表的末尾。
[HttpGet] # public IActionResult Get(String term, int page, int limit, DateTime? minDate = null, Boolean includeInactive=false) # { # //Handle filtering and paging # } #
现在,参数分散在签名中,使方法签名具有混合过滤和分页参数,没有逻辑分组或顺序,因为除了方法签名中参数列表的末尾,您不能拥有其他可选参数。当您意识到必须将这些更改应用到不止一种方法时,复杂性就会大大增加。您可能必须同时对多个端点进行更改,这不仅使一种方法变得困难,而且几乎使整个应用程序外观都难以维护。
我认为我们已经提出了足够多的观点,可以得出结论,对过滤方法使用多个参数是一个坏主意。更好的方法是使用模型,并将所有参数作为POCO类的属性。尽管方法仍然是HTTP GET,但是MVC可以通过使用模型的[FromQuery]关键字从查询字符串中为您绑定模型。
public class FilterModel # { # public String Term { get; set; } # public DateTime MinDate { get; set; } # public Boolean IncludeInactive { get; set; } # public int Page { get; set; } # public int Limit { get; set; } # } # # [HttpGet] # public IActionResult Get([FromQuery] FilterModel filter) # { # //Handle filtering and paging # } #
现在,扩展过滤器是单个类的责任,如果您的过滤器在整个项目中是通用的,或者它具有公共属性(例如页码和页面大小/限制),则可以将其带到基类,并且如果需要扩展跨多个Actions甚至跨多个Controller的过滤器模型,您只需扩展基本模型过滤器类即可。您仍然必须在过滤逻辑中处理新参数,但是方法签名将保持不变,而无需扩展它。
不要让客户端为您分担工作
我看到的许多自定义实现都让客户端形成查询字符串以获取下一页。我认为这不是正确的方法。我看到的并且我真的很喜欢的一种实现[3]是ZenDesk API[4]使用的这种。除了实体集合以外,响应还包括结果下一页和上一页的URL。样本响应将是这样的
{ # persons:[ # { # name: "John Smith", # dob: "1984-10-31", # email: "john@smith.test.com" # }, # ... # ], # nextPage: "http://localhost:5000/api/persons?name=John&page=2&limit=100", # previousPage: null # } #
这样,您的客户就不必确定下一个页面URL是什么,并且在采用当今的现代无限滚动方式的大多数UI实现(包括Web和移动)上,这种方法非常理想,因为每个页面滚动到底部都是一种新方法HTTP GET到下一页URL。在WEB API中,看起来像这样
public class FilterModel # { # public String Term { get; set; } # public DateTime MinDate { get; set; } # public Boolean IncludeInactive { get; set; } # public int Page { get; set; } # public int Limit { get; set; } # } # # public class PagedCollectionResponse<T> where T : class # { # public IEnumerable<T> Items { get; set; } # public Uri NextPage { get; set; } # public Uri PreviousPage { get; set; } # } # # public class Person # { # public String Name { get; set; } # public DateTime DOB { get; set; } # public String Email { get; set; } # } # # [HttpGet] # public ActionResult<PagedCollectionResponse<Person>> Get([FromQuery] FilterModel filter) # { # //Handle filtering and paging # } #
这只是过滤器动作签名的外观的浅层结构。接下来,我将用一段简单的代码解释如何在ASP.NET Core WEB API示例控制器中实现这种方法。
过滤和分页的简单示例
为了向您展示如何使用页面指针URL来实现上述分页方法,我将使用一个简单的控制器和字符串的静态集合。理想情况下,您将查询存储库中的数据,但是为了使事情保持简单并专注于生成所描述的响应结构,我将坚持简单的字符串集合。
在我们跳到逻辑之前,第一件事就是创建模型。由于我们项目中的所有过滤器都会接收页面和限制值,因此有必要将其设为抽象类,以便任何具有页面调度的过滤器都可以继承它。
namespace Sample.Web.Api.Models # { # public abstract class FilterModelBase:ICloneable # { # public int Page { get; set; } public int Limit { get; set; } public FilterModelBase() { this.Page = 1; this.Limit = 100; } public abstract object Clone(); } }
我们有一个默认的构造函数,它将页面大小(Limit属性)设置为100,这意味着默认情况下,任何过滤器模型都将分页显示100个项目的集合中的值。我们还实现了ICloneable接口,但是实现保留为抽象,以允许继承的类处理克隆逻辑,因为它可能涉及继承的POCO类的其他属性。当我们开始实现分页逻辑时,您将明白为什么我们需要涉及ICloneable接口。
现在让我们通过继承FilterModelBase抽象类来实现过滤器
public class SampleFilterModel:FilterModelBase { public string Term { get; set; } public SampleFilterModel():base() { this.Limit = 3; } public override object Clone() { var jsonString = JsonConvert.SerializeObject(this); return JsonConvert.DeserializeObject(jsonString,this.GetType()); } }
除了Page和Limit属性之外,我还添加了一个附加属性Term,该属性应用于过滤我们的字符串集合。我还希望在构造函数中将新页面大小设置为3,而不是在基类构造函数中分配的默认页面大小设置为100,这是因为希望查看少量数据集的分页。Clone方法表示过滤器模型实例的深层副本,使用Newtonsoft.Json[5]包通过简单的序列化/反序列化即可完成。这样,我们涵盖了Action方法的输入,但是现在我们需要注意输出。为了使响应通用,我将使用相同的结构模型,但是将根据控制器的需要更改集合的类型。为此,我使用了通用类型来声明输出模型,以便我们可以在多个Controller Action方法中重用它以返回不同类型的集合元素。
namespace Sample.Web.Api.Models { public class PagedCollectionResponse<T> where T:class { public IEnumerable<T> Items { get; set; } public Uri NextPage { get; set; } public Uri PreviousPage { get; set; } } }
当我们要返回上述人员的集合时,我们可以使用相同的模型类来存储示例数据。
namespace Sample.Web.Api.Models { public class Person { public String Name { get; set; } public DateTime DOB { get; set; } public String Email { get; set; } } }
现在我们准备写下我们的页面处理。正如我提到的,我将使用Person类实例的集合,并且在此演示中,我将它们声明为在Controller构造中启动的集合。
namespace Sample.Web.Api.Controllers { [Route("api/[controller]")] [ApiController] public class PersonsController : ControllerBase { IEnumerable<Person> persons = new List<Person>() { new Person() { Name = "Nancy Davolio", DOB = DateTime.Parse("1948-12-08"), Email = "nancy.davolio@test.com" }, new Person() { Name = "Andrew Fuller", DOB = DateTime.Parse("1952-02-19"), Email = "andrew.fuller@test.com" }, new Person() { Name = "Janet Leverling", DOB = DateTime.Parse("1963-08-30"), Email = "janet.leverling@test.com" }, new Person() { Name = "Margaret Peacock", DOB = DateTime.Parse("1937-09-19"), Email = "margaret.peacock@test.com" }, new Person() { Name = "Steven Buchanan", DOB = DateTime.Parse("1955-03-04"), Email = "steven.buchanan@test.com" }, new Person() { Name = "Michael Suyama", DOB = DateTime.Parse("1963-07-02"), Email = "michael.suyama@test.com" }, new Person() { Name = "Robert King", DOB = DateTime.Parse("1960-05-29"), Email = "robert.king@test.com" }, new Person() { Name = "Laura Callahan", DOB = DateTime.Parse("1958-01-09"), Email = "laura.callahan@test.com" }, new Person() { Name = "Anne Dodsworth", DOB = DateTime.Parse("1966-01-27"), Email = "anne.dodsworth@test.com" } }; // GET api/values [HttpGet] public ActionResult<PagedCollectionResponse<Person>> Get([FromQuery] SampleFilterModel filter) { //Filtering logic Func<SampleFilterModel, IEnumerable<Person>> filterData = (filterModel) => { return persons.Where(p => p.Name.StartsWith(filterModel.Term ?? String.Empty, StringComparison.InvariantCultureIgnoreCase)) .Skip((filterModel.Page-1) * filter.Limit) .Take(filterModel.Limit); }; //Get the data for the current page var result = new PagedCollectionResponse<Person>(); result.Items = filterData(filter); //Get next page URL string SampleFilterModel nextFilter = filter.Clone() as SampleFilterModel; nextFilter.Page += 1; String nextUrl = filterData(nextFilter).Count() <= 0 ? null : this.Url.Action("Get", null, nextFilter, Request.Scheme); //Get previous page URL string SampleFilterModel previousFilter = filter.Clone() as SampleFilterModel; previousFilter.Page -= 1; String previousUrl = previousFilter.Page <= 0 ? null : this.Url.Action("Get", null, previousFilter, Request.Scheme); result.NextPage = !String.IsNullOrWhiteSpace(nextUrl) ? new Uri(nextUrl) : null; result.PreviousPage = !String.IsNullOrWhiteSpace(previousUrl) ? new Uri(previousUrl) : null; return result; } } }
该示例代码非常原始,它不是生产代码,它需要一些处理才能在多个控制器中重复使用,但它的目的是在简单数据收集的小样本上生成所需的输出数据结构和分页逻辑。让我们一步一步地分析该方法的逻辑块
•过滤逻辑 从源数据集合返回项目集合的Simple Func会根据传递的过滤器模型来获取一批对象。此实现很大程度上取决于您的过滤逻辑和您要应用过滤器的数据。Func主体特定于Action方法。•获取当前页面的数据 上述Func实现的简单用法 。将逻辑放在Func中的原因是供以后重用以确定下一页和上一页URL。•获取下一页URL字符串 在这里,我们正在创建具有更新的页码的新模型。还记得我们使用ICloneable作为过滤器POCO吗?现在,我们将使用它来创建深层副本并更新模型的页码,以便我们可以生成下一页的URL。在生成下一页的URL之前,我们需要知道下一页号是否返回任何元素。我们不想在下一页将客户端发送到空集合响应,因为我们希望客户端仅依赖NextPage和PreviousPage URL属性。•获取上一页URL字符串 与获取NextPage URL非常相似,但逻辑上略有不同。我们不需要将结果集集合计为下一页URL。我们只需要检查页码是否为1,这意味着没有更多的页面,PreviousPage URL为空值。
我们几乎涵盖了所有内容,因此让我们看一下它在POSTMAN中的实际工作方式。
在具有默认页面参数的初始请求中,我们可以看到结果集合中有3个人,我们的NextPage URL指向页面号增加1的URL,而PreviousPage URL为null,因为我们在首页上并且没有之前的页面。
如果我们遵循NextPage URL并在POSTMAN中对其执行HTTP GET,我们将得到以下响应。
现在您可以看到我们同时具有NextPage URL和PreviousPage URL。如果您注意到示例数据集合中有9个元素,这意味着对NextPage URL的请求应在结果集合中再给我们3个元素。
我们的最后一页在结果集中返回3个人,但是您可以注意到NextPage URL为空。这是因为页数4的计数将在响应中不返回任何元素,并且我们正在通知使用者没有更多数据要返回。
我在一个简单的数据收集上演示了此WEB API响应分页,但实际情况将涉及数据过滤和查询数据存储库。我希望 不久的将来,我将能够通过使用存储库模式和可重复使用的逻辑(可以应用于多个控制器和操作而无需任何代码重复)的展示,以更加精细的实现编写更详细的文本。
References
[1]
OData: https://www.odata.org/[2]
GraphQL: https://graphql.org/[3]
实现: https://developer.zendesk.com/rest_api[4]
ZenDesk API: https://developer.zendesk.com/rest_api[5]
Newtonsoft.Json: https://www.newtonsoft.com/