Pull to refresh

Aspect-Oriented Programming (AOP) by source-level weaving

Reading time 39 min
Views 1.8K
image

Aspect-oriented programming is a very appealing concept for simplifying your codebase, creating clean code, improving modularity, structure of code and minimizing copy-paste errors.

Today, in most cases, weaving aspect's advices is implemented at the bytecode level, i.e. after compilation, a certain tool «weave» an additional byte code with the support of the required logic.

Our approach (as well as the approach of some other tools) is modifying the source code to implement aspect logic. With introduction of the .NET Compiler Platform (aka Roslyn), it is quite easy to achieve this goal, and the result gives certain advantages over the modification of the byte code itself.

You may think that aspect-oriented programming is not for you and does not particularly concern you, just a bunch of incomprehensible words, but in fact it is much easier than it seems, this is about the problems of real world product development and if you are a software developer, then you and your project can definitely get benefit from using it.

Especially in medium-large sized projects of a corporate level, where the requirements for the functionality of the products are formalized. For example, there may be a requirement — if a configuration flag is set, log all input parameters for all public methods. Or for all methods in a project to have a notification functionality that will send a message when a certain threshold of the execution time of a method is exceeded.

How would you do it without AOP? Either you ignore the rule and do it only for the most important parts of your program, or when writing new methods, you do copy-paste of similar code from neighboring methods, and you could imagine what copy-paste approach brings with it.

With AOP, it is very easy, you write an advice an apply it to the whole project's code and the job is done. When you need to update the logic, you will again update just the advice and it will be re-applied on the next transformation. Without AOP, some many updates throughout the project code, that most probably you will implement the changes only to the critical parts and leave everything else as is.

On the plus side is that your code will not look like a crater covered moon landscape, because it is sprinkled with such functionality, the same way as moon sprinkled with craters and when reading the code it looks like mixed with white noise.

It is my feeling that aspect-oriented programming in .Net ecosystem is significantly less popular in compared to the Java ecosystem. In my opinion, the main reason is the lack of free and open source tools that are comparable to the functionality and quality of Java ones.

How we start using AOP in our project


To improve developers' performance and at the same time make code's quality better, we widely use the automatic code generation capabilities, and also created several plugins and analyzers for Visual Studio, that are custom tailored to our projects and tasks.

The next logical step was the idea to adopt aspect-oriented programming approach. We evaluated several tools, but the result was far from our expectations. Too complex for a middle level developer and takes too much time to develop even for a senior one. This coincided in time with the release of Roslyn technology, and at a certain moment we had an idea to combine the capabilities of automatic code generation and Roslyn.

In just a couple of weeks, a prototype of the tool was created and this approach seemed more promising. Fast forward several updates of the tool and we can say that our expectations were met and exceeded. We have developed a library of useful templates and we use this approach in most of our projects.

As you may expect, our tool is still not perfect and have some limitations, so I would like to split the article into two parts, the first one is how I see the implementation of this functionality in the perfect world and the second is how it works right now.

Before we dive deep into the details, I would like to make a disclaimer — all examples in this article have been simplified to a level that allows you to show the idea, without being overloaded with irrelevant details.

Also, as you may notice, English is not my native language, so please forgive me for any mistakes.

How it would be done in the perfect world


After several years of using our tool, I have a vision of how I would like this to work in the perfect world scenario.

In my vision, the language specifications allow the use of source code transformations, and there is support of such capabilities for a compiler and IDE.

The idea was inspired by introduction of the «partial» modifier in the C# language specification. This rather simple concept (the ability to define a class, structure or interface in several files) has significantly improved and simplified the support of tools for automatic source code generation. You can view it as a kind of horizontal splitting of the source code of a class between several files.

For those who are not fluent in the C# language, a small example.

Suppose we have a simple form described in the file Example1.aspx

<%@ Page Language="C#" AutoEventWireup="True" %>
// . . .
<asp:Button id="btnSubmit"
           Text="Submit"
           OnClick=" btnSubmit_Click" 
           runat="server"/>
// . . .

A developer implements some custom logic (for example, changing the color of the button to red when it is clicked) in the Example1.aspx.cs file

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

The «partial» capabilities allows a tool (for example Visual Studio) to parse the Example1.aspx file and automatically generate the Example1.aspx.designer.cs file.

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

In other words, we have the ability to store a part of the code for the ExamplePage1 class in one file (Example1.aspx.cs) that is updated by a programmer and antoher part of the class in the Example1.aspx.designer.cs file, that is updated by an automatically generated tool.

In the end, for a compiler, it looks like as one class, combined from two parts

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;
  }
}

As you might notice, in the example with the definition of inheritance of the IMyInterface interface, the final result is a combination of class definitions from different «partial» sections from the different files.

If the language lacks «partial» functionality and the compiler demands storing all the class code in only one file, then we can assume inconvenience and additional hurdles have to overcome for auto-generation of the code.

My idea is to include two additional modifiers in the language specification that will make it easier to embed aspects into the source code.

The first modifier is «original» keyword and we add it to the definition of the class that should be able to be transformed.

The second one is «processed» keyword, and it symbolizes that this is the final class definition that is result of the source code transformation tool and which must be accepted by the compiler to generate the bytecode.

The process sequence is something like this:

  1. An user works with the source code of a class that contains the «original» modifier in the .cs file (for example Example1.cs)
  2. When compiling, the compiler checks the correctness of the source code, and if the class is successfully compiled, it checks for the presence of the «original» modifier
  3. If «original» modifier is present, then the compiler transfer the source code to a transformation process (which is a black box for the compiler)
  4. The transformation process, based on a set of rules, modifies the source code and, upon successful completion of the process, creates .processed.cs file and .processed.cs.map files (.processed.cs.map needed to match the code between the .cs files and the .processed.cs file, to help with debugging and to display differences between the files in IDE)
  5. The compiler reads the code from the .processed.cs file (in our example it is Example1.processed.cs) and compiles it
  6. If the code in the file has been successfully compiled, then it is checked that

    a. Classes that had the «original» modifier have the «processed» modifier
    b. The signature of these classes is identical in both the .cs file and the .processed.cs file
  7. If everything is fine, then the byte code produced during compilation of the .processed.cs file is accepted for final byte code generation.

By adding these two modifiers, we were able to introduce support for source code transformation tools at the language level, the same way as «partial» modifier made it possible to simplify support for source code generation.

As I see it, implementation of support of «original»/«processed» feature in the compiler is a week of work for two interns at Microsoft (a joke of course, but only partially). There are no any fundamental difficulties in implementation of the support. From the point of view of the compiler, it is file manipulation and process invocation.

In .NET 5, a new feature was announced — source code generators, which allows you to generate new source code files during compilation process and this is a movement in the right direction. Unfortunately, it only allows you to generate new source code, but not modify the existing one. So Some work still needs to be done.

Here is an example of such process, as I envision it. An user creates file 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;
  }	
}

Starts the compilation process, if it completes successfully and the compiler finds the «original» modifier, then it gives the source code to the transformation process, which generates the Example2.processed.cs file (in the simplest case, it can be just an exact copy of Example2.cs with «original» replaced by «processed»).

In our case, lets assume that the transformation process has added an advice for a logging aspect and the result looks like this:

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
  }
}

The next step would be to verify the signatures of the «original» vs «processed» classes. The _majority_ of signatures are identical and it satisfies the condition that the definitions in «original» and «processed» versions must be exactly the same.

In this finaly «processed» code, I included an example of one more proposal, this is the «processed» modifier for methods, properties and fields.

For methods, properties, and fields the «processed» modifier identifies them as available only to classes with the «processed» modifier and which are ignored when comparing signatures. This is done for the convenience of aspect logic developers and allows them to move the common logic into separate methods so as not to create unnecessary code redundancy.

The compiler verifies this code and if everything is ok, then takes created bytecode to continue the build process.

It is clear that in this example there is some simplification and in reality the logic may be more complicated (for example, when we include both «original» and «partial» modifires into the same class).

The perfect world's IDE functionality to support source code transformations


Main requirement for IDE to support of source code transformations is correct navigation between the «original»/«processed» classes and support of step-by-step debugging.

The second feature of the IDE that I would like to have is to help in reading the code of processed classes. A «processed» class can contain many pieces of code that have been added by several adivces to the same method/property.

Implementation of displaying such code that is similar to the concept of layers in a graphics editor seems to me as the most convenient option to achieve this goal. Our current plugin implements something similar and the response from its users is quite positive.

Another feature that would help introduce AOP into everyday life is the refactoring functionality. An user, selects a part of the code, and could choose «Extract To AOP Template» option and the IDE creates the necessary template files, scaffolds the initial code and after analyzing the project code, suggests candidates for using the template from other classes.

And the icing on the cake would be support in writing aspect templates, for example, interactively applying an advice to a class / method of your choice so that you can evaluate the results of tranfromation on the fly, without an explicit compilation cycle.

I am sure that if the creators of the Resharper plugin applies their magic, the result is guaranteed.

Creating aspect source code in the perfect world


To paraphrase the TRIZ, the ideal creating of source code for the implementation of aspects logic is the absence of creating of additional code that needed only to support the instrumentation processes.

In the perfect world scenario, we would like to write source code for the aspect advices, without the effort of writing helper logic to achieve that goal. And this source code would be an integral part of the project.

The second desire is the ability to have interactive plug & play, i.e. after we have created a template, we would not need to take additional steps in order for it to be used for transformation. There would be no need to recompile the tool or configure post-compilation options for the project.

After a template was created and a developer writes a couple of lines of code, he/she would immediately see the result and if the template contains errors, their detection and debugging of the errors would be integrated into the process of applying the template, and would not be a separate part that requires additional effort from the developer.

Plus, it would be really good that the syntax of the template language would be as close as possible to the syntax of the C # language, ideally just a minor difference, a few keywords and placeholders.

Our implementation


Our current approach is to create two copies of the project. The first one is for the original source code (the one developers work with) and the second one is the transformed version of the source code, which is used for compilation, debugging, testing and execution.

The scenario is something like this

  • The programmer works with the original version of the source code, implements the logic, compiles the project to detect compile-time errors, etc.
  • When he/she is satisfied with the result and ready to start testing, the command line script is launched, which starts the file transformation process and starts the build process (of course, if the transformation is successful).
  • After the build is complete, depending on the type of project, either a browser is launched that opens a test website for a web project, or a desktop program, if it is a WPF project, or autotests, etc.

For debugging, the second copy (transformed one) of the project is opened in an IDE and a developer continues with it.

The process requires a certain discipline, but after some time it has become a habit, and in certain cases this approach has some advantages (for example, a build could be launched and deployed to a remote server, instead of working with a local computer). Plus, our custom plugin for VisualStudio makes the process easier.

IDE


We use a plugin that is custom tailored for our specific needs and processes and support for the transformation of source code is a fairly small part of its capabilities.

For example, the functionality for displaying code in layers (in a style similar to a graphical editor), allows to hide/show comment layers, by method/properties visiblity (for example, so that only public methods are visible), regions. We surround some of the transformed source code by comments in a special format, and they can also be hidden as a separate layer.

Another possibility is to show a diff between the original and the transformed file. The IDE already knows the relative location of the copy of the files in the project, so it can display the differences between the original and transformed files.

Also, the plugin warns when trying to make changes to the transformed copy (so as not to lose them during subsequent re-transformation). We even published the feature as a free and open sourced plugin for VisualSource Code.

Configuration of pointcuts


A separate topic is configuation of pointcuts for transformation rules, i.e. to which classes and methods we will apply the transformation.

For such purposes we use several levels.

The first level is the top-level configuration file. We can set rules depending on the path on the file system, patterns in the name of files, classes or methods and scopes of classes, methods or properties.

The second level is an indication of the application of transformation rules at the level of attributes of classes, methods or fields in source code of original copy.

The third at the level of the code block and the fourth is an explicit indication to include the results of the transformation of the template in a specific place in the source code.

Templates


Historically, for the purposes of automatic generation, we use templates in the T4 format, so it was quite logical to use the same approach as templates for transformation. T4 templates include the ability to execute arbitrary C# code and have minimal overhead.

For those who have never worked with T4, the simplest analogue would be to present the ASPX or Razor formats, which instead of HTML generates source code in C # and is executed not on IIS, but as a separate tool with outputting the result to the console (or to a file).

Talk is cheap. Show me the code.


To understand how this works for us in real life, the simplest way would be to demonstrate the code before and after the transformation and the source code of the templates that is used during the transformation. I'll demonstrate the simplest options, but the potential of this approach is only limited by your imagination.

An example source code
// ##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}";
            }
        }
    }
}

The example source code after transformation is applied
//------------------------------------------------------------------------------
// <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


Step-by-step explanation and template code


AutoComment template

// ##aspect=AutoComment

While processing the source code, if the tool finds a comment in a special format, then it executes the specified template (in this case, it is AutoComment) and insert the transformation result in place of this comment. In this example, it makes sense to automatically insert a special disclaimer that will warn the developer that the code in this file is the result of transformation and it makes no sense to modify this file directly.

AutoComment.t4 template source code

<#@ 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>
//------------------------------------------------------------------------------

The variables FileName, FileSha256, User, MachineName, and Now are exported to the template from the transformation process.

Result of the transformation

//------------------------------------------------------------------------------
// <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>

The next transformation is define by a class attribute

[AopTemplate("ClassLevelTemplateForMethods", NameFilter="First")]
public virtual Person FirstDemo(string firstName, string lastName, int age)
{
  Console.Out.WriteLine("FirstDemo: 1");

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

This attribute signals that the ClassLevelTemplateForMethods template should be applied to all methods of a class containing the word «First» in the method's name. The NameFilter parameter is a regular expression pattern that is used to determine which methods to include in the transformation.

ClassLevelTemplateForMethods.t4 template source code

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

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

Result of the transformation

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

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

The next transformations are defined by method attributes to demonstrate abilities of multiple transformations applied to the same method.

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

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

// ...

    return Customers.ToArray();
}

LogExceptionMethod.t4 template source code

<#@ 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 template source code

<#@ 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 template source code

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

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

<#= MethodEnd() #>

Result of the transformation

public Customer[] SecondDemo(Person[] people)
{
    try
    {
        var stopwatch = Stopwatch.StartNew();
        try
        {
            try
            {
                IEnumerable<Customer> customers;

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

                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
    }
}

The next example of the transformation is defined by a block bounded by a using statement

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 template source code

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

Console.Out.WriteLine("Extra Tag data: <#= ExtraTag #>");

<#= StatementBody() #>

ExtraTag is a string that is passed as a parameter. This can be useful for templates that can have slightly different behavior depending on the input parameters.

Result of the transformation

{
      Console.Out.WriteLine("Extra Tag data: 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}");
      }
}

The next transformations are defined by class attributes and will be applied on a class and properties levels.

AopTemplate("NotifyPropertyChangedClass", Action = AopTemplaceAction.Classes)]
[AopTemplate("NotifyPropertyChanged", Action = AopTemplaceAction.Properties)]
public class Person
{
    public int Id { get; set; }

// ...
}

This is a classic example, which along with the logging example is given in most examples of aspect-oriented programming.

NotifyPropertyChangedClass.t4 template source code that will be applied on a class level
<#@ 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) #>

Here is an example of similar functionality that is implemented by weaving byte code.
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;
    }
}

Just looking at it makes me uncomfortable.

NotifyPropertyChanged.t4 template source сode that will be applied on properties level
<#@ 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();
			}
		}
	}

Result of the transformation:

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();
            }
        }
    }

// ...
}

The next transformation for caching property results, it is defined by the attribute:

[AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]
public string FullName
{
    get
    {
        return $"{FirstName} {LastName}";
    }
}

CacheProperty.t4 template source code
It is quite long, but most of the code is an example of using JSON from ExtraTag property as a config setting

<#@ 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() #>			
		}
		<# } #>

	}

Result of the transformation:

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}";
        }
    }
}

Non-AOP templates


Although this article focuses on aspect-oriented programming, the source code transformation technique is universal and can be used for tasks that are not directly related to AOP.

For example, it can be used for dependency injection, i.e. we change the resource creation code depending on the build parameters.

The transformation is defined by an attribute:

[AopTemplate("DependencyInjection", AdvicePriority = 500, Action = AopTemplateAction.PostProcessingClasses)]
public class ConsoleDemo
{
// ...

    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;

// ...
}

As you see, in the source code the dynamic variable feature is used, which allows them to be assigned to a property/field/variable of any types. For the sake of clarity, we have introduced some kind of quasi keyword.

DependencyInjection.t4 template source code
<#@ 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;
	}

#>

In the template code we use the comparison of GetGlobalSetting ("env") == "test" and depending on this condition, either new DataService() or new MockDataService() will be injected.

Result of the transformation:


public class ConsoleDemo
{
// ...

    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"];
    }

// ...
}

Also, the tool might be used as a «poor man» static analysis (but it is much, much better to implement analyzers using the native functionality of Roslyn). We analyze the code for our rules and insert #error/#warning directives into the source code when we want to raise an error or warning.

The transformation is defined by

[AopTemplate("StaticAnalyzer", Action = AopTemplateAction.Classes)]
public class ConsoleDemo
{
// ..
         IEnumerable<Customer> Customers;
// ..
         Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "Step 3")));
// ..
}

StaticAnalyzer.t4 template source code
<#@ 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;
		}
	}
#>

Result of the transformation:

public class ConsoleDemo
{
// ..
         #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}", "Step 3")));
// ..
}

Another way to use the tool for purposes localizing an application, i.e. find all the strings in the classes and replace them with the use of the appropriate resources.

The transformation is defined by

[AopTemplate("ResourceReplacer", AdvicePriority = 1000, ExtraTag = "ResourceFile=Demo.resx,ResourceClass=Demo", Action = AopTemplateAction.PostProcessingClasses)]
public class ConsoleDemo
{
// ..
    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}", "Step 3")));

// ...
    protected static string i18(string s) => s;
}

ResourceReplacer.t4 template source code
<#@ 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;
#>

As an example, in the resource file Demo.resx, we have created the following lines:

<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>

And VisualStudio generated C# class for us from content of Demo.resx
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);
        }
    }
}

Result of the transformation:


public class ConsoleDemo
{
// ..
    #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}", "Step 3")));
// ...
}

Please note that the interpolated string was replaced with String.Format and the «First Name {0} Last Name {1}» resource was used. For lines that do not exist in the resource file or do not match our format, an error message is added.

Conclusion


The tool gives you a very powerful and at the same time quite simple way to introduce Aspect-Oriented Programming into your projects.

You can give it a try for a whole project, a folder or even just one file, to evaluate how it goes.

In addition, the transformation tool allows you to work not only with C# files, but also with any file type (of course, with certain limitations). If you have a parser that can build an AST for your language, then you can replace Roslyn with this parser, tweak the implementation of the code handler and it will work. Unfortunately, the number of libraries with functionality close to Roslyn is very limited and their use requires much more effort. In addition to C#, we use transforms for JavaScript and TypeScript projects, but certainly not as comprehensively as for C#. And using T4 which is based on C# and to produce output in a different language is not optimal as well.

Just a reminder, the code of the examples and templates are provided as an illustration of the possibilities of such an approach and in real life, as they say, sky is the limit.

Our original tool is developed for the .Net Framework, but we started work on a simplified open source version named Aspectimum, under the MIT license for .Net Core. At the moment, the result is fully functional and 90% ready, there are need for minor improvements, refactor of the code, the creation of documentation and examples, but without all this, barrier to entry will be quite high and DX might be negative.

The project's repository at github.com

If you have any questions or ideas, please feel free to contact me.
Tags:
Hubs:
0
Comments 0
Comments Leave a comment

Articles