SQLite 与 FreeSql ORM
这是「从零搭建灌装监控系统」系列第9篇。监控数据不应该只停留在内存里,这篇把 SQLite、FreeSql 和应用启动流程接起来,让采集到的生产记录、报警记录可以查询、追溯,也让后面的导出和日志查看有真正的数据来源。
软件重启后,昨天的记录去哪了
现场第一次试跑的时候,操作员问了我一个很朴素的问题:“刚才那批数据能不能查?”
当时界面上的曲线和状态都还在,程序也没有报错。可是软件一重启,内存集合清空,上一批记录就像没有发生过一样。监控程序如果只负责“现在显示什么”,它更像一个仪表盘,不像生产系统。
我不想一上来就引入服务器数据库。灌装线上的单机部署、离线运行和维护成本都要求存储方案简单一点:数据放在本地文件里,程序自己创建表,现场不用额外安装数据库服务。于是这一篇选择 SQLite 作为存储引擎,FreeSql 作为访问层。
这里的重点不是把 ORM 当成魔法,而是把三个边界说清楚:数据库什么时候初始化,实体如何变成表,业务服务如何使用同一个连接对象。
为什么选 SQLite + FreeSql
SQLite 是一个文件型数据库,适合单机监控软件:
- 不需要独立的数据库服务;
- 一个
.db文件就能随程序和配置一起管理; - 支持事务、索引和常见查询;
- 备份时复制数据库文件即可,部署简单。
但直接拼 SQL 也有问题。生产记录、报警记录、系统日志都会有查询条件,字符串拼接很快就会变成维护负担。FreeSql 提供了实体映射、分页、插入和查询能力,代码表达的还是 C# 对象和查询条件。
示例实体只保留教学需要的字段:
publicclassProductionRecord{[Column(IsIdentity=true,IsPrimary=true)]publiclongId{get;set;}publicDateTimeTime{get;set;}publicstringBatchNo{get;set;}="";publicdecimalVolume{get;set;}publicdecimalTemperature{get;set;}publicdoubleCycleTime{get;set;}publicstringOperator{get;set;}="";}真实项目里还有更多字段,但文章里的实体要服务于解释,不需要把每个设备寄存器都搬进来。字段越多,读者越容易把注意力放到业务细节上,而不是数据模型设计。
DbProvider:数据库入口只创建一次
项目采用一个静态DbProvider作为数据库入口。它不负责业务查询,只负责创建并暴露 FreeSql 实例。
publicstaticclassDbProvider{privatestaticreadonlyobjectSyncRoot=new();privatestaticIFreeSql?_fsql;publicstaticIFreeSqlFsql=>_fsql??thrownewInvalidOperationException("数据库尚未初始化");publicstaticvoidInitialize(stringconnectionString){if(_fsql!=null)return;lock(SyncRoot){if(_fsql!=null)return;_fsql=newFreeSqlBuilder().UseConnectionString(DataType.Sqlite,connectionString).UseAutoSyncStructure(true).UseMonitorCommand((cmd,trace)=>{LogService.Verbose($"[SQL]{cmd.CommandText}\r\n ->{trace}");}).UseLazyLoading(false).Build();}}}这里有两个容易被忽略的细节。
第一,Fsql不应该在未初始化时返回 null。让调用点直接得到清晰异常,比后面某个页面突然出现空引用更容易排查。
第二,Initialize使用双重检查。外层检查避免大多数调用进入锁,锁内检查保证多线程启动时仍然只创建一个实例。数据库初始化往往发生在应用启动阶段,但登录窗口、后台服务和主窗口初始化可能同时开始,不能假设它一定只有一个线程。
AutoSyncStructure 适合什么场景
UseAutoSyncStructure(true)会根据实体结构同步数据表。开发阶段非常省事,新增一个实体属性后重新启动程序,表结构可以自动更新。
但这不是完整的数据库迁移系统。生产环境的表结构变更如果涉及删除字段、数据转换或大表迁移,不能只依赖自动同步。我的建议是:
- 学习项目和单机小工具,可以打开自动同步;
- 发布版本要记录实体变更,重要改动通过迁移脚本处理;
- 不要把测试数据库直接复制成正式数据库;
- 数据库路径放在工作目录或用户数据目录,不要写死开发机路径。
连接字符串应该由路径服务生成:
vardataDirectory=Path.Combine(workRoot,"Data");Directory.CreateDirectory(dataDirectory);vardatabasePath=Path.Combine(dataDirectory,"filltrack.db");DbProvider.Initialize($"Data Source={databasePath};");文章中的filltrack.db是示例名,现场项目应当根据部署约定生成,不能把开发机盘符、用户名或内部目录写进代码。
业务服务只表达业务,不重复造连接
有了统一入口后,数据服务可以很薄:
publicstaticclassDataService{publicstaticTaskSaveProductionRecordAsync(ProductionRecordrecord){returnDbProvider.Fsql.Insert(record).ExecuteAffrowsAsync();}publicstaticTask<List<ProductionRecord>>QueryAsync(DateTimestart,DateTimeend){returnDbProvider.Fsql.Select<ProductionRecord>().Where(x=>x.Time>=start&&x.Time<end).OrderByDescending(x=>x.Time).ToListAsync();}}注意结束时间用了“小于”,而不是把end直接写成“小于等于”。如果界面传入的是某一天的 00:00,半开区间[start, end)更容易组合,也不会因为毫秒精度造成边界重复。
不要在每个服务里重新new FreeSqlBuilder()。那样做会产生多个连接管理器,表结构同步、SQL 监控和事务边界都会变得混乱。服务可以是静态类,也可以通过 DI 注册成单例,关键是数据库访问入口要统一。
SQL 监控不是为了炫技
开发时打开 SQL 监控,可以回答几个很实际的问题:
- 页面加载一次到底发了几条 SQL?
- 分页查询有没有真的使用
LIMIT/OFFSET? - 条件查询是否因为写法问题变成全表扫描?
- 某个按钮卡顿时,是数据库慢还是设备通信慢?
.UseMonitorCommand((cmd,trace)=>{if(DebugSettings.EnableSqlLog)LogService.Verbose($"[SQL]{cmd.CommandText}\n{trace}");})生产环境不建议把所有 SQL 都打到 UI 日志里。日志量太大时,真正的报警反而会被淹没。可以让 SQL 监控由调试配置开关控制,并且不要把密码、令牌等敏感值写入日志。
事务边界要跟业务动作走
一次灌装记录可能包含主记录、明细和统计数据。只插入主记录成功,并不代表整批数据成功。简单场景下单表插入够用;跨多表时要显式建立事务:
usingvarunitOfWork=DbProvider.Fsql.CreateUnitOfWork();try{unitOfWork.Orm.Insert(record).ExecuteAffrows();unitOfWork.Orm.Insert(detail).ExecuteAffrows();unitOfWork.Commit();}catch{unitOfWork.Rollback();throw;}事务不是“所有代码都包起来”。设备通信、等待用户操作、文件导出都不应该放在数据库事务里,否则一个慢设备就能长时间锁住数据库。
踩坑记录
把数据库初始化放在页面构造函数
页面可能被导航多次,构造函数也可能重复执行。数据库初始化应该放在应用启动流程或核心服务初始化阶段,页面只查询数据。
自动同步当成版本迁移
开发时加字段很舒服,生产环境遇到字段删除、历史数据转换就不够了。正式发布前要明确数据库升级策略。
查询时间条件包含边界错误
“今天”的结束时间如果直接传当天 00:00,会查不到当天数据。统一采用[start, end),调用方把结束时间传到下一天 00:00。
把 SQL 全量打印到界面
调试时很有用,上线后会造成日志噪声。SQL 监控应当由调试开关控制,日志级别也要区分。
本篇小结
| 问题 | 做法 |
|---|---|
| 数据保存在哪里 | SQLite 文件数据库 |
| ORM 如何创建 | FreeSql + 实体模型 |
| 如何保证单例 | 双重检查 + 锁 |
| 表结构怎么处理 | 开发阶段 AutoSyncStructure,生产阶段记录迁移 |
| 数据访问放哪里 | DataService,不在页面里直接写 SQL |
| 查询如何稳定 | 统一时间半开区间,必要时增加索引 |
数据库接通以后,下一步就是把采集到的条码变成真正的生产记录。否则数据库只是一个空壳。
下期预告
第10篇:生产追溯:条码触发自动记录
下一篇从轮询数据里的条码变化开始,讨论如何防止同一个条码被重复入库,并把生产记录和当前设备状态关联起来。