The Perfect Service - Part 2
 
Published: 10 Jan 2006
Unedited - Community Contributed
Abstract
In this article, Ambrose explores the code and techniques involved in getting the "Perfect Service" working. See Part 1 of this series for an introduction to the application and how to use it.
by J. Ambrose Little
Feedback
Average Rating: This article has not yet been rated.
Views (Total / Last 10 Days): 28313/ 52

Introduction

Note: This article was originally published in August 2004 on 15Seconds.com, but it has gotten good reviews, and the techniques, I believe, are still valid.  I hope the readers of ASPAlliance find it as useful as I have.

[Download Code]

In the first part of this series, we covered what it is that the .NET Service Manager does as well as how to install and configure managed services to run under it. In this part, we're going to cover the details of how the .NET Service Manager works, that is, how it enables the great features such as drag-n-drop deployment. Many of the concepts behind this can be abstracted to apply to other .NET projects you may work on.

There are a few core technologies that made this facility possible, which are .NET Remoting, AppDomains, Reflection, shadow copy, and other standard object-oriented practices like classes and interfaces. The key technology that enables the dynamic loading and, more importantly, unloading of assemblies is Remoting; it allows us to completely load, host, and run code in an isolated part of memory (an application domain, a.k.a., AppDomain) that for all intents and purposes acts like a standard Windows process.

Understanding AppDomains and Their Relationship to Remoting

AppDomains are like scaled down processes that allow us to have the isolation we need for reliability and security without the greater processor and memory overhead that complete Win32 processes entail. Many AppDomains can be run inside of one actual process, and communication between AppDomains within a process is, as you might expect, faster than inter-process communication, but there is still some performance impact due to the need to traverse Remoting boundaries.

To understand, relatively, the overhead involved in remote communication, let's consider two people. First, imagine that they are in the same office building, in two different departments. The departments could be likened to AppDomains in one process. To send a message or package between themselves, they use interoffice mail. There's fairly little time or cost involved to do this.

Now imagine those same two people are located across the country from one another and they want to send the same message or package. Now they'd have to pay for postage and schedule a pickup or drop it off at a post office to send it, which clearly involves more time and cost, increased overhead. This would be relatively similar to communicating between actual Win32 processes.

Finally, consider that one person lives in the U.S.A., and the other lives in the U.K. To send the same message or package would dramatically increase time and, to a lesser extent, cost, and is akin to communicating over a network between two physical machines.

If these two people were in the same room, communication would be much faster and cheaper. No packaging or envelopes would be necessary to communicate-they could simply talk to each other and hand each other whatever they needed to. This would resemble in-AppDomain communication and is clearly the most efficient means of communicating between two objects.

So far, the analogy speaks mainly to performance. In our case, with the .NET Service Manager, the more important feature of Remoting is the ability to instantiate an object in another AppDomain and to send it key messages, such as Start and Stop. We do this by using an object that inherits from the MarshalByRef class. Inheriting from this allows our objects to be created and live in one AppDomain and be controlled from another AppDomain because just a reference (address) is sent across AppDomain boundaries in a package (proxy); hence the name marshal (escort, if you will) by reference.

By default, objects are marshaled across Remoting boundaries by value, meaning a complete copy of the data (values) will be serialized and sent. Actually, for that to work with Remoting properly (as the term is normally applied), you have to add the SerializableAttribute to your class. In saying "by default," it would probably be better to say that in most cases, it is the preferred and recommended way to deal with remote data for reasons that we won't cover here. In our case, however, we must use MarshalByRef, and since we are dealing with intra-process, cross-AppDomain communication, the typical negative impacts of MarshalByRef are negligible.

So we use Remoting to create an instance of a type in a different AppDomain. In the code, this type is called RemoteServiceHandler. The reason that we have to use this approach is that we must avoid loading any type information into the main AppDomain because if we do that, the entire assembly will be loaded into the main AppDomain and we won't be able to unload it dynamically. One of our goals in this application is to be able to load and unload our managed services (assemblies) dynamically to allow for hot updates to be applied. The only way to do that, which I have found, is to create a separate AppDomain for each unique managed service assembly and use Remoting to create and control the managed service's IService implementation. (If you are unfamiliar with the IService or managed service terminology, please refer to Part 1.)

By loading up individual AppDomains, we can use the AppDomain.Unload method to completely unload the assemblies from memory and, if applicable, load a new, updated copy without having to restart the main .NET Service Manager process. Again, we use the separate AppDomain to isolate the managed service's assembly information to make this possible. If we loaded the type directly in the main AppDomain, we could not unload it to be updated without restarting the main AppDomain and, consequently, the entire process because, as I said, if you load any type info from an assembly, it will load the assembly into that AppDomain. See Figure 1 for an illustration of the isolation we have.

Figure 1 – AppDomain Remoting

Concepts in Action

Now that we've covered the high-level concepts and technologies involved, we can take a closer look at our particular implementation. The engine of this application is in the ServiceBroker assembly. As stated in Part 1, this is what contains the definition of the required ServiceEntryPoint attribute and the IService interface. It also contains the code the does the actual loading, starting, stopping, and unloading of managed services, including the RemoteServiceHandler type that we use to remotely interact with types in the child AppDomains.

The ServiceBroker class is the core engine class that the .NET Service Manager Windows service code calls when a change is made, either to the service state itself or to the directory that it is set up to monitor. The first part of the ServiceBroker class (Listing 1) contains the declaration of several HybridDictionary static/shared instances that are used to cache data and references to the managed services. I chose the HybridDictionary type because it is the best performer for collections that will typically be small but could become larger.

Listing 1 – Dictionaries

private HybridDictionary serviceNames = 
      new HybridDictionary(10);
 
private HybridDictionary serviceAppDomains = 
      new HybridDictionary(10);
 
private HybridDictionary services = 
      new HybridDictionary(10);
 
private HybridDictionary serviceLastModified = 
      new HybridDictionary(10);

The serviceNames and serviceLastModified dictionaries are used to simply cache some data about the loaded services; serviceNames uses the path to the original assembly location as a key and the ServiceEntryPointAttribute.ServiceName value for that managed service as the value, and serviceLastModified uses the ServiceName as the key and the DateTime that the assembly for that service was last modified. The other two contain references to important objects-serviceAppDomains stores references to the dynamically-created AppDomains, using the ServiceName as key, and services actually contains references to the RemoteServiceHandler that is used to control the managed service remotely in its corresponding AppDomain.

Start 'Em Up

Listing 2, below, shows a portion of the ServiceBroker.StartService method, which is the core method for the application functionality. You can see it takes one parameter, filePath, that is used to locate the desired DLL. First, we extract the file name out of the path and compare it to two files that will always be in the Service Manager directory. We do this to avoid unnecessary processing for these files.

Listing 2 – StartService

public void StartService(string filePath)
{
      string serviceName;
      string fileName = filePath.Substring(
            filePath.LastIndexOf("\\")
+ 1);
 
      if (fileName.IndexOf("Microsoft.ApplicationBlocks")
!= -1 ||
            fileName == "ServiceBroker.dll")
            return;
      try
      {
            AssemblyName asmName = null;
            try
            {
                  asmName = AssemblyName.GetAssemblyName(filePath);
            }
            catch (Exception ex)
            {
                  Logger.WriteToLog(

    String.Format("Could not get assembly name from '{0}'; bypassing that
file.",
                              filePath) + "
Exception Details: " + ex.ToString(),
                        System.Diagnostics.EventLogEntryType.Warning);
                  return;
            }

Next we begin the loading process. We first use Reflection's AssemblyName.GetAssemblyName method to do two things. We want to determine if this is a .NET assembly or some other kind of DLL. If an exception is thrown from that method, it is most likely not a .NET assembly. We also will be using some of the AssemblyName information later. Please note that loading the AssemblyName does not load the assembly into memory-it only reads the metadata necessary to populate the AssemblyName properties.

In Listing 3, we see the next block of code in this method. Its purpose is to see if we can shortcut further execution by seeing if we have already loaded the current version of the assembly in question. It does this by checking first if we've cached it in the serviceNames collection. If so, we get the service name and the last modified time from the file currently in the directory. We use these values to compare to the cached last modified time to see if a new version has been dropped into the directory.

Listing 3 – StartService Cont’d

if (this.serviceNames.Contains(filePath))
{
      serviceName = this.serviceNames[filePath].ToString();
      DateTime curTime = File.GetLastWriteTime(filePath);
      if (curTime.Ticks <= 
            ((DateTime)this.serviceLastModified[serviceName]).Ticks)
      {
            Logger.WriteToLog(
                  String.Format("Skipping
'{0}' because it is already loaded.",
                  serviceName),
System.Diagnostics.EventLogEntryType.Information);
            return;
      }
      else  {
            this.UnloadService(serviceName);
            this.serviceNames.Remove(filePath);
      }
}

If the version currently in the directory is the same as or older than the currently-loaded version, we log that we are skipping it and exit out of the function because we don't need to reload. Otherwise, a new version has been placed in the directory, so we need to unload the current version and remove it from the caches.

The UnloadService method (Listing 4) is used anywhere that we want to stop and unload a managed service and remove references to it from our static caches.

Listing 4 – UnloadService

private void UnloadService(string serviceName)
{
      RemoteServiceHandler service = 
            this.services[serviceName] as RemoteServiceHandler;
      if (service != null)
      {
            try
            {
                  service.StopService();
            }
            catch (Exception ex)
            {
                  Logger.LogException(ex);
            }
      }
      this.services.Remove(serviceName);
      AppDomain svcDomain = 
            this.serviceAppDomains[serviceName] as
AppDomain;
      if (svcDomain != null)
      {
            try
            {
                  AppDomain.Unload(svcDomain);
            }
            catch (Exception ex)
            {
                  Logger.LogException(ex);
            }
      }
      this.serviceAppDomains.Remove(serviceName);
      this.serviceLastModified.Remove(serviceName);
}

Using the service name, we attempt to get the reference to the managed service's RemoteServiceHandler in order to shut it down by calling its IService.StopService implementation-RemoteServiceHandler.StopService calls that on whatever service it is handling. We'll go over the RemoteServiceHandler details shortly. The next thing this method does is get a reference to the managed service's AppDomain and attempts to unload it using AppDomain.Unload, and finally, it removes the other references in the caches, except for the serviceNames cache, which must be cleared in the calling code as seen in the last line of Listing 3.

After checking if we need to load a new version of the managed service (and unloading if it's already there), we move on to the code required to create our AppDomain and remotely load the managed service.

Listing 5 – StartService Cont’d

AppDomain svcDomain = null;
try
{
      AppDomainSetup setup = new AppDomainSetup();
      setup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory;
      setup.PrivateBinPath =
setup.ApplicationBase;
      setup.ApplicationName = asmName.FullName;
      setup.ShadowCopyDirectories =
setup.ApplicationBase;
      setup.ShadowCopyFiles = "true";
      svcDomain = AppDomain.CreateDomain(asmName.FullName,
null, setup);
}
catch (Exception ex)
{
      Logger.LogException(
            new ApplicationException(
String.Format("Could not create an
AppDomain for '{0}'; bypassing that assembly.", 
            asmName.FullName), ex));
      return;
}

The listing above shows the code we use to set up a new app domain to be used for running the managed service. I decided to use an AppDomainSetup object because it provides some options that the AppDomain.CreateDomain overloads do not. The important items here are to set the directory properties to point to the current application directory and to set it to shadow copy the files so that it will not lock the assemblies in the application directory. I wouldn't expect an exception to be thrown here normally, but I thought I'd catch it to log exactly what was taking place and to not end the thread in an exception. This is a good practice when your applications do not have a user interface; that is, you need to catch and log exceptions.

Now we have our very own AppDomain created for our managed service to run within, so the next thing to do is to load up the managed service in the new application domain. To do this without loading any type data into the main AppDomain, we have to use the aforementioned RemoteServiceHandler. What this type does is give us a known type (it's declared in the ServiceBroker assembly, not the managed service assembly) that we can use to instantiate in the child application domain. Since it inherits from MarshalByRef, the instance is actually hosted in the child domain, and we simply send messages to it.

Listing 6 – StartService Cont’d

RemoteServiceHandler svc = null;
try
{
      svc = (RemoteServiceHandler)  
            svcDomain.CreateInstanceFromAndUnwrap(
            svcDomain.BaseDirectory + "\\ServiceBroker.dll",

            "ServiceBroker.RemoteServiceHandler");
}
catch (Exception ex)
{
      AppDomain.Unload(svcDomain);
      Logger.LogException(
            new AssemblyLoadException(
"Could not load ServiceBroker remote
service handler, bypassing that file.",
            asmName.FullName, ex));
      return;
}

Listing 6 shows the code that will attempt to create an instance of the RemoteServiceHandler to be hosted in the managed service's domain. We do this by calling the CreateInstanceFromAndUnwrap method on that AppDomain instance. This particular method is just a handy helper because essentially it just does what two other methods available to us would do, namely AppDomain.CreateInstanceFrom and ObjectHandle.UnWrap. CreateInstanceFrom returns an ObjectHandle; then UnWrap is called on the ObjectHandle, which gives us a transparent proxy to the RemoteServiceHandler in the service's domain.

You could say that at this point we know of a person in a nearby office and have her address on hand for interoffice memos. But everybody else involved in this operation, thus far, has been in the same room with us. This is the first remote person we're dealing with.

Notice also that we start calling AppDomain.Unload in our exception handlers at this point. This is because we now have a loaded application domain that we don't want to keep in memory should something go wrong that makes the AppDomain no longer necessary.

There are a couple things to consider here about RemoteServiceHandler that relate to its Remoting capabilities. First, we set it to inherit from MarshalByRef; I won't reiterate here what has been said on this point already. Also, and quite significantly, we override the InitializeLifetimeService to return null. The reason for this is that we do not want our proxy to expire or our remote object to be collected. Returning null from this method effectively disables lifetime management for the instance. We want to maintain a viable link to the managed service we are creating because we will want to call the StopService method on it at some later time, possibly weeks or even months down the road.

The next step we make, once we have acquired a transparent proxy to a RemoteServiceHandler instance in the service's AppDomain, is to go ahead and attempt to start up the service in its domain. We do this by calling RemoteServiceHandler's LoadService method, as seen in the next listing. Doing this actually sends a message to the instance in the other AppDomain, asking it to load a service with the name we provide, just as we might send a note to a coworker in another office to ask them to help us out with something. Remember, any time we are dealing with a proxy, we are not actually dealing directly with the instance but are, rather, sending messages back and forth.

Listing 7 – StartService Cont’d

try
{
      if (!svc.LoadService(asmName.FullName))
      {
            AppDomain.Unload(svcDomain);
            Logger.WriteToLog(
String.Format("No ServiceEntryPointAttribute
was found for assembly '{0}'.",
                  asmName.FullName),
                  System.Diagnostics.EventLogEntryType.Warning);
            return;
      }
}
catch (Exception ex)
{
      AppDomain.Unload(svcDomain);
      Logger.LogException(ex);
      return;
}

LoadService does three, critical things. First, it attempts to load up the target assembly. In our case, it uses the AssemblyName.FullName property that we retrieved earlier when determining that this is a .NET assembly. After loading the managed service assembly, it attempts to find the ServiceEntryPointAttribute for that assembly using Reflection's Assembly.GetCustomAttributes method. Assuming the loaded assembly has a ServiceEntryPoint attribute applied to it, LoadService can then retrieve the service entry point type name and the service friendly name. Lastly, with the information from that attribute, it attempts to create an instance of the type that implements IService, calling CreateInstance on the assembly and passing in the service entry point type name as shown below in Listing 8.

Listing 8 – RemoteServiceHandler.LoadService (Excerpt)

try
{
      this.service = (IService)assembly.CreateInstance(
            this.serviceEntryPointType, true);
}
catch (Exception ex)
{
      throw new TypeInitializationException(
            this.serviceEntryPointType, ex);
}

If all of this works without exception, the method returns a true value (by sending a message back to ServiceBroker), indicating that the managed service in the specified assembly was loaded correctly. By this point, the bulk of the magic is done in the ServiceBroker. We now have a new application domain in which we have remotely loaded an instance of the desired managed service. All that is left to do now is to start the managed service and cache our references so that we can use them later to stop and unload the service when necessary, as seen in Listing 9.

Listing 9 – StartService Cont’d

svc.StartService();
serviceName = svc.ServiceName;
this.serviceNames.Add(filePath, serviceName);
this.services.Add(serviceName, svc);
this.serviceLastModified.Add(
      serviceName, File.GetLastWriteTime(filePath));
this.serviceAppDomains.Add(serviceName,
svcDomain);

After the code in Listing 9 executes, the service will have started, i.e., whatever code that the author of that particular managed service chose to include in his IService.StartService implementation will have been executed. In Part 1, we covered what is involved in setting up a managed service, and in our particular SampleService, all that would have executed was a message logged to the event log, but I imagine that for most normal managed services, a timer of some sort will be started to handle a task on a regular basis.

Bring 'Em Down

Of course, the flip side of this is what is required to stop a managed service. Fortunately, this is much simpler because we have already done all the hard work. When a managed service assembly is deleted or when a user chooses to stop the .NET Service Manager in the Services applet, a call is made to ServiceBroker's StopService method (Listing 10). This method, as you will see, takes a file path of the managed service to stop and uses that to look up it up in the service names cache. If found, it will call the UnloadService method, which we already covered in Listing 4, and then remove it from the service names cache. Ah, if only it were all so simple!

Listing 10 – StopService

if (this.serviceNames.Contains(filePath))
{
      this.UnloadService(Convert.ToString(this.serviceNames[filePath]));
      this.serviceNames.Remove(filePath);
}

So now you've seen most of what's involved to give the .NET Service Manager its magic. Obviously, most of this, especially the concepts surrounding AppDomains, Remoting, and Reflection, could be very useful in any application where you want to enable such cool features as dynamic loading, updating, and unloading of assemblies.

Housekeeping

There are actually a few things that I won't go into detail on in this article, for brevity's sake, that are part and parcel of the .NET Service Manager. The first is the actual Windows service code. I didn't go into this because I don't think I can add much of value to what's already out there-there are plenty of articles that cover how to build Windows services in .NET. Suffice it to say that the "real" Windows service involves creating an instance of the ServiceBroker and making use of a FileSystemWatcher to watch the application directory for new DLLs. When a DLL is added or changed in the directory, it will call ServiceBroker.StartService for the DLL. When a DLL is deleted, it will call ServiceBroker.StopService for that file. And when you use the Windows Services applet to start and stop or issue the same command from the console, it just cycles through all DLLs in the directory and calls the StartService and StopService methods on its ServiceBroker instance.

There was only one caveat I found in monitoring the directory as described. I found that I sometimes got multiple events raised from the FileSystemWatcher for the same file being dropped into the directory. To avoid processing the same file change twice, I added a check to wait two seconds between changes. Unfortunately, this led to a bug when you drop in multiple DLLs at once, so I modified that check to also see if the file changed was different from the previous. This means that I only process file changes that are either two seconds apart or are not the same file.

The other bit of the app that I'll just cover briefly is the Config class. This is the class, if you remember from Part 1, that enables us to have separate configuration files for each managed service. In short, this class, when instantiated, sets up a FileSystemWatcher to look for a file with the managed service assembly name plus ".config" just like .NET does for .EXE applications. It does this by calling Assembly.GetCallingAssembly().GetName(false).CodeBase, replacing the URL-like syntax with standard Windows syntax and appending ".config" on the end. It uses a HybridDictionary to store the appSettings from that file, delaying loading it until you request a setting using the provided string indexer.

Overall, I hope you find the .NET Service Manager to be a useful service to you in your development; I know I and others already have. There is one caveat with the application as a whole that I don't think can be reasonably solved, which is if you are deploying a managed service that has dependent DLLs, you need to deploy the referenced DLLs first to ensure that when StartService is called on your managed service that you don't get any errors because it can't find referenced assemblies. I suppose I could create some sort of timed queue that waited a certain amount of time before beginning to process assemblies, but that wouldn't be wholly dependable. I think it's best to just make a note of it in your deployment procedures.

Related Resources

.NET Remoting Overview - MSDN Library
Programming with AppDomains - MSDN Library
AppDomains and Dynamic Loading - Eric Gunnerson
Reflection Overview - MSDN Library
FileSystemWatcher Class - MSDN Library
Suzanne Cook's .NET CLR Loader Notes



User Comments

No comments posted yet.






Community Advice: ASP | SQL | XML | Regular Expressions | Windows


©Copyright 1998-2024 ASPAlliance.com  |  Page Processed at 2024-03-29 1:18:02 AM  AspAlliance Recent Articles RSS Feed
About ASPAlliance | Newsgroups | Advertise | Authors | Email Lists | Feedback | Link To Us | Privacy | Search