简介:这份C#读取CSV与Excel文件的示例文档,面向具备C#基础、正在使用WinForms处理表格数据的开发者,重点解决读取CSV和Excel时连接字符串配置、数据填充与界面展示等常见问题。压缩包仅含1个doc文件,大小约34KB,内容集中,便于快速查阅和本地验证。文档围绕OLE DB连接与适配器、DataTable展开,涵盖CSV与Excel不同提供程序的选择、HDR、IMEX、FMT等扩展属性设置、按扩展名分支处理、SQL查询语句编写,以及DataGridView自动生成列和数据源绑定,并给出可直接参考的完整代码片段。示例中还通过路径判断分别处理CSV和XLS,注释提示有列名与无列名场景,读者可据此调整字段名、分隔符、工作表名和文件路径,减少反复试错。已有260人学习,适合需要快速搭建读取流程、理解OLE DB读取差异或排查表格数据显示问题的初、中级开发者参考。
1. 一份 30 行的 OleDb 示例,暴露了 C# 读取 CSV EXCEL 的四个边界
C# 读取 CSV、EXCEL 文件最省事的写法,就是挂一个 OleDbConnection,把整个文件当成一张表来 select。手头这份示例代码走的就是这条路:先用EndsWith("CSV")判断扩展名,再分别拼出文本驱动和工作簿驱动的连接串,OleDbDataAdapter.Fill灌进 DataTable,最后直接绑到 DataGridView 上。三十行跑通演示,看着很舒服。可一旦放进真实项目,第三天就开始翻车:本地读得干干净净的表,换台机器整列变成 DBNull;xlsx 还没打开就抛“未在本地计算机上注册提供程序”;编号列里001变成1,日期列变成一串 45000 多的数字。
这份资源的定位很清楚——它是一条“零依赖读表”的路线:不引第三方库,只用系统里的 OLE DB 提供程序,把 CSV 和 Excel 统一抽象成 SQL 表来查。适合维护老 WinForms 系统、需要给客户机器快速做个导入预览、又不想为读两张表引入一堆 NuGet 包的场景。往下读之前先接受一个前提:OleDb 读表能跑通不难,难的是把它在别人机器上跑通,难的是让列类型别被猜错。
2. 连接字符串逐段拆:Provider、Data Source、Extended Properties 怎么填
示例代码里两段连接串看上去只是换了个字符串,实际上每一段都对应一个会出问题的开关。这一章把三段拆开讲,每段配一个能直接抄的参数改法,先把“为什么这么写”立住,后面排错才有依据。
2.1 Provider 的选择:Jet 4.0 与 ACE 不是替代关系
Microsoft.Jet.OLEDB.4.0是随老系统一路带过来的文本/工作簿驱动,它只认.xls和分隔文本,不认.xlsx,而且只有 32 位版本。这一点非常关键:只要你的进程编译成 AnyCPU 并跑在 64 位运行时上,加载 Jet 就会抛“未在本地计算机上注册”的异常,代码一个字没错,环境直接判死刑。
Microsoft.ACE.OLEDB.12.0(以及更新版本的 ACE 提供程序)同时提供 32 位和 64 位版本,能读.xls、.xlsx和分隔文本。代价是它不一定在目标机器上存在,需要装一个可再发行组件包,而且装 32 位还是 64 位必须和你的进程位数对上,两个版本不能共存于同一个位数下随意混用。
我一般的选法是:新写的导入功能一律用 ACE,进程位数在项目属性里明确固定下来,别留 AnyCPU;只有在维护十几年前的老系统、客户机器上压根不允许装任何组件时,才退回 Jet,并且强制把目标平台设成 x86。这条经验值不少血泪——最典型的翻车是开发机装过完整办公套件所以两个引擎都有,测试和生产是干净的服务器,一跑就炸。
2.2 Data Source 的语义:CSV 指目录,Excel 指文件
这是示例代码里最容易看漏的一处。读 CSV 时,Data Source 填的是目录,不是文件本身;文件名是当作表名出现在 SQL 里的。原代码里那句取目录的写法是对的:
int ipos = csvDir.LastIndexOf("\\"); string filePath = csvDir.Substring(0, ipos + 1); // 截出目录,末尾带反斜杠 string connStr = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + filePath + ";Extended Properties='text;HDR=YES;FMT=Delimited;'";但下面这句拼 SQL 就有隐患了:
string commandText = "select * from " + csvDir.Replace(filePath, ""); // 得到的可能是 test1.csvReplace是按字符串全量替换的,如果文件名片段在目录路径里也出现过,会被一起替换掉,SQL 直接变形。正确做法是用Path.GetFileName取文件名,再用方括号包起来(下面第 3 章会讲为什么必须有方括号):
string fileName = Path.GetFileName(csvDir); string commandText = "select * from [" + fileName + "]";读 Excel 时语义反过来:Data Source 就是完整的文件路径,表名是工作表名加一个$,例如[test1$]。示例里写死select * from [test1$]只能读那张叫 test1 的表,换个工作簿就废,这是演示代码和工程代码的分水岭。
2.3 Extended Properties 三个开关:HDR、FMT、IMEX
这一串用单引号包起来的分号参数,是 OleDb 读表的核心配置,也是最容易被随手复制导致行为不对的地方。
HDR决定首行是不是列名。HDR=YES时首行被当作字段名,从第二行开始出数据;HDR=NO时驱动自己生成F1、F2、F3这样的列名,首行也当数据返回。示例代码里注释掉的那几行dataGridView2.Columns[0].HeaderText = "编号"就是配合HDR=NO用的——没有列名,只能手工改表头。
FMT只对文本驱动有效,常见取值是Delimited(按分隔符)、CSVDelimited(按逗号)、TabDelimited(按制表符)。特别提醒一句玄学点:FMT=Delimited用的是系统区域设置里的列表分隔符,在某些区域配置下它不是逗号。所以只要你的 CSV 结构是固定的,宁可在 schema.ini 里显式声明,也别指望Delimited猜对,这块后面单独讲。
IMEX是混合类型开关,取值 0/1/2。读文件场景基本只用IMEX=1,含义是“把含有混合类型(数字、日期、字符串混在一列)的列整体当文本读”。不加这个参数,驱动会按采样结果给整列定一个类型,一旦某列前几行是数字、后面冒出个N/A,那一格就会变成 DBNull,看着像数据丢了,实际上是类型转换失败被吞了。
// Excel 场景的典型写法:xlsx 用 Excel 12.0 Xml,xls 用 Excel 8.0 string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + filePath + ";Extended Properties='Excel 12.0 Xml;HDR=YES;IMEX=1;'";注意Extended Properties内部的引号用单引号,分号后面不要随手加空格,某些驱动版本对多余空格很敏感,会报“无法识别的扩展属性”。
2.4 一个能跑的最小骨架
把上面的结论收拢,下面这段是去掉界面耦合、可以直接塞进工具类的版本,两种格式统一入口:
using System.Data; using System.Data.OleDb; using System.IO; public static DataTable ReadTable(string path) { string ext = Path.GetExtension(path).ToLowerInvariant(); string connStr; string sql; if (ext == ".csv" || ext == ".txt" || ext == ".tab") { // 文本驱动:Data Source 是目录,表名是带扩展名的文件名 string dir = Path.GetDirectoryName(Path.GetFullPath(path)); string file = Path.GetFileName(path); connStr = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + dir + ";Extended Properties='text;HDR=YES;FMT=CSVDelimited;IMEX=1;'"; sql = "select * from [" + file + "]"; } else if (ext == ".xls") { connStr = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + path + ";Extended Properties='Excel 8.0;HDR=YES;IMEX=1;'"; sql = "select * from [Sheet1$]"; } else { connStr = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + path + ";Extended Properties='Excel 12.0 Xml;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); return dt; } }逐项说明:Path.GetFullPath是必要的,相对路径在GetDirectoryName时可能返回空串,连接串会变成Data Source=;直接报错;using包住连接和适配器,避免文件句柄没释放导致 Excel 里打不开这个文件;adapter.Fill一次把结果灌进 DataTable,行数超十万时会明显吃内存,这条路线不适合超大文件,后面第 6 章给替代方案。
3. CSV 落地:schema.ini 与目录级连接的两处硬约束
CSV 这条线看起来最简单,实际上问题密度最高。原因在于文本驱动没有任何类型元数据可依赖,一切靠猜:猜分隔符、猜编码、猜列类型、猜有没有表头。想让它别猜,就得把答案写进 schema.ini。
3.1 表名带点,必须加方括号
先把示例代码里那个最容易复现的错误说清楚。select * from test1.csv会抛语法错误,因为 SQL 解析器把.当成了库名和表名的分隔符。文本驱动的表名就是文件全名,带扩展名,所以必须包上方括号:
-- 错误写法,解析器会认为 test1 是库、csv 是表 select * from test1.csv -- 正确写法 select * from [test1.csv]同理,文件名里带空格、带中文、带括号时,方括号也必须加。这一点和 Excel 的表名规则一致,统一处理就行。
3.2 FMT=Delimited 的玄学与显式声明的价值
前面提过,FMT=Delimited依赖系统区域设置里的列表分隔符。我在一台欧洲区域配置的机器上遇到过 CSV 用分号分隔、代码里写Delimited后整行被当成一个字段返回的情况,排查了半小时才定位到区域设置。工程上的做法有两个:
一是把FMT写死成CSVDelimited(逗号)或TabDelimited(制表符),适用于格式确定的下游系统导出文件。二是完全不用 FMT 猜,改用 schema.ini 显式声明Format=CSVDelimited或Format=Delimited(;),把分隔符钉死。
; schema.ini,必须放在 CSV 所在目录下,文件名固定 [order_2024.csv] Format=Delimited(;) ColNameHeader=True CharacterSet=936 MaxScanRows=0 DateTimeFormat=yyyy-MM-dd Col1=OrderNo Text Width 32 Col2=CustomerName Text Width 64 Col3=Amount Double Col4=CreatedOn DateTime这份 ini 的每一项都有明确用途。[order_2024.csv]段名必须和文件名完全一致,包括大小写和扩展名,对不上就等于没写。Format=Delimited(;)指定分号作为分隔符,也可以写CSVDelimited表示逗号。CharacterSet=936对应 GBK,65001对应 UTF-8,这一项决定了中文字段会不会变乱码。MaxScanRows=0表示全表扫描来推断类型,默认只扫前 8 行,这是“整列变 null”的头号元凶。Col1、Col2这些显式列定义一旦写了,驱动就完全按你声明的类型走,不再采样猜测,Text Width n的宽度也要给够,超宽会被截断。
需要注意,schema.ini 只对文本驱动生效,而且必须与 CSV 同目录。很多项目是把上传的文件落到临时目录再读,那就得把 schema.ini 一起复制过去,或者在临时目录里动态生成一份。
3.3 编码问题:BOM、GBK 和首列名污染
CharacterSet不写时会用系统默认编码,中文环境下通常是 GBK,于是 UTF-8 的 CSV 读出来中文全是问号。反过来,把CharacterSet=65001写在 GBK 文件上也一样炸。可行的判断方式是读文件头几个字节做 BOM 检测,没有 BOM 再按启发式判断:把前几 KB 按 UTF-8 严格解码,抛异常就退回 GBK。
static int DetectCodePage(string path) { byte[] head = new byte[4]; using (var fs = File.OpenRead(path)) fs.Read(head, 0, 4); if (head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) return 65001; // UTF-8 BOM if (head[0] == 0xFF && head[1] == 0xFE) return 1200; // UTF-16 LE return 936; // 兜底 GBK }带 BOM 的 UTF-8 还有个副作用:HDR=YES时首列列名会带上一个不可见的 BOM 字符,界面上看不出来,代码里按列名取值却取不到。处理办法是在拿到 DataTable 之后统一清洗列名,把\uFEFF和首尾空白一起去掉,这一条后面第 4 章给具体写法。
3.4 目录级连接的副作用
Data Source 指向目录这件事,意味着同目录下所有同扩展名文件都在这个连接的可见范围内。这带来两个实际问题:一是权限,如果目录是共享目录或者用户临时目录,可能读到不该读的文件;二是误读,select * from [a.csv]写错文件名时,某些驱动版本不会报错而是返回空表,排查时容易误判成文件没数据。
我的习惯是读取前先File.Exists校验一次,读完立刻释放连接,临时文件用完就删。另外,文本驱动识别的扩展名是有限集合(.csv、.txt、.tab等),把.csv改名成.dat再读,会直接报“找不到可安装的 ISAM”,这个报错跟引擎没关系,纯粹是扩展名不在白名单里,很容易误判成环境问题。
4. Excel 落地:工作表名、列名清洗与类型推断
Excel 这条线的难点不在连接串,在于工作簿的结构是用户随手画的:表名带空格、首行是标题合并单元格、中间夹杂空行、同一列上半截是数字下半截是文字。OleDb 把这些统统当成一张“表”,你得自己去适应。
4.1 表名即工作表名,$不能少
Excel 驱动的表名规则是[工作表名$],末尾的美元符号必须有,漏了就报“找不到表”。工作表名带空格或中文时同样用方括号包住:
string sheetName = "订单明细"; string sql = "select * from [" + sheetName + "$]";示例代码里select * from [test1$]就是把工作表名写死了。实际项目里工作表名往往由用户决定,比较稳的做法是先查架构信息拿到真实表名,再拼 SQL。
4.2 先读架构再拼 SQL
OleDbConnection.GetOleDbSchemaTable能列出工作簿里所有“表”,过滤掉系统表和命名区域,剩下的就是以$结尾的工作表:
public static List<string> GetSheetNames(string excelPath) { var result = new List<string>(); string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + excelPath + ";Extended Properties='Excel 12.0 Xml;HDR=YES;IMEX=1;'"; using (var conn = new OleDbConnection(connStr)) { conn.Open(); DataTable schema = conn.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, null); foreach (DataRow row in schema.Rows) { string name = row["TABLE_NAME"].ToString(); // 只保留工作表,排除命名区域和系统表 if (name.EndsWith("$") && !name.StartsWith("_xlnm")) result.Add(name.TrimEnd('$')); } } return result; }OleDbSchemaGuid.Tables返回的 TABLE_NAME 已经带了$,拼 SQL 时要补回方括号,所以取名字时先TrimEnd('$')再统一拼接,避免出现[[Sheet1$]$]这种畸形写法。命名区域在架构里也会出现,但它不以$结尾,被上面的条件过滤掉了。
这里有个边界:工作表名本身可能包含$之外的怪字符,比如单引号。拼接 SQL 时如果表名来自用户输入,务必做一次白名单校验,只允许字母、数字、中文、下划线和空格,别把用户输入直接拼进 SQL 字符串。
4.3 列名清洗:重复列、空列名和 BOM
Excel 驱动对列名的处理有一堆默认行为,全都得在拿到 DataTable 之后兜住。第一行有两列都叫“金额”时,驱动会给第二列自动改名成F2之类的占位名;某列首行为空时,同样落到F1、F2;首列带 BOM 时列名前面多一个不可见字符。这些列名直接绑到界面上就是一片混乱。
static void NormalizeColumns(DataTable dt) { var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < dt.Columns.Count; i++) { DataColumn col = dt.Columns[i]; // 去掉 BOM、首尾空白、全角空格 string name = col.ColumnName.Replace("\uFEFF", "").Trim().Trim('\u3000'); if (string.IsNullOrEmpty(name)) name = "列" + (i + 1); string final = name; int suffix = 2; while (!used.Add(final)) // 重名时追加序号 final = name + "_" + suffix++; col.ColumnName = final; } }这段逻辑看着琐碎,但不做的话,后面按列名取值的代码会在某些文件上静默取到 null。清洗放在Fill之后、绑定界面之前,是一步不能省的固定动作。
4.4 类型推断:8 行采样与前导零
Excel 驱动推断列类型只扫描前若干行(默认 8 行,由引擎的 TypeGuessRows 决定)。一列前 8 行都是数字、第 9 行开始出现未知这种文本,结果就是前 8 行正常、后面的行整列变 null。加了IMEX=1能把混合列按文本读,但它同样受采样行数限制——采样时没看到混合,就仍然按数字处理。
比类型推断更烦的是格式丢失。001这种编号列被推断成数字后变成1,2024-01-05被推断成日期后变成 OADate 数字,长的身份证号变成科学计数法。根治办法是在读取时就不让它推断:给需要原样保留的列在 SQL 里显式转换,或者干脆在源头上用 schema 方式约束。Excel 没有 schema.ini,能用的手段是把查询写成select Format(编号,'000') as 编号, ...这类表达式,但格式函数在不同引擎上兼容性一般。
我的常规做法是两步:一是IMEX=1常态化打开,把绝大多数混合列按文本读回来;二是在 DataTable 上做一次“回读校验”,对已知的编号、证件号、日期列按字符串重新格式化,把前导零和日期格式补回去。这属于应用层的兜底,比和驱动较劲省事得多。
4.5 绑定到 DataGridView 的两个细节
示例代码里用的是dataGridView1.DataSource = dt.DefaultView,而不是直接赋dt。DefaultView 支持排序和筛选,界面上的列头点击排序才不会抛异常;如果后续要做过滤,也是在这个视图上设RowFilter。
dataGridView1.AutoGenerateColumns = true; dataGridView1.DataSource = dt.DefaultView; dataGridView1.AllowUserToAddRows = false; // 只读预览时关掉末尾空行HDR=NO的场景要额外处理:列名是F1、F2,得手工改名并且关掉排序,因为文本列排序规则和数值列不一样,用户点了会看到奇怪的顺序。示例代码里注释掉的SortMode = DataGridViewColumnSortMode.NotSortable就是干这个的,只不过它写在了dataGridView2上——复制粘贴时控件名没改,这是这类示例代码最常见的低级错误,照着抄的时候一定逐个核对控件名。
5. 避坑排查:五个真实翻车现场与对应改法
这一章按“现象 → 原因 → 解决”记五条,都是我在这条路线上实打实踩过的,覆盖环境和数据两类。遇到报错先在这里对一遍,能省掉大半排查时间。
5.1 环境与引擎类故障
现象一:本地跑得好好的,换台机器就抛“未在本地计算机上注册 Microsoft.Jet.OLEDB.4.0 提供程序”。
原因基本是两个:进程是 64 位而 Jet 只有 32 位;或者目标机器上压根没装对应的引擎。开发机往往装过完整办公套件,两个引擎都有,所以永远复现不出来。
解决分两步走。先把项目目标平台从 AnyCPU 改成明确的 x86 或 x64,别让它随运行时漂移;再把 Provider 换成 ACE,并在部署文档里写清楚需要安装对应位数的可再发行组件。如果客户环境不允许装组件,就固定 x86 加 Jet,并且确认系统里那个老引擎还在。
现象二:xlsx 文件报“外部表不是预期的格式”。
原因是拿 Jet 或Excel 8.0去读 xlsx。Jet 只认老的二进制工作簿格式,扩展属性写Excel 8.0时也只能读 xls。
解决是按扩展名分流:.xls用Excel 8.0,.xlsx用Excel 12.0 Xml,两者都走 ACE。不要图省事统一写Excel 12.0,某些版本的驱动在读老 xls 时会有兼容性抖动。
5.2 数据解析类故障
现象三:CSV 里某一列大部分是数字,夹杂几个N/A,读出来整列大量 DBNull。
原因是文本驱动按前 8 行采样定类型,采样窗口里全是数字就定成数值列,后面遇到非数字转换失败,直接给 DBNull,不报错。
解决办法是双管齐下:连接串加IMEX=1,同时在 schema.ini 里把MaxScanRows=0打开并显式声明该列类型为Text。IMEX=1单独用并不保险,它受采样行数约束,只有显式列定义才是确定性的。
现象四:编号列前导零丢失,001读成1;日期列变成45296这样的整数。
原因同上,类型被推断成数值或日期,格式信息在转换过程中丢了。
解决办法是让这些列以文本读回来。CSV 在 schema.ini 里声明Col1=Code Text Width 32;Excel 没有 ini 可用,就在读取后做一次格式化回填,把int值按D3补零,把 OADate 用DateTime.FromOADate转回日期再按业务格式输出。
现象五:字段里有逗号,读出来列整体右移,最后一列全是 null。
原因是 CSV 字段里的逗号被当成分隔符,而字段本身没有用引号包裹。文本驱动支持引号包裹,但如果源文件生成时就没加引号,任何驱动都救不了。
解决办法是先判断数据质量:抽样看几行,确认字段里是否存在裸逗号。存在就用TextFieldParser这类按引号规则解析的读取器替代 OleDb,或者要求上游系统导出时统一加引号。别指望在 OleDb 层做兜底,那是解析器该干的事。
6. 封装与验证:把读表逻辑收进一个可复用导入器
上面几章的内容最终要收敛成一个入口,否则每个窗体里都复制一遍连接串,改一个参数要动十个文件。我的做法是抽一个静态类,对外只暴露ReadTable(path)和ReadSheet(path, sheetName)两个方法,内部负责扩展名分流、编码检测、列名清洗和异常包装。异常包装这一步别省:把OleDbException原样抛给界面,用户看到的是一串英文加错误码,包装成“文件格式不支持或缺少驱动,请确认文件未被占用”这类可读信息,排障效率差好几倍。
| 方案 | 适用文件 | 依赖 | 主要限制 |
|---|---|---|---|
| OleDb + Jet | csv、xls | 系统自带,仅 32 位 | 不支持 xlsx,新系统可能已移除 |
| OleDb + ACE | csv、xls、xlsx | 需装可再发行组件 | 位数必须与进程一致 |
| TextFieldParser | csv、txt | .NET 自带 | 不支持 Excel,需自己处理类型 |
| 开源 CSV 库 | csv | NuGet 包 | 只解决 CSV,Excel 仍需另找方案 |
验证环节我固定跑三步。第一步对行数:DataTable.Rows.Count与文件实际数据行数比对,差一行通常是表头或空行被吃掉了。第二步抽样比对:随机取三行,把关键字段和用记事本打开看到的值逐字比,重点看前导零、日期、负数和小数位。第三步是异常路径:传一个不存在的文件、一个被 Excel 占用的文件、一个空文件,确认包装后的提示能说清问题。
最后一句话是血泪经验:凡是涉及外部文件的导入功能,我从不相信“我这边能读就行”。每次提交前强制在 32 位和 64 位两个进程、两个不同区域设置的机器上各跑一遍,把 schema.ini 一起打进去,把引擎依赖写进部署清单。这个习惯是从一次上线当天客户机器缺组件、四十多个人干等着导入数据的那次事故之后养成的。希望帮到你。
本文还有配套的精品资源,点击获取