草庐IT

让代码帮我们写代码(一)

敏杰的学习园地 2023-03-28 原文

Hello,大家好,又是好久不见,最近太忙了(借口)。看了下日志,有 2 个月没写文章了。为了证明公众号还活着,今天必须更新一下了。

在我们的开发过程中,总有那么些需求是那么的变态。常规的方案已经无法满足。比如某些规则非常复杂,而客户又经常要修改它。那么我们可能需要把这部分代码直接做为配置文件提取出来。在每次修改后直接热更新进我们的程序。比如我们做低代码工具的时候可能需要根据用户的输入直接动态生成某些类型。再比如我们做 BI 工具的时候可能需要根据用户选择的表直接动态生成 Entity 的类型。碰到类似需求的时候我们该怎么办?今天就来整理一下 .NET 平台关于动态代码生成的一些技术方案。

ClassDescription

    public class ClassDescription
    {
        public string ModuleName { get; set; }

        public string AssemblyName { get; set; }

        public string ClassName { get; set; }

        public List<PropertyDescription> Properties { get; set; }
    }

    public class PropertyDescription
    {
        public string Name { get; set; }

        public Type Type { get; set; }
    }

在正式开始编写动态代码生成的核心代码之前,首先我们定义一个 ClassDescription 类来帮助描述需要生成的 class 长啥样。里面主要是描述了一些类名,属性名,属性类型等信息。

Emit

在 .NET Core 之前我们要动态生成一个 class 那么几乎 Emit 是首先技术。当然 Emit 在 .NET Core 中依然可以使用。System.Reflection.Emit 的命名空间这样的,所以很明显还是反射技术的一种。普通的反射可能只是动态来获取程序集里的元数据,然后操作或者运行它。而 Emit 可以完全动态的创建一个程序集或者类。那么让我们看看怎么用 Emit 来动态生成一个 class 吧。
比如我们现在需要动态生成一个 User 类,如果正常编写那么大概长这样:

public class User {
    public string Name { get;set;}
    public int Age {get;set;}
}

下面让我们来用 Emit 动态创建它:
首先,用 ClassDescription 来定义 User 类,它里面有 2 个属性 Name,Age。

        var userClassDesc = new ClassDescription()
            {
                AssemblyName = "X",
                ModuleName = "X",
                ClassName = "User",
                Properties = new List<PropertyDescription> {
                    new PropertyDescription {
                        Type = typeof(string),
                        Name = "Name"
                    },
                    new PropertyDescription
                    {
                        Type = typeof(int),
                        Name = "Age"
                    }
                }
            };

接着就是正式使用 Emit 来编写这个类了。整个过程大概可以分这么几步:

  1. 定义 assembly
  2. 定义 module
  3. 定义 class
  4. 定义 properties

上面的代码,如果看过 IL 的同学就比较熟悉了,这个代码基本就是在手写 IL 了。其中要注意的是:属性的定义要分 2 步,除了定义属性外,还需要定义 Get Set 方法,然后跟属性关联起来。因为大家都知道,属性其实只是封装了方法而已。

   public Type Generate(ClassDescription clazz)
        {
            MethodAttributes getSetAttr =
               MethodAttributes.Public | MethodAttributes.SpecialName |
                   MethodAttributes.HideBySig;

            // define class
            var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(clazz.AssemblyName), AssemblyBuilderAccess.Run);
            var moduleBuilder = assemblyBuilder.DefineDynamicModule(clazz.ModuleName);
            var typeBuilder = moduleBuilder.DefineType(clazz.ClassName, TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.AutoClass | TypeAttributes.AnsiClass);

            foreach (var item in clazz.Properties)
            {
                var propName = item.Name;
                var fieldName = $"_{propName}";
                var typee = item.Type;

                //define field
                var fieldBuilder = typeBuilder.DefineField(fieldName,
                                                             typee,
                                                            FieldAttributes.Private);
                //define property
                var propBuilder = typeBuilder.DefineProperty(propName, PropertyAttributes.SpecialName, typee, Type.EmptyTypes);

                //define getter
                var getPropMthdBldr = typeBuilder.DefineMethod($"get{fieldName}", getSetAttr, typee, Type.EmptyTypes);
                var getIL = getPropMthdBldr.GetILGenerator();
                getIL.Emit(OpCodes.Ldarg_0);
                getIL.Emit(OpCodes.Ldfld, fieldBuilder);
                getIL.Emit(OpCodes.Ret);
                //define setter
                var setPropMthdBldr = typeBuilder.DefineMethod($"set{fieldName}", getSetAttr, null, new Type[] { typee });
                var idSetIL = setPropMthdBldr.GetILGenerator();
                idSetIL.Emit(OpCodes.Ldarg_0);
                idSetIL.Emit(OpCodes.Ldarg_1);
                idSetIL.Emit(OpCodes.Stfld, fieldBuilder);
                idSetIL.Emit(OpCodes.Ret);

                // connect prop to getter setter
                propBuilder.SetGetMethod(getPropMthdBldr);
                propBuilder.SetSetMethod(setPropMthdBldr);
            }

            //create type
            var type = typeBuilder.CreateType();

            return type;
        }

下面让我们编写一个单元测试来测试一下:

            var userClassDesc = new ClassDescription()
            {
                AssemblyName = "X",
                ModuleName = "X",
                ClassName = "User",
                Properties = new List<PropertyDescription> {
                    new PropertyDescription {
                        Type = typeof(string),
                        Name = "Name"
                    },
                    new PropertyDescription
                    {
                        Type = typeof(int),
                        Name = "Age"
                    }
                }
            };

            var generator = new ClassGeneratorByEmit();
            var type = generator.Generate(userClassDesc);

            dynamic user = Activator.CreateInstance(type, null);
            Assert.IsNotNull(user);

            user.Name = "mj";
            Assert.AreEqual("mj", user.Name);

            user.Age = 18;
            Assert.AreEqual(18, user.Age);

获得 type 之后,我们使用反射来创建 User 的实例对象。然后通过 dynamic 来给属性赋值跟取值,避免了繁琐的反射代码。
运行上面的测试代码,单元测试绿色,通过了。

Roslyn

Roslyn 是微软最新开源的代码分析,编译工具。它提供了非常多的高级 API 来让用户在运行时分析代码,生成程序集、类。所以它现在是运行时代码生成的首选项。下面让我们看看怎么使用 Roslyn 来实现动态生成一个 User class 。
在使用 Roslyn 之前我们需要安装一个 nuget 包:

Microsoft.CodeAnalysis.CSharp

我们平时正常编写的代码,其实就是一堆字符串,通过编译器编译后变成了 IL 代码。那么使用的 Roslyn 的时候过程也是一样的。我们首先就是要使用代码来生成这个 User class 的字符串模板。然后把这段字符串交给 Roslyn 去分析与编译。编译完后就可以获得这个 class 的 Type 了。

 public Type Generate(ClassDescription clazz)
        {
            const string clzTemp =
                @"
                using System;
                using System.Runtime;
                using System.IO;

                namespace WdigetEngine 
                {
                
                    public class @className 
                    {
                        @properties
                    }
                
                }
                ";

            const string propTemp =
                @"
                public @type @propName { get;set; }
                ";

            var properties = new StringBuilder("");

            foreach (var item in clazz.Properties)
            {
                string strProp = propTemp.Replace("@type", item.Type.Name).Replace("@propName", item.Name);
                properties.AppendLine(strProp);
            }

            string sourceCode = clzTemp.Replace("@className", clazz.ClassName).Replace("@properties", properties.ToString());

            Console.Write(sourceCode);

            var syntaxTree = SyntaxFactory.ParseSyntaxTree(sourceCode);

            var compilation = CSharpCompilation.Create(
            syntaxTrees: new[] { syntaxTree },
            assemblyName: $"{clazz.AssemblyName}.dll",
            options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
            references: AppDomain.CurrentDomain.GetAssemblies().Where(x=> !x.IsDynamic).Select(x => MetadataReference.CreateFromFile(x.Location))
            );

            Assembly compiledAssembly;
            using (var stream = new MemoryStream())
            {
                var compileResult = compilation.Emit(stream);
                if (compileResult.Success)
                {
                    compiledAssembly = Assembly.Load(stream.GetBuffer());
                }
                else
                {
                    throw new Exception("Roslyn compile err .");
                }
            }
            var types = compiledAssembly.GetTypes();

            return types.FirstOrDefault(c => c.Name == clazz.ClassName);

        }

使用同样的测试用例来测试一下 :


            var generator = new ClassGeneratorByRoslyn();
            var type = generator.Generate(userClassDesc);

            dynamic user = Activator.CreateInstance(type, null);
            Assert.IsNotNull(user);

            user.Name = "mj";
            Assert.AreEqual("mj", user.Name);

            user.Age = 18;
            Assert.AreEqual(18, user.Age);

测试同样通过了。
通过以上代码我们可以发现使用 Roslyn 来动态生成代码的难度其实要比 Emit 简单不少。因为使用 Roslyn 的过程更接近于我们手写代码,而 Emit 的话是手写 IL ,显然手写 IL 对于一般同学来说是更困难的。

Natasha

如果还是觉得 Roslyn 操作起来麻烦,那么还可以使用 NCC 旗下开源项目 Natasha。Natasha 做为 Roslyn 的封装,所以放到 Roslyn 下面一起讲。
什么是 Natasha ?
Natasha 是基于 Roslyn 的 C# 动态程序集构建库,该库允许开发者在运行时使用 C# 代码构建域 / 程序集 / 类 / 结构体 / 枚举 / 接口 / 方法等,使得程序在运行的时候可以增加新的模块及功能。Natasha 集成了域管理/插件管理,可以实现域隔离,域卸载,热拔插等功能。 该库遵循完整的编译流程,提供完整的错误提示, 可自动添加引用,完善的数据结构构建模板让开发者只专注于程序集脚本的编写,兼容 netcoreapp3.0+, 跨平台,统一、简便的链式 API。

https://github.com/dotnetcore/Natasha

下面我们演示下使用 Natasha 来构建这个 User Class :
首先使用 nuget 安装 natasha 类库:

DotNetCore.Natasha.CSharp

编写 class 生成的代码:

        public Type Generate()
        {
            NClass nClass = NClass.DefaultDomain();
            nClass
              .Namespace("MyNamespace")
              .Public()
              .Name("User")
              .Property(prop => prop
                .Type(typeof(string))
                .Name("Name")
                .Public()
              )
              .Property(prop => prop
                .Type(typeof(int))
                .Name("Age")
                .Public()
              );

            return nClass.GetType();
        }

以上就是使用 natasha 动态编译一个类型的代码,代码量直线下降,而且支持链式调用,非常的优雅。

CodeDom

在没有 Roslyn 之前,微软还有一项技术 CodeDom ,同样可以根据字符串模板来运行时生成代码。他的使用跟 Roslyn 非常相似,同样是在模拟手写代码的过程。但是现在这项技术仅限于 .Net Framework 上使用了,微软并没有合并到 .NET Core 上来,github 上也有相关讨论,因为已经有了 Roslyn ,微软觉得这个技术已经没有意义了。
不管怎么样这里还是演示一下如何使用 CodeDom 来动态生成代码:

  public Type Generate(ClassDescription clazz)
        {
            const string clzTemp =
                @"
                namespace WdigetEngine {
                
                    public class @className 
                    {
                        @properties
                    }
                
                }
                ";

            const string propTemp =
                @"
                public @type @propName { get;set; }
                ";

            var properties = new StringBuilder("");

            foreach (var item in clazz.Properties)
            {
                string strProp = propTemp.Replace("@type", item.Type.Name).Replace("@propName", item.Name);
                properties.AppendLine(strProp);
            }

            string sourceCode = clzTemp.Replace("@className", clazz.ClassName).Replace("@properties", properties.ToString());

            Console.Write(sourceCode);

            var codeProvider = new CSharpCodeProvider();
            CompilerParameters param = new CompilerParameters(new string[] { "System.dll" });
            CompilerResults result = codeProvider.CompileAssemblyFromSource(param, sourceCode);
            Type t = result.CompiledAssembly.GetType(clazz.ClassName);

            return t;
        }

以上代码需要在 .NET Framework 上测试。整个过程跟 Roslyn 高度相似,不再啰嗦了。

总结

通过以上我们大概总结了 3 种方案(Emit , Roslyn (含 natasha) , CodeDom)来实现运行时代码生成。现在最推荐的是 Roslyn 方案。因为它的过程比较符合手写代码的感觉,而且他还提供了代码分析功能,能返回编写代码的语法错误等信息,非常有助于 debug 。如果你现在有动态代码生成的需求,那么 Roslyn 是你的最佳选择。

未完待续

除了以上 3 种代码生成技术,其实还有一种代码生成技术: Source Generator 。Source Generator 在最近几个版本的 .NET 中是一个非常重要的技术。通过它可以让程序的性能很大的提升。下一篇我们就来说说 Source Generator 。

敬请期待。

有关让代码帮我们写代码(一)的更多相关文章

  1. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

    如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

  2. ruby-on-rails - Rails 源代码 : initialize hash in a weird way? - 2

    在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has

  3. ruby-on-rails - 浏览 Ruby 源代码 - 2

    我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru

  4. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

  5. ruby - 寻找通过阅读代码确定编程语言的ruby gem? - 2

    几个月前,我读了一篇关于ruby​​gem的博客文章,它可以通过阅读代码本身来确定编程语言。对于我的生活,我不记得博客或gem的名称。谷歌搜索“ruby编程语言猜测”及其变体也无济于事。有人碰巧知道相关gem的名称吗? 最佳答案 是这个吗:http://github.com/chrislo/sourceclassifier/tree/master 关于ruby-寻找通过阅读代码确定编程语言的rubygem?,我们在StackOverflow上找到一个类似的问题:

  6. ruby - Net::HTTP 获取源代码和状态 - 2

    我目前正在使用以下方法获取页面的源代码:Net::HTTP.get(URI.parse(page.url))我还想获取HTTP状态,而无需发出第二个请求。有没有办法用另一种方法做到这一点?我一直在查看文档,但似乎找不到我要找的东西。 最佳答案 在我看来,除非您需要一些真正的低级访问或控制,否则最好使用Ruby的内置Open::URI模块:require'open-uri'io=open('http://www.example.org/')#=>#body=io.read[0,50]#=>"["200","OK"]io.base_ur

  7. 程序员如何提高代码能力? - 2

    前言作为一名程序员,自己的本质工作就是做程序开发,那么程序开发的时候最直接的体现就是代码,检验一个程序员技术水平的一个核心环节就是开发时候的代码能力。众所周知,程序开发的水平提升是一个循序渐进的过程,每一位程序员都是从“菜鸟”变成“大神”的,所以程序员在程序开发过程中的代码能力也是根据平时开发中的业务实践来积累和提升的。提高代码能力核心要素程序员要想提高自身代码能力,尤其是新晋程序员的代码能力有很大的提升空间的时候,需要针对性的去提高自己的代码能力。提高代码能力其实有几个比较关键的点,只要把握住这些方面,就能很好的、快速的提高自己的一部分代码能力。1、多去阅读开源项目,如有机会可以亲自参与开源

  8. 7个大一C语言必学的程序 / C语言经典代码大全 - 2

    嗨~大家好,这里是可莉!今天给大家带来的是7个C语言的经典基础代码~那一起往下看下去把【程序一】打印100到200之间的素数#includeintmain(){ inti; for(i=100;i 【程序二】输出乘法口诀表#includeintmain(){inti;for(i=1;i 【程序三】判断1000年---2000年之间的闰年#includeintmain(){intyear;for(year=1000;year 【程序四】给定两个整形变量的值,将两个值的内容进行交换。这里提供两种方法来进行交换,第一种为创建临时变量来进行交换,第二种是不创建临时变量而直接进行交换。1.创建临时变量来

  9. git使用常见问题(提交代码,合并冲突) - 2

    文章目录git常用命令(简介,详细参数往下看)Git提交代码步骤gitpullgitstatusgitaddgitcommitgitpushgit代码冲突合并问题方法一:放弃本地代码方法二:合并代码常用命令以及详细参数gitadd将文件添加到仓库:gitdiff比较文件异同gitlog查看历史记录gitreset代码回滚版本库相关操作远程仓库相关操作分支相关操作创建分支查看分支:gitbranch合并分支:gitmerge删除分支:gitbranch-ddev查看分支合并图:gitlog–graph–pretty=oneline–abbrev-commit撤消某次提交git用户名密码相关配置g

  10. ruby - 这两段代码有什么区别? - 2

    打印1:defsum(i)i=i+[2]end$x=[1]sum($x)print$x打印12:defsum(i)i.push(2)end$x=[1]sum($x)print$x后者是修改全局变量$x。为什么它在第二个例子中被修改而不是在第一个例子中?类Array的任何方法(不仅是push)都会发生这种情况吗? 最佳答案 变量范围在这里无关紧要。在第一段代码中,您仅使用赋值运算符=为变量i赋值,而在第二段代码中,您正在修改$x(也称为i)使用破坏性方法push。赋值从不修改任何对象。它只是提供一个名称来引用一个对象。方法要么是破坏性

随机推荐