はじめに
あるとき出張先でちょっとしたプログラムを作ることになった。出張用のノートPCのため,開発用ツールはインストールされていないし,IT部門に申請する暇もないので,Windows に標準的に組み込まれているメモ帳と C# コンパイラで開発せざるを得なかった。当然のことながら便利なビルド自動化ツールもないので,十数個あるソースファイルのうち一つだけ弄っては全ビルドを実行するという無駄な作業を行っていた。
$\color{red}{\Huge\textsf{嗚呼 make が欲しい。}}$
ちなみに筆者は msbuild の使い方が分からないというか,正しくはプロジェクトファイルの書き方が分からない。たぶん人が手で書くようなものではないと思っている。
お品書き(仕様案)
ということで make のようにビルド自動化を行ってくれるバッチファイルを作ることにした。自動化といっても下記のような単純作業である。
- ターゲットファイル(実行ファイル,EXE ファイルのこと)が存在しないとき,ビルドコマンドを実行する
- ターゲットファイルが存在するとき,ターゲットファイルとソースファイル(CS ファイルのこと,複数)のタイムスタンプを比較し,ソースファイルのうち一つでもターゲットファイルより新しければビルドコマンドを実行する
- ターゲットファイルが全てのソースファイルより新しければ何もしない
ターゲットファイルは複数あるので,これらを実行するサブルーチン AutoBuild としてまとめる。サブルーチン AutoBuild は以下の3つの引数を取るものとする。
- ターゲットファイル名
- ビルド後に生成される実行ファイル名とする。拡張子(この場合は .EXE)を含む。
- ソースファイル名
- 拡張子(この場合は .CS)を含む。複数ファイルを指定するときはスペースで区切り,二重引用符で囲むこと。スペースを含むパス名には対応しない。
- ビルドコマンド
- スペースを含むときは二重引用符で囲むこと。
呼び出し時のイメージを以下に示す。TEST0.CS
~ TEST9.CS
まで10個のテストコードがあり,それらから実行ファイル TEST0.EXE
~ TEST9.EXE
を作る。COMMON.CS
は共用モジュールである。
@echo off
setlocal enabledelayedexpansion
for %%I in ( TEST0 TEST001 TEST2 TEST3 TEST4 TEST5 TEST6 TEST7 TEST8 TEST9 ) do (
set Target=%%I.EXE
set Source=%%I.CS COMMON.CS
set Option=-r:Microsoft.VisualBasic.DLL
set Command=csc -nologo -w:4 -o -out:!Target! !Source! !Option!
call :AutoBuild !Target! "!Source!" "!Command!"
)
課題)秒の単位を得ることができない
バッチファイルにおいて,ファイルの更新日時は簡単に得られる。CALL
コマンドによって呼び出されるバッチパラメータ %1
あるいは FOR
コマンドのループ変数 %%I
とおくと,%~t1
もしくは %%~tI
のように ~t
という修飾子を付加すればファイルの更新日時を得ることができる。具体的には以下のように年月日に加えて24時間制の時分を得ることができるのだが,残念ながら秒の単位を取得することができない。
c:\qiita>@for %I in ( test0.* ) do @echo %~tI %I
2024/02/01 17:30 test0.exe
2024/02/01 17:27 test0.cs
もちろんこれでもたいていの場合は問題ないが,コード修正~ビルド~テスト実行を1分以内に繰り返した場合,ソースファイルとビルドした実行ファイルの新旧判別ができないのだ。
※他に dir
コマンドを用いてもファイルの更新日時を取得することができるが,秒の単位を得ることはできない。
案その1)forfiles コマンドを使う
Windows に標準搭載されているコマンドの中で解決しようとすれば,forfiles
コマンドを使う方法が知られている。ただし,forfiles
コマンドの動作は何故かもっさりとしていて受け入れ難い。もう少し,きびきびと動かないものだろうか。
c:\qiita>forfiles /M test0.* /c "cmd /c echo @fdate @ftime @file"
2024/02/01 17:30:39 "test0.exe"
2024/02/01 17:27:12 "test0.cs"
案その2)PowerShell を使う
PowerShell を使えば何でもできる。下記の場合は秒単位での表示に留めたが,ミリ秒単位で得ることも可能である。※参考文献参照のこと。
c:\qiita>powershell "Get-ChildItem test0.* | Select Name, LastWriteTime"
Name LastWriteTime
---- -------------
test0.exe 2024/02/01 17:30:39
test0.cs 2024/02/01 17:27:12
加えて,タイムスタンプでソートすることも楽勝である。
c:\qiia>powershell "Get-ChildItem test0.* | Sort-Object LastWriteTime | Select Name"
Name
----
test0.cs
test0.exe
ただ,バッチファイルから PowerShell スクリプトを呼び出すと動作がもっさりしてしまうのが難点だ。ファイルのタイムスタンプを得るためだけに PowerShell を使うのは効率が悪い。PowerShell を使うのであればビルド自動化まで全部 PowerShell スクリプトで書くべきだろう。
案その3)dir コマンド を使う
PowerShell を使っていて閃いた。コマンドプロンプト上で使えるビルトインコマンドではファイルの更新日時を秒単位で得ることはできないが,dir
コマンドを用いればファイルを秒単位で比較・ソートすることはできる。
c:\qiita>dir /B /OD /TW test0.*
test0.cs
test0.exe
最新のファイル名を取得したければ,for
コマンドを用いて最終行のみ得ればよい。
※スキップする行数(=ソースファイル数)を事前に把握しておく必要がある。
c:\qiita>@for /F "skip=1" %I in ( 'dir /B /OD /TW test0.*' ) do @echo %I
test0.exe
コード実装
ということで,案その3を採用することにした。
一次チェック
最初から dir
コマンドの出力を用いるともっさりとした動作になってしまうので,一次チェックとしてバッチパラメータの修飾子 %~t1
を用いてファイルの更新日時を比較する。ソースファイルのうちターゲットファイルよりも新しいものが一つでもあればビルド実行 ExecBuild=1
である。更新日時が同時分で新旧の判別がつかないものは Ambiguous=1
として二次チェックに進む。なお,一次チェックの中でソースファイル数をカウントしておく。
二次チェック
二次チェックでは dir
コマンドの出力を用いる。ターゲットファイルとソースファイルの中で最新のファイル名を得て,これがターゲットファイル名と一致すればビルドしない。
実装コード
以上より,実装コードを以下に示す。
rem ----------------------------------------------------------------------------
rem 自動ビルド
rem
rem 引数(1) ターゲットファイル
rem 引数(2) ソースファイル(複数指定の場合は二重引用符で囲むこと)
rem 引数(3) 実行コマンド(スペースを含む場合は二重引用符で囲むこと)
rem ----------------------------------------------------------------------------
:AutoBuild
setlocal
rem ----------------------------------------------------------------------------
rem ターゲットファイルが存在しなければビルドを実行する
rem ----------------------------------------------------------------------------
if not exist %1 goto AutoBuild_Exec
rem ----------------------------------------------------------------------------
rem 一次チェック
rem
rem ・全ソースファイルのうちターゲットファイルよりも新しいファイルが一つでも存在
rem すればビルドを実行する
rem ・全ソースファイルのうちターゲットファイルと同時分のファイルが存在すれば
rem 新旧不明として二次チェックに進む
rem ・ソースファイル数をカウントする ※二次チェックで使用する
rem ----------------------------------------------------------------------------
set Count=0
set ExecBuild=0
set Ambiguous=0
for %%I in ( %~2 ) do (
if "%~t1" LSS "%%~tI" set ExecBuild=1
if "%~t1" EQU "%%~tI" set Ambiguous=1
set /a Count+=1
)
if %ExecBuild%==1 goto AutoBuild_Exec
if %Ambiguous%==0 goto AutoBuild_Return
rem ----------------------------------------------------------------------------
rem 二次チェック
rem
rem 全ソースファイルとターゲットファイルを更新日時でソートして一番新しいファイル
rem がターゲットファイルの場合はビルドを実行しない
rem ----------------------------------------------------------------------------
set ExecBuild=1
for /F "skip=%Count%" %%I in ( 'dir /B /OD /TW %~1 %~2' ) do (
if /I %%I==%~1 set ExecBuild=0
)
if %ExecBuild%==0 goto AutoBuild_Return
rem ----------------------------------------------------------------------------
rem ビルド実行
rem ----------------------------------------------------------------------------
:AutoBuild_Exec
echo %~3
%~3
:AutoBuild_Return
endlocal
exit /b
備考
自分で言うのもなんだが,今回作ったバッチファイルは二度と使わないかもしれない。