大文件复制期间的进度(Copy-Item和Write-Progress?)

93

有没有办法在PowerShell中复制一个非常大的文件(从一个服务器到另一个服务器)并显示进度?

有一些解决方案可以使用Write-Progress与循环结合使用来复制多个文件并显示进度。 但是,我似乎找不到任何可以显示单个文件进度的东西。

有什么想法吗?

12个回答

133

似乎更好的解决方案是使用BitsTransfer,它似乎在大多数安装有PowerShell 2.0或更高版本的Windows机器上默认安装。

Import-Module BitsTransfer
Start-BitsTransfer -Source $Source -Destination $Destination -Description "Backup" -DisplayName "Backup"

太好了!事实上,这还为我提供了一个(powershell)进度指示器。 - mousio
如果您不从远程位置拉取源代码,它可能不会利用 BITS 功能,但它可以平稳运行。 - mCasamento
4
正是我想要的——完美地运作并提供进度条! - Shawson
4
这应该是最佳答案。 - tyteen4a03
2
奇怪的是,当我运行命令时,没有任何输出,也没有任何反应?例如,我使用 -Source "\comp1\c$\folder" -Destionation "\comp2\c$\folder",有什么想法可能出了问题吗?我可以访问这两个文件夹,没有问题。如果我使用 copy-item 命令,它可以工作,但显然没有进度显示。 - Rakha
显示剩余7条评论

55

我没有听说过 Copy-Item 有什么进展。如果您不想使用任何外部工具,可以尝试使用流进行实验。缓冲区的大小是不同的,您可以尝试不同的值(从2kb到64kb)。

function Copy-File {
    param( [string]$from, [string]$to)
    $ffile = [io.file]::OpenRead($from)
    $tofile = [io.file]::OpenWrite($to)
    Write-Progress -Activity "Copying file" -status "$from -> $to" -PercentComplete 0
    try {
        [byte[]]$buff = new-object byte[] 4096
        [long]$total = [int]$count = 0
        do {
            $count = $ffile.Read($buff, 0, $buff.Length)
            $tofile.Write($buff, 0, $count)
            $total += $count
            if ($total % 1mb -eq 0) {
                Write-Progress -Activity "Copying file" -status "$from -> $to" `
                   -PercentComplete ([long]($total * 100 / $ffile.Length))
            }
        } while ($count -gt 0)
    }
    finally {
        $ffile.Dispose()
        $tofile.Dispose()
        Write-Progress -Activity "Copying file" -Status "Ready" -Completed
    }
}

6
有趣的解决方案。当我尝试它时收到错误提示 - 无法将值“2147483648”转换为类型“System.Int32”。错误:“值对于 Int32 来说过大或过小。”将[int]替换为[long]后,它运行得很好。谢谢。 - Jason Jarrett
1
小小的.NET问题:finally应该调用Dispose()而不是Close()。不过解决方案很好。我很遗憾没有内置的进度可用。 - TheXenocide
@TheXenocide 为什么? Stream.Close() 只是调用 Stream.Dispose() 吗?http://referencesource.microsoft.com/#mscorlib/system/io/stream.cs - Keith Hill
没错,但基本上只要对象是IDisposable,我就调用Dispose()。在我看来这样更安全,而且你不需要知道内部细节。 - stej
5
从计算机科学的角度来看,这更像是一种编程上的俏皮话,而不是脚本语言(如果你选择区分的话)。你依赖于对象的内部实现细节,这些细节不能保证并且随时可能会改变。此外,你没有遵循公共契约的已建立模式,这违反了面向对象设计的基本原则,并忽略了公共IDisposable契约(你应该知道其存在),而该契约有着既定的最佳实践,规定它应始终被处理。 - TheXenocide
显示剩余8条评论

33

或者,该选项使用本机Windows进度条...

$FOF_CREATEPROGRESSDLG = "&H0&"

$objShell = New-Object -ComObject "Shell.Application"

$objFolder = $objShell.NameSpace($DestLocation) 

$objFolder.CopyHere($srcFile, $FOF_CREATEPROGRESSDLG)

1
这太棒了,你如何为这个方法指定“始终覆盖”标志,这是可能的吗?这样它就不会在文件存在时提示。 - Rakha
@Rakha,你只需要将16作为CopyHere函数的第二个参数传递,像这样:$objFolder.CopyHere($srcFile, 16) - ‌‌R‌‌‌.

33
cmd /c copy /z src dest

虽然不是纯的PowerShell,但可以在PowerShell中执行,并以百分比显示进度。


非常好的回答。我也使用了这个回答来输出进度。 - ZX9

17

我修改了来自stej的代码(非常棒,正是我需要的!)以使用更大的缓冲区,[long]用于更大的文件,并使用System.Diagnostics.Stopwatch类跟踪经过的时间并估计剩余时间。

同时添加了在传输期间报告传输速率和输出总耗时和总传输速率。

使用4MB(4096 * 1024字节)的缓冲区可实现从NAS到笔记本电脑上的USB存储设备的wifi传输带宽优于Win7本机传输速度。

待办事项:

  • 添加错误处理(catch)
  • 将get-childitem文件列表作为输入处理
  • 在复制多个文件时嵌套进度条(文件x of y,已复制的总数据量的百分比等)
  • 缓冲区大小的输入参数

欢迎使用/改进 :-)

function Copy-File {
param( [string]$from, [string]$to)
$ffile = [io.file]::OpenRead($from)
$tofile = [io.file]::OpenWrite($to)
Write-Progress `
    -Activity "Copying file" `
    -status ($from.Split("\")|select -last 1) `
    -PercentComplete 0
try {
    $sw = [System.Diagnostics.Stopwatch]::StartNew();
    [byte[]]$buff = new-object byte[] (4096*1024)
    [long]$total = [long]$count = 0
    do {
        $count = $ffile.Read($buff, 0, $buff.Length)
        $tofile.Write($buff, 0, $count)
        $total += $count
        [int]$pctcomp = ([int]($total/$ffile.Length* 100));
        [int]$secselapsed = [int]($sw.elapsedmilliseconds.ToString())/1000;
        if ( $secselapsed -ne 0 ) {
            [single]$xferrate = (($total/$secselapsed)/1mb);
        } else {
            [single]$xferrate = 0.0
        }
        if ($total % 1mb -eq 0) {
            if($pctcomp -gt 0)`
                {[int]$secsleft = ((($secselapsed/$pctcomp)* 100)-$secselapsed);
                } else {
                [int]$secsleft = 0};
            Write-Progress `
                -Activity ($pctcomp.ToString() + "% Copying file @ " + "{0:n2}" -f $xferrate + " MB/s")`
                -status ($from.Split("\")|select -last 1) `
                -PercentComplete $pctcomp `
                -SecondsRemaining $secsleft;
        }
    } while ($count -gt 0)
$sw.Stop();
$sw.Reset();
}
finally {
    write-host (($from.Split("\")|select -last 1) + `
     " copied in " + $secselapsed + " seconds at " + `
     "{0:n2}" -f [int](($ffile.length/$secselapsed)/1mb) + " MB/s.");
     $ffile.Close();
     $tofile.Close();
    }
}

不错的脚本,但会出现除以零的情况。我不得不添加以下内容:如果($secselapsed -ne 0){ [single]$xferrate = (($total/$secselapsed)/1mb); } else { [single]$xferrate = 0.0 } - 79E09796
你应该将 [io.file]::OpenWrite($to) 替换为 [io.file]::Create($to),否则它将无法正确地覆盖文件。 - Scott Chamberlain
@ScottChamberlain使用::OpenWrite方法从未出现过任何覆盖失败,但如果有的话,我会记住这一点。 - Graham Gold
@ScottChamberlain 解释了为什么我在使用上述代码时不会出现这种情况 - 在我使用它的脚本中,这种情况是不可能发生的,如果在运行时选择覆盖选项,则每个现有文件都会被重命名为临时文件名,然后复制新文件,最后在复制完成后删除临时文件。 - Graham Gold
如何让Start-BitsTransfer(实际上是Write-Progress)将更新记录为文本输出到标准输出,而不仅仅是 PowerShell 进度条? - John Zabroski
显示剩余5条评论

9

我不知道有没有这样的功能。无论如何,我不建议使用copy-item命令来完成此操作。我认为它并没有像robocopy.exe一样被设计成强大且支持重试的工具,而对于需要在网络上复制大型文件的情况,你肯定需要这样的工具。


1
有道理。在这种情况下,我不太担心鲁棒性。它只是在同一背板上的两个服务器之间复制一个15G的文件。但在其他情况下,我肯定会考虑更加稳健的解决方案。 - Jason Jarrett

4

不想成为推动旧话题的人,但是我发现这篇文章非常有用。在对 stej 的代码片段及其由Graham Gold改进和Nacht提出的BITS建议进行性能测试之后,我决定:

  1. 我真的很喜欢Graham带有时间估计和速度读数的命令。
  2. 我也非常喜欢使用BITS作为传输方式所带来的显著速度提升。

面对这两个选择...我发现 Start-BitsTransfer 支持异步模式。因此,这就是我将两者合并的结果。

function Copy-File {
    # ref: https://dev59.com/uXE95IYBdhLWcg3wJKl_#55527732
    param([string]$From, [string]$To)

    try {
        $job = Start-BitsTransfer -Source $From -Destination $To `
            -Description "Moving: $From => $To" `
            -DisplayName "Backup" -Asynchronous

        # Start stopwatch
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        Write-Progress -Activity "Connecting..."

        while ($job.JobState.ToString() -ne "Transferred") {
            switch ($job.JobState.ToString()) {
                "Connecting" {
                    break
                }
                "Transferring" {
                    $pctcomp = ($job.BytesTransferred / $job.BytesTotal) * 100
                    $elapsed = ($sw.elapsedmilliseconds.ToString()) / 1000

                    if ($elapsed -eq 0) {
                        $xferrate = 0.0
                    }
                    else {
                        $xferrate = (($job.BytesTransferred / $elapsed) / 1mb);
                    }

                    if ($job.BytesTransferred % 1mb -eq 0) {
                        if ($pctcomp -gt 0) {
                            $secsleft = ((($elapsed / $pctcomp) * 100) - $elapsed)
                        }
                        else {
                            $secsleft = 0
                        }

                        Write-Progress -Activity ("Copying file '" + ($From.Split("\") | Select-Object -last 1) + "' @ " + "{0:n2}" -f $xferrate + "MB/s") `
                            -PercentComplete $pctcomp `
                            -SecondsRemaining $secsleft
                    }
                    break
                }
                "Transferred" {
                    break
                }
                Default {
                    throw $job.JobState.ToString() + " unexpected BITS state."
                }
            }
        }

        $sw.Stop()
        $sw.Reset()
    }
    finally {
        Complete-BitsTransfer -BitsJob $job
        Write-Progress -Activity "Completed" -Completed
    }
}

4
我发现以上的例子都不能满足我的需求,我想要复制一个带有子目录的文件夹,但是问题是我的源目录中有太多的文件,所以我很快就达到了BITS文件限制(我有超过1500个文件),而且总目录大小也相当大。
我找到了一个使用robocopy的函数,它是一个很好的起点,网址为https://keithga.wordpress.com/2014/06/23/copy-itemwithprogress/,但是我发现它不够强大,不能优雅地处理尾随斜杠和空格,并且在脚本停止时无法停止复制。
这是我的改进版本:
function Copy-ItemWithProgress
{
    <#
    .SYNOPSIS
    RoboCopy with PowerShell progress.

    .DESCRIPTION
    Performs file copy with RoboCopy. Output from RoboCopy is captured,
    parsed, and returned as Powershell native status and progress.

    .PARAMETER Source
    Directory to copy files from, this should not contain trailing slashes

    .PARAMETER Destination
    DIrectory to copy files to, this should not contain trailing slahes

    .PARAMETER FilesToCopy
    A wildcard expresion of which files to copy, defaults to *.*

    .PARAMETER RobocopyArgs
    List of arguments passed directly to Robocopy.
    Must not conflict with defaults: /ndl /TEE /Bytes /NC /nfl /Log

    .PARAMETER ProgressID
    When specified (>=0) will use this identifier for the progress bar

    .PARAMETER ParentProgressID
    When specified (>= 0) will use this identifier as the parent ID for progress bars
    so that they appear nested which allows for usage in more complex scripts.

    .OUTPUTS
    Returns an object with the status of final copy.
    REMINDER: Any error level below 8 can be considered a success by RoboCopy.

    .EXAMPLE
    C:\PS> .\Copy-ItemWithProgress c:\Src d:\Dest

    Copy the contents of the c:\Src directory to a directory d:\Dest
    Without the /e or /mir switch, only files from the root of c:\src are copied.

    .EXAMPLE
    C:\PS> .\Copy-ItemWithProgress '"c:\Src Files"' d:\Dest /mir /xf *.log -Verbose

    Copy the contents of the 'c:\Name with Space' directory to a directory d:\Dest
    /mir and /XF parameters are passed to robocopy, and script is run verbose

    .LINK
    https://keithga.wordpress.com/2014/06/23/copy-itemwithprogress

    .NOTES
    By Keith S. Garner (KeithGa@KeithGa.com) - 6/23/2014
    With inspiration by Trevor Sullivan @pcgeek86
    Tweaked by Justin Marshall - 02/20/2020

    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Source,
        [Parameter(Mandatory=$true)]
        [string]$Destination,
        [Parameter(Mandatory=$false)]
        [string]$FilesToCopy="*.*",
        [Parameter(Mandatory = $true,ValueFromRemainingArguments=$true)] 
        [string[]] $RobocopyArgs,
        [int]$ParentProgressID=-1,
        [int]$ProgressID=-1
    )

    #handle spaces and trailing slashes
    $SourceDir = '"{0}"' -f ($Source -replace "\\+$","")
    $TargetDir = '"{0}"' -f ($Destination -replace "\\+$","")


    $ScanLog  = [IO.Path]::GetTempFileName()
    $RoboLog  = [IO.Path]::GetTempFileName()
    $ScanArgs = @($SourceDir,$TargetDir,$FilesToCopy) + $RobocopyArgs + "/ndl /TEE /bytes /Log:$ScanLog /nfl /L".Split(" ")
    $RoboArgs = @($SourceDir,$TargetDir,$FilesToCopy) + $RobocopyArgs + "/ndl /TEE /bytes /Log:$RoboLog /NC".Split(" ")

    # Launch Robocopy Processes
    write-verbose ("Robocopy Scan:`n" + ($ScanArgs -join " "))
    write-verbose ("Robocopy Full:`n" + ($RoboArgs -join " "))
    $ScanRun = start-process robocopy -PassThru -WindowStyle Hidden -ArgumentList $ScanArgs
    try
    {
        $RoboRun = start-process robocopy -PassThru -WindowStyle Hidden -ArgumentList $RoboArgs
        try
        {
            # Parse Robocopy "Scan" pass
            $ScanRun.WaitForExit()
            $LogData = get-content $ScanLog
            if ($ScanRun.ExitCode -ge 8)
            {
                $LogData|out-string|Write-Error
                throw "Robocopy $($ScanRun.ExitCode)"
            }
            $FileSize = [regex]::Match($LogData[-4],".+:\s+(\d+)\s+(\d+)").Groups[2].Value
            write-verbose ("Robocopy Bytes: $FileSize `n" +($LogData -join "`n"))
            #determine progress parameters
            $ProgressParms=@{}
            if ($ParentProgressID -ge 0) {
                $ProgressParms['ParentID']=$ParentProgressID
            }
            if ($ProgressID -ge 0) {
                $ProgressParms['ID']=$ProgressID
            } else {
                $ProgressParms['ID']=$RoboRun.Id
            }
            # Monitor Full RoboCopy
            while (!$RoboRun.HasExited)
            {
                $LogData = get-content $RoboLog
                $Files = $LogData -match "^\s*(\d+)\s+(\S+)"
                if ($null -ne $Files )
                {
                    $copied = ($Files[0..($Files.Length-2)] | ForEach-Object {$_.Split("`t")[-2]} | Measure-Object -sum).Sum
                    if ($LogData[-1] -match "(100|\d?\d\.\d)\%")
                    {
                        write-progress Copy -ParentID $ProgressParms['ID'] -percentComplete $LogData[-1].Trim("% `t") $LogData[-1]
                        $Copied += $Files[-1].Split("`t")[-2] /100 * ($LogData[-1].Trim("% `t"))
                    }
                    else
                    {
                        write-progress Copy -ParentID $ProgressParms['ID'] -Complete
                    }
                    write-progress ROBOCOPY  -PercentComplete ($Copied/$FileSize*100) $Files[-1].Split("`t")[-1] @ProgressParms
                }
            }
        } finally {
            if (!$RoboRun.HasExited) {Write-Warning "Terminating copy process with ID $($RoboRun.Id)..."; $RoboRun.Kill() ; }
            $RoboRun.WaitForExit()
            # Parse full RoboCopy pass results, and cleanup
            (get-content $RoboLog)[-11..-2] | out-string | Write-Verbose
            remove-item $RoboLog
            write-output ([PSCustomObject]@{ ExitCode = $RoboRun.ExitCode })

        }
    } finally {
        if (!$ScanRun.HasExited) {Write-Warning "Terminating scan process with ID $($ScanRun.Id)..."; $ScanRun.Kill() }
        $ScanRun.WaitForExit()

        remove-item $ScanLog
    }
}

3
这个递归函数可以将文件和目录从源路径复制到目标路径。如果在目标路径上已经存在该文件,则只会复制新的文件。
Function Copy-FilesBitsTransfer(
        [Parameter(Mandatory=$true)][String]$sourcePath, 
        [Parameter(Mandatory=$true)][String]$destinationPath, 
        [Parameter(Mandatory=$false)][bool]$createRootDirectory = $true)
{
    $item = Get-Item $sourcePath
    $itemName = Split-Path $sourcePath -leaf
    if (!$item.PSIsContainer){ #Item Is a file

        $clientFileTime = Get-Item $sourcePath | select LastWriteTime -ExpandProperty LastWriteTime

        if (!(Test-Path -Path $destinationPath\$itemName)){
            Start-BitsTransfer -Source $sourcePath -Destination $destinationPath -Description "$sourcePath >> $destinationPath" -DisplayName "Copy Template file" -Confirm:$false
            if (!$?){
                return $false
            }
        }
        else{
            $serverFileTime = Get-Item $destinationPath\$itemName | select LastWriteTime -ExpandProperty LastWriteTime

            if ($serverFileTime -lt $clientFileTime)
            {
                Start-BitsTransfer -Source $sourcePath -Destination $destinationPath -Description "$sourcePath >> $destinationPath" -DisplayName "Copy Template file" -Confirm:$false
                if (!$?){
                    return $false
                }
            }
        }
    }
    else{ #Item Is a directory
        if ($createRootDirectory){
            $destinationPath = "$destinationPath\$itemName"
            if (!(Test-Path -Path $destinationPath -PathType Container)){
                if (Test-Path -Path $destinationPath -PathType Leaf){ #In case item is a file, delete it.
                    Remove-Item -Path $destinationPath
                }

                New-Item -ItemType Directory $destinationPath | Out-Null
                if (!$?){
                    return $false
                }

            }
        }
        Foreach ($fileOrDirectory in (Get-Item -Path "$sourcePath\*"))
        {
            $status = Copy-FilesBitsTransfer $fileOrDirectory $destinationPath $true
            if (!$status){
                return $false
            }
        }
    }

    return $true
}

2

来自Hey, Scripting Guy! 博客的Sean Kearney提供了一个我发现非常好用的解决方案。

Function Copy-WithProgress
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$true,
            ValueFromPipelineByPropertyName=$true,
            Position=0)]
        $Source,
        [Parameter(Mandatory=$true,
            ValueFromPipelineByPropertyName=$true,
            Position=0)]
        $Destination
    )

    $Source=$Source.tolower()
    $Filelist=Get-Childitem "$Source" –Recurse
    $Total=$Filelist.count
    $Position=0

    foreach ($File in $Filelist)
    {
        $Filename=$File.Fullname.tolower().replace($Source,'')
        $DestinationFile=($Destination+$Filename)
        Write-Progress -Activity "Copying data from '$source' to '$Destination'" -Status "Copying File $Filename" -PercentComplete (($Position/$total)*100)
        Copy-Item $File.FullName -Destination $DestinationFile
        $Position++
    }
}

然后使用它:
Copy-WithProgress -Source $src -Destination $dest

2
这将报告在$Filelist中复制的文件数量,而问题是要求报告复制单个文件的进度(即迄今为止复制的字节/块数)。如果使用此代码复制单个大文件,则无法指示复制操作在该文件内进行了多少进度。从问题正文中:“有解决方案可以使用Write-Progress与循环结合使用来复制多个文件并显示进度。但是我似乎找不到任何可以显示单个文件进度的东西。” - Lance U. Matthews

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接