使用C#对Excel文件进行内容筛选

(一) 问题

一个使用C#编写的小工具,对指定文件夹下的所有Excel文件进行筛选,如果一个文件中含有某个指定的字符串,记录该文件,并且定位这个字符串出现的位置。

网上使用C#对Excel文件进行读写的方法主要有四种:

(1)使用OleDb的方法读取Excel文件;

(2)将xls文件转化为csv文件再进行读取;

(3)使用NPOI进行Excel文件读取;

(4)使用COM组件进行读取。


(二)尝试

这几种读取Excel文件的方法我都了解了一下,每种方法各有利弊。下面进行简单的描述:

(1)使用OleDb的方法读取Excel文件

这种方法相关内容在网上可以找到很多,很多人把这种方法当作读取Excel文件的主流。简单来说就是将Excel文件当作数据库进行操作,对表中内容使用sql语句进行提取,其间需要使用到DataSet类。

顺便说一句,对于不同的.net数据提供者,ADO.NET采用不同的Connection对象连接数据库。这些Connection对我们屏蔽了实现的具体细节,并提供了一种统一的实现方式。Connection类有四种:SqlConnection、OleDbConnection、OdbcConnection和OracleConnection。分别用来连接SQL Server数据库、OLE DB数据库(如Access)、ODBC数据库、Oracle数据库。与数据库的所有通讯都是通过Connection对象完成的。本文简单谈一谈OleDb。

建立OleDb连接的核心是建立连接字符串ConnectionString,主要注意的是,对xls文件(Excel2003)和xlsx文件(Excel2007)进行连接的连接字符串不同。假设excelPath为目的excel文件的路径,则建立OleDb连接的语句分别为:

OleDbConnection connection = new OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + excelPath + ";" + "Extended Properties=\"Excel 8.0;HDR=yes;IMEX=1;\"");

OleDbConnection connection = new OleDbConnection("Provider=Microsoft.Jet.OLEDB.12.0;Data Source=" + excelPath + ";" + "Extended Properties=\"Excel 12.0;HDR=yes;IMEX=1;\"");

其中"HDR=yes"是说Excel文件的第一列是列明而不是数。如果列中数据类型不一致,使用"IMEX=1"可以避免类型冲突。下面是一段网上的示例代码:

public DataSet ExcelToDS(string Path)  
{  
    string strConn = "Provider=Microsoft.Jet.OLEDB.4.0;" +"Data Source="+ Path +";"+"Extended Properties=Excel 8.0;";  
    OleDbConnection conn = new OleDbConnection(strConn);  
    conn.Open(); 
    string strExcel = "";  
    OleDbDataAdapter myCommand = null;  
    DataSet ds = null;  
    strExcel="select * from [sheet1$]";  
    myCommand = new OleDbDataAdapter(strExcel, strConn);  
    ds = new DataSet();  
    myCommand.Fill(ds,"table1");  
    return ds;  
}

但是在尝试这种方法解决问题的时候遇到了一些麻烦,比如说满足问题需要的Sql并不是很好写(数据库基本功稍差,惭愧)。另外在网上没有找到在使用OleDb读取文件的时候获取excel所有sheet表的方法(后来在另外一片博客中找到了,代码没有经过尝试:https://www.cnblogs.com/xifengyeluo/p/8617865.html)。再者DataSet数据类型也不是很熟悉,因此本方法没有继续下去。

(2)将xls文件转化为csv文件再进行读取

之前使用过Java读取xls文件,当时直接将xls文件当作csv文件(Comma-Separated Values File逗号分隔值文件)进行读取解析。C#也可以使用类似的方法读取xls文件,等价于使用文件流读取一个txt文本文件。

同样附上一段示例代码:

FileStream fs = new FileStream(excelPath, FileMode.Open, FileAccess.Read, FileShare.None);  
StreamReader sr = new StreamReader(fs, System.Text.Encoding.GetEncoding(936));  
string str = sr.ReadLine();  //读取表头
string target = Console.ReadLine();   //输入想要查询的字符串
while (str != null)  
{      
    int count = 0;
    str = sr.ReadLine();
    string[] strs = str.Split(',');  
    foreach(string s in strs)
    {
        if(s == target)
            Console.WriteLine(++count);
    }  
}     
sr.Close();

将xls文件转化为csv文件最大的问题在于分隔符的问题。一般来说,使用这种方法读取的时候,我们每读取一行字符串相当于读取了xls文件中的一行,之后会使用Split(‘,’)方法进行字符串分割从而获得表中的每一单元格的数据。但是如果原本xls正文中有逗号的话,分割会出现错误。另外还有很多问题需要考虑,比如说:一个文件多个sheet的获取方法、定位当前的单元格的方法、这种方法的实现有很多字符串操作。所以这种使用数据流来读取xls的方法并不“优美”。

(3)使用NPOI进行Excel文件读取

这种方法能够查阅到的资料过少,因此没有进行尝试。有时间的话,了解一下再来补充。

(4)使用COM组件进行读取

这种方法需要先引用Microsoft.Office.interop.Excel。可以很灵活的读取Excel中的数据,而且使用方式很丰富,基本上凡是打开OfficeExcel软件能够用鼠标点击完成的事,调用COM组件都能完成,而且可以调用Excel自身带的宏方法。

添加COM组件引用的过程如下图所示,使用的编译器为VS2017:

一个最直接的想法就是使用COM组件按照行列依次读取Excel文件中每个sheet的单元格,具体代码如下:

static void ReadExcel(string path, string target)
{
    Console.WriteLine(path);
    try
    {
        //如果要读取多个Excel文件建议将下面两个变量的声明和初始化放在全局变量的位置
        //每新建一次Excel.Application都相当于打开了一次Excel.exe,将其声明为全局变量可以提高性能
        Microsoft.Office.Interop.Excel._Application xlsApp = new Microsoft.Office.Interop.Excel.Application();
        Microsoft.Office.Interop.Excel.Workbooks books = xlsApp.Workbooks;

        books.Open(path);
        Microsoft.Office.Interop.Excel.Sheets sheets = xlsApp.Worksheets;
        
        //注意:sheet和row,column的起始索引都是1
        for (int s = 1; s <= sheets.Count; s++)
        {
            Console.WriteLine(path + "\t" + s);    //打印搜索进度
            int rows = sheets[s].Rows.Count;
            int columns = sheets[s].Columns.Count;

            for(int i=1;i<=rows;i++)
            {
                for(int j=1;j<=columns;j++)
                {
                    //下面这一行获取了一个单元格
                    Microsoft.Office.Interop.Excel.Range cell = sheets[s].Range[xlsApp.Cells[i, j], xlsApp.Cells[i, j]];
                    if(cell.Text == target)
                    {
                        //将满足条件的单元格路径、sheet序列、位置、内容记录进入一个txt文件,这个函数需要自己实现
                        RecordIntoTXT(path, s, cell.Address, target);
                    }
                }    
            }
        }
    }
    catch (Exception e)
    {
        //sw1为一个log文件的文件流,将出现异常的文件路径记录到文件中
        //这个位置也可以使用throw抛出异常
        sw1.WriteLine(path);
    }
}

这种方法网上也能找到很多类似的代码,比如:

https://blog.csdn.net/qq_33459369/article/details/79314465中的代码。这个博客中的代码使用单线程读取Excel文件的时候使用了range.Select方法,这个方法相当于是在Excel表格中选中了一个范围内的格子(在这个代码语境下是选中了一个(i,j)位置的格子),然后再使用ActiveCell字段获取被选中的格子,再进行后续操作,比较麻烦,也没有必要。

到现在为止,已经完成了这个问题的核心,也就是读取Excel文件。但是这种方法有很大的不足。首先是效率问题,这种使用代码遍历单元格的执行效率极低,甚至到了一个不能接受的程度。另外,上面的示例代码只是判断了一下字符串相同,如果要进行字符串匹配的话(即判断target字符串是否被包含在表格的字符串中,最经典的字符串匹配算法是KMP算法,有兴趣的可以了解一下。当然,使用string内置的方法也可以)就又会多一些工作量。所以还有很大的提升空间,在一番尝试下,终于有了一个比较优美的解答。


(三)最终解

之前在描述COM组件的时候提了一句,使用COM组件可以调用Excel自身的宏方法。在Excel中寻找指定的内容最直接的办法当然是调用Excel本身的Find方法(相当于在Excel中使用Ctrl+F快捷键)。这样一来,可以避免之前代码中对行列进行的二重循环,也解决了字符串匹配的问题。唯一需要注意的地方在于使用Find+FindNext方法联合进行查找的时候需要定位初始位置,否则会进行无限循环,这也算是FindNext方法的特性吧。示例代码如下:

static void ReadExcel(string path, string target)
{
    Console.WriteLine(path);
    try
    {
        Microsoft.Office.Interop.Excel._Application xlsApp = new Microsoft.Office.Interop.Excel.Application();
        Microsoft.Office.Interop.Excel.Workbooks books = xlsApp.Workbooks;
        books.Open(path);
        Microsoft.Office.Interop.Excel.Sheets sheets = xlsApp.Worksheets;

        for (int s = 1; s <= sheets.Count; s++)
        {
            Console.WriteLine(path + "\t" + s);
            Microsoft.Office.Interop.Excel.Range range = sheets[s].Cells;
            Microsoft.Office.Interop.Excel.Range ans = range.Find(target);
            if (ans == null) continue;
            string firstAddress = ans.Address;    //定位初始位置
            do
            {
                string text = ans.Text;
                RecordIntoTXT(path, s, ans.Address, text);
                ans = range.FindNext(ans.Cells[1, 1]);    //意思是从当前查找位置开始寻找下一个
            } while (ans != null && ans.Address != firstAddress);    //如果到达初始位置表示当前sheet查找结束
        }
    }
    catch (Exception e)
    {
        sw1.WriteLine(path);
    }
}

在使用COM组件进行读取Excel文件的时候,一定要注意最终程序的出口。如果在执行完上面的函数没有进行任何结束处理就退出程序的话,Excel程序其实还是在后台运行的,双击之前的Excel文件会提示正在被修改只能以只读模式打开。因此需要增加一个退出程序的函数。网上的资料中这个函数的实现方法都大致相同,如下给出示例:

static void QuitExcel()
{
    if (xlsApp != null)    //xlsApp为上面那个示例函数中的变量,当作全局变量声明了
        xlsApp.Quit();
    System.Diagnostics.Process[] pros = System.Diagnostics.Process.GetProcessesByName("excel");
    foreach (System.Diagnostics.Process pro in pros)
    {
        pro.Kill();    //杀死了所有excel进程
    }
    GC.Collect();    //进行垃圾回收,其实我并不知道为什么在这里要强制调用垃圾回收,只是网上这样写了,我就照做了
}

(四)除了读取Excel文件之外的其他功能实现

到现在位置,整个代码的核心部分就已经完成了。我们还需要完成一些核心的支持工作。

(1)文件夹下所有文件的遍历

这一部分纯属文件目录相关的操作,需要引入System.IO命名空间。基本思想是获取一个给定的根目录,将所有excel文件加入一个list中,再获取这个根目录下的所有子文件夹,使用相同的方法遍历所有的子文件夹,将里面的所有excel文件同样加载到同一个list中,示例代码如下:

static void GetFiles(string path)
{
    DirectoryInfo root = new DirectoryInfo(path);

    FileInfo[] files = root.GetFiles();        //获取根目录的excel文件
    string fileName;
    foreach (FileInfo f in files)
    {
        fileName = f.Name.ToLower();
        if (fileName.EndsWith(".xls") || fileName.EndsWith(".xlsx"))
        {
            allFiles.Add(f);    //allFiles是一个全局的List<FileInfo>
        }
    }

    //下面这几行遍历子节点
    DirectoryInfo[] directories = root.GetDirectories();
    foreach (DirectoryInfo d in directories)
        GetFiles(d.FullName);
}

(2)查找结果的记录

查找结果记录的方法可以自选,这里使用了存储在桌面下的txt文件中,开启结束文件流的示例代码如下:

//fs,sw,fs1,sw1为四个全局变量
static void StartFileStream()
{
    string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);    //这一行获取桌面路径

    fs = new FileStream(desktopPath + "\\record.txt", FileMode.Create, FileAccess.Write);
    sw = new StreamWriter(fs);
    fs1 = new FileStream(desktopPath + "\\log.txt", FileMode.Create, FileAccess.Write);
    sw1 = new StreamWriter(fs1);
}

static void EndFileStream()
{
    sw.Flush();    //清空缓存
    sw.Close();    //关闭writer
    fs.Close();    //关闭文件流
    sw1.Flush();
    sw1.Close();
    fs1.Close();
}

到此位置,整个问题的功能基本实现。整体代码有机会的话,会上传到GitHub,到那时候再来这里附上链接。

注意:使用COM组件打开Excel文件的时候会出现一些问题。当你文件本身存在问题,即直接双击打开一个Excel文件在表头出现如下提示的时候,使用COM组件打开会失败,这时候当时的try-catch语句的功能就体现出来了,会将这个文件的路径记录到log中,便于后续人工操作。另外,有的Excel文件在打开的时候会弹出一个Warining对话框,让用户选择一些操作,这个时候使用COM组件进行读取的时候也弹出对话框让用户进行选择。还有一点需要注意,使用这种方法读取Excel文件之后,所有被读取过的xlsx没有问题,而被读取过的xls文件会在SVN中提示文件有修改,猜测可能是新版的Excel读取xls文件会就修改原有文件中的游标位置之类的辅助信息,文档本体内容并没有变化。

(←示例图片如左)

  • 6
    点赞
  • 48
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值