前文中介绍了如何将程序及其依赖的dll打包到一个可执行文件中,其核心在于在AppDomain中注册AssemblyResolve事件并在回调里手动从资源文件中加载对应的dll。 紧接着介绍了使用Fody/Costura来全程一步到位实现将依赖的dll嵌入到资源、注册AppDomain的AssemblyResolve事件和处理dll的加载。只需要引用Corstura然后编译项目即可,完全不需要改动现有代码。那这个神奇的Costura是如何实现以上功能的呢?这里就用到了模块初始化器和MSIL中间语言修改的技术。

这篇文章严重参考 module-initializers-in-dotnet,有兴趣直接查看原文。 

模块初始化器


模块初始化器(Module Initializer)可以看作是模块(Module)的构造函数。每一个程序集(Assembly)由一个或多个模块(Module)构成(通常是一个),从这个角度也可以认为是模块的构造函数。当程序集第一次被加载的时候就会执行其包含的模块的模块初始化器,并且它能保证该函数在模块中的任何其它代码执行前执行,这些代码包括类型初始化器、静态构造函数、或者其它的初始化代码。

但模块构造器是CLR的功能,直到C# 9.0才将其暴露为了一个ModuleInitializerAttribute属性。这意味着在此C# 9.0之前的代码是无法编写或者实现模块构造器功能的,但我们可以在代码编译之后,通过一些工具将模块构造器的功能注入到编译后生成的中间语言(MSIL),也就是直接修改中间语言,这个操作也叫做IL缝合(IL weaving)。

▲ 在编译器把源代码编译为MSIL之后,可以对中间语言进行改写

上图描述了从C#代码到生成机器码的整个处理流程。 现在需要操作修改的是编译后生成的中间语言MSIL。

编辑IL代码


要编辑修改IL代码,可以使用Ildasm.exe先将可执行文件反编译为IL中间语言,然后对IL语言的文本文件进行修改。修改完成之后,使用Ilasm.exe再将中间语言重新编译为可执行文件。这个过程和操作需要对IL语言有较深的理解。

幸好有一些工具,比如Mono.Cecil可以在代码中直接操作和修改IL代码。一些工具比如Fody就使用了这个工具并实现了很多针对IL修改的很多有用的功能。

功能注入


这里我们要实现的是,对于任意一个程序集,要在其模块中动态注入一个模块初始化函数,这个函数在内部会注册AppDomain的AssemblyResolve回调事件,并处理好程序集的加载。

现在假设我们编写了一个程序,名叫InjectModuleInitializer.exe,它的使用方法如下:

InjectModuleInitializer.exe ModuleInitializer Initialize path/to/ToModifyDll.dll

这个工具需要做的就是要将待修改的ToModifyDll.dll中的名为ModuleInitializer类中的Initialize静态函数注入到ToModifyDll.dll的模块的构造函数中并进行调用。

模块的静态初始化器是一个返回void的不带参数的静态方法。所以待注入且能被模块初始化器调用的方法也必须为不带参数的返回值为void的静态方法。

新建一个TestSingleConsoleUsingInjection的控制台工程,在里面新建ModuleInitializer类,并添加Initialize方法,这个方法作为被注入到模块初始化器里面的被调用的方法,它用来注册和处理AssemblyResolve事件:

namespace TestSingleUsingInjection
{
    internal class ModuleInitializer
    {
        public static void Initialize()
        {
            Console.WriteLine(" ModuleInitializer.Initialize() method called. Subscribing to AssemblyResolve event");
            AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainAssemblyResolve;
        }

        private static Assembly CurrentDomainAssemblyResolve(object sender, ResolveEventArgs args)
        {
            System.Console.WriteLine("dynamic load:" + args.Name);
            string dllName = new AssemblyName(args.Name).Name + ".dll";
            var assem = Assembly.GetExecutingAssembly();
            string resourceName = assem.GetManifestResourceNames().FirstOrDefault(rn => rn.EndsWith(dllName));
            if (resourceName == null) return null;
            using (var stream = assem.GetManifestResourceStream(resourceName))
            {
                byte[] assemblyData = new byte[stream.Length];
                stream.Read(assemblyData, 0, assemblyData.Length);
                return Assembly.Load(assemblyData);
            }
        }
    }
}

TestSingleConsoleUsingInjection控制台程序的Main方法很简单,就是调用前一篇文章中介绍的TestSingleDLL.dll类库里的MathLib方法,执行两个数的加法操作:

namespace TestSingleConsoleUsingInjection
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("enter c to add to data");
            string readKey = Console.ReadLine();
            if (readKey != null)
            {
                if (readKey.Equals("c"))
                {
                    Console.WriteLine("please enter two data");
                    string d1 = Console.ReadLine();
                    string d2 = Console.ReadLine();
                    int result = MathLib.Add(int.Parse(d1), int.Parse(d2));
                    Console.WriteLine($"the result of {d1}+{d2} is {result.ToString()}");
                }
            }
            Console.ReadLine();
        }
    }
}

注意,这里在Main方法中,并没有注册AppDomain的AssemblyResolve方法。这个时候如果直接运行,就会报找不到TestSingleDLL.dll的错误,因为这个文件已经被内嵌到资源文件中了,所以需要注册AppDomain的AssemblyResolve方法,但现在没有注册,这个方法目前被抽出来放到ModuleInitializer类中了,现在需要做的是,通过InjectModuleInitializer.exe将ModuleInitializer类的Initialize注入到TestSingleConsoleUsingInjection的模块初始化构造中。使得在模块构造时,就能调用ModuleInitializer.Initialize()。

InjectModuleInitializer的实现


InjectModuleInitializer这个控制台程序的主要功能就是将传进来的方法注入到模块中的模块初始化器中。为了修改IL代码,这里需要引入Mono.Cecil。新建一个Injector类,里面只有一个Inject方法,方法的签名如下:

public void Inject(string injectionTargetAssemblyPath, string className, string methodName, string keyfile = null)

方法的框架结构如下:

private AssemblyDefinition InjectionTargetAssembly { get; set; }
public void Inject(string injectionTargetAssemblyPath, string className, string methodName, string keyfile = null)
{
    try
    {
        //将程序集读入到定义的InjectionTargetAssembly变量中
        ReadInjectionTargetAssembly(injectionTargetAssemblyPath);
        //定位程序集的中className类的methodName方法
        MethodReference initializeMethod = GetModuleInitializerMethod(className, methodName);
        //往程序记的模块初始化器的方法中注入提供的上述定位到的方法
        InjectInitializer(initializeMethod);
        //将修改后的IL代码回写到程序集中
        WriteAssembly(injectionTargetAssemblyPath, keyfile);
    }
    catch (Exception ex)
    {
        throw new InjectException(ex.Message, ex);
    }
}

try里面只有4个方法,这4个方法描述了整个注入的过程:

  1. 首先是读取程序集到定义的InjectionTargetAssembly中,供后续操作,这个读取操作使用的是Mono.Cecil中的方法。
  2. 紧接着定位程序集中根据参数提供的className里面的methodName方法。
  3. 紧接着创建程序集中模块的模块初始化器,并在初始化器内调用第2步中定位到的className的methodName方法。
  4. 最后将修改后的代码重写保存回程序集中。

下面就详细描述上述4个方法的具体实现:

ReadInjectionTargetAssembly,读取程序集的代码如下:

private void ReadInjectionTargetAssembly(string assemblyFile)
{
    if (assemblyFile == null)
    {
        throw new ArgumentNullException(nameof(assemblyFile));
    }

    var readParams = new ReaderParameters(ReadingMode.Immediate);
    readParams.ReadWrite = true;
    readParams.InMemory = true;
    if (GetPdbFilePath(assemblyFile) != null)
    {
        readParams.ReadSymbols = true;
        readParams.SymbolReaderProvider = new PdbReaderProvider();
    }
    InjectionTargetAssembly = AssemblyDefinition.ReadAssembly(assemblyFile, readParams);
}

private string GetPdbFilePath(string assemblyFilePath)
{
    if (assemblyFilePath == null)
    {
        throw new ArgumentNullException(nameof(assemblyFilePath));
    }

    var path = Path.ChangeExtension(assemblyFilePath, ".pdb");
    return File.Exists(path) ? path : null;
}
  • 方法首先定义一个读取的参数ReaderParameters,并指定读取模式为“立即读取”。
  • 判断是否存在.pdb文件,判断方法为GetPdbFilePath。
  • 如果存在.pdb文件,则设置读取参数使得允许读取符号。
  • 使用AssemblyDefinition的静态ReadAssembly方法,传入程序集路径以及上述指定的读取参数设置。
  • 将读取到的程序集定义赋值给私有的InjectTargetAssembly对象,供后续方法使用。

需要注意的是,如果Mono.Cecil用的是0.10.0及之后的版本,因为后续要将读取到的程序集修改后写回到dll,所以一定要加上以下两句

readParams.ReadWrite = true;
readParams.InMemory = true;

否则,写入程序集到文件时会报文件已被占用的错误。如果使用的是之前的版本,则没有这个问题。

紧接着就是GetModuleInitializerMethod(string className,string methodName)方法,这个方法要从上述读取到的程序集中定位到className参数给定的类和methodName参数给定的方法。

private MethodReference GetModuleInitializerMethod(string className, string methodName)
{
    if (InjectionTargetAssembly == null)
    {
        throw new InjectException("unable to determin ModuleInitializer:InjectionTargetAssembly is null");
    }

    TypeDefinition moduleInitializerClass = InjectionTargetAssembly.MainModule.Types.FirstOrDefault(t => t.Name == className);
    if (moduleInitializerClass == null)
    {
        throw new InjectException($"No type found named '{className}'");
    }

    MethodDefinition resultMethod = moduleInitializerClass.Methods.FirstOrDefault(m => m.Name == methodName);
    if (resultMethod == null)
    {
        throw new InjectException($"No method named '{methodName}' exist in type '{moduleInitializerClass.Name}'");
    }

    if (resultMethod.Parameters.Count > 0)
    {
        throw new InjectException($"Module initializer method must not have any parameters");
    }

    if (resultMethod.IsPrivate || resultMethod.IsFamily)
    {
        throw new InjectException("Module initializer method may not be private or protected,use public or internal instead");
    }

    if (!resultMethod.ReturnType.FullName.Equals(typeof(void).FullName))
    {
        throw new InjectException("Module initializer method must have 'void' as return type");
    }

    if (!resultMethod.IsStatic)
    {
        throw new InjectException("Module initializer method must be static");
    }
    return resultMethod;
}

这个方法首先从AssemblyDefinition中找到TypeDefinition然后找到MethodDefinition。找到需要的方法之后验证方法的参数、返回值以及类型。要能被模块的初始化器调用,方法必须满足以下条件:

  • 方法必须没有参数
  • 必须是可被调用的,属性为public或protected
  • 方法返回值必须为void
  • 方法必须是静态的。

在获取到了对应的MethodDefinition之后,后面的代码都是验证是否满足上述条件的逻辑。

获取到了方法之后,接下来就是把方法注入到模块的初始化构造中了。

private void InjectInitializer(MethodReference initializeMethod)
{
    if (initializeMethod == null)
    {
        throw new ArgumentNullException(nameof(initializeMethod));
    }
    const MethodAttributes attributes = MethodAttributes.Static | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName;
    TypeReference initializeReturnType = InjectionTargetAssembly.MainModule.ImportReference(initializeMethod.ReturnType);

    MethodDefinition cctor = new MethodDefinition(".cctor", attributes, initializeReturnType);
    ILProcessor il = cctor.Body.GetILProcessor();
    il.Append(il.Create(OpCodes.Call, initializeMethod));
    il.Append(il.Create(OpCodes.Ret));

    TypeDefinition moduleClass = InjectionTargetAssembly.MainModule.Types.FirstOrDefault(t => t.Name == "<Module>");
    if (moduleClass == null)
    {
        throw new InjectException("No module class found");
    }
    moduleClass.Methods.Add(cctor);
}

InjectInitializer方法里里面展示了Mono.Cecil的威力:

  • 首先第一步就是要创建一个模块初始化器,这个模块初始化器其实是一个特殊的方法,这个方法的属性必须为 Static|SpecialName|RTSpecialName,这是约定的,返回类型为void,这里的void没有直接硬编码,而是采用了上一步查找到的initializeMethod的返回值。返回值和属性标记定义好之后,再用这些信息生成一个名为".cctor"的方法,“.cctor”表示“静态构造函数”。
  • 接下来定义模块初始化器里面需要调用的方法,首先获"cctor"静态构造函数的IL指令,这时只定义了一个空的方法,此时获取到的IL指令集合应该的空的,然后在里面添加调用initialzieMethod的指令,以及返回指令。
  • 模块构造初始化器的定义准备好之后,接下来就是找到程序集里面的模块了。根据名称"<Module>"来查找,找到之后,然后在模块的方法里面添加上述的定义好的模块初始化器cctor。

上述这一切对程序集的修改都是保留在内存中的,接下来就需要把这些内容回写到程序集了。

private void WriteAssembly(string assemblyFile, string keyfile)
{
    if (InjectionTargetAssembly == null)
    {
        throw new InjectException("Unable to write the Injection TargetAssembly:InjectTargetAssembly is null");
    }

    WriterParameters writeParams = new WriterParameters();
    if (GetPdbFilePath(assemblyFile) != null)
    {
        writeParams.WriteSymbols = true;
        writeParams.SymbolWriterProvider = new PdbWriterProvider();
    }
    if (keyfile != null)
    {
        writeParams.StrongNameKeyPair = new System.Reflection.StrongNameKeyPair(File.ReadAllBytes(keyfile));
    }
    InjectionTargetAssembly.Write(assemblyFile, writeParams);
}

跟ReadInjectionTargetAssembly类似,写入程序集同样需要先定义一个WriteParameters对象,然后判断是否存在pdb文件然后设置相应的参数,这里还需要判断是否存在签名文件,如果存在也需要给参数赋值。最后就是将修改后的程序集调用Write方法回写到dll文件中。

至此整个代码的核心逻辑就完成了。接下来在就是在Main函数中解析args参数,然后调用上述的Injector类中的Inject方法:

static int Main(string[] args)
{
    var injector = new Injector();

    // Validate arguments
    if (args.Length < 3 || args.Length > 4 || Regex.IsMatch(args[0], @"^((/|--?)(\?|h|help))$"))
    {
        PrintHelp();
        return 1;
    }

    var version = Assembly.GetExecutingAssembly().GetName().Version;
    Console.WriteLine("InjectModuleInitializer v{0}.{1}", version.Major, version.Minor);
    Console.WriteLine("");

    // Parse the arguments
    string keyfile = null;
    var assemblyFile = args[args.Length - 1];
    var methodName = args[args.Length - 2];
    var className = args[args.Length - 3];

    for (var i = 0; i < args.Length - 1; i++)
    {
        var keyMatch = Regex.Match(args[i], "^/k(eyfile)?:(.+)", RegexOptions.IgnoreCase);
        if (keyMatch.Success)
        {
            keyfile = keyMatch.Groups[2].Value;
        }
    }

    // Start injecting the ModuleInitializer into the static constructor of the assembly
    try
    {
        injector.Inject(assemblyFile, className, methodName, keyfile);
        Console.WriteLine("Module Initializer successfully injected in assembly " + assemblyFile);
        return 0;
    }
    catch (InjectException e)
    {
        Console.Error.WriteLine("error: " + e);
        return 1;
    }
}

测试与运行


上述InjectModuleInitializer控制台程序编译完成之后,得到了可以用来给程序集注入的程序,接下来回到我们之前的TestSingleConsoleUsingInjection控制台工程。这个项目引用了TestSingleDLL.dll,并将其包含在了资源文件中,但是它没有在Main方法中注册AppDomain.AssemblyResolve事件并处理程序集加载的问题,所以如果直接运行TestSingleConsoleUsingInjection.exe就会报错。

▲默认没有注册AppDomain.Resolve情况下的报错

使用ILSpy程序,可以看到,程序里面是有名为“<Module>”的结构,但是它的模块初始化器里面的内容是空的。

▲ 默认的模块类型,里面空空如也

现在,我们使用InjectModuleInitializer工具,将TestSingleConsoleUsingInject.exe中的ModuleInitializer的Initialize方法,注入到TestSingleConsoleUsingInject程序集的模块初始化器中。执行一下命令:

c:\Debug>InjectModuleInitializer.exe ModuleInitializer Initialize C:\TestSingleConsoleUsingInjection\TestSingleConsoleUsingInjection.exe
InjectModuleInitializer v1.0

Module Initializer successfully injected in assembly C:\TestSingleConsoleUsingInjection\TestSingleConsoleUsingInjection.exe

可以看到执行成功,接下来再次运行程序。一切正常。

再次使用ILSpy打开程序,可以看到这时模块的初始化器里面,已经创建了一个静态构造函数,并且在静态构造函数里面,调用了了ModuleInitializer.Initialize()方法:

▲ 模块的初始化器已经被注入

这样当该模块被加载的时候,就会调用静态构造函数,执行里面的ModuleInitializer.Initialize()方法,去注册AppDomain.AssemblyResolve方法以及去处理从资源文件中读取程序集的操作。

 

调试


这里简单介绍一下如何调试InjectModuleInitializer程序,要调试InjectModuleInitializer.exe也很简单,在项目->调试页面的启动选项里面的命令行参数里面设置好要注入的类名、方法、程序即可。

▲ 调试带参数的命令行程序

在debug模式下运行程序,然后设置断点。可以看到Mono.Cecil的威力:

▲ 列出了MainModule中的所有类型,可以看到类型里面有一个'<Module>'模块

▲ 列出类型里面的所有方法

▲ 列出方法里的所有IL指令

结语


本文接上文,介绍了模块默认的模块初始化构造(Module Initializer)及其作用,进一步介绍了如何使用Mono.Cecil这一强大的IL语言修改器来修改已经编译好的程序集,来将注册AppDomain的AssemblyResolve事件,注入到模块的初始化构造器中,以实现将第三方依赖dll嵌入到资源文件中,程序集在动态加载时能够自动加载依赖的dll的功能。

 

 

参考