BackacheEngineerの技術的な備忘録

技術系でいろいろ書けたらなーと

C#:自作属性を使ってコマンドラインオプション処理を1クラスに

目次

C#の属性を使った処理をまとめる。(転職活動が忙しすぎて更新できんかった・・・) 最初は一般的なことを書くが、後半は 属性を自作して処理を追加するといったことまでまとめる。



属性でできること

属性にできることはいっぱいある。一例を書く。

  • フィールド、プロパティに対してライブラリ側で管理したい処理を任せられる
  • 単体試験で様々なパラメータの調整を任せられる
  • コード内で管理が完結する(はず)


3つ目が、とってもいいことだと思う。 コード内で完結してくれれば無駄に管理物が増えないからだ。 また、コードはGitやSVNなど構成管理をするので、勝手に何がどう変わったかをチェックできる。



属性を使う

割と使うことなるのは下記あたりか。

  • Serializable
  • Obsolete
  • STAThread

最後のは Form アプリケーションやってる人だったらよく見るやつ。

結局ここら辺の属性ってのは、「フィールド・プロパティ・メソッドがなんたるか」、というのを 定義してくれている。

シリアライズ対象だったり、古いやつだったり、シングルスレッドで動かしたりといった具合だ。



自作属性を作る

作り方をまとめる。作るうえで例がないとわかりにくいので、 今回はコマンドラインオプション処理を任せる属性を作る。 ( python ならライブラリあるじゃんww というのはNG。C#が使いたいんだよ!)

コマンドラインオプションでやってほしいのは以下のとおりにする。 全力で作りこむならもっと機能が必要になる。

  • 必須オプションを設定できる
  • オプション名を自由に設定できる
  • 必須オプションの有無を確認する
  • コマンドラインオプションで指定された値はフィールドやプロパティに格納する
  • コマンドラインオプションのヘルプを作れる


先にどんなものを作るのか結果を載せておく。下記のように、フィールドやプロパティに対して 属性を指定することで、コマンドラインオプションと入力された値の格納先を指定する。 そして、コマンドラインオプションのチェックだとかなんかはぜーんぶ1クラスに任せてしまうというもの。

コード。

[CommandLineTargetClass]
class Program
{
    [CommandLineAttr("i", true, "help: input file")]
    public static string InputFile { get; set; }

    [CommandLineAttr("o", false, "help: output file")]
    public static string OutputFile { get; set; } = @"C:\temp\outputFile.txt";

    [CommandLineAttr("test", false, "help: test option")]
    static string privateTest { get; } = "Test!!";

    [CommandLineAttr("mode", false, "help: mode")]
    static int _mode = 0;

    [CommandLineAttr("debug", false, "help: debug mode start")]
    static bool _debug = false;

    static void Main()
    {
        var parser = new CommandLineParser(typeof(Program));

        if (parser.IsHelp)
        {
            Console.WriteLine(parser.HelpMessage);
            return;
        }

        if (parser.IsError)
        {
            Console.WriteLine(parser.ErrorMessage);
            return;
        }

        // main process start ...
        Console.WriteLine($"default: none\t\t\t\t\tvalue: {InputFile}");
        Console.WriteLine($"default: C:\\temp\\outputFile.txt\t\tvalue: {OutputFile}");
        Console.WriteLine($"default: Test!!\t\t\t\tvalue: {privateTest}");
        Console.WriteLine($"default: 0\t\t\t\t\tvalue: {_mode}");
        Console.WriteLine($"default: false\t\t\t\tvalue: {_debug}");
    }
}

使ってみるとこんな感じ

f:id:BackacheEngineer:20210605163545g:plain
コマンドラインオプションのデモ



環境

今回試す環境は下記のとおり。やっとこさ .NET 5 でやるけどもうすぐ .NET 6 くるとかいう絶望。



クラスを作る

まず作るのはカスタム属性クラスである。Attribute クラスを継承したクラスである。

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class CommandLineAttr : Attribute
{

}


完了だ。 実はもうこれだけで自作属性はできた。もう使える。


次に行く前にいくつか説明を行う。

項目 説明
AttributeUsage 属性を付ける対象を選べる。今回はコマンドラインオプションなので、フィールドとプロパティにした。

AllowMultiple はこの属性を複数つけれるようにするかどうか。今回は1つだけにしたいんで false 。

Inherited は属性を継承させるか。これは「この属性を付けているプロパティなどに対して」という意味。
sealed 継承不可にする便利なやつ
Attribute 継承元クラス。これを継承するだけでなんと自作属性完成だ。


さっき作ったのが「プロパティやフィールドをコマンドラインオプションとして認識するための属性」である。


あともう一個、「そのクラスがコマンドラインオプションを持つクラスかどうか確認するための属性」を作る。 とはいっても作り方はすでに説明したとおり簡単。

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class CommandLineTargetClass: Attribute
{

}



コンストラクタを考える

作ったクラスのコンストラクタを考える。 こいつが重要なのだ。なぜなら、コンストラクタの引数は属性に渡すパラメータそのものだからである。

今回は3つ必要になる。

  • string引数:オプション文字列を指定できるように
  • bool引数:必須かどうかを指定できるように
  • string引数:オプションのヘルプメッセージを指定できるように


まずはコマンドラインオプション側から。

/// <summary>
/// deal Command line option Attribute
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class CommandLineAttr : Attribute
{
    static bool? _inherited;

    /// <summary>
    /// CLOption Name
    /// </summary>
    public string CLOptionName { get; }
    /// <summary>
    /// Is Option Required
    /// </summary>
    public bool Required { get; }
    /// <summary>
    /// Help Message
    /// </summary>
    public string HelpMessage { get; }
    /// <summary>
    /// Is Inherited Property of AttributeUsage
    /// </summary>
    public static bool Inherited
    {
        get
        {
            if (_inherited != null) return _inherited.Value;
            var attr = typeof(CommandLineAttr).GetCustomAttributes(typeof(AttributeUsageAttribute), true)[0] as AttributeUsageAttribute;
            _inherited = attr.Inherited;
            return _inherited.Value;
        }
    }

    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="clOptionName">Command line option name</param>
    /// <param name="required"> Is option required </param>
    /// <param name="helpMessage">Help message</param>
    public CommandLineAttr(string clOptionName, bool required, string helpMessage = null)
    {
        CLOptionName = clOptionName;
        Required = required;
        HelpMessage = helpMessage;
    }
}



続いてコマンドラインターゲットクラス。

/// <summary>
/// this attribute is used by developers to check Command Line Attributes for properties or fields in Class..
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class CommandLineTargetClass : Attribute
{
    static bool? _inherited;

    /// <summary>
    /// Is Inherited Property of AttributeUsage
    /// </summary>
    public static bool Inherited
    {
        get
        {
            if (_inherited != null) return _inherited.Value;
            var attr = typeof(CommandLineAttr).GetCustomAttributes(typeof(AttributeUsageAttribute), true)[0] as AttributeUsageAttribute;
            _inherited = attr.Inherited;
            return _inherited.Value;
        }
    }
}


Inheritedプロパティはあとあと使うので作った。まぁ、正直いらないような気もしてる。



機能を追加する

残りの機能を作っていこう。あとは以下の3機能だ。

  • 必須オプションを設定できる
  • オプション名を自由に設定できる
  • 必須オプションの有無を確認する
  • コマンドラインオプションで指定された値はフィールドやプロパティに格納する
  • コマンドラインオプションのヘルプを作れる

さて、さっき作ったのはただの属性だ。 今回作る機能は Parser クラスを作って実行されることにする。

public sealed class CommandLineParser
{
}

このクラスにどんどん機能を盛り込んでいく。まず、「必須オプションの有無を確認する機能」から。



必須オプションの有無を確認する機能

この機能にまず必要なのは以下の2点。

  1. 入力されたオプション引数を取得する
  2. クラスに設定された「CommandLineAttr」属性を取得する
  3. Requiredプロパティを確認しながら、引数に必須オプションがあるかどうか確認する


1はだいぶ簡単に取得できる。 下記で終わりだ。

Args = Environment.GetCommandLineArgs()[1..]; // First arg is EXE file name.

.NET 5 なので配列の範囲指定を使用している。たしかC# 8.0 以降の機能なんで、 .NET Framework 4.8 の場合は適宜変更が必要(フレームワークによってデフォのC#バージョンが決まるため)。

問題は2と3である。 これはまず細分化する。

  • クラスから、フィールド・プロパティを全部取得する
  • 取得したフィールド・プロパティから、自作した CommandLineAttr 属性のインスタンスを取得する
  • 取得した CommandLineAttr インスタンスの「Required」プロパティを確認する
  • 1で取得した引数と比較し、Requiredプロパティが true の引数があるか確認する

である。


まずは全部のフィールド・プロパティを取得するものから。 とはいっても、できるだけ最小限にしたいので「CommandLineAttr」属性が 引っ付いてるもののみにする。

IEnumerable<MemberInfo> getAllCommandLineMember()
{
    var memlist = new List<MemberInfo>();
    memlist.AddRange(_targetType.GetRuntimeFields());
    memlist.AddRange(_targetType.GetRuntimeProperties());

    foreach (var member in memlist)
    {
        if (member.GetCustomAttribute(typeof(CommandLineAttr), CommandLineAttr.Inherited) == null) continue;
        yield return member;
    }
}

「_target」は「CommandLineTargetClass」属性がついているクラスのTypeインスタンスである。 「GetRuntimeFields」と「GetRuntimeProperties」でフィールドとプロパティを全部取得する。

private だの public だの static だのは関係なくすべて取得できる。

そして、次の foreach で「CommandLineAttr」属性を持つものだけを返す。 「GetCustomAttribute」メソッドで「CommandLineAttr」属性が抜き出せるかどうかチェックしている。 抜き出せない場合は null が返ってくる。抜き出せたら、「CommandLineAttr」属性を持っているということ。


次に「CommandLineAttr」属性のインスタンスを取得する。 さっきのメソッド使って foreach し、「GetCustomAttribute」メソッドで取得していく。

IEnumerable<CommandLineAttr> getCLAttrs()
{
    foreach (var mem in getAllCommandLineMember())
    {
        yield return mem.GetCustomAttribute(typeof(CommandLineAttr), CommandLineAttr.Inherited) as CommandLineAttr;
    }
}

さっきと似たような「GetCustomAttribute」の書き方をしてわざわざ別メソッドにしてるのは、 「getAllCommandLineMember」メソッドは「コマンドラインオプションで指定された値をフィールドやプロパティに代入する」際にも 使いたいからである。


最後に、Required プロパティを確認しながらチェックしていく。

const string c_Prefix = "-";
readonly string _pleaseHelp = $"\n\nPlease reference help that you specify \" {c_Prefix}h or {c_Prefix}help\"";
List<CommandLineAttr> _commandLineAttrList;

// 「CommandLineAttr」属性インスタンスを全部取得するプロパティ
List<CommandLineAttr> CommandLineAttrList
{
    get
    {
        if (_commandLineAttrList != null) return _commandLineAttrList;
        _commandLineAttrList = getCLAttrs().ToList();
        return _commandLineAttrList;
    }
}

// コマンドラインをチェックするメソッド
bool chkCommadLine()
{
    // check required option 
    if (chkRequired(CommandLineAttrList) == false) return false;

    return true;
}

// 必須オプションが指定されているかチェックするメソッド
bool chkRequired(IEnumerable<CommandLineAttr> allCommandLineAttrs)
{
    foreach (var optName in allCommandLineAttrs.Where(x => x.Required).Select(x => $"{c_Prefix}{x.CLOptionName}"))
    {
        if (Args.Contains(optName) == false)
        {
            ErrorMessage = $"Error: Option {optName} is not specified.{_pleaseHelp}";
            return false;
        }
    }
    return true;
}

最後の「chkRequired」メソッドが今回の目的のメソッドである。 foreach でループを回す際に、「Required」プロパティが true の「CommandLineAttr」属性に絞り込み、 さらにそれをユーザが指定したコマンドライン引数と同じ形になるよう Select してやる。

Selectしたやつが Args というコマンドライン引数に「存在しない」なら、 必須オプションが指定されていないということである。 その時はエラーメッセージを設定し、false を返す。 すべての必須オプションが存在すれば true を返す。



コマンドラインオプションで指定された値をフィールドやプロパティに格納する

次に、各フィールドやプロパティにコマンドライン引数で指定された値を格納する機能を作る。 まずは細分化、もとい簡単なアルゴリズムを。

  1. 「CommandLineAttr」属性が付いたフィールド・プロパティを取得する
  2. 「CommandLineAttr」属性から必要なオプション名を作成する
  3. 2のオプション名がコマンドライン引数に存在するかチェックする
  4. 存在する場合、その時指定されている値をキャストし、フィールド・プロパティに格納する
  5. 存在しない場合、何もしない


1は既にメソッドとして作った。 2は「GetCustomAttribute」メソッドから「CommandLineAttr」属性のインスタンスを取得し、作る。

なので、ここまでは簡単。

次からがちょっと厄介だが、今回は4を実現するために辞書を作ることにした。

Keyには入力されたコマンドライン引数のオプション(ex. -i , -o など)、

Valueにはその時指定された内容(ex. -i input.txt の input.txt 側)を格納する。

これで、TryGetValue メソッドを使っていろいろできるようになる。

以下がそのコードである。

// プロパティ・フィールドに値を格納するメソッド
void setValueToPropAndField()
{
    foreach (var mem in getAllCommandLineMember())
    {
        var option = $"{c_Prefix}{(mem.GetCustomAttribute(typeof(CommandLineAttr)) as CommandLineAttr).CLOptionName}";

        Type memType;
        if (mem is PropertyInfo pi)
        {
            memType = pi.PropertyType;
            if (tryGetOptionValue(memType, option, out var newVal) == false) continue;
            pi.SetValue(null, newVal);
        }
        else if (mem is FieldInfo fi)
        {
            memType = fi.FieldType;
            if (tryGetOptionValue(memType, option, out var newVal) == false) continue;
            fi.SetValue(null, newVal);
        }
    }
}

Dictionary<string, string> _optValueDic;
// 入力されたコマンドライン引数をキー、その時指定された内容をValueに持つ辞書のプロパティ
Dictionary<string, string> OptValueDic
{
    get
    {
        if (_optValueDic != null) return _optValueDic;
        _optValueDic = new Dictionary<string, string>();
        var opts = CommandLineAttrList.Select(x => $"{c_Prefix}{x.CLOptionName}").ToList();
        var beforeOpt = string.Empty;
        foreach (var arg in Args)
        {
            if (opts.Contains(arg))
            {
                // if options are duplicated, Exceptin occur.
                _optValueDic.Add(arg, string.Empty);
                beforeOpt = arg;
            }
            else
            {
                _optValueDic[beforeOpt] = arg;
            }
        }
        return _optValueDic;
    }
}

// 格納前に値をキャストするメソッド
bool tryGetOptionValue(Type memberType, string option, out object val)
{
    val = null;
    // implement other type after days ...
    if (memberType == typeof(int))
    {
        if (OptValueDic.TryGetValue(option, out var value) == false) return false;
        val = int.Parse(value);
    }
    else if (memberType == typeof(double))
    {
        if (OptValueDic.TryGetValue(option, out var value) == false) return false;
        val = double.Parse(value);
    }
    else if (memberType == typeof(string))
    {
        if (OptValueDic.TryGetValue(option, out var value) == false) return false;
        val = value;
    }
    else if (memberType == typeof(bool)) val = OptValueDic.TryGetValue(option, out _);
    else return false;

    return true;
}


今回、辞書を作る過程で「重複したオプションがあると例外が発生する」ようになっている。 もしここでそんなことが起きてほしくない場合は、別途メソッドを追加する必要がある。

また、値をキャストする「tryGetOptionValue」メソッドはキャスト対応が間に合っていない。 別に List 型だってやろうと思えばできるのである。しかし、今回は最低限として、

  • int
  • double
  • string
  • bool

とした。

memType はフィールド・プロパティのタイプのことである。このタイプに沿ってキャストする。 見てみるとわかるが、int や double は変換に失敗すると例外がでる。


今までの2機能のまとめ

今までの2機能を CommandLineParser クラスのコンストラクタで呼び出す。 これにより、 CommandLineParser クラスのインスタンスを作った時点で 「必須オプションのチェック」、「フィールド・プロパティに対する引数値の代入」が 完了することになる。

public CommandLineParser(Type targetType)
{
    _targetType = targetType;
    if (_targetType == null) throw new ArgumentNullException();
    if (isDefineCommandTargetAttr() == false) throw new Exception($"Type[{_targetType.Name}] does not have {nameof(CommandLineTargetClass)}.");

    Args = Environment.GetCommandLineArgs()[1..]; // First arg is EXE file name.

    IsError = chkCommadLine() == false;

    // implement checking args method after days ...

    setValueToPropAndField();
}

// CommandLineTargetClass 属性が存在するかどうか
bool isDefineCommandTargetAttr()
{
    return _targetType?.GetCustomAttributes(typeof(CommandLineTargetClass), CommandLineTargetClass.Inherited).Length != 0;
}


コンストラクタの引数は「CommandLineTargetClass」属性を付与したクラスの Type インスタンスである。 「CommandLineTargetClass」属性がついてるかどうかのチェックメソッドも追加した。



ヘルプメッセージを出力する機能

最後の機能はヘルプメッセージを出力する機能である。

「-h」とか指定すると表示されるやつ。

「CommandLineAttr」属性の引数にヘルプメッセージ用の string 型がいるのは、 ヘルプメッセージのフォーマットを固定にして、そのオプションの説明を指定できるようにしたかったから。

そしてこれをプロパティで取得できるようにすることで、デバッグ時にコピペできるようにもする。

出力する情報は以下のとおり。

  • ツール名や会社名、説明などアセンブリに登録された情報
  • 「usage : ...」ってやつ
  • 必須オプションだけは usage の時に出力する
  • 必須じゃないオプションは「[ options ... ]」でひとまとめにする
  • 各オプションのヘルプメッセージ
  • ヘルプ表示するにはどのオプションを指定するか
// ヘルプメッセージのプロパティ
public string HelpMessage
{
    get
    {
        var sb = new StringBuilder();

        sb.AppendLine(getAssemblyInfo());

        sb.Append($"usage: {getAssemblyName()} ");
        var allAttrs = CommandLineAttrList;
        var reqOpts = allAttrs.Where(x => x.Required).Select(x => $"{c_Prefix}{x.CLOptionName}").ToList();
        if (reqOpts.Count != 0)
        {
            foreach (var reqOpt in reqOpts)
            {
                sb.Append($"{reqOpt} {{ param }} ");
            }
        }
        sb.AppendLine($"[ options ... ]");

        sb.AppendLine();

        foreach (var attr in allAttrs.OrderBy(x => x.CLOptionName.Length))
        {
            sb.AppendLine($"{c_Prefix}{attr.CLOptionName}\t: {attr.HelpMessage}");
        }

        sb.AppendLine();

        sb.AppendLine("Display this message.");
        sb.AppendLine($"{c_Prefix}h");
        sb.AppendLine($"{c_Prefix}help");

        return sb.ToString();
    }
}

// アセンブリに設定された「ツール名」「バージョン」「会社名」「著作権」「ツールの説明」を抜き出して文字列にするメソッド
string getAssemblyInfo()
{
    var assembly = Assembly.GetAssembly(_targetType);
    if (assembly == null) return string.Empty;

    var sb = new StringBuilder();
    var fvi = FileVersionInfo.GetVersionInfo(assembly.Location);

    // FileDescription is Title.
    sb.AppendLine($"{fvi.FileDescription} {fvi.FileVersion} {fvi.CompanyName} {fvi.LegalCopyright}");
    sb.AppendLine(fvi.Comments);

    return sb.ToString();
}

// アセンブリに設定されたアプリケーションの名前を取得するメソッド
string getAssemblyName()
{
    var assembly = Assembly.GetAssembly(_targetType);
    if (assembly == null) return string.Empty;
    return assembly.GetName().Name;
}


メッセージは StringBuilder を使って作る。

いろんなツールでこの機能が使えないと不便なので、 アセンブリから会社名とかを抜き出し、 StringBuilder に格納する。 これにより、適宜アセンブリを設定するだけでヘルプメッセージが変わってくれるようになる。 これでツール説明部分が完了である。

次に、これまでに作った「CommandLineAttrList」プロパティを使って「CommandLineAttr」属性を全部抜き出す。 そこから、必須属性だけを抜き出し、それをメッセージ用の StringBuilder に追加してやる。 あとはサクッと「[ options ... ]」を追加してやって usage 部分は完了。

次に、各オプションのヘルプメッセージ部分。 「CommandLineAttrList」プロパティで全部抜き出してるんで、それで foreach を回し、ヘルプメッセージ参照するだけである。

今回は長さでソートしてみた。本当はアルファベット順とか、必須のやつは先頭とか別のソートのがいいのかもしれない。 必須のオプションとそうでないオプションをグループ分けして表示するのもよさそうである。 ここはすごい改良の余地が残る。

最後にヘルプメッセージ表示のためのオプションを足してヘルプメッセージは完了である。



ヘルプメッセージかどうかの判定

とっても簡単だが、指定オプションがヘルプかどうかのプロパティも載せておく。

public bool IsHelp
{
    get
    {
        if (Args == null || Args.Any() == false) return false;
        return Args.Any(x => x == $"{c_Prefix}h" || x == $"{c_Prefix}help");
    }
}



CommandLineParser の全体コード

全体コードも載せる。git にも上げてるけど、ブログはブログで完結しててほしいんで。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;

namespace CLOptionLib
{
    /// <summary>
    /// command line parser
    /// </summary>
    public sealed class CommandLineParser
    {
        #region private member
        Type _targetType;
        const string c_Prefix = "-";
        readonly string _pleaseHelp = $"\n\nPlease reference help that you specify \" {c_Prefix}h or {c_Prefix}help\"";
        Dictionary<string, string> _optValueDic;
        List<CommandLineAttr> _commandLineAttrList;
        #endregion

        #region property
        /// <summary>
        /// Arguments
        /// </summary>
        public IEnumerable<string> Args { get; }
        /// <summary>
        /// Did Command Line Error occur.
        /// </summary>
        public bool IsError { get; private set; }
        /// <summary>
        /// error message
        /// </summary>
        public string ErrorMessage { get; private set; }
        /// <summary>
        /// exist help command
        /// </summary>
        public bool IsHelp
        {
            get
            {
                if (Args == null || Args.Any() == false) return false;
                return Args.Any(x => x == $"{c_Prefix}h" || x == $"{c_Prefix}help");
            }
        }
        /// <summary>
        /// help message
        /// </summary>
        public string HelpMessage
        {
            get
            {
                var sb = new StringBuilder();

                sb.AppendLine(getAssemblyInfo());

                sb.Append($"usage: {getAssemblyName()} ");
                var allAttrs = CommandLineAttrList;
                var reqOpts = allAttrs.Where(x => x.Required).Select(x => $"{c_Prefix}{x.CLOptionName}").ToList();
                if (reqOpts.Count != 0)
                {
                    foreach (var reqOpt in reqOpts)
                    {
                        sb.Append($"{reqOpt} {{ param }} ");
                    }
                }
                sb.AppendLine($"[ options ... ]");

                sb.AppendLine();

                foreach (var attr in allAttrs.OrderBy(x => x.CLOptionName.Length))
                {
                    sb.AppendLine($"{c_Prefix}{attr.CLOptionName}\t: {attr.HelpMessage}");
                }

                sb.AppendLine();

                sb.AppendLine("Display this message.");
                sb.AppendLine($"{c_Prefix}h");
                sb.AppendLine($"{c_Prefix}help");

                return sb.ToString();
            }
        }
        /// <summary>
        /// command line attr of target type
        /// </summary>
        List<CommandLineAttr> CommandLineAttrList
        {
            get
            {
                if (_commandLineAttrList != null) return _commandLineAttrList;
                _commandLineAttrList = getCLAttrs().ToList();
                return _commandLineAttrList;
            }
        }
        /// <summary>
        /// Key: option
        /// <para>Value: argument value</para>
        /// </summary>
        Dictionary<string, string> OptValueDic
        {
            get
            {
                if (_optValueDic != null) return _optValueDic;
                _optValueDic = new Dictionary<string, string>();
                var opts = CommandLineAttrList.Select(x => $"{c_Prefix}{x.CLOptionName}").ToList();
                var beforeOpt = string.Empty;
                foreach (var arg in Args)
                {
                    if (opts.Contains(arg))
                    {
                        // if options are duplicated, Exception occur.
                        _optValueDic.Add(arg, string.Empty);
                        beforeOpt = arg;
                    }
                    else
                    {
                        _optValueDic[beforeOpt] = arg;
                    }
                }
                return _optValueDic;
            }
        }
        #endregion

        #region Get assembly name
        /// <summary>
        /// Get assembly name
        /// </summary>
        /// <returns>assembly name of exe</returns>
        string getAssemblyName()
        {
            var assembly = Assembly.GetAssembly(_targetType);
            if (assembly == null) return string.Empty;
            return assembly.GetName().Name;
        }
        #endregion

        #region Get assembly info
        /// <summary>
        /// Get assembly info.
        /// <para>Tool Title</para>
        /// <para>Tool Version</para>
        /// <para>CompanyName</para>
        /// <para>LecalCopyright</para>
        /// <para>Comments(tool description)</para>
        /// </summary>
        /// <returns>string : {title version company legalCopyright \n comments}</returns>
        string getAssemblyInfo()
        {
            var assembly = Assembly.GetAssembly(_targetType);
            if (assembly == null) return string.Empty;

            var sb = new StringBuilder();
            var fvi = FileVersionInfo.GetVersionInfo(assembly.Location);

            // FileDescription is Title.
            sb.AppendLine($"{fvi.FileDescription} {fvi.FileVersion} {fvi.CompanyName} {fvi.LegalCopyright}");
            sb.AppendLine(fvi.Comments);

            return sb.ToString();
        }
        #endregion

        #region ctor
        /// <summary>
        /// ctor
        /// </summary>
        /// <param name="targetType">Type with specified CommandLineTargetClass Attribute</param>
        /// <exception cref="ArgumentNullException"/>
        /// <exception cref="Exception"/>
        public CommandLineParser(Type targetType)
        {
            _targetType = targetType;
            if (_targetType == null) throw new ArgumentNullException();
            if (isDefineCommandTargetAttr() == false) throw new Exception($"Type[{_targetType.Name}] does not have {nameof(CommandLineTargetClass)}.");

            Args = Environment.GetCommandLineArgs()[1..]; // First arg is EXE file name.

            IsError = chkCommadLine() == false;

            // implement checking args method after days ...

            setValueToPropAndField();
        }
        #endregion

        #region Check having CommandLineTargetClass Attribute
        /// <summary>
        /// check having CommandLineTargetClass Attribute.
        /// </summary>
        /// <returns>true: has CommandLineTargetClass, false: does not have CommandLineTargetClass</returns>
        bool isDefineCommandTargetAttr()
        {
            return _targetType?.GetCustomAttributes(typeof(CommandLineTargetClass), CommandLineTargetClass.Inherited).Length != 0;
        }
        #endregion

        #region Check Command Line Options
        /// <summary>
        /// Check Command Line Options.
        /// </summary>
        /// <returns>true: OK, false: NG</returns>
        bool chkCommadLine()
        {
            // check required option 
            if (chkRequired(CommandLineAttrList) == false) return false;

            return true;
        }
        #endregion

        #region Get Command Line Attrs
        /// <summary>
        /// Get Command Line Attrs of MemberInfo
        /// </summary>
        /// <returns>Command Line Attr Objects</returns>
        IEnumerable<CommandLineAttr> getCLAttrs()
        {
            foreach (var mem in getAllCommandLineMember())
            {
                yield return mem.GetCustomAttribute(typeof(CommandLineAttr), CommandLineAttr.Inherited) as CommandLineAttr;
            }
        }
        #endregion

        #region Get Command Line Member
        /// <summary>
        /// Get Command Line Member.
        /// </summary>
        /// <returns></returns>
        IEnumerable<MemberInfo> getAllCommandLineMember()
        {
            var memlist = new List<MemberInfo>();
            memlist.AddRange(_targetType.GetRuntimeFields());
            memlist.AddRange(_targetType.GetRuntimeProperties());

            foreach (var member in memlist)
            {
                if (member.GetCustomAttribute(typeof(CommandLineAttr), CommandLineAttr.Inherited) == null) continue;
                yield return member;
            }
        }
        #endregion

        #region check required option
        /// <summary>
        /// Check required option
        /// </summary>
        /// <param name="allCommandLineAttrs">All Command Line Attrs</param>
        /// <returns>true: OK, false: NG</returns>
        bool chkRequired(IEnumerable<CommandLineAttr> allCommandLineAttrs)
        {
            foreach (var optName in allCommandLineAttrs.Where(x => x.Required).Select(x => $"{c_Prefix}{x.CLOptionName}"))
            {
                if (Args.Contains(optName) == false)
                {
                    ErrorMessage = $"Error: Option {optName} is not specified.{_pleaseHelp}";
                    return false;
                }
            }
            return true;
        }
        #endregion

        #region set value to property and field
        /// <summary>
        /// set value to property and field
        /// </summary>
        void setValueToPropAndField()
        {
            foreach (var mem in getAllCommandLineMember())
            {
                var option = $"{c_Prefix}{(mem.GetCustomAttribute(typeof(CommandLineAttr)) as CommandLineAttr).CLOptionName}";

                Type memType;
                if (mem is PropertyInfo pi)
                {
                    memType = pi.PropertyType;
                    if (tryGetOptionValue(memType, option, out var newVal) == false) continue;
                    pi.SetValue(null, newVal);
                }
                else if (mem is FieldInfo fi)
                {
                    memType = fi.FieldType;
                    if (tryGetOptionValue(memType, option, out var newVal) == false) continue;
                    fi.SetValue(null, newVal);
                }
            }
        }
        #endregion

        #region option value after cast type
        /// <summary>
        /// option value after cast type.
        /// </summary>
        /// <param name="memberType">member(property or field) type</param>
        /// <param name="option">option name(Key of OptValueDic)</param>
        /// <param name="val">out value</param>
        /// <returns>true: success getting, false: fail getting</returns>
        bool tryGetOptionValue(Type memberType, string option, out object val)
        {
            val = null;
            // implement other type after days ...
            if (memberType == typeof(int))
            {
                if (OptValueDic.TryGetValue(option, out var value) == false) return false;
                val = int.Parse(value);
            }
            else if (memberType == typeof(double))
            {
                if (OptValueDic.TryGetValue(option, out var value) == false) return false;
                val = double.Parse(value);
            }
            else if (memberType == typeof(string))
            {
                if (OptValueDic.TryGetValue(option, out var value) == false) return false;
                val = value;
            }
            else if (memberType == typeof(bool)) val = OptValueDic.TryGetValue(option, out _);
            else return false;

            return true;
        }
        #endregion
    }
}



まとめ

属性でできることや、自作属性の作り方、また自作属性を使ったコマンドラインオプションをまとめた。 属性を使ってできることはもっともっとあるだろうし、理解しておくとすごいできることが広がる(と思うのでまとめて考えを整理)。


ただ注意したいのは、今回作った CommandLineParser クラスは肝心要の「引数で指定した値のチェック」や 「重複オプションのチェック」などはしていない。それらは適宜追加が必要である。 ただ、これも列挙型を作り「このコマンドラインオプションに来る値の種類」を指定できるようにすると、 「引数で指定した値のチェック」もできるようになる。

例えば、ファイルの存在有無とか。

また、最近では「runコマンドに対するヘルプ」といった単位でヘルプを参照できる。 「docker run -h」などで「run のヘルプ」が見れるといった形である。 こういったモダンな感じにするのも忘れてはいけないと思う。


以上で属性のまとめ。 結構長くなってしまった。 自分も使っていこうかなぁと考えながら作ったので、このコマンドラインオプションを認識するコードはgitから見れるという。

ダウンロードもしやすいようにリポジトリ作った。これでブランチも作りやすいね。

https://github.com/akadamario/CommandLineParser