[小ネタ] .NETアセンブリが存在するディレクトリパス名を取得する方法 (単一ファイルに発行対応版)

# 1. 概要

プログラムの実行時に、ある特定のアセンブリが格納されているディレクトリパス名が知りたくなることがあります。
私の場合なら、以下のような状況がよくあります。
+ アセンブリと同じディレクトリにインストールされているはずの `.json` ファイルのパス名を知りたくなった時
+ 同じディレクトリにインストールされているはずのプラグインのアセンブリを動的にロードするために、 プラグインのアセンブリのフルパス名を組み立てる時

c# ではこういう場合には常套手段があるのですが、とある状況で常套手段が使えないことが判明したので、より汎用的な手段を考えてみました。

この記事がどなたかの参考になれば幸いです。

# 2. アセンブリが格納されているディレクトリパス名を取得する方法 (通常版)

`System.Reflection.Assembly`クラスの `Location` プロパティを参照するとアセンブリのフルパス名を取得することが出来ます。
そこから `System.IO.Path.GetDirectoryName()` メソッドを使用してディレクトリパス名だけ抜き出すことによって、アセンブリのあるディレクトリパス名を取得することが出来ます。

例えば、以下のコードのような感じです。

```csharp
using System;
using System.IO;

namespace LibraryProject
{
    public class MyClass
    {
        private static readonly string _baseDirectoryPath;

        static MyClass()
        {
            // このアセンブリがあるディレクトリパス名を設定する
            _baseDirectoryPath = Path.GetDirectoryName(typeof(MyClass).Assembly.Location) ?? throw new Exception();
        }

        public void Foo()
        {
            // このアセンブリと同じディレクトリにあるはずの "settings.json" ファイルのフルパス名を求める。
            var jsonFilePath = Path.Combine(_baseDirectoryPath, "settings.json");

            // "settings.json" ファイルを読み込む。
            var jsonText = File.ReadAllText(jsonFilePath);

            // 以降、読み込んだ jsontext を解析するコード
        }
    }
}
```

上記の例では「`MyClass`を含んでいるアセンブリ」を明示的に指定するために `typeof(MyClass).Assembly` というコードを記述していますが、その代わりに `System.Reflection.Assembly.GetCallingAssembly()` と書いても同じ結果が得られます。

これと同じようなやり方は既に多くの方が実践されているようですが、最近これではうまくいかないケースに出くわしてしまい、対応方法に若干悩むことになってしまいました。

# 3. 問題点

先日、前述したようなコードを含むプログラムのテストを linux (WSL) 上で行うために linux-x64 用の実行可能ファイルを発行したのですが、発行中に以下のようなエラーが発生しました。

> 3>'System.Reflection.Assembly.Location' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

大雑把に訳すと、「 **単一ファイルアプリケーションに埋め込まれたアセンブリの場合は`System.Reflection.Assembly.Location` は常に空文字列を返す** けど、それじゃダメでしょ?」ってことのようです。
エラーメッセージにあるように、確かに私は発行時のオプションに「単一ファイルの作成」を指定していました。

この問題は、発行先が linux であるからどうかとは関係なく、Windows でも起きます。
要するに、**発行時のオプションに「単一ファイルの作成」を指定しているかどうか** だけがキーポイントのようです。

まぁ、単一ファイルに結合されているわけだからアセンブリの配置情報とかは失われても仕方ないよね、ってことなのでしょうか…

# 4. 対処方法

一番簡単な対処方法は、 **「単一ファイルに作成」のオプションを指定しない、または発行そのものをしないこと** です。
しかし、当時私が作っていたのはライブラリで、私自身はもちろんそのライブラリを使いますし、他の誰かも使うかもしれません。
そのライブラリの利用者に **「このライブラリを使う人は単一ファイルに発行しないでね!」** という制限をつけても、それが必ずしも守られるとは限りません。 ~~自慢ではありませんが私の記憶力の乏しさには自信がありますので。~~

なので、もっと汎用的な解決方法を考えてみることにしました。
とは言っても、前述のエラーメッセージに既に答えが書いてあります。代わりに `System.AppContext.BaseDirectory` プロパティを使用すればいいのです。
`System.AppContext.BaseDirectory` プロパティの値は実行可能ファイル(`.exe`)が格納されているディレクトリパス名を示すものであり、特定アセンブリの配置場所を示すものではありませんが、単一ファイルとして発行されている場合なら結果は同じなので問題はありません。

しかし、自分が作ったライブラリのアセンブリが配置されているディレクトリパスと実行可能ファイルが格納されているディレクトリパスが異なることも想定しなければならないので、常に`System.AppContext.BaseDirectory` プロパティを使用するわけにはいきません。ちょっと工夫が必要です。

なので、以下のような拡張メソッドを書いてみました。

```csharp
using System;
using System.Reflection;

namespace LibraryProject
{
    public static class AssemblyExtensions
    {
        /// <summary>
        /// アセンブリが格納されているディレクトリパス名を取得します。
        /// </summary>
        /// <param name="assembly">
        /// ディレクトリパス名を取得するアセンブリを示す <see cref="Assembly"/> オブジェクトです。
        /// </param>
        /// <returns>
        /// アセンブリが格納されているディレクトリパス名を示す <see cref="string"/> オブジェクトです。
        /// </returns>
        /// <exception cref="ArgumentNullException">
        /// <paramref name="assembly"/> が null です。
        /// </exception>
        public static string? GetBaseDirectory(this Assembly assembly)
        {
            if (assembly is null)
                throw new ArgumentNullException(nameof(assembly));

            // アセンブリが配置されているフルパス名を求める
            var location = assembly.Location;

            // assembly.Location が 空文字列ではない場合、そこから Path.GetDirectoryName() でディレクトリパス名を抜き出した文字列を復帰値とする
            // assembly.Location が 空文字列である場合、AppContext.BaseDirectory の値を復帰値とする
            return
                !string.IsNullOrEmpty(location)
                ? Path.GetDirectoryName(location)
                : AppContext.BaseDirectory;
        }
    }
}
```

この拡張メソッドを使えば、発行時に単一ファイルになっている場合でも、それ以外の場合でも、そのアセンブリ(またはアセンブリを含んでいる実行可能ファイル) が配置されているディレクトリパス名を取得することが出来ます。


この拡張メソッドを使用するように最初のコードを書き直すと以下のようになります。

```csharp
using System;
using System.IO;

namespace LibraryProject
{
    public class MyClass
    {
        private static readonly string _baseDirectoryPath;

        static MyClass()
        {
            // このアセンブリがあるディレクトリパス名を設定する
#if true
            // 変更後
            _baseDirectoryPath = typeof(MyClass).Assembly.GetBaseDirectory() ?? throw new Exception();
#else
            // 変更前
            _baseDirectoryPath = Path.GetDirectoryName(typeof(MyClass).Assembly.Location) ?? throw new Exception();
#endif
        }

        public void Foo()
        {
            // このアセンブリと同じディレクトリにあるはずの "settings.json" ファイルのフルパス名を求める。
            var jsonFilePath = Path.Combine(_baseDirectoryPath, "settings.json");

            // "settings.json" ファイルを読み込む。
            var jsonText = File.ReadAllText(jsonFilePath);

            // 以降、読み込んだ jsontext を解析するコード
        }
    }
}
```

このように変更したコードを普通に実行したり、Windows および linux 向けに発行して実行したりして、いずれも期待した通りの動作をすることを確認しました。

# 5. 結論

+ `System.Reflection.Assembly`クラスの `Location` プロパティを使用しているコードを発行するときに「単一ファイルの作成」オプションを使用している場合、`Location` プロパティは常に `""` (空文字列) を返す。
+ 前記のような状況でアセンブリが配置されているディレクトリパス名を取得するためには `System.AppContext.BaseDirectory` プロパティを利用することが出来る。
+ [4. 対策](#4-%E5%AF%BE%E5%87%A6%E6%96%B9%E6%B3%95) で示した拡張メソッドを利用することにより、通常にビルドした場合/発行した場合、「単一ファイルの作成」オプションを使用した場合/使用しない場合、いずれの状況でも、アセンブリが配置されているディレクトリパス名を取得することが出来る。

# 6. 蛇足

問題の再現用のコードを書いて、対策に頭を悩ませて、対策用のコードも書いて実験して、この記事をここまで書いて、今更やっと気がついたのですが…

修正後のコードでも `System.Reflection.Assembly.Location` を使用しているのですが、何故かこれは単一ファイルに発行する場合でもエラーにならないんですよね。ほんっと～～に今更ですが。
発行の際にはかなり高度なコード分析が行われているのだと思います。