🦁

複数プロジェクトのテンプレートを作成する (Visual Studio)

に公開

はじめに

Blazor Web App で、自分がよく使うパターンのテンプレを作りたかったので、やってみました。
試行錯誤の末なので、「これでできたみたい」ですが、「普通はこうだ」とか「これで正しい」とかではないかもしれません。

環境

  • Windows 11
  • Visual Studio Community 2022

やりたいこと

Visual Studioには、プロジェクトをテンプレートとしてエクスポートする機能があります。
この機能は、単一のプロジェクトに対して作用するため、複数のプロジェクトを含むソリューションの雛形は作れません。
例えば、Blazor Web Appの規定のテンプレートは、サーバ側とクライアント側の二つのプロジェクトを含んでいます。
そういった、複数のプロジェクトを内包するソリューションを生成するテンプレートを作りたいです。

具体的には、Blazor Web Appのテンプレをベースに、MudBlazorPetaPocoMySqlConnectorAuthentication.Googleを加えて、すぐ使えるようにしたものがほしいです。

解決方法

実は、そのままの表題の公式の資料があります。

https://learn.microsoft.com/ja-jp/visualstudio/ide/how-to-create-multi-project-templates?view=vs-2022

書いてあるとおりに進めればできるはずなのですが、判然としなかったり、アレンジしたかったりする部分があって、将来の再試行のために、自分なりの解釈をまとめておきます。

テンプレートの構造

  • テンプレートは、%HOMEPATH%\Documents\Visual Studio 2022\Templates\ProjectTemplates\に配置された.zipファイルです。
    • ~.zipファイルの名前は、UIに出てきません。
  • ~.zipファイルのルートには、必ず、ルート・テンプレート(~.vstemplate)が存在します。
    • ~.vstemplateファイルの名前は、UIに出てきません。
  • その他、ルートには、テンプレートが使用するリソースの他、ソリューションを構成するプロジェクトのフォルダが配置されます。
イメージ
%HOMEPATH%\Documents\Visual Studio 2022\Templates\ProjectTemplates\MudBlazorWebApp.zip
├─Project
│  ├─~
│  └─Project.vstemplate
├─Project.Client
│  ├─~
│  └─Project.Client.vstemplate
├─__TemplateIcon.png
└─MudBlazorWebApp.vstemplate

元プロジェクトの準備

テンプレート化したいソリューションを作成します。
ソリューションやプロジェクトの名前は後で変更するので、適当に無難なものにしておきます。

各プロジェクトのテンプレートをエクスポート

ソリューションに含まれているプロジェクトを個別に全てエクスポートします。
エクスポートすると、%HOMEPATH%\Documents\Visual Studio 2022\My Exported Templates.zipファイルが生成されます。(このフォルダは、ファイルの書き換えができません。編集して保存しても元に戻って残らないので注意が必要です。)
仮に、Project.zipProject.Client.zipを生成したものとします。

テンプレートの構成

  • テンプレのソースを格納するフォルダを用意します。
    • 仮にMudBlazorWebAppTemplateとします。
    • このフォルダは単なる入れ物です。フォルダ名はUIを含めて一切使われません。
  • 先ほどエクスポートした.zipファイルを、個別にフォルダを作って展開します。
    • このフォルダ名は、生成されるソリューションに反映されます。
    • ここでは、$safeprojectname$$safeprojectname$.Clientに展開します。
      • この$safeprojectname$は、この文字並びのまま使用します。

パラメータ

  • $~$はパラメータ(Parameters)と呼ばれ、テンプレートが実体化される際に、自動的に置き換えられます。
  • パラメータは、フォルダ名やファイル名の他、テンプレートファイル(~.vstemplate)とその中で指定したファイル内でも使用可能です。
  • $safeprojectname$は、ソリューション名に置き換わります。

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

ソリューションのテンプレート

  • ルート・テンプレートファイルを作ります。
    • 仮に、MudBlazorWebApp.vstemplateとします。
      • このファイル名はUIに現れません。
      • 単一プロジェクトの場合はType="Project"ですが、複数のプロジェクトを束ねるルートではType="ProjectGroup"とします。
MudBlazorWebApp.vstemplate
<VSTemplate Version="2.0.0" Type="ProjectGroup"
    xmlns="http://schemas.microsoft.com/developer/vstemplate/2005">
  <TemplateData>
    <Name>MudBlazor Web App with MySql via PetaPoco</Name>
    <Description>Blazor Web App + MudBlazor + PetaPoco + MySqlConnector</Description>
    <ProjectType>CSharp</ProjectType>
    <ProjectSubType>
    </ProjectSubType>
    <SortOrder>1000</SortOrder>
    <CreateNewFolder>true</CreateNewFolder>
    <DefaultName>MudBlazorWebApp</DefaultName>
    <ProvideDefaultName>true</ProvideDefaultName>
    <LocationField>Enabled</LocationField>
    <EnableLocationBrowseButton>true</EnableLocationBrowseButton>
    <CreateInPlace>true</CreateInPlace>
    <Icon>__TemplateIcon.png</Icon>
    <TemplateGroupID>Web</TemplateGroupID>
    <TemplateCategory>
      <BuildSystem>MSBuild</BuildSystem>
      <LanguageTag>Csharp</LanguageTag>
      <PlatformTag>Linux</PlatformTag>
      <PlatformTag>macOS</PlatformTag>
      <PlatformTag>Windows</PlatformTag>
      <ProjectTypeTag>Blazor</ProjectTypeTag>
      <ProjectTypeTag>Cloud</ProjectTypeTag>
      <ProjectTypeTag>Web</ProjectTypeTag>
    </TemplateCategory>
  </TemplateData>
  <TemplateContent>
    <ProjectCollection>
      <ProjectTemplateLink ProjectName="$safeprojectname$" CopyParameters="true">
        $safeprojectname$/Project.vstemplate
      </ProjectTemplateLink>
      <ProjectTemplateLink ProjectName="$safeprojectname$.Client" CopyParameters="true">
        $safeprojectname$.Client/Project.Client.vstemplate
      </ProjectTemplateLink>
    </ProjectCollection>
  </TemplateContent>
</VSTemplate>

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

  • タグNameDescriptionTemplateCategoryなどが、テンプレートの識別用にUIで使われます。
  • タグDefaultNameは、生成時に提示されるデフォルトのソリューション名になります。
  • アイコンは、.ico以外に.pngとかでもいけます。
  • タグProjectTemplateLinkには、フォルダに展開した各プロジェクトの~.vstemplateへの相対パスを記載します。
    • この~.vstemplateのファイル名は、ここに書かれるパスと一致していれば何でも構わず、任意に分かり易い名前に変更できます。
    • ProjectName="~"には、実際に生成されるプロジェクトの名前を記載します。
      • ここでは、$safeprojectname$を使うことで、ソリューション名に関連付けています。
    • CopyParameters="true"を指定することで、ルートのパラメータが、プロジェクトのテンプレに伝達されます。
      • 子プロジェクト内では、$safeprojectname$は、自身のプロジェクト名になります。
        • 例えば、ルートで$safeprojectname$.Clientと表現していたクライアント側のプロジェクト名は、そのプロジェクトの中では、$safeprojectname$と記載するだけで、同じ文字列に置換されます。
      • 親から伝えられたパラメータにアクセスする際は、$ext_safeprojectname$のように、パラメータ名にext_を前置します。
      • ほとんどの場合は、$safeprojectname$で用が足りると思います。

プロジェクトのテンプレート

  • .zipから展開されている各プロジェクトフォルダ内のファイルを編集します。
  • まずは、プロジェクトの~.vstemplateを編集します。
Project.vstemplate
<VSTemplate Version="3.0.0" xmlns="http://schemas.microsoft.com/developer/vstemplate/2005" Type="Project">
  <TemplateData>
    <Name>$safeprojectname$</Name>
    <Description>Blazor Web App + MudBlazor + PetaPoco + MySqlConnector</Description>
    <ProjectType>CSharp</ProjectType>
    <ProjectSubType>
    </ProjectSubType>
    <SortOrder>1000</SortOrder>
    <CreateNewFolder>true</CreateNewFolder>
    <DefaultName>$safeprojectname$</DefaultName>
    <ProvideDefaultName>true</ProvideDefaultName>
    <LocationField>Enabled</LocationField>
    <EnableLocationBrowseButton>true</EnableLocationBrowseButton>
    <CreateInPlace>true</CreateInPlace>
    <Icon>__TemplateIcon.ico</Icon>
  </TemplateData>
  <TemplateContent>
    <Project TargetFileName="$safeprojectname$.csproj" File="$safeprojectname$.csproj" ReplaceParameters="true">
      <Folder Name="Properties" TargetFolderName="Properties">
        <ProjectItem ReplaceParameters="true" TargetFileName="launchSettings.json">launchSettings.json</ProjectItem>
      </Folder>
      <Folder Name="wwwroot" TargetFolderName="wwwroot">
        <ProjectItem ReplaceParameters="true" TargetFileName="app.css">app.css</ProjectItem>
        <ProjectItem ReplaceParameters="false" TargetFileName="favicon.png">favicon.png</ProjectItem>
      </Folder>
      <Folder Name="Components" TargetFolderName="Components">
        <Folder Name="Layout" TargetFolderName="Layout">
          <ProjectItem ReplaceParameters="false" TargetFileName="MainLayout.razor">MainLayout.razor</ProjectItem>
          <ProjectItem ReplaceParameters="true" TargetFileName="NavMenu.razor">NavMenu.razor</ProjectItem>
        </Folder>
        <Folder Name="Pages" TargetFolderName="Pages">
          <ProjectItem ReplaceParameters="false" TargetFileName="AccessDenied.razor">AccessDenied.razor</ProjectItem>
          <ProjectItem ReplaceParameters="false" TargetFileName="Error.razor">Error.razor</ProjectItem>
          <ProjectItem ReplaceParameters="false" TargetFileName="Home.razor">Home.razor</ProjectItem>
          <ProjectItem ReplaceParameters="false" TargetFileName="Weather.razor">Weather.razor</ProjectItem>
        </Folder>
        <ProjectItem ReplaceParameters="true" TargetFileName="_Imports.razor">_Imports.razor</ProjectItem>
        <ProjectItem ReplaceParameters="true" TargetFileName="App.razor">App.razor</ProjectItem>
        <ProjectItem ReplaceParameters="false" TargetFileName="Routes.razor">Routes.razor</ProjectItem>
      </Folder>
      <Folder Name="Services" TargetFolderName="Services">
        <ProjectItem ReplaceParameters="true" TargetFileName="$safeprojectname$DataSet.cs">$safeprojectname$DataSet.cs</ProjectItem>
        <ProjectItem ReplaceParameters="true" TargetFileName="$safeprojectname$AppModeService.cs">$safeprojectname$AppModeService.cs</ProjectItem>
      </Folder>
      <ProjectItem ReplaceParameters="true" TargetFileName="appsettings.json">appsettings.json</ProjectItem>
      <ProjectItem ReplaceParameters="true" TargetFileName="appsettings.Development.json">appsettings.Development.json</ProjectItem>
      <ProjectItem ReplaceParameters="true" TargetFileName="Program.cs">Program.cs</ProjectItem>
      <ProjectItem ReplaceParameters="false" TargetFileName="revision.info">revision.info</ProjectItem>
    </Project>
  </TemplateContent>
</VSTemplate>
  • エクスポート時に適当に名付けた部分を、$ext_safeprojectname$$safeprojectname$に置き換えます。
    • 子プロジェクトフォルダ内の$safeprojectname$は、そのプロジェクトの名前に置き換えられます。
    • ソリューション名として、テンプレ・ルートの$safeprojectname$に置き換える場合は、$ext_safeprojectname$を使います。
  • ProjectItemタグで、対象ファイル内のパラメータを置き換える場合は、ReplaceParameters="true"が必要です。
  • 上記で、プロジェクトファイル(.csproj)の名前は自身のプロジェクト名に関連付けられています。
  • 以下の_Imports.razorファイル内では、プロジェクト名が名前空間に使われています。
Component/_Imports.razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using PetaPoco
@using Tetr4lab
@using $safeprojectname$
@using $safeprojectname$.Client
@using $safeprojectname$.Components
@using $safeprojectname$.Components.Layout
@using $safeprojectname$.Components.Pages
@using $safeprojectname$.Data
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize (Policy = "Users")]

テンプレートのデプロイ

  • テンプレートが完成したら、テンプレートのルートフォルダを開き、テンプレートに含めるファイルを全て選択します。
  • ~.vstemplateファイルを右クリックして、コンテキストメニューから「圧縮先...」>「ZIPファイル」を選択します。
    • フォルダ内に~.zipが生成されます。
    • ルートフォルダそのものを圧縮しないように注意してください。
  • 生成された~.zipを切り取り、%HOMEPATH%\Documents\Visual Studio 2022\Templates\ProjectTemplates\(またはそのサブフォルダに)に貼り付けます。
  • Visual Studioを再起動して、「新規プロジェクトの作成」を選ぶと、「新規」テンプレートとして表示されるはずです。

テンプレートのキャッシュ

テンプレートはキャッシュされるようです。
テンプレの更新が反映されない場合は、いったんデプロイしたzipを削除してみましょう。

テンプレートの利用

規定のテンプレートと同様に、「新しいプロジェクトの作成」から、テンプレートを指定します。

書法の変化

テンプレートで自分なりの書法で記載していても、生成されたソリューションには反映されません。
以下に一例を示します。

$safeprojectname$DataSet.cs(テンプレート)
using PetaPoco;
using Tetr4lab;

namespace $safeprojectname$.Data;

/// <inheritdoc/>
public class $safeprojectname$DataSet : MySqlDataSet {
    /// <inheritdoc/>
    public $safeprojectname$DataSet (Database database, string? key = "database") : base (database, key) {
    }
    /// <inheritdoc/>
    public override Task<Result<bool>> GetListSetAsync () {
        //throw new NotImplementedException ();
        return Task.FromResult (new Result<bool> ());
    }
}

MudBlazorWebApp1DataSet.cs(生成後)
using PetaPoco;
using Tetr4lab;

namespace MudBlazorWebApp1.Data
{
    /// <inheritdoc/>
    public class MudBlazorWebApp1DataSet : MySqlDataSet
    {
        /// <inheritdoc/>
        public MudBlazorWebApp1DataSet(Database database, string? key = "database") : base(database, key)
        {
        }
        /// <inheritdoc/>
        public override Task<Result<bool>> GetListSetAsync()
        {
            //throw new NotImplementedException ();
            return Task.FromResult(new Result<bool>());
        }
    }
}

特に、namespaceのブロックが驚きですが、元々、内部的にはそのようになっているのでしょうね。

おわりに

テンプレートのパラメータやmsmakeの変数(.csproj内)などを使って一般化を進めると、ソリューション生成後の作業を減らせそうです。

最後までお読みいただきありがとうございました。

Discussion