Задача реализовать генерацию SPA (Vue/React) приложения на основе моделей и контроллеров C#.

В .NET 5 появился source generator. С его помощью это и сделаем. В данной статье будут рассмотрены основные проблемы, с которыми я столкнулся при использовании source generator и их решение. Сама генерация UI выходит за рамки этой статьи. Используется Visual Studio 2019.

Итак, что для этого потребуется:

  1. Возможность генерации js / vue / jsx файлов
  2. Доступ к каталогу основного проекта
  3. Доступ к файлу настроек
  4. Использование сторонних библиотек внутри генератора, например Newtonsoft.Json
  5. Использование других моих сборок внутри генератора
  6. Доступ к классам/типам контроллеров и моделей, расположенных в разных сборках
  7. Отладка

Пара слов о T4


В .NET 4.x есть кодогенератор T4. Изначально я пробовал решить свою задачу с его помощью. Был ряд проблем, в основном связанных с подгрузкой системных библиотек, которые решались с переменным успехом. Но когда дело дошло до обработки сборки .NET 5 с контроллерами, которая ссылается на чуждую (для .NET 4.x рантайма) AspNetCore библиотеку — тут мой мозг зашел в тупик. T4 ни в какую не хотел ее находить и грузить.

Структура проекта


Все новые технологии Microsoft начинаются с Hello World, в котором все круто работает. Но когда начинаешь использовать их в реальном проекте, то сталкиваешься с кучей проблем. Одной из таких как раз является структура проекта. В Hello World — это одна сборка. А в реальном проекте их несколько.

Мой проект включает в себя четыре условные сборки:

  1. NetGenerator5.Web — основное запускаемое веб-приложение (net5.0), содержит контроллеры, к нему подключается сборка с моделями и сам генератор.
  2. NetGenerator5.Model — cборка с моделями (net5.0)
  3. NetGenerator5.Generator — cборка с генератором (netstandard2.0)
  4. NetGenerator5.Generator.Dependency — условная сборка, которая используется внутри генератора (netstandard2.0)

Генератор


Класс генератора реализует интерфейс ISourceGenerator с двумя методами — Initialize и Execute. Метод Execute будет запускаться непосредственно во время компиляции проекта, к которому подключен генератор.

Сам проект генератора

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2" PrivateAssets="all" />
  </ItemGroup>

</Project>

Как его подключать? Необходимо в основном проекте (NetGenerator5.Web), прописать следующее:

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

<ItemGroup>
  <ProjectReference Include="..\NetGenerator5.Generator\NetGenerator5.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

Возможность генерации js / vue / jsx файлов


Изначально у генератора на выходе cs файлы с C# кодом. Для этого внутри метода Execute используется метод контекста GeneratorExecutionContext.AddSource. Поменять расширение у них, я так понял, нельзя и эти файлы так же компилируются. Поэтому поместить туда код на любом другом языке не представляется возможным. Visual Studio начинает выдавать ошибки компиляции.

Поэтому для сохранения js / vue / jsx файлов нам потребуется другой подход. Обычный System.IO.File.WriteAllText мне помог. Но для этого необходимо знать куда именно надо сохранить сгенерированные файлы, т.е. знать каталог основного проекта.

Доступ к каталогу основного проекта


Его можно получить следующим образом:

Прописать в основном NetGenerator5.Web проекте следующее:

<ItemGroup>
  <CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>

Этим мы сделаем видимой системную переменную для source generator.

А в самом генераторе получим к ней доступ в методе Execute следующим образом:

context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var projectDirectory)

Помимо этого нам надо знать куда именно складывать сгенерированные файлы внутри самого веб проекта (например в wwwroot/js). Мне пришло в голову передать это через файл с настройками generatorsettings.json, который располагался бы в основном проекте. Но теперь мне как-то необходимо рассказать о нем генератору.

Доступ к файлу настроек


В генераторе есть возможность обратиться к файлам через коллекцию контекста GeneratorExecutionContext.AdditionalFiles внутри метода Execute. Чтобы мой файл с настройками оказался там, необходимо проставить у него свойство Build Action=C# analyzer additional file, или так:

<ItemGroup>
  <AdditionalFiles Include="generatorsettings.json" />
</ItemGroup>

После этого содержимое файла можно считать следующим образом

var content = context.AdditionalFiles.First(e => e.Path.EndsWith("generatorsettings.json")).GetText(context.CancellationToken);

Далее возникает проблема — это же json, а как мне, собственно, его распарсить?

Использование сторонних библиотек внутри генератора


Использовать внешнюю библиотеку. Например Newtonsoft.Json. Вот тут действительно что-то пошло не так. Я ее подключил через nuget, но генератор ни в какую не хотел видеть эту библиотеку.

Exception was of type 'FileNotFoundException' with message 'Could not load file or assembly 'Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed' or one of its dependencies.

и хоть ты тресни.

В cookbook есть раздел, посвященный этому.

Там даже немного больше информации — как свой генератор оформить в виде nuget пакета. Мне это почему-то не помогло.

В итоге сначала решил странным способом. Я тупо добавил саму библиотеку напрямую в проект как файл и указал для нее Copy to Output Directory = Copy always / Copy if newer и все заработало. Но позже мне ответили на вопрос в разделе дискуссий, посвящённому roslyn. Совет мне помог. Нужно прописать в проекте генератора именно так:

<ItemGroup>
    <!-- Generator dependencies -->
    <PackageReference Include="Newtonsoft.Json" Version="12.0.3" GeneratePathProperty="true" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\NetGenerator5.Generator.Dependency\NetGenerator5.Generator.Dependency.csproj" />
  </ItemGroup>

  <PropertyGroup>
    <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
  </PropertyGroup>

  <Target Name="GetDependencyTargetPaths">
    <ItemGroup>
      <TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
    </ItemGroup>
  </Target>

Или, как альтернатива, использовать встроенный System.Text.Json.

Использование других моих сборок внутри генератора


Далее, было бы неплохо использовать внутри генератора другие мои сборки. Например, вспомогательные классы для Vue и React хорошо бы разбросать по двум разным сборкам и подключать их к генератору по необходимости.

Как ни странно, здесь у меня все прошло гладко. Я просто подключил NetGenerator5.Generator.Dependency через Dependencies — Add Project Reference. Хотя у кого-то возникали проблемы.

Доступ к классам/типам контроллеров и моделей, расположенных в разных сборках


Теперь перейдем к самому интересному. Чтобы сгенерировать файлы — мне нужен был доступ к классам/типам контроллеров и моделей. Microsoft рекомендует использовать SyntaxReceiver
Но он имеет доступ только к классам текущего компилируемого проекта (т.е. в моем случае NetGenerator5.Web), а классов NetGenerator5.Model там нет.

В том же разделе дискуссий roslyn было найдено решение. Внутри контекста GeneratorExecutionContext есть Compilation.GlobalNamespace. По нему можно пройтись рекурсивно и получить описания всех типов, в том числе и текущей компилируемой сборки и сборки с моделями.

Отладка


Для отладки достаточно прописать в классе генератора в методе Initialize

#if DEBUG
  if (!Debugger.IsAttached)
  {
    Debugger.Launch();
  }
#endif

При запуске билда основного проекта открывается окно с предложением запустить отладчик. Если нажать OK — то будет запущен еще один экземпляр Visual Studio и в нем будет режим отладки данного генератора. Можно заходить внутрь всех других классов и методов, даже в те, которые находятся в отдельной сборке NetGenerator5.Generator.Dependency

Итоги


После компиляции в NetGenerator5.Web / wwwroot/js появится файл generated.js, а в NetGenerator5.Web\obj\GeneratedFiles\NetGenerator5.Generator\NetGenerator5.Generator.SourceGenerator появится файл пустышка generated.cs

Полный исходный код можно посмотреть тут

Источники