C# WinForms用OleDb统一读取CSV与Excel表格
简介:《C#读取CSV EXCEL文件》是一份面向C#桌面开发初学者与需要处理表格数据开发者的技术文档,聚焦用OleDbConnection、OleDbDataAdapter读取CSV与Excel并绑定DataGridView显示的常见做法。压缩包仅含1个doc文件,大小约34KB,篇幅集中,便于快速查阅连接字符串、SQL查询与数据填充示例。文档围绕HDR、IMEX、FMT等扩展属性说明列名识别、混合数据类型和分隔符处理,也区分CSV与Excel在Provider及表名写法上的差异,可帮助读者理解数据导入窗体程序的基本流程并减少调试试错。目前已有260人学习,适合作为CSV/Excel读取的入门参考和代码片段备查。
1. 20 万行的表格卡住界面之后:用 OleDb 一条通道同时吃下 CSV 和 Excel
后台管理系统里最不起眼、又最容易出事的模块,大概就是「导入表格」。运营随手从系统里导出一个 CSV,或者把数据填进 Excel 发过来,程序要做的就是把它变成一张能在界面上看的表。C# 里读这两种文件的路子很多,但如果你不想为了一个导入功能引入三四个第三方库,OleDb 这条老通道依然是最省事的:CSV 走 text 驱动,Excel 走 Excel 驱动,连接字符串换一换,后面的OleDbDataAdapter.Fill和DataGridView绑定完全不用改。这份代码就是一个最朴素的样板,窗体构造函数里判断扩展名,分叉到两条读取路径。它适合刚接文件导入需求、想先把链路跑通的开发者,也适合手里有老 WinForms 项目、需要在不加依赖的前提下补一个读表能力的人。下面从环境、连接字符串、数据流一路拆到怎么改成能复用的类。
2. 先把环境跑通:x86 目标平台、Jet/ACE 提供程序与窗体骨架
这段代码在别人机器上跑不起来,十有八九不是代码写错了,而是目标平台或者提供程序没对上。原始片段里用的是Microsoft.Jet.OLEDB.4.0,这是一个只有 32 位的组件,而现在的 Visual Studio 新建 WinForms 项目默认是 AnyCPU,在 64 位系统上跑起来就是 64 位进程,一Open()就直接抛「未在本地计算机上注册 Microsoft.Jet.OLEDB.4.0 提供程序」。所以第一步不是改代码,是把运行环境钉死。
2.1 目标平台与提供程序的匹配关系
Jet 4.0 只有 32 位版本,ACE 12.0 同时有 32 位和 64 位版本,但两者必须和进程位数一致。判断方式很简单:任务管理器里看进程有没有「(32 位)」后缀,或者代码里打一行IntPtr.Size。我一般会在项目文件里直接把平台定死,而不是靠 IDE 的默认值,因为发布到服务器上时 AnyCPU 会跟着宿主走。
<!-- .csproj 里把目标平台钉成 x86,避免 Jet 4.0 在 64 位进程下找不到 --> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net48</TargetFramework> <PlatformTarget>x86</PlatformTarget> <Prefer32Bit>true</Prefer32Bit> </PropertyGroup>PlatformTarget决定编译产物是 32 位还是 64 位,Prefer32Bit只在 AnyCPU 下生效,两者一起写是为了防止有人后来把平台改回 AnyCPU 又忘了这茬。如果项目必须跑 64 位,就得把连接字符串里的Jet.OLEDB.4.0换成Microsoft.ACE.OLEDB.12.0,同时在机器上装对应位数的 Access Database Engine,装错位数一样报「未注册」。这是个死循环型错误:报错文本完全一样,但原因一个是平台、一个是安装包位数,排查时先确认这两件事,能省掉半小时。
2.2 从原始片段到可运行工程:窗体、控件和路径来源
原始代码把路径写死在构造函数里,string csvDir = @"C:\test1.xls";,注释还写着「要读取的 CSV 路径」,实际指的却是 xls,这种命名在后期维护时非常要命。改造的第一步是把路径从构造函数里挪出来,让按钮点击触发读取,窗体上放一个文本框显示路径、两个按钮分别选文件和开始导入、两个DataGridView分别接 CSV 和 Excel 的结果。
工程结构按最朴素的 WinForms 组织就行,不需要分层:
| 文件 | 职责 | 备注 |
|---|---|---|
CsvOrXlsForm.cs | 窗体逻辑,选文件、触发读取、绑表格 | 原始代码的CSVORXLS类 |
CsvOrXlsForm.Designer.cs | 控件声明 | dataGridView1/2、按钮、文本框 |
TableReader.cs | 读取逻辑,后面第 6 章会抽出来 | 初期可以先写在窗体里 |
App.config | 连接字符串集中管理 | 可选,但强烈建议 |
构造函数只留初始化,不再做 IO,这是血泪经验:构造函数里抛异常,窗体连设计器都打不开,调试时非常难受。
public partial class CsvOrXlsForm : Form { public CsvOrXlsForm() { InitializeComponent(); // 自动按 DataTable 的列生成网格列,列名直接取表头 dataGridView1.AutoGenerateColumns = true; dataGridView2.AutoGenerateColumns = true; // 只读,避免用户在界面上改了数据以为已经回写文件 dataGridView1.ReadOnly = true; dataGridView2.ReadOnly = true; } private void btnPick_Click(object sender, EventArgs e) { using (var dlg = new OpenFileDialog()) { // 过滤器和读取分支保持一致,别让用户选到不支持的格式 dlg.Filter = "表格文件|*.csv;*.xls;*.xlsx"; if (dlg.ShowDialog() == DialogResult.OK) { txtPath.Text = dlg.FileName; } } } }AutoGenerateColumns = true是让DataGridView按DataTable的Columns集合自动建列,列头文本就是DataTable的列名;如果设成false,就必须手写每一列的DataPropertyName,原始代码里那几行被注释掉的Columns[0].HeaderText = "编号"就是在干这件事。ReadOnly是我额外加的,导入场景下用户改网格没有任何意义,反而会让人误以为改动被保存了。
2.3 首次运行要验证的三件事
代码能编译不等于能用,第一次跑至少准备三个样本文件:一个只有表头没有数据行的空 CSV、一个字段里带英文逗号和双引号的 CSV(比如备注列写张三, "已确认")、一个包含两个工作表的 xls。三个样本分别对应后面会踩的三类坑:空表时Fill返回 0 行、带引号的字段被错误切分、select * from [test1$]只读到了第一个表。跑完这三个还没问题,再拿真实业务文件试,因为真实文件里往往有合并单元格、空列、日期格式混排,这些才是真正让人加班的东西。
3. 连接字符串拆解:text 与 Excel 两套 Extended Properties 的参数含义
OleDb 读文件的全部魔法都在这串连接字符串里,代码逻辑反而是最简单的部分。原始片段里两条分支的连接串写法差异很大,一条用目录 + text 驱动,一条用文件全路径 + Excel 驱动,很多人抄的时候只改了路径,没改Extended Properties,结果就是「明明文件在那儿,就是读不出来」。
3.1 CSV:Data Source 是目录,不是文件
这是最容易翻车的一点:text 驱动的Data Source必须填 CSV 所在的目录,文件名要放到 SQL 的表名位置。原始代码里那段LastIndexOf("\\")再Substring的切分,目的就是把全路径拆成目录和文件名,写法能用但很脆,路径末尾带反斜杠或者用正斜杠时会切错。用Path类更稳。
// 用 Path 拆目录和文件名,比手写 LastIndexOf 稳 var dir = Path.GetDirectoryName(csvFullPath); // D:\data\input var file = Path.GetFileName(csvFullPath); // test1.csv // text 驱动的关键参数:HDR 表头、FMT 分隔符、IMEX 混合类型、CharacterSet 编码 var connStr = "Provider=Microsoft.Jet.OLEDB.4.0;" + "Data Source=" + dir + ";" + "Extended Properties=\"text;HDR=YES;FMT=Delimited;IMEX=1;CharacterSet=936\";"; // 表名就是文件名,必须用方括号包住,否则名字里的点会被当成语法符号 var sql = "SELECT * FROM [" + file + "]";几个参数逐个说清楚。HDR=YES表示第一行是列名,设成NO时 Jet 会自动生成 F1、F2、F3 这样的列名,原始代码里被注释掉的第二个dataGridView2分支配合Columns[0].HeaderText = "编号"就是在手动补列名,说明那份数据是没有表头的。FMT=Delimited表示按分隔符切分,默认逗号;如果文件是分号或制表符分隔,要写成FMT=Delimited(;)或FMT=TabDelimited,不写的话整行会被当成一个字段读进来,界面上只显示一列,这个现象我第一次见的时候还以为是编码问题。IMEX=1把混合类型的列强制按文本读,是防「某几行数据凭空消失」的关键,下一节细说。CharacterSet=936指定按 GBK 解析,中文环境下不写它,用 Excel 另存的 CSV 大概率乱码。
还有一点,Data Source指向的目录里如果存在schema.ini,Jet 会优先读它而不是连接字符串里的FMT,这个特性后面第 5 章会用来强制列类型。
3.2 Excel:Excel 8.0 与 12.0 的版本对应,以及 sheet 名的写法
Excel 分支的Data Source是文件全路径,Extended Properties里写版本号。Excel 8.0对应 xls(97-2003 格式),Excel 12.0对应 xlsx,前者用 Jet 4.0 就能读,后者必须用 ACE 12.0 以上。原始代码里两种写法混着出现,其中一条把 Excel 文件的Extended Properties写成了text;HDR=YES;IMEX=1;,这是个典型的复制粘贴事故,用它去读 xls 会直接报「外部表不是预期的格式」。
// xls 老格式,Jet 4.0 即可;改成 xlsx 要换成 ACE.OLEDB.12.0 + Excel 12.0 var connStr = "Provider=Microsoft.Jet.OLEDB.4.0;" + "Data Source=" + xlsFullPath + ";" + "Extended Properties=\"Excel 8.0;HDR=YES;IMEX=1\";"; // 工作表名后面必须带 $,整体用方括号包住;名字里有空格也靠方括号兜住 var sql = "SELECT * FROM [Sheet1$]";HDR=YES在 Excel 下的含义和 CSV 一致,但它对空列的处理不太一样:如果第一行某个单元格是空的,Jet 会给这一列生成F1这样的名字,而不是报错。工作表名必须以$结尾,且整个标识符用方括号包住,[test1$]读的是名为test1的工作表;漏掉$会报「找不到表」,写成[test1.xls$]也不对。如果表名里有单引号或者方括号,还得再做转义,这种极端情况我一般直接放弃用 OleDb,转去用专门的库。
3.3 不写死工作表名:用 GetOleDbSchemaTable 枚举
原始代码把[test1$]写死在 SQL 里,换一个文件就得改代码。OleDbConnection提供了读取元数据的方法,可以在打开连接后先问一下这个文件里有哪些表、有哪些列,再决定查哪张。
using (var conn = new OleDbConnection(connStr)) { conn.Open(); // 第一个参数指定要取哪类元数据,Tables 就是工作表/命名区域 var schema = conn.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, null); foreach (DataRow row in schema.Rows) { var name = row["TABLE_NAME"].ToString(); // 过滤掉 _xlnm 开头的定义名称,只保留真正的工作表 if (name.EndsWith("$") && !name.StartsWith("_xlnm")) { Console.WriteLine(name); // 输出形如 Sheet1$、数据$ } } }GetOleDbSchemaTable的第一个参数是个 Guid,传OleDbSchemaGuid.Tables拿表清单,传OleDbSchemaGuid.Columns拿列清单。返回的DataTable里TABLE_NAME是表名,COLUMN_NAME是列名,DATA_TYPE是 OleDb 类型码。有了这个,界面就可以先弹一个下拉框让用户选工作表,选完再拼 SQL,比写死表名靠谱得多。要注意的是这个调用在文件很大时会慢一点,因为它要解析工作簿结构,所以别在循环里反复调。
4. 读取链路:从 OleDbDataAdapter.Fill 到 DataGridView 的完整数据流
原始代码的核心其实只有四行:建连接、打开、建OleDbDataAdapter、Fill到DataTable,最后把DefaultView挂给DataGridView。这段链路短得不像一个「项目」,但它包含几个默认行为,不理解就会在数据上吃亏。
4.1 逐段读原始代码:路径切分、SQL 拼接、Fill
把原始构造函数里的逻辑原样翻译成方法,逐行加注释,先看清楚它在干什么。
private DataTable ReadByOleDb(string fullPath) { bool isCsv = fullPath.Trim().ToUpper().EndsWith("CSV"); string connStr; string sql; if (isCsv) { // CSV:Data Source 是目录,表名是文件名 int pos = fullPath.LastIndexOf("\\"); string dir = fullPath.Substring(0, pos + 1); string fileName = fullPath.Substring(pos + 1); // HDR=YES 表示首行为列名;FMT=Delimited 表示逗号分隔 connStr = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + dir + ";Extended Properties='text;HDR=YES;FMT=Delimited;'"; sql = "SELECT * FROM [" + fileName + "]"; } else { // Excel:Data Source 是文件全路径,表名是工作表名加 $ connStr = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + fullPath + ";Extended Properties='Excel 8.0;HDR=YES;IMEX=1;'"; sql = "SELECT * FROM [Sheet1$]"; } using (var conn = new OleDbConnection(connStr)) using (var adapter = new OleDbDataAdapter(sql, conn)) { var dt = new DataTable(); adapter.Fill(dt); // 打开连接、执行查询、把结果灌进 DataTable return dt; } }using是相对原始代码唯一必须做的改造,原版OleDbConnection打开后没有Close也没有Dispose,文件句柄会一直被占着,用户想删文件或者用 Excel 打开就提示被占用。Fill内部会自己调Open,所以外部不需要手动开连接,但连接对象本身必须释放。OleDbDataAdapter的构造函数接受 SQL 文本和连接对象,Fill返回的是受影响行数,实际数据在dt里。
绑定这一步原始代码写了两行,AutoGenerateColumns = true加DataSource = dt.DefaultView。用DefaultView而不是直接给dt,好处是后面想加排序、筛选可以直接在DataView上设Sort和RowFilter,不用重新查一遍。
// 绑定前先解绑,避免连续导入两次时列被叠加 dataGridView1.DataSource = null; dataGridView1.AutoGenerateColumns = true; dataGridView1.DataSource = dt.DefaultView; // 想让某一列不可排序,可以在绑定后按列名取 if (dataGridView1.Columns["Id"] != null) { dataGridView1.Columns["Id"].SortMode = DataGridViewColumnSortMode.NotSortable; }DataSource = null这行是踩过坑之后加的:同一个网格连续绑定两个列结构不同的DataTable,旧列有时候会残留,表现为新数据左边多出几列空白。
4.2 DataTable 里的类型推断与脏数据处理
Fill出来的DataTable不是所有列都是string。Jet 会按前几行的内容猜类型,猜出来是System.Int32、System.Double、System.DateTime就按对应类型建列。问题在于它只采样前 8 行:如果前 8 行身份证号是纯数字,它建成整数列,第 200 行出现一个带 X 的号码,那一行的这一列就会变成DBNull,而不是报错。这就是「数据凭空少了」的真相。
IMEX=1的作用是把混合类型的列按文本处理,代价是纯数字列也变成字符串,界面上没法直接排序和计算。真正的解法是schema.ini,它和 CSV 放在同一目录,文件名必须和连接串里的表名对上。
[test1.csv] Format=Delimited(,) ColNameHeader=True CharacterSet=936 MaxScanRows=0 Col1=Id Integer Col2=Name Text Col3=Amount DoubleMaxScanRows=0表示扫描全部行来推断类型,比默认的 8 行靠谱得多;Col1、Col2这种按位置显式声明列名和类型,是最可控的写法。缺点是要为每个文件维护一个 ini,动态导入场景下不太现实,所以我的做法是:字段类型明确的业务导入用 schema.ini,来源不可控的临时导入一律IMEX=1全按字符串读,业务层再自己TryParse。
还有两个细节值得知道:重复列名会被 Jet 改成字段名1、F1这种形式;整行全空的记录会被直接跳过,所以DataTable的行号不等于文件的行号,报错信息里写「第 N 行」的时候要小心对不上。
4.3 大文件与界面卡顿:分批读和后台线程
几千行以内,上面这套代码直接在按钮事件里跑没问题。上万行开始DataGridView绑定就会明显卡,几十万行时界面直接假死,用户以为程序崩了。我这里给两条路:行数可控(比如十万以内)就用后台线程读、读完一次性绑定;行数不可控就用虚拟模式,只保留数据不建行。
private async void btnImport_Click(object sender, EventArgs e) { var path = txtPath.Text; btnImport.Enabled = false; try { // Task.Run 把同步的 Fill 挪到线程池,UI 线程保持响应 var dt = await Task.Run(() => ReadByOleDb(path)); dataGridView1.DataSource = dt.DefaultView; lblStatus.Text = $"共 {dt.Rows.Count} 行,{dt.Columns.Count} 列"; } catch (Exception ex) { // OleDb 的异常信息很模糊,把连接串一起打出来才找得到原因 MessageBox.Show(ex.Message, "读取失败"); } finally { btnImport.Enabled = true; } }await Task.Run之后回到 UI 线程再赋值DataSource,这一步不能省,跨线程直接改控件会抛InvalidOperationException。如果行数确实到了几十万,更彻底的做法是给DataGridView开VirtualMode = true,实现CellValueNeeded事件按需取值,DataTable只当数据仓库不当显示源。这条路代码量翻倍,但对内存和滚动流畅度的改善非常明显,值不值得看你的用户会不会真的导入那么大的文件。
5. 避坑与排查:OleDb 读表最常见的 5 个翻车现场
这套东西的报错信息普遍很模糊,同样一句「未指定的错误」背后可能是五种原因。下面五条是我实际遇到频率最高的,每条按现象、原因、解决写,照着对号入座基本能定位。
5.1 五条现象、原因与解决
第一条:报「未在本地计算机上注册 Microsoft.Jet.OLEDB.4.0 提供程序」。现象是代码编译通过,一运行到conn.Open()就抛异常,换个同事的电脑又能跑。原因是进程位数和提供程序位数不一致,Jet 4.0 只有 32 位版本,AnyCPU 在 64 位系统上默认跑 64 位。解决是把PlatformTarget改成x86重新编译,或者换Microsoft.ACE.OLEDB.12.0并装对应位数的组件。这里有个副作用要提醒:改成 x86 之后如果引用了别的 64 位原生库,会反过来报加载失败,所以这个决定要在项目早期定,别等上线前才发现。
第二条:Excel 里明明有数据,读出来某几列是空的,或者行数对不上。原因是 Jet 的类型推断只扫前 8 行,混合类型列后面的非数字值被当成DBNull。解决是在连接串加IMEX=1全按文本读,或者在同目录放schema.ini用MaxScanRows=0加显式列类型。改注册表里TypeGuessRows的偏方我不推荐,那会影响机器上所有程序,部署时也不好交代。
第三条:CSV 读出来只有一列,整个表格挤在一起。原因是文件不是逗号分隔,可能是分号、制表符或者竖线,而连接串里写的是默认的FMT=Delimited。解决是改成FMT=Delimited(;),制表符用FMT=TabDelimited。判断方法很简单,用记事本打开文件看分隔符就行。另外还有一种情况是文件本身是 UTF-16 编码,Jet 的 text 驱动对它支持很差,得先转成 UTF-8 或 GBK。
第四条:SQL 报语法错误,但语句看起来没问题。原因是表名或列名里带了空格、点号、短横线、中文括号,没被方括号包住。select * from test1.csv里的点会被当成「表.字段」的分隔符,select * from Sheet 1$里的空格直接截断语句。解决是所有标识符统一加[],动态拼接时对名字里的]做一次转义(替换成]])。我见过最狠的一个文件名里带单引号,最后是先把文件复制成临时名再读的。
第五条:文件正被 Excel 打开着,读取时报「正在使用中」或者读到一半失败。原因是 OleDb 打开文件的方式默认不是只读共享,遇到独占锁就失败。解决是在连接串后面加Mode=Read,或者更稳妥地先把文件复制到临时目录再读,读完删掉。复制这一步多花几十毫秒,但能彻底避开文件锁和用户中途保存导致的内容变化,生产环境里我基本都走这条。
提示:所有 OleDb 异常都建议把连接串(去掉敏感路径)和 SQL 一起打到日志里,光看
Message几乎定位不到问题,InnerException里往往才有真正的原因。
5.2 一套可以照着走的排查顺序
出问题时不要一上来就改代码,按这个顺序走一遍通常五分钟内能定位:先确认文件能不能被 Excel 正常打开(排除文件本身损坏);再看进程位数和提供程序是否匹配(排除环境);然后把连接串和 SQL 单独拎出来,在代码里打日志,确认表名和分隔符写对(排除参数);接着用GetOleDbSchemaTable把文件里真实的表名和列名打出来,对照 SQL 里的写法(排除命名);最后才怀疑数据本身,比如前 8 行类型、空行、编码。
这套顺序的价值在于把「猜」变成「查」。我见过不少人一遇到读不出来就去搜连接字符串的各种变体,一个个试,试到能跑为止,结果换一个文件又不行,因为根本没找到真正的原因。
6. 二次开发:把窗体里的硬编码抽成可复用的 CsvExcelReader
窗体里堆 IO 逻辑,写着快,改起来疼。只要这个功能会被第二个页面用到,就应该抽出来。我一般的做法是定义一个极薄的接口,把「读成 DataTable」和「读成强类型列表」分开,前者给网格用,后者给业务逻辑用。
| 成员 | 签名 | 说明 |
|---|---|---|
ReadTable | DataTable ReadTable(string path, string sheet = null) | 按扩展名自动分派,返回原始表 |
GetSheets | List<string> GetSheets(string path) | 只对 Excel 有效,返回工作表名 |
ReadObjects | List<T> ReadObjects<T>(string path) where T : new() | 按列名反射赋值,给业务层用 |
PreviewRows | DataTable PreviewRows(string path, int count) | 只读前 N 行,给导入预览用 |
实现上把第 3 章的连接串构造单独抽成一个BuildConnectionString方法,把第 4 章的读取包一层 try/catch 转成自定义异常,调用方就不用关心 OleDb 那套错误码。
public class CsvExcelReader { // 按扩展名分派,调用方不需要知道底下用的是哪套驱动 public DataTable ReadTable(string path, string sheet = null) { var ext = Path.GetExtension(path).ToLowerInvariant(); switch (ext) { case ".csv": return ReadDelimited(path); case ".xls": case ".xlsx": // 不传 sheet 就取第一个工作表,传了就按名字查 return ReadWorkbook(path, sheet ?? GetSheets(path).First()); default: throw new NotSupportedException($"不支持的格式:{ext}"); } } // 只读预览:SQL 里加 TOP,避免为了看十行把整个文件拉进来 public DataTable PreviewRows(string path, int count) { var ext = Path.GetExtension(path).ToLowerInvariant(); if (ext == ".csv") { var dir = Path.GetDirectoryName(path); var file = Path.GetFileName(path); var sql = $"SELECT TOP {count} * FROM [{file}]"; return Fill(BuildTextConn(dir), sql); } throw new NotSupportedException("预览目前只实现了 CSV"); } }改造时有两点值得提前想清楚。一是错误处理,OleDb 的异常信息对用户毫无意义,包装成「第 3 列的类型和模板不一致」这种话才有用,可以在Fill之后拿DataTable.Columns[i].DataType和预期类型比对,把差异一次性收集起来再抛。二是替换路径,CsvExcelReader这个抽象层的意义在于将来换成别的解析库时,只改实现不改调用方:CSV 换成分隔符处理更细的专用库,Excel 换成不依赖驱动的 OpenXML 方案,接口保持不变,窗体和业务代码一行不用动。
我现在的习惯是,凡是接文件导入的需求,先问清楚三件事:文件是系统导出的还是人工填的、最大多少行、字段类型稳不稳定。这三个答案决定了用IMEX=1全字符串兜底还是用schema.ini精确声明,也决定了要不要上虚拟模式。从那以后我每次写这类读取代码,都强制先跑一遍空表、带引号字段、多工作表这三个样本,再拿真实文件验证,省下来的返工时间远比准备样本的时间多。希望帮到你。
本文还有配套的精品资源,点击获取