在ASP.NET Core Web API上动态构建Lambda表达式实现指定字段的数据排序
这又是一个来自于实际项目的问题:如何在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 options)
: base (options) { }
public DbSet Weathers { get; set ;}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasKey(w => w.Id);
modelBuilder.Entity()
.Property(w => w.City)
.IsRequired()
.HasMaxLength(256)
.IsUnicode(true);
modelBuilder.Entity()
.Property(w => w.Summary)
.IsRequired()
.HasMaxLength(256)
.IsUnicode(true);
modelBuilder.Entity()
.Property(w => w.Date)
.IsRequired();
modelBuilder.Entity()
.Property(w => w.Temperature)
.IsRequired();
}
}
下一步就是添加Npgsql.EntityFrameworkCore.PostgreSQL NuGet包的引用,以便使用PostgreSQL数据库,并且在Program.cs文件中,将EFWeatherContext添加到依赖注入体系中:
builder.Services.AddDbContext(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 _logger;
private readonly EFWeatherContext _context;
public WeatherForecastController(ILogger logger, EFWeatherContext context)
=> (_logger, _context) = (logger, context);
[HttpPost]
public async Task 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 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(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(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语句如下,可以看出,整个排序是在数据库端完成的: