
简介这份压缩包面向使用 .NET 开发 Windows 桌面应用的初中级开发者围绕 Entity Framework 与 WinForms 的结合演示如何在程序中读取、修改并保存数据库连接配置同时附带可直接配合使用的数据库文件适合需要理解配置驱动连接与桌面端数据库交互的读者。包内共 240 个文件约 61.27MB以 95 个 C# 源码、30 个程序集、13 组 resx/resources 资源、9 个 config 配置、6 个 csproj 项目文件及 mdf/ldf 数据库为主并含 exe 与 EF 模型相关文件便于还原分层项目结构。已有 453 人学习下载。读者可从中了解 DbContext 的定制与 CRUD 调用、连接字符串的动态更新与刷新、配置文件持久化以及 Code First 建模思路配合附带的数据库与 BLL、Model 等分层工程还可用于本地环境搭建、连接配置排错和 EFWinForms 项目骨架参考。1. 一条连接串卡住整个交付EF WinForms 的可改配置要解决什么一套 WinForms 客户端交付到客户现场最容易被打回重做的往往不是业务逻辑而是“数据库换了连不上”。做过 EF WinForms 的人大概都遇到过连接字符串写死在 app.config 里客户换了一台服务器运维要抱着键盘去每台机器上改配置文件改完还得让所有人退出重进更糟的是程序装在 Program Files 下普通账号连保存的权限都没有只能在客户面前尴尬地看着报错弹窗。这篇讲的是把方向反过来程序里可修改配置连接改完即时生效再把一份附带的数据库文件塞进安装包首次启动自己建库、自己灌初始数据。适合做桌面端交付、现场环境不受控、又不愿意为一次改库重新发版的开发者。2. 连接串的落点EF 从哪读、为什么改了 app.config 不生效2.1 DbContext 在构造期就把连接定死了先把这个原理说透后面所有“为什么改了没反应”都能自己解释。EF 拿连接字符串无非两条路一是约定DbContext派生类走无参构造函数时EF 去配置文件的connectionStrings节里找name等于类名或类全名的那一项二是构造注入你传一个字符串进base(...)。这里有个很多人不知道的细节传进去的那个字符串只要包含等号EF 就当成完整连接串不含等号才当成配置文件里的节名去查。所以new AppDbContext(MainDb)和new AppDbContext(Server.;DatabaseMainDb;...)是两种完全不同的语义写错了不报错只是默默连到别的地方去。关键在于时机。一个DbContext实例被创建的那一刻连接字符串就挂在这个实例上了后面你改内存里的配置对象、改磁盘上的文件、甚至调ConfigurationManager.RefreshSection(connectionStrings)对这个已经活着的实例统统无效。而ConfigurationManager自己也会在首次访问时把配置节整个读进内存缓存磁盘文件变了它不认。这两层缓存叠在一起就构成了那句经典抱怨——“我明明改了配置怎么还是连旧库”。还有一个更现实的问题WinForms 默认的可执行目录就是安装目录。程序装在系统盘的受保护路径下ConfigurationManager.Save会因为权限不足直接抛异常或者被系统悄悄重定向到虚拟存储里你在安装目录下看到的还是老文件。玄学感就是从这儿来的。所以结论很清楚app.config 只能当只读的出厂默认值不能当唯一可写的配置源。2.2 配置落点三选一app.config、独立配置文件、数据库表落点可写性权限要求多用户共享适合存什么app.config差写入常失败高受保护目录需提权是同一份出厂默认值、程序集绑定独立 json/iniProgramData好普通用户可写低安装时授一次权是现场实际连接参数、上次使用记录独立 jsonLocalAppData好低否按用户隔离个人偏好的测试库连接数据库表不可用——只适合存 UI 记忆项因为要先连上才能读我一般的取舍是出厂默认值留在 app.config 里作为最后兜底真正生效的配置放%ProgramData%\厂商目录\产品名\dbsettings.json安装时由安装器给这个目录授一次写权限。ProgramData 的好处是同机所有用户共用一份运维改一次全场生效不用逐用户复制坏处是它没有天然的版本管理两个人同时改会互相覆盖所以写入时要用临时文件加替换的方式别直接覆盖原文件。至于数据库表存配置只有一种场景值得考虑程序已经连上了你想记住“这台机器上次用哪个库”。连接参数本身绝不能存在库里那是鸡生蛋的问题。2.3 一个配置模型和四层兜底的读取顺序配置对象不要做成一个裸字符串按字段拆开存。裸字符串直接落盘改一个字段要人工拼接出错了还看不出来。// DbSettings.cs —— 配置的内存模型只管数据不管 IO public sealed class DbSettings { public string Provider { get; set; } Sqlite; // Sqlite / SqlServer public string Server { get; set; } ; // 仅 SqlServer 用 public string Database { get; set; } ; // 仅 SqlServer 用 public string FilePath { get; set; } ; // 附带库文件路径Sqlite 用 public string UserId { get; set; } ; public string EncryptedPassword { get; set; } ; // 密文永不明文落盘 public int ConnectTimeoutSeconds { get; set; } 5; // 测试连接必须有上限 public string SchemaVersion { get; set; } 1; // 配置结构版本便于日后升级 public string BuildConnectionString() { if (Provider.Equals(Sqlite, StringComparison.OrdinalIgnoreCase)) { // CacheShared 让同进程多个连接看到同一份数据Pooling 默认开启 return $Data Source{FilePath};CacheShared;Default Timeout{ConnectTimeoutSeconds}; } var b new SqlConnectionStringBuilder { DataSource Server, InitialCatalog Database, UserID UserId, Password SecretBox.Unprotect(EncryptedPassword), // 解密后再拼 ConnectTimeout ConnectTimeoutSeconds, PersistSecurityInfo false, MultipleActiveResultSets true }; return b.ConnectionString; } }这个模型里有两个刻意的设计。EncryptedPassword存密文而不是明文解密动作放在BuildConnectionString里调用方拿到的永远是可直接用的完整串不需要知道密码从哪来。ConnectTimeoutSeconds默认给 5 秒而不是默认的 15是为了让“测试连接”按钮在填错地址时能快速返回别把 UI 线程冻住。读取顺序建议按优先级从高到低串成一条链任何一层命中就停// DbSettingsProvider.cs —— 四层兜底命令行 用户配置 全局配置 出厂默认 public static class DbSettingsProvider { private static DbSettings _current; public static DbSettings Current _current ?? Load(); public static DbSettings Load() { // 1. 命令行参数运维批量部署时用 /db:Server... 覆盖避免逐台改文件 var fromArgs TryParseArgs(Environment.GetCommandLineArgs()); if (fromArgs ! null) return fromArgs; // 2. 全局配置ProgramData 下同机所有用户共享 var globalPath Path.Combine(CommonAppDir(), dbsettings.json); if (File.Exists(globalPath)) return ReadJson(globalPath); // 3. 用户配置LocalAppData 下个人测试偏好 var userPath Path.Combine(LocalAppDir(), dbsettings.json); if (File.Exists(userPath)) return ReadJson(userPath); // 4. 出厂默认从 app.config 读一条只读的通常指向附带的库文件 return ReadFromAppConfig(); } public static void Reload() { _current Load(); } // 切换后手动刷新缓存 }参数含义再点一句Reload()只刷新内存里的配置对象它不会让已经存在的DbContext换库这一步得在下一章处理。很多人只写了Reload()就以为大功告成结果界面上数据纹丝不动就是因为漏了上下文重建这一环。3. 运行时换库DbContext 重建、连接池清理与一次探活3.1 用构造函数注入连接但必须保留无参构造想做到“运行时换库”第一步是让DbContext接受外部传入的连接串而不是靠类名约定去配置文件里找。// AppDbContext.cs —— 两种构造并存 public class AppDbContext : DbContext { private readonly string _conn; // 无参构造给迁移工具Add-Migration / Update-Database和设计器用 public AppDbContext() : this(DbSettingsProvider.Current.BuildConnectionString()) { } // 注入构造业务代码一律走这个 public AppDbContext(string connectionString) : base(connectionString) { _conn connectionString; Database.SetInitializerAppDbContext(null); // EF6关掉自动建库改由自己控制 } public DbSetOrder Orders { get; set; } public DbSetCustomer Customers { get; set; } }无参构造千万别删。迁移命令在启动时会反射创建上下文找不到无参构造就直接报“无法创建 DbContext 实例”EF Core 里可以靠IDesignTimeDbContextFactoryT顶替但多写一个类不如留个构造函数省事。反过来业务代码里也绝不要随手new AppDbContext()那样等于绕过了配置层切换库的时候它不会跟着变这是最隐蔽的一类翻车。还有一个容易忽略的点Database.SetInitializer(null)在 EF6 里要放在构造里尽早执行否则默认的CreateDatabaseIfNotExists会在第一次查询时按旧规则建库和你的迁移脚本打架。EF Core 不需要这一句它默认什么都不做。3.2 上下文工厂一次操作用一个实例绝不长期持有DbContext不是线程安全的也不适合做成全局单例长期挂着。它的定位是“工作单元”一次业务操作一个实例用完就丢靠底层的连接池复用物理连接开销并不大。工厂的职责很简单每次创建时按当前配置重新拼一遍连接串。// DbContextFactory.cs —— 切换库的唯一入口 public static class DbContextFactory { public static AppDbContext Create() { // 每次创建都从 Current 现取确保切换后新实例用的是新串 var conn DbSettingsProvider.Current.BuildConnectionString(); var ctx new AppDbContext(conn); ctx.Database.CommandTimeout 30; return ctx; } } // 业务侧用法using 包住作用域尽量短 using (var ctx DbContextFactory.Create()) { var list ctx.Orders.Where(o o.Status 1).ToList(); // 出了大括号就 Dispose连接归还池子 }工厂里那句“每次创建都从Current现取”是整套方案的核心。只要所有业务代码都从工厂拿上下文切换库就变成了“改配置 刷缓存”两件事历史实例会因为作用域结束自然消亡。反过来说只要有一处代码持有了长生命周期的上下文那处就会一直连旧库而且不报错——这种 bug 查起来非常折磨人。3.3 连接池里的旧连接切换后必须显式清理这是我认为整套方案里最容易漏的一步。ADO.NET 的连接池是按连接字符串做键的池子里的物理连接一旦建立就不会因为你换了配置而主动断开。如果新旧连接串恰好只差在数据库名上比如同一个实例、不同 catalog池子里可能已经有指向旧库的连接新的SqlConnection打开时直接复用于是你“切了库”却读到了旧数据。// ConnectionSwitcher.cs —— 切换流程落盘 - 刷缓存 - 清池 - 探活 public static async TaskSwitchResult SwitchAsync(DbSettings newSettings, CancellationToken ct) { var backup DbSettingsProvider.Current; // 先留后悔药 try { // 1. 写盘写临时文件再替换避免半个文件 DbSettingsStore.Save(newSettings); // 2. 刷新内存缓存 DbSettingsProvider.Reload(); // 3. 清空连接池把旧串建立的物理连接全部掐断 SqlConnection.ClearAllPools(); // 用 Microsoft.Data.SqlClient 时同样存在 Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 4. 用新配置探活失败就回滚 var probe await ProbeAsync(newSettings, ct); if (!probe.Ok) throw new InvalidOperationException(probe.Message); return SwitchResult.Success(); } catch (Exception ex) { DbSettingsStore.Save(backup); DbSettingsProvider.Reload(); SqlConnection.ClearAllPools(); return SwitchResult.Failed(ex.Message); } }参数和取舍说明ClearAllPools()是全局的会掐断当前进程所有池里的连接包括那些正在被使用的使用中的连接会等归还后才真正关闭。这会造成一次短暂的性能抖动但切换数据库本身是低频操作一天可能就几次这个代价完全值得。相比之下ClearPool(connection)只清指定串的池更精细但它要求你手里还留着旧串——而旧串在Reload()之后就拿不到了所以实际操作里反而不如全局清省事。3.4 探活三秒钟给用户一个确定答案切换完必须立刻验证不能让用户先保存、再操作、最后才在业务代码里炸。探活要满足三个条件有超时、不阻塞 UI、结果可读。// ProbeAsync —— 用最小代价确认新连接真的能打开 public static async Task(bool Ok, string Message) ProbeAsync( DbSettings s, CancellationToken ct) { try { using var ctx new AppDbContext(s.BuildConnectionString()); // CanConnectAsync 内部会开连接并执行一次极轻量的探测EF Core 可用 var ok await ctx.Database.CanConnectAsync(ct); if (!ok) return (false, 连接已建立但目标库不存在或无访问权限); // 再补一刀确认核心表结构在防止连到了同名的空库 var tableOk await ctx.Orders.AnyAsync(ct); // 空表也返回 false不抛异常即通过 return (true, 连接正常); } catch (OperationCanceledException) { return (false, $连接超时{s.ConnectTimeoutSeconds} 秒请检查地址与端口); } catch (Exception ex) { return (false, ex.Message); // 直接透出底层信息现场排查全靠它 } }注意CanConnectAsync只回答“能不能连上”不回答“是不是我要的那个库”。一个新建的空库同样能连上所以后面补一次针对核心表的查询。这一步的异常处理不要吞掉ex.Message现场工程师拿到的错误原文往往比你自己包装的“连接失败”有用十倍这是血泪经验。4. 附带数据库SQLite 单文件与 LocalDB 附加的取舍和首次初始化4.1 两种“附带数据库”的路子先选对再动手标题里说的“另附带数据库”落地时通常有两种形态选错了后面全是坑。维度SQLite 单文件SQL Server / LocalDB 附加部署依赖零一个 dll 加一个文件需目标机装运行时或已有实例权限要求只需数据目录可写附加 mdf 需目录可写 实例权限并发能力单写多读写锁粗好适合多人同时写备份方式复制文件需先 checkpoint备份命令或分离后复制体积几 MB运行时数百 MB适合场景单机、门店、离线场景局域网多人协作已有 SQL 实例我的判断标准很粗暴数据只在一台机器上用选 SQLite多台机器要共享同一份数据选服务器连接安装包里附带的就不是 mdf 而是建库脚本加种子数据。硬把 SQLite 文件放到共享目录让多台机器同时打开是典型的技术选型失误——文件锁在网络文件系统上的行为很不确定轻则报“数据库已锁定”重则文件损坏。4.2 首次启动迁移而不是 EnsureCreated库文件随程序分发有两条路一是安装包里放一个已经建好的空库程序只管连二是只放结构定义首次启动自己建。前者的问题是版本升级困难——老用户手里的库文件结构是旧的新版本程序一读就缺列后者用迁移管起来升级时自动补齐。// DbInitializer.cs —— 首次启动与版本升级共用一条路径 public static void EnsureReady() { using var ctx DbContextFactory.Create(); // 只做一次存在性判断不要每次启动都 Migrate大库上有开销 var pending ctx.Database.GetPendingMigrations().Any(); if (pending) { ctx.Database.Migrate(); // 按迁移历史逐条补齐幂等 } SeedIfEmpty(ctx); // 种子数据必须自己写幂等判断 } private static void SeedIfEmpty(AppDbContext ctx) { if (ctx.Customers.Any()) return; // 已有数据就跳过别重复插 ctx.Customers.AddRange( new Customer { Name 示例客户, Code C0001 }, new Customer { Name 备用客户, Code C0002 }); ctx.SaveChanges(); }这里最需要强调的是EnsureCreated()和Migrate()不能混用。EnsureCreated建库时不写迁移历史表之后Migrate()会认为数据库里一条迁移都没应用过试图从第一条开始执行撞上已存在的表直接报错。这个坑一旦踩了除了删库重建没有干净的补救办法。所以从第一天起就统一走Migrate()哪怕空库初始化多花几十毫秒也认了。GetPendingMigrations()的判断也值得留着。多数小库上直接Migrate()也就几百毫秒但有些客户的数据攒了几年启动时每次都跑一遍迁移检查会明显拖慢启动速度加个判断能省掉这笔开销。4.3 路径解析别信当前目录也别写安装目录路径问题是附带数据库最集中的翻车点几乎每个 WinForms 项目都栽过一次。// PathResolver.cs —— 数据目录统一在这里算别在业务代码里散落 public static class PathResolver { public static string DataDir() { // 优先用 ProgramData同机共用、普通用户可写、不会被安装器覆盖 var root Environment.GetFolderPath( Environment.SpecialFolder.CommonApplicationData, Environment.SpecialFolderOption.Create); var dir Path.Combine(root, YourVendor, YourProduct); Directory.CreateDirectory(dir); return dir; } public static string DbFile() Path.Combine(DataDir(), app.db); // 解析安装包里的模板库程序目录只读每次都是新副本 public static string TemplateDb() Path.Combine(AppDomain.CurrentDomain.BaseDirectory, template.db); }三条经验第一绝对不要用Directory.GetCurrentDirectory()快捷方式启动时工作目录可能是 System32从资源管理器双击又是另一个值同一个程序两种行为排查起来毫无头绪。第二安装目录BaseDirectory在 Program Files 下是只读的SQLite 在那里连“建一个临时日志文件”都做不到报错信息往往是“attempt to write a readonly database”看着像权限问题其实是你把库放错了地方。第三如果非要用 EF6 的|DataDirectory|占位符必须在程序启动最早期调AppDomain.CurrentDomain.SetData(DataDirectory, 实际路径)晚一步就按默认值解析了。首次启动的完整动作是判断DataDir()下有没有app.db没有就从安装目录复制一份template.db过去然后再跑迁移。复制而不是直接连模板是因为模板目录只读任何写操作都会失败。5. 避坑连接切换与库文件分发的五个翻车现场5.1 连接相关的三个现场现象一点了“保存并测试”提示成功但主界面数据还是旧的。原因通常有两个并且常常同时出现。一是某处代码把DbContext做成了静态单例或者using之外的字段切换后它仍然持有旧连接串二是连接池里旧串对应的物理连接没清新连接复用了旧句柄。解决的顺序是全局搜一遍new AppDbContext(把所有直接构造换成DbContextFactory.Create()然后确认切换流程里调了ClearAllPools()。改完之后建议加一条日志把每次Create()时的连接串脱敏打印出来出问题时一眼就能看出是谁在用旧串。现象二点“测试连接”按钮界面直接卡死半分钟用户以为程序崩了。原因是同步的Open()跑在 UI 线程上而默认连接超时是 15 秒甚至 30 秒填错 IP 时就是硬等。解决分两步连接串里把Connect Timeout压到 3 到 5 秒这个值对正常局域网连接绰绰有余按钮处理改成async void事件处理器内部await ProbeAsync(...)探测期间把按钮置灰。注意async void只允许出现在事件处理器上其他位置一律async Task。现象三客户的安全扫描报出配置文件里有明文数据库密码。原因是最初图省事把密码直接写在 json 里。解决用 Windows 自带的 DPAPIProtectedData.Protect(bytes, null, DataProtectionScope.LocalMachine)密文存进EncryptedPassword运行时解密。选LocalMachine还是CurrentUser取决于配置是全局的还是按用户的——ProgramData 那种全局配置必须用LocalMachine否则换个账号登录就解不开。代价是同一台机器上的其他程序理论上也能解所以这是“防止明文泄露”不是“防止本机提权”心里要有数。5.2 库文件相关的两个现场现象四装完第一次运行就报“attempt to write a readonly database”或者“拒绝访问”。原因几乎都是数据文件落在了安装目录。安装器默认把整个包解压到 Program Files那里的写权限只给管理员。解决办法是把数据目录整体迁到 ProgramData并在安装脚本里给这个目录授一次Users的修改权限如果用的是 LocalDB 附加 mdf还要额外处理 mdf 所在目录的可写性和实例归属。已经上线的老版本迁移时要做一次“旧路径存在就复制到新路径”的兼容逻辑别让用户的历史数据凭空消失。现象五升级版本后启动报“数据库中已存在名为 XXX 的对象”。这是迁移历史和实际结构对不上。常见成因有三种早期版本用了EnsureCreated()建库没有迁移历史表有人手工在客户库里加过表两台机器上跑了不同版本的迁移脚本。处理顺序是先在测试环境复现确认是哪种成因对EnsureCreated建的老库可以生成一个基线迁移把当前模型快照写进第一条迁移再手工往__EFMigrationsHistory里插一条记录标记为已应用对完全跑偏的库老实做数据导出再重建别指望脚本能自动救回来。预防手段只有一个从项目第一天起就只允许Migrate()。6. 上线前自检把配置、库文件和迁移历史各验一遍这套方案涉及配置、连接、文件、迁移四个环节任何一环没验到问题都会在客户现场以“打不开”的形式暴露。我现在交付前会固定跑一遍下面这张检查表十分钟能省掉后面几天的远程支持。检查项怎么看不合格的表现配置目录可写用普通账号启动改一次配置点保存抛权限异常或保存后重开丢失配置能回滚故意填错服务器地址点保存程序卡死、或错误配置被写进去且不还原连接串已脱敏看日志里的连接串日志里出现明文密码库文件路径装到 Program Files 后首次启动报只读错误迁移历史查__EFMigrationsHistory条数有表无记录或记录数与迁移数不符空库初始化删掉数据目录重装缺表、缺种子数据、重复插入除了表格我还会留一个小工具方法打包进程序里做成隐藏菜单现场支持时让对方点一下把关键信息脱敏后导出来// SelfCheck.cs —— 现场支持用输出脱敏后的环境快照 public static string Dump() { var s DbSettingsProvider.Current; var conn s.BuildConnectionString(); // 只保留 DataSource / InitialCatalog / Data Source密码字段一律替换 var safe string.Join(;, conn.Split(;) .Where(p !p.StartsWith(Password, StringComparison.OrdinalIgnoreCase) !p.StartsWith(Pwd, StringComparison.OrdinalIgnoreCase))); var sb new StringBuilder(); sb.AppendLine($Provider : {s.Provider}); sb.AppendLine($Connection : {safe}); sb.AppendLine($DataDir : {PathResolver.DataDir()}); sb.AppendLine($DbExists : {File.Exists(PathResolver.DbFile())}); try { using var ctx DbContextFactory.Create(); sb.AppendLine($CanConnect : {ctx.Database.CanConnect()}); sb.AppendLine($Pending : {ctx.Database.GetPendingMigrations().Count()}); } catch (Exception ex) { sb.AppendLine($Error : {ex.Message}); } return sb.ToString(); }这段代码本身不值钱值钱的是那条脱敏规则——我见过太多次现场为了排查问题让人把配置文件整份发过来密码就这么在聊天记录里躺了半年。最后说个我自己的习惯。这套东西做完之后我不会只在自己的开发机上验一遍就发版而是找一台干净的机器用普通用户账号把安装包装上去然后故意做三件事把服务器地址填错一次、把数据目录的写权限去掉一次、把库文件删掉一次。三次里有任何一次程序没有给出人能看懂的错误提示就回去补提示文案。桌面端的现场支持成本极高用户能自己看懂的报错比你在电话里解释二十分钟有用得多。希望帮到你。本文还有配套的精品资源点击获取