这又是一个来自于实际项目的问题:如何在RESTful API上实现指定字段的服务端排序。问题背景是这样的,有一个前端的页面,它会显示下面这张表格:
这张表格的表头是可以被点击的,当点击某个列的列头(Column Header)时,数据就会根据这一列进行升序或者降序排列。当数据量小的时候,一张表格就可以显示所有的数据,无需进行数据的分页显示,于是,类似这样的排序可以直接在前端实现。然而,理想很丰满现实很骨感,在绝大多数情况下,类似这样的数据表格基本上都需要实现分页和排序操作,考虑到数据量大的情况,这种分页和排序是需要在服务端实现的,更确切地说,是在数据持久化层(通俗地说,数据库)实现的。
为何这种分页和排序需要在服务端实现呢?因为分页和排序是需要基于所有数据的,而服务端不可能将所有数据读入内存并进行分页和排序计算。所以,就上图所描述的这种需求而言,前端会向RESTful API发送获取数据的请求,而RESTful API则会根据传入参数,对数据库进行查询,仅获取某一页的数据并返回给前端,于是,前端只需要将返回的数据绑定到界面上即可。
为了简化,暂不讨论分页的情况,先看如何做排序。可以想到,前端会传给API两个参数:需要排序的字段名和排序的方式(升序或者降序)。在这里,我们仍然采用ASP.NET Core Web API项目模板自动生成的天气数据模型作为例子,在此模型基础上增加两个字段:Id和City,从而能够更好地展示动态指定排序字段的效果;此外,我们选用EntityFrameworkCore结合PostgreSQL作为后端数据库和数据访问层的实现,这样可以直接观察由Lambda表达式所生成的SQL语句,从而更好地验证我们的实现过程。
如何在ASP.NET Core Web API中使用EntityFrameworkCore来连接PostgreSQL,并不是本文的重点,这里仅将主要的部分介绍一下。首先,定义一个Weather的record:
public record Weather(int Id, string? City, DateOnly Date, int Temperature, string? Summary);
然后,定义一个DbContext,取名为EFWeatherContext,在这个DbContext中,可以通过重载OnModelCreating方法,指定数据表的生成方式:
public class EFWeatherContext : DbContext { public EFWeatherContext(DbContextOptions<EFWeatherContext> options) : base(options) { } public DbSet<Weather> Weathers { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Weather>() .HasKey(w => w.Id); modelBuilder.Entity<Weather>() .Property(w => w.City) .IsRequired() .HasMaxLength(256) .IsUnicode(true); modelBuilder.Entity<Weather>() .Property(w => w.Summary) .IsRequired() .HasMaxLength(256) .IsUnicode(true); modelBuilder.Entity<Weather>() .Property(w => w.Date) .IsRequired(); modelBuilder.Entity<Weather>() .Property(w => w.Temperature) .IsRequired(); } }
下一步就是添加Npgsql.EntityFrameworkCore.PostgreSQL NuGet包的引用,以便使用PostgreSQL数据库,并且在Program.cs文件中,将EFWeatherContext添加到依赖注入体系中:
builder.Services.AddDbContext<EFWeatherContext>(options => { options.UseNpgsql(builder.Configuration["db:connectionString"]); });
在这里,数据库的连接字符串需要从配置系统中读取,比如,在Debug的时候,可以将这个配置写在appsettings.Development.json文件中。此外,修改日志配置,让EntityFrameworkCore所产生的SQL语句输出到控制台。完整的appsettings.Development.json文件内容如下:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "db": { "connectionString": "Host=localhost;Database=EFWeatherForecast;Username=postgres;Password=postgres" } }
然后,使用EntityFrameworkCore命令行工具,在PostgreSQL中生成数据表,就可以在WeatherForecastController中写三个API,来创建并返回天气数据了:
[ApiController] [Route("weather")] public class WeatherForecastController : ControllerBase { private readonly ILogger<WeatherForecastController> _logger; private readonly EFWeatherContext _context; public WeatherForecastController(ILogger<WeatherForecastController> logger, EFWeatherContext context) => (_logger, _context) = (logger, context); [HttpPost] public async Task<IActionResult> CreateAsync(Weather weather) { _context.Weathers.Add(weather); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetByIdAsync), new { id = weather.Id }, weather.Id); } [HttpGet("{id}")] public async Task<IActionResult> GetByIdAsync(int id) { var weather = await _context.Weathers.FirstOrDefaultAsync(w => w.Id == id); return weather == null ? NotFound() : Ok(weather); } [HttpGet] public IActionResult GetAll() => Ok(_context.Weathers); }
我们创建一些天气数据,然后用GET API查看一下创建好的数据:
daxne@daxnet-laptop:~$ curl http://$(hostname).local:5199/weather | json_pp % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 248 0 248 0 0 4454 0 --:--:-- --:--:-- --:--:-- 4509 [ { "city" : "Shanghai", "date" : "2023-04-07", "id" : 2, "summary" : "Sunny", "temperature" : 18 }, { "city" : "Beijing", "date" : "2023-04-07", "id" : 3, "summary" : "Rainy", "temperature" : 20 }, { "city" : "Chongqing", "date" : "2023-04-07", "id" : 4, "summary" : "Cloudy", "temperature" : 15 } ]
目前看上去是以Id进行排序的,但是并不能确保数据库一定会按照Id进行排序,这个查询生成的SQL语句并没有指定任何的排序参数:
在实际项目中,对于Weather这个数据模型,前端其实可以选择针对City、Date、Id、Summary或者Temperature这些字段,选择升序或者降序排列,当API获得前端传入的需排序的字段名,以及升序/降序的参数后,又应该如何进行处理呢?
在EntityFrameworkCore中,对实体(Entity)数据的访问,可以在DbContext所定义的DbSet实体数据集上,通过流畅接口(Fluent API)的形式,指定排序参数,也可以通过直接写LINQ的形式,完成相同的工作,比如:
// 流畅接口 Fluent API _context.Weathers.Where(w => w.Temperature > 10).OrderBy(w => w.Summary); // LINQ from w in _context.Weathers where w.Temperature > 10 orderby w.Summary ascending select w;
相比之下,要实现动态指定排序的字段,选择流畅接口的写法就会更容易实现,因为在LINQ下,排序的指定是直接嵌入在C#的语句中的,而在流畅接口的写法中,我们可以以Lambda表达式的方式来指定排序的字段。
那么,看起来这个Lambda表达式应该根据传入的排序字段名来动态生成,这样才能直接传给OrderBy扩展方法,以实现数据库端的排序。下面的C#函数就可以实现这样的功能,它能根据传入的字段名称,动态生成Lambda表达式:
protected Expression<Func<TEntity, object>>? CreateSortExpression<TEntity>(string sortPropertyName) { // 根据指定的字段名,获得TEntity类型的属性名称。通常,从API传入的参数都遵循camelCase,此处 // 将其转换成PascalCase,因为C#中的命名规则都是遵循PascalCase的 sortPropertyName = $"{sortPropertyName[0].ToString().ToUpper()}{sortPropertyName[1..]}"; // 根据属性名称,获得PropertyInfo var sortPropertyInfo = typeof(Weather).GetProperty(sortPropertyName, BindingFlags.Public | BindingFlags.Instance); if (sortPropertyInfo != null) { // 构建ParameterExpression (生成表达式:sort) var parameterExp = Expression.Parameter(typeof(TEntity), "sort"); // 构建PropertyExpression (生成表达式:sort => sort.Property) var propertyExp = Expression.Property(parameterExp, sortPropertyName); // 构建LambdaExpression (生成表达式:sort => sort.Property) return Expression.Lambda<Func<TEntity, object>>(sortPropertyInfo.PropertyType == typeof(string) ? propertyExp : Expression.Convert(propertyExp, typeof(object)), parameterExp); } return null; }
注意的一点就是,如果指定的字段的数据类型不是字符串型,就需要通过Expression.Convert方法,将PropertyExpression的返回类型转换成object,否则就会报类型不匹配的错误。
接下来,修改GetAll API:
[HttpGet] public IActionResult GetAll([FromQuery] string sortField = "city", [FromQuery] bool sortAscending = true) { var sortExpression = CreateSortExpression<Weather>(sortField); if (sortExpression == null) { return BadRequest("Cannot build the lambda expression from the specified sortField."); } return Ok(sortAscending ? _context.Weathers.OrderBy(sortExpression) : _context.Weathers.OrderByDescending(sortExpression)); }
此时,GetAll API就可以通过传入的字段名称,以及排序方式,动态构建Lambda表达式,然后在DbContext上进行查询与排序了,代码比较简单,就不多作介绍了。
下面是按照City字段进行升序排序的API调用与返回:
daxne@daxnet-laptop:~$ curl -X GET "http://$(hostname).local:5199/weather?sortField=city&sortAscending=true" | json_pp % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 248 0 248 0 0 23117 0 --:--:-- --:--:-- --:--:-- 24800 [ { "city" : "Beijing", "date" : "2023-04-07", "id" : 3, "summary" : "Rainy", "temperature" : 20 }, { "city" : "Chongqing", "date" : "2023-04-07", "id" : 4, "summary" : "Cloudy", "temperature" : 15 }, { "city" : "Shanghai", "date" : "2023-04-07", "id" : 2, "summary" : "Sunny", "temperature" : 18 } ]
以下是按照Temperature字段进行降序排列的API调用与返回:
daxne@daxnet-laptop:~$ curl -X GET "http://$(hostname).local:5199/weather?sortField=temperature&sortAscending=false" | json_pp % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 248 0 248 0 0 4008 0 --:--:-- --:--:-- --:--:-- 4065 [ { "city" : "Beijing", "date" : "2023-04-07", "id" : 3, "summary" : "Rainy", "temperature" : 20 }, { "city" : "Shanghai", "date" : "2023-04-07", "id" : 2, "summary" : "Sunny", "temperature" : 18 }, { "city" : "Chongqing", "date" : "2023-04-07", "id" : 4, "summary" : "Cloudy", "temperature" : 15 } ]
所产生的SQL语句如下,可以看出,整个排序是在数据库端完成的: