Introduction
Consider writing a .NET remoting based server application that needs to serve every client session in a separate AppDomain. You could take a simple and straightforward approach by allocating multiple AppDomains and configuring each AppDomain for .NET remoting. Each AppDomain, however, must be configured so that the server's listening channels are assigned a different port. TCP will not permit more than one listener per port.
On the client side, every application must know which endpoint (hostname:port) to address for a session to be established in a designated AppDomain. This concept of binding a session to a designated AppDomain and directing the client application via a specific endpoint there can certainly be realized, but it is neither practical nor conventional. A client typically requests to be served at a commonly known endpoint, irregardless in which AppDomain the server wishes to host the session.
This article is about hosting client sessions in separate AppDomains but allowing all clients to connect to a commonly known endpoint. The idea is that the server receives a client request in the default AppDomain but then intercepts and forwards it on to a different AppDomain.
How to intercept and marshal a client's request from one AppDomain to another
A remote method invocation is all about Message objects passing through a chain of sinks from the client's end to the server's end. A method call is transformed into a serializable object, passed into a sink chain on the client side, serialized into a stream, and transmitted across the network. On the server's side, it is received, deserialized into the Message object, and passed up a sink chain to be finally reconstructed into a call to the service. We want to intercept the Message object before it reaches the point of reconstruction into the proper method call. Once intercepted, we can redirect the Message object to the appropriate AppDomain where the reconstruction to the final method call will take place.
Fortunately, the .NET remoting infrastructure provides us with a facility to make these interceptions. The key here is the ContextBoundObject
. Instead of deriving a remote service object from MarshalByRefObject
, you need to derive from ContextBoundObject
.
Of course, the clients can never directly connect to the actual service objects that are instantiated in separate AppDomains. The object they will connect to must be some sort of a cross domain marshaller that can intercept a method call and forward it to the appropriate AppDomain. A CrossDomainMarshaller
that is a ContextBoundObject
must be installed as an interceptor.
[CrossDomainContextAttribute]
class CrossDomainMarshaller : ContextBoundObject
{
}
CrossDomainMarshaller marshaller = new CrossDomainMarshaller();
RemotingServices.Marshal(marshaller, "PrintService.rem");
The CrossDomainMarshaller
assumes the object URI "PrintService.rem"
of the actual service that the clients are interested in. The connection code on the the client side would appear normal, like so:
string url = "tcp://myhost:1234/PrintService.rem"
IPrintService service =
(IPrintService)Activator.GetObject(typeof(IPrintService), url);
service.PrintMessage("Hello World");
The CrossDomainMarshaller
does not implement the IPrintService
interface at all. So, how can this possibly work? The answer is with the ContextBoundObject
. I will not explain how to implement a ContextBoundObjet
but refer you instead to MSDN or an excellent book about .NET Remoting, such as "Advanced .NET Remoting", written by Ingo Rammer et all, and published by Apress. This article also includes source code that you may download and inspect.
The important matter is that implementing a CrossDomainMarshaller
as a ContextBoundObject
requires us also to implement a MessageSink
object. That offers us the opportunity to intercept the Message before it enters the point of reconstruction to the ultimate method call.
class MessageSink : IMessageSink
{
public IMessage SyncProcessMessage(IMessage msg)
{
return this.nextSink.SyncProcessMessage(msg);
}
}
As I have earlier mentioned, a Message travels along a chain of message sinks until it arrives on the server's side to be converted into a method call. As the code snippet indicates, the method invocation is bound to the IPrintService
interface and the CrossDomainMarshaller
does not implement one. So, passing it off to the next sink will result in failure. We need to redirect the call to the appropriate service object in the appropriate AppDomain.
The problem is one of finding the correct AppDomain for the presently calling client. The CrossDomainMarshaller
intercepts all messages send by all clients but we have no means to identify each client. The Message object contains no particular information as to the client's identity. To improve upon this, we need to engage the client into cooperation. The client agrees to connect via a CustomProxy
. Here is how the client code would look like.
string url = "tcp://myhost:1234/PrintService.rem"
IPrintService service = (IPrintService)new CustomProxy(url,
typeof(IPrintService)).GetTransparentProxy();
service.PrintMessage("Hello World");
This is not an intolerable imposition. But the client must implement a CutomProxy
that must look like so:
class CustomProxy : RealProxy
{
string url;
string clientID;
IMessageSink messageSink;
public CustomProxy(string url, Type type) : base(type)
{
this.url = url;
this.clientID = Guid.NewGuid().ToString();
foreach (IChannel channel in ChannelServices.RegisteredChannels)
{
if (channel is IChannelSender)
{
IChannelSender sender = (IChannelSender)channel;
if (string.Compare(sender.ChannelName, "tcp") == 0)
{
string objectUri;
this.messageSink =
sender.CreateMessageSink(this.url,
null, out objectUri);
if (this.messageSink != null)
break;
}
}
}
if (this.messageSink == null)
throw new Exception("No channel found for " + this.url);
}
public override IMessage Invoke(IMessage msg)
{
msg.Properties["__Uri"] = this.url;
LogicalCallContext callContext =
(LogicalCallContext)msg.Properties["__CallContext"];
callContext.SetData("__ClientID", this.clientID);
return this.messageSink.SyncProcessMessage(msg);
}
}
The CustomProxy
allows to create, and then transmit, a unique client identifier with every method call. The Message object allows to pass along additional out of band data as part of its LogicalCallContext
, providing the means to identify each client on the server's side.
class MessageSink : IMessageSink
{
public IMessage SyncProcessMessage(IMessage msg)
{
LogicalCallContext callContext =
(LogicalCallContext)msg.Properties["__CallContext"];
string clientID = (string)callContext.GetData("__ClientID");
if(clientID != null)
return CrossDomainMarshaller.GetService(clientID).Marshal(msg);
else
return new ReturnMessage(new ApplicationException("No __ClientID"),
(IMethodCallMessage)msg);
}
}
The line return CrossDomainMarshaller.GetService(clientID).Marshal(msg);
is a short way of saying 'get the correct service based on the client identifier and invoke its Marshal
method'. Let us look at the implementation of CrossDomainMarshaller.GetService
.
[CrossDomainContextAttribute]
class CrossDomainMarshaller : ContextBoundObject
{
public static Dictionary<string, ICrossDomainService>
Dictionary = new Dictionary<string, ICrossDomainService>();
public static ICrossDomainService GetService(string clientID)
{
if (Dictionary.ContainsKey(clientID))
return Dictionary[clientID];
AppDomain appDomain = AppDomain.CreateDomain(clientID);
ICrossDomainService service =
(ICrossDomainService)appDomain.CreateInstanceAndUnwrap(
"ContextBoundRemoting.Service",
"ContextBoundRemoting.PrintService");
Dictionary.Add(clientID, service);
return service;
}
}
You can readily see that we create an AppDomain per client only once, and not per every call. We, then, instantiate the PrintService
in that AppDomain and then store it so it can be retrieved for any subsequent call. What we return, however, is not some interface to a PrintService
but an ICrossAppDomainService
interface which allows us to marshal the Message
across the AppDomain. So, that PrintService
also needs to implement the ICrossDomainService
interface. The remaining and interesting question is how passing the Message object to the PrintService
can possibly result in a proper method call on the PrintService
. The short answer lies with another CustomProxy
. Take a quick look at the complete implementation of the PrintService
class:
public class PrintService :
CrossDomainService,
IPrintService
{
public string PrintMessage(string msg)
{
Console.WriteLine("{0} in AppDomain {1}", msg,
AppDomain.CurrentDomain.FriendlyName);
return "Ok " + msg;
}
}
You can see that the PrintService
class extends the CrossDomainService
class. The secret of converting the Message to a proper call on the PrintService
is hidden there. Examine the CrossDomainService
:
public class CrossDomainService :
MarshalByRefObject,
ICrossDomainService
{
class Proxy : RealProxy
{
string uri;
public Proxy(MarshalByRefObject obj)
{
this.uri = RemotingServices.Marshal(obj).URI;
}
public override IMessage Invoke(IMessage msg)
{
msg.Properties["__Uri"] = this.uri;
return ChannelServices.SyncDispatchMessage(msg);
}
}
Proxy proxy;
public CrossDomainService()
{
this.proxy = new Proxy(this);
}
public IMessage Marshal(IMessage msg)
{
return this.proxy.Invoke(msg);
}
}
The CrossDomainService
implements the ICrossDomainService
interface method Marshal(IMessage msg)
. The Message object is forwarded into a custom proxy which finally resolves the call to IPrintService.PrintMessage
. The implementation of the custom proxy is not that complicated.
Conclusion
The ContextBoundObject
is a rather powerful means to intercept method calls. If you are interested in application server technologies and ever wanted to build an application server yourself, then you are in luck. Application servers are all about intercepting and interfering with method calls.
- You can provide your own authentication and authorisation scheme.
- You can rewrite the Message object to redirect the call into an alternate object/method. This is one useful technique if you ever re-deploy your server but cannot update your clients with the new interface definitions that are normally kept in a shared assembly.
- You can selectively apply encryption/decryption to the granularity of a single method parameter.
- You can monitor anything you consider worthwhile by counting and timing a method call.
The list of possibilities is limited only by your imagination. I hope that this article meets your appreciation.