做上位机开发的朋友应该都有过这样的经历:程序写好了,在现场部署的时候客户说数据库地址变了、设备IP要调整、串口波特率要改,你拎着笔记本跑过去改代码重新编译,然后祈祷别再改第二次。其实这些场景本该通过配置文件解决,而App.Config就是C#桌面应用里最基础也最实用的一种配置方案。这篇文章我会把这玩意儿从头到尾讲透,包括它的加载机制、读取API、自定义配置节、写配置的坑,以及和JSON配置的选型取舍。
1. App.Config到底是什么:先搞懂它的加载机制
1.1 从“改配置要重新编译”的痛点说起
先说你最关心的问题:为什么用了App.Config就不需要重新编译了。
App.Config本质上就是一个XML文件,C#编译器在编译项目时,会把这个文件复制到输出目录,并根据程序集输出名称自动改名为程序集名.exe.config。比如你的项目叫DeviceTool,编译后生成DeviceTool.exe和DeviceTool.exe.config两个文件。程序启动时,.NET运行时会在当前应用程序域里加载这个配置文件,把里面的键值对解析成内存中的配置对象。你改配置时只需要用记事本打开那个exe.config文件,修改XML里的文本,保存后重启程序就生效了,完全不用碰代码。
这个机制很多人第一次接触时会困惑:为什么我在项目里明明叫App.Config,编译出来却是另一个名字?因为App.Config只是源代码里给开发者看的名字,编译器负责在你构建时做改名和复制。如果这个改名和复制环节出了问题——比如项目文件损坏、手动删除了输出目录里的配置文件、或者改了程序集名称但没有同步重建——就会导致程序启动后读不到配置,运行时直接抛ConfigurationErrorsException或者返回null。
1.2 一个最小可用的App.Config长什么样
先看一个最典型的配置结构:
xml复制<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<appSettings>
<add key="DevicePort" value="COM3" />
<add key="BaudRate" value="9600" />
<add key="ServerIp" value="192.168.1.100" />
</appSettings>
<connectionStrings>
<add name="MainDb"
connectionString="Data Source=.;Initial Catalog=DeviceDb;User Id=sa;Password=123456;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</configuration>
startup节点是Visual Studio模板自带的,声明.NET运行时版本。真正干活的是appSettings和connectionStrings,前者存自定义键值对,后者存数据库连接串。.NET Framework和.NET Core/.NET 5+在读取方式上有差异,这套写法在传统.NET Framework里最成熟;如果你在.NET 6/8里用,官方更推荐appsettings.json,但如果你想兼容旧项目,也可以继续用App.Config的方式,后面我会讲二者的选型。
1.3 每次启动都会重新读取吗
这是很多人容易产生误解的地方。ConfigurationManager在第一次访问配置时会缓存整个配置对象,之后每次读取都不会重新解析XML文件。你在程序运行过程中手动修改了exe.config,程序里读到的还是旧值,必须重启进程才生效。这种设计有其合理性:避免频繁的磁盘IO和XML解析开销,但也意味着如果你需要“运行中动态刷新配置”,要么重启程序,要么放弃ConfigurationManager,自己监听文件变化并重新加载。多数桌面应用不需要这种能力,所以直接接受“改完配置必须重启”这个约定就好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读取配置最常用的三种姿势:AppSettings、连接字符串与Connection对象
2.1 从AppSettings读键值对:最常见也最容易踩坑
读取appSettings里的配置,最直接的方式是ConfigurationManager.AppSettings["KeyName"]。先要在代码文件顶部引入System.Configuration命名空间,并在项目引用里添加System.Configuration.dll。很多新手在这里卡住,因为AppSettings属性本身在System.Configuration里,不引用会提示命名空间找不到。
csharp复制using System.Configuration;
string port = ConfigurationManager.AppSettings["DevicePort"];
string baudRate = ConfigurationManager.AppSettings["BaudRate"];
if (string.IsNullOrEmpty(port))
{
throw new InvalidOperationException("配置文件中缺少DevicePort配置项");
}
这里有几个容易踩的坑。
第一个是key不存在时返回null而不是空字符串,你直接拿来当参数传给串口类,控件可能抛异常,或者更糟——以奇怪的默认行为运行。所以读取后最好做判空。
第二个是value里带了空格。因为配置文件是XML,很多人习惯在value=" COM3 "里多加空格,运行时读到的值就带着前后空格,导致串口打不开、拼接字符串多出空格。这种情况在排查时特别隐蔽,所以读取后就地Trim一下是好习惯:
csharp复制string port = ConfigurationManager.AppSettings["DevicePort"]?.Trim();
第三个是多次读取的损耗。如果你的代码在循环里频繁读取配置,每次AppSettings[index]都走一次字典查找,性能有损耗。高频场景下,启动时先读出来放到静态字段里更合适。
2.2 读取connectionStrings的正确姿势
数据库连接字符串是另一个高频配置。读取方式和appSettings不同,用的是ConfigurationManager.ConnectionStrings:
csharp复制using System.Configuration;
using System.Data.SqlClient;
string connStr = ConfigurationManager.ConnectionStrings["MainDb"]?.ConnectionString;
using (SqlConnection conn = new SqlConnection(connStr))
{
conn.Open();
// 业务代码...
}
这里注意,ConnectionStrings索引器返回的是ConnectionStringSettings对象,它有三个属性:Name、ConnectionString、ProviderName。如果你只想要连接串本身,记得要.ConnectionString,别把整个对象传给SqlConnection,那会直接编译报错。
还有一点,.NET Framework时代很多项目会同时用到多种数据库——SQLite、MySQL、Oracle——连接串里的providerName字段就派上用场了。你可以根据providerName决定创建哪种数据库连接对象,例如先用switch判断:
csharp复制var settings = ConfigurationManager.ConnectionStrings["MainDb"];
if (settings.ProviderName.Contains("SqlClient"))
{
// 创建SqlConnection
}
else if (settings.ProviderName.Contains("SQLite"))
{
// 创建SQLiteConnection
}
2.3 ConfigurationManager和Configuration类:什么时候用后者
上面说的AppSettings和ConnectionStrings都是ConfigurationManager的静态访问入口,内部实际使用的是“当前应用程序默认配置”。但如果你的程序需要操作任意路径下的配置文件,或者需要修改配置并保存,就要用到更高层的Configuration类。
csharp复制using System.Configuration;
Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
string port = config.AppSettings.Settings["DevicePort"]?.Value;
OpenExeConfiguration返回的Configuration对象代表当前exe的配置文件,AppSettings.Settings是一个KeyValueConfigurationCollection集合,读取时用Settings["key"].Value。这个对象是读改写一体的,修改后调用config.Save()就能写回文件。ConfigurationUserLevel.None表示操作的是应用程序级配置,不是用户级配置。
那什么时候用ConfigurationManager,什么时候用Configuration类?我的经验是:
- 只读配置,用ConfigurationManager最简单。
- 需要读写配置、或者读取的不是当前程序的配置文件、需要指定路径时,用Configuration类。
- 需要枚举所有配置项,配置项很多不想一个个写key时,用Configuration类遍历更方便。
3. 自定义配置节:当appSettings不够用的时候
3.1 为什么会有自定义配置节的需求
appSettings只有key-value这种扁平结构,当一个模块需要配置一组结构化参数时,就会显得很别扭。比如一个上位机项目里要配置多台仪器的通信参数,每台仪器有设备名、IP、端口、超时时间,如果全部摊平写成appSettings,key会变成这样:Instrument1_Name、Instrument1_Ip、Instrument1_Timeout。不仅表达力差,代码里读取也要写一堆重复逻辑。这时候就该自定义配置节了。
先看定义配置节类的代码。自定义配置节需要继承ConfigurationSection,内部的每个参数用ConfigurationProperty标记:
csharp复制using System.Configuration;
public class InstrumentConfig : ConfigurationSection
{
[ConfigurationProperty("name", IsRequired = true)]
public string Name
{
get { return (string)this["name"]; }
set { this["name"] = value; }
}
[ConfigurationProperty("ip", IsRequired = true)]
public string Ip
{
get { return (string)this["ip"]; }
set { this["ip"] = value; }
}
[ConfigurationProperty("port", IsRequired = true, DefaultValue = 502)]
public int Port
{
get { return (int)this["port"]; }
set { this["port"] = value; }
}
[ConfigurationProperty("timeout", IsRequired = false, DefaultValue = 3000)]
public int Timeout
{
get { return (int)this["timeout"]; }
set { this["timeout"] = value; }
}
}
类的属性名和配置文件里XML节点的属性名靠[ConfigurationProperty("name")]里的字符串做映射,不是自动对应,写的时候别搞混。要让这个配置类对应配置文件里的某个节点,还需要在配置文件里声明configSections:
xml复制<configuration>
<configSections>
<section name="instrument" type="YourNamespace.InstrumentConfig, YourAssemblyName" />
</configSections>
<instrument name="PLC1" ip="192.168.0.10" port="502" timeout="3000" />
</configuration>
注意type必须写成“完整命名空间.类名,程序集名”的格式,程序集名不带.dll后缀。工程里如果类库和exe项目在不同程序集,这里要写类所在的程序集的名称,不是入口程序集的名字。踩过这个坑的人应该知道,配置加载失败时报的错往往不能直接告诉你答案,只能靠排查.
3.2 使用ConfigurationSectionCollection管理多个相同类型的配置节
比单个仪器更常见的场景是多个仪器。我们可以设计一个自定义配置元素的集合,用ConfigurationElementCollection来实现。步骤是这样的:先定义一个InstrumentElement继承ConfigurationElement,再定义一个InstrumentCollection继承ConfigurationElementCollection,最后在DeviceConfigSection里暴露这个集合。
csharp复制using System.Configuration;
public class InstrumentElement : ConfigurationElement
{
[ConfigurationProperty("name", IsRequired = true)]
public string Name
{
get { return (string)this["name"]; }
set { this["name"] = value; }
}
[ConfigurationProperty("ip", IsRequired = true)]
public string Ip
{
get { return (string)this["ip"]; }
set { this["ip"] = value; }
}
}
public class InstrumentCollection : ConfigurationElementCollection
{
protected override ConfigurationElement CreateNewElement()
{
return new InstrumentElement();
}
protected override object GetElementKey(ConfigurationElement element)
{
return ((InstrumentElement)element).Name;
}
public InstrumentElement this[int index]
{
get { return (InstrumentElement)BaseGet(index); }
}
}
public class DeviceConfigSection : ConfigurationSection
{
[ConfigurationProperty("instruments")]
public InstrumentCollection Instruments
{
get { return (InstrumentCollection)this["instruments"]; }
}
}
对应的XML结构:
xml复制<configSections>
<section name="devices" type="YourNamespace.DeviceConfigSection, YourAssemblyName" />
</configSections>
<devices>
<instruments>
<add name="PLC1" ip="192.168.0.10" />
<add name="PLC2" ip="192.168.0.11" />
</instruments>
</devices>
读取方式:
csharp复制DeviceConfigSection section = (DeviceConfigSection)ConfigurationManager.GetSection("devices");
foreach (InstrumentElement instrument in section.Instruments)
{
Console.WriteLine($"{instrument.Name} -> {instrument.Ip}");
}
这里有个细节,GetSection返回的是object,必须强转。如果配置文件里section声明写错、或者配置类程序集没被正确加载,返回null的可能性也存在,强转前最好判断一下。
3.3 自定义配置节的核心原理:ConfigurationProperty
理解自定义配置节的关键,在于明白它其实是在“告诉.NET怎么把XML映射成对象”。ConfigurationProperty特性里的IsRequired表示这个属性在配置里必须出现,缺少会抛异常;DefaultValue表示缺省时的值。这些元信息会被运行时用来校验和初始化配置对象。
这种机制特别适合“配置结构复杂、重用小项多”的场景。比如设备通信参数、报表导出模板、算法阈值等。但也要注意,自定义配置节也会让配置文件的阅读门槛变高,非技术人员直接编辑时容易把XML写坏。所以一般情况下,我会建议团队里保留一个内置的配置编辑界面,通过代码来写配置,而不是让人手改XML。
4. 实战中必须绕开的坑:命名空间、文件位置、修改不生效
4.1 程序集改名和输出目录问题
这一节聊聊我在实际项目里遇到过的、也经常被同事问起的几个坑。
第一个是程序集改名后,配置文件失效。有些项目在迭代时改了主程序集名称,比如从DeviceTool改为DeviceMonitor,编译输出虽然会重新生成DeviceMonitor.exe.config,但如果你在某个路径下手动保留了一份旧的DeviceTool.exe.config,或者安装包脚本里还是用旧名字去找配置文件,启动时就会读不到配置。排查办法是检查输出目录里,exe文件和config文件的前缀是否完全一致。
第二个是启动目录不等于exe所在目录。用OpenExeConfiguration时,默认读的是Application.ExecutablePath对应的配置文件,这个路径通常是bin\Debug下的exe。但如果你在代码里通过Directory.GetCurrentDirectory()去定位配置文件,而程序是以服务方式启动或由其他进程拉起,当前目录不一定等于exe目录。稳妥的做法是显式拼接路径:
csharp复制string exePath = Assembly.GetExecutingAssembly().Location;
string configPath = exePath + ".config";
4.2 引用的DLL读不到配置:类库与入口程序集的关系
这是上位机和插件化项目里非常经典的问题。假设你建了一个Communication.dll类库,里面有段代码用了ConfigurationManager.AppSettings["ServerIp"],然后主程序MainApp.exe引用了这个DLL。你可能会想:DLL的配置文件在哪里?
答案是:DLL的代码没有自己的配置文件,它读的仍然是入口程序集的配置文件。也就是说,你需要在MainApp.exe.config里写ServerIp,不管这段代码运行在哪个类库里。如果你把配置写进了Communication.dll.config,那完全不会生效。
这个设计其实很有迷惑性。我早期就犯过这种错误:单独写了个类库处理配置,还给类库项目添加了App.Config,结果运行主程序时读出来全是null。正确做法是,所有配置集中放在入口exe的配置文件里,类库只负责通过ConfigurationManager去读取全局配置。
4.3 修改配置文件后不生效:缓存和文件被占用
还有一个常见场景:程序运行中你手动改了exe.config,重启程序后发现有的配置生效了,有的还是老值。这是因为ConfigurationManager在第一次访问后会把配置对象缓存起来,之后即使文件变了也不会重新加载。如果你确实需要热更新,可以调用ConfigurationManager.RefreshSection("appSettings")强制刷新:
csharp复制ConfigurationManager.RefreshSection("appSettings");
string value = ConfigurationManager.AppSettings["ServerIp"];
但要注意,RefreshSection只对后续读取生效,如果你已经把值缓存到了静态字段里,那个字段仍然是旧值。
另外,有些杀毒软件或文件同步工具会锁定exe.config文件。一旦文件被锁,保存配置时会抛IOException,提示文件正由另一进程使用。一般建议通过“写入临时文件再替换”的方式做保存,降低文件占用导致写失败的概率。
4.4 部署时配置文件丢失和权限问题
发布时,很多人习惯只拷exe,忘了把.config文件一起拷走。程序启动时虽然不会立刻报错,但所有配置项都会变成null,接着一系列奇怪的现象出现了:串口打不开、数据库连接失败、界面加载异常。排查时很容易让人以为是代码逻辑问题。所以在发布脚本里一定要包含exe.config文件。
另外就是权限问题。安装到C:\Program Files目录后,普通用户权限下,程序可能无法修改exe.config。如果你想在程序里动态保存配置,最好把配置文件放到Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)目录下,或者提示用户以管理员权限运行。这不是技术难点,但部署时最容易被忽略。
5. 动态写配置:程序里如何安全地修改并保存
5.1 修改并保存的基础代码
配置不只是用来读的,很多工具类应用需要把用户设置写回配置,比如记住上次选择的串口、窗口大小、主题颜色。用Configuration类能做到。
csharp复制using System.Configuration;
Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
config.AppSettings.Settings["DevicePort"].Value = "COM5";
config.Save(ConfigurationSaveMode.Modified);
ConfigurationManager.RefreshSection("appSettings");
这段代码的意思是:打开当前exe的配置文件,修改DevicePort的值,保存时只把发生变化的配置写回文件,最后刷新内存缓存让后续读取拿到新值。
ConfigurationSaveMode.Modified很有用,它只写有改动的部分,不会把整个配置重写一遍,减少文件变更范围,也比Full模式更清晰。如果配置文件中存在非标准内容但当前程序不关心,用Modified模式可以尽量保留原有内容。
5.2 保存时的异常和安全策略
保存配置最常见的异常有两个。
第一个是配置文件被占用或只读,config.Save()会抛异常,调用前最好先用FileAttributes检查文件是否为只读,或者try-catch后提示用户关闭文件重试。第二个是配置文件的XML结构损坏,OpenExeConfiguration阶段就可能抛ConfigurationErrorsException,这时应该备份原始文件,在异常信息里带上行号和文件路径,方便用户反馈。
更稳妥的做法是:写配置前先备份原文件,写完后校验XML能否被正常解析。如果写失败就回滚备份。这在生产环境里非常重要,因为一旦配置文件损坏,程序很多时候会直接起不来。
我习惯封装一个ConfigHelper:
csharp复制public static bool TrySaveConfig(XmlDocument doc, string path)
{
string backupPath = path + ".bak";
try
{
File.Copy(path, backupPath, true);
doc.Save(path);
return true;
}
catch
{
if (File.Exists(backupPath))
{
File.Copy(backupPath, path, true);
}
return false;
}
}
5.3 保存后马上读取还是旧值?
写完配置后,如果程序继续运行并读取配置,别忘了调用ConfigurationManager.RefreshSection("appSettings")或RefreshSection("connectionStrings")。原因前面说过,缓存不刷新,读到的还是内存里的老值。很多人改了配置没刷新,下一行去读还是旧值,就以为自己写失败了。其实刷新一下就好。
6. 再看一个完整示例:串口上位机里的配置读取实践
6.1 需求场景
假设你做一个简单的串口上位机,需要配置串口号、波特率、数据位、停止位、校验位,并且要把上一次的配置保存下来方便下次打开。用App.Config是最直观的。
6.2 配置文件设计
xml复制<configuration>
<appSettings>
<add key="ComPort" value="COM3" />
<add key="BaudRate" value="9600" />
<add key="DataBits" value="8" />
<add key="StopBits" value="One" />
<add key="Parity" value="None" />
</appSettings>
</configuration>
6.3 代码实现
csharp复制public class SerialPortConfig
{
public string ComPort { get; set; }
public int BaudRate { get; set; }
public int DataBits { get; set; }
public string StopBits { get; set; }
public string Parity { get; set; }
public static SerialPortConfig Load()
{
return new SerialPortConfig
{
ComPort = ConfigurationManager.AppSettings["ComPort"]?.Trim() ?? "COM1",
BaudRate = int.TryParse(ConfigurationManager.AppSettings["BaudRate"], out int baud) ? baud : 9600,
DataBits = int.TryParse(ConfigurationManager.AppSettings["DataBits"], out int bits) ? bits : 8,
StopBits = ConfigurationManager.AppSettings["StopBits"]?.Trim() ?? "One",
Parity = ConfigurationManager.AppSettings["Parity"]?.Trim() ?? "None"
};
}
public void Save()
{
Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
config.AppSettings.Settings["ComPort"].Value = ComPort;
config.AppSettings.Settings["BaudRate"].Value = BaudRate.ToString();
config.AppSettings.Settings["DataBits"].Value = DataBits.ToString();
config.AppSettings.Settings["StopBits"].Value = StopBits;
config.AppSettings.Settings["Parity"].Value = Parity;
config.Save(ConfigurationSaveMode.Modified);
ConfigurationManager.RefreshSection("appSettings");
}
}
这个封装好处在于:读取时有默认值兜底,配置缺失、类型转换失败不会抛异常;保存时统一走配置系统,后续加字段只需在配置项里加一行。界面里初始化时调用Load(),窗口关闭或点击保存时调用Save()就行。
6.4 这个示例里体现的读取习惯
我把常用的读取习惯总结一下:
- 默认值兜底:不是所有配置项都必须强制存在,像端口号可以默认COM1。
- 类型转换失败时静默回退:用int.TryParse而不是int.Parse,避免配置写错导致程序崩溃。
- Trim处理空白:防止手误或编辑器自动补空格。
- 写后立即刷新:保证同一进程内后续读取正确。
这四点看着不起眼,但在长年维护的项目里,能省下很多排查时间。
7. App.Config和appsettings.json怎么选:我的个人经验
很多新手问,既然.NET Core/.NET 5+流行appsettings.json,那App.Config是不是该淘汰了。我的看法是,看项目类型。
- 传统.NET Framework WinForms/WPF项目,维护多年、没有升级框架的计划,继续用App.Config没毛病,生态成熟,工具链完善。
- 新项目、目标框架是.NET 6/8,特别是有可能跨平台部署的应用,优先用appsettings.json。它的格式更简洁、层级更清晰、社区资料也更多。
- 如果你是做上位机、工控这种Windows独占项目,两种都可以。App.Config可以少引一个包,直接用ConfigurationManager;appsettings.json更适合配合依赖注入、Options模式做复杂配置管理。
如果是新项目但我又不希望引入太多依赖,我有时候会用App.Config打底:反正只要在csproj里引用System.Configuration.ConfigurationManager包就能用。不过如果项目里已经用依赖注入,我就更倾向appsettings.json,因为IOptions<T>的强类型绑定实在舒服。
另外,敏感信息一个原则:不管是App.Config还是appsettings.json,都不该提交明文密码到代码仓库。生产环境的连接串、密钥走环境变量、用户密钥文件或专门的配置中心。
写在最后
这篇文章没有贴太多高深理论,写的都是我实际排查和使用App.Config过程中沉淀下来的东西。你可以先搭个最小Demo跑一遍,把串口配置那个示例改改,然后试着加一个自定义配置节,再故意改坏一次配置文件看看报错,这样比背十遍文档都管用。遇到问题多从“加载机制”和“程序集命名”这两个角度想,大多数坑都能找到根源。
