欢迎光临
我们一直在努力

从零搭建灌装监控系统(九):SQLite与FreeSql ORM,让监控数据真正落盘

SQLite 与 FreeSql ORM

这是「从零搭建灌装监控系统」系列第9篇。监控数据不应该只停留在内存里,这篇把 SQLite、FreeSql 和应用启动流程接起来,让采集到的生产记录、报警记录可以查询、追溯,也让后面的导出和日志查看有真正的数据来源。

SQLite 与 FreeSql 数据落盘链路


软件重启后,昨天的记录去哪了

现场第一次试跑的时候,操作员问了我一个很朴素的问题:“刚才那批数据能不能查?”

当时界面上的曲线和状态都还在,程序也没有报错。可是软件一重启,内存集合清空,上一批记录就像没有发生过一样。监控程序如果只负责“现在显示什么”,它更像一个仪表盘,不像生产系统。

我不想一上来就引入服务器数据库。灌装线上的单机部署、离线运行和维护成本都要求存储方案简单一点:数据放在本地文件里,程序自己创建表,现场不用额外安装数据库服务。于是这一篇选择 SQLite 作为存储引擎,FreeSql 作为访问层。

这里的重点不是把 ORM 当成魔法,而是把三个边界说清楚:数据库什么时候初始化,实体如何变成表,业务服务如何使用同一个连接对象。


为什么选 SQLite + FreeSql

SQLite 是一个文件型数据库,适合单机监控软件:

  • 不需要独立的数据库服务;
  • 一个 .db 文件就能随程序和配置一起管理;
  • 支持事务、索引和常见查询;
  • 备份时复制数据库文件即可,部署简单。

但直接拼 SQL 也有问题。生产记录、报警记录、系统日志都会有查询条件,字符串拼接很快就会变成维护负担。FreeSql 提供了实体映射、分页、插入和查询能力,代码表达的还是 C# 对象和查询条件。

示例实体只保留教学需要的字段:

public class ProductionRecord
{
[Column(IsIdentity = true, IsPrimary = true)]
public long Id { get; set; }

public DateTime Time { get; set; }
public string BatchNo { get; set; } = "";
public decimal Volume { get; set; }
public decimal Temperature { get; set; }
public double CycleTime { get; set; }
public string Operator { get; set; } = "";
}

真实项目里还有更多字段,但文章里的实体要服务于解释,不需要把每个设备寄存器都搬进来。字段越多,读者越容易把注意力放到业务细节上,而不是数据模型设计。


DbProvider:数据库入口只创建一次

项目采用一个静态 DbProvider 作为数据库入口。它不负责业务查询,只负责创建并暴露 FreeSql 实例。

public static class DbProvider
{
private static readonly object SyncRoot = new();
private static IFreeSql? _fsql;

public static IFreeSql Fsql => _fsql
?? throw new InvalidOperationException("数据库尚未初始化");

public static void Initialize(string connectionString)
{
if (_fsql != null)
return;

lock (SyncRoot)
{
if (_fsql != null)
return;

_fsql = new FreeSqlBuilder()
.UseConnectionString(DataType.Sqlite, connectionString)
.UseAutoSyncStructure(true)
.UseMonitorCommand((cmd, trace) =>
{
LogService.Verbose($"[SQL] {cmd.CommandText}\\r\\n -> {trace}");
})
.UseLazyLoading(false)
.Build();
}
}
}

这里有两个容易被忽略的细节。

第一,Fsql 不应该在未初始化时返回 null。让调用点直接得到清晰异常,比后面某个页面突然出现空引用更容易排查。

第二,Initialize 使用双重检查。外层检查避免大多数调用进入锁,锁内检查保证多线程启动时仍然只创建一个实例。数据库初始化往往发生在应用启动阶段,但登录窗口、后台服务和主窗口初始化可能同时开始,不能假设它一定只有一个线程。


#mermaid-svg-KxzizhqCZxpwJSII{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KxzizhqCZxpwJSII .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KxzizhqCZxpwJSII .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KxzizhqCZxpwJSII .error-icon{fill:#552222;}#mermaid-svg-KxzizhqCZxpwJSII .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KxzizhqCZxpwJSII .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KxzizhqCZxpwJSII .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KxzizhqCZxpwJSII .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KxzizhqCZxpwJSII .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KxzizhqCZxpwJSII .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KxzizhqCZxpwJSII .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KxzizhqCZxpwJSII .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KxzizhqCZxpwJSII .marker.cross{stroke:#333333;}#mermaid-svg-KxzizhqCZxpwJSII svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KxzizhqCZxpwJSII p{margin:0;}#mermaid-svg-KxzizhqCZxpwJSII .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-KxzizhqCZxpwJSII .cluster-label text{fill:#333;}#mermaid-svg-KxzizhqCZxpwJSII .cluster-label span{color:#333;}#mermaid-svg-KxzizhqCZxpwJSII .cluster-label span p{background-color:transparent;}#mermaid-svg-KxzizhqCZxpwJSII .label text,#mermaid-svg-KxzizhqCZxpwJSII span{fill:#333;color:#333;}#mermaid-svg-KxzizhqCZxpwJSII .node rect,#mermaid-svg-KxzizhqCZxpwJSII .node circle,#mermaid-svg-KxzizhqCZxpwJSII .node ellipse,#mermaid-svg-KxzizhqCZxpwJSII .node polygon,#mermaid-svg-KxzizhqCZxpwJSII .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KxzizhqCZxpwJSII .rough-node .label text,#mermaid-svg-KxzizhqCZxpwJSII .node .label text,#mermaid-svg-KxzizhqCZxpwJSII .image-shape .label,#mermaid-svg-KxzizhqCZxpwJSII .icon-shape .label{text-anchor:middle;}#mermaid-svg-KxzizhqCZxpwJSII .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KxzizhqCZxpwJSII .rough-node .label,#mermaid-svg-KxzizhqCZxpwJSII .node .label,#mermaid-svg-KxzizhqCZxpwJSII .image-shape .label,#mermaid-svg-KxzizhqCZxpwJSII .icon-shape .label{text-align:center;}#mermaid-svg-KxzizhqCZxpwJSII .node.clickable{cursor:pointer;}#mermaid-svg-KxzizhqCZxpwJSII .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KxzizhqCZxpwJSII .arrowheadPath{fill:#333333;}#mermaid-svg-KxzizhqCZxpwJSII .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KxzizhqCZxpwJSII .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KxzizhqCZxpwJSII .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KxzizhqCZxpwJSII .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KxzizhqCZxpwJSII .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KxzizhqCZxpwJSII .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KxzizhqCZxpwJSII .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KxzizhqCZxpwJSII .cluster text{fill:#333;}#mermaid-svg-KxzizhqCZxpwJSII .cluster span{color:#333;}#mermaid-svg-KxzizhqCZxpwJSII div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KxzizhqCZxpwJSII .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KxzizhqCZxpwJSII rect.text{fill:none;stroke-width:0;}#mermaid-svg-KxzizhqCZxpwJSII .icon-shape,#mermaid-svg-KxzizhqCZxpwJSII .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KxzizhqCZxpwJSII .icon-shape p,#mermaid-svg-KxzizhqCZxpwJSII .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KxzizhqCZxpwJSII .icon-shape rect,#mermaid-svg-KxzizhqCZxpwJSII .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KxzizhqCZxpwJSII .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KxzizhqCZxpwJSII .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KxzizhqCZxpwJSII :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是

否

应用启动

Fsql 已创建?

直接返回

进入 lock

Fsql 已创建?

FreeSqlBuilder 构建实例

AutoSyncStructure 同步表结构

暴露 DbProvider.Fsql

AutoSyncStructure 适合什么场景

UseAutoSyncStructure(true) 会根据实体结构同步数据表。开发阶段非常省事,新增一个实体属性后重新启动程序,表结构可以自动更新。

但这不是完整的数据库迁移系统。生产环境的表结构变更如果涉及删除字段、数据转换或大表迁移,不能只依赖自动同步。我的建议是:

  • 学习项目和单机小工具,可以打开自动同步;
  • 发布版本要记录实体变更,重要改动通过迁移脚本处理;
  • 不要把测试数据库直接复制成正式数据库;
  • 数据库路径放在工作目录或用户数据目录,不要写死开发机路径。

连接字符串应该由路径服务生成:

var dataDirectory = Path.Combine(workRoot, "Data");
Directory.CreateDirectory(dataDirectory);
var databasePath = Path.Combine(dataDirectory, "filltrack.db");
DbProvider.Initialize($"Data Source={databasePath};");

文章中的 filltrack.db 是示例名,现场项目应当根据部署约定生成,不能把开发机盘符、用户名或内部目录写进代码。


业务服务只表达业务,不重复造连接

有了统一入口后,数据服务可以很薄:

public static class DataService
{
public static Task SaveProductionRecordAsync(ProductionRecord record)
{
return DbProvider.Fsql.Insert(record).ExecuteAffrowsAsync();
}

public static Task<List<ProductionRecord>> QueryAsync(
DateTime start, DateTime end)
{
return DbProvider.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 监控由调试配置开关控制,并且不要把密码、令牌等敏感值写入日志。


事务边界要跟业务动作走

一次灌装记录可能包含主记录、明细和统计数据。只插入主记录成功,并不代表整批数据成功。简单场景下单表插入够用;跨多表时要显式建立事务:

using var unitOfWork = 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篇:生产追溯:条码触发自动记录

下一篇从轮询数据里的条码变化开始,讨论如何防止同一个条码被重复入库,并把生产记录和当前设备状态关联起来。

赞(0)
未经允许不得转载:171主机测评 » 从零搭建灌装监控系统(九):SQLite与FreeSql ORM,让监控数据真正落盘
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址