Aspect Oriented Programming (AOP) via source code





Aspect-oriented programming is a very attractive concept for simplifying your codebase, generating clean code, and minimizing copy-paste errors.



Today, in most cases, aspects are implemented at the bytecode level, i.e. after compilation, a tool "interweaves" 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 the transition to Roslyn technology, it is very easy to achieve this, and the result gives certain advantages over the modification of the byte code itself.



If you are interested in the details, please see cat.



You can think that aspect-oriented programming is not about 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 product development and if you are engaged in industrial development, then you can definitely get benefit from using it.



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



How is this done without AOP? Either it is hammered in and done only for the most important parts, or when writing new methods, copy-paste of similar code from neighboring methods, with all accompanying methods, goes.



When using AOP, an advice is written once that is applied to the project and the job is done. When you need to update the logic a little, you will again update the advice once and it will be applied on the next build. Without AOP, that's 100,500 updates throughout the project code.



The plus is that your code stops looking like a person who has had smallpox, due to the fact that it is strewn with such functionality and when reading the code it looks like annoying noise.



After introducing AOP into your project, you start implementing things that you never dreamed of without it, because they looked like a relatively small advantage, at a large cost. With AOP, everything is exactly the opposite, relatively low costs and great benefits (for a similar level of cost of your efforts).



My feeling is that aspect-oriented programming is significantly less popular in the .Net ecosystem compared to the Java ecosystem. I think the main reason is the lack of free and open source tools that are comparable to the functionality and quality of Java.



PostSharp provides similar functionality and convenience, but not many are willing to pay hundreds of dollars to use it in their projects, and the community version is very limited in its capabilities. Of course there are alternatives, but unfortunately they have not reached the level of PostSharp.



You can compare the capabilities of the tools (it must be borne in mind that the comparison was made by the owner of PostSharp, but it gives some picture).



Our path to aspect-oriented programming



We are a small consulting company (12 people) and the end result of our work is the source code. Those. we get paid to create source code, quality code. We only work in one industry and many of our projects have very similar requirements and as a result, the source code is also quite similar between these projects.



And since we are limited in resources, then for us, one of the most important tasks is the ability to reuse code and use tools that save the developer from routine tasks.



To achieve this, one of the ways is that we make extensive use of the automatic code generation capabilities, and also created several custom plugins and analyzers for Visual Studio specific to our projects and tasks. This made it possible to significantly increase the productivity of programmers, while maintaining the high quality of the code (one might even say that the quality has become higher).



The next logical step was the idea to implement the use of aspect-oriented programming. We tried several approaches and tools, but the result was far from our expectations. This coincided in time with the release of Roslyn technology, and at a certain moment we had the idea to combine the capabilities of automatic code generation and Roslyn.



In just a couple of weeks, a prototype of the instrument was created and, according to our feelings, this approach seemed more promising. After several iterations in the way of using and updating this tool, we can say that our expectations were met and even more than we expected. We have developed a library of useful templates and we use this approach in most of our projects, and some of our clients also use it and even order the development of templates for their needs.



Unfortunately, our tool is still far from ideal, so I would like to split the description into two parts, the first is how I see the implementation of this functionality in an ideal world and the second is how it is done here.



Before we get to the details, I would like to make a small explanation - all examples in this article have been simplified to a level that allows you to show the idea, without being overloaded with irrelevant details.



How it would be done in a perfect world



After several years of using our tool, I had a vision of how I would like this to work if we lived in an ideal world.



In my vision of an ideal world, the language specifications allow the use of source code transformations, and there is compiler and IDE support.



The idea was inspired by the inclusion 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 dramatically improved and simplified the support of tools for automatic source code generation. Those. it is a kind of horizontal splitting of the source code of a class between several files. For those who do not know 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"/>
// . . .


And custom logic (for example, changing the button color 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 presence in the language of the capabilities provided by "partial" allows the toolkit 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;
}


Those. we have the ability to store a part of the code for the ExamplePage1 class in one file by the updatable programmer (Example1.aspx.cs) and a part in the Example1.aspx.designer.cs file by the automatically generated toolkit. To the compiler, in the end, it looks like one general class



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


Using the example with the definition of inheritance of the IMyInterface interface, you can see that the final result is a combination of class definitions from different files.



If we do not have functionality like partial and the compiler requires storing all the class code in only one file, then we can assume inconvenience and additional gestures necessary to support auto-generation.



Accordingly, my idea is to include two additional modifiers in the language specification, which will make it easier to embed aspects into the source code.



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



The second is processed and it symbolizes that this is the final class definition that was obtained by the source transformation tool and that must be accepted by the compiler to generate the bytecode.



The sequence is something like this



  1. The user works with the source code of the 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 original
  3. If original is present, then the compiler gives the source code of this file to the transformation process (which is a black box for the compiler).
  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 .


Those. by adding these two modifiers, we were able to organize language-level support for source code transformation tools, just as partial made it easier to support source code generation. Those. parial is horizontal code splitting, original / processed is vertical.



As I see it, implementing support for original / processed in the compiler is a week of work for two interns at Microsoft (a joke, of course, but it's not far from the truth). By and large, there are no fundamental difficulties in this task, from the point of view of the compiler it is file manipulation and process invocation.



A new feature was added in .NET 5 - source code generatorswhich already allows you to generate new source code files during compilation 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 we're still waiting.



An example of a similar process. 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;
  }	
}


Runs for compilation, if everything is ok and the compiler sees 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 just be an exact copy of Example2.cs with original replaced by processed) ...



In our case, we will assume that the transformation process has added 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 is to verify the signatures. The _main_ signatures are identical and satisfy the condition that the definitions in original and processed must be exactly the same.



In this example, I specially added one more small sentence, this is the processed modifier for methods, properties and fields.



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



The compiler compiled this code and if everything is ok, then took the byte code to continue the 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 for one class), but this is not insurmountable complexity.



Basic IDE functionality in a perfect world



Support for working with the source code of .processed.cs files in the IDE is mainly in the correct navigation between the original / processed classes and transitions during step-by-step debugging.



The second most important feature of the IDE (from my point of view) 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 aspects. Implementation of a display that is similar to the concept of layers in a graphics editor seems to us 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. the user, highlighting a part of the code, could say "Extract To AOP Template" and the IDE created the correct files, generated the initial code and after analyzing the project code, suggested candidates for using a template from other classes.



Well, the icing on the cake would be support for writing aspect templates, for example, interactively applying an aspect to a class / method of your choice so that you can evaluate the final result on the fly, without an explicit compilation cycle on your part.



I am sure that if the creators of the resharper take over the business, then the magic is guaranteed.



Writing aspect code in a perfect world



To paraphrase TRIZ, the ideal writing of code for the implementation of aspects is the absence of writing additional code that exists only to support the tooling processes.



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



The second desire is the ability to have interactive plug & play, i.e. having written a template, we would not need to take additional steps in order for it to be used for transformation. There was no need to recompile the tool, catch its errors, etc. And also configure options in projects for post-compilation.



Having created a template and writing a couple of lines, I would immediately see the result and if it contains errors, their detection and debugging would be integrated into the process of applying the template, and not be a separate part that requires additional effort from the programmer.



Well, so that the syntax of the template is as close as possible to the syntax of the C # language, ideally a minor add-on, plus a few keywords and placeholders.



Our current implementation



Unfortunately, we do not live in a perfect world, so we have to reinvent bicycles and ride them.



Code injection, compilation and debugging



Our current model is to create two copies of the project. One is the original one, the one with which the programmer works, the second is the transformed one, which is used for compilation and execution.



The scenario is something like this



  • , , ..
  • , , , .
  • , , , WPF , ..


For debugging, the second copy of the IDE is launched, a country-formed copy of the project is opened and it works with the copy to which the transformation was applied.



The process requires a certain discipline, but from time to time it becomes a habit and in certain cases this approach has some advantages (for example, a build can be launched and deployed to a remote server, instead of working with a local machine). Plus the help from the plugin in VisualStudio simplifies the process.



IDE



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



For example, the functionality for displaying layers, in a style similar to a graphical editor, allows, for example, to hide / show comment layers, by scope (for example, so that only public methods are visible), regions. The embedded code is surrounded by comments of 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. since the IDE knows the relative location of the copy of the file in the project, it can display the differences between the original and country-generated files.



Also, the plugin warns when trying to make changes to the country-generated copy (so as not to lose them during the subsequent re-transformation)



Configuration



A separate task is to set the transformation rules, i.e. to which classes and methods we will apply the transformation.



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



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, have minimal overhead and good expressiveness.



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



Examples of



To understand how this works in reality, the simplest thing is 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 is only limited by your imagination.



Sample source code before transformation
// ##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}";
            }
        }
    }
}




Full version of the source code after 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: 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







Explanations and template code



AutoComment Template



// ##aspect=AutoComment


If in the source code we encounter a comment in a special format, then we execute the specified template (in this case, it is AutoComment) and insert the transformation result instead of this comment. In this example, it makes sense to automatically insert a special disclaimer that will warn the programmer that the code in this file is the result of transformation and it makes no sense to modify this file directly.



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


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



Transformation result



//------------------------------------------------------------------------------
// <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 following transformation is specified as an attribute of the class



[AopTemplate ("ClassLevelTemplateForMethods", NameFilter = "First")]



This attribute signals that the template should be applied to all class methods containing the word "First". The NameFilter parameter is a regular expression pattern that is used to determine which methods to include in the transformation.



Template code ClassLevelTemplateForMethods.t4



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

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


This is the simplest example that adds a comment // class level templatebefore the method code



Transformation result



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


The following transformations are specified as method attributes to demonstrate multiple transformations applied to the same method. LogExceptionMethod.t4 Template



[AopTemplate("LogExceptionMethod")]

[AopTemplate("StopWatchMethod")]

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






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

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

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

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

<#= MethodEnd() #>


Result of transformations

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


The following transformation is given for a block limited to a using construct



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

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

// second demo <#= ExtraTag #>

<#= StatementBody() #>


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



Transformation result



{
  // 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}");
      }
  }
}


The following transformation is specified by the attributes of the NotifyPropertyChanged class. This is a classic example, which along with the logging example is given in most examples of aspect-oriented programming.



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

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








NotifyPropertyChangedClass.t4 template is applied to class code
<#@ 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 template applied to class properties
<#@ 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();
			}
		}
	}


Original code of class and properties

public class Person
{
    public int Id { get; set; }

// ...
}


Transformation result

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

// ...
}


An example of a template for caching property results, it is specified by the



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



Template parameters are specified as JSON attribute . If there are no explicit parameters, the default parameters are used.



CacheProperty.t4 Template
<#@ 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() #>			
		}
		<# } #>

	}


Source

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


Transformation result for 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}";
        }
    }
}


The next call to the template again from the comment

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


FullNameComment.t4 Template

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

// FullNameComment <#= PropertyNode.Identifier #>


Very similar to AutoComment.t4 template, but here we demonstrate the use of PropertyNode. Also, the "extra data here" data is available to the FullNameComment.t4 template through the ExtraTag parameter (but in this example we do not use them, so they are simply ignored)



Transformation result

// FullNameComment FullName


The following transformation in the file is specified by the



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



AND attribute is identical to that of the Person class. The source code for the NotifyPropertyChanged.t4 template has already been included above.



Transformation result

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

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


Final part



Although this article focuses on aspect-oriented programming, the source code transformation technique is universal and, in principle, can be used for tasks that are not 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.



DependencyInjection.t4 Template
<#@ 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 source code (here the dynamic variable feature is used, which allows them to be assigned to any types), i.e. for expressiveness, we kind of came up with a new keyword.

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;


During transformation, the comparison GetGlobalSetting ("env") == "test" is used and depending on this condition, either new DataService () or new MockDataService () will be injected.



Transformation result


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


Or you can use this tool as a "poor man" static analysis (but it is much, much more correct to implement analyzers using the native Roslyn functionality), we analyze the code for our rules and insert it into the source code.



#error our error message here



That will lead to a compile-time error.



#warning our warning message here



Which will serve as a warning in the IDE or when compiling.



StaticAnalyzer.t4 Template
<#@ 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;
		}
	}
#>




Transformation result

#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")));


Or as an automatic tool for localizing an application, i.e. find all the strings in the classes and replace them with the use of the appropriate resources.



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




Source


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;


In the resource file Demo.resx, for example, 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 the auto-generated code of the Demo.Designer.cs file
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);
        }
    }
}


Transformation result (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

//#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")));


In addition, the transformation tool allows you to work not only with C # files, but also with any file type (of course, with certain restrictions). 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, there are a very limited number of libraries with functionality close to Roslyn 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 #.



Once again, I repeat that the example code and templates are given as an illustration of the possibilities of such an approach and, as they say - sky is the limit.



Thanks for your time.



The main part of this article was written a couple of years ago, but unfortunately, due to certain reasons, it was possible to publish it only now.



Our original tool is developed on the .Net Framework, but we started work on a simplified open source version under the MIT license for .Net Core. At the moment, the result is fully functional and 90% ready, there are minor improvements, hairstyle of the code, creation of documentation and examples, but without all this it will be difficult to enter the project, the idea itself will be compromised and DX will be negative.



The person who worked on its creation could not finish it before moving to another company, so before allocating resources to continue the work, we want to look at the reaction of the community, since we understand that what is suitable in our case is not necessarily in demand and is quite possible, that this niche is being filled by some alternative tool or approach to development.



The very idea of ​​the tool is very simple and the developer spent a total of about a month on the implementation of a workable version, so I think that a programmer with good qualifications and experience with Roslyn will be able to create his own specific version in a few days. At the moment, the size of the project source code is only about 150KB, including examples and templates.



I would be glad to receive constructive criticism (non-constructive criticism will not upset me either, so do not hesitate).



Thanks to Phil Rangin (fillpackart) for motivation in writing the article. Channel "We Are Doomed" rules!



All Articles