Generando CIL con CECIL


Este fin de semana me puse a investigar como inyectar código .Net con la librería Cecil. El uso más común para este tipo de práctica es el la programación orientada a aspectos. La idea es compilar un assembly .net y como proceso post-compilación modificar dicho assembly para agregarle funcionalidad. El ejemplo puntual es muy simple y no pretende ser un framework para programación de aspectos (al menos por ahora), pero si es útil para ver cuán poderoso son los mecanismos de reflection que brinda .net y abre la puerta para un montón de aplicaciones o bibliotecas de generación o modificación de código.

La gran ventaja de estos métodos es que el código original no se vea afectado en nada (no es intrusivo) y por lo tanto permite agregar funcionalidad transversal sin “ensuciar” el modelo de clases, ni tener que ir clase a clase copiando y pegando código repetitivo. Ahora sí, les presento el ejemplo en cuestión…

Primero defino un atributo custom para marcar los métodos que quiero modificar:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class AOPAttribute : Attribute
{
	public AOPAttribute( Type methodInterceptor )
	{

		this.methodInterceptor = methodInterceptor;
	}

	protected Type methodInterceptor;
	public Type MethodInterceptor
	{

		get { return methodInterceptor; }
	}
}

Luego, creo una interfaz que tiene los métodos que se van a poder inyectar:

public interface IMethodInterceptor
{
	void Before( MethodInfo method, object[] args, object target );
	void After( object returnValue, MethodInfo method,
	object[] args, object target );

}

El método “Before” es llamado antes del ejecutar el método original y el método “After” es invocado antes de que el método original termine. También es necesario definir una clase de prueba, que va a ser la que luego será inyectada con el código:

public class ExampleClass
{
	[AOP( typeof( ConsoleLogging ) )]

	public int divide( int a, int b, int c, int d )

	{
		int result = a/b;
		int result3 = c*d;
		if (result > 5)

			return -1;
		int result2 = a + d;
		if (result2 == 4)

			return result2;

		return result + result3;
	}
}

Hasta acá tenemos una clase (la que sería de nuestro modelo), que se le decoro el método “test” con el atributo “AOP” para indicarle a generador que dicho método debe ser procesado. Como parámetro del atributo se le pase el tipo de la clase que se va a inyectar, en este caso “ConsoleLogging” que está definida de la siguiente manera:

public class ConsoleLogging : IMethodInterceptor

{
	public void Before( MethodInfo method, object[] args, object target )
	{

		Console.Out.WriteLine(“Llamada a {0} interceptada”, method.Name);
		Console.Out.WriteLine(“Destino: {0}”, target);
		Console.Out.WriteLine(“Argumentos:);
		if (args != null)

			foreach (object arg in args)
				Console.Out.WriteLine(“\t:+ arg);
	}

	public void After( object returnValue, MethodInfo method, object[] args, object target )

	{
		Console.Out.WriteLine(“Llamada a {0} interceptada”, method.Name);
		Console.Out.WriteLine(“Destino: {0}”, target);
		Console.Out.WriteLine(“Argumentos:);
		if (args != null)

			foreach (object arg in args)
				Console.Out.WriteLine(“\t:+ arg);
		Console.Out.WriteLine(“Salida: {0}+ returnValue);
	}

}

Como vemos esta clase emite usando la consola los datos del método, parámetros, objeto y valor de retorno. Por último, lo más importante el modulo inyector de código:

public class CodeInjector
{
	// Agrega un paremtro del metodo original al object[] que recibe el metodo inyectado

	private void addParameter( ParameterDefinition parameter, ICollection<Instruction> iList, CilWorker worker, Instruction locToUse )
	{
		// Indice del object[] a setear:

		// Si hay mas de 8, tengo que usar Ldc_I4_S para especificarlo
		if ( parameter.Sequence-1 >= arrayIndexes.Length )

			iList.Add( worker.Create( OpCodes.Ldc_I4_S, (sbyte)(parameter.Sequence-1) ) );
		else

			iList.Add( worker.Create( OpCodes.GetOpCode( arrayIndexes[parameter.Sequence-1] ) ) );

		// Argumento utilizar como valor:
		// Si son mas de 5 tengo que usar Ldarg_S para especificarlo
		if (parameter.Sequence-1 >= argumentIndexes.Length)

			iList.Add( worker.Create( OpCodes.Ldarg_S, parameter ) );
		else
			iList.Add( worker.Create( OpCodes.GetOpCode(argumentIndexes[parameter.Sequence-1])));

		// Boxing si es necesario para pasar de un valueType a un object
		if ( parameter.ParameterType.IsValueType )
			iList.Add( worker.Create( OpCodes.Box, parameter.ParameterType ) );

		// Comando para setear el valor (toma los parametros de la pila)
		iList.Add(worker.Create(OpCodes.Stelem_Ref));
		iList.Add( locToUse );
	}

	// Inyecta el llamado al metodo “After”
	private IList<Instruction> addAfterHandler( string className, MemberReferenceHelper memberRefHelper,
							CilWorker worker, ParameterDefinitionCollection parameters)
	{

		IList<Instruction> iList = new List<Instruction>();

		// Guardo lo que se iba a devolver con el return
		iList.Add(worker.Create( OpCodes.Stloc_0 ) );

		// Instancio un objeto de la clase que quiere recibir el evento
		iList.Add(worker.Create(OpCodes.Ldstr, className));

		iList.Add(worker.Create(OpCodes.Call, memberRefHelper.GetTypeMethodReference));
		iList.Add(worker.Create(OpCodes.Call, memberRefHelper.CreateInstanceMethodReference));
		iList.Add(worker.Create(OpCodes.Castclass, memberRefHelper.MethodInterceptorTypeReference));

		// Obtengo el valor de retorno y le hago boxing
		iList.Add(worker.Create(OpCodes.Ldloc_0 ) );
		iList.Add(worker.Create(OpCodes.Box, memberRefHelper.Int32TypeReference));

		// Obtengo una referencia al methodinfo
		iList.Add(worker.Create(OpCodes.Call, memberRefHelper.GetCurrentMethodMethodReference));
		iList.Add(worker.Create(OpCodes.Castclass, memberRefHelper.MethodInfoTypeReference));

		// Construyo el object[] para pasar los parametros originales
		sbyte count = Convert.ToSByte(parameters.Count);
		if (count > arrayIndexes.Length)

			iList.Add(worker.Create(OpCodes.Ldc_I4_S, count));
		else
			iList.Add(worker.Create(OpCodes.GetOpCode(arrayIndexes[count])));

		// Instancio el array
		iList.Add(worker.Create(OpCodes.GetOpCode(arrayIndexes[count])));
		iList.Add(worker.Create(OpCodes.Newarr, memberRefHelper.ObjectTypeReference));
		iList.Add(worker.Create(OpCodes.Stloc_1));
		iList.Add(worker.Create(OpCodes.Ldloc_1));

		// Agrego los elementos uno a uno
		foreach (ParameterDefinition parameter in parameters)
			addParameter( parameter, iList, worker, worker.Create( OpCodes.Ldloc_1 ));

		// Obtengo la referencia a “this” para pasarlo como parametro
		iList.Add(worker.Create(OpCodes.Ldarg_0));

		// Hago la llamada
		iList.Add(worker.Create(OpCodes.Callvirt, memberRefHelper.AfterMethodReference));

		// Vuelvo a poner en la pila el return value original
		iList.Add(worker.Create(OpCodes.Ldloc_0));

		return iList;
	}

	// Inyecta el llamado al metodo “Before”
	private IList<Instruction> addBeforeHandler( string className, MemberReferenceHelper memberRefHelper,
						CilWorker worker, ParameterDefinitionCollection parameters )
	{

		IList<Instruction> iList = new List<Instruction>();

		// Instancio un objeto de la clase que quiere recibir el evento
		iList.Add(worker.Create(OpCodes.Ldstr, className));
		iList.Add(worker.Create(OpCodes.Call,memberRefHelper.GetTypeMethodReference));
		iList.Add(worker.Create(OpCodes.Call,memberRefHelper.CreateInstanceMethodReference));
		iList.Add(worker.Create(OpCodes.Castclass,memberRefHelper.MethodInterceptorTypeReference));

		// Obtengo una referencia al methodinfo

		iList.Add(worker.Create(OpCodes.Call,memberRefHelper.GetCurrentMethodMethodReference));
		iList.Add(worker.Create(OpCodes.Castclass,memberRefHelper.MethodInfoTypeReference));

		// Construyo el object[] para pasar los parametros originales
		sbyte count = Convert.ToSByte(parameters.Count);
		if (count > arrayIndexes.Length)

			iList.Add(worker.Create(OpCodes.Ldc_I4_S, count));
		else
			iList.Add(worker.Create(OpCodes.GetOpCode(arrayIndexes[count])));

		// Instancio el array
		iList.Add(worker.Create(OpCodes.Newarr,memberRefHelper.ObjectTypeReference));
		iList.Add(worker.Create(OpCodes.Stloc_1));
		iList.Add(worker.Create(OpCodes.Ldloc_1));

		// Agrego los elementos uno a uno
		foreach (ParameterDefinition parameter in parameters)
			addParameter( parameter, iList, worker, worker.Create( OpCodes.Ldloc_1 ) );

		// Obtengo la referencia a “this” para pasarlo como parametro
		iList.Add(worker.Create(OpCodes.Ldarg_0));

		// Hago la llamada
		iList.Add(worker.Create(OpCodes.Callvirt,memberRefHelper.BeforeMethodReference));

		return iList;
	}

	private void EditType( string className, MemberReferenceHelper helper, MethodDefinition method )

	{
		CilWorker worker = method.Body.CilWorker;
		Instruction initialInstruction = method.Body.Instructions[0];

		IList<Instruction> beforeList = addBeforeHandler( className, helper, worker, method.Parameters );
		IList<Instruction> afterList = addAfterHandler(className, helper, worker, method.Parameters);

		worker.InsertBefore(initialInstruction, beforeList[0]);
		for (int i = 1; i < beforeList.Count; i++)

			worker.InsertAfter(beforeList[i - 1], beforeList[i]);

		for (int j = 0; j < method.Body.Instructions.Count; j++ )

		{
			Instruction ins = method.Body.Instructions[j];
			if (ins.Next == null || ins.Next.OpCode != OpCodes.Ret)

				continue;
			worker.InsertAfter(ins, afterList[0]);
			j++;

			for (int i = 1; i < afterList.Count; i++)

			{
				worker.InsertAfter( afterList[i - 1], afterList[i] );
				j++;
			}

		}
	}

	// Procesa un assembly inyectandole el codigo que corresponda
	public void ProcessAssembly( string assemblyPath )

	{
		AssemblyDefinition assembly = AssemblyFactory.GetAssembly(assemblyPath);
		MemberReferenceHelper helper = new MemberReferenceHelper( assembly );

		// Para cada tipo definido en el assembly
		foreach (TypeDefinition type in assembly.MainModule.Types)
		{

			if (type.Name ==<Module>) continue;

			// Para cada metodo definido en el assembly

			foreach (MethodDefinition method in type.Methods)
			{
				ArrayList toDelete = new ArrayList();

				// Para cada atributo custom
				foreach (CustomAttribute customAtt in method.CustomAttributes)
				{
					// Si el atributo no esta definido, sigo.

					If ( ! customAtt.Constructor.DeclaringType.FullName.Equals(typeof(AOPAttribute).FullName))

						continue;
					toDelete.Add(customAtt);

					string className = ( string ) customAtt.ConstructorParameters[0];
					EditType( className, helper, method );
				}

				// Borra los atributos del metodo procesado
				// para evitar agregar el codigo inyectado dos veces
				foreach ( CustomAttribute customA in toDelete )
					method.CustomAttributes.Remove(customA);

				method.Body.Simplify();
			}

			// Importa el tipo modifcado al assembly
			assembly.MainModule.Import(type);
		}

		// Guarda los cambios en disco
		AssemblyFactory.SaveAssembly(assembly, assemblyPath);
	}

	private readonly Code[] arrayIndexes = new Code[] { Code.Ldc_I4_0, Code.Ldc_I4_1,
								Code.Ldc_I4_2, Code.Ldc_I4_3,
								Code.Ldc_I4_4, Code.Ldc_I4_5,
								Code.Ldc_I4_6, Code.Ldc_I4_7,
								Code.Ldc_I4_8 };

	private readonly Code[] argumentIndexes = new Code[] { 	Code.Ldarg_1, Code.Ldarg_2,
								Code.Ldarg_3 };

}

Esta clase se invoca utilizando el método “ProcessAssembly” que recibe la ubicación física del assembly a modificar. Luego la misma recorre todos los tipos definidos en este assembly, buscando por el atributo custom AOP. Si lo encuentra para algún método, entonces inserta el código CIL necesario para:

  • Instanciar un objeto del tipo pasado como parámetro al atributo (en el ejemplo “ConsoleLogging”)
  • Armar los parámetros que recibe el método “Before” (methodinfo, parámetros originales y el this)
  • Llamar al método Before antes de la ejecución del código del método
  • Llamar al método After antes de la ejecución de un return

Como vemos el código CIL no es de lo más sencillo, pero tampoco es assembler, así que con un poco de análisis se puede entender perfectamente que hace cada sentencia. La idea es que hay una pila de valores y cada operación se ejecuta utilizando los valores en la pila, por lo tanto hay que ir agregando y quitando cosas de la pila según que se requiera en cada llamada.

CECIL (librería del proyecto mono) nos ayuda muchísimo en esta tarea, definiendo de antemano las instrucciones CIL disponibles y permitiéndonos editar el CIL del assembly (cosa que reflection.emit no permite.)

Para finalizar, hay que compilar la clase “ExampleClass” y luego armar un .exe con la clase “CodeInjector”. Pasarle a este exe la ubicación de “ExampleTest” y listo. Si examinamos con el reflector la dlls luego de inyectar el código, vamos a ver que el mismo cambio de acuerdo a lo que buscábamos.

Finalmente, hay que aclarar que el mayor problema que tiene esta técnica es “romper” la sincronía entre el pdb y la dll, lo cual ocasiona problemas al momento de debuggear. Lo ideal es únicamente inyectar el código una vez que el arreglo de errores este en etapas finales.

Referencias: Cecil

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s