天天看點

使用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:

使用C#對Excel檔案進行内容篩選
使用C#對Excel檔案進行内容篩選

一個最直接的想法就是使用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檔案會就修改原有檔案中的遊标位置之類的輔助資訊,文檔本體内容并沒有變化。
使用C#對Excel檔案進行内容篩選

(←示例圖檔如左)