Customizing the .NET Common Object Runtime - Part 2
Dan Buskirk
June 11, 2007
Overview of AppDomains
In the first part of this series, we created a small custom host for the
.NET CLR. Our goal is to include custom loading of AppDomains as a part of the host's
function, but first we must take a little time to review the fundamentals of the
AppDomain itself.
I doubt that anyone would like to return to the days of Windows 3.1, when any errant
application would crash the entire operating environment. In 32-bit Windows, applications
run in their own separate process, and the operating system prevents an action in
one process from inadvertently stomping on something in another. An AppDomain is
very much an extension of this notion. As several processes run within the operating
system, the CLR controls one or more AppDomains within a single process. An AppDomain
is the unit of isolation with the CLR just as the process is the unit of isolation
within the OS. The .NET runtime takes this further, however. AppDomains are not
just about memory access, they are also the unit of isolation for security as well.
The most common application of multiple AppDomains within a single process is the
ASP.NET engine. Individual web applications run in separate AppDomains. As you know,
the web application is the unit of security within ASP.NET; this is easy for the
CLR to enforce via AppDomains. Our goal is similar; we wish to isolate unpredicatble
add-ins from each other and from the application itself.
Another very valuable characteristic of AppDomains is that they can be unloaded.
Once loaded, the CLR itself can never be unloaded, except by shutting down the process
itself. Neither can individual assemblies be unloaded. Therefore, if we are loading
a large assembly and wish to unload it to recover memory, the only alternative is
to load that assembly into a separate AppDomain. Once we are finished with the code,
the AppDomain, and the managed code within it, can be unloaded.
When the default host is asked to load managed .NET code into the CLR, an AppDomain,
not surprisingly called the default AppDomain, is created. The host creates
an AppDomainManager, which loads security information, which then loads and runs
the managed code. Additional AppDomains can be created entirely within managed code.
Each one gets an instance of the AppDomainManager which then loads more security
information and more managed code.
Before we continue the development of our custom host, we will build two small applications
to illustrate the creation of AppDomains in managed code. The first will simply
load and run some user code in a new AppDomain. The second will create a managed
AppDomainManager to provide more fine-grained control over this process.
Creating an Application that Compiles and Runs User C# Code
Much of the available sample code that illustrates th creation of new AppDomains
illustrates how to load code from an assembly file into that new AppDomain. This,
of course, is the practical task most developers will need to perform. For illustrative
purposes, and perhaps a little fun, we will take a slightly different tack. Our
application will accept a C# script from the user, compile it, and then run it in
a new AppDomain.
Using CodeDom to Compile C# Code at Runtime
The .NET Framework has always provided mechanisms for compiling VB and C# code at
runtime. Indeed, code written for this purpose in versions 1.0 and 1.1 will still
continue to work in .NET 2.0. However, Microsoft is in the process of redesigning
some of the key classes in the CodeDom namespace, so we will make a short detour
to review them.
In earlier versions, to get a compiler you would declare a variable of type
ICodeCompiler, instantiate an object of VBCodeProvider
or CSharpCodeProvider class, and then call the
CreateCompiler method of the appropriate object. The process is
more direct with .NET 2.0. We simply call the new CompileAssemblyFromSource method
of the CSharpCodeProvider class and we are all set. In
the example code which follows, we will accept a string as input, then wrap that
string in a class and function declaration. After this "on-the-fly" class is compiled,
we will load its assembly and execute our method. The net effect, from the user's
perspective, will be the execution of his or her code snippet immediately. If you
are using the Visual Studio, create a Class Libbrary project called "AppDomainTestLibrary"
and define the following class:
1 using
System;
2 using
System.Text;
3 using
System.Windows.Forms;
4 using
System.Collections;
5 using
System.Collections.Generic;
6 using
Microsoft.CSharp;
7 using
System.CodeDom.Compiler;
8 using
System.Reflection;
9
10
11 public
class CompileInvoke
12 {
13
public void RunIt(string CodeSnippet)
14
{
15
16
// (1)
17
CSharpCodeProvider cscp = new
CSharpCodeProvider();
18
CompilerParameters cp = new
CompilerParameters();
19
20
// (2) We are goind to let users create
message boxes if they like
21
cp.ReferencedAssemblies.Add("System.Windows.Forms.dll");
22
cp.GenerateInMemory = true;
// false means create an output file
23
24
// (3) We can save intermediate files
if we like
25
//cp.TempFiles = new TempFileCollection(".",
true);
26
27
28
//List<string> al = new List<string>();
29
30
// (4) our source code will be in one
big string
31
StringBuilder sb =
new StringBuilder();
32
33
sb.Append("using System;\n");
34
sb.Append("using System.Windows.Forms;\n");
35
sb.Append("public class hola\n");
36
sb.Append("{\n");
37
sb.Append("public void f()\n");
38
sb.Append("{\n");
39
sb.Append(CodeSnippet);
40
sb.Append("\n}\n}\n");
41
42
// one array element is like one source
file
43
string[] theCode = { sb.ToString()
};
44
45
46
// (5) Where the action is
47
CompilerResults cr = cscp.CompileAssemblyFromSource(cp, theCode);
48
if (cr.Errors.Count == 0)
49
{
50
Assembly
a = cr.CompiledAssembly;
51
Type HolaType;
52
HolaType = a.GetType("hola");
53
Object it;
54
it = Activator.CreateInstance(HolaType);
55
MethodInfo
mi;
56
mi = HolaType.GetMethod("f");
57
try
58
{
59
mi.Invoke(it,
null);
60
// (6)The following lines report where the code is running, but otherwise add nothing
61
Console.ForegroundColor
= ConsoleColor.Blue;
62
Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);
63
Console.ForegroundColor
= ConsoleColor.Gray;
64
65
}
66
catch (Exception Ex)
67
{
68
MessageBox.Show(Ex.Message);
69
}
70
}
71
else
72
{
73
Console.ForegroundColor
= ConsoleColor.Red;
74
foreach (CompilerError
ce in cr.Errors)
75
{
76
Console.WriteLine(ce.ErrorText);
77
}
78
Console.ForegroundColor
= ConsoleColor.Gray;
79
}
80
}//RunIt
81
82
83
84 }//CompileInvoke
Class
85
86
87
88
Figure 1.
In section (1), we create our two fundamental objects, the CSharpCodeProvider which
will supply the compiler and the CompilerParameters object which we will use to
control the conditions of compilation. In section (2) we set a parameter which adds
System.Windows.Forms.dll as a reference. Obviously we can add whatever we need.
For example, we could add a reference to the application itself, and allow users
to control the program with .NET scripts. In this case, we tell the set the GenerateInMemory
parameter to true, indicating that we do not wish to create an actual exe
or dll file.
In section (4), we use a StringBuilder to create the actual source code for out
class. When the code string is finished, we assign it to the first element of a
string array called theCode. The compiler expects a string array as an argument,
even if we are only compiling a single string as we are here. A multiple element
string array would be the equivalent of compiling a project with multiple C# source
files. Each element of the array must therefore have the appropriate namespace declarations
etc.
In section (5) we actually do the work of compilation. We obtain a CompilationResults
object from the CompilaAssemblyFromSource method, and then assign
the CompiledAssembly reference to a variable of type assembly.
This is a full-fledged assembly and contains our new class and method. However,
the code which will run this new assembly does not know about the class or method,
so we must do some more work. We call GetType to obtain the type of our "hola" class
and we then use Activator.CreateInstance to instantiate it. A
MethodInfo object is then used to invoke the "f" mthod which was defined
in out source code. In this example we are keeping things simple, but in practice
the function we are invoking might require parameters. In this case, the second
argument of Invoke would be an Object array holding
the parameters. After Invoke executes (and we're certain not to get an error, aren;t
we?) a few lines of code print to the console the name of the AppDomain in which
this code is executing. At present, this code does not provide us with any useful
information. Later, if we run this code from a different AppDomain, this code will
provide confirmation of that fact.
We will need a simple user interface to test this code. If you like, you can create
a console application which loads a Windows Form with a RichTextBox control serving
as a simple source code editor. Or, if you prefer, you can use the form found in
the collector01.zip file. The form must contain a button or a menu item which calls
our RunIt method and passes the contents of the RichTextBox containing the C# source
code. The code might look something like this:
12
internal static
void RunItHere(string
CodeSnippet)
13
{
14
(new CompileInvoke()).RunIt(CodeSnippet);
15
}
Figure 2.
Loading .NET Assemblies into a New AppDomain
Loading .NET assemblies into a new AppDomain is more difficult to understand than
it is to actually program. The conceptual difficulty lies in the fact that the code
which gets the ball rolling runs in the first AppDomain, so the code loaded into
the new AppDomain will not be directly accessible. The alternative method to RunItHere
(figure 2) is RunItInNewAppDomain which we will design to create a new AppDomain
and then load our CompileInvoke object into that new AppDomain. We will then have
to find some way of calling the RunIt method, which as we have said cannot be called
directly. Here is our alternative to the code in Figure 2:
17
internal static
void RunItInNewAppDomain(string
CodeSnippet)
18
{
19
// (1) Create the new AppDomain
20
AppDomain AD =
AppDomain.CreateDomain("HeyThereBuddy");
// It's a friendly name
21
// (2) load our class into the AppDomain,
instantiate CompileInvoke,
22
// and get a reference to a proxy
for the CompileInvoke object
23
CompileInvoke mbro = (CompileInvoke)AD.CreateInstanceAndUnwrap("AppDomainTestLibrary", "CompileInvoke");
24
mbro.RunIt(CodeSnippet);
25
}
Figure 3.
Section (1) creates the AppDomain with a friendly name of our choosing (as opposed
to a full name). Section (2) does quite a bit. It tells the new AppDomain
to load AppDomainTestLibrary.dll and to instantiate a CompileInvoke object. We will
need to cast the return of CreateInstanceAndUnwrap as the correct
type. But the return value is not actually a reference to our new object. Since
the code in the new AppDomain cannot be directly accessed, what we get is actually
a reference to a proxy which will marshal any calls to the code in the new AppDomain.
But how did the proxy get created? The answer is "It didn't!". If we tried
to run this code, we would get a runtime error. In order to be accessed across AppDomains,
a class must either be serializable or it must inherit from MarshalByRefObject.
My CompileInvoke method is neither, but you probably already noticed that. Let's
modify the CompileInvoke class:
10 //
must either be serializable or inherit from MarshalByRefObject
11 public
class CompileInvoke
: MarshalByRefObject
12 {
13
public void RunIt(string CodeSnippet)
14
{...
Figure 4.
Now we should be able to test our application.
Of course, up till now we have been letting the AppDomain management tasks to the
CLR itself. Now we will turn our attention to designing our own AppDomain manager.
In .NET versions prior to 2.0, this usually involved writing unmanaged code in our
host. Now, however, .NET 2.0 has provided a toolset to accomplish this task entirely
in managed code. Appropriately enough, the new class provided for this purpose is
called the AppDomainManager class.
Next: Page 2