5
9

More than 1 year has passed since last update.

T4テンプレートでらくらくソースコード自動生成(Excel設計書からの動的出力編)

Last updated at Posted at 2021-09-13

設計書から自動実装した~い!

皆さんはDBやAPIの設計書をどのように管理されていますか?残念ながら弊社ではExcel方眼紙で管理しています。
Excelで管理するということは、Excelを見ながらテーブル定義やら通信処理やらを実装しないといけないということ・・・とても面倒くさいですよね。
何が面倒くさいっていうと、

  • 設計書から作るコードなんてだいたい定形(ボイラープレートコード)なので、手実装だとコピペが多発する→コピペミスで大惨事に
  • 修正やら拡張やらのたびにソースコードだけ直して設計書を直さない人が出てきて実装と設計が乖離する
  • VBAで楽しようとしても開発しにくいしソース管理での差分も取りにくい。最終的に修正前後をコメントアウトしてModify start yyyy/MM/ddとかやってしまう
  • セキュリティの都合でマクロ付きブックをお客様に送付できなかったりする

正直に言って設計書に従った実装なんてものは開発の本質部分ではないので、こんなものに労力を費やしたくないわけですね。そこで、T4テンプレートを使って動的にソースコードを作ろう!というのが本稿の目的となります。

用意するもの

  • 書き方がルール決めされているExcelの設計書
  • Visual Studio 2017 / 2019 (本稿では2019で説明しますが、2017でも大差ありません)

当たり前ですが、Excelの設計書はどんなルールでもよいので、どの列/シートになんの値が入っているのか統制してください。そうでないと、設計内容の解釈のしようがありません。

自動生成プログラム実装手順

生成プログラムのプロジェクトを作る

コンソールプログラムでもWinFormsでもなんでもよいです。

  1. 作ったプログラムをVBAやらVisual Studio拡張やらにしたい場合はコンソールプログラム
  2. 実際に使う人がとっつきやすいようにしたいならWinFormsやWPFなどのGUI

私の場合、設計書が内部資料かお客様にも渡すものかで決めています。例えば前者ならプログラムをブックのVBAからキックしたいのでコンソール、後者ならお客様が自動生成できる必要はないのでGUIにしています。

ClosedXMLをインストール

パッケージマネージャ コンソールで以下のコマンドを実行し、ClosedXMLをプロジェクトにインストールします。ClosedXMLはMITライセンスです。

Install-Package ClosedXML

ようはExcelブックが読めればいいのでCOMオブジェクト経由でもいいのですが、そうするとプログラムの動作が実行者のPCにインストールされたExcelのバージョンに依存しますので、ClosedXML等のライブラリを使用するほうがよいでしょう。

ブックから設計内容を読み込む

Excel設計書のルールに従って設計内容を読み込みます。以下の例は1行1列からデータを読み取っていくサンプルコードです。戻り値がIEnumerable<string>になっていますが、適当な構造体やrecordを定義してそれを返してやればよいでしょう。

Book.cs
using System.Collections.Generic;
using ClosedXML.Excel;
namespace T4RuntimeSample
{
    public class Book
    {
        public IEnumerable<string> Load(string bookPath, string sheetName)
        {
            // ブックをオープンする 別のプロセスから開いてたりするとException
            using (var workbook = new XLWorkbook(bookPath))
            {
                // シートをオープンする
                var sheet = workbook.Worksheet(sheetName);
                // 行、列のインデックスは1始まり
                int row = 1;
                int col = 1; // 列は "A","B"などの文字列指定でもOK
                while (true)
                {
                    // セルから文字列を読む
                    yield return sheet.Cell(row++, col++).GetString().Trim();
                }
            }
        }
    }
}

T4テンプレートを作る

いよいよ本題のテンプレート作成です。プロジェクトを右クリックし、「新しい項目の追加」からランタイム テキスト テンプレートを選択します。
image.png

他に「テキスト テンプレート」というものがありますが、これはデザイン時コード生成という静的にコードを作成するテンプレートです。こちらの活用方法は本稿では説明しません。

追加すると以下のファイルが追加されます。

RuntimeTextTemplate1.tt
<#@ template language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>

ランタイムテキストテンプレートについて

ランタイムテキストテンプレートは簡単に説明すると、デザインファイル(.tt)に則って文字列を出力するクラスを作ってくれるテンプレートエンジンです。つまり、実際のテキストを出力するには作られたクラスをnewして文字列生成メソッド(TransformText())を実行する必要があります。
「ランタイム」のついていない方はクラスを作るのでなくテキストファイルをそのまま出力します。イメージとしてはWinFormsやWPFでデザイナをいじると.designer.csや.xamlが勝手に変更されていくイメージに近いです(T4のデザイナをいじるとコードもかわるのでデザイン時コード生成)。
Excel設計書の内容をT4のデザイナに書くのではなく、設計書をロードした内容を使って動的にコード生成したいからランタイムテキストテンプレートを使用するというわけです。

しかし、どういうわけかランタイムテキストテンプレート(TextTemplatingFilePreprocessor)を追加してもデザイン時コード生成(TextTemplatingFileGenerator)扱いでプロジェクトに追加されるようです。そのため、.csprojを以下のように手修正する必要があります。

変更前.csproj
  <ItemGroup>
    <None Update="RuntimeTextTemplate1.tt">
      <Generator>TextTemplatingFileGenerator</Generator> <!-- この行を -->
      <LastGenOutput>RuntimeTextTemplate1.cs</LastGenOutput>
    </None>
  </ItemGroup>
変更後.csproj
  <ItemGroup>
    <None Update="RuntimeTextTemplate1.tt">
      <Generator>TextTemplatingFilePreprocessor</Generator> <!-- これに変える -->
      <LastGenOutput>RuntimeTextTemplate1.cs</LastGenOutput>
    </None>
  </ItemGroup>

.csprojを変更して保存すると、以下のように.ttファイルの下に.csファイルが作られます。ファイル名と同名のpartialクラスが展開されており、TransformText()メソッドがいるのがわかると思います。
image.png

テンプレートをいじる前の準備

ここで、上図のオレンジ色部分を注目してください。ここにはなんと開発者の環境での.ttファイルのフルパスが記載されています。こんなものをソース管理にpushするわけにはいかないので、フルパスを出力しないように1行目にlinePragmas設定を追加します。

RuntimeTextTemplate1.tt
<#@ template language="C#" linePragmas="false"  #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>

また、ロードした設計書内容をテンプレートで使うためのクラスの定義が必要です。前の図の通り、T4テンプレートが作る文字列生成クラスはpartialクラスなので、別ファイルにpartialクラスを定義することで機能を拡張することが可能です。
間違ってもT4テンプレートが作ったクラスを直接編集しないでください。当たり前ですが、T4のデザイナを変更すると編集内容が消えます。

RuntimeTextTemplate1_partial.cs
namespace T4RuntimeSample
{
    public partial class RuntimeTextTemplate1
    {
        public ExcelData Data { get; } // ExcelDataは適当な構造体
        // コンストラクタを生やしたりpublicなsetter経由でデータを渡してやる
        public RuntimeTextTemplate1(ExcelData data)
        {
            Data = data;
        }

        // 設計内容をコードに変換する処理を書いたりする
        public string HogeConvert(ExcelData data) { return $"public {data.Type} {data.Name} {{ get; set; }}"; }
    }
}

デザインを編集する

あとは生成したいコードができるように.ttファイルを編集するだけです。基本的には.ttファイルに記述した通りに文字列が出力されるほか、以下の2つの制御記号でループしたり変数の内容を出力したりすることができます。注意点として、forなどの制御を書くときはインデントをしないことをおすすめします。インデント部分が空白文字として出力されてしまうためです。

記述法 内容
<# 制御 {#> ~ <#} #> forforeachなどのループ、ifによる条件分岐を行います。必ず { } でくくる必要があります。
<#= 変数名 #> 変数名の内容を出力します。

以下のコードは実際に作った.ttファイルです。このファイルの目的は「CsvHelperで読み込むCSVファイルのデータ構造を設計書からロードして自動生成する」でした。partialクラスには設計書のデータを保持するプロパティContextを実装しています。

MyTemplate.tt
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
using System.Collections.Generic;
using CsvHelper.Configuration.Attributes;
namespace MyImport.DataStructure
{
    /// <summary>
    /// <#= Context.ClassXmlComment #>
    /// </summary>
    public class <#= Context.ClassName #> : IImportable
    {
        /// <summary>データの列数</summary>
        public int ColumnCount { get => <#= Context.Def.Count() #>; }

        /// <summary>コンストラクタ</summary>
        public <#= Context.ClassName #>() { }

        /// <summary>エラー出力に使用するデータの行番号(0始まり)を取得または設定します。</summary>
        [Ignore]
        public int RowIndex { get; set; }
<# foreach(var def in Context.Def) {#>
        /// <summary><#= def.VarJpName #>
<# foreach(var s in def.Summary.Split(new string[] { "\r", "\n", "\r\n" }, StringSplitOptions.None)) { #>
        /// <para><#= s #></para>
<#}#>
        /// </summary>
        [Index(<#= def.Idx#>)]
        public string <#= def.VarName #> { get; set; }
<# } #>
    }
}

実際にできあがるのは以下のようなコードです。

MyImport.cs
using System.Collections.Generic;
using CsvHelper.Configuration.Attributes;
namespace MyImport.DataStructure
{
    /// <summary>
    /// CsvHelperで取り込んだデータを保持します。
    /// </summary>
    public class MyImport : IImportable
    {
        /// <summary>データの列数</summary>
        public int ColumnCount { get => 15; }

        /// <summary>コンストラクタ</summary>
        public MyImport() { }

        /// <summary>エラー出力に使用するデータの行番号(0始まり)を取得または設定します。</summary>
        [Ignore]
        public int RowIndex { get; set; }
        /// <summary>データ1
        /// <para>データ1かもね</para>
        /// </summary>
        [Index(0)]
        public string Data1 { get; set; }
        /// <summary>データ2
        /// <para>データ2だよ</para>
        /// </summary>
        [Index(1)]
        public string Data2 { get; set; }
        /// <summary>データ3
        /// <para>データ3だよ</para>
        /// </summary>
        [Index(2)]
        public string Data3 { get; set; }
        /// <summary>データ4
        /// <para>データ4だよ このデータは注意してね</para>
        /// </summary>
        [Index(3)]
        public string Data4 { get; set; }
    }
}

また、ランタイムテキストテンプレートは文字列生成クラスの生成なので、ソースコード以外の文字列も生成可能です。以下に、私が実際に作ったDIコンテナUnityの設定ファイルを出力するテンプレートを記載します。

UnitySetting.tt
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<configuration>
  <configSections>
    <section name="unity" type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection, Unity.Configuration"/>
  </configSections>

  <unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
<# foreach (var asm in Context.GetAssemblies()) { #>
    <assembly name="<#=asm#>" />
<#}#>
<# foreach (var ns in Context.GetNameSpaces()) { #>
    <namespace name="<#=ns#>" />
<#}#>

<# foreach (var c in Context.Settings) {#>
    <alias alias="I<#=c.ClassName#>" type="<#=c.InterfaceNameSpace#>.I<#=c.ClassName#>, DbLogicDefinitions" />
    <alias alias="<#=c.ClassName#>" type="<#=c.LogicNameSpace#>.<#=c.ClassName#>, <#=c.AssemblyName#>" />
<#}#>

    <container>
<# foreach (var context in Context.Settings) { #>
      <register type="I<#= context.ClassName#>" mapTo="<#= context.ClassName#>" />
<#}#>
    </container>
  </unity>
</configuration>

ソースコードを出力しよう

何度も繰り返しになりますが、ランタイムテキストテンプレートは文字列生成処理を行うクラスを生成するエンジンです。テンプレートに従った文字列を出力する処理と、その文字列をファイル等に保存する処理は自分で実装する必要があります。
コンソールアプリなら出力先パスを実行時引数で受け取り、GUIアプリなら画面から設定できるようにすればよいでしょう。

MyExport.cs
public void Export(ExcelData data, string path)
{
    // 文字列生成処理をnewして
    var template = new RuntimeTextTemplate1(data);
    // 文字列を出力して
    var text = template.TransformText();
    // ファイル出力する
    using (var sw = new StreamWriter(path, true, Encoding.UTF8))
    {
        sw.Write(text);
    }
}

まとめ

Excel設計書からソースコードを自動生成するには以下手順で行えます。

  1. Excel設計書を作る
  2. ClosedXMLでブックを読み取って、適当な構造体なりrecordなりに設計内容を保持する
  3. T4テンプレートの「ランタイム テキスト テンプレート」で出力内容をデザインする
  4. T4テンプレートから文字列を作ってファイルに出力する処理を実装する

もし設計書の仕様もゼロから作る場合、設計書のルールをすべて決められるとするならば、私の経験則としては3日程度かかる見込みです。内訳としては、

  1. 設計書を適当にデザインして
  2. 自動実装するコードをなんとなく決めて
  3. T4テンプレートをふんわりデザインして
  4. 設計書に項目が足りないことがわかって最初からやり直したり
  5. 開発者が使いにくそうなので実装されるコードを練り直したり
  6. T4にデザインしたコードが文法上間違っててコンパイルエラーになるので直したり

という作業を何度もやり直しながら完成形を作るためです。
ただ、設計書からのソースコード作成って、本当に何度でもやります。それこそ設計書の定義(デザイン・レイアウトともいう)が変わったりしなければ5年くらい使いまわしたりするのではないでしょうか。そう考えると、最初に自動生成プログラムを作っておくことで後がすごい楽になるわけですよね。しかも、

  1. 機械的に作るから品質が一定!
  2. 出力は一瞬でできる!
  3. 設計と実装が完全一致するため設計書が信頼できるドキュメントになる!

と、当たり前ですがすごいメリットを享受できるわけです。みなさんもExcelをどんどんT4テンプレートに食わせていきましょう!
よいT4テンプレートライフを!

おまけ

VBAからコンソールアプリをキックするにはこんな感じにするとよいです。

Output.vba
Sub 出力ボタン_Clicked()
    Dim arg1 As String
    arg1 = ThisWorkbook.FullName
    Dim path As String
    path = ActiveWorkbook.path & "\出力PG\Export.exe" ' 出力PGへのフルパス

    Dim obj As New IWshRuntimeLibrary.WshShell
    Call obj.Run("""" & path & """ """ & arg1 & """", 1, True)

    MsgBox "処理完了しました。"
End Sub

デザイン時テンプレートの活用の記事を書きました(宣伝)

5
9
0

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
9