返回
Featured image of post 温故而知新:后端通用查询方案的再思考

温故而知新:后端通用查询方案的再思考

AI 摘要
本文探讨了后端通用查询方案的实现和优化。作者从 Gridify 库获得启发,提出了一种基于泛型和接口的查询方案,实现了灵活的分页和过滤功能。文章详细介绍了方案的实现细节,包括 QueryParameter 类的设计、IQueryableFilter 接口的应用,以及如何通过自定义 IModelBinder 来兼容不同的参数传递格式,改变 ASP.NET Core 默认的模型绑定行为。文章强调了在 AI 时代,程序员应该关注复杂问题的解决和生产关系的改善,而不仅仅是简单的代码生成。

最近,我一直在体验 Cursor 这款产品,与先前的 CodeGeex通义灵码“插件类” 产品相比,Cursor 在产品形态上更接近 Github Copilot。在多项测评中,Cursor 甚至一度超越了 Github Copilot。尽管我没有体验过 Github Copilot,但从用户体验的角度来看,Cursor 基于 VS Code 进行了深度定制。除了基础的代码自动补全功能外,它还可以允许你从原型图生成代码、将整个工程作为 Codebase、一键应用代码到本地。最令我印象深刻的是,它指导我完成了一个 Vue 的小项目,从零开始。诚然,“幻觉” 的存在让它在 Vue 2 和 Vue 3 之间反复横跳,其编程能力的提升主要得益于 Claude 3.5 系列模型,可我还是像《三体》中的杨冬一样感到震惊:物理学不存在了,那前端呢?有人说,程序员真正的护城河是沟通能力,因为执行层面的工作可以交给 AI。实际上,我并不担心 AI 取代人类,我更倾向于与 AI 沟通和合作,你可能想象不到,这篇文章中的思考正是来自于我和 Claude 老师的日常交流。

CRUD Boys 的日常

程序员普遍喜欢自嘲,以博主为例,作为一名后端工程师,我的日常工作主要就是 CRUD,因此,你可以叫我们 CRUD Boys。鲁迅先生曾作《自嘲》一诗,“破帽遮颜过闹市,漏船载酒泛中流”。面对软件世界里里的复杂性和不确定性,如果没有乐观的心态和耐心,哪怕是最基础的 CRUD,你不见得就能做到得心应手。你可能听说过这样一句话,“上岸第一剑,先斩意中人”,AI 领域的第一把火,永远烧向程序员自己,自打一众 AI 辅助编程工具问世以来,各种程序员被 AI 取代的声音不绝于耳,甚至 Cursor 可以在 45 分钟内让一个 8 岁小孩搭建出聊天网站,更不必说,在 OpenAI 发布全新的 o1 模型后,很多人觉得连提示工程、Agent 这些东西都不存在了。其实,代码生成、低代码/无代码相关的技术一直都存在,在很久以前,我们就在通过 T4 模板生成业务代码,自不必说各种代码生成器。截止到目前,Excel 依然是这个地球上最强大的低代码工具,可又有谁能掌握 Excel 的全部功能呢?

你猜用 Cursor 写一个这样的页面需要多久?
你猜用 Cursor 写一个这样的页面需要多久?

退一步讲,即使的最简单的 CRUD,虽然业务的推进会不断地演化出新的问题。譬如,当你为了加快查询效率引入了缓存,你需要去解决数据库和缓存一致性、缓存失效等问题;当你发现数据库读/写不平衡引入读写分离、分库分表,你就需要去解决主从一致、分布式事务、跨库查询等问题;当你发现单点性能不足引入了多机器、多线程,你需要去解决负载均衡、线程同步等问题……单单一个查询就如此棘手,你还会觉得后端的 CRUD 简单吗?我承认,后端的确都是 CRUD,可在不同的维度上这些 CRUD 并不完全相同,譬如,分布式的相关算法如 PaxosRaft 等,难道不是针对分布式环境中的节点做 CRUD 吗?可此时你还会觉得它简单吗?Cursor 的确可以帮你生成代码,但真正让它出圈的是背后的 Claude 模型。我始终相信某位前辈曾经讲过的话:“没有银弹”,在软件行业里,复杂度永远不会消失,它只会以一种新的方式出现。如果你觉得 CRUD 简单,或许是你从未接触过那些千姿百态的查询接口:

// OData 风格
Products?$filter=Category eq 'Electronics'&$orderby=Price desc&$top=10&$skip=20

// GraphQL 风格
{
  products(category: "Electronics", first: 10, skip: 20, orderBy: {field: PRICE, direction: DESC}) {
    name
    price
  }
}

// RESTful 风格
products?category=Electronics&sort=-price&limit=10&offset=20
products?filter[category]=Electronics&sort=-price&page[limit]=10&page[offset]=20

从 Gridify 中得到的启发

Gridify 是一个卓越的动态 LINQ 库,它可以将字符串转化为 LINQ 查询。因此,它非常适合处理分页、过滤、排序等问题。如以下代码示例所示,你只需要在控制器方法中添加一个 GridifyQuery 类型的参数,即可实现一个通用的分页查询接口,可以说是非常的 Amazing 啊!👌

[HttpGet]
public Paging<Product> GetPagingProducts([FromQuery]GridifyQuery query) {
    var queryable = _context.Products.AsQueryable();
    return queryable.Gridify(query);
}

事实上,Gridify 为 IQueryable 接口提供了丰富的扩展方法,这使得我们可以如此优雅地实现分页查询功能。此时,我们可以使用类似下面这样的语法来查询产品信息:

// 查询品牌中含有 Nick 的产品
api/products?page=1&pageSize=10&filter=brand=*Nike

// 查询价格大于或等于 10 元的产品
api/products?page=1&pageSize=10&filter=price>=10

// 查询名称以 Nick 开头的产品
api/products?page=1&pageSize=10&filter=name=^Nick

// 查询品牌中含有 Nick 的产品,并按 SKU 降序排列
api/products?page=1&pageSize=10&filter=brand=*Nike&orderBy=sku desc

你可以理解为,Gridify 定义了一套过滤和排序的语法,这些语法将会被解析、编译为表达式,并最终应用到 IQueryable 接口上面。对于 .NET 开发者来说,常见的做法是:使用 Where() 筛选数据,使用 Skip() 和 Take() 实现分页查询,以及使用 OrderBy()、OrderByDescending()、ThenBy() 和 ThenByDescending() 对数据进行排序。目前,Gridify 支持的操作符如下所示:

Gridify 中定义的操作符
Gridify 中定义的操作符

这个方案在某种程度上与我过去开源的项目 DynamicSearch 非常相似,都是通过构建表达式实现动态查询。事实上,之前已经有一个名为 System.Linq.Dynamic 的项目专门解决这类动态查询问题。唯一的挑战是,这些方案对前端同事来说过于复杂了,即便 Gridify 中非常贴心的提供了前端实现。当然,一个更要的原因是,我更倾向于避免解析字符串信息。那么,是否有更好的解决方案呢?诸如 Java 程序员在 XML 里写 SQL 的做法,窃为我所不取也!

一个通用查询方案的实现

首先,我们参照 GridifyQuery 定义一个泛型类 QueryParameter:

class QueryParameter<TEntity,TFilter> where TFilter : class, IQueryableFilter<TEntity> {
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
    public TFilter Filter { get; set; }
    public string SortBy { get; set; }
    public bool IsDescending { get; set; }
}

其中,参数 TFilter 需要满足一定的约束条件,即:实现 IQueryableFilter 接口,该接口定义如下:

interface IQueryableFilter<TEntity> {
    ISugarQueryable<TEntity> Apply(ISugarQueryable<TEntity> queryable);
}

博主这里使用的 ORM 是 SqlSugar,因此,这个接口需要依赖 ISugarQueryable 接口,如果你使用 EntityFramework,可以将其替换为 IQueryable。当我们需要对特定数据进行筛选时,我们只需要为其定义一个或者多个过滤条件即可。例如,通过下面的 LlmModelQueryableFilter 类,即可完成对大模型信息的检索:

class LlmModelQueryableFilter : IQueryableFilter<LlmModel> {
    public string ModelName { get; set; }
    public int? ModelType { get; set; }
    public int? ServiceProvider {  get; set; }

    public ISugarQueryable<LlmModel> Apply(ISugarQueryable<LlmModel> queryable) {
        if (!string.IsNullOrEmpty(ModelName))
            queryable = queryable.Where(x => x.ModelName.Contains(ModelName));

        if (ModelType.HasValue)
            queryable = queryable.Where(x => x.ModelType == ModelType);

        if (ServiceProvider.HasValue)
            queryable = queryable.Where(x => x.ServiceProvider == ServiceProvider);

        return queryable;
    }
}

通常情况下,你只需要继承 CrudBaseController,并将其与实体类关联起来即可:

[Route("api/[controller]")]
[ApiController]
class LlmModelController : CrudBaseController<LlmModel,LlmModelQueryableFilter> {
    private readonly IRepository<LlmModel> _llmModelRepository;
    public LlmModelController(CrudBaseService<LlmModel> crudBaseService, IRepository<LlmModel> llmModelRepository) : base(crudBaseService) {
        _llmModelRepository = llmModelRepository;
    }
}

此时,你将得到一组 RESTful 风格的 CRUD 接口,如下图所示:

自动解锁一组 RESTful 风格的 CRUD 接口
自动解锁一组 RESTful 风格的 CRUD 接口

其中, api/LlmModel/paginateapi/LlmModel/list 具有相同的过滤条件,真正做到了 “一次编写,到处运行”。关于 CrudBaseController 的具体实现,我认为并不复杂,关键代码片段展示如下:

// 分页查询
async Task<PagedResult<T>> PaginateAsync<TQueryFilter>(
    QueryParameter<T, TQueryFilter> queryParameter, 
    ISugarQueryable<T> queryable = null
)
    where TQueryFilter : class, IQueryableFilter<T> {
    queryable = queryable ?? base.AsQueryable();

    if (queryParameter.Filter != null)
        queryable = queryParameter.Filter.Apply(queryable);

    var total = await queryable.CountAsync();

    queryable = queryable.Skip((queryParameter.PageIndex - 1) * queryParameter.PageSize).Take(queryParameter.PageSize);

    if (!string.IsNullOrEmpty(queryParameter.SortBy))
        queryable = queryable.OrderByPropertyName(queryParameter.SortBy, queryParameter.IsDescending ? OrderByType.Desc : OrderByType.Asc);

    var list = await queryable.ToListAsync();
    return new PagedResult<T> { TotalCount = total, Rows = list };
}

// 列表查询
Task<List<T>> FindListAsync<TQueryFilter>(
    TQueryFilter filter, 
    ISugarQueryable<T> queryable = null
) 
    where TQueryFilter : class, IQueryableFilter<T> {
    queryable = queryable ?? base.AsQueryable();
    if (filter != null) queryable = filter.Apply(queryable);

    return queryable.ToListAsync();
}

大家可能已经注意到,代码本身并没有变,但是它解决了一个关键问题:如何让整个控制器层变得整洁。回想一下,我们过去是如何处理这类问题的?是不是经常不断地向控制器添加参数?虽然我可能只是一个 CRUD Boy,可我并不愿意就此止步,能从这个再熟悉不过的话题中 “温故而知新”、查漏补缺,这是我写这篇博客的主要目标。让我们沿着这个思路继续探索,此时,我们会发现这里的过滤条件并不符合我们的预期:

Swagger 中展示的分页查询接口
Swagger 中展示的分页查询接口

具体来说,博主期待的查询参数应该是下面这种格式:

api/LlmModel/paginate?pageIndex=1&pageSize=10&modelName=llama3&modelType=0

事实上,在真实的请求中,你将会看到的下面这种格式的查询参数:

api/LlmModel/paginate?pageIndex=1&pageSize=10&filter.modelName=llama3&filter.modelType=0

在某个瞬间,你是否会对这个格式感到困惑呢?实际上,这两种格式都是合理的,前者被称为平铺格式,而后者则被称为嵌套格式。其中,平铺模式对前端友好,缺点是当参数较多时可能会造成混淆,无法区分出哪些是过滤条件;嵌套模式对后端友好,可以直接映射到 QueryParameter 参数的 Filter 属性上,缺点是可能与现有组件不兼容,需要前端在传参时做特殊处理。考虑到,无论是 HTTP 协议还是 RESTful 规范,在这一类问题均未做出强制性约束。因此,在实际工作中,你会看到五花八门的实现方式,如以下代码所示:

// 使用 JSON 编码的查询参数
products?query={"page":{"index":1,"size":10},"sort":{"field":"price","direction":"desc"},"filter":{"name":"laptop","category":"electronics"}}

// 使用组合式的过滤条件
products?page=1&size=10&sort=price&order=desc&filters=name:laptop;category:electronics

结合前文可知,ODataGridify 都选择了组合式的过滤条件写法,而我们期望的是平铺格式的过滤条件写法。由此可见,单单是一个查询就有这么多种写法,秦始皇 “书同文”“车同轨”、统一度量衡的意义在这一刻终于找到了答案,可是话说回来,正是这种灵活性让我们有了发挥的空间,不是吗?那么,该如何解决这个问题呢?这里的方案是实现一个自定义的 IModelBinder,从而改变 ASP.NET Core 模型绑定的默认行为,关键代码如下:

TFilter BindFilter(ModelBindingContext bindingContext) {
    var filter = new TFilter();
    var filterType = typeof(TFilter);
    var properties = filterType.GetProperties();

    foreach (var property in properties) {
        var key = property.Name.ToLower();
        if (!ReservedParameters.Contains(key)) {
            // 同时兼容平铺型以及嵌套型参数
            // ?pageIndex=1&pageSize=10&name=xxx&age=xxx
            // ?pageIndex=1&pageSize=10&filter.name=xxx&filter.age=xxx
            var value = bindingContext.ValueProvider.GetValue(key).FirstValue;
            if (value == null) {
                value = bindingContext.ValueProvider.GetValue($"filter.{key}").FirstValue;
            }
            if (value != null) {
                object convertedValue = null;
                var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;

                if (targetType.IsEnum) {
                    convertedValue = Enum.Parse(targetType, value, true);
                } else if (targetType == typeof(bool) && bool.TryParse(value, out var _)) {
                    convertedValue = bool.Parse(value);
                } else if (targetType == typeof(DateTime) && DateTime.TryParse(value, out var _)) {
                    convertedValue = DateTime.Parse(value);
                } else {
                    if (!string.IsNullOrEmpty(value.ToString())) 
                        convertedValue = Convert.ChangeType(value, targetType);
                }

                property.SetValue(filter, convertedValue);
            }
        }
    }

    return filter;
}

可以注意到,本质上就是对两种类型的查询参数做了兼容,这样做的好处是,现在前后端都可以在各自的舒适区愉快工作,我认为这种效率的提升是有效提升,因为它真正地改善了生产关系,让前后端都能都能从中受益。相反,一味地提升效率而不去改善生产关系,永远都只会造成更多的内卷。这个方案是由我和 Claude 老师一起完成的,我觉得它还有进一步改善的空间,因为现在 IQueryableFilterISugarQueryable 强耦合,这显然不是我们希望看到的结果。当你使用 EntityFramework 时,你会希望这里变成 IQueryable;当你使用 MongoDB 时,你会希望这里变成 FilterDefinitionBuilder。所以,我认为这里还可以再引入一个中间层,你觉得呢?

本文小结

回顾整个探索过程,我不禁莞尔,从在Gridify 中寻找灵感,到亲自动手设计查询方案,再到深入思考参数传递的细节,对我而言,这个过程中是在一次次的思考和实践中,逐渐接近问题的本质。说到底,我们讨论的并不仅仅是一个查询方案,而是如何在灵活性和规范性之间寻找平衡。这个问题,恐怕从软件开发诞生以来就一直存在,而今天我们依旧在探索。也许,正是这种永无止境的探索,构成了编程的真正魅力。AI 无疑会给程序员带来不小的冲击,可仔细想想,它不过是我们工具箱中的新成员,就像我们并不会因为有了电动螺丝刀,就忘记如何使用普通螺丝刀。作为程序员,我们不应该局限在代码生成这一个点,而是要着眼于解决更复杂的问题、改善生产关系。本文探索的通用查询方案,表面上看是一个技术问题,实则触及了软件工程中的普适性难题,即:灵活性与规范性的取舍。我承认,AI 是可以帮我们快速生成各种代码,可未来的软件开发,决不会仅仅是代码的简单堆砌,为什么我们的目标不能是更高层次的系统设计和问题解决?就像这篇文章里提出的方案并非完美无瑕,它依然有改进和优化的空间。所以,请不要放弃你的主观能动性,让 AI 辅助你思考,而不是替你思考

Built with Hugo v0.126.1
Theme Stack designed by Jimmy
已创作 273 篇文章,共计 1032286 字