简介:面向 .NET 桌面开发者的 Entity Framework 与 WinForms 实战示例工程,适合具备 C# 基础、希望掌握数据库交互与连接配置管理的开发者。项目以 Code First 组织实体与 DbContext,重点演示程序运行期间读取、修改并保存配置文件中的连接字符串,再借助 EF 完成增删改查,可缓解多环境部署时连接地址频繁变更、硬编码难以维护的问题。压缩包共 240 个文件、约 61.27MB,以 95 个 cs 源码和 6 个 csproj 工程文件为主体,辅以 dll、pdb、resources 等编译与界面资源,另有 9 个 config 配置、xml 说明及 mdf/ldf 数据库文件,构成可直接运行的完整解决方案。已有 454 人学习下载。读者可获取分层项目结构、连接字符串动态切换代码、数据库与示例数据,并理解 EF 与 WinForms 结合时的上下文生命周期与配置刷新方式,适合作为课程设计或小型管理系统的参考骨架。
1. 现场改连接串不用重新编译,这个 EF + WinForms 组合值得拆一遍
上周有个做设备管理软件的开发者问我:客户现场把数据库从测试机搬到正式服务器,程序一跑就报连接失败,是不是只能重新打包发版?我说不用,EF + WinForms 这套组合只要连接串不是硬编码在代码里,界面上给个输入框、写回配置文件、重建 DbContext 就行。他愣了半秒说,那不就是个配置窗口吗。对,就是个配置窗口,但把这条链路跑通、跑扎实,里面有几个坑是必须踩过才知道的。这个项目干的就是这件事:一个 WinForms 客户端,内置一份数据库,连接串存在配置文件里,程序运行中可以改、改完立刻生效,不用关程序更不用重新编译。适合谁?适合手里有一堆现场部署、数据库地址三天两头变、又不想为改一行连接串走一遍发版流程的人。
2. 项目骨架与启动链路:EF 上下文、配置文件、入口窗体怎么串起来
2.1 目录结构与分层
先说结论:这个项目没有把 EF 直接塞进 Form 里。连接串相关的东西被刻意收拢到两个地方——配置文件和数据访问层,其余窗体一律不碰连接串。这么做的好处是,后面要换存储方式(从 JSON 换到注册表、换到加密文件)时,改动面只有一处。
目录大致长这样:
项目根目录/ ├── App.WinForms/ # 启动项目,只放窗体和交互 │ ├── Program.cs # 入口,构建 DI 容器 │ ├── Forms/ │ │ ├── MainForm.cs # 主界面 │ │ └── DbConfigForm.cs # 连接配置窗体 │ ├── appsettings.json # 连接串与其它配置 │ └── App.WinForms.csproj ├── App.Data/ # EF Core 数据访问层 │ ├── AppDbContext.cs │ ├── DbContextFactory.cs │ ├── Entities/ │ └── App.Data.csproj ├── App.Config/ # 配置读写与校验 │ ├── ConnectionConfigStore.cs │ └── ConnectionStringValidator.cs └── Database/ # 附带数据库文件 ├── AppDb.mdf └── init.sqlApp.WinForms是唯一带窗体的项目,它的职责边界很清楚:拿到用户输入的连接串、调校验、调保存、刷新界面状态,自己不去 new DbContext。App.Data里只有实体、上下文和工厂,工厂负责把磁盘上的连接串变成DbContextOptions。App.Config是一个很薄的项目,放读写和格式校验,不引用 EF,这样单元测试里可以单独测它。Database目录随安装包一起发布,装到现场后就是程序目录下的一个子目录。
项目引用关系是单向的:WinForms 引用 Data 和 Config,Data 引用 Config,Config 谁都不引用。包引用上,WinForms 项目需要Microsoft.Extensions.Configuration.Json、Microsoft.Extensions.DependencyInjection,Data 项目需要Microsoft.EntityFrameworkCore.SqlServer(或用 SQLite 就换成Microsoft.EntityFrameworkCore.Sqlite)。版本不要在每个 csproj 里各写各的,常见做法是在根目录放一个Directory.Build.props,用一个属性统一控制,升级的时候只改一处。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <!-- 版本由根目录 Directory.Build.props 统一注入,避免各项目版本漂移 --> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="$(ExtVersion)" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(ExtVersion)" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\App.Data\App.Data.csproj" /> <ProjectReference Include="..\App.Config\App.Config.csproj" /> </ItemGroup> </Project>这段 csproj 里有两个点值得注意。TargetFramework必须是net8.0-windows而不是net8.0,否则UseWindowsForms不生效,编译时会提示找不到 WinForms 相关的程序集。Nullable打开之后,连接串返回值会被标成string?,这会逼着你在工厂里处理空值,比运行到一半抛空引用强得多。
2.2 启动时的连接串加载顺序
配置不是只有一份。实际部署里常见三层来源:安装目录里的appsettings.json、运维临时设置的环境变量、以及代码里的兜底默认值。加载顺序决定了谁覆盖谁,ConfigurationBuilder的规则是后加的源覆盖先加的源,所以顺序要设计成「默认值最前,环境变量最后」。
// Program.cs static void Main() { ApplicationConfiguration.Initialize(); var config = new ConfigurationBuilder() // 基准目录必须是 EXE 所在目录,不能依赖当前工作目录 .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) // 环境变量前缀限定为 APPDB_,避免读到系统里无关的变量 .AddEnvironmentVariables("APPDB_") .Build(); var services = new ServiceCollection(); services.AddSingleton<IConfiguration>(config); services.AddSingleton<ConnectionConfigStore>(); services.AddSingleton<ConnectionStringValidator>(); services.AddSingleton<DbContextFactory>(); services.AddTransient<MainForm>(); services.AddTransient<DbConfigForm>(); using var provider = services.BuildServiceProvider(); Application.Run(provider.GetRequiredService<MainForm>()); }SetBasePath(AppContext.BaseDirectory)这行是血泪经验。用Directory.GetCurrentDirectory()的话,从快捷方式启动、从计划任务启动、从资源管理器拖文件到 EXE 上启动,工作目录可能是C:\Windows\System32,配置文件直接读不到。optional: false表示文件缺失就抛异常,这是故意的——配置文件缺失属于部署错误,早报比晚报好。reloadOnChange: true是为了让外部手工改文件后不用重启,但注意它只对IConfiguration的读取生效,EF 那边还是要靠工厂重建。
AddEnvironmentVariables("APPDB_")的前缀不能省。省掉之后,机器上任何名字碰巧叫ConnectionStrings的环境变量都会被读进来,排查起来非常难受。加上前缀后,现场应急可以这样覆盖:
:: 临时把程序指向备份库,不动安装目录里的配置文件 set APPDB_ConnectionStrings__Default=Server=10.0.0.8;Database=AppDbBak;User Id=app;Password=***;注意环境变量名里的双下划线,它对应 JSON 里的层级分隔符,ConnectionStrings__Default展开后就是ConnectionStrings:Default。写成单下划线是读不到的,这个细节坑过不少人。
2.3 第一次跑起来的最小步骤
拿到源码后不要急着点调试,按顺序走一遍能省很多时间。
第一步,还原并编译:
dotnet restore dotnet build -c Debug第二步,确认数据库文件的位置。如果附带的是.mdf,需要本机装了 LocalDB 或者完整实例;如果是.db(SQLite),只要文件在Database目录里就行。把Database目录整个复制到输出目录下,或者在该文件的属性里设置「如果较新则复制」。
第三步,检查appsettings.json里的初始连接串。首次运行用的应该是开发机上的地址,现场的地址留到程序里改。
{ "ConnectionStrings": { "Default": "Server=(localdb)\\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\\AppDb.mdf;Database=AppDb;Trusted_Connection=True;" } }第四步,运行:
dotnet run --project App.WinForms如果启动就抛「未能找到数据库文件」,八成是|DataDirectory|没有被替换成实际路径。这个占位符在 .NET Framework 时代由框架自动处理,到了 .NET Core 之后需要自己设置,方法在 4.1 节里写。另一个常见问题是平台目标:老项目习惯 AnyCPU 加「首选 32 位」,换了机器就报 BadImageFormatException,新项目直接在 csproj 里指定x64或x86更省事。
3. 核心实现拆解:连接配置从窗体到 DbContext 的完整链路
3.1 连接配置的读写封装
连接串最终落在appsettings.json的ConnectionStrings:Default节点上。读写这段代码看着简单,但要处理三件事:路径不能受工作目录影响、并发写不能把文件写坏、修改时不能把 JSON 里其它节点搞丢。
public class ConnectionConfigStore { private readonly string _configPath; private readonly object _lock = new(); public ConnectionConfigStore(IConfiguration config) { // 固定指向程序目录,和 Program.cs 里的 SetBasePath 保持一致 _configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); } public string? Read() { lock (_lock) { if (!File.Exists(_configPath)) return null; var json = File.ReadAllText(_configPath); using var doc = JsonDocument.Parse(json); return doc.RootElement.TryGetProperty("ConnectionStrings", out var cs) && cs.TryGetProperty("Default", out var def) ? def.GetString() : null; } } public void Save(string connectionString) { lock (_lock) { var json = File.ReadAllText(_configPath); var node = JsonNode.Parse(json)!; // 只改这一个节点,其余配置原样保留 node["ConnectionStrings"]!["Default"] = connectionString; var tmp = _configPath + ".tmp"; File.WriteAllText(tmp, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); // 先写临时文件再整体替换,避免写到一半断电留下半个 JSON File.Move(tmp, _configPath, overwrite: true); } } }lock在这里不是摆设。配置窗体可能有「测试」和「保存」两个动作并发触发,多线程同时写同一个文件会直接抛 IOException。用JsonNode而不是重新序列化一个匿名对象,是为了保住文件里其它配置节——很多项目在这里图省事直接JsonSerializer.Serialize(new { ConnectionStrings = ... }),结果每次保存都把日志级别、界面参数全冲掉。
配一个校验器,在保存前把明显错误的连接串挡下来:
public static class ConnectionStringValidator { public static bool TryBuild(string raw, out string error) { error = string.Empty; try { // 用 SqlConnectionStringBuilder 解析,键名拼错会直接抛异常 var b = new SqlConnectionStringBuilder(raw); if (string.IsNullOrWhiteSpace(b.DataSource)) { error = "服务器地址不能为空"; return false; } if (string.IsNullOrWhiteSpace(b.InitialCatalog)) { error = "数据库名不能为空"; return false; } return true; } catch (ArgumentException ex) { error = "连接串格式不正确:" + ex.Message; return false; } } }用SqlConnectionStringBuilder做校验,比自己写正则可靠得多。它能识别Data Source、Server、Address这些同义键名,也能发现User Id拼成Userid之类的问题。如果项目用的是 SQLite,把这里换成SqliteConnectionStringBuilder,逻辑一模一样。
3.2 用 DbContextOptionsBuilder 动态构造上下文
EF 的上下文默认是从配置文件读连接串的,一旦启动就固定了。要做到运行中可改,就不能让框架自己管,得自己建工厂。
public class DbContextFactory { private readonly ConnectionConfigStore _store; private DbContextOptions<AppDbContext>? _cached; public DbContextFactory(ConnectionConfigStore store) => _store = store; public AppDbContext Create() { var conn = _store.Read(); if (string.IsNullOrWhiteSpace(conn)) throw new InvalidOperationException("连接串为空,请先在配置窗口填写"); // 连接串没变就复用 options,省掉每次查询重新解析的开销 _cached ??= BuildOptions(conn); return new AppDbContext(_cached); } private static DbContextOptions<AppDbContext> BuildOptions(string conn) { var builder = new DbContextOptionsBuilder<AppDbContext>(); builder.UseSqlServer(conn, opt => { // 现场网络抖动时自动重试,次数别开太大,否则界面等太久 opt.EnableRetryOnFailure(3); // 单条命令超时,默认 30 秒对现场够用 opt.CommandTimeout(30); }); return builder.Options; } // 配置保存成功后调用,让下一次 Create 重新读盘 public void Reset() => _cached = null; }这里有两个设计选择要说清楚。第一,DbContext不做单例。EF 的上下文不是线程安全的,长生命周期还会累积跟踪实体导致内存上涨,WinForms 里多个窗体同时查询时尤其明显,正确做法是每次操作using一个新的。第二,options缓存但可失效。Reset()是整条链路的关键开关,配置窗体保存成功后必须调它,否则下次查询还是旧连接串——这也是第 5 章第一条要讲的问题。
如果不缓存,每次Create都重新BuildOptions,功能上没问题,但连接串解析和内部服务构建会有额外开销,查询频繁的界面能感觉到。缓存加显式失效是更平衡的做法。
3.3 配置窗体的交互与生效时机
配置窗体上就三个控件:一个多行文本框、一个「测试连接」按钮、一个「保存」按钮。看着简单,交互上有两个细节决定体验。
private async void btnTest_Click(object sender, EventArgs e) { var conn = txtConn.Text.Trim(); if (!ConnectionStringValidator.TryBuild(conn, out var err)) { lblStatus.Text = err; return; } btnTest.Enabled = false; lblStatus.Text = "正在测试…"; try { // 用 CancellationToken 控制超时,避免网络不通时界面假死 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var ok = await Task.Run(() => TryOpen(conn, cts.Token), cts.Token); lblStatus.Text = ok ? "连接成功" : "连接失败,请检查地址与账号"; } catch (OperationCanceledException) { lblStatus.Text = "连接超时(5 秒),请检查网络或服务器地址"; } finally { btnTest.Enabled = true; } } private void btnSave_Click(object sender, EventArgs e) { var conn = txtConn.Text.Trim(); if (!ConnectionStringValidator.TryBuild(conn, out var err)) { lblStatus.Text = err; return; } _store.Save(conn); _factory.Reset(); // 让后续查询立刻走新连接串 DialogResult = DialogResult.OK; Close(); }btnTest_Click里必须用async加Task.Run。直接在 UI 线程里SqlConnection.Open(),服务器地址写错或者网络不通时,界面会卡到 TCP 超时,用户以为程序死了,反复点按钮开出更多阻塞线程,这是我在现场见过最多的翻车方式。加 5 秒的CancellationTokenSource是给现场一个明确反馈,比等系统默认的 15 秒以上体验好很多。
btnSave_Click只做三件事:落盘、调Reset()、关窗口。不在这里重建主界面,也不在这里立刻执行查询,把「什么时候用新连接」交给调用方决定。主窗体收到DialogResult.OK之后刷新列表就行。
TryOpen的实现很朴素,就是建一个连接打开再关掉:
private static bool TryOpen(string conn, CancellationToken token) { try { using var c = new SqlConnection(conn); c.OpenAsync(token).GetAwaiter().GetResult(); return true; } catch { return false; } }3.4 数据访问层的薄封装
有了工厂,业务代码里就不该再出现new AppDbContext()。常见的做法是每个业务模块一个服务类,构造函数注入工厂,方法内部using一个上下文。
public class DeviceService { private readonly DbContextFactory _factory; public DeviceService(DbContextFactory factory) => _factory = factory; public List<Device> GetOnline() { using var db = _factory.Create(); // 只读列表用 AsNoTracking,避免无谓的变更跟踪开销 return db.Devices .AsNoTracking() .Where(d => d.IsOnline) .OrderBy(d => d.Code) .ToList(); } public void UpdateStatus(int id, bool online) { using var db = _factory.Create(); var entity = db.Devices.Find(id); if (entity is null) return; entity.IsOnline = online; db.SaveChanges(); } }using var db保证连接和跟踪器及时释放。AsNoTracking()对查询列表意义很大,尤其是设备表几千行的时候,跟踪开销能占掉可观的一部分时间。写操作里用Find而不是FirstOrDefault,命中主键时会先查本地跟踪缓存,同一上下文里多次按主键取能少一次查询。
提示:如果业务代码里出现
static AppDbContext _db = new AppDbContext(),基本可以判定连接串改不动。静态上下文把启动时的配置焊死了,改配置只能重启进程。
4. 数据库跟着程序走:附带库的挂载、初始化与多环境切换
4.1 附带数据库文件的两种常见形态
标题里的「另附带数据库」在实际项目里通常是两种形态之一,选哪种直接决定连接串怎么写。
| 形态 | 连接串关键片段 | 适用场景 | 现场前提 |
|---|---|---|---|
| SQL Server 数据文件(.mdf) | AttachDbFilename=|DataDirectory|\\AppDb.mdf | 单机部署、数据量中等 | 本机需有 LocalDB 或 SQL 实例 |
| SQLite 文件(.db) | `Data Source=|DataDirectory | \AppDb.db` | 绿色部署、免安装 |
| SQL Server 远程实例 | Server=IP;Database=AppDb;User Id=... | 多客户端共享 | 网络可达、账号有权限 |
.mdf方案的坑集中在|DataDirectory|这个占位符上。它在 .NET Core 里不会自动展开,必须自己设置,而且要在第一次建立连接之前设置:
// 放在 Program.cs 最前面,早于任何数据库访问 var dbDir = Path.Combine(AppContext.BaseDirectory, "Database"); Directory.CreateDirectory(dbDir); // 目录不存在时先建,避免附加失败 AppDomain.CurrentDomain.SetData("DataDirectory", dbDir);AppContext.BaseDirectory拿到的是 EXE 所在目录,末尾带反斜杠。SetData是进程级的,设一次全局生效。如果项目里既用.mdf又用 SQLite,SQLite 那边也支持|DataDirectory|,可以共用同一个目录,安装包里只放一个Database文件夹。
用 LocalDB 还要注意实例名。连接串里的(localdb)\\MSSQLLocalDB是默认实例,如果现场装的是完整版 SQL Server Express,要改成.\SQLEXPRESS。这个差异最好在配置窗体的说明文字里写清楚,否则现场人员只能靠猜。
4.2 首次启动的建库与种子数据
程序第一次在某台机器上跑,数据库文件可能是空的,也可能是随安装包带过来但还没附加。初始化逻辑要能同时应付这两种情况。
public static void InitDatabase(AppDbContext db) { // 能连上说明库和表都已存在,直接返回,避免每次启动重复建表 if (db.Database.CanConnect()) return; // 首启:按实体模型建库建表 db.Database.EnsureCreated(); // 写入基础数据,省得现场不知道初始账号 db.Users.Add(new User { Name = "admin", // 存哈希不存明文,这里只是示意 PasswordHash = PasswordHasher.Hash("Init@123"), CreatedAt = DateTime.Now }); db.SaveChanges(); }CanConnect()在数据库不存在时返回 false,但注意它内部会尝试打开连接,网络不通时同样返回 false,不能区分「库不存在」和「连不上」。所以调用之前要先用配置窗体确认连接可用,否则会在网络故障时误判成首次运行,然后EnsureCreated抛一个更难懂的异常。
EnsureCreated()和Migrate()不能混用。前者是「按当前模型从零建库」,后者是「按迁移历史增量升级」。项目里如果用了迁移,首次初始化必须走Migrate();如果只是随包附带一个固定结构的库,EnsureCreated()更简单。判断标准是代码里有没有Migrations目录,有就统一用Migrate(),别两边都写,混用的后果见 5.5。
4.3 开发与现场两套连接串的切换
开发和现场用的库肯定不是一个,但配置文件只有一个。靠环境变量区分是最省事的做法。
| 环境 | 连接串来源 | 典型值 |
|---|---|---|
| 开发机 | appsettings.Development.json | LocalDB 附加本地 .mdf |
| 现场 | 程序目录appsettings.json(界面写入) | 服务器实例 + 账号密码 |
| 应急 | 环境变量APPDB_ConnectionStrings__Default | 临时指向备份库 |
开发环境的多配置文件加载要在Program.cs里显式加上:
var env = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{env}.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables("APPDB_") .Build();appsettings.Development.json设为optional: true,发布包里通常不带它。本地调试时在launchSettings.json里把DOTNET_ENVIRONMENT设成Development,就自动走本地库,不用每次手工改连接串。注意后加载的源优先级更高,appsettings.Development.json放在appsettings.json之后,才能覆盖默认值。
现场应急用环境变量覆盖时,改完要重启程序才生效,因为Program.cs只在启动时构建一次配置。如果希望不重启,就得让ConnectionConfigStore也监听环境变量变化,实现上比监听文件复杂,一般项目用不上,知道有这条路就行。
5. 常见问题排查:连接串改了不生效、发布后找不到库、迁移报错
5.1 界面上改了连接串,查询还走旧库
现象:配置窗口显示保存成功,主界面刷新列表,数据还是原来那个库的,关掉程序重启才切过去。
原因:DbContextFactory缓存了DbContextOptions,保存配置后没有调Reset()。另一种可能是某个窗体在启动时创建了一个AppDbContext并长期持有,用的还是旧 options。
解决:检查配置窗体的保存按钮,必须跟着一次_factory.Reset()。再全局搜一遍new AppDbContext,把所有直接实例化的地方改成走工厂。如果项目里注册了AddDbContext单例,改成AddDbContextFactory或者自己写的工厂,别用默认的单例注册。
5.2 发布后双击 EXE 报找不到数据库文件
现象:开发机上跑得好好的,拷到现场机器上启动就弹「无法打开登录所请求的数据库」或者「未能找到文件 AppDb.mdf」。
原因:三种可能。Database目录没随安装包复制过去;|DataDirectory|没有设置,被当成相对路径解析到工作目录;现场机器没装 LocalDB,附加.mdf直接失败。
解决:先看 EXE 同目录下有没有Database文件夹,没有就是打包漏了,在 csproj 里给数据库文件加CopyToOutputDirectory。有的话在启动代码里打印一次AppDomain.CurrentDomain.GetData("DataDirectory"),确认路径对不对。最后用sqllocaldb info确认实例存在,没有就改用 SQLite 或者要求现场装运行环境。
5.3 保存成功了,重启之后连接串又被还原
现象:配置窗口改完能用,第二天再开机,连接串变回安装时的值。
原因:安装目录在Program Files下,普通用户没有写权限,File.Move失败但异常被吞了;或者程序被重新安装覆盖;还有一种容易被忽略的情况,appsettings.json在 csproj 里被设成了「始终复制」,每次编译都从源码目录重新拷贝一份。
解决:把配置文件的写入路径改到%ProgramData%下的程序专属目录,安装目录只读。如果必须写在程序目录,安装时把该目录的写权限授予 Users 组。检查 csproj 里appsettings.json的复制设置,改成「如果较新则复制」,不要用「始终复制」。
5.4 连接测试一直转圈,界面卡死
现象:点了「测试连接」之后窗口无响应,任务管理器显示进程占用 CPU 不高,就是不动。
原因:SqlConnection.Open()在 UI 线程同步执行,服务器地址填错或者防火墙拦了端口时,会一直等到 TCP 超时,默认时间可能超过 15 秒。期间消息循环被阻塞,窗口无法重绘。
解决:按 3.3 的写法改成async加Task.Run,外面套CancellationTokenSource控制超时时间。连接串里也可以加Connect Timeout=5,双保险。别用Thread.Sleep轮询,那是另一种卡死。
5.5 初始化脚本重复执行报对象已存在
现象:第二次启动程序时抛「数据库中已存在名为 Users 的对象」,或者迁移时报「表已存在」。
原因:EnsureCreated()和Migrate()混用了。EnsureCreated建库时不会写__EFMigrationsHistory表,之后调Migrate()会认为所有迁移都没执行过,从头开始建表,撞上已存在的表就报错。
解决:二选一。用迁移就删掉EnsureCreated,首次启动调db.Database.Migrate();不用迁移就永远别调Migrate()。已经混用出问题的库,手工补__EFMigrationsHistory记录,或者直接删库重建——现场数据不能删的话,只能写脚本把历史表补齐,这个操作前一定先备份。
6. 二次开发改造:把「改连接串」从一个窗口升级成一层配置
这个项目当前的形态是「能用的最小闭环」,但如果要交给多个现场长期维护,我一般会做三处改造。
第一处是把ConnectionConfigStore抽成接口。现在它固定读写 JSON,将来要支持注册表、加密文件或者由服务端下发,就得改调用方。定义IConnectionConfigStore,把Read、Save提出来,JSON 实现放一个类,将来加一个RegistryConfigStore就能切换,工厂和窗体都不用动。
public interface IConnectionConfigStore { string? Read(); void Save(string connectionString); }第二处是连接串加密。明文密码放在appsettings.json里,任何能进安装目录的人都能看到。Windows 上可以用 DPAPI 的ProtectedData.Protect,按当前用户或本机加密,保存时加密、读取时解密,改动量只有Save和Read两个方法内部几行。注意别用固定密钥的对称加密,密钥跟着程序走等于没加密。
第三处是用 EF 自带的IDbContextFactory替换手写工厂。EF Core 提供了现成的注册方式,生命周期管理由框架负责,比自己维护缓存和Reset更省心:
services.AddDbContextFactory<AppDbContext>((sp, opt) => { var store = sp.GetRequiredService<IConnectionConfigStore>(); var conn = store.Read(); opt.UseSqlServer(conn, o => o.EnableRetryOnFailure(3)); });这种注册方式下,配置改完不能靠Reset()了,得让工厂重新读连接串。做法是在IConnectionConfigStore里加一个变更通知,或者干脆每次保存后重建服务提供者——后者对 WinForms 来说太重。更实际的做法是保留自定义工厂,只在业务层用IDbContextFactory的接口形状,内部实现自己写,两边的好处都占到。
验证改造是否成功,别只靠手点。写一个集成测试,用 SQLite 内存库或者临时文件库跑一遍「保存连接串 → 创建上下文 → 写入一条记录 → 换一个库再读」,确认两次操作落在不同库上。WinForms 的窗体逻辑可以抽到一个 ViewModel 里,按钮点击只做转发,这样测试不用起 UI 线程。
从那以后我每次交付前,都会在一台干净机器上把「改连接串 → 测试 → 保存 → 重启 → 查询」这条链路手动走一遍,哪一步少了都可能在现场翻车。希望帮到你。
本文还有配套的精品资源,点击获取