在ASP.NET Core Web API上动态构建Lambda表达式实现指定字段的数据排序

By | 2023年4月7日

这又是一个来自于实际项目的问题:如何在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语句如下,可以看出,整个排序是在数据库端完成的:

(总访问量:488;当日访问量:1)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据