В .NET 5 появился source generator. С его помощью это и сделаем. В данной статье будут рассмотрены основные проблемы, с которыми я столкнулся при использовании source generator и их решение. Сама генерация UI выходит за рамки этой статьи. Используется Visual Studio 2019.
Итак, что для этого потребуется:
- Возможность генерации js / vue / jsx файлов
- Доступ к каталогу основного проекта
- Доступ к файлу настроек
- Использование сторонних библиотек внутри генератора, например Newtonsoft.Json
- Использование других моих сборок внутри генератора
- Доступ к классам/типам контроллеров и моделей, расположенных в разных сборках
- Отладка
Пара слов о T4
В .NET 4.x есть кодогенератор T4. Изначально я пробовал решить свою задачу с его помощью. Был ряд проблем, в основном связанных с подгрузкой системных библиотек, которые решались с переменным успехом. Но когда дело дошло до обработки сборки .NET 5 с контроллерами, которая ссылается на чуждую (для .NET 4.x рантайма) AspNetCore библиотеку — тут мой мозг зашел в тупик. T4 ни в какую не хотел ее находить и грузить.
Структура проекта
Все новые технологии Microsoft начинаются с Hello World, в котором все круто работает. Но когда начинаешь использовать их в реальном проекте, то сталкиваешься с кучей проблем. Одной из таких как раз является структура проекта. В Hello World — это одна сборка. А в реальном проекте их несколько.
Мой проект включает в себя четыре условные сборки:
- NetGenerator5.Web — основное запускаемое веб-приложение (net5.0), содержит контроллеры, к нему подключается сборка с моделями и сам генератор.
- NetGenerator5.Model — cборка с моделями (net5.0)
- NetGenerator5.Generator — cборка с генератором (netstandard2.0)
- 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
Полный исходный код можно посмотреть тут
Источники
- github.com/amis92/csharp-source-generators
- github.com/dotnet/roslyn/blob/master/docs/features/source-generators.md
- github.com/dotnet/roslyn/blob/master/docs/features/source-generators.cookbook.md
- mihailromanov.wordpress.com/2021/01/31/net-code-generation-part-6-c-source-generators
- dominikjeske.github.io/source-generators
- github.com/dotnet/roslyn/discussions
- habr.com/ru/post/530454
- habr.com/ru/post/533128
mayorovp
Source Generator предназначен для создания кода на C#, а не произвольных файлов.
Для создания же произвольных файлов есть задачи (Task) MSBuild.
onets Автор
Это лично мне нужен js код и после T4 я решил попробовать именно source generator. Но те, кто будет использовать его для C# кода могут столкнутся с аналогичными проблемами. А в статье уже есть ответы на некоторые вопросы, на которые я сам ответа сходу не нашел.
MSBuild я пока не копал, но там точно так же может быть куча проблем. Если вы активно им пользовались — поделитесь своим опытом. Возникают ли там озвученные мной проблемы? Они решаемы?
mayorovp
Возникают. Решаемы.