Hace unos días Eduard mostraba en su blog cómo cargar información de reflection sin cargar el assembly. La técnica que explica en el post usando AppDomains
es muy interesante, pero en los comentarios saqué el tema de Mono.Cecil y me pareció una buena excusa para probarlo.
¿Qué es Mono.Cecil?
Mono.Cecil es un proyecto englobado dentro de Mono, una implementación alternativa y multiplataforma de .NET. Mono.Cecil permite leer assemblies de .NET e inspeccionar los tipos que contienen, los métodos de cada tipo, e incluso modificar los assemblies y volver a guardarlos en disco.
Aunque forme parte de Mono, Cecil es un assembly que puede ser utilizado desde casi cualquier versión de .NET Framework. Actualmente está alojado en github, de donde podéis bajar los fuentes y compilarlos vosotros mismos, pero si os resulta más cómodo también existe un paquete para NuGet. Para utilizarlo, sólo necesitamos referenciar el assembly Mono.Cecil.dll
.
A diferencia de reflection, Cecil no carga el assembly dentro del AppDomain
, sino que únicamente lee la información del assembly y nos permite trabajar con ella. De hecho, esto permite usar sin problemas Cecil para leer assemblies de versiones del framework distintas de la que estamos usando. Por ejemplo, podemos crear una aplicación con .NET 2.0 que use Cecil y analice assemblies compilados con .NET 4.0.
¿Para que sirve? Pues se me ocurren varios usos, aunque lo mejor es verlo con algunos ejemplos.
Inspeccionar un assembly sin cargarlo
Lo primero que he probado es hacer lo mismo que hacía Eduard con los AppDomain
, pero con Cecil: averiguar los tipos que hay un assembly sin tener que cargarlo. Para ello, sólo hay que usar el siguiente código:
var assembly = AssemblyDefinition.ReadAssembly("the.assembly.dll"); var types = assembly.MainModule.Types.Select(x => x.FullName);
El punto de entrada es siempre AssemblyDefinition.ReadAssembly()
, que permite leer la información de un assembly desde disco y devuelve un objeto de tipo AssemblyDefinition
. Todo el API de Cecil está organizada en una estructura de clases análoga a los AssemblyInfo/MethodInfo/PropertyInfo...
que se usa con reflection, pero en este caso se llaman AssemblyDefinition/MethodDefinition/PropertyDefinition/...
.
Un AssemblyDefinition
contiene una colección de ModuleDefinition
que nos permite tratar con assemblies que contengan más de un módulo. La verdad es que esto no es muy frecuente, lo más parecido a ello que me he encontrado nunca es el caso de assemblies en mixed mode, como el de SQLite, así que generalmente nos bastará con acceder a la propiedad MainModule
que nos permite acceder al módulo principal del assembly y a lo que contiene. A partir de ahí, podemos obtener los TypeDefinition
y de ellos… bueno, ya te puedes imaginar como sigue la cosa viendo el código de arriba.
Inspeccionar el cuerpo de los métodos
Otro caso en el que puede ser útil Cecil es si queremos inspeccionar qué es lo que hace un método. A través de la propiedad Body.Instructions
de un MethodDefinition
podemos acceder a las instrucciones IL que forman la implementación del método. Esto de toquetear el IL puede parecer un poco raro al principio, pero cuando te pones, es hasta divertido.
Como ejemplo, podríamos ver qué métodos en un assembly están usando un método determinado, por ejemplo Console.WriteLine
:
var assembly = AssemblyDefinition.ReadAssembly("assembly.dll"); var methods = assembly.MainModule .Types.SelectMany(type => type.Methods) .Where(method => method.Body.Instructions .Any(x => (x.OpCode == OpCodes.Call || x.OpCode == OpCodes.Calli || x.OpCode == OpCodes.Callvirt) && x.Operand.ToString().Contains("System.Console.WriteLine")));
Para entender el ejemplo hay que tener en cuenta que las invocaciones de métodos en IL se realizan con una instrucción que tiene como código de operación Call
, Calli
o Callvirt
, usando como parámetro el nombre del método a invocar. En este caso la instrucción que se usa realmente es Call
por tratarse de un método estático, pero he preferido poner todas para tenerlas como referencia por si queréis probar con otros métodos.
Esto, aparte de ser curioso, puede tener su utilidad para automatizar chequeos basados en análisis estáticos de código. Algo parecido a lo que hace NDepend, pero más de andar por casa. Por ejemplo, podrías tener un test unitario que validase que todas las excepciones que se lanzan desde el Domain son del tipo DomainException
o cosas similares.
Modificar assemblies
Por último, otra de las cosas que nos permite hacer Cecil es modificar un assembly y volver a guardarlo en disco. ¿Por qué querría alguien hacer eso? Lo más habitual es para aplicar técnicas AOP con IL-Weaving.
Ya he puesto algún ejemplo en este blog de como aplicar AOP usando un contenedor IoC y LCG, pero esas técnicas implican generar clases en tiempo de ejecución, por lo que suponen una penalización al rendimiento. Usando Cecil podemos modificar el assembly generado después de la compilación, consiguiendo no penalizar el rendimiento al ejecutar la aplicación.
Para que no se complique la cosa, en el ejemplo vamos a hacer algo más simple, vamos a sustituir todas las invocaciones a Console.WriteLine
con invocaciones a Trace.WriteLine
:
// TODO: Obtener los métodos que usan System.Console.WriteLine // como hemos visto en el ejemplo anterior var methods = ...; foreach (var method in methods) { // Importamos el método Trace.WriteLine en el módulo principal // del assembly para obtener un MethodDefinition var traceWriteLine = assembly.MainModule .Import(typeof (Trace).GetMethod("WriteLine", new[] {typeof (string)})); // Obtenemos las instrucciones que usan WriteLine var writeLines = processor.Body.Instructions .Where(x => x.OpCode == OpCodes.Call && x.Operand.ToString().Contains("System.Console.WriteLine")) .ToArray(); foreach (var instruction in writeLines) { // Aprovecho que los parámetros son los mismos. El parámetro // quedará apilado con una instrucción Ldstr antes de Call Console.WriteLine // así que no lo toco // Creamos la instrucción Call Trace.WriteLine var trace = method.Body.GetILProcessor().Create(OpCodes.Call, traceWriteLine); // La insertamos justo antes del Console.WriteLine... processor.InsertBefore(instruction, trace); // ... y eliminamos el Console.WriteLine processor.Remove(instruction); } } // Escribimos a disco el assembly modificado assembly.Write("assembly.patched.dll");
Es un poco lioso, pero creo que leyendo los comentarios del código se puede llegar a entender. La parte más complicada realmente es la de generar las instrucciones IL correctas para hacer lo que queramos hacer en cada caso. En este ejemplo, como sólo queríamos reemplazar el método que se estaba invocando y, además, tenían los mismos parámetros, es relativamente sencillo.
Conclusiones
Mono.Cecil es una librería que no creo que vayas a usar cada día, pero que «en lo suyo» me parece que hace una labor excelente. Es muy fácil de manejar, el API está bien estructurada y, al menos para hacer las tres tonterías que he hecho en este post, funciona como esperas (que es más de lo que se puede decir de muchas librerías por ahí).
En definitiva, una herramienta más a tener en cuenta para cuando haga falta.