Windows
PowerShell

更新頻度の高いファイルの自動世代管理

目的

よく更新するファイルを、更新日時を名前に付して自動的にコピー(バックアップ)し、世代管理します。

環境

OS : Windows 7 以降
PowerShell: Version 3 以降

使用方法

タスクスケジューラやスタートアップに登録して常時実行したままの状態にします。

コード

PowerShellスクリプトですが、Windows バッチファイルとして実行できる特殊な構造になっています。拡張子を.ps1に変更するには1行目を削除してください。

  • 特定の1ファイルのみを対象とするスクリプト
backup_file_on_update.bat
@Powershell -NoProfile -Command "&([ScriptBlock]::Create((Get-Content '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit
# by earthdiver1
###################################################################################################################################
$src_file = "C:\:somedir\important_file.doc"
$dst_dir  = "D:\backup"
$num_copy = 20
$interval_1  = 180
$interval_2  =  15
###################################################################################################################################
$ErrorActionPreference = "Stop"
$old_mtime = ""
$old_hash  = ""
while ($True) {
    try { 
        $file = Get-ChildItem -LiteralPath $src_file
        $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
        if ($new_mtime -ne $old_mtime) {
            $new_hash = $file.GetHashCode()
            if ($new_hash -ne $old_hash) {
                $dst_file =  $dst_dir + "\" + $file.BaseName + "_" + $new_mtime + $file.Extension
                if (-not (Test-Path $dst_file)) {
                    Copy-Item -LiteralPath $src_file -Destination $dst_file -Force
                    $filter =  $file.BaseName + "_????????????" + $file.Extension
                    Get-ChildItem -LiteralPath $dst_dir -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                }
                $old_hash  = $new_hash
            }
            $old_mtime = $new_mtime
        }
        $interval = $interval_1
    } catch [System.Exception] { 
        Write-Host $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
        $interval = $interval_2
    }
    Start-Sleep $interval
}
  • 指定したフォルダに含まれる全ファイルを対象とするスクリプト
backup_files_on_update.bat
@Powershell -NoProfile -Command "&([ScriptBlock]::Create((Get-Content '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit
# by earthdiver1
###################################################################################################################################
$src_root = "C:\somedir_containing_important_files"
$dst_root = "D:\backup"
$num_copy =  20
$interval_1  = 180
$interval_2  =  15
###################################################################################################################################
$ErrorActionPreference = "Stop"
$src_root  = Convert-Path -LiteralPath $src_root
$dst_root  = Convert-Path -LiteralPath $dst_root
$old_mtime = New-Object 'System.Collections.Generic.Dictionary[Int, String]'
$old_hash  = New-Object 'System.Collections.Generic.Dictionary[Int, String]'
while ($True) {
    try {
        Get-ChildItem -LiteralPath $src_root -File -Recurse | ForEach-Object {
            $file = $_
            $src_file = $file.FullName
            if ($src_file -Like "$dst_root\*") { return }
            $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
            $idx = $src_file.GetHashCode()
            if ($new_mtime -ne $old_mtime[$idx]) {
                $new_hash = $file.GetHashCode()
                if ($new_hash -ne $old_hash[$idx]) {
                    $dst_dir = $dst_root + $file.DirectoryName.Substring($src_root.Length, $file.DirectoryName.Length - $src_root.Length)
                    if (-not (Test-Path -LiteralPath $dst_dir)) {
                        New-Item -ItemType Directory -Force -Path $dst_dir | Out-Null
                    }
                    $dst_file =  $dst_dir + "\" + $file.BaseName + "_" + $new_mtime + $file.Extension
                    if (-not (Test-Path $dst_file)) {
                        Copy-Item -LiteralPath $src_file -Destination $dst_file -Force
                        $filter =  $file.BaseName + "_????????????" + $file.Extension
                        Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                    }
                    $old_hash[$idx] = $new_hash
                }
                $old_mtime[$idx] = $new_mtime
            }
        }
        $interval = $interval_1
    } catch [System.Exception] {
        Write-Host $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
        $interval = $interval_2
    }
    Start-Sleep $interval
}

備考

  • 3分毎に更新をチェックします(異常発生時には15秒後)。
  • ファイル毎のコピーの最大数は20です。
  • (2018.4.18修正)\$dst_rootに、\$src_rootと同一のフォルダは指定できません。
  • 2番目の例で17行目の後に if (\$file.Extension -eq ".tmp") {return} などを挿入することで監視対象から一部のファイルを除外することが可能です。
  • タスクトレイ常駐版(こちらを参照
    ソースはこちらをクリック
    backup_files_on_update.bat
    @Powershell -NoP -W Hidden -C "$PSCP='%~f0';&([ScriptBlock]::Create((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" %* & exit/b
    # by earthdiver1
    if ($PSCommandPath) {
        $PSCP = $PSCommandPath
        $code = '[DllImport("user32.dll")]public static extern bool ShowWindowAsync(IntPtr hWnd,int nCmdShow);'
        $type = Add-Type -MemberDefinition $code -Name Win32ShowWindowAsync -PassThru
        [void]$type::ShowWindowAsync((Get-Process -PID $PID).MainWindowHandle,0) }
    Add-Type -AssemblyName System.Windows.Forms, System.Drawing
    $menuItem = New-Object System.Windows.Forms.MenuItem "Exit"
    $menuItem.add_Click({$notifyIcon.Visible=$False;while(-not $status.IsCompleted){Start-Sleep 1};$appContext.ExitThread()})
    $contextMenu = New-Object System.Windows.Forms.ContextMenu
    $contextMenu.MenuItems.AddRange($menuItem)
    $notifyIcon = New-Object System.Windows.Forms.NotifyIcon
    $notifyIcon.ContextMenu = $contextMenu
    $notifyIcon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($PSCP)
    $notifyIcon.Text = (Get-ChildItem $PSCP).BaseName
    $notifyIcon.Visible = $True
    $_syncHash = [hashtable]::Synchronized(@{})
    $_syncHash.NI   = $notifyIcon
    $_syncHash.PSCP = $PSCP
    $runspace = [RunspaceFactory]::CreateRunspace()
    $runspace.ApartmentState = "STA"
    $runspace.ThreadOptions  = "ReuseThread"
    $runspace.Open()
    $runspace.SessionStateProxy.SetVariable("_syncHash",$_syncHash)
    $scriptBlock = Get-Content $PSCP | ?{ $on -or $_[1] -eq "!" }| %{ $on=1; $_ } | Out-String
    $action=[ScriptBlock]::Create(@'
    #   param($Param1, $Param2)
        Start-Transcript -LiteralPath ($_syncHash.PSCP -Replace '\..*?$',".log") -Append
        Function Start-Sleep {param([Int]$Seconds,[Switch]$NoExit)
                $int = 5
                for ($i = 0; $i -lt $Seconds; $i += $int) {
                    if (-not($NoExit -or $_syncHash.NI.Visible)) { exit }
                    Microsoft.PowerShell.Utility\Start-Sleep -Seconds $int }}
    '@ + $scriptBlock)
    $PS = [PowerShell]::Create().AddScript($action) #.AddArgument($Param1).AddArgument($Param2)
    $PS.Runspace = $runspace
    $status = $PS.BeginInvoke()
    $appContext = New-Object System.Windows.Forms.ApplicationContext
    [void][System.Windows.Forms.Application]::Run($appContext)
    exit
    #! ---------- ScriptBlock (Line No. 26) begins here ---------- DO NOT REMOVE THIS LINE
    ###################################################################################################################################
    $src_root = "C:\somedir_containing_important_files"
    $dst_root = "D:\backup"
    $num_copy =  20
    $interval_1  = 180
    $interval_2  =  15
    ###################################################################################################################################
    $ErrorActionPreference = "Stop"
    $src_root  = Convert-Path -LiteralPath $src_root
    $dst_root  = Convert-Path -LiteralPath $dst_root
    $old_mtime = New-Object 'System.Collections.Generic.Dictionary[Int, String]'
    $old_hash  = New-Object 'System.Collections.Generic.Dictionary[Int, String]'
    while ($True) {
        try {
            Get-ChildItem -LiteralPath $src_root -File -Recurse | ForEach-Object {
                $file = $_
                $src_file = $file.FullName
                if ($src_file -Like "$dst_root\*") { return }
                $new_mtime = $file.LastWriteTime.ToString('yyyyMMddHHmm')
                $idx = $src_file.GetHashCode()
                if ($new_mtime -ne $old_mtime[$idx]) {
                    $new_hash = $file.GetHashCode()
                    if ($new_hash -ne $old_hash[$idx]) {
                        $dst_dir = $dst_root + $file.DirectoryName.Substring($src_root.Length, $file.DirectoryName.Length - $src_root.Length)
                        if (-not (Test-Path -LiteralPath $dst_dir)) {
                            New-Item -ItemType Directory -Force -Path $dst_dir | Out-Null
                        }
                        $dst_file =  $dst_dir + "\" + $file.BaseName + "_" + $new_mtime + $file.Extension
                        if (-not (Test-Path $dst_file)) {
                            Copy-Item -LiteralPath $src_file -Destination $dst_file -Force
                            $filter =  $file.BaseName + "_????????????" + $file.Extension
                            Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object | Select-Object -SkipLast $num_copy | Remove-Item -Force
                        }
                        $old_hash[$idx] = $new_hash
                    }
                    $old_mtime[$idx] = $new_mtime
                }
            }
            $interval = $interval_1
        } catch [System.Exception] {
            Write-Host $Error[0].ToString() $Error[0].InvocationInfo.PositionMessage
            $interval = $interval_2
        }
        Start-Sleep $interval
    }
    

    注意

    ファイル数が非常に多い場合(例えば100,000以上など)は、GetHashCode()メソッドが返すハッシュが衝突する可能性を否定できません。System.Security.Cryptography.SHA1CryptoServiceProviderのComputeHash()メソッドやVersion 4以降で追加されたGet-FileHashコマンドレットを用いてより強度の高いハッシュアルゴリズムを利用する方法もありますが(そもそもFileSystemWatcherクラスを利用すべきか)、大量のファイルを監視対象にしたい場合は素直に FreeFileSync(https://www.freefilesync.org/ 、無償)や Bvckup2(https://bvckup2.com/ 、有償)などの専用ツールを利用することをお勧めします。

    2018.8.9 バグ修正(Get-ChildItemの出力が必ずしも名前順でソートされないため Sort-Object コマンドレットを追加。)

    おまけ(2018.8.26追記)

    Remove-Item コマンドレットが含まれる行を

    Get-ChildItem -LiteralPath $dst_dir -File -Filter $filter | Sort-Object -Descending | Retention3221 | Remove-Item -Force
    

    に置き換えることにより、コピーしたファイルを下記条件で保持するリテンションポリシーを適用できます。

    • 過去72時間(3日間)の全更新ファイル(上限 $num_copy 個)
    • 過去2週間の各日の最終更新ファイル
    • 過去2ヶ月間の各週の最終更新ファイル
    • 過去1年間の各月の最終更新ファイル
    • 各年の最終更新ファイル

    Retention3221 関数の定義は以下の通り(while ($True)の前に挿入します)。
    注1:パイプライン内でフィルターとして動作します。
    注2:入力されるオブジェクトの並びが更新日時で降順にソートされている必要があります。

    Function Retention3221 {
        begin {
            $now = Get-Date
            $b3d = $now.AddDays(-3)   # 3 days
            $b2w = $now.AddDays(-7*2) # 2 weeks
            $b2m = $now.AddMonths(-2) # 2 months
            $b1y = $now.AddYears(-1)  # 1 year
            $last_year  = $now.Year
            $last_month = $now.Month
            $last_day   = $now.Day
            $jan1st2000 = [datetime]::ParseExact("2000/01/01", "yyyy/MM/dd", $Null)
            $last_week  = [Math]::Ceiling(($now-$jan1st2000).Days/7)
            $n3d = 0
        }
        process {
            $LastWriteTime = $_.BaseName.SubString($_.BaseName.Length-12,12)
            $lwt   = [datetime]::ParseExact($LastWriteTime, "yyyyMMddHHmm", $Null) # $_.LastWriteTime
            $year  = $lwt.Year
            $month = $lwt.Month
            $day   = $lwt.Day
            $week  = [Math]::Ceiling(($lwt-$jan1st2000).Days/7)
    
            $keep = $False
    
            if ($lwt -gt $b3d -and $n3d -lt $num_copy) {
                $n3d++
                $keep = $True
            } elseif ($lwt -gt $b2w) {
                if ($day -ne $last_day)     { $keep = $True }
            } elseif ($lwt -gt $b2m) {
                if ($week -ne $last_week)   { $keep = $True }
            } elseif ($lwt -gt $b1y) {
                if ($month -ne $last_month) { $keep = $True }
            } else {
                if ($year -ne $last_year)   { $keep = $True }
            }
    
            $last_year  = $year
            $last_month = $month
            $last_day   = $day
            $last_week  = $week
    
            if (-not $keep) { return $_ }
        }
    }
    

    クリエイティブ・コモンズ 表示 - 継承 4.0 国際