
前阵子有个老朋友问我说团队一直用EF Core做数据访问最近要新开一个不那么重的内部系统问我有没有轻量一点的方案。我第一反应就是FreeSql。说实话这玩意儿在圈子里口碑两极分化——喜欢的人觉得它又轻又好用讨厌的人觉得文档不够系统、踩坑全得自己试。不过我建议你别被这些评价带偏自己上手跑一遍比什么都有说服力。今天这篇我就把FreeSql从安装到实战从基础CRUD到AOP、仓储、工作单元这些进阶特性连同我实际踩过的坑一起分享一下给正在观望的朋友做个参考。这篇内容适合谁如果你用的是C#主要做后端服务、内部系统、中小型Web项目讨厌EF Core那种重型约束又觉得Dapper写SQL太裸那FreeSql大概率是你想要的那个中间态。它不挑数据库SqlServer、MySql、PostgreSQL、Sqlite、Oracle基本都能一套API跑通学习成本不高适合直接拿来用。1. FreeSql是什么值不值得入坑先说定位。FreeSql是一个国产开源ORM框架核心目标是“轻量、灵活、功能完整”。它不是一个简单的SQL封装层而是从实体类到数据库表结构从查询语法到级联操作从仓储模式到工作单元从AOP到多租户都提供了完整方案的ORM。它的“轻”体现在哪主要体现在使用方式上。FreeSql的核心入口就是IFreeSql这个接口你像配置一个连接字符串一样初始化它后面所有操作都通过这个对象完成。你不需要写庞大的DbContext子类不需要做复杂的模型配置不需要启动时扫描一堆程序集。一个对象、一个Builder、一个实体类CRUD就能跑了。功能完整性则是它的另一个优势。FreeSql在这么多年的社区迭代里积累了非常多的实用功能CodeFirst自动建表、DbFirst反向生成、lambda表达式查询、级联保存、导航属性延迟加载、仓储模式、UnitOfWork、多租户、读写分离、全局过滤、审计、缓存、软删除等等。这些功能不是鸡肋堆砌很多场景真的能直接用上。我个人对它的评价是如果你的项目不是超大规模、需要极强的DBA深度介入的复杂数据体系FreeSql完全够用而且用起来很顺手。它没有EF Core那种既定的、偏“重”的开发模式也没有Dapper那种需要自己处理太多细节的感觉正好卡在平衡点。当然也有要注意的地方。FreeSql的资料分散在作者博客和官方文档里没有实体书没有那么多系统的视频教程遇到问题更依赖自己动手验证和社区搜索。所以“入坑”这个词是我故意用的——你得有点心理准备不要抱着“看一遍教程就会”的预期。2. 为什么我选FreeSql而不是EF Core/Dapper说实话现在C#生态里ORM的格局基本就是三足鼎立EF Core是大而全的官方方案Dapper是极轻量的微ORMFreeSql夹在中间。选型这个事没有绝对的对错只有场景匹配。我把这三个放到一起做个简单对比方便你直观感受。维度EF CoreDapperFreeSql概念重量级重DbContext、模型配置、迁移一体极轻本质是扩展AD)方法中等入口就是IFreeSql学习曲线平坦但繁琐概念多要求你懂SQL平坦既有CRUD又有复杂查询SQL控制力弱护给生成的SQL强SQL全自己写中等复杂查询可写SQL也可用Lambda自动建表支持但有迁移概念要学不支持支持SyncStructure一句同步级联处理支持但配置繁琐自己写支持Include/Join灵活多数据库支持支持支持得更彻底API统一仓储/工作单元需另装包/NuGet组合完全没有内置开箱即用国产社区一般少相对活跃有QQ群讨论你可以看到FreeSql最大的特色就是把“重量级功能”和“轻量级使用方式”结合到了一起。EF Core适合大团队、大项目因为它的约束让代码风格统一Dapper适合追求SQL可控、性能极致的团队而FreeSql适合那种“我们想要规范但不想被框架绑架”的团队这也是我推荐它的理由。再补一句性能相关的经验。很多人问FreeSql和Dapper性能差多少。我实测下来的感受是FreeSql在简单CRUD上确实比Dapper慢一点但这点差距在绝大多数业务系统里根本感知不到因为瓶颈99%都不在这。它比EF Core的普通查询快不少尤其是选择性查询和批量操作。所谓“性能差”基本属于理论层面不是决策依据。3. 快速上手安装、初始化与第一个CRUD3.1 安装和初始化安装很简单NuGet搜索FreeSql找到主包直接装。我这里以.Net 6/7/8为例一条命令搞定dotnet add package FreeSql如果你的项目还要用仓储模式额外装一个FreeSql.Repository如果用AOP、管理功能再看情况装对应扩展包。我先不铺开主包足够跑起来做CRUD。连接初始化是FreeSql最关键的一步几乎决定你后面所有代码的风格。我的习惯是用一个静态变量或单例持有IFreeSql实例而不是每个方法new一次static IFreeSql fsql new FreeSqlBuilder() .UseConnectionString(DataType.SqlServer, Data Source.;Initial CatalogDemoDb;User Idsa;Passwordxxx;) .UseAutoSyncStructure(true) // 开启了自动同步表结构 .Build();.UseAutoSyncStructure(true)这个很容易被忽略但这是CodeFirst的核心开关。开启之后你新建实体类运行程序表会自动建出来。老手通常也会配合SyncStructure方法手动调用控制同步的时机。3.2 实体类定义与建表实体类的写法和EF Core类似但更奔放一点。以下是我常用的一个示例class Blog { public long Id { get; set; } public string Title { get; set; } public string Content { get; set; } public DateTime CreateTime { get; set; } public bool IsDeleted { get; set; } // 软删除标记 }你可能会问没有主键特性FreeSql怎么知道主键约定是Id作为主键和EF Core的做法不太一样。如果你不想用约定可以用[Column(IsPrimary true)]显示指定。这里我补充一个经验长整型自增主键在分布式环境下更友好所以我个人习惯用long而不是int。关于建表我更推荐显式同步因为自动同步在某些并发场景下可能出现重复建表的竞争问题。虽然FreeSql在内部处理了锁但显式同步更容易控制在一个入口统一调用一次更稳妥fsql.CodeFirst.SyncStructure(typeof(Blog));如果你实体不少也可以传一个类型数组一次性同步fsql.CodeFirst.SyncStructure(typeof(Blog), typeof(Post), typeof(Tag));3.3 第一个增删改查实体类搞定后CRUD就是这么直白// 插入 var blog new Blog { Title Hello FreeSql, Content first post }; fsql.Insert(blog).ExecuteAffrows(); // 根据Id获取 var id blog.Id; var getBlog fsql.SelectBlog().Where(a a.Id id).First(); // 修改 getBlog.Title Updated; fsql.UpdateBlog().SetSource(getBlog).ExecuteAffrows(); // 删除 fsql.DeleteBlog().Where(a a.Id id).ExecuteAffrows();你看插入一条、查询一条、更新一条、删除一条四行核心代码没有DbContext没有SaveChanges没有复杂的配置。数据实体类的状态管理就更轻了——FreeSql默认不追踪实体状态你要改哪个字段就自己set它不会像EF Core那样帮你搞一堆状态追踪。插入的时候还有个细节如果你在实体类里没指定[Column(IsPrimary true)]而主键Id是自增的插入之后FreeSql会把自增值回填到实体上。所以上面那段代码里blog.Id在插入后就已经有值了这点对很多从EF Core转过来的人来说很惊喜。4. 日常高频查询、分页、级联与导航属性CRUD只是入门日常用得多的是查询和级联。FreeSql最吸引我的地方就是它的查询语法可读性好而且很多细节都帮你处理好了。4.1 查询与分页查询就是链式调用重点说一下分页。早些年用EF Core分页还得自己算Skip和TakeFreeSql的Page方法一步到位var list fsql.SelectBlog() .Where(a a.Title.Contains(FreeSql)) .OrderByDescending(a a.CreateTime) .Page(2, 10) // 第2页每页10条 .ToList();如果你需要同时拿到总数和列表我推荐用更高效的两步式var total fsql.SelectBlog().Where(a a.IsDeleted false).Count(); var pageData fsql.SelectBlog() .Where(a a.IsDeleted false) .OrderByDescending(a a.CreateTime) .Skip(20).Take(10) .ToList();Page内部也是转成Skip/Take。总数和分页数据分开拿逻辑更清晰而且FreeSql的Count()是直接执行SELECT COUNT(1)不是把列表查回来再数效率没问题。另一个我经常用的小技巧是投影。如果实体的字段很多但界面只要标题和创建时间不投影就可能把整个大字段比如Content也查出来浪费IO。所以我习惯这样写var dtos fsql.SelectBlog() .Where(a a.Id 100) .ToList(a new BlogBriefDto { Id a.Id, Title a.Title, CreateTime a.CreateTime });投影到Dto类型还能避免导航属性循环序列化的问题。这个我们后面说。4.2 级联查询Include还是JoinFreeSql给级联查询提供了好几个选择最容易混淆的就是Include和Join。我说说我的用法和踩坑心得。Include是“按导航属性加载”适合ICollection或者单对象导航属性的场景生成的SQL本质是主表子表联合查询或者分批查询后内存组装。比如博客和文章的关系class Blog { public long Id { get; set; } public string Title { get; set; } [Navigate(nameof(Post.BlogId))] public ListPost Posts { get; set; } } class Post { public long Id { get; set; } public long BlogId { get; set; } public string Body { get; set; } }查询时这样写就能把文章的列表一并加载出来var blog fsql.SelectBlog() .Include(a a.Posts) .Where(a a.Id 1) .First();这里有个重要的经验Include里如果还带着ThenInclude就一层层写比如再加载Post下的评论。但导航属性不要搞得太深两三层就够多了太深会带来性能陷阱。Join则是真正的SQL join查询适合按关联条件做过滤、聚合。比如我要查所有写了文章标题包含“FreeSql”的博客的Id和标题var result fsql.SelectBlog, Post() .InnerJoin((b, p) b.Id p.BlogId) .Where((b, p) p.Title.Contains(FreeSql)) .ToList((b, p) new { BlogId b.Id, BlogTitle b.Title, PostTitle p.Title });简而言之需要加载导航属性、要拿到子集合用Include需要跨表过滤、需要统计、需要自定义返回结构用Join。别混着用不然生成的SQL你看得头疼。4.3 延迟加载与序列化陷阱FreeSql也支持导航属性的延迟加载这在EF Core里很常见。开启方式很简单在实体类导航属性上加一个特性[Navigate(nameof(Post.BlogId)), Lazy] public ListPost Posts { get; set; }这么做的好处是用到的时候才查避免一次查出太多不必要的数据。但我要提醒的是延迟加载在序列化场景里就是灾难。你把实体直接返回给Web API序列化器递归访问导航属性很容易出现循环引用或触发N1查询。我最早用FreeSql就踩过这个坑后来总结出来的规矩是启用延迟加载的实体不要直接作为API返回值。要么用投影转化为Dto要么在初始化时用Include显式加载。如果你依赖全局关闭序列化循环引用治标不治本因为N1查询还在。5. 进阶特性仓储模式、工作单元、AOP与CodeFirst日常CRUD用IFreeSql直接操作没啥问题但项目一多人一多代码风格就开始乱了。这时候仓储模式和工作单元的价值就体现出来了。5.1 仓储模式代码更规范FreeSql.Repository扩展包提供了BaseRepository和IBaseRepositoryT。用法很简单var repo fsql.GetRepositoryBlog();然后你就可以用InsertAsync、UpdateAsync、DeleteAsync、Select这些方法写起来和直接操作IFreeSql差不多但加了一层约束和封装。我的习惯是一个聚合根一个仓储业务代码不直接持有IFreeSql而是注入仓储接口这样单元测试也更容易mock。仓储模式本身就体现的是“防止业务代码到处直接操作数据源”的思想。不用仓储也能跑但项目一复杂你就会发现到处是零散的fsql.Select、fsql.Update改需求的时候特别头疼。5.2 工作单元与事务FreeSql的事务和工作单元是结合起来的看着很顺眼。标准写法是using var uow fsql.CreateUnitOfWork(); try { var blogRepo uow.GetRepositoryBlog(); var postRepo uow.GetRepositoryPost(); var blog new Blog { Title 事务测试 }; blogRepo.Insert(blog); var post new Post { BlogId blog.Id, Body 内容 }; postRepo.Insert(post); uow.Commit(); } catch { uow.Rollback(); throw; }这里有个细节在同一个工作单元里所有仓储操作共享同一个数据库连接和事务。如果某个仓储失败了Rollback会把所有变更回滚。我踩过的一个坑是Commit抛出异常时忘记Rollback后来我直接在catch里显式调uow.Rollback()然后再抛出异常保证连接释放和事务清理。5.3 AOP与审计FreeSql内置的AOP机制里最常用的是AuditValue、AuditSettle和CurdBefore。简单说你可以在插入或更新前监听并修改实体值。比如统一填充创建时间、更新时间fsql.Aop.AuditValue (_, e) { if (e.Column.Name CreateTime e.AuditValue null) { e.AuditValue DateTime.Now; } };这个写法非常实用比我以前在EF Core里重写SaveChanges或者用数据库默认值都简单得多。它也能做日志审计比如在CurdBefore事件里打印执行的SQL方便开发期排查问题fsql.Aop.CurdBefore (_, e) { Console.WriteLine(e.Sql); };如果你生产环境需要SQL慢日志这个回调就是现成的切入点。5.4 CodeFirst的Sdutog和DbFirst前面提了SyncStructure那就是CodeFirst的核心。实际开发中我始终用SyncStructure来做表的增量同步它不会误删多余字段但要注意它默认不会改字段类型。如果你把Title从string改成int它可能不会崩溃但数据库的字段类型也不会跟着变需要你自己改。DbFirst则适合数据库表已经存在、想反向生成实体类的场景。FreeSql提供了IDbFirst接口用起来也很直接fsql.DbFirst.GetTablesByDatabase(DemoDb);然后根据你要的表结构生成实体类。不过说实话它的实体类生成模板还是有点朴素生成的特性注释不多真生产环境还是得手动调整一下。但作为起步没问题比自己手工写几十个类省事得多。6. 入坑必看我踩过的五个典型坑FreeSql的坑大多不在于它功能不够而在于“它太灵活了容易玩脱”。我按实际频率整理一下给大家当速查表。问题现象根因解决办法导航属性序列化循环引用延迟加载直接返回实体用投影/定义Dto不直接返回实体查询不到延迟加载的数据没有配置[Lazy]或没有预先Include确认导航属性特性/UseAutoSyncStructure更新后其他实例看不到最新值不同作用域用不同IFreeSql实例整个应用尽量共享同一个IFreeSql实例大字段如Content频繁查询每次全字段查询IO浪费投影只查需要的字段事务整体失败但部分提交Commit后没正确处理异常在catch中显式Rollback6.1 多个IFreeSql实例的坑有个朋友的项目里每个服务创建了自己的IFreeSql结果更新完数据另一个服务怎么查都是旧值。一看代码两个服务各连各的、各建各的连接池混乱得很。解决办法很简单连接池有个核心概念叫“复用”IFreeSql对象本身建议全局一个实例。如果你要区分不同业务可以在一个实例上挂多个数据源或者用AOP去切库不要随手new。6.2 启用自动同步结构但字段没更新有同学开了UseAutoSyncStructure(true)新增字段的时候表自动加字段了他以为alter字段类型也会自动结果改了实体类型后数据库没变报错半天。FreeSql的SyncStructure确实能自动加新字段但修改字段类型是需要谨慎处理的危险操作默认不会动。所以遇到改字段类型你要么手动SQL要么删表重建仅限开发环境要么查清楚特性配置。这个我之前也吃过亏建议实体重构后跑个差异脚本。6.3 关于事务里使用仓储的混淆工作单元里拿仓储有人用全局的fsql.GetRepositoryT()去操作而不是从uow拿仓储导致事务完全失效。原因很直接工作单元里的仓储绑定了uow的连接和事务而全局仓储用的是全局连接两者根本不共享。所以我的经验是只要走了工作单元就必须从uow里拿仓储绝对不要混着用。6.4 大字段查询的IO问题一次我排查一个接口很慢的问题查了SQL才发现每次列表查询都SELECT了所有字段包括一个nvarchar(max)的正文几十行的列表查询传输量大得离谱。后来改成投影只查Id、Title、CreateTime接口响应时间直接下来了。这类问题不是FreeSql的缺陷而是使用习惯。FreeSql提供了很强的投影能力你在列表页就该用它。7. 最后分享一点经验与建议这几天想明白了选型自由其实是双刃剑。FreeSql灵活意味着团队要有自己的规范否则代码风格会迅速失控。我这两年用下来总结了几条属于自己的“使用公约”第一尽量用IFreeSql单例。整个应用生命周期只初始化一次不要到处new。第二能用仓储模式就尽量用仓储尤其在多人协作的代码库里这层接口能让代码风格收敛、可测试性更高。第三业务复杂的地方允许直接写SQL字符串。FreeSql的Ado方法提供了执行原生SQL的能力很多人忽略了它。非要绕过ORM上的某种不便时这是好保底但要封装在合适的地方别撒得到处都是。第四实体定义大字段长文本、blob不要和常用字段挤在一个表里查询。实在要放一起查询时记得投影这是很多学员真正遇到的性能瓶颈。要是你正在纠结要不要引入FreeSql我的建议是拿一个非核心的小功能模块比如一个简单的字典管理或者日志查询先跑两周试试水。真实业务跑起来比看十篇对比文章都管用。如果你的团队之前用Dapper比较多、SQL功底普遍很好FreeSql的lambda语法也能很快适应。反正找一个最小的切入点练一练再决定要不要全面铺开。