バッチファイルで、世代管理をしつつバックアップを取る

# やりたいこと

ネットワーク上の共有ディレクトリのバックアップを取りたい。
タスクスケジューラを用いて、自動的にバックアップを取りたい。
任意の世代数を保持し、それを超えたものは自動的に削除してほしい。

現実問題として、バックアップ元のファイルサーバの性能が大変残念な感じなので、負荷を抑えつつ高速にバックアップをとりたい。

そんな夢物語を、標準コマンドだけで実現したい。


# 文章ベースでの設計

1. バックアップ先のディレクトリがなければ作る。
2. すでに今日の分のバックアップ処理が行われていたら、処理を中止する。
3. 可能であれば、バックアップ先にある最新のバックアップを一時ディレクトリにコピーしする。
4. バックアップ元からバックアップ先の一時ディレクトリへ、差分コピーを行う。
5. 一時ディレクトリを、本来のディレクトリ名にリネームする。
6. すでに今日の分のバックアップ処理が行われていたら、処理を中止する。
7. バックアップ先のディレクトリの数を調べ、保持する世代数を超えていたら、最も古いバックアップを削除する。


# コードに落とす

試行錯誤しまくった結果は以下の通り。

```bat:backup.bat
@ECHO OFF

SETLOCAL ENABLEDELAYEDEXPANSION

REM ★「YYYYMMDD」形式で日付を取得する
SET TODAY=%date:~0,4%%date:~5,2%%date:~8,2%


REM ★各種設定はじまり
SET SEDAI=3
SET SRC=\\server\path\to\source\
SET DST=Z:\path\to\dist\
SET LOG=Z:\path\to\dist\backup_%TODAY%.log
REM ★各種設定おわり

REM ★もし、バックアップ先の末尾に「\」がなければ補完
IF %DST:~-1% NEQ \ (
 SET DST="%DST%\"
)

REM ★もし、バックアップ先が存在しなければ作成
IF NOT EXIST "%DST%" (
 MKDIR "%DST%"
)

SET DSTDIR="%DST%%TODAY%"
SET TMPDIR="%DST%INPROGRESS"

REM ★もし、今日のバックアップ用ディレクトリが存在したら終了
IF EXIST %DSTDIR% (
 EXIT /B 1
)

REM ★もし、一時保存ディレクトリが存在しなければ。。。
IF NOT EXIST "%TMPDIR%" (
 REM ★バックアップ先にある、最新のバックアップを探す
 SET NEWER=
 FOR /F "usebackq" %%a IN (`DIR "%DST%" /AD /OD /B`) DO SET NEWER=%%a

 REM ★もし、最新のバックアップが存在すれば、それを一時保存ディレクトリにコピーする
 IF "!NEWER!hoge" neq "hoge" (
  ROBOCOPY "%DST%!NEWER!" %TMPDIR% /MIR /FFT /NP /COPY:DT /DCOPY:T /R:0 /W:0 /LOG+:"%LOG%"
 )
)

REM ★バックアップ元から、一時保存ディレクトリに対して差分コピーする
ROBOCOPY "%SRC%" %TMPDIR% /MIR /FFT /NP /COPY:DT /DCOPY:T /R:0 /W:0 /LOG+:"%LOG%"

REM ★もし、今日のバックアップ用ディレクトリが存在したら終了
IF EXIST %DSTDIR% (
 ECHO %date% %time% [ERR]すでに本日分のフォルダが存在します。中止。 >> "%LOG%"
 ECHO %date% %time% [ERR]異常終了。 >> "%LOG%"
 EXIT /B 1
)

REM ★一時保存ディレクトリの名前を、今日のバックアップ用ディレクトリにリネーム
MOVE %TMPDIR% %DSTDIR%

REM ★バックアップ先にあるディレクトリの数を数える
SET CNT=
FOR /F "usebackq" %%t IN (`DIR /AD /B "%DST%" ^| FIND /C /V ""`) DO SET CNT=%%t

REM ★バックアップ先のディレクトリの数が、保持する世代数を超えていたら...
IF %CNT% GTR %SEDAI% (
 REM ★バックアップ先にある、最も古いディレクトリを探す
 SET OLDER=
 FOR /F "usebackq" %%a IN (`DIR /AD /O-D /B "%DST%"`) DO SET OLDER=%%a
 
 REM ★最も古いディレクトリが見つかったら、削除する
 IF EXIST "%DST%!OLDER!" (
  RMDIR /S /Q "%DST%!OLDER!"
 )
)

ENDLOCAL
```

# 簡単な解説

できるだけ簡素な感じに書いてみたつもり
実際はログ吐き出しとかで割とぐちゃぐちゃしてる


- `SETLOCAL ENABLEDELAYEDEXPANSION` ＝ 遅延環境変数の有効化。FOR文で得たファイル名が空白になる現象を抑制するために付けてみた
- `SET TODAY=%date:~0,4%%date:~5,2%%date:~8,2%` ＝ 環境変数`date`は、`YYYY/MM/DD`形式で日付を返すため、そのままではディレクトリ名に使えない。数字の部分だけ切り出して連結し、`YYYYMMDD`形式に整形している
- ROBOCOPYコマンド ＝ ミラーリングなどができる多機能なコピーコマンド。Vistaから標準で使用可能。"Robust Copy"であり、ロボットがいるわけではない
 - `/MIR` ＝ ミラーリングする。
 -` /FFT` ＝ 多少のタイムスタンプのズレを許容する。FATファイルシステムの場合に有効
 - `/NP` ＝ 進行状況を表示しない（あんま意味ない？）
 - `/COPY` ＝ ファイルにコピーする追加情報を指定する
     - `D` ＝ データ
     - `T` ＝ タイムスタンプ
 - `/DCOPY:T` ＝ ディレクトリのタイムスタンプをコピーする
 - `/R:0` ＝ リトライカウントゼロ
 - `/W:0` ＝ リトライウェイトゼロ
 - `/LOG+:` ＝ ROBOCOPYの実行ログの追記先
 - そのほかにも大量にオプションがある。詳しくはヘルプ見て 
- `IF "!NEWER!hoge" neq "hoge" (...` ＝ hogeに秘密がある
- `DIR /AD /B "%DST%" ^| FIND /C /V ""` ＝ DIRコマンドの結果を、FINDコマンドにパイプで渡している。パイプはハットマークでエスケープしないと動作しなかった

最初のバージョンでは、スリム化したつもりでIF文のカッコをワンラインで書いていたが
これだとうまく動かない可能性がある
具体的には、%DST%の後に余計なスペースが入ってしまい、意図しない動作をしてしまった

## hogeの秘密
`IF "!NEWER!hoge" neq "hoge" (...` の`hoge`の意味
この前で、「`%DIR%`ディレクトリの直下にある最も新しいディレクトリを探す」FOR文が回っている。
「最も新しいディレクトリ」が存在しない場合は、`!NEWER!`に`NULL`が格納される。
もし、`!NEWER!`が`NULL`だった場合は「存在しない」扱いにしたいけど
`EXIST "%DST%!NEWER!"`で判定してしまうと、親ディレクトリの存在確認をしてしまい、常に「真」になってしまう。
正直に`"!NEWER!" equ ""`でもうまくいかなかったので、ダミー文字列を含ませて判定させてみた。もちろん`hoge`でなくてもいい

最後の方の「最も古いディレクトリを探す」部分ではEXISTを使っているが、
世代数カウントを通過しているので、ディレクトリが確実に1個は存在するはず。ので大丈夫だと思う。
用途的に有り得ないけど、保持世代数をゼロに設定した場合は、ルートディレクトリごと削除してしまう可能性がありそう


## 一時ディレクトリを作る理由

バックアップ先のディスクは比較的高速。
バックアップ元のファイルは大きくは動かない。
ROBOCOPYだけでフルバックアップした場合に比べて（当社比）2倍の速度で処理が終わる上、
実際に転送されるデータは差分だけなので、ネットワーク上のトラフィックも最小で済む。
素敵。

敢えて難を言うなら、
差分計算のためのディスクI/O増加により、CPU使用率が高くなってしまうこと


# 所感

やってやった感ある。
しばらくこれで運用してみて、ブラッシュアップしていきたい。