用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))
将后面的大于号改为不等于号“!=”。
点我下载源码及可执行文件
posted @ 2020-10-24 17:37  黑衫老腰  阅读(1337)  评论(0编辑  收藏  举报