Динамическая загрузка кода
Нередко встречается
ситуация когда, в зависимости от тех или иных факторов, нужно загрузить
некоторую сборку (assembly) для
последующего выполнения содержащегося в ней кода. Пример из жизни –
WS-Security ([1]), в
котором для проверки пароля пользователя используется динамически
загружаемая сборка, идентифицируемая с помощью элемента passwordProvider
в файле web.config.
В WS-Security
используется самый распространенный подход для динамического подключения
сборок, который заключается в следующем:
1.
Создается базовый класс или интерфейс для классов в динамически
загружаемых сборках, что позволяет впоследствии вызывать нужные методы
напрямую, без использования рефлексии.
2.
Описываются соглашения для получения информации о классе,
находящемся в динамически загружаемой сборке. Часто для этих целей
используется конфигурационный файл приложения, но можно, например,
считывать эту информацию из базы данных.
3.
В процессе работы, на основе конфигурационной информации
динамически подгружается сборка и создается экземпляр нужного класса, к
которому можно обращаться с помощью интерфейсных (или базовых) методов.
Если это необходимо, можно вызывать методы при помощи рефлексии.
Создать экземпляр
класса, находящегося в динамически загружаемой сборке можно несколькими
способами. Самый простой путь – вызвать у класса
Activator ([2]) метод CreateInstance
(или CreateInstanceFrom). При этом будет
неявно загружена сборка, содержащая искомый класс (если эта сборка не
была загружена ранее). Можно выбрать более сложный путь – загрузить
сборку (вызвав один из соответствующих статических методов, например,
Assembly.Load),
получить нужный тип (вызвав метод Assembly.GetType,
с названием класса в качестве параметра), получить у типа информацию о
конструкторе (вызвав метод Type.GetConstructor)
и, наконец, создать экземпляр класса с помощью метода
ConstructorInfo.Invoke. Можно также
воспользоваться методами CreateInstance
классов Assembly или
AppDomain (они вызывают метод Activator.CreateInstance).
О том, как загрузить сборку, и какая информация для этого необходима,
подробно описано в MSDN ([3],[4]).
Динамическая загрузка
доменов приложений (application domains)
может понадобиться по двум причинам – для обеспечения большей
безопасности (изолированности загруженного кода) и для возможности
выгрузки динамически загруженных сборок (в .NET
Framework нет возможности выгрузить сборку – только домен
приложения). Однако за изолированность доменов приходится платить тем,
что вызовы между границами доменов происходят с помощью
Remoting. Подробнее о доменах приложений
вообще и, в частности, об их изолированности можно прочитать в
MSDN ([5]).
В .NET
Framework 2.0 появились новые методы, Assembly.ReflectionOnlyLoad
и Assembly.ReflectionOnlyLoadFrom,
позволяющие загрузить сборку только для получения информации о ней.
Вызов этого метода позволяет считывать информацию о сборке, не загружая
ее для выполнения. Также добавлен аналогичный метод для типа -
Type.ReflectionOnlyGetType. Загруженные таким образом сборки по-прежнему
нельзя выгрузить отдельно. Но, помимо более высокой скорости загрузки,
эти методы позволяют обойти некоторые запреты, имеющие смысл при
выполнении кода, но мешающие получению информации о сборке. К примеру,
можно просмотреть сборку, скомпилированную под другой процессор, обойти
CAS Policy, не выполнять конструктор модуля.
Снятие подобных ограничений будет особенно полезно при написании
приложений предназначенных только для исследования сборок, таких как .NET
Reflector([15]). Очевидно, что для подобных приложений
невозможность получить информацию о 64-битной сборке на машине с
32-битным процессором была бы, по меньшей мере, неприятной.
Динамическая компиляция исходного кода на C#
Наиболее удобной в
использовании технологией динамической компиляции является компиляция
исходного кода. Как следует из названия, эта технология позволяет
динамически компилировать исходный код. Не правда ли, замечательная
возможность – писать макросы для своего приложения на
C#?
Но есть и плохая
новость – создание сборки происходит не в оперативной памяти, поскольку
используется компилятор командной строки (csc.exe).
И, хотя у класса CompilerParameters есть
привлекательное на вид свойство GenerateInMemory,
присвоив ему значение “true”, вы лишь получите
динамическую сборку в виде временного файла. С другой стороны, мыслить
нужно позитивно, поэтому можно сказать следующее – .NET
Framework предоставляет очень удобную инфраструктуру для
динамической компиляции исходного кода (скрывая вызов компилятора
командной строки).
Еще одна проблема,
которая может возникнуть при частой динамической компиляции исходного
кода в рамках одного домена приложения – нехватка памяти. Не забывайте
также, что компилируя дважды одинаковый код, вы получите две сборки. Как
уже упоминалось выше, невозможно выгрузить отдельную сборку – можно лишь
выгрузить целиком домен приложения. В статьях “Dynamically
executing code in .Net” и “AppDomains
and Dynamic Loading” ([6], [7]) описано, помимо прочего, решение
этой проблемы путем создания новых доменов приложений. Конечно, не
обязательно всегда использовать несколько доменов приложения, иногда
вполне допустимо использовать один домен приложения.
Рассмотрим динамическую
компиляцию исходного кода на простом примере. Напишем класс, позволяющий
запускать «макросы на 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
= new CSharpCodeProvider();
// добавляем ссылки на
сборки
CompilerParameters parameters =
new CompilerParameters(assemblies);
// добавляем ссылку на
нашу сборку SimpleMacro
string path =
Assembly.GetExecutingAssembly().Location;
parameters.ReferencedAssemblies.Add(path);
// компилируем
CompilerResults results =
codeCompiler.CompileAssemblyFromSource(parameters, code);
// есть ли ошибки?
if( results.Errors.HasErrors )
{
foreach( CompilerError
error in results.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()
{
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
(если они не были унаследованы от известных вызывающему коду базовых
классов и не реализуют известные интерфейсы).
Получить более
подробную информацию об использовании Reflection Emit
можно в MSDN ([8], [9]).
В .NET
Framework 2.0 появилось много новых вариантов использования
Reflection Emit. Перечислю лишь некоторые из
них (подробнее в [10],
[11]):
·
Генерация шаблонов ([12]).
·
Использование MethodBody для
получения содержимого метода ([11],[13])
·
Генерация и выполнение методов без создания динамической
сборки ([14]).
Остановимся подробнее
на генерации динамических методов без создания сборки. Она позволяет
создавать метод, глобальный для модуля и, помимо этого, не использовать
проверку видимости (JIT compiler’
visibility checks). Рассмотрим небольшой
пример, вычисляющий значение логарифма (разумеется, пример не преследует
цель найти самый легкий путь для вычисления логарифма). Нам потребуются
следующие пространства имен:
using System;
using System.Reflection;
using System.Reflection.Emit;
Затем мы должны описать
делегат, с помощью которого мы будем вызывать динамический метод:
private delegate double
DynamicCalcDelegate(double x, double y);
Теперь мы можем
написать метод (например, Main), который будет
создавать динамический метод и вызывать его:
// задаем сигнатуру метода
DynamicMethod dynamicCalc = new
DynamicMethod("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.