有状态控制台应用中如何规范设计模型并使用EF Core
问题背景
ASP.NET 项目的处理逻辑通常围绕单次HTTP请求构建,上下文隔离度高;但控制台应用往往需要长期维持运行状态、跟踪内存中的动态数据,复杂度会随运行时长持续抬升。
以典型游戏服务场景为例:
- 项目中存在
GameInstance类,负责表示游戏实时运行状态,维护所有已连接的活跃玩家信息 - 游戏启动阶段需要通过数据库实体
GameEntity加载初始状态 - 游戏运行过程中需要定时/按需将最新状态持久化到数据库
已尝试的实现方案及存在的问题
方案1:领域对象与数据库模型完全分离,每次变更手动构造DB模型同步
实现代码如下:
public class GameInstance { private int Id { get; } public string Name { get; private set; } public string Description { get; private set; } public int Score { get; private set; } private List<NetworkConnection> connectedClients; public GameInstance(GameModel model) { this.Id = model.Id; this.Name = model.Name; this.Description = model.Description; this.Score = model.Score; } public void SetDescription(string description) { using (DbContext db = new DbContext()) { GameModel model = new GameModel() { Id = this.Id, Description = this.Description }; db.Attach(model); model.Description = description; db.SaveChanges(); } this.Description = description; } } public class GameModel { public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } public int Score { get; set; } }
该方案缺陷十分明显:每次数据变更都要重复构造数据库模型对象,写法冗余笨拙,还会产生不必要的内存分配开销。
方案2:GameInstance内部持有GameModel实例
实现逻辑为类的公开属性直接映射内部持有的GameModel对应属性,所有数据变更直接操作内部的模型实例。
该方案虽然减少了重复构造对象的开销,但设计层面合理性存疑。
方案3:合并为单一GameInstance类直接做EF映射
实现逻辑为仅保留GameInstance一个类,通过EF Core Fluent API完成数据库映射配置,所有数据变更直接操作运行时实例,实现逻辑非常简便。
该方案的问题是需要在运行时实例中混入数据库专属字段,和运行时状态跟踪属性(如网络连接、临时对局状态等)混杂,破坏单一职责原则,也不利于落地领域驱动架构。
核心矛盾
对比ASP.NET Core REST接口的常规实现:用户输入通过独立RequestModel接收,查询数据库得到DatabaseModel后根据请求参数完成变更,最后通过ResponseModel返回响应,所有对象在请求处理完成后即可释放,完全符合单一职责原则,EF Core和这种短生命周期场景适配度极高,但这套模式无法直接套用到长生命周期的控制台应用中。
落地建议
你没有过度复杂化问题,长生命周期持有领域状态和EF Core推荐的短生命周期上下文模式本身就是一组需要明确边界的设计矛盾,核心解法是把状态持有和持久化操作彻底解耦,不要让领域对象感知数据库的存在,具体可以按以下规则落地:
- 严格拆分三类对象,职责完全隔离
- 运行时领域对象:即
GameInstance,只负责维护游戏运行逻辑、内存中的动态状态(比如在线连接、实时对局数据),完全不引用任何EF Core相关包、也不持有数据库模型实例,所有属性修改只在内存层面完成,不需要关心持久化逻辑。 - 持久化实体:即和数据库表结构一一对应的
GameEntity,只作为EF Core和数据库交互的载体,不掺杂任何运行时逻辑。 - 仓储层(Repository):单独实现持久化封装类,统一负责两类操作:启动时从数据库查询
GameEntity,映射成GameInstance返回给业务逻辑;需要保存时,接收当前内存中的GameInstance状态,把需要持久化的字段更新到对应实体上,调用SaveChanges完成持久化。
- 运行时领域对象:即
- DbContext始终按操作生命周期短实例化,不要长期持有
不要把DbContext注册为单例跟随服务全生命周期运行,不管是定时持久化还是触发式持久化,每次操作时新建一个DbContext实例,操作完立刻释放:- 定时保存场景:到达保存时间点 -> 新建DbContext -> 查询对应主键的
GameEntity-> 把当前GameInstance里需要持久化的字段值更新到GameEntity-> 调用SaveChanges-> 释放DbContext。 - 即时保存场景:比如玩家修改房间描述需要立刻落库 -> 新建DbContext -> 查询/Attach对应实体 -> 更新目标字段 -> 保存 -> 释放。
这种方式比手动new实体Attach的可靠性更高,EF Core的变更跟踪会自动识别变更字段,生成最优的UPDATE语句,不会产生冗余的全字段更新。
- 定时保存场景:到达保存时间点 -> 新建DbContext -> 查询对应主键的
- 针对已有三个方案的问题修正
- 方案1的冗余问题:把重复的实体构造、字段映射逻辑统一收口到仓储层,不要散写在每个领域方法里,业务代码完全不需要感知数据库实体的存在,自然不会有重复代码。如果觉得手动写字段映射麻烦,可以用轻量对象映射库做自动映射,不要为了省几行代码破坏分层边界。
- 方案2的设计合理性问题:不要让领域对象持有数据库模型实例,这种写法本质是把持久化逻辑耦合进了核心领域对象,后续调整存储结构、加缓存逻辑的时候都会侵入核心业务代码,维护成本极高。
- 方案3的职责混杂问题:运行时对象里的
NetworkConnection这类非持久化字段就算通过Fluent API配置为忽略,本质上还是让核心领域对象依赖了EF的持久化逻辑,后续做单元测试的时候必须引入EF相关依赖,无法实现纯内存的领域逻辑测试,违反领域驱动设计中领域层无外部依赖的原则。
注意:不要为了追求代码最少而把所有逻辑揉到同一个类里,长生命周期服务的可维护性优先级远高于少量样板代码带来的“简洁”。
内容的提问来源于stack exchange,提问作者user19468674

