На главную

Скрещиваем T4 и NuGet

15-05-2018

T4

Text Template Transofmaton Toolkit можно перевести на русский, как «инструментарий для преобразования текстовых шаблонов». Название объясняет не очень многое, зато красиво сокращается до TTTT. Традиционно вместо TTTT пишут просто T4.

С помощью T4 можно генерировать произвольные тексовые файлы, но чаще всего его используют для генерации кода на C# или VisualBASIC. Для чего это нужно? Иногда для того, чтобы перенести вычисления с времени выполнения программы на время её компиляции. Например, при реализации алгоритма Зиккурата мы можем заранее рассчитать функцию вероятностного распределения в точках разбиения.

Иногда кодогенерация позволяет «написать» десятки методов с простой структурой, которые в противном случае пришлось бы кодировать вручную.

Часто с помощью кодогенерации создают модели по описанию, например, Entity Framework умел генерировать DTO классы для таблиц, описанных в .edmx модели, когда поддерживал разработку по модели.

При сборке проекта Visual Studio (MSBuild) сначала генерирует из шаблонов файлы .cs, а затем компилирует их вместе с кодом, написанным вручную.

Шаблоны хранятся в файлах с расширинем .tt. Код шаблона содержит управляющие инструкции, то есть условия и циклы на языке C#, и текстовые вставки, из которых в конечном итоге и собирается программа. Похожим образом выглядит код на PHP или страница ASPX, только результатом работы кодогенератора является код на C#, а не на HTML.

NuGet

NuGet — пакетный менеджер для .NET проектов. Пакеты упрощают подключение внешних библиотек, и решают сопуствующие проблемы, например, проблему несовместимости версий.

Обычно в состав пакета входит исполняемый код, которые вызывает наша программа. Но иногда пакет может содержать только ресурсы, как, например, jQuery, где нет ничего кроме файлов .js.

Обратим внимание на эту возможность, так как шаблоны T4 — тоже своего рода ресурсы.

Проблема

Обычно кодогенерация решает локальные проблемы проекта, поэтому необходимость повторно использовать шаблоны T4 возникает нечасто. Одним из случаев можно считать задачу генерации моделей для приложений Web API.

Предположим, что наш сервис позволяет регистрировать пользователей и изменять их данные. Пусть это будут:

  1. Целочисленный идентификатор.
  2. Адрес электронной почты, а по совместительству — логин.
  3. Пароль.
  4. Имя.
  5. Фамилия.

Мы, кажется, могли бы использовать в качестве модели простой класс:

UserModel.cs

public class UserModel
{
    public int Id { get; set;}

    public string Email { get; set; }

    public string Password { get; set; }

    public string GivenNames { get; set; }

    public string FamilyName { get; set; }
}

К сожалению, в разных сценариях нам нужны разные поля.

  1. При создании пользователя мы передаём на сервер адрес электронной почты, пароль, имя и фамилию. Идентификатор сервер генерирует сам.
  2. При чтении пользователя мы не можем вернуть пароль, поскольку мы храним не сам пароль, а его хеш.
  3. При изменении мы должны использовать два метода. Первый позволяет менять имя и фамилию (считаем, что поле Email изменить нельзя).
  4. Второй метод позволяет изменить пароль. Традиционно для этого нужно передать старый пароль и новый пароль.

Итого вместо одной модели мы должны использовать четыре.

POST /users/
UserCreateModel.cs

public class UserCreateModel
{
    public string Email { get; set; }

    public string Password { get; set; }

    public string GivenNames { get; set; }

    public string FamilyName { get; set; }
}

GET /users/{userId}
UserReadModel.cs

public class UserReadModel
{
    public int Id { get; set; }

    public string Email { get; set; }

    public string GivenNames { get; set; }

    public string FamilyName { get; set; }
}

PUT /users/{userId}
UserUpdateModel.cs

public class UserUpdateModel
{
    public string GivenNames { get; set; }

    public string FamilyName { get; set; }
}

PUT /users/{userId}/password
UserPasswordUpdateModel

public class UserPasswordUpdateModel
{
    public string OldPassword { get; set; }

    public string NewPassword { get; set; }
}

Четыре простых класса вместо одного — признак грядущих проблем. Если мы захотим переименовать поле, можем случайно забыть про один из классов. Казалось бы, надо организовать эти классы в иерархию, чтобы каждое свойство описать один раз. Но они не образуют иерархию.

Здесь нам на помощью приходит кодогенерация. Мы можем описать модели и свойства в табличном виде, например, в файле .csv, и затем сгенерировать код моделей с помощью шаблона T4.

Type Name Create Read Update PasswordUpdate
int Id   +    
string Email + +    
string GivenNames + + +  
string FamilyName + + +  
string Password +      
string OldPassword       +
string NewPassword       +

Возможно ли это? Попробуем узнать.

Шаг I: включение шаблонов

Начнём с простого сценария: мы поставляем в пакете один файл .tt, который редактируем так, чтобы он корректно работал с нашими моделями. Хорошее ли это решение?

Не очень. Если мы захотим расширить возможности шаблона и выпустим новую версию пакета, новый шалон нельзя будет просто скопировать в проект, поскольку там уже есть изменённая копия.

Правильное решение должно состоять из двух файлов. Один распространяется в составе пакета и умеет генерировать код из .csv, а второй вызывает методы первого с конкретными параметрами. Это может выглядеть так.

Models.ttinclude

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#+
public void PrintModels(string csvFilename, string classNameTemplate)
{
    . . .
}
#>

Models.tt

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ include file="Models.ttinclude" #>
<#@ output extension=".cs" #>
using System;
using Taxys.Geometry;

namespace Foo.Bar
{
<#
PrintModels(Host.ResolvePath("User.csv", "{File}{Column}Model"));
#>
}

Я не привожу код метода PrintModels, поскольку он будет отвлекать нас от основной темы статьи. Как говорят авторы учебников, его написание оставляю вам в качестве упражнения.

Директива @include включает содержимое одного шаблона в другой. Visual Studio генерирует код для каждого файла с расширением .tt, поэтому расширение включаемого шаблона надо изменить — для него не нужно генерировать код. Вместо .tt обычно используют расширение .t4 или .ttinclude.

В шаблоне Models.ttinclude (включаемом) реализуем метод PrintModels. Вызовем его из шаблона Models.tt (включающего) и передадим в параметрах путь к файлу моделей и шаблон имени класса. Шаблон "{File}{Column}Modle" означает, что имя класса строится из имени файла, имени колонки и слова Model.

Файл User.csv и колонка Create дадут класс UserCreateModel.

Шаг II: создание пакета NuGet с шаблоном .ttinclude

Создадим пустую папку, где будут размещаться исходные коды пакета, скопируем в неё Models.ttinclude. Скачаем утилиту nuget.exe. Создадим спецификацию пакета Models.nuspec.

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
  <metadata minClientVersion="3.3.0">
    <id>Models</id>
    <version>1.0.0</version>
    <description>T4 Include file to generate a code.</description>
    <authors>Mark Shevchenko</authors>
  </metadata>

  <files>
    <file src="Models.ttinclude" target="content/Models.ttinclude" />
  </files>
</package>

Обязательными реквизитами пакета являются id, version, description и authors, поэтому мы их заполним.

Запустим утилиту сборки пакета:

nuget pack Models.nuspec

Утилита создаст пакет с именем Models.1.0.0.nupkg. Пакет NuGet это обычный zip-архив, поэтому мы можем переименовать его в Models.1.0.0.zip и исследовать. В корне архива мы обнаружим спецификацию Models.nuspec и папку content, где лежит Models.ttinclude.

├─ Models.nuspec
└─ content
   └─ Models.ttinclude

Откуда появилась папка content? Ответ мы найдём в нашей спецификации, в разделе files:

    <file src="Models.ttinclude" target="content/Models.ttinclude" />

Утилита nuget.exe, встретив элемент file копирует файл из атрибута src в архив, в папку из атрибута target.

Проверим, что получилось: создадим пустой проект .NET Framework (не .NET Core и не .NET Standard) и установим пакет туда.

Удобно добавить папку, где лежит Models.1.0.0.nugpk в список Available package sources. В Visual Studio выберите  →NuGet Package Manager →Package Manager Settings →Package sources. После добавления папки запустите Package Manager, щёлкнув правой клавишей на проекте .NET Framework и выбрав Manage NuGet Packages. Справа вверху выберите только что созданный источник пакетов, и установите Models.1.0.0.nupkg.

После установки файл Models.ttinclude будет скопирован в корень проекта. Это именно то, что нам нужно.

Шаг III: проекты .NET Core и .NET Standard

История не была бы детективной, если бы на этом всё закончилась. У неё есть продолжение. Давайте создадим проект .NET Core (или .NET Standard) и попробуем установить наш пакет. Мы обнаружим, что хотя установка прошла без ошибок, файла Models.ttinclude в проекте не появился.

Что произошло?

Оказывается, в Visual Studio 2017 и в NuGet, начиная с версии 4.0 появился новый способ добавления ресурсов в проект. Поскольку файлы ресурсов не предназначены для редактирования (мы разбирались, почему, на примере Models.ttinclude) их можно хранить в единственном экземпляре.

В предыдущих версиях NuGet пакеты хранились в каждом решении (solution), но теперь для них предусмотрено единственное место — локальный кэш, а именно папка %USERPROFILE%.nuget\packages.

И NuGet не копирует в новые проекты файли из content, а добавляет к ним ссылки на файлы из contentFiles.

Дополним спецификацию Models.nuspec:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
  <metadata minClientVersion="3.3.0">
    <id>Models</id>
    <version>1.0.0</version>
    <description>T4 Include file to generate a code.</description>
    <authors>Mark Shevchenko</authors>
    <contentFiles>
      <files include="**/Models.ttinclude" buildAction="None" />
    </contentFiles>
  </metadata>

  <files>
    <file src="Models.ttinclude" target="content/Models.ttinclude" />
    <file src="Models.ttinclude" target="contentFiles/cs/netstandard/Models.ttinclude" />
  </files>
</package>

Взглянем на элемент contentFiles. Он говорит утилите NuGet, что при установке пакета к проекту надо добавить ссылки на файлы Models.ttinclude из папки contentFiles. Во время сборки с этими файлами ничего делать не надо, на что указыает значение None атрибута buildAction.

Помимо файлов «ни для чего», мы можем поместить в проекты ресурсы (buildAction="Embedded Resource"), исходный код (buildAction="Compile") и содержимое (buildAction="Content").

Имя файла указано в сокращённой форме **/Models.ttinclude, полное имя — contentFiles/cs/netstandard/Models.ttinclude. Элемент contentFiles не копирует файлы: как и раньше, мы помещаем Models.ttinclude в папку contentFiles/cs/netstandar с помощью элемента file:

    <file src="Models.ttinclude" target="contentFiles/cs/netstandard/Models.ttinclude" />

Отдельного обсуждения заслуживает структура папки contentFiles. Что означают подпапки cs и netstandard? Ответ в том, что новый NuGet позволяет добавлять разные файлы к разным проектам.

Непосредственно в contentFiles мы создаём папку с типом проекта: cs для C#, vb для VisualBASIC, fs для F#, и any в случае, если файл можно добавлять к любому проекту. На втором уровне мы создаём папку с названием целевой платформы. netstandard означает .NET Standard независимо от версии.

Ещё раз соберём пакет и заглянем внутрь.

├─ Models.nuspec
├─ content
│  └─ Models.ttinclude
└─ contentFiles
   └─ cs
      └─ netstandard
         └─ Models.ttinclude

При установке пакета в проект .NET Framework NuGet, как и раньше, копирует файл Models.ttinclude. После установки пакета в проекты .NET Core и .NET Standard, мы увидим файл в корне проекта в Visual Studio, но если мы заглянем на диск, то файла там не обнаружим.

Как я писал выше, файл находится в локальном кэше пакетов (%USERPROFILE%.nuget\packages\models\1.0.0\contentFiles\cs\netstandard), а из проекта на него стоит ссылка.

Сейчас мы можем прописать в директиве @include абсолютный путь к файлу, но у этого решения есть два больших минуса. Во-первых, %USERPROFILE% может отличаться у разных членов команды. Во-вторых, при обновлении пакета и изменении его версии, нужно будет изменять полный путь к Models.ttinclude во всех шаблонах .tt, которые его используют.

Шаг IV: относительный путь

К нашему счастью, шаблонизатор T4 может быть интегрирован с Visual Studio и MSBuild. Интеграция заключается в том, что мы можем «передать» шаблонизатору свойства проекта как параметры.

Добавим в файл проекта свойство $(ModelsIncludeFolder):

<PropertyGroup>
  <ModelsIncludeFolder>$(%USERPROFILE)\.nuget\packages\models\1.0.0\contentFiles\any\any</ModelsIncludeFolder>
</PropertyGroup>

«Передадим» его в T4:

<ItemGroup>
  <T4ParameterValues Include="ModelsIncludeFolder">
    <Value>$(ModelsIncludeFolder)</Value>
  </T4ParameterValues>
</ItemGroup>

Теперь свойство $(ModelsIncludeFolder) можно использовать в шаблоне, в частности, в директиве @include:

<#@ include file="$(ModelsIncludeFolder)\Models.ttinclude" #>

Если мы перенесём этот код в проект .NET Framework, куда Models.ttinclude просто копируется, он перестанет работать. Чтобы сохранить совместимость, нужно иницилизировать свойство значением $(MSBuildProjectDirectory), в котором хранится путь к проекту.

<PropertyGroup>
  <ModelsIncludeFolder>$(MSBuildProjectDirectory)</ModelsIncludeFolder>
</PropertyGroup>

Осталось разобраться, как при установке пакета добавить свойство в файл проекта .csproj. Это несложно. В состав пакета могут входить файлы с расширениями .props и .targets. NuGet проверяет целевую платформу проекта, и ищет подходящий файл в папке build. Для проекта .NET Standard поиск происходит в папке build/netstandard, а для .NET Framework — в папке build/net.

Если файлы найдены, NuGet импортирует .props в начало файла проекта, а .target — в конец. В проектах .NET Core и .NET Standard импорт осуществляется чуть сложнее — через файл project.assets.json, но в конечном счёте мы получаем то же самое.

Создадим два файла .props и один файл .targets (он идентичен для всех типов проектов).

Models.netstandard.props — для .NET Standard

<?xml version="1.0"?>
<Project>
  <PropertyGroup>
    <ModelsIncludeFolder>$(NuGetPackageRoot)models\1.0.0\contentFiles\any\any</ModelsIncludeFolder>
  </PropertyGroup>
</Project>

Models.net.props — для .NET Framework

<?xml version="1.0"?>
<Project>
  <PropertyGroup>
    <ModelsIncludeFolder>$(MSBuildProjectDirectory)</ModelsIncludeFolder>
  </PropertyGroup>
</Project>

Models.targets — для любой платформы

<?xml version="1.0"?>
<Project>
  <ItemGroup>
    <T4ParameterValues Include="ModelsIncludeFolder">
      <Value>$(ModelsIncludeFolder)</Value>
    </T4ParameterValues>
  </ItemGroup>
</Project>

С помощью элемента files добавим файлы в проект в правильные папки:

  <files>
    <file src="Models.ttinclude" target="content/Models.ttinclude" />
    <file src="Models.ttinclude" target="contentFiles/any/any/Models.ttinclude" />

    <file src="Models.net.props" target="build/net/Models.props" />
    <file src="Models.targets" target="build/net/Models.targets" />
    <file src="Models.netstandard.props" target="build/netstandard/Models.props" />
    <file src="Models.targets" target="build/netstandard/Models.targets" />
  </files>

После сборки исследуем структуру.

├─ Models.nuspec
├─ content
│  └─ Models.ttinclude
├─ contentFiles
│  └─ any
│     └─ any
│        └─ Models.ttinclude
└─ build
   ├─ net
   │  ├─ Models.props
   │  └─ Models.targets
   └─ netstandard
      ├─ Models.props
      └─ Models.targets

Важно разместить файлы платформы net перед файлами платформы netstandard потому что NuGet использует первые подходящие файлы, а последние версии .NET Framework совместимы с .NET Standard.

Заключение

Теперь наш пакет может быть установлен как в проекты .NET Core/Standard, так и в проекты .NET Framework. В первом случае в проект добавляется ссылка на файл в кэше пакетов, а во втором — копия файла из пакета.

В обоих случаях мы используем путь "$(ModelsIncludeFolder)\Models.ttinclude" в директиве @include.