Аспектно-ориентированное программирование очень привлекательная концепция для упрощения кодовой базы, создания чистого кода и минимизации ошибок типа копипасты.

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

Наш подход (также как и подход некоторых других инструментов), это модификация исходного кода для внедрения логики аспектов. С переходом на технологию Roslyn, добиться этого очень легко и результат даёт определённые преимущества по сравнению с модификацией непосредственно байт кода.

Если вам интересно узнать детали, прошу пожаловать под кат.

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

Особенно в средне-крупных проектах корпоративного уровня, где формализованны требования к функциональности продуктов. Для примера, может существовать требование — при установки флага конфигурации осуществить логирование всех входных параметров для всех публичных методов. Или для всех методов проекта иметь систему уведомления которая пошлёт сообщение при превышении некого порога времени исполнения этого метода.

Как это делается без AOP? Или забивается и делается только для наиболее важных частей или при написании новых методов идёт копипаста подобного кода из соседних методов, со всеми сопутствующими подобного способа.

При использовании AOP, один раз пишется advice который применяется к проекту и дело сделано. Когда надо будет немного обновить логику, вы опять же один раз обновите advice и он будет применён при следующей сборке. Без AOP, это 100500 обновлений по всему коду проекта.

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

После внедрения AOP в ваш проект, вы начинаете внедрять вещи о которых без него даже не мечтали, потому что они выглядили как отностительно малое преимущество, при больших затратах. При AOP всё с точностью наоборот, относительно малые затраты и большие преимущества (для подобного уровня затрат ваших усилий).

По моим ощущениям, в экосистеме .Net, аспектно-ориентированное программирование существенно менее популярно в сравнении с экосистемой Java. Я думаю, что основная причина это отсутствие бесплатного и открытого инструментария, сравнимого с функциональностью и качеством такого в Java.

PostSharp предоставляет подобную функциональность и удобство, но не многие готовы платить сотни долларов за использование его в своих проектах, а community версия очень ограничена в возможностях. Конечно есть альтернативы, но к сожалению они не достигли уровня PostSharp.

Можно сравнить возможности инструментов (надо иметь в виду что сравнение сделано владельцем PostSharp, но некоторую картину оно даёт).

Наш путь к аспектно-ориентированному программированию


Мы небольшая консалтинговая компания (12 человек) и конечным результатом нашей работы является исходный код. Т.е. нам платят за то что мы создаём исходный код, качественный код. Мы работаем только в одной индустрии и многие наши проекты имеют очень похожие требования и как результат, исходный код также достаточно похож между этими проектам.

А так как мы ограниченны в ресурсах, то для нас, одна из самых главных задач является возможность переиспользования кода и использование инструментов которые избавляют разработчика от рутинных задач.

Для достижения этого, один из способов заключается в том, что мы интенсивно используем возможности автоматической генерации кода, а также создали несколько кастомных плагинов и анализаторов для Visual Studio специфичных для наших проектов и задач. Это позволило существенно увеличить производительность программистов, одновременно сохранив высокое качество кода (можно даже сказать, что качество стало выше).

Следующим логическим шагом, была идея внедрить использование аспектно-ориентированного программирования. Мы попробовали несколько подходов и инструментов, но результат был далёк от наших ожиданий. По времени это совпало с выходом технологии Roslyn и в определённый момент у нас родилась идея объединить возможности автоматической генерации кода и Roslyn.

Буквально за пару недель был создан прототип инструмента и по нашим ощущениям, данный подход показался более перспективным. Спустя несколько итераций в способах использования и обновлений этого инструмента, можно сказать что наши ожидания оправдались и даже более чем мы того рассчитывали. Мы наработали библиотеку полезных шаблонов и используем данный подход в большинстве наших проектов и некоторые наши клиенты также его используют и даже заказывают разработку шаблонов под свои нужды.

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

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

Как это было бы сделано в идеальном мире


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

В моём виденьи идеального мира, спецификации языка позволяют использование трансформации исходного кода и существует поддержка компилятором и IDE.

Идея была навеяна включением модификатора «partial» в спецификации языка C#. Эта, достаточно простая концепция (возможность определения класса, структуры или интерфейса в нескольких файлах) кардинально улучшила и упростила поддержку инструментов для автоматической генерации исходного код. Т.е. это своего рода горизонтальная разбивка исходного кода класса между несколькими файлами. Для тех кто не знает языка C#, небольшой пример.

Предположим у нас есть простая форма описанная в файле Example1.aspx
<%@ Page Language="C#" AutoEventWireup="True" %>
// . . .
<asp:Button id="btnSubmit"
           Text="Submit"
           OnClick=" btnSubmit_Click" 
           runat="server"/>
// . . .

И пользовательская логика (например изменение цвета кнопки на красный при её нажатии) в файле Example1.aspx.cs

public partial class ExamplePage1 : System.Web.UI.Page, IMyInterface
{
  protected void btnSubmit_Click(Object sender, EventArgs e) 
  {
    btnSubmit.Color = Color.Red;
  }
}

Наличие в языке возможностей предоставляемых «partial» позволяет инструментарию распарсить файл Example1.aspx и автоматически сгенерировать файл Example1.aspx.designer.cs

public partial class ExamplePage1 : System.Web.UI.Page
{
  protected global::System.Web.UI.WebControls.Button btnSubmit;
}

Т.е. мы имеем возможность хранить часть кода для класса ExamplePage1 в одном файле обновляемым программистом (Example1.aspx.cs) и часть в файле Example1.aspx.designer.cs автоматически генерируемым инструментарием. Для компилятора же это выглядит в конце концов как один общий класс

public class ExamplePage1 : System.Web.UI.Page, IMyInterface
{ 
  protected global::System.Web.UI.WebControls.Button btnSubmit;

  protected void btnSubmit_Click(Object sender, EventArgs e) 
  {
    btnSubmit.Color = Color.Red;
  }
}

На примере с определением наследования интерфейса IMyInterface, видно, что окончательный результат это комбинирование определений класса из разных файлов.

Если у нас отсутствует функциональность подобная partial и компилятор требует хранение всего кода класса только в одном файле, то можно предположить неудобства и дополнительные телодвижения необходимые для поддержки автогенерации.

Соответственно моя идея заключается в включении двух дополнительных модификаторов в спецификацию языка, которые позволят упростить возможность внедрения аспектов в исходный код.

Первый модификатор это original и его добавляем в определение класса который должен иметь возможность быть трансформирован.

Второй это processed и он символизирует, что это окончательное определение класса которое было получено инструментом трансформации исходного кода и которое должно быть принято компилятором для генерации байт кода.

Последовательность примерно такая

  1. Пользователь работает с исходным кодом класса который содержит модификатор original в файле .cs (например Example1.cs)
  2. При компиляции, компилятор проверяет корректность исходного кода и если класс успешно скомпилировался, идёт проверка на наличие original
  3. Если original присутствует, то компилятор отдаёт исходный код этого файла процессу трансформации (который является чёрным ящиком для компилятора).
  4. Процесс трансформации базируясь на наборе правил выполняет модифицирование исходного кода и при успешном завершении процесса создаёт файлы файла .processed.cs и файла .processed.cs.map (для соответствия кода между файлами .cs и файла .processed.cs, для помощи при отладки и для корректного отображения в IDE)
  5. Компилятор получает код из файла .processed.cs (в нашем примере это Example1.processed.cs) и компилирует уже этот код.
  6. Если код в файле успешно скомпилировался, то идёт проверка что

    a. Классы которые имели модификатор original имеют модификатор processed
    b. Сигнатура этих классов идентична как в файле .cs так и в файле .processed.cs
  7. Если всё нормально, то байт код полученный при компиляции файла .processed.cs включается в объектный файл для дальнейшего использования.

Т.е. добавив эти два модификатора, мы смогли на уровне языка организовать поддержку инструментов трансформации исходного кода, подобно тому как partial позволил упростить поддержку генерации исходного кода. Т.е. parial это горизонтальное разбитие кода, original/processed вертикальное.

Как мне видится, реализовать поддержку original/processed в компиляторе это неделя работы для двух интернов в компании Микрософт (шутка конечно, но она не далека от истины). По большому счёту, в этой задаче нету никаких фундаментальных сложностей, с точки зрения компилятора это манипуляция файлами и вызов процесса.

В .NET 5 была добавлена новая фича — генераторы исходного кода которая уже позволяет генерировать новые файлы исходного кода в процессе компиляции и это движение в правильном направлении. К сожалению она позволяет генерировать только новый исходный код, но не изменять существующий. Так что всё ещё ждём.

Пример подобного процесса. Пользователь создаёт файл Example2.cs
public original class ExamplePage2 : System.Web.UI.Page, IMyInterface
{ 
  protected global::System.Web.UI.WebControls.Button btnSubmit;

  protected void btnSubmit_Click(Object sender, EventArgs e) 
  {
    btnSubmit.Color = Color.Red;
  }	
}

Запускает на компиляцию, если всё прошло ок и компилятор видит модификатор original, то отдаёт исходный код, процессу трансформации, который генерирует файл Example2.processed.cs (в самом простейшем случае это может быть просто точная копия Example2.cs с заменённым original на processed).

В нашем случае мы предположим, что процесс трансформации добавил аспект логирования и результат выглядит как:
public processed class ExamplePage2 : System.Web.UI.Page, IMyInterface
{ 
  protected global::System.Web.UI.WebControls.Button btnSubmit;

  protected void btnSubmit_Click(Object sender, EventArgs e) 
  {
    try
    {
      btnSubmit.Color = Color.Red;
    } 
    catch(Exception ex)
    {
      ErrorLog(ex);
      throw;
    }

    SuccessLog();
  }	

  private static processed ErrorLog(Exception ex)
  {
    // some error logic here
  }

  private static processed SuccessLog([System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
  {
    // some success logic here
  }
}

Следующий шаг, это проверка сигнатур. _Основные_ сигнатуры идентичны и удовлетворяют условию что определения в original и processed должны быть абсолютно одинаковы.

В этот пример я специально добавил ещё одно небольшое предложение, это модификатор processed для методов, свойств и полей.

Он помечает методы, свойства и поля как доступные только классам с модификатором processed и которые игнорируются при сравнении сигнатур. Это сделано для удобства разработчиков аспектов и позволяет выносить общую логику в отдельные методы, чтобы не создавать излишнюю избыточность кода.

Компилятор скомпилировал этот код и если всё ок, то взял байт код для продолжения процесса.

Понятно, что в данном примере идёт некоторое упрощение и в реальности логика может быть сложнее (например когда мы включаем оба original и partial для одного класса), но это не непреодолимая сложность.

Основная функциональность IDE в идеальном мире


Поддержка работы с исходным кодом файлов .processed.cs в IDE заключается в основном в корректной навигации между original/processed классами и переходов при пошаговой отладки.

Вторая по важности функция IDE (с моей точки зрения) это помощь в чтении кода processed классов. Processed класс может содержать множество частей кода, которые были добавлены несколькими аспектами. Реализация отображения которая похожа на концепцию слоёв в графическом редакторе представляется нам самым удобным вариантом для достижения данной цели. Наш текущей плагин реализует нечто подобное и реакция его пользователей вполне положительна.

Ещё одна функция которая бы помогла внедрению AOP в повседневную жизнь, это функциональность refactoring, т.е. пользователь выделив часть кода мог бы сказать «Extract To AOP Template» и IDE создала правильные файлы, сгенерировала первоначальный код и проанализировав код проекта предложила кандидатов на использование шаблона из других классов.

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

Я уверен, что если за делов возьмутся создатели решарпера, то магия обеспечена.

Написание кода аспекта в идеальном мире


Если перефразировать ТРИЗ, то идеальное написание кода для реализации аспектов, это отсутствие написания дополнительного кода, который существует только для поддержки процессов инструментария.

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

Второе желание, это возможность иметь интерактивный plug&play, т.е. написав шаблон, нам бы не требовалось совершать дополнительные шаги, для того чтобы он мог быть использован для трансформации. Не требовалось перекомпилировать инструмент, отлавливать его ошибки и т.п. А также настраивать опции в проектах для посткомпиляции.

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

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

Наша текущая реализация


К сожалению мы живём не в идеальном мире, поэтому приходится изобретать велосипеды и ездить на них.

Внедрение кода, компиляция и отладка


Наша текущая модель заключается в создании двух копий проекта. Одна это исходная, та с которой работает программист, вторая трансформированная, которая используется для компиляции и исполнения.

Сценарий примерно такой

  • Программист работает с оригинальной версией кода, реализует логику, компилирует проект чтобы обнаружить ошибки времени компиляции и т.п.
  • Когда он удовлетворён результатом и готов протестировать результаты своего труда, то запускается скрипт командной строки, который запускает процесс трансформации файлов и при успешной завершении трансформации, запускает процесс сборки.
  • После завершения сборки в зависимости от типа проекта запускается либо браузер, который открывает тестовый веб сайт для веб проекта, либо десктоп программа, если это WPF проект, либо автотесты и т.п.

Для отладки запускается вторая копия IDE, открывается странсформированная копия проекта и он работает с копией к которой была применена трансформация.

Процесс требует определённой дисциплины, но со времен приходит привычка и в определённых случаях подобный подход имеет некоторые преимущества (например билд можно запускать и деплоить на удалённый сервер, вместо того чтобы работать с локальной машиной). Плюс помощь со стороны плагина в VisualStudio упрощает процесс.

IDE


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

Например функциональность по отображению слоёв, в стиле подобному графическому редактору позволяет например скрывать/показывать слои комментариев, по области видимости (например чтобы были видены только публичные методы), регионам. Внедренный код обрамляется комментариями специального формата и они также могут быть скрыты как отдельный слой.

Еще одна возможность, это показать diff между оригинальным и трансформированным файлом. так как IDE знает относительное расположение копии файла в проекте, то может отобразить различия между оригинальными и странсформированными файлами.

Также плагин предупреждает при попытке внесения изменений в странсформированную копию (чтобы не потерять их при последующей перетрансформации)

Конфигурация


Отдельной задачей стоит задание правил трансформации, т.е. к каким классам и методам мы будем применять трансформацию.

Мы используем несколько уровней.

Первый уровень это конфигурационный файл верхнего уровня. Мы можем задать правила в зависимости от пути на файловой системе, паттернов в имени файлов, классов или методов, областей видимости классов, методов или свойств.

Второй уровень это указание на применение правил трансформации на уровне атрибутов классов, методов или полей.

Третий на уровне блока кода и четвёртый это явное указание на включение результатов трансформации шаблона в конкретное место в исходном коде.

Шаблоны


Исторически сложилось что для целей автоматической генерации мы используем шаблоны в формате T4, поэтому вполне логично было использовать этот же подход и в качестве шаблонов для трансформации. Шаблоны T4 включают в себя возможность исполнять произвольный код на C#, имеют минимальный оверхэд и хорошую выразительность.

Для тех кто никогда не работал с T4, самым простым аналогом будет представить ASPX формат, который вместо HTML генерирует исходный код на C# и исполняется не на IIS, а отдельной утилитой с выводом результата на консоль (или в файл).

Примеры


Для понимания как это работает в реальности, самое простое это будет продемонстрировать код до и после трансформации и исходный код шаблонов который применяется в течении трансформации. Я продемонстрирую самые простые варианты, но потенциальные возможности ограничивается только вашим воображением.

Исходный код примера перед трансформацией
// ##aspect=AutoComment

using AOP.Common;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Aspectimum.Demo.Lib
{

    [AopTemplate("ClassLevelTemplateForMethods", NameFilter = "First")]
    [AopTemplate("StaticAnalyzer", Action = AopTemplateAction.Classes)]
    [AopTemplate("DependencyInjection", AdvicePriority = 500, Action = AopTemplateAction.PostProcessingClasses)]
    [AopTemplate("ResourceReplacer", AdvicePriority = 1000, ExtraTag = "ResourceFile=Demo.resx,ResourceClass=Demo", Action = AopTemplateAction.PostProcessingClasses)]
    public class ConsoleDemo
    {
        public virtual Person FirstDemo(string firstName, string lastName, int age)
        {
            Console.Out.WriteLine("FirstDemo: 1");

            // ##aspect="FirstDemoComment" extra data here

            return new Person()
            {
                FirstName = firstName,
                LastName = lastName,
                Age = age,
            };
        }

        private static IConfigurationRoot _configuration = inject;
        private IDataService _service { get; } = inject;
        private Person _somePerson = inject;

        [AopTemplate("LogExceptionMethod")]
        [AopTemplate("StopWatchMethod")]
        [AopTemplate("MethodFinallyDemo", AdvicePriority = 100)]
        public Customer[] SecondDemo(Person[] people)
        {
            IEnumerable<Customer> Customers;

            Console.Out.WriteLine("SecondDemo: 1");

            Console.Out.WriteLine(i18("SecondDemo: i18"));

            int configDelayMS = inject;
            string configServerName = inject;

            using (new AopTemplate("SecondDemoUsing", extraTag: "test extra"))
            {

                Customers = people.Select(s => new Customer()
                {
                    FirstName = s.FirstName,
                    LastName = s.LastName,
                    Age = s.Age,
                    Id = s.Id
                });

                _service.Init(Customers);

                foreach (var customer in Customers)
                {
                    Console.Out.WriteLine(i18($"First Name {customer.FirstName} Last Name {customer.LastName}"));
                    Console.Out.WriteLine("SecondDemo: 2 " + i18("First Name ") + customer.FirstName + i18(" Last Name   ") + customer.LastName);
                }
            }

            Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));
            Console.Out.WriteLine($"Server {configServerName} default delay {configDelayMS}");
            Console.Out.WriteLine($"Customer for ID=5 is {_service.GetCustomerName(5)}");

            return Customers.ToArray();
        }

        protected static string i18(string s) => s;
        protected static dynamic inject;

        [AopTemplate("NotifyPropertyChangedClass", Action = AopTemplateAction.Classes)]
        [AopTemplate("NotifyPropertyChanged", Action = AopTemplateAction.Properties)]
        public class Person
        {
            [AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]
            public string FullName
            {
                get
                {
                    // ##aspect="FullNameComment" extra data here
                    return $"{FirstName} {LastName}";
                }
            }

            public int Id { get; set; }
            public string FirstName { get; set; }
            public string LastName { get; set; }
            public int Age { get; set; }
        }

        [AopTemplate("NotifyPropertyChanged", Action = AopTemplateAction.Properties)]
        public class Customer : Person
        {
            public double CreditScore { get; set; }
        }

        public interface IDataService
        {
            void Init(IEnumerable<Customer> customers);
            string GetCustomerName(int customerId);
        }

        public class DataService: IDataService
        {
            private IEnumerable<Customer> _customers;
            public void Init(IEnumerable<Customer> customers)
            {
                _customers = customers;
            }

            public string GetCustomerName(int customerId)
            {
                return _customers.FirstOrDefault(w => w.Id == customerId)?.FullName;
            }
        }

        public class MockDataService : IDataService
        {
            private IEnumerable<Customer> _customers;
            public void Init(IEnumerable<Customer> customers)
            {
                if(customers == null)
                    throw (new Exception("IDataService.Init(customers == null)"));
            }

            public string GetCustomerName(int customerId)
            {
                if (customerId < 0)
                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be negative"));

                if (customerId == 0)
                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be zero"));

                return $"FirstName{customerId} LastName{customerId}";
            }
        }
    }
}


Полная версия исходного кода после трансформации
//------------------------------------------------------------------------------
// <auto-generated> 
//     This code was generated from a template.
// 
//     Manual changes to this file may cause unexpected behavior in your application.
//     Manual changes to this file will be overwritten if the code is regenerated.
//
//  Generated base on file: ConsoleDemo.cs
//  ##sha256: ekmmxFSeH5ev8Epvl7QvDL+D77DHwq1gHDnCxzeBWcw
//  Created By: JohnSmith
//  Created Machine: 127.0.0.1
//  Created At: 2020-09-19T23:18:07.2061273-04:00
//
// </auto-generated>
//------------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;

namespace Aspectimum.Demo.Lib
{
    public class ConsoleDemo
    {
        public virtual Person FirstDemo(string firstName, string lastName, int age)
        {
            Console.Out.WriteLine("FirstDemo: 1");
            // FirstDemoComment replacement extra data here
            return new Person()
            {FirstName = firstName, LastName = lastName, Age = age, };
        }

        private static IConfigurationRoot _configuration = new ConfigurationBuilder()
            .SetBasePath(System.IO.Path.Combine(AppContext.BaseDirectory))
            .AddJsonFile("appsettings.json", optional: true)
            .Build();
        
        private IDataService _service { get; } = new DataService();

#error Cannot find injection rule for Person _somePerson
        private Person _somePerson = inject;

        public Customer[] SecondDemo(Person[] people)
        {
            try
            {
#error variable "Customers" doesn't match code standard rules
                IEnumerable<Customer> Customers;
                
                Console.Out.WriteLine("SecondDemo: 1");

#error Cannot find resource for a string "SecondDemo: i18", please add it to resources
                Console.Out.WriteLine(i18("SecondDemo: i18"));

                int configDelayMS = Int32.Parse(_configuration["delay_ms"]);
                string configServerName = _configuration["server_name"];
                {
                    // second demo test extra
                    {
                        Customers = people.Select(s => new Customer()
                        {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, Id = s.Id});
                        _service.Init(Customers);
                        foreach (var customer in Customers)
                        {
                            Console.Out.WriteLine(String.Format(Demo.First_Last_Names_Formatted, customer.FirstName, customer.LastName));
                            Console.Out.WriteLine("SecondDemo: 2 " + (Demo.First_Name + " ") + customer.FirstName + (" " + Demo.Last_Name + "   ") + customer.LastName);
                        }
                    }
                }

#error Argument for i18 method must be either string literal or interpolated string, but instead got Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax
#warning Please replace String.Format with string interpolation format.
                Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));
                Console.Out.WriteLine($"Server {configServerName} default delay {configDelayMS}");
                Console.Out.WriteLine($"Customer for ID=5 is {_service.GetCustomerName(5)}");

                return Customers.ToArray();
            }
            catch (Exception logExpn)
            {
                Console.Error.WriteLine($"Exception in SecondDemo\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");
                throw;
            }
        }

        protected static string i18(string s) => s;
        protected static dynamic inject;
        public class Person : System.ComponentModel.INotifyPropertyChanged
        {
            public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
            protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
            {
                PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
            }

            public string FullName
            {
                get
                {
                    System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;
                    string cachedData = cache["name_of_cache_key"] as string;
                    if (cachedData == null)
                    {
                        cachedData = GetPropertyData();
                        if (cachedData != null)
                        {
                            cache.Set("name_of_cache_key", cachedData, System.DateTimeOffset.Now.AddMinutes(10));
                        }
                    }

                    return cachedData;
                    string GetPropertyData()
                    {
                        // FullNameComment FullName
                        return $"{FirstName} {LastName}";
                    }
                }
            }

            private int _id;
            public int Id
            {
                get
                {
                    return _id;
                }

                set
                {
                    if (_id != value)
                    {
                        _id = value;
                        NotifyPropertyChanged();
                    }
                }
            }

            private string _firstName;
            public string FirstName
            {
                get
                {
                    return _firstName;
                }

                set
                {
                    if (_firstName != value)
                    {
                        _firstName = value;
                        NotifyPropertyChanged();
                    }
                }
            }

            private string _lastName;
            public string LastName
            {
                get
                {
                    return _lastName;
                }

                set
                {
                    if (_lastName != value)
                    {
                        _lastName = value;
                        NotifyPropertyChanged();
                    }
                }
            }

            private int _age;
            public int Age
            {
                get
                {
                    return _age;
                }

                set
                {
                    if (_age != value)
                    {
                        _age = value;
                        NotifyPropertyChanged();
                    }
                }
            }
        }

        public class Customer : Person
        {
            private double _creditScore;
            public double CreditScore
            {
                get
                {
                    return _creditScore;
                }

                set
                {
                    if (_creditScore != value)
                    {
                        _creditScore = value;
                        NotifyPropertyChanged();
                    }
                }
            }
        }

        public interface IDataService
        {
            void Init(IEnumerable<Customer> customers);
            string GetCustomerName(int customerId);
        }

        public class DataService : IDataService
        {
            private IEnumerable<Customer> _customers;
            public void Init(IEnumerable<Customer> customers)
            {
                _customers = customers;
            }

            public string GetCustomerName(int customerId)
            {
                return _customers.FirstOrDefault(w => w.Id == customerId)?.FullName;
            }
        }

        public class MockDataService : IDataService
        {
            private IEnumerable<Customer> _customers;
            public void Init(IEnumerable<Customer> customers)
            {
                if (customers == null)
                    throw (new Exception("IDataService.Init(customers == null)"));
            }

            public string GetCustomerName(int customerId)
            {
                if (customerId < 0)
                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be negative"));
                if (customerId == 0)
                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be zero"));
                return $"FirstName{customerId} LastName{customerId}";
            }
        }
    }
}

// ##template=AutoComment sha256=Qz6vshTZl2/u+NgtcV4u5W5RZMb9JPkJ2Zj0yvQBH9w
// ##template=AopCsharp.ttinclude sha256=2QR7LE4yvfWYNl+JVKQzvEBwcWvReeupVpslWTSWQ0c
// ##template=FirstDemoComment sha256=eIleHCim5r9F/33Mv9B7pcNQ/dlfEhDVXJVhA7+3OgY
// ##template=FullNameComment sha256=2/Ipn8fk2y+o/FVQHAWnrOlhqS5ka204YctZkwl/CUs
// ##template=NotifyPropertyChangedClass sha256=sxRrSjUSrynQSPjo85tmQywQ7K4fXFR7nN2mX87fCnk
// ##template=StaticAnalyzer sha256=zmJsj/FWmjqDDnpZXhoAxQB61nYujd41ILaQ4whcHyY
// ##template=LogExceptionMethod sha256=+zTre3r3LR9dm+bLPEEXg6u2OtjFg+/V6aCnJKijfcg
// ##template=NotifyPropertyChanged sha256=PMgorLSwEChpIPnEWXfEuUzUm4GO/6pMmoJdF7qcgn8
// ##template=CacheProperty sha256=oktDGTfC2hHoqpbKkeNABQaPdq6SrVLRFEQdNMoY4zE
// ##template=DependencyInjection sha256=nPq/ZxVBpgrDzyH+uLtJvD1aKbajKinX/DUBQ4BGG9g
// ##template=ResourceReplacer sha256=ZyUljjKKj0jLlM2nUIr1oJc1L7otYUI8WqWN7um6NxI




Пояснения и код шаблонов


Шаблон AutoComment

// ##aspect=AutoComment

Если в исходном коде мы встречается комментарий в специальном формате, то исполняем заданный шаблон (в данном случае это AutoComment) и вставляем результат трансформации вместо этого комментария. В этом примере имеет смысл автоматически вставлять специальный дисклаймер который предупредит программиста что код в этом файле это результат трансформации и не имеет смысла изменять этот файл напрямую.

Код шаблона AutoComment.t4

<#@ include file="AopCsharp.ttinclude" #>

//------------------------------------------------------------------------------
// <auto-generated> 
//     This code was generated from a template.
// 
//     Manual changes to this file may cause unexpected behavior in your application.
//     Manual changes to this file will be overwritten if the code is regenerated.
//
//  Generated base on file: <#= FileName #>
//  ##sha256: <#= FileSha256 #>
//  Created By: <#= User #>
//  Created Machine: <#= MachineName #>
//  Created At: <#= Now #>
//
// </auto-generated>
//------------------------------------------------------------------------------

Переменные FileName, FileSha256, User, MachineName и Now экспортируются в шаблон из процесса трансформации.

Результат трансформации

//------------------------------------------------------------------------------
// <auto-generated> 
//     This code was generated from a template.
// 
//     Manual changes to this file may cause unexpected behavior in your application.
//     Manual changes to this file will be overwritten if the code is regenerated.
//
//  Generated base on file: ConsoleDemo.cs
//  ##sha256: PV3lHNDftTzVYnzNCZbKvtHCbscT0uIcHGRR/NJFx20
//  Created By: EuGenie
//  Created Machine: 192.168.0.1
//  Created At: 2017-12-09T14:49:26.7173975-05:00
//
// </auto-generated>
//------------------------------------------------------------------------------

Следующая трансформация задаётся как атрибут класса

[AopTemplate(«ClassLevelTemplateForMethods», NameFilter=«First»)]

Этот атрибут сигнализирует что шаблон необходимо применить ко всем методам класса, содержащим слово «First». Параметр NameFilter это паттерн для регулярного выражения которое применяется при определении какие методы включать в трансформацию.

Код шаблона ClassLevelTemplateForMethods.t4

<#@ include file="AopCsharp.ttinclude" #>

// class level template
<#= MethodStart() #><#= MethodBody() #><#= MethodEnd() #>

Это простейший пример который добавляет комментарий // class level template перед кодом метода

Результат трансформации

// class level template
public virtual Person FirstDemo(string firstName, string lastName, int age)
{
  Console.Out.WriteLine("FirstDemo: 1");

  // ##aspect="FirstDemoComment" extra data here

  return new Person()
      {
        FirstName = firstName,
        LastName = lastName,
        Age = age,
      };
}

Следующие трансформации задаются как атрибуты метода, для демонстрации множественных трансформации применяемых к одному и тому же методу.

[AopTemplate("LogExceptionMethod")]
[AopTemplate("StopWatchMethod")]
[AopTemplate("MethodFinallyDemo", AdvicePriority = 100)]


Шаблон LogExceptionMethod.t4
<#@ include file="AopCsharp.ttinclude" #>
<# EnsureUsing("System"); #>
<#= MethodStart() #>
try
{
<#= MethodBody() #>
} 
catch(Exception logExpn)
{
	Console.Error.WriteLine($"Exception in <#= MethodName #>\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");
	throw;
}

<#= MethodEnd() #>

Шаблон StopWatchMethod.t4
<#@ include file="AopCsharp.ttinclude" #>
<# EnsureUsing("System.Diagnostics"); #>
<#= MethodStart() #>

var stopwatch = Stopwatch.StartNew(); 

try
{
<#= MethodBody() #>
} 
finally
{
	stopwatch.Stop();
	Console.Out.WriteLine($"Method <#= MethodName #>: {stopwatch.ElapsedMilliseconds}");

}

<#= MethodEnd() #>

Шаблон MethodFinallyDemo.t4
<#@ include file="AopCsharp.ttinclude" #>

<#= MethodStart() #>
try
{
<#= MethodBody() #>
} 
finally 
{
	// whatever logic you need to include for a method
}

<#= MethodEnd() #>

Результат трансформаций
public Customer[] SecondDemo(Person[] people)
{
    try
    {
        var stopwatch = Stopwatch.StartNew();
        try
        {
            try
            {
                IEnumerable<Customer> customers;
                Console.Out.WriteLine("SecondDemo: 1");
                {
                    // second demo test extra
                    {
                        customers = people.Select(s => new Customer()
                        {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, });
                        foreach (var customer in customers)
                        {
                            Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");
                        }
                    }
                }

                Console.Out.WriteLine("SecondDemo: 3");
                return customers.ToArray();
            }
            catch (Exception logExpn)
            {
                Console.Error.WriteLine($"Exception in SecondDemo\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");
                throw;
            }
        }
        finally
        {
            stopwatch.Stop();
            Console.Out.WriteLine($"Method SecondDemo: {stopwatch.ElapsedMilliseconds}");
        }
    }
    finally
    {
    // whatever logic you need to include for a method
    }
}

Следующая трансформация задаётся для блока ограниченного в конструкцию using

using (new AopTemplate("SecondDemoUsing", extraTag: "test extra"))
{
    customers = people.Select(s => new Customer()
    {
        FirstName = s.FirstName,
        LastName = s.LastName,
        Age = s.Age,
    });

    foreach (var customer in customers)
    {
        Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");
    }
}

Шаблон SecondDemoUsing.t4
<#@ include file="AopCsharp.ttinclude" #>

// second demo <#= ExtraTag #>

<#= StatementBody() #>

ExtraTag это строка которая передаётся в параметре. Это может быть полезно для универсальных шаблонов которые могут иметь слегка отличное поведение в зависимости от входных параметров.

Результат трансформации

{
  // second demo test extra
  {
      customers = people.Select(s => new Customer()
      {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, });
      foreach (var customer in customers)
      {
          Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");
      }
  }
}

Следующая трансформация задаётся атрибутами класса

[AopTemplate("NotifyPropertyChangedClass", Action = AopTemplaceAction.Classes)]
[AopTemplate("NotifyPropertyChanged", Action = AopTemplaceAction.Properties)]


NotifyPropertyChanged это классический пример, который наряду с примером логирования приводится в большинстве примеров аспектно-ориентированного программирования.

Шаблон NotifyPropertyChangedClass.t4 применяется к коду класса
<#@ include file="AopCsharp.ttinclude" #>
<#
	// the class already implements INotifyPropertyChanged, nothing to do here
	if(ImplementsBaseType(ClassNode, "INotifyPropertyChanged", "System.ComponentModel.INotifyPropertyChanged"))
		return null;

	var classNode = AddBaseTypes<ClassDeclarationSyntax>(ClassNode, "System.ComponentModel.INotifyPropertyChanged"); 
#>

<#= ClassStart(classNode) #>
            public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;

            protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
            {
                PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
            }

<#= ClassBody(classNode) #>
<#= ClassEnd(classNode) #>

Можно сравнить как подобная функциональность реализуется вплетением байт кода.

На примере реализации для Fogy
using Mono.Cecil;
using Mono.Cecil.Cil;
using Mono.Cecil.Rocks;

public partial class ModuleWeaver
{
    public void InjectINotifyPropertyChangedInterface(TypeDefinition targetType)
    {
        targetType.Interfaces.Add(new InterfaceImplementation(PropChangedInterfaceReference));
        WeaveEvent(targetType);
    }

    void WeaveEvent(TypeDefinition type)
    {
        var propertyChangedFieldDef = new FieldDefinition("PropertyChanged", FieldAttributes.Private | FieldAttributes.NotSerialized, PropChangedHandlerReference);
        type.Fields.Add(propertyChangedFieldDef);
        var propertyChangedField = propertyChangedFieldDef.GetGeneric();

        var eventDefinition = new EventDefinition("PropertyChanged", EventAttributes.None, PropChangedHandlerReference)
            {
                AddMethod = CreateEventMethod("add_PropertyChanged", DelegateCombineMethodRef, propertyChangedField),
                RemoveMethod = CreateEventMethod("remove_PropertyChanged", DelegateRemoveMethodRef, propertyChangedField)
            };

        type.Methods.Add(eventDefinition.AddMethod);
        type.Methods.Add(eventDefinition.RemoveMethod);
        type.Events.Add(eventDefinition);
    }

    MethodDefinition CreateEventMethod(string methodName, MethodReference delegateMethodReference, FieldReference propertyChangedField)
    {
        const MethodAttributes Attributes = MethodAttributes.Public |
                                            MethodAttributes.HideBySig |
                                            MethodAttributes.Final |
                                            MethodAttributes.SpecialName |
                                            MethodAttributes.NewSlot |
                                            MethodAttributes.Virtual;

        var method = new MethodDefinition(methodName, Attributes, TypeSystem.VoidReference);

        method.Parameters.Add(new ParameterDefinition("value", ParameterAttributes.None, PropChangedHandlerReference));
        var handlerVariable0 = new VariableDefinition(PropChangedHandlerReference);
        method.Body.Variables.Add(handlerVariable0);
        var handlerVariable1 = new VariableDefinition(PropChangedHandlerReference);
        method.Body.Variables.Add(handlerVariable1);
        var handlerVariable2 = new VariableDefinition(PropChangedHandlerReference);
        method.Body.Variables.Add(handlerVariable2);

        var loopBegin = Instruction.Create(OpCodes.Ldloc, handlerVariable0);
        method.Body.Instructions.Append(
            Instruction.Create(OpCodes.Ldarg_0),
            Instruction.Create(OpCodes.Ldfld, propertyChangedField),
            Instruction.Create(OpCodes.Stloc, handlerVariable0),
            loopBegin,
            Instruction.Create(OpCodes.Stloc, handlerVariable1),
            Instruction.Create(OpCodes.Ldloc, handlerVariable1),
            Instruction.Create(OpCodes.Ldarg_1),
            Instruction.Create(OpCodes.Call, delegateMethodReference),
            Instruction.Create(OpCodes.Castclass, PropChangedHandlerReference),
            Instruction.Create(OpCodes.Stloc, handlerVariable2),
            Instruction.Create(OpCodes.Ldarg_0),
            Instruction.Create(OpCodes.Ldflda, propertyChangedField),
            Instruction.Create(OpCodes.Ldloc, handlerVariable2),
            Instruction.Create(OpCodes.Ldloc, handlerVariable1),
            Instruction.Create(OpCodes.Call, InterlockedCompareExchangeForPropChangedHandler),
            Instruction.Create(OpCodes.Stloc, handlerVariable0),
            Instruction.Create(OpCodes.Ldloc, handlerVariable0),
            Instruction.Create(OpCodes.Ldloc, handlerVariable1),
            Instruction.Create(OpCodes.Bne_Un_S, loopBegin), // go to begin of loop
            Instruction.Create(OpCodes.Ret));
        method.Body.InitLocals = true;
        method.Body.OptimizeMacros();

        return method;
    }
}

Честно говоря, подобный код немного пугает неофитов AOP в .Net

Шаблон NotifyPropertyChanged.t4 применяется к свойствам класса
<#@ include file="AopCsharp.ttinclude" #>
<#
 	if(!(PropertyHasEmptyGetBlock() && PropertyHasEmptySetBlock()))
		return null;

	string privateUnqiueName = GetUniquePrivatePropertyName(ClassNode, PropertyNode.Identifier.ToString());
#>

	private <#= PropertyNode.Type.ToFullString() #> <#= privateUnqiueName #><#= PropertyNode.Initializer != null ? " = " + PropertyNode.Initializer.ToFullString() : "" #>;

<#= PropertyNode.AttributeLists.ToFullString() + PropertyNode.Modifiers.ToFullString() + PropertyNode.Type.ToFullString() + PropertyNode.Identifier.ToFullString() #>
	{
		get { return <#= privateUnqiueName #>; }
		set 
		{
			if(<#= privateUnqiueName #> != value)
			{
				<#= privateUnqiueName #> = value;
				NotifyPropertyChanged();
			}
		}
	}

Оригинальный код класса и свойств
public class Person
{
    public int Id { get; set; }

// ...
}

Результат трансформации
public class Person : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
    protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
    }

    private int _id;
    public int Id
    {
        get
        {
            return _id;
        }

        set
        {
            if (_id != value)
            {
                _id = value;
                NotifyPropertyChanged();
            }
        }
    }

// ...
}

Пример шаблона для кэширования результатов свойства, он задаётся атрибутом

[AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]

Параметры шаблона заданы в виде JSON. При отсутствии явных параметров, используются параметры по умолчанию.

Шаблон CacheProperty.t4
<#@ include file="AopCsharp.ttinclude" #>
<#
	// The template accepts a configuration value from extraTag in two ways
	// 1. as a number of minutes to use for expiration (example: 8)
	// 2. as a string in JSON in format { CacheKey: "name_of_cache_key", CacheKeyVariable: "name_of_variable", ExpiresInMinutes: 10, ExpiresVariable: "name_of_variable" }
	//
	//    CacheKey (optional) name of the cache key, the name will be used as a literal string (example: my_key)
	//    CacheKeyVariable (optional) name of variable that holds the cache key (example: GlobalConsts.MyKeyName)
	//
	//    ExpiresInMinutes (optional) number minutes that the cache value will expires (example: 12)
	//    ExpiresVariable (optional) name of a variable that the expiration value will be get from (example: AppConfig.EXPIRE_CACHE)
	//
	// if any of expiration values are not specified, 5 minutes default expiration will be used

	if(!PropertyHasAnyGetBlock())
		return null;

	const int DEFAULT_EXPIRES_IN_MINUTES = 5;

	string propertyName = PropertyNode.Identifier.ToFullString().Trim();
	string propertyType = PropertyNode.Type.ToFullString().Trim();
	string expiresInMinutes = DEFAULT_EXPIRES_IN_MINUTES.ToString();
	string cacheKey = "\"" + ClassNode.Identifier.ToFullString() + ":" + propertyName + "\"";

	if(!String.IsNullOrEmpty(ExtraTag))
	{
		if(Int32.TryParse(ExtraTag, out int exp))
		{
			expiresInMinutes = exp.ToString();
		}
		else
		{
			JsonDocument json = ExtraTagAsJson();
			if(json != null && json.RootElement.ValueKind  == JsonValueKind.Object)
			{
				if(json.RootElement.TryGetProperty("CacheKey", out JsonElement cacheKeyElement))
				{
					string s = cacheKeyElement.GetString();
					if(!String.IsNullOrEmpty(s))
						cacheKey = "\"" + s + "\"";
				}
				else if(json.RootElement.TryGetProperty("CacheKeyVariable", out JsonElement cacheVariableElement))
				{
					string s = cacheVariableElement.GetString();
					if(!String.IsNullOrEmpty(s))
						cacheKey = s;
				}

				if(json.RootElement.TryGetProperty("ExpiresInMinutes", out JsonElement expiresInMinutesElement))
				{
					if(expiresInMinutesElement.TryGetInt32(out int v) && v > 0)
						expiresInMinutes = "" + v;
				} 
				else if(json.RootElement.TryGetProperty("ExpiresVariable", out JsonElement expiresVariableElement))
				{				
					string s = expiresVariableElement.GetString();
					if(!String.IsNullOrEmpty(s))
						expiresInMinutes = s;
				}
			}
		}
	}

#>


<#= PropertyDefinition() #>
	{
		get 
		{ 
			System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;			

			<#= propertyType #> cachedData = cache[<#= cacheKey #>] as <#= propertyType #>;
			if(cachedData == null)
			{
				cachedData = GetPropertyData();
				if(cachedData != null)
				{					
					cache.Set(<#= cacheKey #>, cachedData, System.DateTimeOffset.Now.AddMinutes(<#= expiresInMinutes #>)); 
				}
			}

			return cachedData;

			<#= propertyType #> GetPropertyData()
			{
				<# if(PropertyNode.ExpressionBody != null ) { #>
				return (<#= PropertyNode.ExpressionBody.Expression.ToFullString() #>);
				<# } else if(PropertyNode?.AccessorList?.Accessors.FirstOrDefault(w => w.ExpressionBody != null && w.Keyword.ToString() == "get") != null) { #>
				return (<#= PropertyNode?.AccessorList?.Accessors.FirstOrDefault(w => w.ExpressionBody != null && w.Keyword.ToString() == "get").ExpressionBody.Expression.ToFullString() #>);
				<# } else { #>
				<#= PropertyGetBlock() #>
				<# } #>
			}
       }

		<#
		
		if(PropertyHasAnySetBlock()) { #>
		set 
		{
			System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;  

			cache.Remove(<#= cacheKey #>); // invalidate cache for the property		
			
			<#= PropertySetBlock() #>			
		}
		<# } #>

	}

Исходный код
[AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]
public string FullName
{
    get
    {
        return $"{FirstName} {LastName}";
    }
}

Результат трансформации для CacheProperty.t4
public string FullName
{
    get
    {
        System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;
        string cachedData = cache["name_of_cache_key"] as string;
        if (cachedData == null)
        {
            cachedData = GetPropertyData();
            if (cachedData != null)
            {
                cache.Set("name_of_cache_key", cachedData, System.DateTimeOffset.Now.AddMinutes(10));
            }
        }

        return cachedData;
        string GetPropertyData()
        {
            // FullNameComment FullName
            return $"{FirstName} {LastName}";
        }
    }
}

Следующий вызов шаблона опять из комментария
// ##aspect="FullNameComment" extra data here

Шаблон FullNameComment.t4
<#@ include file="AopCsharp.ttinclude" #>

// FullNameComment <#= PropertyNode.Identifier #>

Очень похож на шаблон AutoComment.t4, но здесь демонстрируем использование PropertyNode. Также шаблону FullNameComment.t4 доступны данные «extra data here» через параметр ExtraTag (но в данном примере мы их не используем, поэтому они просто игнорируется)

Результат трансформации
// FullNameComment FullName

Следующая трансформация в файле задаётся атрибутом класса

[AopTemplate("NotifyPropertyChanged", Action = AopTemplaceAction.Properties)]

И идентична таковой для класса Person. Исходный код шаблона NotifyPropertyChanged.t4 уже был включен выше по тексту.

Результат трансформации
public class Customer : Person
{
    private double _creditScore;
    public double CreditScore
    {
        get
        {
            return _creditScore;
        }

        set
        {
            if (_creditScore != value)
            {
                _creditScore = value;
                NotifyPropertyChanged();
            }
        }
    }
}

Заключительная часть


Хотя данная статья акцентируется на аспектно-ориентированном программировании, техника трансформации исходного кода универсальна и в принципе можно ее использовать для задач которые не имеют отношения к AOP.

Например можно использовать для dependency injection, т.е. мы меняем код создания ресурсов в зависимости от параметров сборки.

Шаблон DependencyInjection.t4
<#@ include file="AopCsharp.ttinclude" #>
<#
	var syntaxNode = FieldsInjection(SyntaxNode);
	syntaxNode = VariablesInjection(syntaxNode);
	syntaxNode = PropertiesInjection(syntaxNode);	

	if(syntaxNode == SyntaxNode)
		return null;
#>

<#= syntaxNode.ToFullString() #>

<#+
	private SyntaxNode VariablesInjection(SyntaxNode syntaxNode)
	{
		return RewriteNodes<LocalDeclarationStatementSyntax >(syntaxNode, OnLocalVariablesInjection);	
	
		SyntaxNode OnLocalVariablesInjection(LocalDeclarationStatementSyntax node)
		{
			var errorMsgs = new System.Text.StringBuilder();

			SyntaxNode syntaxNode = RewriteNodes<VariableDeclaratorSyntax>(node, (n) => OnVariableDeclaratorVisit(n, node.Declaration.Type, errorMsgs));

			if(errorMsgs.Length > 0)
				return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());

			return syntaxNode;
		}
	}

	private SyntaxNode PropertiesInjection(SyntaxNode syntaxNode)
	{
		return RewriteNodes<PropertyDeclarationSyntax>(syntaxNode, OnPropertyInjection);	
	
		SyntaxNode OnPropertyInjection(PropertyDeclarationSyntax node)
		{
			if(node.Initializer?.Value?.ToString() != "inject")
				return node;

			var errorMsgs = new System.Text.StringBuilder();

			SyntaxNode syntaxNode = DoInjection(node, node.Identifier.ToString().Trim(), node.Initializer.Value, node.Type, errorMsgs);

			if(errorMsgs.Length > 0)
				return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());

			return syntaxNode;
		}
	}

	private SyntaxNode FieldsInjection(SyntaxNode syntaxNode)
	{
		return RewriteNodes<BaseFieldDeclarationSyntax>(syntaxNode, OnFieldsInjection);	
	
		SyntaxNode OnFieldsInjection(BaseFieldDeclarationSyntax node)
		{
			var errorMsgs = new System.Text.StringBuilder();

			SyntaxNode syntaxNode = RewriteNodes<VariableDeclaratorSyntax>(node, (n) => OnVariableDeclaratorVisit(n, node.Declaration.Type, errorMsgs));

			if(errorMsgs.Length > 0)
				return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());

			return syntaxNode;
		}
	}

	private SyntaxNode OnVariableDeclaratorVisit(VariableDeclaratorSyntax node, TypeSyntax typeSyntax, System.Text.StringBuilder errorMsgs)
	{
		if(node.Initializer?.Value?.ToString() != "inject")
			return node;

		return DoInjection(node, node.Identifier.ToString().Trim(), node.Initializer.Value, typeSyntax, errorMsgs);
	}

	private SyntaxNode DoInjection(SyntaxNode node, string varName, ExpressionSyntax initializerNode, TypeSyntax typeSyntax, System.Text.StringBuilder errorMsgs)
	{		
		string varType = typeSyntax.ToString().Trim();

		Log($"{varName} {varType} {initializerNode.ToString()}");

		if(varName.StartsWith("config"))
		{
			string configName = Regex.Replace(Regex.Replace(varName, "^config", ""), "([a-z])([A-Z])", (m) => m.Groups[1].Value + "_" + m.Groups[2].Value).ToLower();
			ExpressionSyntax configNode = CreateElementAccess("_configuration", CreateStringLiteral(configName));

			if(varType == "int")
			{
				configNode = CreateMemberAccessInvocation("Int32", "Parse", configNode);
			}

			return node.ReplaceNode(initializerNode, configNode);
		}

		switch(varType)
		{
			case "Microsoft.Extensions.Configuration.IConfigurationRoot":
			case "IConfigurationRoot":
				EnsureUsing("Microsoft.Extensions.Configuration");

				ExpressionSyntax pathCombineArg = CreateMemberAccessInvocation("System.IO.Path", "Combine", CreateMemberAccess("AppContext", "BaseDirectory"));

				ExpressionSyntax builderNode = CreateNewType("ConfigurationBuilder").WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));
				builderNode  = CreateMemberAccessInvocation(builderNode, "SetBasePath", pathCombineArg).WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));

				ExpressionSyntax addJsonFileArg = CreateMemberAccessInvocation("System.IO.Path", "Combine", CreateMemberAccess("AppContext", "BaseDirectory"));

				builderNode  = CreateMemberAccessInvocationNamedArgs(builderNode, "AddJsonFile", 
																		(null, CreateStringLiteral("appsettings.json")), 
																		("optional",  SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))).WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));

				if(GetGlobalSetting("env")?.ToLower() == "test")
				{
					builderNode  = CreateMemberAccessInvocationNamedArgs(builderNode, "AddJsonFile", 
																			(null, CreateStringLiteral("appsettings.test.json")), 
																			("optional",  SyntaxFactory.LiteralExpression(SyntaxKind.FalseLiteralExpression)));
				}

				builderNode  = CreateMemberAccessInvocation(builderNode, "Build");

				return node.ReplaceNode(initializerNode, builderNode);
				
			case "IDataService":
			{
				string className = (GetGlobalSetting("env")?.ToLower() == "test" ? "MockDataService" : "DataService");

				return node.ReplaceNode(initializerNode, CreateNewType(className));
			}
		}

		errorMsgs.AppendLine($"Cannot find injection rule for {varType} {varName}");

		return node;
	}

#>


В исходном коде (здесь используется особенность dynamic переменных, которая позволяет присваивать их любым типам), т.е. для выразительности мы как бы придумали новое ключевое слово.
private static IConfigurationRoot _configuration = inject;
private IDataService _service { get; } = inject;
// ...
public Customer[] SecondDemo(Person[] people)
{
     int configDelayMS = inject; // we are going to inject dependency to local variables
     string configServerName = inject;
}
// ...
protected static dynamic inject;

При трансформации используется сравнение GetGlobalSetting(«env») == «test» и в зависимости от этого условия, будет внедрено или new DataService() или new MockDataService().

Результат трансформации

private static IConfigurationRoot _configuration = new ConfigurationBuilder()
    .SetBasePath(System.IO.Path.Combine(AppContext.BaseDirectory))
    .AddJsonFile("appsettings.json", optional: true)
    .Build();

private IDataService _service { get; } = new DataService();
// ...
public Customer[] SecondDemo(Person[] people)
{
       int configDelayMS = Int32.Parse(_configuration["delay_ms"]);
       string configServerName = _configuration["server_name"];
}
// ...

Или можно использовать этот инструмент как «poor man» static analysis (но гораздо-гораздо более правильно это реализовать анализаторы используя родную функциональность Roslyn), мы анализируем код на наши правила и вставляем в исходный код

#error our error message here

Что приведёт к ошибке времени компиляции.

#warning our warning message here

Что послужит предупреждению в IDE или при компиляции.

Шаблон StaticAnalyzer.t4
<#@ include file="AopCsharp.ttinclude" #>
<#
	var syntaxNode = AnalyzeLocalVariables(SyntaxNode);
	syntaxNode = AnalyzeStringFormat(syntaxNode);	

	if(syntaxNode == SyntaxNode)
		return null;
#>

<#= syntaxNode.ToFullString() #>

<#+

	private SyntaxNode AnalyzeLocalVariables(SyntaxNode syntaxNode)
	{
		return RewriteNodes<LocalDeclarationStatementSyntax>(syntaxNode, OnAnalyzeLocalVariablesNodeVisit);	
	
		SyntaxNode OnAnalyzeLocalVariablesNodeVisit(LocalDeclarationStatementSyntax node)
		{
			var errorMsgs = new System.Text.StringBuilder();
			
			string d = "";
			foreach(VariableDeclaratorSyntax variableNode in node.DescendantNodes().OfType<VariableDeclaratorSyntax>().Where(w => Regex.IsMatch(w.Identifier.ToString(), "^[A-Z]")))
			{
				LogDebug($"variable: {variableNode.Identifier.ToString()}");

				errorMsgs.Append(d + $"variable \"{variableNode.Identifier.ToString()}\" doesn't match code standard rules");
				d = ", ";
			}

			if(errorMsgs.Length > 0)
				return AddErrorMessageTrivia(node, errorMsgs.ToString());

			return node;
		}
	}


	private SyntaxNode AnalyzeStringFormat(SyntaxNode syntaxNode)
	{
		return RewriteLeafStatementNodes(syntaxNode, OnAnalyzeStringFormat);	
	
		SyntaxNode OnAnalyzeStringFormat(StatementSyntax node)
		{
			bool hasStringFormat = false;

			foreach(MemberAccessExpressionSyntax memberAccessNode in node.DescendantNodes().OfType<MemberAccessExpressionSyntax>())
			{
				if(memberAccessNode.Name.ToString().Trim() != "Format")
					continue;

				string expr = memberAccessNode.Expression.ToString().Trim().ToLower();
				if(expr != "string" && expr != "system.string")
					continue;

				hasStringFormat = true;
				break;
			}

			if(hasStringFormat)
				return AddWarningMessageTrivia(node, "Please replace String.Format with string interpolation format.");

			return node;
		}
	}
#>


Результат трансформации
#error variable "Customers" doesn't match code standard rules
IEnumerable<Customer> Customers;
// ...
#warning Please replace String.Format with string interpolation format.
Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));

Или как автоматический инструмент для локализации приложения, т.е. находим все строки в классах и заменяем их на использование соответствующих ресурсов.

Шаблон ResourceReplacer.t4
<#@ include file="AopCsharp.ttinclude" #>
<#

	Dictionary<string, string> options = ExtraTagAsDictionary();
	_resources = LoadResources(options["ResourceFile"]);
	_resourceClass = options["ResourceClass"];

	var syntaxNode = RewriteLeafStatementNodes(SyntaxNode, OnStatementNodeVisit);	
#>

<#= syntaxNode.ToFullString() #>

<#+ 
	private SyntaxNode OnStatementNodeVisit(StatementSyntax node)
	{
		if(!node.DescendantNodes().OfType<InvocationExpressionSyntax>().Any(w => (w.Expression is IdentifierNameSyntax) && ((IdentifierNameSyntax)w.Expression).Identifier.ToString() == "i18"  ))
			return node;

		var errorMsgs = new System.Text.StringBuilder();

		SyntaxNode syntaxNode = RewriteNodes<InvocationExpressionSyntax>(node, (n) => OnInvocationExpressionVisit(n, errorMsgs));

		if(errorMsgs.Length > 0)
			return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());

		return syntaxNode;
	}

    private SyntaxNode OnInvocationExpressionVisit(InvocationExpressionSyntax node, System.Text.StringBuilder errorMsgs)
	{
		if(!(node.Expression is IdentifierNameSyntax && ((IdentifierNameSyntax)node.Expression).Identifier.ToString() == "i18"  ))
			return node;

		ArgumentSyntax arg = node.ArgumentList.Arguments.Single(); // We know that i18 method accepts only one argument. Keep in mind that it is just a demo and in real life you could be more inventive
		
		var expr = arg.Expression;
		if(!(expr is LiteralExpressionSyntax || expr is InterpolatedStringExpressionSyntax))
		{
			errorMsgs.AppendLine($"Argument for i18 method must be either string literal or interpolated string, but instead got {arg.Expression.GetType().ToString()}");

			return node;
		}
		
		string s = expr.ToString();
		if(s.StartsWith("$"))
		{
			(string format, List<ExpressionSyntax> expressions) = ConvertInterpolatedStringToFormat((InterpolatedStringExpressionSyntax)expr);

			ExpressionSyntax stringNode = ReplaceStringWithResource("\"" + format + "\"", errorMsgs);
			if(stringNode != null)
			{
				var memberAccess = CreateMemberAccess("String", "Format");
			
				var arguments = new List<ArgumentSyntax>();
	
				arguments.Add(SyntaxFactory.Argument(stringNode));
				expressions.ForEach(item => arguments.Add(SyntaxFactory.Argument(item)));

				var argumentList = SyntaxFactory.SeparatedList(arguments);

				return SyntaxFactory.InvocationExpression(memberAccess, SyntaxFactory.ArgumentList(argumentList));
			}
		}
		else
		{
			SyntaxNode stringNode = ReplaceStringWithResource(s, errorMsgs);
			if(stringNode != null)
				return stringNode;
		}

		return node;
	}

	private ExpressionSyntax ReplaceStringWithResource(string s, System.Text.StringBuilder errorMsgs)
	{
		Match m = System.Text.RegularExpressions.Regex.Match(s, "^\"(\\s*)(.*?)(\\s*)\"$");
		if(!m.Success)
		{
			errorMsgs.AppendLine($"String doesn't match search criteria");

			return null;
		}

		if(!_resources.TryGetValue(m.Groups[2].Value, out string resourceName))
		{

			errorMsgs.AppendLine($"Cannot find resource for a string {s}, please add it to resources");
			return null;
		}

		string csharpName = Regex.Replace(resourceName, "[^A-Za-z0-9]", "_");

		ExpressionSyntax stringNode = CreateMemberAccess(_resourceClass, csharpName);

		if(!String.IsNullOrEmpty(m.Groups[1].Value) || !String.IsNullOrEmpty(m.Groups[3].Value))
		{
			if(!String.IsNullOrEmpty(m.Groups[1].Value))
			{
				stringNode = SyntaxFactory.BinaryExpression(SyntaxKind.AddExpression, 
																CreateStringLiteral(m.Groups[1].Value), 
																stringNode);
			}

			if(!String.IsNullOrEmpty(m.Groups[3].Value))
			{
				stringNode = SyntaxFactory.BinaryExpression(SyntaxKind.AddExpression, 
															stringNode, 
															CreateStringLiteral(m.Groups[3].Value));
			}

			stringNode = SyntaxFactory.ParenthesizedExpression(stringNode);
		}

		return stringNode;
	}	

	private string _resourceClass;
	private Dictionary<string,string> _resources;
#>


Исходный код

Console.Out.WriteLine(i18("SecondDemo: i18"));
// ...
Console.Out.WriteLine(i18($"First Name {customer.FirstName} Last Name {customer.LastName}"));

Console.Out.WriteLine("SecondDemo: 2 " + i18("First Name ") + customer.FirstName + i18(" Last Name   ") + customer.LastName);
// ...
 Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));
// ...
protected static string i18(string s) => s;

В файле ресурсов Demo.resx у нас для примера созданы следующие строки
<data name="First Last Names Formatted" xml:space="preserve">
  <value>First Name {0} Last Name {1}</value>
</data>
<data name="First Name" xml:space="preserve">
    <value>First Name</value>
</data>
<data name="Last Name" xml:space="preserve">
  <value>Last Name</value>
</data>

и автоматически сгенерированный код файла Demo.Designer.cs
public class Demo 
{
// ...

    public static string First_Last_Names_Formatted
    {
        get
        {
            return ResourceManager.GetString("First Last Names Formatted", resourceCulture);
        }
    }

    public static string First_Name
    {
        get
        {
            return ResourceManager.GetString("First Name", resourceCulture);
        }
    }

    public static string Last_Name
    {
        get
        {
            return ResourceManager.GetString("Last Name", resourceCulture);
        }
    }
}

Результат трансформации (обратите внимание что интерполированная строка была заменена на String.Format и был использован ресурс «First Name {0} Last Name {1}»). Для строк которые не существуют в файле ресурсов или не соответсвуют нашему формату, добавляется сообщение об ошибке
//#error Cannot find resource for a string "SecondDemo: i18", please add it to resources
Console.Out.WriteLine(i18("SecondDemo: i18"));
// ...
Console.Out.WriteLine(String.Format(Demo.First_Last_Names_Formatted, customer.FirstName, customer.LastName));

Console.Out.WriteLine("SecondDemo: 2 " + (Demo.First_Name + " ") + customer.FirstName + (" " + Demo.Last_Name + "   ") + customer.LastName);
// ...
//#error Argument for i18 method must be either string literal or interpolated string, but instead got Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax
Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));

Вдобавок, инструмент трансформации позволяет работать не только с файлами C#, но и с любыми типами файлов (конечно с определёнными ограничениями). Если у вас есть парсер который может построить AST для вашего языка, то можно заменить Roslyn на этот парсер, подшаманить реализацию обработчика кода и это будет работать. К сожалению библиотек с функциональностью близкой к Roslyn очень ограниченное количество и их использование требует существенно больше усилий. В добавок к C#, мы используем трансформацию для JavaScript и TypeScript проектов, но конечно не так полно как для C#.

Ещё раз повторюсь, что код примера и шаблонов приведены в качестве иллюстрации возможностей подобного подхода и как говориться — sky is the limit.

Спасибо за то что уделили своё время.

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

Наш оригинальный инструмент разработан на .Net Framework, но мы начали работу над упрощённой версией с открытым кодом под лицензией MIT для .Net Core. На текущий момент результат полностью функционален и готов на 90%, остались незначительные доработки, причёска кода, создание документации и примеров, но без всего этого будет сложно войти в проект, сама идея будет скомпрометирована и DX будет негативным.

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

Сама идея инструмента очень простая и разработчик потратил в общей сложности порядка месяца на реализацию работоспособной версии, так что я думаю, что программист с хорошей квалификацией и опытом работы с Roslyn, вполне сможет создать собственный специфичный вариант за несколько дней. На данный момент размер исходного кода проекта всего около 150KB, включая примеры и шаблоны.

Буду рад конструктивной критике (неконструктивная меня тоже не огорчит, так что не стесняйтесь).

Благодарен Филу Ранжину (fillpackart) за мотивацию в написании статьи. Канал «Мы обречены» рулит!