Динамическая компиляция и загрузка кода

ОГЛАВЛЕНИЕ

Прежде чем говорить о динамической компиляции и загрузке кода, нужно ответить на вопрос: зачем нужно динамически выполнять код? Можно привести массу примеров использования, но все, в конечном итоге сводится к одной цели – возможности расширения и изменения функциональности приложения без его перекомпиляции. А теперь посмотрим, чем может помочь .NET Framework в решении этой задачи.

Динамическая загрузка кода

Нередко встречается ситуациякогда, в зависимости от тех или иных факторов, нужно загрузить некоторую сборку(assembly) для последующего выполнения содержащегося вней кода. Пример из жизни – WS-Security, в котором для проверки пароля пользователя используется динамическизагружаемая сборка, идентифицируемая с помощью элемента passwordProvider вфайле web.config. В WS-Security используется самыйраспространенный подход для динамического подключения сборок, которыйзаключается в следующем:

1. Создаетсябазовый класс или интерфейс для классов в динамически загружаемых сборках, чтопозволяет впоследствии вызывать нужные методы напрямую, без использованиярефлексии.

2. Описываютсясоглашения для получения информации о классе, находящемся в динамически загружаемойсборке. Часто для этих целей используется конфигурационный файл приложения, номожно, например, считывать эту информацию из базы данных.

3. Впроцессе работы, на основе конфигурационной информации динамически подгружаетсясборка и создается экземпляр нужного класса, к которому можно обращаться спомощью интерфейсных (или базовых) методов. Если это необходимо, можно вызыватьметоды при помощи рефлексии.

Создать экземпляр класса,находящегося в динамически загружаемой сборке можно несколькими способами. Самыйпростой путь – вызвать у класса Activator метод CreateInstance (или CreateInstanceFrom).При этом будет неявно загружена сборка, содержащая искомый класс (если этасборка не была загружена ранее). Можно выбрать более сложный путь – загрузитьсборку (вызвав один из соответствующих статических методов, например, Assembly.Load), получить нужный тип(вызвав метод Assembly.GetType,с названием класса в качестве параметра), получить у типа информацию оконструкторе (вызвав метод Type.GetConstructor)и, наконец, создать экземпляр класса с помощью метода ConstructorInfo.Invoke. Можно также воспользоваться методами CreateInstance классов Assembly или AppDomain (они вызывают метод Activator.CreateInstance). О том, как загрузить сборку, и какаяинформация для этого необходима, подробно описано в MSDN.

Динамическая загрузка доменовприложений (application domains) может понадобиться по двум причинам – дляобеспечения большей безопасности (изолированности загруженного кода) и длявозможности выгрузки динамически загруженных сборок (в .NET Framework нет возможности выгрузитьсборку – только домен приложения). Однако за изолированность доменов приходитсяплатить тем, что вызовы между границами доменов происходят с помощью Remoting. Подробнее о доменах приложений вообще и, вчастности, об их изолированности можно прочитать в MSDN.

В .NET Framework 2.0 появились новые методы, Assembly.ReflectionOnlyLoad и Assembly.ReflectionOnlyLoadFrom, позволяющие загрузить сборку только для получения информации о ней. Вызов этогометода позволяет считывать информацию о сборке, не загружая ее для выполнения.Также добавлен аналогичный метод для типа - Type.ReflectionOnlyGetType.Загруженные таким образом сборки по-прежнему нельзя выгрузить отдельно. Но,помимо более высокой скорости загрузки, эти методы позволяют обойти некоторыезапреты, имеющие смысл при выполнении кода, но мешающие получению информации осборке. К примеру, можно просмотреть сборку, скомпилированную под другойпроцессор, обойти CAS Policy, не выполнять конструктор модуля. Снятие подобныхограничений будет особенно полезно при написании приложений предназначенныхтолько для исследования сборок, таких как .NET Reflector. Очевидно, что дляподобных приложений невозможность получить информацию о 64-битной сборке намашине с 32-битным процессором была бы, по меньшей мере, неприятной.


Динамическая компиляция исходного кода на C#

Наиболее удобной в использованиитехнологией динамической компиляции является компиляция исходного кода. Какследует из названия, эта технология позволяет динамически компилироватьисходный код. Не правда ли, замечательная возможность – писать макросы длясвоего приложения на C#?

Но есть и плохая новость – созданиесборки происходит не в оперативной памяти, поскольку используется компиляторкомандной строки (csc.exe). И,хотя у класса CompilerParameters есть привлекательноена вид свойство GenerateInMemory, присвоив ему значение“true”, вы лишь получите динамическую сборку в видевременного файла. С другой стороны, мыслить нужно позитивно, поэтому можносказать следующее – .NET Framework предоставляет очень удобную инфраструктуру длядинамической компиляции исходного кода (скрывая вызов компилятора команднойстроки).

Еще одна проблема, которая можетвозникнуть при частой динамической компиляции исходного кода в рамках одногодомена приложения – нехватка памяти. Не забывайте также, что компилируя дваждыодинаковый код, вы получите две сборки. Как уже упоминалось выше, невозможновыгрузить отдельную сборку – можно лишь выгрузить целиком домен приложения. Встатьях “Dynamically executing code in .Net” и “AppDomains and Dynamic Loading” описано, помимо прочего, решение этойпроблемы путем создания новых доменов приложений. Конечно, не обязательновсегда использовать несколько доменов приложения, иногда вполне допустимоиспользовать один домен приложения.

Рассмотрим динамическуюкомпиляцию исходного кода на простом примере. Напишем класс, позволяющийзапускать «макросы на C#», выдающие информациюпользователю в текстовом виде.

Для уменьшения объема исходного кода и профилактики туннельного синдрома запястий опишем пространства имен:

using System;
using System.Reflection;
using System.CodeDom.Compiler;
using Microsoft.CSharp;
namespace SimpleMacro {

Чтобы абстрагироваться от способавывода сообщений (консоль, журнал событий и т.д.) опишем делегат, единственнаяфункция которого – передавать сообщение:

public delegate void SpeakOut(string message); 

Чтобы не использовать для запускамакроса рефлексию, опишем интерфейс:

public interface IMacro
{
void Run(SpeakOut speakOut);
}

Теперь, напишем класс,осуществляющий динамическую компиляцию и запуск макроса. Этот класс будетсодержать поле типа IMacro и делегат SpeakOut:

public class Macro {
readonly SpeakOut speakOut;
readonly IMacro macro;

Основной код, компилирующиймакрос, поместим в конструктор класса, параметрами которого являются – исходныйкод, название класса, реализующего интерфейс IMacro,список подключаемых сборок и делегат SpeakOut:

public Macro(string code, string className,string [] assemblies, SpeakOut speakOut)
{
if( code == null || assemblies == null ||speakOut == null )
throw new ArgumentNullException();
// запоминаем делегат, для вызова в Run
this.speakOut = speakOut;
// создаем экземпляр компилятора
CSharpCodeProvider codeCompiler = newCSharpCodeProvider();
// добавляем ссылки на сборки
CompilerParameters parameters = newCompilerParameters(assemblies);
// добавляем ссылку на нашу сборкуSimpleMacro
string path =Assembly.GetExecutingAssembly().Location;
parameters.ReferencedAssemblies.Add(path);
// компилируем
CompilerResults results =codeCompiler.CompileAssemblyFromSource(parameters, code);
// есть ли ошибки?
if( results.Errors.HasErrors )
{
foreach( CompilerError error inresults.Errors )
{
speakOut(string.Format("Line:{0:d},Error:{1}\n",error.Line, error.ErrorText));
}
throw new ArgumentException("Ошибкипри компиляции.");
}
// создаем класс
object objMacro =results.CompiledAssembly.CreateInstance(className);
if( objMacro == null )
{
throw new ArgumentException("Ошибкапри создании класса " + className);
}
// запоминаем класс как интерфейс
macro = objMacro as IMacro;
if( macro == null )
{
throw new ArgumentException("Нереализован интерфейс IMacro.");
}
}

Разумеется, нужно написать метод Run, который будет запускать макрос:

public void Run()
<
>{!--&t; macro.Run(speakOut); }

Теперь осталось написатьклиентский код для этого класса. Чтобы не изобретать велосипед для тестовогопримера, я написал макрос, выводящий на консоль “Hello,world!”:

const string NAMESPACE_NAME = "Test";
const string CLASS_NAME = "TestMacro";
// для нашего случая даже System.dll не нужна
string [] assemblies = new string[0];
// формируем исходный код
string codeString = @"using System;
using SimpleMacro;
namespace "+NAMESPACE_NAME+@"
{
public class "+CLASS_NAME+@" :IMacro
{
public void Run(SpeakOut speakOut)
{
speakOut("+"\"Hello, world!\"" +@");
}
}
}";
// компилируем макрос
Macro macro = new Macro(codeString,
NAMESPACE_NAME+"."+CLASS_NAME,
assemblies,
new SpeakOut(Console.WriteLine) );
// запускаем макрос
macro.Run();

Как видно из примера,динамическая компиляция исходного кода не сводится к вызову одного метода спарой параметров. С другой стороны, имеет смысл один раз написать класс,отвечающий за динамическую компиляцию (универсальный или для частного случая) иповторно использовать его в дальнейшем.

Приведенный пример, несмотря насвою простоту, демонстрирует весьма большие возможности, которые открывает длянас .NET Framework.Как их использовать? Вот всего лишь один из вариантов.

Пусть в базе данных хранитсясписок тестов и их исходный код. На основе некоторого запроса к БД клиентскоеприложение получает определенный набор тестов, которые затем динамическикомпилируются и запускаются. При желании, исходный код тестов можномодифицировать «на лету» с помощью того же запроса к БД (или в клиентскомприложении).

В .NET Framework 2.0 некоторые методысчитаются устаревшими. Поэтому в примере используются методы класса CSharpCodeProvider (а не ICodeCompilerкак в .NET Framework 1.1).


Динамическая компиляция средствами Reflection Emit

Пространство имен Reflection.Emit предоставляет наборклассов для динамической генерации кода на промежуточном языке (MSIL).Неоспоримым преимуществом этой технологии перед динамической компиляциейисходного кода написанного на языках высокого уровня является скоростькомпиляции. Это, безусловно, важный фактор, хотя не каждый программист захочетизучать ради этого MSIL.

Интерес представляет также то,что можно использовать конструкции, недопустимые в языках высокого уровня.Например, можно при определении метода задать доступ к нему только дляпроизводных классов в той же сборке (MethodAttributes.FamANDAssem), в то времякак C# не позволяет задать такой модификатор доступа (“protected internal”соответствует MethodAttributes.FamORAssem).

Как правило, для динамическойкомпиляции используется следующая последовательность действий:

1. Создаетсядинамическая сборка (экземпляр класса AssemblyBuilder) с помощью метода AppDomain.DefineDynamicAssembly.

2. Создаетсямодуль (экземпляр класса ModuleBuilder) с помощью методаAssemblyBuilder.DefineDynamicModule.

3. Создаютсяэлементы модуля (TypeBuilder, EnumBuilder,..) с помощью методов классаModuleBuilder.

4. Еслинужно, сборка сохраняется на диск, с помощью метода AssemblyBuilder.Save.

5. Создаютсянужные экземпляры классов (получить соответствующий тип можно с помощью TypeBuilder.CreateType) и вызываютсяих методы посредством Reflection (если они не былиунаследованы от известных вызывающему коду базовых классов и не реализуютизвестные интерфейсы).

Получить более подробнуюинформацию об использовании ReflectionEmit можно в MSDN.

В .NET Framework 2.0 появилось много новыхвариантов использования Reflection Emit. Перечислю лишь некоторые из них:

•Генерация шаблонов.

•Использование MethodBody для получения содержимого метода

•Генерация и выполнение методов без создания динамической сборки.

Остановимся подробнее на генерациидинамических методов без создания сборки. Она позволяет создавать метод,глобальный для модуля и, помимо этого, не использовать проверку видимости(JIT compiler’visibility checks).Рассмотрим небольшой пример, вычисляющий значение логарифма (разумеется, примерне преследует цель найти самый легкий путь для вычисления логарифма). Нампотребуются следующие пространства имен:

using System;
using System.Reflection;
using System.Reflection.Emit;

Затем мы должны описать делегат,с помощью которого мы будем вызывать динамический метод:

private delegate doubleDynamicCalcDelegate(double x, double y); 

Теперь мы можем написать метод(например, Main), который будет создавать динамическийметод и вызывать его:

// задаем сигнатуру метода
DynamicMethod dynamicCalc = newDynamicMethod("DynamicCalc",
typeof(double),
new Type[] { typeof(double),typeof(double) },
typeof(Program));
// получаем описание стандартного метода для вычисления логарифма
MethodInfo log =typeof(Math).GetMethod("Log",
new Type[] { typeof(double),typeof(double) });
// генерируем реализацию метода
ILGenerator il = dynamicCalc.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldarg_1);
il.EmitCall(OpCodes.Call, log, null);
il.Emit(OpCodes.Ret);
// получаем делегат и вызываем его
DynamicCalcDelegate dc =(DynamicCalcDelegate)dynamicCalc.CreateDelegate( typeof (DynamicCalcDelegate));
Console.WriteLine(dc(100.0, 10.0));

Естественно, наш код, генерирующий реализацию метода оченьпрост. Простота объясняется небольшим количеством кода и совпадением сигнатурметодов Math.Log и DynamicCalc. С другой стороны,пример наглядно демонстрирует, что написание динамических методов сравнительнопростая задача, если не принимать во внимание генерацию кода с помощьюILGenerator.

Автор: OlegAxenow