Freigeben über


Sessionless Duplex Services, Part Two: Lifetimes and Contexts

Two posts ago I wrote the following post about how to build a duplex service and client that does NOT use sessions. Once I wrote the sample, I wanted to extend it to provide some more flexibility. I've done that now, but in so doing, I ran across some issues that make this type of service useful in only a couple of circumstances -- IMHO. However, I want to throw the newer version out there to demonstrate some programmatic areas and to see whether any of you can figure out what scenarios this type of service is useful.

The first thing I wanted to do was to hook up custom extensions that tracked the lifetimes of the ServiceHost, the InstanceContext, and the IContextChannel when uses without sessions. To do this I used Extensible Objects (objects that implement IExtensibleObject<T> to which objects that implement IExtension<T> (where T is the specific extensible object) can be added). Michele Leroux Bustamante has a simple example clearly implemented here if you want to examine how this works very quickly.

I did the same thing. Here's the implementation for the ServiceHost tracker:

  class ServiceHostContext : IExtension<ServiceHostBase>, IDisposable
  {
    Guid id;

    public ServiceHostContext()
    { this.id = Guid.NewGuid(); }

    public string ID
    { get { return this.id.ToString(); } }
     
    #region IExtension<ServiceHost> Members

    public void Attach(ServiceHostBase owner)
    {
      Console.ForegroundColor = ConsoleColor.Red;
      Console.WriteLine("Attached to new ServiceHost.");
      Console.ResetColor();
    }

    public void Detach(ServiceHostBase owner)
    { throw new Exception("The method or operation is not implemented."); }

    #endregion

    #region IDisposable Members

    public void Dispose()
    {
      Console.WriteLine("Destroying service host: " + this.id);
    }

    #endregion
  }

This is made then used by a service behavior like so:

    public void ApplyDispatchBehavior(ServiceDescription serviceDescription, ServiceHostBase serviceHostBase)
    {
      serviceHostBase.Extensions.Add(new ServiceHostContext());
    }

 I addition, I have an instancecontext tracker:

  public class MyInstanceContextExtension : IExtension<InstanceContext>
  {

    //Associate an Id with each Instance Created.
    String instanceId;

    public MyInstanceContextExtension()
    { this.instanceId = Guid.NewGuid().ToString(); }

    public String InstanceId
    {
      get
      { return this.instanceId; }
    }

    public void Attach(InstanceContext owner)
    {
      Console.ForegroundColor = ConsoleColor.Red;
      Console.WriteLine("Attached to new InstanceContext.");
      Console.ResetColor();
    }

    public void Detach(InstanceContext owner)
    {
      Console.WriteLine("Detached from InstanceContext.");
    }
  }

And this is added by and endpoint behavior (so it can be used on both sides!) that itself adds an instance context initializer. So it looks like this:

 public class MyInstanceContextInitializer : IInstanceContextInitializer
  {
    public void Initialize(InstanceContext instanceContext, Message message)
    {
      MyInstanceContextExtension extension = new MyInstanceContextExtension();

      //Add your custom InstanceContex extension that will let you associate state with this instancecontext
      instanceContext.Extensions.Add(extension);
    }
  }

And finally let's track channels by creating an IContextChannel extension:

  public class ChannelTrackerExtension : IExtension<IContextChannel>
  {
    <snipExtraneousIdentifyingStuff />

    #region IExtension<IContextChannel> Members

    public void Attach(IContextChannel owner)
    {
      Console.ForegroundColor = ConsoleColor.Red;
      Console.WriteLine("Attached to new IContextChannel {0}.", owner.GetHashCode());
      this.channel = owner;
      Console.ResetColor();
    }

    public void Detach(IContextChannel owner)
    {
      Console.WriteLine("Detached from IContextChannel {0}.", owner.GetHashCode());
    }

  }

And we use an endpoint behavior (again for both sides) that implements IChannelInitializer to detect the creation of channels.

  public class ChannelInitializer : IChannelInitializer
  {
    #region IChannelInitializer Members

    public void Initialize(IClientChannel channel)
    {
      Console.WriteLine("IClientChannel.Initialize called.");
      channel.Extensions.Add(new ChannelTrackerExtension());
    }
    #endregion
  }

The service and endpoint behaviors all implement System.ServiceModel.Configuration.BehaviorExtensionElement so that they can be wired up using a configuration file. The other thing I've done is to update both the contract and the client. First, the contract now specifies one request/response service and has one one-way callback operation, like so:

  [ServiceContract(
    Name = "SampleDuplexHello",
    Namespace = "https://microsoft.wcf.documentation",
    CallbackContract = typeof(IHelloCallbackContract),
    SessionMode = SessionMode.NotAllowed
  )]
  public interface IDuplexHello
  {
    [OperationContract]
    string Hello(string greeting);
  }

  public interface IHelloCallbackContract
  {
    [OperationContract(IsOneWay = true)]
    void Reply(string responseToGreeting);
  }

The client application now creates a new object for each client proxy. This is an artifact of the fact that it's a console application; if it were a Windows Form or WPF application I wouldn't need to do this. But while I was building it out, I stumbled across some interesting behavior that illuminates what is going on. The host creates a new Client object for each connection it wants to make:

  public class ClientApp
  {
    public static void Main()
    {
      Client client = new Client();
      client.Run();
      client = new Client();
      client.Run();

And each Client object creates an AutoResetEvent to hold open the client until all callbacks are received and then in Run() creates a duplex client, invokes the request/reply operation and waits for the callbacks.

   public void Run()
    {
      // Picks up configuration from the config file.
      InstanceContext callbackInstanceContext = new InstanceContext(this);
      SampleDuplexHelloClient wcfClient = new SampleDuplexHelloClient(callbackInstanceContext);
      try
      {
        using (OperationContextScope opScope = new OperationContextScope(wcfClient.InnerDuplexChannel))
        {
          // add replyto
          Console.WriteLine(wcfClient.InnerDuplexChannel.LocalAddress.ToString());
          OperationContext.Current.OutgoingMessageHeaders.ReplyTo
            = wcfClient.InnerDuplexChannel.LocalAddress;
          Console.ForegroundColor = ConsoleColor.White;
          Console.WriteLine("Enter a greeting to send and press ENTER: ");
          Console.Write(">>> ");
          Console.ForegroundColor = ConsoleColor.Green;
          string greeting = Console.ReadLine();
          Console.ForegroundColor = ConsoleColor.White;
          Console.WriteLine("Called service with: \r\n\t" + greeting);
          Console.ForegroundColor = ConsoleColor.Cyan;
          Console.WriteLine(wcfClient.Hello(greeting));
          Console.ResetColor();
          this.waitHandle.WaitOne();
          Console.WriteLine("Set was called.");
        }
      }
      catch (TimeoutException timeProblem)
      {
        Console.WriteLine("The service operation timed out. " + timeProblem.Message);
        wcfClient.Abort();
      }
      catch (CommunicationException commProblem)
      {
        Console.WriteLine("There was a communication problem. " + commProblem.Message);
        wcfClient.Abort();
      }
    }

The callback (Reply()) remains the same. Now we can talk. As soon as I figure out how to post .zips I will. Anyway, the service now looks like this:

   public string Hello(string greeting)
    {
      Console.ForegroundColor = ConsoleColor.Green;
      Console.WriteLine("Caller sent: " + greeting);
      Console.ResetColor();
      Console.WriteLine("Session ID: " + OperationContext.Current.SessionId);
      string response = "Service object " + this.GetHashCode().ToString() + " received: " + greeting;
     
      // Generate five callbacks
      CallbackArguments args = new CallbackArguments();
      args.numCallbacks = 5;
      args.to = OperationContext.Current.IncomingMessageHeaders.ReplyTo.Uri;
      args.callerClient
        = OperationContext.Current.GetCallbackChannel<IHelloCallbackContract>();
      args.incomingHeaders
        = OperationContext.Current.IncomingMessageHeaders.ReplyTo.Headers;
      // Do this on another thread.
      System.Threading.ThreadPool.QueueUserWorkItem(
        new WaitCallback(DuplexHello.GenerateCallbacks), args
      );
      Thread.Sleep(1000);
      //DuplexHello.GenerateCallbacks(args);
      return String.Format("Hi there. You sent {0}.", greeting); 
    }

Note that I have a GenerateCallbacks method that is supposed to invoke five callbacks to the caller (you can do this either on this thread or another). Note, also, that the request/reply portion of the call is handled simply by returning. The only thing of interest here is the callbacks generated by the service. For this, recall that we needed to add the inbound ReplyTo value to the outbound To header. But -- and this is one of those interesting things, when we did TWO separate clients, the second client failed to see the response and the service did send the messages. What happened?

What happened is that the first request does not need to append any address information to the callback To header. But subsequent callbacks DO append information, and that extra information does need to be appended. So here's the simple way to do this in GenerateCallbacks:

    private static void GenerateCallbacks(object parameters)
    {
      CallbackArguments args = parameters as CallbackArguments;
      IHelloCallbackContract callerClient
        = args.callerClient;
      using (
        OperationContextScope callbackOpContext
          = new OperationContextScope((IContextChannel)callerClient)
      )
      {
        OperationContext.Current.OutgoingMessageHeaders.To = args.to;
        foreach (AddressHeader ah in args.incomingHeaders)
        {
          // deal with ref params
          OperationContext.Current.OutgoingMessageHeaders.Add(ah.ToMessageHeader());
        }

We assign the To and then we take any extra information coming in from the client instance and make sure to add that information as well. In this case, the extra information is that on subsequent calls the client creates listeners on addresses that make use of reference parameters and these, too, must be added to the outbound headers collection. Whoohooo!

Now, let's make our callbacks.

     for(int i = 0; i < args.numCallbacks; ++i)
      {
        try
        {
          Console.WriteLine("callback {0} to: {1}", i, args.to);
          callerClient.Reply(String.Format("Notification {0}.", i.ToString()));
        }
        catch (TimeoutException timeout)
        {
          Console.WriteLine("There was a timeout exception on a callback.");
        }
        catch (CommunicationException commException)
        {
          Console.WriteLine("CommunicationException: {0}", commException.Message);
        }
        catch (Exception ex)
        {
          Console.WriteLine("General exception: {0}.", ex.Message);
        }
      }

Great, right? Um, no. Here's another interesting thing. The default service InstanceContextMode is PerSession. But we aren't using a session, remember? The behavior we get in this case ends up being "PerCall". So if we return from the operation prior to the completion of the callbacks you can guess what happens: The callbacks don't get there because the service infrastructure has cleaned up the operation context and service channel from underneath us.

One fix is to do what I did here: Throw a Thread.Sleep in the operation to enable the callbacks to reach their destination successfully. Obviously this is just a mitigation for the example. Try it yourself without the Sleep and see what you get. The other way to handle this is to realize that the service InstanceContextMode could be single, in which case the underlying service channel stays around for all clients. Mix and match solutions and enjoy yourself.

Let's get back to the extensions that track lifetimes. One of the things that building the sample this way accomplishes is to establish what happens on each side when multiple clients run. First of all, each client callback address is different, so clients are still separate entities for the purposes of duplex calls. We can see that for each new Client object, you get a brand new channel. For the service, however, there is only ever one channel for the lifetime of the service host with the exception of any timeouts. When the InstanceContextMode is functionally PerCall, you get the following:

Attached to new ServiceHost.
Channel tracker added.
The service is ready.
Press <ENTER> to terminate service.

IClientChannel.Initialize called.
Attached to new IContextChannel 42132014.
Attached to new InstanceContext.
Service object created: 45155606
Caller sent: Hello.
Session ID:
callback 0 to: https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
callback 1 to: https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
callback 2 to: https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
callback 3 to: https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
callback 4 to: https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
Service object destroyed: 45155606
Attached to new InstanceContext.
Service object created: 29447802
Caller sent: Hello again.
Session ID:
callback 0 to: https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
callback 1 to: https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
callback 2 to: https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
callback 3 to: https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
callback 4 to: https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
Service object destroyed: 29447802

You can see that we get a new IntanceContext and service object for each call. But only one service channel is ever created. It's this channel that must be available for the callbacks. This means, also, that you cannot call Close on it when you're done with your callbacks. Go ahead: Close it and see what happens -- you're going to need it again later. ;-)

The client, however, has channels appearing all over. OK, for each new client:

Channel tracker added.
IClientChannel.Initialize called.
Attached to new IContextChannel 44419000.
https://localhost:8081/Callback/cea9fe7f-c3a6-41cf-9151-c54b1404d5b5
Enter a greeting to send and press ENTER:
>>> Hello first.
Called service with:
        Hello first.

        Notification 0.

        Notification 1.

        Notification 2.

        Notification 3.

        Notification 4.
Hi there. You sent Hello first..
Set was called.
Channel tracker added.
IClientChannel.Initialize called.
Attached to new IContextChannel 59109011.
https://localhost:8081/Callback/99928a92-2d3c-42cc-bd1b-34af14cc737b
Enter a greeting to send and press ENTER:
>>> Hello second.
Called service with:
        Hello second.

        Notification 0.

        Notification 1.

        Notification 2.

        Notification 3.

        Notification 4.
Hi there. You sent Hello second..
Set was called.
Press ENTER to exit...

Now, I think that based on what we've discovered that this a) isn't the application structure you'd want if you were building a sessionless duplex service and client, and b) that if you built it correctly it would still only be useful in limited scenarios given how hard you have to work to correlate things AND managed service infrastructure lifetimes. Because Christian Weyer had asked me about the sessionless duplex possibility before, now I'm going to challenge him to take a stance: How should this application be built, and once it's built correctly, what scenarios can make use of it? Christian?

 UPDATE: I forgot one other critical little piece. Note that in the callback section I generate a new OperationContext that is not the one used with request/reply. Why? Because if I use the same one I'll set the To values for the request/reply operation as well as the oneway callback -- and that would muck with the response to the operation. Therefore I only need to set the callback headers for the callback context. That's why I do that.....