見切り発車

見切り発車

とりあえずかきとめたい

Windows 環境でのCMake のfind_package() 数珠繋ぎを EXPORT_PACKAGE_DEPENDENCIES で解決する

まとめ

  • CMake で間接的に参照するパッケージはinstall(EXPORT) のEXPORT_PACKAGE_DEPENDENCIES オプションを指定すると解決してくれる
  • EXPORT_PACKAGE_DEPENDENCIES を指定するためにはset(CMAKE_EXPERIMENTAL_EXPORT_PACKAGE_DEPENDENCIES 実験的機能のUUID) が必要
  • 実験的機能のUUID はCMake のcmExperimental.cxx に書いてあり、定期的に変更されるらしい

本文

CMake で作成したライブラリのプロジェクトを、別のCMake プロジェクトから利用する場合、find_package() を利用するのが良いようです。

自分ではこれまでわりとgit submodule add でプロジェクトのツリーに取り込んでadd_subdirectory() で組み込むということをやってたのですが、find_package() を利用するほうが良さそうだということを学び始めました。

自作パッケージをCMakeのfind_package()に対応させる
お手軽な xxx-config.cmake の作成方法
【モダンCMake】つくったC++ライブラリを簡単にインストールできるようにする

特にこのあたりの記事や、他のプロジェクトを参考にさせていただき

  • CMake がfind_package() の時に検索するルールに沿ったパスにパッケージ名-config.cmake ファイルを置く
  • パッケージ名-config.cmake はinstall(EXPORT) コマンドでだいたい自動的に生成できる

といったことがわかりました。

しかし、いざ自分の作成したライブラリを他から利用しようとした場合に「パッケージが見つからない」というエラーが出てきてしまいました。

プロジェクトが以下のような依存関係で、app からfind_package(libA) とした場合にlibB が見つからない、ということでした。

graph RL;
    app-->libA;
    libA-->libB;

全部同じCMake プロジェクトにしていた場合にはtarget_link_libraries() に指定したものは勝手に解決してくれていたのですがパッケージが分かれているとそうはいかないようです。

app プロジェクト内でfind_package(libB) とすれば解決するようですが、app が直接依存しているのはlibA なのでできれば避けたい方法です。いくらか試行錯誤したらどうにかなったので結果をGitHub のリポジトリ に置きました。細かい説明はリポジトリのREADME.md に書いています。

EXPORT_PACKAGE_DEPENDENCIES を有効化する方法については今回調べたなかでも特に情報が見つけられず、CMake のソースコードから調べたのでここにまとめます。


CMake のinstall(EXPORT) の説明 には

Note Experimental. Gated by CMAKE_EXPERIMENTAL_EXPORT_PACKAGE_DEPENDENCIES.

と記載されていますが、ON とかTRUE を設定してもEXPORT_PACKAGE_DEPENDENCIES が未定義というエラーは解消しません。CMake のドキュメントでexperimental などと検索しても関係なさそうなものがいろいろと出てくるためソースコードから調べることにしました。

EXPORT_PACKAGE_DEPENDENCIES で検索するとcmInstallCommand.cxx で

  if (cmExperimental::HasSupportEnabled(
        status.GetMakefile(),
        cmExperimental::Feature::ExportPackageDependencies)) {
    ica.Bind("EXPORT_PACKAGE_DEPENDENCIES"_s, exportPackageDependencies);
  }

のような箇所がありました。そしてHasSupportEnabled の中身はcmExperimental.cxx で

bool cmExperimental::HasSupportEnabled(cmMakefile const& mf, Feature f)
{
  bool enabled = false;
  auto& data = ::DataForFeature(f);

  auto value = mf.GetDefinition(data.Variable);
  if (value == data.Uuid) {
    enabled = true;
  }

  if (enabled && !data.Warned) {
    mf.IssueMessage(MessageType::AUTHOR_WARNING, data.Description);
    data.Warned = true;
  }

  return enabled;
}

となっています。data.UUid はおなじcmExperimental.cxx で定義されていて、

/*
 * The `Uuid` fields of these objects should change periodically.
 * Search for other instances to keep the documentation and test suite
 * up-to-date.
 */
cmExperimental::FeatureData LookupTable[] = {
  // ExportPackageDependencies
  { "ExportPackageDependencies",
    "1942b4fa-b2c5-4546-9385-83f254070067",
    "CMAKE_EXPERIMENTAL_EXPORT_PACKAGE_DEPENDENCIES",
    "CMake's EXPORT_PACKAGE_DEPENDENCIES support is experimental. It is meant "
    "only for experimentation and feedback to CMake developers.",
    {},
    cmExperimental::TryCompileCondition::Always,
    false },

となっていました(確認時)。コメントにUuid フィールドは定期的に変更されるとあります。

CMake の実験的機能を使うにはソースコードを見てUUID を調べる必要がある、というのが正解なのかどうかは分かってませんが、動きはしました……

実際の利用箇所はこちらのCMakeLists.txt です。


その他の感想として、cmake --install (make install) のような作法はWindows だとあまり馴染みが無いなーと思いました。

*-config.cmake のパスについても、CMAKE_INSTALL_PREFIX の説明にあるデフォルト値とConfig Mode Search Procedure の組み合わせで考えると、UINX(Linux) 系は/usr/local/lib/cmake/パッケージ名 となってそれっぽく見えます1Windows 系だとC:\Program Files\${PROJECT_NAME} になっていて、あんまりライブラリをインストールする先っぽくないし普通のユーザーだと書き込み権限もなかったりして、なんというか微妙な感じがします。

Windows 環境でも/usr/local のようなディレクトリの運用をしてみる、とか検討してみてもいいかな?とも思いましたがプロジェクト内で完結していたほうが手軽な気もするなあ。


  1. /usr/local に一般ユーザーの書き込み権限があるかとかはよく分かってません……

C++/CLI のライブラリを(新しめの)CMake で使用する

今更ながらC++/CLI を試していて、CMake でライブラリを作成して利用する時にいくつか詰まったところがあって解決したのでまとめました。 なおGitHub にプロジェクトを公開してREADME にも同じ内容を書いています。

CMake でC++/CLI プロジェクトを作成する

CMake でC++/CLI のプロジェクトを作成する場合、ターゲットのプロパティにCOMMON_LANGUAGE_RUNTIME を指定します。

C++/CLI にはMixed, Pure, Safe などの種類がありますが、Visual Studio 2015 以降ではMixed 以外は非推奨、2017 ではサポート外となっています。
混在 (ネイティブおよびマネージド) アセンブリ混在 (ネイティブおよびマネージド) アセンブリ

Mixed を指定する場合はプロパティの値に空文字列を指定します。

set_target_properties(target PROPERTIES COMMON_LANGUAGE_RUNTIME "")

CMake 3.28 以降でのC++ モジュールへの対応

CMake 3.27 で試した時にはSHARED 指定のライブラリをtarget_link_library に渡すだけでC++/CLI のアプリケーションからC++/CLI のライブラリを利用することができました。

しかし同じプロジェクトをCMake 3.29 でビルドした場合、cl.exe が/clr と/ifcOutput は同時に指定できない、というエラーを出力しました。

CMake 3.27 と3.29 の出力したvcxproj の差分を見るとScanSourceForModuleDependencies というプロパティの有無が違っていました。

CMake 3.28 でCXX_SCAN_FOR_MODULES というプロパティが追加されており、これにOFF を指定しておくとScanSourceForModuleDependencies がfalse となってコンパイラのエラーは回避できます。GitHub にあげたサンプルプロジェクトではCMAKE_CXX_SCAN_FOR_MODULES を指定して、全体的に適用されるようにしています。

なお、C++ モジュールは当面使う予定がないのでこのような対応で回避しています。

using ディレクティブへの対応

C++/CLI ではusing ディレクティブでdll ファイルを指定すると、そこに含まれる機能を利用できます。

using ディレクティブで指定されたdll はルールに従って順に検索されます。
#using ディレクティブ(C++/CLI)

CMake ではcl.exe の/AI オプションに対応するAdditionalUsingDirectories プロパティをvcxproj に出力することができます。指定方法についての説明が見つけられず、CMake のソースコードを解析したところ次の条件に当てはまる場合に適用されることが分かりました。

  1. ライブラリを利用するターゲットがネイティブではない(マネージドである)
  2. ライブラリのターゲットがネイティブではない
  3. ライブラリのターゲットがインポートされている
  4. ライブラリがインターフェースではない

1, 2 は前述のCOMMON_LANGUAGE_RUNTIME がそれぞれのターゲットのプロパティで指定されていればOK です。3 は、add_library にIMPORTED が指定されているかどうかです。4 はadd_library でINTERFACE が指定されていなければOK ですが、INTERFACE はヘッダオンリーのライブラリなどで指定するタイプですので今回は問題ありません。

Imported Library の対応

IMPORTED 指定はプロジェクト外で用意されたライブラリなどを利用するときに指定します。
Imported Libraries

Imported Library はビルド済みのライブラリを指定する前提で、add_library にソースファイルなどは指定できないようです。おなじCMake プロジェクト内でライブラリを作成している場合、通常のSHARED ライブラリとアプリケーションの間にImported Library をはさむようにします。

add_library(lib SHARED lib.cpp)
set_target_property(lib PROPERTIES COMMON_LANGUAGE_RUNTIME "")

add_library(implib SHARED IMPORTED GLOBAL)
set_target_property(implib PROPERTIES
  IMPORTED_COMMON_LANGUAGE_RUNTIME "pure"
  IMPORTED_LOCATION ${CMAKE_CURRENT_BINARY_DIR}/$(Configuration)/lib.dll
)

add_executable(exe main.cpp)
set_target_property(exe PROPERTIES COMMON_LANGUAGE_RUNTIME "")
target_link_libraries(exe implib)

この例ではまとめて書いていますが、実際にはadd_library とadd_executable は別のCMakeLists.txt に含まれています。

Imported Library では実際に利用するdll ファイルのパスをIMPORTED_LOCATION で指定します。また、このdll がマネージドであることを示すためにターゲットプロパティにIMPORTED_COMMON_LANGUAGE_RUNTIME を指定します。COMMON_LANGUAGE_RUNTIME ではないという点に注意です。

IMPORTED_COMMON_LANGUAGE_RUNTIME にpure を指定する

Import Library のターゲットプロパティIMPORTED_COMMON_LANGUAGE_RUNTIME にpure を指定しているのは次のような理由です。

通常、C++ 用のdll をリンクする場合、インポートライブラリとしてlib ファイルを指定します。CMake のImported Library では、ターゲットプロパティIMPORTED_IMPLIB にdll に対応するlib ファイルのパスを指定します。このプロパティを指定しない場合、CMake は追加のライブラリとしてターゲット名-NOTFOUND をvcxproj に出力します。これは当然リンクエラーとなります。またCMake 自体の実行結果もエラー終了となります。

C++/CLI のdll でlib ファイルが出力されない場合、IMPORTED_COMMON_LANGUAGE_RUNTIME に"pure" を指定しておくとインポートライブラリは無視されるようです。Visual Studio 2015 以降ではpure は非推奨ですが、ここでのpure 指定はCMake の動作にのみ影響するものでビルドには影響ありません。

dll を実行ファイルのパスにコピーする

C++/CLI でdll を利用する場合、dll は検索可能なパスに置いてある必要があります。CMake を利用する場合、ビルド後のカスタムコマンドで実行ファイルが依存するdll をコピーできるよう、Generator Expression が用意されています。
$<TARGET_RUNTIME_DLLS:tgt>

add_custom_command(TARGET exe POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy -t $<TARGET_FILE_DIR:exe> $<TARGET_RUNTIME_DLLS:exe>
    COMMAND_EXPAND_LISTS
)

感想

最初のうちはググったりドキュメントを調べたりしてましたが行き詰まり、結局CMake のソースからvcxproj のプロパティ名を検索したり、ビルドしてトレース実行して無理やり調べました。

C++/CLI はオワコンでWinRT を使ったほうがよさそう、みたいな話も見かけましたが当面の目的には使えそうなのでもう少し触ってみます。

コマンドラインからC++ プロジェクトにNuGet パッケージをインストールする

前回の記事でC++ プロジェクトにNuGet パッケージをインストールすることについて紹介しました。

MSBuild のカスタムタスクをNuGet パッケージ化してC++ プロジェクトに組み込む

そこでnuget.exe clidotnet cli ではvcxproj にImport 要素が追加されないということに触れました。

nuget.exe CLI

nuget.exe はその説明のページにプロジェクトファイルを変更しないということが書かれています。

NuGet.exe CLI を使用して NuGet パッケージを管理する - パッケージをインストールする

dotnet CLI

dotnetSDK スタイルのプロジェクトにPackageReference 形式でパッケージを加するようです。

dotnet CLI を使用して NuGet パッケージをインストールし、管理する


あまり機会はないかもしれませんが、スクリプトからNuGet パッケージをC++ プロジェクトにインストールしたい場合、nuget.exe やdotnet-cli は利用できなさそうです。

パッケージマネージャーコンソール

Visual Studio IDE では、パッケージマネージャーコンソールが利用できます。これはVisual Studio に組み込まれたコンソールで、NuGet パッケージのインストールなどをコマンドラインから利用できます。

Visual Studio パッケージ マネージャー コンソールを使用して、パッケージを管理する (PowerShell)

パッケージマネージャーコンソールのベースはPowerShell で、そこにパッケージを操作するための機能拡張がされています。

パッケージマネージャーコンソールのInstall-Package コマンドでNuGet パッケージをC++ プロジェクトにインストールした場合、vcxproj にprops ファイル、targets ファイルのImport 要素も追加されます。Install-Package コマンドの利用例は次のようなものです。

パッケージマネージャーコンソール - Install-Package

Install-Package で対象のプロジェクトを指定しない場合、「既定のプロジェクト」に表示されているものが対象となります。また、-Source オプションでnupkg があるパッケージソースを指定することができます。

ただ、パッケージマネージャーコンソールはVisual Studio に組み込まれた状態になっていてそのまま外部での利用はできません。またPowerShell 自体にInstall-Package コマンドが存在していますが、これはパッケージマネージャーコンソールの同名コマンドとは別物で、C++ プロジェクトへのNuGet パッケージのインストールには利用できません。

Visual Studio DTE を利用する

Visual Studio DTE (Development Tool Environment) はVisual Studio を操作する機能を提供しています。C# ではEnvDTE などのアセンブリを組み込んで利用できるようです。

DTE はPowerShell からも利用することができます。そしてDTE に用意されたExecuteCommand メソッドを通してPowerShell からパッケージマネージャーコンソールを利用することもできます。通常のPowerShell のプロンプトからDTE を利用する例は以下のような感じです。

> $VSDTE=New-Object -ComObject VisualStudio.DTE
> $VSDTE.Solution.Open("MyCppProj.sln")
> $VSDTE.MainWindow.Visible=$True
> $VSDTE.ExecuteCommand("View.PackageManagerConsole", "Install-Package MyCustomTask -Source ..\MyCustomTask\bin\Debug")
> $VSDTE.Quit()

各コマンドの実行結果の出力は省略しています。

まず、DTE のインスタンスをCOM オブジェクトとして作成し、ソリューションファイルを開きます。

次にIDE のメインウィンドウを表示状態にします。IDE が表示状態でない場合、パッケージマネージャーコンソールでのコマンドの実行がうまくいかないようです。

それからDTE のExecuteCommand メソッドを呼び出します。一つ目の引数はVisual Studio のコマンドで、View.PackageManagerConsole はパッケージマネージャーコンソールを呼び出すコマンドです。コマンドはVisual Studio のメニューから「ツール」->「オプション」を選択し、「環境」->「キーボード」の項目で一覧が表示されます。

ツール - オプション - 環境 - キーボード

日本語環境だと表示されるコマンドも日本語になっていますが、ExecuteCommand に指定するコマンドは日本語(例えば"表示.パッケージマネージャーコンソール")でも受け付けてくれるようです。

ExecuteCommand の二つ目の引数は一つ目で指定したコマンドに渡すオプションです。この場合はパッケージマネージャーコンソールで実行するコマンドを引数付きで指定しています。

コマンドが実行されたらDTE のQuit() を呼び出してインスタンスを終了します。

プロンプトから手動で呼び出す場合、以上の手順でNuGet パッケージをC++ プロジェクトにインストールできます。しかし、一連の処理をスクリプトにして自動化しようとした場合、そのままではうまくいきません。

PowerShell スクリプトからDTE を利用してパッケージをインストールする

先ほどのプロンプトから入力していた処理をそのままPowerShellスクリプトに記述して実行した場合、ExecuteCommand を実行するあたりで失敗します。

View.PackageManagerConsole を利用する場合、準備が完了するまで待つ必要があるようです。単純にStart-Sleep コマンドで少しの時間だけ待つという方法でも大丈夫ですが、試行錯誤してみたところ以下のような方法でもうまくいきました。

$VSDTE=New-Object -ComObject VisualStudio.DTE
$VSDTE.Solution.Open("MyCppProj.sln")
$VSDTE.MainWIndow.Visible=$True
# コマンド情報を得る
$Cmd=$VSDTE.Commands.Item("View.PackageManagerConsole")
# コマンドが利用可能になるまで待つ(念のため再試行回数を制限する)
for ($Count=0; $Count<10; $Count++)
{
    # コマンドが利用可能ならループを抜ける
    if ($Cmd.IsAvailable)
    {
        break;
    }
    Start-Sleep -Seconds 1.0
}
$VSDTE.ExecuteCommand("View.PackageManagerConsole", "Install-Package MyCustomTask -Source ..\MyCustomTask\bin\Debug")

DTE から直接View.PackageManagerConsole コマンドを取り出し、利用可能になっているかどうかをチェックしています。

また、ExecuteCommand はコマンドの実行終了を待たずに次の処理に進みます。そのままだとDTE のQuit() を呼び出してVisual Studio が終了してしまい、Install-Package コマンドが中断してしまいます。

コマンドの終了を待つ方法を探してみましたが見つからず、最終的にvcxproj にprops, targets ファイルのImport 要素が追加されたかどうかを監視するという方法で目的は達成できました。

$VSDTE.ExecuteCommand("View.PackageManagerConsole", "Install-Package MyCustomTask -Source ..\MyCustomTask\bin\Debug")
for ($Count=0; $Count<10; $Count++)
{
    # 1 秒待つ。時間がかかるようなので先に待ち時間を設ける
    Start-Sleet -Seconds 1.0
    # vcxproj にMyCustomTask.target とImport Project の文字列を含む行が存在するかどうかをチェックする
    $Result=Get-Content "MyCppProj.vcxproj"|Select-String -Pattern "MyCustomTask.target"|Select-String -Pattern "Import Project"
    # 存在していたらループを抜ける
    if ($Result.Count -GE 1)
    {
        break
    }
}

まとめ

PowerShell スクリプトからVisual Studio DTE を利用してC++ プロジェクトにNuGet パッケージをインストールし、vcxproj にprops, targets を参照するImport 要素を追加する方法を紹介しました。

NuGet はソースコードgithub で公開されているのでそちらを詳しく調べてみるともっといい方法が見つかる可能性があるかもしれません。通常のPower-Shell からパッケージマネージャーコンソールのInstall-Package コマンドが利用できればよかったのですが今回は見つけられませんでした。

MSBuild のカスタムタスクをNuGet パッケージ化してC++ プロジェクトに組み込む

MSBuild のカスタムタスクをC++ のvcxproj に組み込む方法として、以前の記事ではImport Project を手動で記述するというのを紹介しました。

C++ をclang で解析するときに情報をvcxproj から取得する方法

その後、NuGet パッケージとして組み込む方法についても確認し、そちらの方がシンプルでよさそうなので以下で紹介します。

NuGet パッケージの組み込み形式について

NuGet パッケージの組み込み形式には、PackageReference 形式とpackages.config 形式の2つがあります。

PackageReference 形式はプロジェクトファイルの中にパッケージの情報を含める方法です。これはVisual Studio 2017 バージョン 15.7 以降から利用可能になったpackages.config 形式よりも新しい形式です。

packages.config から PackageReference への移行

上記のリンク先にも書いてある通り、見た目がシンプルになったり、グローバルパッケージフォルダーが利用されることでパッケージ管理のパフォーマンスが向上するなどの利点がありますが、C++ のプロジェクトでは利用できません。

packages.config 形式はNuGet パッケージの情報をvcxproj ファイルとは別のファイルで管理し、パッケージの内容はソリューションごとに用意されたpackages フォルダにダウンロードして展開されます。また、NuGet パッケージごとのプロパティなどの定義が含まれるprops ファイル、targets ファイルなどがある場合はvcxproj にImport 要素を含めておく必要があります。しかしC++ プロジェクトでは現在のところpackages.config 形式を利用するしかありません。

vcxproj へのNuGet パッケージのインストール

チュートリアルに従って作成したカスタムタスクのNuGet パッケージをvcxproj に組み込むのは、Visual Studio IDE のNuGet パッケージマネージャーを利用すれば簡単に実行できます。

チュートリアル: コード生成用のカスタム タスクを作成する

NuGet パッケージマネージャーからカスタムタスクのNuGet パッケージを選択してインストールを実行すると、ソリューションのpackages フォルダへの展開、packages.config の作成・更新、必要に応じてvcxproj へのImport 要素の追加も行われます。

NuGet パッケージマネージャー

packages.config

props ファイルのImport

targets ファイルのImport

パッケージソースの設定

NuGet パッケージをパッケージマネージャーのリストから選択するためには、パッケージソースとして設定されている場所にnupkg ファイルを置いておきます。ローカルファイルシステムにおいてあるnupkg ファイルを利用したい場合、Visual Studio のメニューで「ツール」->「NuGet パッケージマネージャー」->「パッケージマネージャー設定」からウィンドウを開き、NuGet パッケージマネージャーの「パッケージソース」で登録しておくこともできますが、ソリューションファイルと同じ階層にnuget.config ファイルを置いてプロジェクト固有のパスを指定することもできます。

NuGet パッケージマネージャーのパッケージソースの設定

<configuration>
  <packageRestore>
    <add key="enabled" value="true" />
    <add key="automatic" value="true" />
  </packageRestore>
  <packageSources>
    <add key="local" value="../MyCustomTask/bin/Debug" />
  </packageSources>
</configuration>

NuGet パッケージのインストール手段の注意点

NuGet パッケージはパッケージマネージャーからインストールするほか、nuget.exe clidotnet cli などのコマンドラインツールからインストールすることもできますが、NuGet パッケージマネージャー以外のコマンドラインツールを利用した場合はvcxproj からprops ファイル、targets ファイルを参照するためのImport 要素は追加されません。これについては別途情報をまとめます。

まとめ

開発したカスタムタスクを配布する場合、NuGet パッケージを利用したほうが利便性が高そうですが、vcxproj で利用する場合には新しくて便利なPackageReference 形式は利用できないことも分かりました。また、これまで古いほうのpackages.config 形式の知識しかなかったのですがPackageReference 形式になるとdll やprops, targets ファイルがソリューション、プロジェクトのフォルダに展開されずに戸惑い、いろいろと調べて情報をアップデートするいい機会になりました。

C++ をclang で解析するときに情報をvcxproj から取得する方法

この記事はC++ Advent Calendar 2023 の5 日目です。 前に書いたものを簡単に流用しやすくなるように意識してまとめなおしたものです。


C++ でリフレクションやシリアライズを行うためにコードを解析したくなる場合があると思います。

その時の選択肢としてclang(libclang) やそのバインディングを利用するというのは以前から見かける方法です。

さらにWindows でのプログラミングの場合はlibclang に渡すためのインクルードパスなどの情報をVisual Studio のvcxproj から取得しようというアイディアも以前から存在しています。

例えば2017 年のヘキサドライブさんの記事(https://hexadrive.jp/hexablog/program/18139/) や2019 年のランカースさんの記事(http://www.lancarse.co.jp/blog/?p=2885) などです。

上記の記事ではvcxproj ファイルを独自に簡易的に解析して情報を抽出していました。目的によってはそれで十分ですが、Debug ビルドとRelease ビルドで設定が違う、といった場合など少し複雑な条件に対応しようとすると自前での解析はなかなか大変です。

Visual Studio でvcxproj を実際に解析して処理しているのは内部で使用されているMSBuild です。MSBuild の機能はMSBuild API としてC# から利用できるパッケージがNuGet で提供されています。vcxproj の処理はMSBuild API に任せてしまえば後はlibclang でパースしたコードの解析に専念することができます。

以下、C# やNuGet パッケージ、MSBuild の話になりますがそれぞれの細かい説明は省きます。

カスタムタスク

MSBuild が解析したvcxproj の情報を取得するには、C# でカスタムタスクというものを作成してvcxproj に組み込みます。

カスタムタスクは、NuGet で取得できるMicrosoft.Build パッケージ、Microsoft.Build.Utilities.Core パッケージに含まれるMicrosoft.Build.Utilities.Task クラスを継承したクラスがその本体です。例えば以下のようなものです。

using Microsoft.Build.Utilities;

namespace MyCustomTask
{
    public class MyCustomTask : Task
    {
        // ソースコードファイル名
        public string SourceFiles { get; set; }
        // インクルードパス
        public string IncludePaths { get; set; }
        // プリプロセッサマクロ定義
        public string PreprocessorMacro { get; set; }

        // 実際の処理
        public override bool Execute()
        {
            // 解析処理
            // 成功したらtrue を返す
            return true;
        }
    }
}

プロパティはMSBuild のプロジェクトとパラメータを受け渡すのに使用します。

Execute はカスタムタスクの処理を記述するメソッドでMSBuild から呼び出されます。

これをC# のクラスライブラリプロジェクトとしてビルドしてdll ファイルを生成します。

vcxproj への組み込み

カスタムタスクとはカスタムしたMSBuild のタスクですが、タスクというのはMSBuild が処理するプロジェクト(vcxproj やcsproj) に含まれる、実際の処理の単位です。

MSBuild のタスクはプロジェクトファイルの中にあるターゲットに含まれます。ターゲットというのはタスクをまとめたもので、処理の起点として指定したり、処理同士の依存関係や処理順序を制御する役割があります。既定のターゲットとしてBuild やClean などがあります。

作成したカスタムタスクは、MSBuild プロジェクトにUsingTask 要素で追加して、独自ターゲットから呼び出します。MSBuild プロジェクトへの追加は、vcxproj ファイルを直接編集することもできますが、vcxproj と同じディレクトリにDirectory.Build.props やDirectory.Build.targets などのファイルを置いておくと自動的にインポートされます。これらはvcxproj と同様のxml 形式のファイルですが、Directory.Build.props はvcxproj の内容より前にインポートされて、Directory.Build.targets はvcxproj の内容より後にインポートされるという違いがあります。次のコードはDirectory.Build.targets の例です。

<Project>
  <!-- vcxproj と同じディレクトリにあるMyCustomTask.dll のタスクを使用 -->
  <UsingTask
    TaskName="MyCustomTask.MyCustomTask"
    AssemblyFile="MyCustomTask.dll" />
  <!-- 独自ターゲット -->
  <Target
    Name="MyCustomTarget"
    BeforeTargets="ClCompile">
    <!-- カスタムタスクを呼び出す -->
    <MyCustomTask
      SourceFiles="@(ClCompile)"
      IncludePaths="$(IncludePath);%(ClCompile.AdditionalIncludeDirectories)"
      PreprocessorMacro="%(ClCompile.PreprocessorDefinitions)"
      />
  </Target>
</Project>

UsingTask で使用するタスクの名前とタスクが含まれるアセンブリを指定しています。タスク名は名前空間も含めたものです。Target の子要素として呼び出すときは名前が衝突しなければ名前空間は省略できます。

Target 要素ではName 属性で任意の名前を指定しています。BeforeTargets 属性は、このMyCustomTarget がどのターゲットより前に処理されるかという依存関係を表します。指定されているClCompile ターゲットは、MSBuild でのC++コンパイルを行うターゲットです。つまりこの例はMyCustomTarget ターゲットに含めたMyCustomTask がC++コンパイルの前に呼び出されるような指定となります。

カスタムタスククラスへの入力

カスタムタスククラスのプロパティにパラメータを渡す場合、MSBuild プロジェクトではタスク要素の属性として指定します。前のDirectory.Build.targets の例の場合を順に説明します。

まず入力ソースファイルを受け取るプロパティのSourceFiles には@(ClCompile) を渡していますが、これはvcxproj の中を覗くと見つけられるItemGroup の子要素となっているClCompile をまとめたものです。MSBuild の用語ではItemGroup の子要素は項目といいます。@(ClCompile) はカスタムタスクのC# 側クラスのプロパティにはセミコロンで連結されたファイルパスとして文字列で渡されます。C# 側ではそれを分割すればファイルパスリストに戻せるというわけです。

また、MSBuild 側で@(ClCompile) の代わりに%(ClCompile.Identity) を指定すると、ファイルパスひとつずつに対してカスタムタスクが実行されます。この場合C# 側が受け取るのは単一のファイルパスになります。

インクルードパスを受け取るIncludePaths には$(IncludePath) と%(ClCompile.AdditionalIncludeDirectories) をセミコロンで連結して渡しています。$(IncludePath) はプロジェクト全体に指定されたインクルードパスを保持するプロパティです。SDK ごとのパスなども含まれています。

%(ClCompile.AdditionalIncludeDirectories) は追加のインクルードパスを保持するメタデータです。Visual Studio IDE でプロジェクトのプロパティから指定できる 追加のインクルードディレクトリが入力されますが、ソースファイル個別のプロパティで追加のインクルードディレクトリが上書きされている場合はそちらに置き換わります。C# 側への入力がどうなるかというと、同じ値を持つClCompile 要素ごとに分けて呼び出されます。これは項目のメタデータバッチ処理という機能によるものです。

最後にプリプロセッサマクロ定義を受け取るPreprocessorMacro には%(ClCompile.PreprocessorDefinitions) が渡されています。これもメタデータで、%(ClCompile.AdditionalIncludeDirectories) と同様に処理されます。

なお、目的の情報がMSBuild 内の項目、メタデータ、プロパティなどのどこから取得できるのかはvcxproj をMSBuildプリプロセスし、インポートされるプロジェクトがすべて展開された状態のファイルを出力して、その中から探しました。ほかに必要な情報がある場合も同様に調べることが可能です。

これで、カスタムタスククラスにソースコードのファイルパスと必要な情報を渡すことができるようになりました。解析はlibclang なりClangSharp なり、お好みのライブラリ、言語で自由にやっちゃってください。

生成したソースコードをビルドに含める

なお、カスタムクラスでC++ ソースコードを生成した場合、それをそのままビルドに含めることもできます。

using Microsoft.Build.Utilities;

namespace MyCustomTask
{
    public class MyCustomTask : Task
    {
        // 略

        // MSBuild に値を返すプロパティ
        [Output]
        public string GeneratedFiles { get; set; }

        // 実際の処理
        public override bool Execute()
        {
            // 解析処理は省略
            GeneratedFiles = "path/to/generated.cpp";
            return true;
        }
    }
}
<Project>
  <!-- 略 -->
  <Target
    Name="MyCustomTarget"
    BeforeTargets="ClCompile">
    <!-- カスタムタスクを呼び出す -->
    <MyCustomTask
      SourceFiles="@(ClCompile)"
      IncludePaths="$(IncludePath);%(ClCompile.AdditionalIncludeDirectories)"
      PreprocessorMacro="%(ClCompile.PreprocessorDefinitions)">
      <!-- 出力プロパティの値をClCompile 項目に追加 -->
      <Output TaskParameter="GeneratedFiles" ItemName="ClCompile" />
    </MyCustomTask>
  </Target>
</Project>

C# 側のクラスでOutput 属性を持つプロパティに持たせた値を、MSBuild プロジェクト側ではタスク要素の子のOutput 要素で追加先としてItemName にClCompile を指定するだけです。


C++ のコードは出てきませんでしたが間違いなくC++ プログラミングに役立つものと信じて書きました。

MSBuild のカスタムタスクでファイルを生成してビルドに含める

vcxproj に含まれる情報を取得してlibclang で処理する方法を調べていますが 今回は生成したファイルをビルドに含める方法について説明します。

前回:MSBuild でvcxproj に独自の処理を追加する - 見切り発車

独自の処理を作成する

前回の記事では、独自の処理をMSBuild のビルドプロセスに組み込む方法について 独自ターゲットを追加して既定のターゲットとの依存関係を設定するということを 書きました。

独自ターゲット内で実行される独自処理を作成する場合は、カスタムタスクを作成します。

カスタムタスクはMicrosoft.Build.Framework パッケージに含まれる ITask インターフェースを実装したクラスです。Microsoft.Build.Utilities.Core パッケージに含まれるTask クラスを継承してExecute メソッドをオーバーライドすると 手軽に実装が可能です。

これらのパッケージは、NuGet でインストールできます。

カスタムタスクを作成する方法は、以下のリンクにチュートリアルが用意されています。

https://learn.microsoft.com/ja-jp/visualstudio/msbuild/tutorial-custom-task-code-generation

その中で触れられていますが、フレームワークはnetstandard2.0 にする 必要があるそうです。そのため、Microsoft.Build のNuGet パッケージも 最新の17.x.x ではなく15.x.x を選択しました。

ごく単純なカスタムタスククラスは次のようになります。

import Microsoft.Build.Utilities;

namespace MyCustomTask
{
    public class MyCustomTask : Task
    {
        public override bool Execute()
        {
            return true;
        }
    }
}

このカスタムタスクは何もしませんが、ビルドプロセスからExecute メソッドが 呼び出されます。true を返すと処理が正常終了したことを表します。 false を返すとビルドが失敗します。

カスタムタスクをvcxproj に組み込む

カスタムタスクのクラスを含むプロジェクトは、クラスライブラリとして作成します。

ビルドするとdll が出力されるので、これをvcxproj からUsingTask 要素で参照します。

先ほどのMyCustomTask を参照する場合にはUsingTask 要素は次のように指定します。

  <UsingTask
    TaskName="MyCustomTask.MyCustomTask"
    AssemblyFile="dll ファイルのパス"
    />

TaskName にはタスククラスの名前を入力しますが、名前空間を含めたほうが確実です。

AssemblyFile にはdll のパスを入力しますが、相対パスでもフルパスでもどちらでも大丈夫です。

UsingTask 要素を追加したら、ターゲットで次のようにして呼び出せます。

  <Target Name="MyTarget" BeforeTargets="ClCompile">
    <MyCustomTask />
  </Target>

カスタムタスクをNuGet パッケージ化すると組み込みが簡単になりそうですが それについては別の機会にします。

vcxproj とカスタムタスクのパラメータのやりとり

カスタムタスク側は、プロパティを使用してパラメータを受け取ります。 またカスタムプロセスから情報を返す場合にもプロパティを使用します。

プロパティには属性を指定して、必須項目や出力用として扱います。

public class MyCustomTask : Task
{
    // 必須の入力ファイル名
    [Required]
    public string InputFile { get; set; }

    // オプションのサフィックス
    public string Suffix { get; set; }

    // MSBuild に返す出力ファイル名
    [Output]
    public string OutputFile { get; set; }

    public override bool Execute()
    {
        // 入力ファイルが無ければ失敗
        if (!System.IO.File.Exists(InputFile))
        {
            return false;
        }
        // ファイルパスをディレクトリ、ファイル名、拡張子に分割
        string dir = System.IO.Path.GetDirectoryName(InputFile);
        string fileName = System.IO.Path.GetFileNameWithoutExtension(InputFile);
        string ext = System.IO.Path.GetExtension(InputFile);
        // Suffix の指定が無ければ.gen を付与
        if (string.IsNullOrEmpty(Suffix))
        {
            OutputFile = System.IO.Path.Combine(dir, $"{fileName}.gen{ext}")
        }
        // ファイル名と拡張子の間にSuffix を挿入
        else
        {
            OutputFile = System.IO.Path.Combine(dir, $"{fileName}{Suffix}{ext}")
        }
        // ダミーのコードを出力
        System.IO.File.WriteAllText(OutputFile, "namespace { int _{ 0 }; }");

        return true;
    }
}

vcxproj では入力パラメータをカスタムタスク要素の属性で渡します。 カスタムタスクの出力パラメータの受け取りは後述します。

  <Target Name="MyTarget" BeforeTargets="ClCompile">
    <MyCustomTask
      InputFile="%(ClCompile.Identity)"
      />
  </Target>

InputFile に@(ClCompile) を渡した場合には、C++コンパイル対象の ファイル名がセミコロンで連結されたひとつの文字列が入力となりますが、 %(ClCompile.Identity) を指定すると各入力ファイルごとに分割されて、 それぞれの入力ファイル名ごとにカスタムタスクが呼び出されます。 これはMSBuildメタデータバッチ処理という機能によるものです。

生成したファイルをC++ のビルドに含める

独自のターゲットでClCompile という項目にファイル名を追加すると、 そのファイルもC++コンパイル対象になります。

カスタムタスクで生成したファイルをClCompile に追加したい場合は Output という子要素とカスタムタスクの出力パラメータを利用します。

次の例のようにOutput 子要素のTaskParameter に出力パラメータ名、 ItemName にClCompile を指定すると、カスタムタスクのExecute メソッドで プロパティに設定した内容がClCompile に追加されます。

  <Target Name="MyTarget" BeforeTargets="ClCompile">
    <MyCustomTask InputFile="%(ClCompile.Identity)">
      <Output TaskParameter="OutputFile" ItemName="ClCompile">
    </MyCustomTask>
  </Target>

まとめ

MSBuild のカスタムタスクでC++ のコードを生成してビルドに含める方法について説明しました。

今回の内容も、チュートリアルとサンプルプロジェクト、 公式のドキュメントの情報を参照して調べたものです。 時間をかけて読むのは強いなと思いました。

MSBuild でvcxproj に独自の処理を追加する

vcxproj からソースファイル名やインクルードパス、プリプロセッサマクロ定義などを取り出してlibclang に渡して処理するという目的のため試行錯誤しているのですが、 断片的な情報でハックするようなやり方ではなかなか思ったような結果にならないため改めてMSBuild のドキュメントに記載されている公式に提供されている手段で実現する方法を調べました。

今回は特にvcxproj のビルドプロセスに独自の処理を追加する方法と、コンパイラに渡されるパラメータを利用する方法について記述します。

MSBuild のドキュメント

https://learn.microsoft.com/ja-jp/visualstudio/msbuild/msbuild?view=vs-2022

MSBuild についての情報はこちらに記載されています。 今回の内容はこちらに記載されているものとGitHub で提供されているサンプルを参考にしました。

基本的にはMSBuild のドキュメントをしっかり読み込むことができればほぼすべての情報は得られると思います。

独自ターゲットをビルドプロセスに組み込む

vcxproj などのMSBuild のプロジェクトファイルでは各種の処理を行うタスク要素をターゲット要素の子要素としてまとめています。 そしてMSBuild の実行時に起点となるターゲットを指定すると依存関係のあるターゲットに含まれるタスクが実行されます。

独自の処理を追加したい場合、C# で独自のタスククラスを含むクラスライブラリを作成しこれを呼び出す独自ターゲットを定義して既定のターゲットとの依存関係を設定します。

例えば、vcxproj でコンパイルを行うターゲットはClCompile という名前ですが、 コンパイル処理の前に独自の処理を実行したい場合、独自のターゲットを作成してBeforeTargets 属性にClCompile を指定します。

<Target Name="MyPreClCompile" BeforeTargets="Clcompile">
  <Message Text="MyPreClCompile" />
</Target>

コンパイラに渡される情報を利用する

MSBuild には項目、メタデータ、プロパティといったパラメータがあります。

項目はItemGroup で定義される主にファイルを指すパラメータです。 @(ClCompile) のように@ を付けて参照します。

メタデータはItemGroup やItemDefinitionGroup で項目の子要素として定義されます。 これには%(ClCompile.PreprocessorDefinitions) のように% を付けて項目の子要素として参照します。

プロパティはPropertyGroup で定義されるパラメータです。 $(IncludePath) のように$ を付けて参照します。

ClCompile でどのようなメタデータが利用できるかは C:\Program Files\Microsoft Visual Studio\2022\Community\Msbuild\Microsoft\VC\v170 などのディレクトリから探すこともできますが、 vcxproj をMSBuild の/pp オプションでプリプロセスして出力したファイルから読み取ることもできます。

独自ターゲットでMessage タスクを利用して各パラメータの値を確認することができます。

<Target Name="MyPreClCompile" BeforeTargets="Clcompile">
  <Message Text="ClCompile=@(ClCompile)" />
  <Message Text="IncludePath=$(IncludePath)" />
  <Message Text="PreprocessorDefinitions=%(ClCompile.PreprocessorDefinitions)" />
</Target>

パラメータの種類に合わせた参照するための記号を使用するよう注意が必要です。

独自ターゲットの挿入位置

プロパティやメタデータは同じ要素がvcxproj 内の複数個所にある場合には出現順序によって最終的な結果が決まるため、 ターゲットでこれらを定義している場合には順序に気を付ける必要がありますが、 今回のように参照するだけであれば他のターゲットとの前後関係はあまり気にしなくてよさそうです。 vcxproj に直接挿入する以外に、vcxproj と同じ階層に置いたDirectory.Build.targets ファイルに記述する方法もあり、 こちらのほうが独立して取り扱いがしやすいかもしれません。

まとめ

vcxproj に独自処理を追加してvcxproj の情報を利用する場合には独自ターゲットを追加して既定ターゲットとの依存関係を指定し、 種類ごとに決められた記号を使用してパラメータの値を参照します。

これらはMSBuild のドキュメントに書いてあり、時間はかかりますが理解しておくと目的に合わせたアレンジがしやすくなりそうです。