Один из наших клиентов, приверженец технологий php, жаловался что с переходом на C# и стек .Net лишился одной из своих любимых возможностей – использовать в своих скриптах traits и что он хотел бы добавления подобной функциональности в продукт разрабатываемый для его компании.
В один прекрасный момент, мы решили сделать ему подарок и реализовали proof of concept схожей функциональности для C#.
На удивление, всё случилось довольно быстро и интересно. Кому любопытно что из этого получилось и как можно попробовать, добро пожаловать под кат.
Краткая справка для тех кому повезло на своём жизненном пути не повстречаться с php и кто не в курсе что такое traits (с сайта php.net).
Трейт — это механизм обеспечения повторного использования кода в языках с поддержкой только одиночного наследования, таких как PHP. Трейт предназначен для уменьшения некоторых ограничений одиночного наследования, позволяя разработчику повторно использовать наборы методов свободно, в нескольких независимых классах и реализованных с использованием разных архитектур построения классов. Семантика комбинации трейтов и классов определена таким образом, чтобы снизить уровень сложности, а также избежать типичных проблем, связанных с множественным наследованием и смешиванием (mixins).
Трейт очень похож на класс, но предназначен для группирования функционала хорошо структурированным и последовательным образом. Невозможно создать самостоятельный экземпляр трейта. Это дополнение к обычному наследованию и позволяет сделать горизонтальную композицию поведения, то есть применение членов класса без необходимости наследования.
Многие языки программирования (в том числе и C#) не поддерживают множественное наследование для избегания сложности и неопределённости которые оно привносит. Но в определённых сценариях это может быть достаточно полезно и уменьшит общую сложность проекта и размер кодовой базы. Особенно в случаях когда нет контроля над выбором базового класса и созданием удобной иерархии классов.
В последних версиях C# добавили поддержку создания методов на уровне интерфейсов, чтобы в какой-то мере решить подобные проблемы, но на эти методы накладываются довольно сильные ограничения.
После обсуждения, мы пришли к минимальным требованиям по функциональности traits:
- статическая типизация
- интегрирование непосредственно в прикладной проект (т.е. не пре/пост-процессинг)
- совместимость с IDE (VS) со всеми вкусными фичами: автодополнения, обнаружение ошибок в процессе редактирования кода и т.п.,
- минимум дополнительных усилий от разработчика.
У нас имелся значительный опыт разработки систем для работы с исходным кодом и поэтому когда source generators начали набирать популярность, то стало понятно, что они наиболее полно отвечают нашим требованиям и имеет смысл попытаться реализовать MVP.
Про source generators можно почитать статьи на Хабре:
- C#: Знакомство с генераторами исходного кода
- Кодогенерация при помощи Roslyn
- Заменяем события C# на Reactive Extensions с помощью кодогенерации
- Оживляем деревья выражений кодогенерацией
Поэтому не буду останавливаться на деталях реализации самого source generator, а опишу общии идеи проекта.
Через пару недель общих человеко-затрат по времени, у нас родился инструмент под названием SmartTraits.
Мы реализовывали функциональность поэтапно и я расскажу через какие стадии прошёл наш source generator, но для начала опишу соглашения по формату Trait и его классов потребителей.
Trait это
- абстрактный класс который объявлен как partial
- класс помечается атрибутом Trait
- этот класс не может быть унаследован от другого класса
- но он может (но может и не) содержать интерфейс который он должен реализовать
Потребитель Trait (т.е. класс в который будет добавлен исходный код от Trait)
- должен быть partial
- должен содержать один или несколько атрибутов
[AddTrait]
с типом трейта в качестве параметра
Для примера возьмём простейшую иерархию классов
class ExampleA: BaseA
{}
class BaseA {}
class ExampleB: BaseB
{}
class BaseB {}
Т.е. классы унаследованы от разных базовых классов которые не имеют общего предка (например базовые классы из сторонних библиотек) и как результат, мы не можем добавить требуемую общую функциональность во все эти классы.
Примечание : Все примеры специально упрощены для демонстрации принципов. И мы в курсе, что существуют альтернативные способы решений данных требований (например наш пакет aspectimum).
Но например, условный джун столкнувшись с данной проблемой, применит своё проверенное оружие, copy&paste и как результат, вкусит плоды который данный приём привносит с собой.
Первая итерация – причёсанный #include
Предположим, что в классы ExampleA и ExampleB в нашем примере сверху, необходимо добавить код для работы с именами
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{FirstName} {LastName}";
}
Соответственно мы создаём наш простейший Trait
[Trait]
abstract partial class NamesTrait
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{FirstName} {LastName}";
}
}
И применяем его к ExampleA и ExampleB добавив атрибут
[AddTrait]
[AddTrait(typeof(NamesTrait))]
partial class ExampleA: BaseA
{}
Больше ничего не требуется и наш source generator «behind the scene» автоматически создаёт вторую часть для классов ExampleA и ExampleB копируя исходники всех методов, свойств и т.п. из NamesTrait
partial class ExampleA
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{FirstName} {LastName}";
}
}
Т.е. самые базовые требования достигнуты.
- ничем не отличается от остального кода и все правила применяемые к обычному коду c# полностью применимы к Traits
- довольно небольшой оверхед
- полная поддержка со стороны IDE
- немедленные изменения для ExampleA и ExampleB при изменении кода в NamesTrait
Вторая итерация – гарантия реализации требуемого набора методов и свойств
Добиться гарантий мы может стандартным способом, созданием интерфейса и присвоением его классу Trait и классу потребителю.
Определяем интрефейс
interface INames
{
string FirstName { get; set; }
string LastName { get; set; }
string GetFullName();
}
Объявления классов потребителей будут выглядять как (добавили интерфейс INames)
[AddTrait(typeof(NamesTrait))]
partial class ExampleA: BaseA, INames
А для класса Trait (также добавили интерфейс INames)
[Trait]
abstract partial class NamesTrait: INames
Теперь код не скомпилируется пока все требуемые методы/свойства не будут реализованны.
Третья итерация – разрешение конфликтов
По умолчанию всё содержание Trait класса будет скопированно в класс потребитель, но предположим что мы хотим иметь возможность в некоторых классах потребителях реализовать свою версию методов, свойств и т.п.
Решается довольно просто, те члены Trait которые могут быть реализованны в классах потребителях мы помечаем атрибутом
[Overrideable]
и при копировании из Trait класса, проверяем что если подобный метод или свойство уже реализованы, тогда не копируем версию из Trait.Пример, если ExampleB имеет собственную реализацию метода GetFullName, то мы игнорируем версию этого метода из NamesTrait
[AddTrait(typeof(NamesTrait))]
partial class ExampleB: BaseB, INames
{
public string MiddleName { get; set; }
public string GetFullName() {
return $" {FirstName} {MiddleName} {LastName}";
}
}
Сгенерированная часть для класса ExampleB
partial class ExampleB
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
В NamesTrait, метод GetFullName выглядит как (добавлен атрибут
[Overrideable]
)[Overrideable]
public string GetFullName() {
return $"{FirstName} {LastName}";
}
Четвёртая итерация – доступ к методам и свойствам класса потребителя из методов Trait
Если мы знаем что все классы потребители реализуют некоторые методы или свойства, который нам необходимо вызвать из Trait, то добиться этой возможности у на есть двумя способами.
1. В Trait добавить mock и игнорировать этот mock в процессе копирования. Для private и protected методов и свойств, это единственная простая опция которую мы могли придумать. Добиться игнорирования копирования членов класса, можно пометив их атрибутом
[TraitIgnore]
UPD: Пользователь Mingun предложил вариант с использованием абстракных методов/свойств
вместо mock-ов и игнорированию их при копированнии. Также при создании mock-ов он порекомендовал использовать исключения, вместо возврата default значений. Это более правильный и безопасный вариант.
2. В добавок к варианту с mock, для public методов этого можно добиться более элегантным способом. Создаём интерфейс(ы) и назначаем их как Trait, так и классу потребителю. Т.е. мы гарантируем наличие членов интерфейса в обоих классах. Также для этих членов интерфейса мы можем автоматически сгенерировать mock-и уже в самом Trait. Т.е. получается что генерация нам помогает как и в случае класса потребителя, так и для самого Trait.
Предположим оба ExampleA и ExampleB имеют метод
GetNamePrefix(): string
и в мы хотим иметь возможность вызвать этот метод из Trait.Вариант 1. Mock-и
[AddTrait(typeof(NamesTrait))]
partial class ExampleA
{
private string GetNamePrefix()
{
return "mr/mrs. ";
}
}
И как этот вызов будет выглядеть из Trait (см. использование GetNamePrefix() в функции GetFullName) и определение mock-а
[Trait]
abstract partial class NamesTrait: INames
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{GetNamePrefix()} {FirstName} {LastName}";
}
[TraitIgnore]
private string GetNamePrefix()
{
return null;
}
}
Сгенерированный код для ExampleA будет выглядеть как
partial class ExampleA: INames
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{GetNamePrefix()} {FirstName} {LastName}";
}
}
Вариант 2. Публичные методы/свойства класса получателя через интерфейсы
Определяем интерфейс который реализует класс получатель (в этом примере это INamePrefix)
interface INamePrefix
{
string GetNamePrefix();
}
[AddTrait(typeof(NamesTrait))]
partial class ExampleA: INames, INamePrefix
{
public string GetNamePrefix()
{
return "mr/mrs. ";
}
}
И добавляем его в Trait
[Trait]
abstract partial class NamesTrait: INames, INamePrefix
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{GetNamePrefix()} {FirstName} {LastName}";
}
}
Сгенерированный код для класа потребителя (ExampleA) будет выглядеть как
partial class ExampleA: INames, INamePrefix
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName() {
return $"{GetNamePrefix()} {FirstName} {LastName}";
}
}
А автоматически сгенерированный код для Trait будет выглядеть как
abstract partial class NamesTrait: BaseMockNamesTrait
{
}
abstract class BaseMockNamesTrait: INamePrefix
{
public abstract string GetNamePrefix();
}
Примечание: для того чтобы отличить интерфейсы доступа к членам класса потребителя (в примере это INamePrefix) для которых необходимо генерировать mock-и, от интерфейса который гарантирует контракт trait-а (в примере это INames) мы вводим дополнительное условие что интерфейс Trait контракта помечается атрибутом
[TraitInterface]
.Пятая итерация – strict mode
Мы добавили дополнительную поддержку строгого режима для предотвращения неожиданностей и для уменьшения вероятностей стрельбы себе в ногу. Т.е. исходный код Trait и класса потребителя обязаны реализовывать интерфейс помеченный атрибутом
[TraitInterface]
. Плюс Trait обязан реализовать ТОЛЬКО методы и свойства из этого интерфейса. Никаких дополнительных методов/свойств не разрешается. Этот режим позволяет гарантировать соответствия между Trait и классом потребителем на уровне проверки компилятором.Пример задания strict режима
[Trait(TraitOptions.Strict)]
abstract partial class NameTrait : IName
Если добавить метод/свойство которое не существует в интерфейсе IName, то появиться сообщение об ошибке и не сможете собрать проект.
Предварительный итог
После пяти итераций, получился вполне себе полезный инструмент, но внимательного читателя с самого начала грызёт вопрос, почему мы добавили слово smart в название пакета. Пока что явно недостаточно чтобы называться SmartTraits — я полностью согласен, поэтому впереди ещё пара итераций.
Шестая итерация – smart методы
Работая с source generators у нас есть доступ к исходному коду проекта. Именно эта возможность позволила нам реализовать функциональность Traits.
Но если подумать, то имея исходный код, мы может его не только копировать, но и исполнять.
Поэтому мы добавили новую возможность, методы которые
- помечены атрибутом Process
- принимают параметр ClassDeclarationSyntax
- и возвращают string
Будут налету скомпилированы и выполнены. В качестве параметра им будет передан SyntaxNode класса потребителя.
Результат выполнения будет добавлен в код класса. Если опциональный параметр атрибута указан как ProcessOptions.Global то будет добавлен не в код класса, а как отдельный сгенерированный код. Это позволит генерировать дополнительные классы реализующие требуемую функциональность. Когда начинаешь работать с этой функцией, то вначале это реально выглядит как магия.
Smart методы в основном полезны для генерации кода в зависимости от внешних данных (доступы к api/db/xml и т.п.). Но надо понимать, что это может быть довольно ресурсозатратно и необходимо реализовать правильную стратегию кэширования и инвалидации.
Пример подобного кода (помечен атрибутом [Process]). Для класса ExampleA будет сгенерирован метод GetExampleA, а для ExampleB метод GetExampleB
[Trait]
abstract partial class NamesTrait: INames, INamePrefix
{
[Process]
string BuildMethod(ClassDeclarationSyntax classNode)
{
return $"public string Get{classNode.Identifier}() {{ return \"{classNode.Identifier}\"; }}";
}
}
Для примера, сгенерированный код в классе потребителе ExampleA будет выглядеть как
partial class ExampleA: INames, INamePrefix
{
public string GetExampleA()
{
return "ExampleA";
}
}
А для ExampleB как
partial class ExampleB: INames, INamePrefix
{
public string GetExampleB()
{
return "ExampleB";
}
}
Седьмая итерация – ещё smarter
Возможность налету исполнять кастомный код который сразу же меняет код этого же проекта, это конечно круто, но требует определённой квалификации от человека который этот код пишет. Создание кода манипулированием и навигацией через Roslyn — к этому надо привыкнуть.
Поэтому было принято решение упростить и расширить через добавление поддержки исполнения шаблонов T4 в процессе source generation. В конце концов, T4 был для этого придуман. Легко генерировать исходный код и даже если разработчик никогда ранее не занимался написанием скриптов T4, то научиться этому на порядок проще чем манипулированием объектов через Roslyn. Особенно учитывая их иммутабельность — пока постигнешь дзен, пару раз его потеряешь.
Если же ты раньше писал aspx или jsp, то фактически уже знаешь T4. Ну или если писал странички на php – основная идея та же. Особенно удобно в нашем случае, принимая во внимание, то что наш заказчик как раз был из клана пхп.
Идея такая же как и предыдущем случае. Но тут мы можем пометить атрибутом
[ApplyT4]
любой метод, свойство или класс. И ему не обязательно быть членом класса потребителя [AddTrait]
.Единственная разница, то что по умолчанию результат исполнения шаблона будет записан как отдельный сгенерированный код, т.е. T4TemplateScope.Global. Но при желании для членов класса потребителя (класса с атрибутом
[AddTrait]
), можно указать опцию T4TemplateScope.Local и результат работы шаблона будет добавлен в partial часть сгенерированного класса.За счёт того что хранится скомпилированная версия шаблона, исполнение происходит очень быстро. При изменении шаблона T4, он налету перекомпилируется и применяется новая версия.
Очень удобно по сравнению со стандартными source generators, где на любой чих, надо новую сборку делать и передеплоить пакет.
После полугода работы, собрали статистику что версия с Process (компиляция встроенного кода на лету) фактически не используется и её отключили, оставили только T4 версию.
Некоторые разрабочики, которым не требуется функциональность из пакета SmartTraits, установили генератор и используют его только из-за синергии source generators и T4, как раз из-за возможности перекомпиляции налету и мгновенным результатам работы.
То как определяем шаблоны:
[AddTrait(typeof(NameTrait))]
[ApplyT4("DemoTemplate", Scope = T4TemplateScope.Local, VerbosityLevel = T4GeneratorVerbosity.Debug)]
partial class ExampleA : BaseA, IName
{
}
class BaseA { }
Пример шаблона T4:
<#@ include file="SmartTraitsCsharp.ttinclude" #>
<#
// for this demo, we ignore empty nodes, but in real life you would want the template to fail, to be able to catch issues earlier
if(ClassNode?.Identifier == null)
return "";
#>
public string GetT4<#= ClassNode.Identifier.ToString() #>()
{
return "T4 <#= ClassNode.Identifier.ToString() #>";
}
Сгенерированный код для ExampleA
partial class ExampleA {
public string GetT4ExampleA()
{
return "T4 ExampleA";
}
}
Сгенерированный код для ExampleB
partial class ExampleB {
public string GetT4ExampleB()
{
return "T4 ExampleB";
}
}
Заключение
В целом это краткое описание того что было сделано для демонстрации возможностей технологии. Наш любитель php был очень рад и на радостях оплатил доведение инструментов до рабочего состояния.
Посоветовавшись со старшими товарищами, мы решили опубликовать исходный код этого proof of concept по свободной лицензии – зачем добру пропадать. Тем более если идея взлетит и будет поддерживаться другими разработчиками, то в конце концов это будет взаимная польза. Может новые идеи из открытого проекта позволят улучшить и наш внутренний инструмент.
Версию после proof of concept мы к сожалению не можем открыть, потому что её разработка была оплачена заказчиком и мы не можем распоряжаться ею свободно. Но основные идеи те же, была добавлена поддержка CI/CD, несколько небольших фич и т.п… Плюс была доработана до стабильного состояния, а также создана библиотека полезных шаблонов.
Мысли вслух
В принципе подобную функциональность можно было бы даже впилить в сам язык, пара-тройка ключевых слов и всё бы было гораздо более органично. Судя по опыту этого проекта, всё получается вполне удобно и полезно.
Ещё бы посоветовал товарищам из Микрософта, добавить опцию, чтобы можно было пометить свой анализатор или генератор как «heavy» — т.е. запускаем его только на билды, а не на любое нажатие кнопки. В определённых случаях, я бы предпочёл чтобы не было моментального обновления сгенерированных файлов, но зато можно было бы добавить гораздо более ресурсоёмкие проверки.
Ну и пока стабильности не очень хватает, особенно в связке с resharper. Но это временное явление, даже сейчас уже вполне стабильно работает и подглючивает очень изредка.
Как попробовать?
Код проекта можно найти в репозитории
Пакеты также доступны на nuget.org
Хочу предупредить — развитие этой версии остановилось после демки клиенту и после этого её особо не трогали. Так что это MVP, NVP, MVB, с соответсвующим качеством кода и с большой вероятностью можно наткнутся на баги, но перед публикацией статьи я протестировал этот код, так что по крайней мере кейсы из демо проекта точно работают.
Для того чтобы проект записывал сгенерированный код на файловую систему, надо добавить в файл проекта:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
Также рекомендую обновиться до последней версии VisualStudio и Resharper, source generators это новая технология и инструменты всё ещё иногда подглючивают.
Результат работы демо
Код демо:
Вывод на консоль:
Console.WriteLine("Hello SmartTraits!");
Stage1.Test.Example();
Stage2.Test.Example();
Stage3.Test.Example();
Stage4.Test.Example();
Stage5.Test.Example();
Stage6.Test.Example();
Stage7.Test.Example();
Вывод на консоль:
Hello SmartTraits!
### Test 1
SmartTraits.Tests.Stage1.ExampleA
Jim Smith
SmartTraits.Tests.Stage1.ExampleB
Jane Silver
123 Main st. New York NY 10012
### Test 2
SmartTraits.Tests.Stage2.ExampleA
Jim Smith
SmartTraits.Tests.Stage2.ExampleB
Jane Silver
123 Main st. New York NY 10012
### Test 3
SmartTraits.Tests.Stage3.ExampleA
Jim Smith
SmartTraits.Tests.Stage3.ExampleB
Jane J. Silver
123 Main st. New York NY 10012
### Test 4
SmartTraits.Tests.Stage4.ExampleA
mr/mrs. Jim Smith
SmartTraits.Tests.Stage4.ExampleB
sir/madam Jane Silver
Address Label: 123 Main st. New York NY 10012
### Test 5
SmartTraits.Tests.Stage5.ExampleA
Jim Smith
SmartTraits.Tests.Stage5.ExampleB
Jane Silver
Address Label: 123 Main st. New York NY 10012
### Test 6
SmartTraits.Tests.Stage6.ExampleA
Jim Smith
ExampleA
SmartTraits.Tests.Stage6.ExampleB
Jane Silver
123 Main st. New York NY 10012
ExampleB
### Test 7
SmartTraits.Tests.Stage7.ExampleA
Jim Smith
T4 ExampleA
SmartTraits.Tests.Stage7.ExampleB
Jane Silver
123 Main st. New York NY 10012
T4 ExampleB
Спасибо за внимание.