用C#寫差異文件備份工具


大家是不是平常都有好多文件需要定期備份?如歌曲、視頻、文檔,代碼文件等等,如果經常增加刪除修改文件,就需要定期備份,最早之前文件都不大的時候我都是手工先全部刪除,然后再全部拷貝,感覺比較保險。后來有了很大的視頻文件(隨附的字幕文件經常有誤需要修改)和很瑣碎的代碼文件之后,這樣搞太折磨人,就學網上說的用Xcpoy組裝了一個批處理,但是選擇源目錄和備份目錄的時候比較麻煩,得一個個手輸。學了C#后,感覺還是做一個GUI體驗更好用起來更方便。至於專業的工具,還真沒怎么試過,有點不放心吧,有好用的倒是可以試試。現在先自己做一個用着吧。

關鍵代碼如下:

        private async void btnBackUp_Click(object sender, EventArgs e)
        {
            string sourceDirectory = txtSource.Text;
            string targetDirectory = txtTarget.Text;
            if (sourceDirectory.ToLower() == targetDirectory.ToLower())
            {
                Console.WriteLine("源目錄和備份目錄不能是同一目錄!");
                MessageBox.Show("源目錄和備份目錄不能是同一目錄!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
                return;
            }
            DirectoryInfo diSource = new DirectoryInfo(sourceDirectory);    // 源目錄
            DirectoryInfo diTarget = new DirectoryInfo(targetDirectory);    // 備份目錄
            if (diTarget.Name != diSource.Name)
                diTarget = new DirectoryInfo(Path.Combine(diTarget.FullName, diSource.Name));    // 創建同名目錄
            if (!diTarget.Exists) diTarget.Create();    // 如果該目錄已存在,則此方法不執行任何操作
            btnBackUp.Enabled = false;
            txtSource.Enabled = false;
            txtTarget.Enabled = false;
            lblWork.Text = "備份開始!";
            if (await CopyAllAsync(diSource, diTarget))
            {
                lblWork.Text = "備份完成!";
                MessageBox.Show("備份完畢!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
            }
            else lblWork.Text = "出現錯誤!";
            btnBackUp.Enabled = true;
            txtSource.Enabled = true;
            txtTarget.Enabled = true;
            btnBackUp.Focus();
        }

        public async Task<bool> CopyAllAsync(DirectoryInfo source, DirectoryInfo target)
        {
            try
            {
                foreach (FileInfo fi in source.GetFiles())    // 復制最新文件
                {
                    Console.WriteLine(@"准備復制文件 {0}\{1}", target.FullName, fi.Name);    // Name不含路徑,僅文件名
                    FileInfo newfi = new FileInfo(Path.Combine(target.FullName, fi.Name));
                    if (!newfi.Exists || (newfi.Exists && fi.LastWriteTime > newfi.LastWriteTime))
                    {
                        Console.WriteLine("正在復制文件 {0}", newfi.FullName);
                        lblWork.Text = string.Format("正在復制文件 {0}", newfi.FullName);
                        if (newfi.Exists && newfi.IsReadOnly) newfi.IsReadOnly = false;
                        // 覆蓋或刪除只讀文件會產生異常:對路徑“XXX”的訪問被拒絕
                        fi.CopyTo(newfi.FullName, true);    // Copy each file into it's new directory
                    }
                }

                foreach (FileInfo fi2 in target.GetFiles())    // 刪除源目錄沒有而目標目錄中有的文件
                {
                    FileInfo newfi2 = new FileInfo(Path.Combine(source.FullName, fi2.Name));
                    if (!newfi2.Exists)
                    {
                        Console.WriteLine("正在刪除文件 {0}", fi2.FullName);
                        lblWork.Text = string.Format("正在刪除文件 {0}", fi2.FullName);
                        if (fi2.IsReadOnly) fi2.IsReadOnly = false;
                        fi2.Delete();    // 沒有權限(如系統盤需管理員權限)會產生異常,文件不存在不會產生異常
                    }
                }

                foreach (DirectoryInfo di in source.GetDirectories())    // 復制目錄(實際上是創建同名目錄,和源目錄的屬性不同步)
                {
                    Console.WriteLine("  {0}  {1}", di.FullName, di.Name);    // Name不含路徑,僅本級目錄名
                    Console.WriteLine(@"准備創建目錄 {0}\{1}", target.FullName, di.Name);
                    DirectoryInfo newdi = new DirectoryInfo(Path.Combine(target.FullName, di.Name));
                    if (!newdi.Exists)    // 如果CopyAllAsync放在if里的bug: 只要存在同名目錄,則不會進行子目錄和子文件的檢查和更新
                    {
                        Console.WriteLine("正在創建目錄 {0}", newdi.FullName);
                        lblWork.Text = string.Format("正在復制目錄 {0}", newdi.FullName);
                        DirectoryInfo diTargetSubDir = target.CreateSubdirectory(di.Name);    // 創建目錄
                        Console.WriteLine("完成創建目錄 {0}", diTargetSubDir.FullName);
                    }
                    if (await CopyAllAsync(di, newdi) == false) return false; ;    // Copy each subdirectory using recursion
                }

                foreach (DirectoryInfo di2 in target.GetDirectories())    // 刪除源目錄沒有而目標目錄中有的目錄(及其子目錄和文件)
                {
                    DirectoryInfo newdi2 = new DirectoryInfo(Path.Combine(source.FullName, di2.Name));
                    if (!newdi2.Exists)
                    {
                        Console.WriteLine("正在刪除目錄 {0}", di2.FullName);
                        lblWork.Text = string.Format("正在刪除目錄 {0}", di2.FullName);
                        di2.Delete(true);    // 只讀的目錄和文件也能刪除,如不使用參數則異常"目錄不是空的"
                    }
                }
                return true;
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                MessageBox.Show(e.Message, "提示", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return false;
            }
        }

 注意事項:

// 文件和目錄的創建日期為首次全新復制時的創建時間
// 文件復制后修改日期始終保持原先的不變,目錄的修改日期為首次全新復制時的創建時間(因為本就是新建)
// 單純的覆蓋不會改變修改時間和創建時間
// 文件發生的屬性變化全新復制時可以保留(無法通過更新時間判斷文件的屬性變化)

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

今天測試,又發現一個bug,真是防不勝防,好在終於找到病根並解決了。

問題出在 if (await CopyAllAsync(diSource, diTarget)) 這個地方,備份開始后,lblWork.Text = "備份開始!";  結果發現標簽的設置並不生效,然后界面很卡,不能拖動窗口。在需要備份更新的文件特別多時感覺更明顯。

原來,設置控件的Enabled屬性是立即生效,但控件的Text屬性並不是立即生效,就是UI界面不會立即更新,只是將設置信息加入了windows消息隊列,通常等所在的方法執行完畢后才生效,但如果方法中該語句后面還有同類的設置,就會感覺不到它的生效,其實是生效了,只是先設為了一個值,然后又立即設為了另一個值,因為太快了,人眼看不出來。同樣的原因,“正在復制文件XXX”也不即時顯示正在復制的文件信息。

然后,界面卡頓,是因為拷貝的時候執行緊密運算,但是CopyAllAsync(diSource, diTarget)方法並沒有在單獨的線程運行,占用了UI線程,導致界面卡頓,改成下面這樣,完美解決:

lblWork.Text = "備份開始!"; 
bool result = await Task.Run(() => CopyAllAsync(diSource, diTarget)); // 這兒是關鍵 if (result) // if (await CopyAllAsync(diSource, diTarget)) 開始后界面會卡
{ lblWork.Text = "備份完成!"; MessageBox.Show("備份完畢!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); } else lblWork.Text = "出現錯誤!";

 2020.11更新:添加了瀏覽選擇目錄功能,可以不用手動輸入路徑了。

2020.12.3更新:

又發現了一個bug,win10(測試版本1909【18363.1198】)的目錄無法手動設置為只讀的,即右鍵勾選只讀,對目錄本身是無效的,完全不會起作用,如果設置的同時選中“將更改應用於該文件夾、子文件夾和文件”,那么對子文件是有作用的,對子目錄無效。

也就是win10里目錄無法手動設置為只讀,也無法通過右鍵查看是否為只讀(文件可以這樣查看),因此造成了我的程序的bug(CopyAllAsync方法中第四個foreach),加上文件夾沒有IsReadOnly屬性,讓我誤以為只讀文件夾可以刪除,其實是無法直接刪除的。

另外,如果一個目錄下有只讀的文件或文件夾,那么該文件夾也是無法直接刪除的。

那么怎么知道一個目錄或文件夾是否只讀呢,那就是使用命令:attrib

 

顯示或更改文件屬性
=======================================================================================
attrib [+R | -R] [+A | -A ] [+S | -S] [+H | -H] [[drive:] [path] filename] [/S [/D]]
=======================================================================================
+/-  設置/清除屬性。
R   只讀文件屬性。
A   存檔文件屬性。
S   系統文件屬性。
H   隱藏文件屬性。
[drive:][path][filename]      指定要處理的文件屬性。
/S  處理當前文件夾及其子文件夾中的匹配文件。
/D  也處理文件夾。
查看當前目錄下所有目錄和文件的屬性:
for /F "delims==" %i in ('dir /a /b') do @attrib "%i"

  

 

然后,有bug的代碼更正為(下載鏈接已同步更新):

foreach (DirectoryInfo di2 in target.GetDirectories())    // 刪除源目錄沒有而目標目錄中有的目錄(及其子目錄和文件)
{
    DirectoryInfo newdi2 = new DirectoryInfo(Path.Combine(source.FullName, di2.Name));
    if (!newdi2.Exists)
    {
        Console.WriteLine("正在刪除目錄 {0}", di2.FullName);
        lblWork.Text = string.Format("正在刪除目錄\n{0}", di2.FullName);
        if ((di2.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
        //if (di2.Attributes.HasFlag(FileAttributes.ReadOnly))    // 作用同上(二選一即可)
        {
            Console.WriteLine("ReadOnlyDirectory");
            di2.Attributes = di2.Attributes & ~FileAttributes.ReadOnly;    // 取消目錄的只讀屬性
        }
        di2.Delete(true);    // 如不使用參數則異常"目錄不是空的";只讀的目錄和文件無法刪除(如下級有只讀的子目錄和文件也不行)
    }
}

  此處更正的代碼並沒有實現“如下級有只讀的子目錄和文件,那么先刪除這些子目錄和文件”,比如.git的目錄下的那些一長串16進制名的文件,默認都是只讀的,如果你之前備份的時候有git目錄,而后源目錄刪除了.git目錄,那么備份過程中遇到該情況時會提示異常,此時可以手動刪除備份目錄里的.git目錄(或者你自己添加該功能,我懶得搞了),然后重新開始接着備份,因為是差異備份,所以中途斷了完全沒有關系的,接着搞就是了。

 2020.12.18更新:

又發現bug了,一次就是仨,真要命!

第一個:源目錄選擇驅動器根目錄時,備份目錄會和源目錄完全一樣,這不是胡鬧嗎。

btnBackUp_Click方法中更正:

            DirectoryInfo diSource = new DirectoryInfo(sourceDirectory);    // 源目錄
            DirectoryInfo diTarget = new DirectoryInfo(targetDirectory);    // 備份目錄
            Console.WriteLine("源目錄: {0}  備份目錄: {1}", diSource.FullName, diTarget.FullName);
            Console.WriteLine("源目錄: {0}  {1}  {2}", diSource.FullName, diSource.Name, diSource.Root);
            // 因為根目錄的Name和FullName完全一樣,如果源目錄是根目錄,則diSource.Name變成絕對路徑,
            // 由此Path.Combine會直接返回第二個參數(參見方法的說明),導致得到的備份目錄和源目錄一樣
            // 實現效果:源目錄為根目錄時,備份其下所有子目錄和文件;非根目錄時,只備份該目錄本身
            // 指定的備份目錄名字和源目錄名字相同時,直接備份該目錄,如不存在則新建;不同時,視作指定的是所要備份目錄的存放目錄即父目錄
            if (diSource.FullName != diSource.Name && diTarget.Name != diSource.Name) // 此處有變化
                diTarget = new DirectoryInfo(Path.Combine(diTarget.FullName, diSource.Name));    // 創建同名目錄
            if (!diTarget.Exists) diTarget.Create();    // 如果該目錄已存在,則此方法不執行任何操作
            Console.WriteLine("源目錄: {0}  備份目錄: {1}", diSource.Name, diTarget.FullName);

第二個:根目錄下的"$RECYCLE.BIN"和"System Volume Information"是無權訪問的,會導致異常而終止備份,因此需要跳過,在第三個foreach開頭處添加:

if (di.Name == "$RECYCLE.BIN" || di.Name == "System Volume Information") continue;

第三個:這個是最要命的,而且不好解決,越往下研究越會發現很多windows底層的東西

復制過程中取消的問題(針對很大的文件,未等待復制完成時):真的不能自以為是啊,原來以為中途取消應該不會有影響,下次繼續即可,實測不是這樣。
在windows文件資源管理器中的復制,不會顯式產生占位文件,復制過程中如果取消,windows系統會主動收尾,不會出現遺留文件(也就是半拉子工程);
但是本程序使用CopyTo方法復制時,首先會先按照源文件的大小和名字生成一個同樣大小和名字的占位文件,
如果復制完成,會將新文件的修改時間設為和源文件一致,如果中途取消復制,不會刪除該占位文件,且該文件的修改時間和創建時間一致。
按本程序目前的設計思路,下次備份會跳過該文件,因此中途取消會導致文件產生無效備份。這是一個很大的問題,暫時沒有解決方案。

原本想加如task任務的取消功能來實現,但是這個只能解決用戶手動取消的問題,如果中途突然斷電怎么辦?遺留的半拉子文件仍然存在,下次無法識別。

臨時的解決方案是:將第一個foreach中的條件判斷改一下:

if (!newfi.Exists || (newfi.Exists && fi.LastWriteTime > newfi.LastWriteTime))
將后面的大於號改為不等於號“!=”。
點我下載源碼及可執行文件


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM