LoginSignup
5
6

More than 1 year has passed since last update.

T4テンプレートでらくらくソースコード自動生成(類似クラス大量生成編)

Last updated at Posted at 2021-09-14

あるインターフェイスを実装するクラスをたくさん作りた~い!

皆さんはあるインターフェイスや抽象クラスを実装するクラスを大量に作りたいとき、どうやっていますか?
え、そんなことしない?
例えばこんなケースがあると思います!

  • WPF,WinFormsなどのGUIで、共通の機能を持つ複数の画面部品を作る場合

    • 例えばアプリのフッターにあるファンクションボタン。各ボタンは特定のインターフェイスを実装する個別のクラスとして作成することが多いです。
  • API名や定義から通信処理を自動実装したいとき

    • Excel設計書から自動実装してもいい(宣伝)んですが、APIの定義とコードは直接作成/編集し、APIリファレンスをXMLドキュメントで公開するというやり方もありますよね。Excelをいじらなくてよいぶん、体感ではこちらのほうが開発体験はいいです。

2~3種類ならコピペで手っ取り早く作ってもいいのですが、こういった部品郡はだいたい10や20を超えてくるので手実装するのは大変なわけです。しかも後から機能を生やしたり数を増やしたりしようとするともう手がつけられません!大量のクラスにコピペで修正を反映していくなんて地獄の作業はやりたくないですよね?
そこで、T4テンプレートを使って静的にソースコードを作ろう!というのが本稿の目的となります。

用意するもの

  • Visual Studio

どうやらVS2012くらいから使えるようですが、本稿ではVS2019で説明します。頑張ればVSCodeでも使えるらしいです。

デザイン時テキストテンプレート

テンプレートファイルの追加

プロジェクトを右クリックし、「新しい項目の追加」からテキスト テンプレートを選択します。似た名前にランタイム テキスト テンプレートというものがありますが、これは文字列生成するクラスを作るテンプレートです。お間違えないようにしてください。
image.png
追加すると以下のファイルが作られます。

TextTemplate1.tt
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

このテンプレートを追加すると、ソリューションエクスプローラーで追加したテンプレートファイル(.tt)と同名の.txtファイルが生えます。これが生成先のファイルになります。試しに適当な文字列を.ttファイルに記述して保存すると、自動で.txtファイルに内容が反映されます。
image.png
テンプレートファイルの末尾にあるoutput extensionパラメータを変更すると、.csファイルや.xmlファイルなど任意の拡張子での出力が可能です。コードを生成するなら.csや.vb等の適切な拡張子に設定しましょう。.csファイルにすると、生成されたコードは普通に作ったコードと同じように使ったりブレークポイントを置いたりすることができます。
ちなみに/を指定するとファイルが作られないようです。ファイル名に/が使えないからでしょうか?

なお、開発者が誤って自動生成したコードを編集しないように注意書きを書いておきましょう。出力内容の先頭に<auto-generated>タグでコメントを書いておけばよいでしょう。ただし編集しようと思えばできてしまうのでチーム内での教育/ルール決めもお忘れなく。

TextTemplate1.tt
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY FunctionButtons.tt
// DO NOT CHANGE IT.
// </auto-generated>

デザインの前に

デザインファイルを編集する前に、自動生成したいコードがどんなコードかを具体化しておきましょう。ゴールを先にコード化しておかないと、テンプレートデザイン上でどこが動的に変わる部分かがわからなくなります。
今回のゴールは以下とします。

  1. 目的
    1. WPFのMVVMアプリでファンクションボタンを個別にたくさん作る
  2. 前提条件
    1. MVVMフレームワークにPrismを使用する
    2. ボタンの処理はIFunctionButtonを実装する抽象クラスFunctionButtonBase<T>に実装する
    3. TはPrismのPubSubEventを継承したクリックイベントのクラス。これも、共通処理を抽象クラスClickEventBaseに実装する
  3. T4で作るゴールのコード
    1. 抽象クラスFunctionButtonBase<T>を継承する各ボタンのクラス
    2. 各ボタンが使用するClickEventBaseを継承したイベントクラス

前提条件を満たすコードの実装

先に必要なコードを書いておかないと、T4で自動生成してもコンパイルエラーになります。必要なIFunctionButton, FunctionButtonBase<T>, ClickEventBase を実装しておきます。

IFunctionButton.cs
namespace MyApp.FunctionButtons
{
    /// <summary>フッターに配置されたファンクションボタンの機能を規定するインターフェイスです。</summary>
    public interface IFunctionButton 
    {
        // 本当はViewを制御するためのIsEnabledプロパティ等を持っていますが省略
        /// <summary>
        /// ボタンのクリックイベントを発行します。ただし、ボタンが押下可能な状態でなければ何もしません。
        /// </summary>
        void Click();
    }
}
FunctionButtonBase.cs
using Prism.Events;
using Prism.Mvvm;
namespace MyApp.FunctionButtons
{
    /// <summary>
    /// フッターのファンクションボタンの大まかな共通機能を提供する抽象クラスです。
    /// </summary>
    public class FunctionButtonBase<TEvent> : BindableBase, IFunctionButton
        where TEvent : ClickedEventBase, new()
    {
        // 本当はClickに紐づくReactiveCommand等を持っていますが省略
        private readonly IEventAggregator _eventAggregator;
        internal FunctionButtonBase(IEventAggregator eventAggregator)
        {
            _eventAggregator = eventAggregator;
        }
        public void Click() 
        {
            _eventAggregator.GetEvent<TEvent>().Publish();
        }
    }
}

ClickEventBaseにはボタン押下時にどのファンクションボタンが押されたのかログ出力する仕組みを仕込んでおきます。各ボタン用の具象クラスはFunctionNameForLogプロパティを実装してログ出力時のボタン名を管理するものとしました。

ClickEventBase.cs
using Prism.Events;
namespace MyApp.FunctionButtons
{
    /// <summary>ファンクションボタンのクリックイベントを規定するクラスです。</summary>
    public abstract class ClickedEventBase : PubSubEvent
    {
        /// <summary>ロガー</summary>
        private static NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger();
        /// <summary>ログ出力に使用する機能の名称を取得します。/summary>
        public abstract string FunctionNameForLog { get; }
        /// <summary>ボタンクリック時イベントを発行します。</summary>
        public override void Publish()
        {
            Logger.Info($"[ファンクションボタン押下] {FunctionNameForLog}");
            base.Publish();
        }
    }
}

最終的に、各ボタンごとに以下のコードを作ることをゴールとします。

AnyButtons.cs
using System;
using Prism.Events;
namespace MyApp.FunctionButtons
{
    /// <summary>ほげボタン</summary>
    public class HogeButton : FunctionButtonBase<HogeButtonClickedEvent>
    {
        internal HogeButton (IEventAggregator eventAggregator) : base(eventAggregator) { }
    }
    /// <summary>ほげボタンクリックイベント</summary>
    public class HogeButtonClickedEvent : ClickedEventBase
    {
        public override string FunctionNameForLog => "ほげ";
    }
}

デザイン

完成形のコードが出来たなら、後は完成形のうちどこが動的に変わるのかを抜き出してT4テンプレートのデザインに落とし込むだけです。
上のコードで考えると、以下が変わる内容になります。

  1. ボタンクラスの<summary>タグ内の「ほげ」
  2. ボタンクラス名のHoge
  3. FunctionButonBase<T>Tに指定するクリックイベントクラスのHoge
  4. コンストラクタのHoge
  5. クリックイベントクラスの<summary>タグ内の「ほげ」
  6. クリックイベントクラス名のHoge
  7. FunctionNameForLogプロパティが返す値の「ほげ」

つまり、「ボタン名のHoge」と「ボタンを説明するテキストのほげ」が動的に変わる部分であることがわかります。この2パラメータをボタンの数だけループさせればよいことになります。
デザイナ上で変数等を定義したい場合、<# ~~ #>ブロックを使用可能です。また、関数や構造体など内部で使用したいコードはテンプレートファイルの末尾に<#+ ~~ #>ブロックを記述することで実現可能です。具体例を以下に示します。2パラメータを構造体とし、配列で作るボタンの数を管理しています。
なお、<# ~~ #>にはFunc<T>型で関数を定義したりLinQを使って変数をセットするなど大概のC#コードがかけるのですが、タプルが使えなかったりするなど、よくわからないことになっています。

FunctionButtonTemplate.tt
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
    // ファンクションボタン定義。"更新"等はログに出す以外で使わないので日本語べた書きとする
    var buttons = new [] {
        new ButtonType("Reload","更新"),
        new ButtonType("Previous","1つ戻る"),
        new ButtonType("Next","1つ次へ"),
                      ︙
        new ButtonType("Exit","前画面に戻る/アプリケーション終了"),
    };
#>
<#+
    internal struct ButtonType { 
        public string Name; public string LogText;
        public ButtonType(string a, string b) { Name = a; LogText = b; }
    }
#>

ここまでできたらbuttons配列をループで回してコード生成するのみです。先のコードと一部重複しますが、実際に作ったテンプレートファイルをお見せします。Qiitaではハイライトが効かないので一見するとわかりにくいですが、やっているのは単純にループしながら変わるところに値を埋め込んでいるだけです。なお、内部でIFunctionButtonの型制約をかけたジェネリックメソッドで使い倒しているところがあるため、空のコンストラクタもあわせて作っています。こういった機能拡張をデザイナの修正だけでできるのが自動生成の強みですね。

FunctionButtonTemplate.tt
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
    // ファンクションボタン定義。"更新"等はログに出す以外で使わないので日本語べた書きとする
    var buttons = new [] {
        new ButtonType("Reload","更新"),
        new ButtonType("Previous","1つ戻る"),
        new ButtonType("Next","1つ次へ"),
                      ︙
        new ButtonType("Exit","前画面に戻る/アプリケーション終了"),
    };
#>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY FunctionButtons.tt
// DO NOT CHANGE IT.
// </auto-generated>
using System;
using System.ComponentModel;
using System.Windows;
using System.Reactive.Linq;
using Prism.Events;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
namespace MyApp.FunctionButtons
{
<# foreach (var button in buttons) {#>
    /// <summary><#= button.LogText#>ボタン</summary>
    public partial class <#=button.Name#>Button : FunctionButtonBase<<#=button.Name#>ButtonClickedEvent>
    {
        internal <#=button.Name#>Button(IEventAggregator eventAggregator) : base(eventAggregator) { }
        /// <summary>このコンストラクタはジェネリック制約のために生成しています。使用しないでください。</summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        [Obsolete("このコンストラクタはジェネリック制約のために自動生成しています。使用しないでください。", true)]
        public <#=button.Name#>Button() : base(null!) { }
    }
    /// <summary><#= button.LogText#>ボタンクリックイベント</summary>
    public partial class <#=button.Name#>ButtonClickedEvent : FunctionButtonClickedEventBase
    {
        public override string FunctionNameForLog => "<#=button.LogText#>";
    }
<#}#>
}
<#+
    internal struct ButtonType { 
        public string Name; public string LogText;
        public ButtonType(string a, string b) { Name = a; LogText = b; }
    }
#>

できあがるのは以下になります。

FunctionButtonTemplate.cs
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY FunctionButtons.tt
// DO NOT CHANGE IT.
// </auto-generated>
using System;
using System.ComponentModel;
using System.Windows;
using System.Reactive.Linq;
using Prism.Events;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
namespace MyApp.FunctionButtons
{
    /// <summary>更新ボタン</summary>
    public partial class ReloadButton : FunctionButtonBase<ReloadButtonClickedEvent>
    {
        internal ReloadButton(IEventAggregator eventAggregator) : base(eventAggregator) { }
        /// <summary>このコンストラクタはジェネリック制約のために生成しています。使用しないでください。</summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        [Obsolete("このコンストラクタはジェネリック制約のために自動生成しています。使用しないでください。", true)]
        public ReloadButton() : base(null!) { }
    }
    /// <summary>更新ボタンクリックイベント</summary>
    public partial class ReloadButtonClickedEvent : FunctionButtonClickedEventBase
    {
        public override string FunctionNameForLog => "更新";
    }
    /// <summary>1つ戻るボタン</summary>
    public partial class PreviousButton : FunctionButtonBase<PreviousButtonClickedEvent>
    {
        internal PreviousButton(IEventAggregator eventAggregator) : base(eventAggregator) { }
        /// <summary>このコンストラクタはジェネリック制約のために生成しています。使用しないでください。</summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        [Obsolete("このコンストラクタはジェネリック制約のために自動生成しています。使用しないでください。", true)]
        public PreviousButton() : base(null!) { }
    }
    /// <summary>1つ戻るボタンクリックイベント</summary>
    public partial class PreviousButtonClickedEvent : FunctionButtonClickedEventBase
    {
        public override string FunctionNameForLog => "1つ戻る";
    }
    /// <summary>1つ次へボタン</summary>
    public partial class NextButton : FunctionButtonBase<NextButtonClickedEvent>
    {
        internal NextButton(IEventAggregator eventAggregator) : base(eventAggregator) { }
        /// <summary>このコンストラクタはジェネリック制約のために生成しています。使用しないでください。</summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        [Obsolete("このコンストラクタはジェネリック制約のために自動生成しています。使用しないでください。", true)]
        public NextButton() : base(null!) { }
    }
/* 以下略 実際にはこれが15クラスぶんあります */
}

デザイン時生成したコードの修正について

生成されるのはあくまでファイルなので、生成したコードを直接編集することも可能です。これを活用すると、コードを編集して動きを確認してからT4のデザインを変更する、といったことができます。これはT4テンプレートが静的にコード生成するタイミングが以下になるためです。

  1. .ttファイルを編集して保存したとき
  2. メニューバーから[ビルド]→[すべての T4テンプレートの変換]を実行したとき
  3. ソリューションエクスプローラーから.ttファイルを右クリックして[カスタム ツールの実行]を選択したとき

当たり前ですが.ttファイルを保存した瞬間に直接編集した内容は消えるので、自動生成したコードは原則いじらないようにしましょう。手実装するコードの雛形を作りたい、というようなケースなら自動生成後にリファクタリング機能でクラスごとに別ファイルに抜き出すことを推奨します。あるいは、自動生成するクラスやメソッドをpartialにして機能拡張を別ファイルで行う方法も考えられます。

自動生成したコードのテスト

自動生成したコードそのもののテストは私は行っていません。理由は以下によります。

  1. 生成したコードがいなくなることがありえる
    1. 本稿の例ではそのようなケースはないですが、buttons配列から消せば生成されなくなります。そのようなコードをテスト対象にすることは不適切です。
  2. 生成したコードそのものに機能をもたせることが少ない
    1. 本稿の例ではボタンが持つべき機能はすべてIFunctionButtonに規定されており、すべての実装はFunctionButtonBase<T>TとなるClickEventBaseが担っています。ということはこれらのクラスをテストすればよいため、各コードのテストはしていません。
    2. 大量生成するコードに大量の実装をもたせても見通しが悪くなるのも理由の一つです。

まとめ

Excel設計書から作る場合と比べて(宣伝)、すべての作業がVisual Studio内で完結するので、このテンプレートを作る作業は以外と短いです。T4の制御構文(<# for(~~~) { } #>とか<# if (~~~){ } #>)に慣れると1日程度で完成形まで持ってこれると思います。

コピペは便利ですが、一度修正があるとすべてのコードも修正内容をコピペして回らないといけないなど、すごく大変だと思います。正直私はコピペコーディングは悪くらいの気持ちでやっている1ので、テキストテンプレートを活用することで品質の担保サボり作業の効率化を行っています。皆さんもぜひテキストテンプレートを活用して定時帰りしましょう!
よいT4テンプレートライフを!


  1. コピペする量と回数にもよります。今回の例もボタンの数が十分少ないならコピペして回るほうが工数が短いです。何回も生成するかどうか/機能の修正・拡張がありそうかどうか/生成対象を増やすかどうか、でコピペコーディングするか自動生成するかを決めるとよいと思います。 

5
6
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6