Tchatchez avec Silverlight 2 et WCF

Florian Casabianca

Cet article va être l'occasion de nous intéresser au support des communications bidirectionnelles entre un service WCF et un client Silverlight 2 via HTTP.

Commentez cet article : Commentez ?

- Titre : Tchatchez avec Silverlight 2 et WCF

- Auteur : Florian Casabianca

- Parution : 15 décembre 2008

- Mise à jour : 15 décembre 2008

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Florian Casabianca. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- Introduction

- I. Présentation de l'application

- II. Le service WCF

- II-A. Les contrats WCF

- II-A-1. Le contrat de service

- II-A-2. Le contrat de rappel

- II-B. Le binding (liaison)

- II-C. Implémentation du service

- II-D. L'hébergement du service

- III. Le client Silverlight

- III-A. Présentation

- III-B. Le modèle

- III-C. L'interface graphique

- III-D. Le controller

- III-E. La communication avec le service

- IV. Exemple en image

- Conclusion

- Sources

- Liens

- Remerciements

- Contact

Introduction

Silverlight 2 propose un support des communications réseau via HTTP et TCP. Il est ainsi possible d'effectuer une requête HTTP depuis un client Silverlight vers un Web service SOAP ou REST pour y récupérer des données ou bien directement « pousser » ces données depuis le serveur vers le client en utilisant les sockets. Ce dernier scénario est intéressant, car il permet au serveur de notifier instantanément le client de mises à jour sur des données par exemple sans que celui-ci ait à le demander explicitement. Cependant, un certain nombre de contraintes, comme une restriction au niveau des ports utilisables, peuvent vous empêcher de mettre en place ce scénario.

C'est ici qu'intervient une nouveauté apportée avec la bêta 2 de Silverlight 2 et maintenant opérationnelle dans la version finale : le support des communications bidirectionnelles entre un service WCF et un client Silverlight 2 via HTTP.

I - Présentation de l'application

Afin d'illustrer cette nouveauté, nous allons développer une petite application de tchat. Nous aurons un service WCF qui permettra aux utilisateurs de démarrer une session et d'envoyer des messages (visibles par tous les autres utilisateurs connectés, car il s'agit d'un tchat). La partie cliente sera bien sûr développée en Silverlight 2 et bénéficiera d'une interface graphique des plus poussées (à l'image de celles créées par Thomas Lebrun).

II - Le service WCF

Lorsque vous créez un nouveau projet de type Silverlight dans Visual Studio 2008, celui-ci propose la création d'un projet Web afin d'héberger l'application Silverlight. Afin de simplifier la démonstration, c'est donc au sein de ce projet Web que nous développerons le service WCF de tchat. En effet, dans un cas réel les contrats WCF, leur implémentation ainsi que l'hébergement du service seraient répartis dans des projets séparés.

Pour créer le service WCF, faites un clic droit sur le projet Web et choisissez Ajouter un nouvel élément. Sélectionnez ensuite l'élément Service WCF et nommez-le ChatService.svc.

Il faudra aussi ne pas oublier de rajouter les références aux assemblages System.Runtime.Serialization, System.ServiceModel et System.ServiceModel.PollingDuplex.

II-A - Les contrats WCF

II-A-1 - Le contrat de service

Pour définir les services offerts par notre application côté serveur (opérations disponibles, types de données utilisées, etc.) nous allons créer un contrat de service. Il s'agit d'une interface décorée avec l'attribut ServiceContract et contenant des méthodes décorées par l'attribut OperationContract.

Le contrat de service WCF

[ServiceContract(Namespace = "ChatSilverlight",
CallbackContract = typeof(IChatClient),
SessionMode = SessionMode.Required)]
public interface IChatService
{
[OperationContract(IsInitiating = true, IsTerminating = false, IsOneWay = true)]
void DemarrerSession(Message receivedMessage);

[OperationContract(IsInitiating = false, IsTerminating = false, IsOneWay = true)]
void EnvoyerMessage(Message receivedMessage);
}

La propriété CallbackContract indique le type du contrat de rappel utilisé (voir ci-dessous) et la valeur Required de la propriété SessionMode spécifie que le service devra supporter les sessions.

Les différentes méthodes ont la propriété IsOneWay de l'attribut OperationContract positionnée à true pour indiquer que le client ne restera pas bloqué à attendre un message de réponse.

Positionnée à true, la valeur de IsInitiating indique qu'un appel à la méthode correspondante créera une session (sauf s'il en existe déjà une). Positionnée à false, cela indiquera que la méthode ne pourra être appelée que si une session existe déjà (dans le cas contraire, une exception sera déclenchée). Ainsi, dans notre contrat, si le premier appel d'un client au service s'effectue sur la méthode EnvoyerMessage, une exception sera déclenchée, car aucune session n'aura été créée. Un client voulant utiliser notre service devra donc obligatoirement appeler la méthode DemarrerSession en premier.

II-A-2 - Le contrat de rappel

Le contrat de rappel indique au service comment communiquer avec le client. Nous ne définirons ici qu'une méthode asynchrone ReceptionMessage que le service appellera lorsqu'il voudra envoyer un message à un client. Cette méthode possède aussi la propriété IsOneWay positionnée à true.

Le contrat de rappel WCF

[ServiceContract]
public interface IChatClient
{
/// <summary>
/// Reception par le client d'un message
/// </summary>
[OperationContract(IsOneWay = true, AsyncPattern = true)]
IAsyncResult BeginReceptionMessage(Message returnMessage, AsyncCallback callback, object state);

void EndReceptionMessage(IAsyncResult result);}

Une méthode asynchrone possède la propriété AsynPattern positionnée à true. Son nom doit respecter le pattern Begin<nom de la méthode> et il doit lui correspondre une deuxième méthode dont le nom est End<nom de la méthode>. Ce mécanisme évitera au service de rester bloqué lorsqu'un client ne répond pas.

II-B - Le binding (liaison)

Les communications bidirectionnelles avec WCF 3.0 (hors Silverlight) avaient déjà été illustrées dans un précédent article : Chattez avec WCF. L'application client se comportait alors comme un serveur Web que le service pouvait rappeler.

Cependant, les restrictions imposées par le navigateur Web l'empêchent d'être adressable par un serveur. Il n'est donc pas possible pour notre service de tchat de se connecter à l'application cliente Silverlight afin de lui envoyer des messages. Pour simuler ce comportement le nouveau Binding WCF PollingDuplexHttpBinding introduit avec Silverlight 2 utilise le protocole WS-Make-Connection (WS-MC).

Le but de se protocole est de définir un mécanisme pour le transfert de messages entre deux points de communication lorsque celui qui envoie (notre service de tchat par exemple) n'est pas capable d'initialiser une nouvelle connexion vers celui qui reçoit (notre application Silverlight 2). Le protocole définit un mécanisme permettant d'identifier de façon unique les points de communication non adressables et un mécanisme grâce auquel les messages destinés à ces points de communication sont délivrés.

Lors de la connexion au service de tchat, le client Silverlight va envoyer une requête HTTP qui ressemblera à celle-ci :

En tête HTTP

POST /ChatService.svc HTTP/1.1
Accept: */*
Content-Length: 481
Content-Type: text/xml; charset=utf-8
SOAPAction: "ChatSilverlight/IChatService/DemarrerSession"
UA-CPU: x86
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; SLCC1; .NET CLR 2.0.50727;
.NET CLR 3.5.21022; .NET CLR 1.1.4322; OfficeLiveConnector.1.1; MS-RTC LM 8; .NET CLR 3.5.30729; .NET CLR 3.0.30618)
Host: 127.0.0.1:19021
Connection: Keep-Alive
Pragma: no-cache

Corps HTTP

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header>
<netdx:Duplex xmlns:netdx="http://schemas.microsoft.com/2008/04/netduplex">
<netdx:Address>http://docs.oasis-open.org/ws-rx/wsmc/200702/anonymous?id=34a7724d-eb04-48f3-b7bf-94b5e92e8f00</netdx:Address>
<netdx:SessionId>4fab6a08-4b85-412d-8a3f-319479d8e152</netdx:SessionId>
</netdx:Duplex>
</s:Header>
<s:Body>
<string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">Tom</string>
</s:Body>
</s:Envelope>

Vous remarquerez dans l'en-tête la valeur de SOAPAction qui indique la méthode appelée sur le service (DemarrerSession). Le corps du message contient une enveloppe SOAP qui comporte le pseudo de l'utilisateur qui démarre une session. Notez enfin la présence d'une URI Make-Connection anonyme qui sera utilisée plus tard.

Suite à ça le service renvoie une réponse vide avec le code d'état HTTP 200 afin d'indiquer que le message a bien été reçu.

En tête de la réponse du service

HTTP/1.1 200 OK
Server: ASP.NET Development Server/9.0.0.0
Date: Mon, 03 Nov 2008 11:54:43 GMT
X-AspNet-Version: 2.0.50727
Cache-Control: private
Content-Length: 0
Connection: Close

Le client va maintenant interroger régulièrement le service en lui envoyant des messages Make-Connection:

En tête du message MakeConnection

POST /ChatService.svc HTTP/1.1
Accept: */*
Content-Length: 318
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://docs.oasis-open.org/ws-rx/wsmc/200702/MakeConnection"
UA-CPU: x86
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; SLCC1; .NET CLR 2.0.50727;
.NET CLR 3.5.21022; .NET CLR 1.1.4322; OfficeLiveConnector.1.1; MS-RTC LM 8; .NET CLR 3.5.30729; .NET CLR 3.0.30618)
Host: 127.0.0.1:19021
Connection: Keep-Alive
Pragma: no-cache

Notez la valeur du champ d'en-tête SOAPAction.

Corps du message MakeConnection

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<wsmc:MakeConnection xmlns:wsmc="http://docs.oasis-open.org/ws-rx/wsmc/200702">
<wsmc:Address>http://docs.oasis-open.org/ws-rx/wsmc/200702/anonymous?id=34a7724d-eb04-48f3-b7bf-94b5e92e8f00</wsmc:Address>
</wsmc:MakeConnection>
</s:Body>
</s:Envelope>

Dans l'élément Make-Connection du corps du message se trouve l'élément Adress contenant le même URI Make-Connection anonyme que celle envoyée précédemment.

Le service est configuré par défaut pour garder la connexion ouverte pendant soixante secondes. Si pendant ce laps de temps un message pour ce client arrive alors le serveur peut le lui transmettre instantanément (car la connexion est toujours ouverte).

Voici par exemple la réponse renvoyée par le service au client Tom suite à un message de Louis :

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header>
<netdx:Duplex xmlns:netdx="http://schemas.microsoft.com/2008/04/netduplex">
<netdx:Address>http://docs.oasis-open.org/ws-rx/wsmc/200702/anonymous?id=187c8792-cbc4-4329-9291-7933a46bb1fe</netdx:Address>
<netdx:SessionId>267442df-8c42-4ff1-9c33-7c76b1c7862c</netdx:SessionId>
</netdx:Duplex>
</s:Header>
<s:Body>
<ChatMessage xmlns="http://schemas.developpez.com/ChatSilverlight/2008/10" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Text>Salut Tom !</Text>
<UserName>Louis</UserName>
</ChatMessage>
</s:Body>
</s:Envelope>

Si au terme de ce temps il n'y a aucun message à transmettre au client alors le serveur renvoie une réponse vide avec le code d'état HTTP 200 afin d'indiquer que le message a bien été reçu, mais qu'il n'y a aucun message pour le client. Le client renvoie alors un nouveau messageMake-Connection et la boucle continue.

II-C - Implémentation du service

Une fois le contrat de service défini il faut passer à son implémentation. Nous créons donc une classe ChatService implémentant l'interface IChatService.

[ServiceBehavior(
//1 objet créé par client
InstanceContextMode = InstanceContextMode.PerSession,
//associé à Session: 1 seul appel par client à la fois, mais plusieurs clients simultanément
ConcurrencyMode = ConcurrencyMode.Single)]
public class ChatService : IChatService

La classe est décorée de l'attribut ServiceBehavior qui permet de spécifier le comportement interne du service.

La propriété InstanceContextMode contrôle la façon dont les objets du service sont instanciés suite aux requêtes clientes. La valeur PerSession indique qu'un nouvel objet de service est créé pour chaque nouvelle session cliente et est conservé pendant toute la durée de vie de celle-ci. Ainsi, chaque client aura une instance de la classe ChatService qui lui sera dédiée.

La propriété ConcurrencyMode permet de contrôler le nombre de threads actifs sur une instance d'un objet de service. Ici nous spécifions la valeur Single qui indique qu'un seul thread à la fois peut accéder à un objet de service. Les autres threads qui souhaitent accéder à l'instance doivent se bloquer jusqu'à ce que le thread d'origine quitte l'instance. Ainsi un objet de service donné ne pourra traiter qu'un seul message provenant de son client dédié à la fois.

Voici un schéma qui permettra de comprendre comment nous allons implémenter le service :

1) Lorsqu'un client envoie un message, celui-ci est traité par l'instance de ChatService dédiée à ce client.

2) Cet objet de service déclenche l'évènement ChatMessageEvent auquel toutes les instances de ChatService sont abonnées.

3) Tous les objets de service sont notifiés de l'envoi d'un nouveau message.

4) Les différents objets de service transmettent le message à leur client correspondant.

C'est en fait le principe du design pattern observateur qui est ici repris. Lors de la création de la session, le nouvel objet ChatService s'abonne aux différents évènements. Et lors de la déconnexion du client, l'objet Chatservice dédié s'en désabonne.

Nous déclarons donc trois évènements static dans la classe ChatService ainsi que les délégués correspondants :

internal static event ChatMessageEventHandler ChatMessageEvent;
internal delegate void ChatMessageEventHandler(object sender, ChatMessageEventArgs e);

internal static event ConnexionEventHandler ConnexionEvent;
internal delegate void ConnexionEventHandler(object sender, ConnexionEventArgs e);

internal static event DeconnexionEventHandler DeconnexionEvent;
internal delegate void DeconnexionEventHandler(object sender, DeconnexionEventArgs e);

Et voici les trois classes définissant les types des arguments utilisés par les différents évènements :

internal class ChatMessageEventArgs : EventArgs
{
//le message
public String Message { get; private set; }
//l'expéditeur du message
public String UserName { get; private set; }
public ChatMessageEventArgs(string message, string username)
{
Message = message;
UserName = username;
}
}

internal class DeconnexionEventArgs : EventArgs
{
//utilisateur se déconnectant
public String UserName { get; private set; }
public DeconnexionEventArgs(string username)
{
UserName = username;
}
}

internal class ConnexionEventArgs : EventArgs
{
//utilisateur se connectant
public String UserName { get; private set; }
public ConnexionEventArgs(string username)
{
UserName = username;
}
}

La classe ChatService contiendra trois autres variables permettant de garder une référence sur le canal de communication avec le client, de connaître le pseudo de l'utilisateur associé à l'objet de service et de définir un objet de type DataContractSerializer utilisé pour sérialiser les messages à envoyer.

//Le callback pour rappeler le client
IChatClient _callback;

//Pseudo de l'utilisateur associé à l'instance de ChatService
private String _currentUser;
//Le DataContractSerializer utilisé pour sérialiser les messages
readonly DataContractSerializer _serializer = new DataContractSerializer(typeof(ChatMessage));

Voici la définition de la classe ChatMessage utilisée côté service :

Classe ChatMessage

namespace SilverlightChat.Web
{
[DataContract(Namespace = "http://schemas.developpez.com/ChatSilverlight/2008/10", Name = "ChatMessage")]
public class ChatMessage
{
[DataMember]
public string UserName { get; set; }

[DataMember]
public string Text     { get; set; }
}
}

Pour se connecter au service de tchat, un client devra en premier lieu appeler la méthode DemarrerSession. La méthode perçoit, via le paramètre de type Message, le message SOAP envoyé par le client.

public void DemarrerSession(Message pseudoMessage)
{
//on récupère le canal de retour du client
_callback = OperationContext.Current.GetCallbackChannel<IChatClient>();

_currentUser = pseudoMessage.GetBody<string>();

//on s'abonne à la réception de nouveaux messages
ChatMessageEvent += ChatMessageHandler;

//on s'abonne à la connexion de nouveaux utilisateurs
ConnexionEvent += ConnexionHandler;

//on s'abonne à la déconnexion d'utilisateurs
DeconnexionEvent += DeconnexionHandler;

OperationContext.Current.Channel.Closing += new EventHandler(Channel_Closing);

//on informe les utilisateurs de la connexion d'un nouveau venu
ConnexionEventArgs cea = new ConnexionEventArgs(_currentUser);
if(ConnexionEvent!= null)
ConnexionEvent(this, cea);
}

Nous récupérons donc en premier le canal de communication vers le client que nous stockons dans la variable _callback. Le client est sensé avoir mis le pseudo (de type String) qu'il compte utiliser dans le corps du message SOAP envoyé. Nous le récupérons donc via la méthode générique GetBody.

Nous nous abonnons ensuite aux différents évènements dont nous avons parlé précédemment ainsi qu'à celui de fermeture du canal de communication.

Enfin, nous déclenchons l'évènement ConnexionEvent afin d'informer les différentes instances de ChatService de la connexion d'un nouvel utilisateur. Ainsi, sur chaque instance de ChatService, la méthode ConnexionHandler sera appelée. Celle-ci crée un nouvel objet de type ChatMessage et l'envoie au client via la méthode SendMessage :

//méthode appelée quand l'évènement ConnexionEvent est déclenché
private void ConnexionHandler(object sender, ConnexionEventArgs e)
{
//le texte du message
string texte = string.Format("Connexion de l'utilisateur: {0}", e.UserName);

//Le ChatMessage que l'on veut envoyer. Il sera sérialisé avec le DataContractSerializer
ChatMessage msg = new ChatMessage { UserName = "Service", Text = texte };

SendMessage(msg);
}

La méthode SendMessage crée un message SOAP 1.1 via la méthode static CreateMessage de la classe Message. Cette méthode possède plusieurs surcharges, mais celle que nous utilisons prend en paramètre la version SOAP à utiliser, une chaîne de caractères définissant l'action SOAP (espace de nom du service, interface du service, nom de la méthode du contrat de callback sans le préfixe Begin), le corps du message (de type object) et un XmlObjectSerializer à utiliser afin de sérialiser de corps du message (l'objet ChatMessage). Puis nous appelons la méthode BeginReceptionMessage en lui passant en paramètre le message SOAP précédemment construit et une fonction de callback.

private void SendMessage(ChatMessage message)
{
try
{
Message returnMessage = Message.CreateMessage(MessageVersion.Soap11,
"ChatSilverlight/IChatService/ReceptionMessage", message, _serializer);
_callback.BeginReceptionMessage(returnMessage, EndSend, null);
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}

Il est important de comprendre que côté client nous devrons utiliser le même XmlObjectSerializer afin de désérialiser le corps du message envoyé par le service (et vice versa).

Voici le code de la fonction de callback EndSend :

private void EndSend(IAsyncResult ar)
{
try
{
_callback.EndReceptionMessage(ar);
}
catch (Exception ex)
{
Debug.WriteLine("Erreur EndSend: " + ex);
}
}

Pour envoyer un message, un client appelle la méthode EnvoyerMessage.

public void EnvoyerMessage(Message receivedMessage)
{
//le message à envoyer
string msg = receivedMessage.GetBody<string>();
//création de l'EventArgs
ChatMessageEventArgs mea = new ChatMessageEventArgs(msg, _currentUser);
//on informe les utilisateurs d'un nouveau message
if(ChatMessageEvent!= null)
ChatMessageEvent(this, mea);
}

Le corps du message SOAP contient directement le texte du message que nous pouvons donc facilement récupérer. Nous déclenchons ensuite l'évènement ChatMessageEvent pour informer les autres instances de ChatService qu'un nouveau message a été envoyé par un utilisateur.

Sur ces différentes instances de ChatService, la méthode ChatMessageHandler sera ainsi appelée.

//méthode appelée quand l'évènement ChatMessageEvent est déclenché
private void ChatMessageHandler(object sender, ChatMessageEventArgs e)
{
//Le ChatMessage que l'on veut envoyer. Il sera sérialisé avec le DataContractSerializer
ChatMessage msg = new ChatMessage { UserName = e.UserName, Text = e.Message };

SendMessage(msg);
}

Nous construisons un objet de type ChatMessage que nous envoyons au client correspondant à l'instance de ChatService sur laquelle nous sommes.

Lorsqu'un utilisateur ferme l'application cliente le canal de communication est lui aussi fermé. Ainsi, côté service nous pouvons nous abonner à l'évènement de fermeture de canal afin de lancer la procédure de fermeture de session.

//le client ferme le canal
private void Channel_Closing(object sender, EventArgs e)
{
//on ferme la session
FermerSession();
}

L'instance de ChatService qui voit son client fermer le canal de communication se désabonne des différents évènements puis informe les autres instances de ChatService qu'un client s'est déconnecté en déclenchant l'évènement DeconnexionEvent.

private void FermerSession()
{
//on désabonne le client
ChatMessageEvent -= ChatMessageHandler;
ConnexionEvent -= ConnexionHandler;
DeconnexionEvent -= DeconnexionHandler;

//on informe les utilisateurs de la déconnexion
DeconnexionEventArgs dea = new DeconnexionEventArgs(_currentUser);

if (DeconnexionEvent != null)
DeconnexionEvent(this, dea);
}

Sur les autres instances de ChatService, la méthode DeconnexionHandler sera ainsi appelée. Celle-ci envoie un message au client lui informant de la déconnexion d'un utilisateur.

//méthode appelée quand l'évènement DeconnexionEvent est déclenché
private void DeconnexionHandler(object sender, DeconnexionEventArgs e)
{
//le texte du message
string texte = string.Format("Déconnexion de l'utilisateur: {0}", e.UserName);

//Le ChatMessage que l'on veut envoyer. Il sera sérialisé avec le DataContractSerializer
ChatMessage msg = new ChatMessage { UserName = "Service", Text = texte };

SendMessage(msg);
}

II-D - L'hébergement du service

Modifions ensuite le fichier ChatService.svc afin qu'il contienne la ligne suivante :

<%@ ServiceHost language="C#" Debug="true" Factory="SilverlightChat.Web.PollingDuplexServiceHostFactory"%>

Comme vous le voyez, nous utilisons l'attribut Factory qui permet de personnaliser la création de l'hôte du service WCF.

Les services classiques WCF utilisent comme hôte un objet de type ServiceHost qui dérive de ServiceHostBase. Cependant la classe ServiceHost n'est ici pas adaptée à notre cas et nous avons besoin de créer nous même un hôte personnalisé.

Il nous faut donc créer deux classes : une dérivant de ServiceHost et qui représentera notre hôte personnalisé et une dérivant de ServiceHostFactoryBase et qui sera la Factory qui créera l'instance de l'hôte.

Voici le code de la classe PollingDuplexServiceHostFactory qui dérive de ServiceHostFactoryBase :

public class PollingDuplexServiceHostFactory : ServiceHostFactoryBase
{
public override ServiceHostBase CreateServiceHost(string constructorString,
Uri[] baseAddresses)
{
return new PollingDuplexSimplexServiceHost(baseAddresses);
}
}

Rien de bien compliqué comme vous le voyez. Nous redéfinissons la fonction CreateServiceHost qui renvoie une instance de l'hôte à utiliser. Dans notre cas il s'agit d'une instance de la classe PollingDuplexSimplexServiceHost qui représente notre hôte personnalisé dont voici le code :

class PollingDuplexSimplexServiceHost : ServiceHost
{
public PollingDuplexSimplexServiceHost(params System.Uri[] addresses)
{
base.InitializeDescription(typeof(ChatService), new UriSchemeKeyedCollection(addresses));
}

protected override void InitializeRuntime()
{
// Défini le binding et le time-out.
PollingDuplexBindingElement pdbe = new PollingDuplexBindingElement()
{
InactivityTimeout = TimeSpan.FromMinutes(2)
};

// Ajoute un endpoint associé au contrat de service IChatService
this.AddServiceEndpoint(
typeof(IChatService),
new CustomBinding(
pdbe,
new TextMessageEncodingBindingElement(
MessageVersion.Soap11,
System.Text.Encoding.UTF8),
new HttpTransportBindingElement()),
"");

base.InitializeRuntime();
}
}

Au niveau des propriétés du projet Web, pensez à définir un port spécifique. Cela vous évitera de devoir reconfigurer le client à chaque relance du service.

III - Le client Silverlight

Nous allons maintenant étudier la création de l'application cliente Silverlight. L'interface graphique ainsi que le code C# seront relativement simplistes afin de garder une certaine clarté.

III-A - Présentation

La structure du projet client se présente de cette façon :

L'objet Page ne sert qu'à afficher la vue ChatWindow (UserControl) qui définit l'interface graphique de la fenêtre de tchat. Le controller ChatController est associé à la vue qu'il alimente en données. Il définit aussi des opérations (SendMessage par exemple) qui peuvent être invoquées par la vue. La classe WCFPushReceiver sert de proxy pour communiquer avec le service de tchat. Seul le controller peut l'utiliser.

Il ne faudra pas oublier de rajouter une référence aux assemblages System.Runtime.Serialization, System.ServiceModel et à la version cliente de System.ServiceModel.PollingDuplex.

III-B - Le modèle

L'application utilisera une classe ChatMessage pour représenter les messages (texte et expéditeur) reçus du service.

Classe ChatMessage

using System.Runtime.Serialization;

namespace SilverlightChat.Client.Models
{
[DataContract(Namespace = "http://schemas.developpez.com/ChatSilverlight/2008/10", Name = "ChatMessage")]
public class ChatMessage
{
[DataMember]
public string UserName { get; set; }

[DataMember]
public string Text { get; set; }
}
}

La classe est marquée par l'attribut DataContract (et ses membres public par l'attribut DataMember) pour indiquer qu'elle est sérialisable par le serialiser DataContractSerializer que nous utilisons pour envoyer les messages via WCF.

III-C - L'interface graphique

L'objet Page contient très peu de code XAML :

Code XAML de Page.xaml

<UserControl x:Class="SilverlightChat.Client.Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Views="clr-namespace:SilverlightChat.Client.Views" >
<Grid x:Name="LayoutRoot" Background="White">
<Views:ChatWindow/>
</Grid>
</UserControl>

Il s'agit ici de déclarer la vue à afficher, c'est-à-dire ChatWindow dont voici une capture d'écran :

La vue possède un controller associé que nous déclarons en tant que ressource :

Déclaration du controller dans les ressources

<UserControl.Resources>
<Controllers:ChatController x:Key="ChatControllerDS"/>
</UserControl.Resources>

Nous pourrons ainsi y faire référence dans le code XAML. Par exemple pour l'affichage de la liste des messages reçus nous utilisons un ItemsControl dont nous définissons la source comme étant le controller :

Binding sur la liste des messages

<ItemsControl ItemsSource="{Binding Mode=OneWay, Path=MessageHistory, Source={StaticResource ChatControllerDS}}">

MessageHistory étant une propriété du controller ChatController de type ObservableCollection<ChatMessage>.

Passons au code C# de la vue.

Dans le constructeur nous récupérons l'instance du controller déclaré dans les ressources. Cela nous permettra d'y accéder dans le reste du code.

Constructeur de ChatWindow

private readonly ChatController _controller;

public ChatWindow()
{
InitializeComponent();

// on récupère l'instance du ChatController depuis le XAML
_controller = (ChatController)Resources["ChatControllerDS"];

if(_controller == null)
{
throw new Exception("Pas de controller pour la page !!");
}
}

Lorsque l'utilisateur clique sur le bouton de connexion, le code suivant est exécuté :

Méthode associée au bouton de connexion

private void btnConnexion_Click(object sender, RoutedEventArgs e)
{
if (String.IsNullOrEmpty(txtPseudo.Text))
return;

//on se connecte au tchat en indiquant le pseudo de l'utilisateur
_controller.Connection(this.Dispatcher, txtPseudo.Text);
}

Nous vérifions tout d'abord qu'un pseudo a bien été saisi puis nous appelons la méthode Connection du controller. Nous lui passons en paramètre le Dispatcher associé à la vue ainsi que le pseudo de l'utilisateur.

Quand l'utilisateur souhaitera envoyer un message nous utiliserons la méthode SendMessage du controller :

Méthode associée au bouton d'envoi de message

private void btnSendMessage_Click(object sender, RoutedEventArgs e)
{
//on n'envoie pas de message vide
if (String.IsNullOrEmpty(txtMessage.Text))
return;

_controller.SendMessage(txtMessage.Text);

ClearText();
}

III-D - Le controller

Le controller possède une référence sur l'objet PushDataReceiver qui sert de proxy pour communiquer avec le service et une référence sur le Dispatcher de la vue associée. La propriété public MessageHistory est une collection de ChatMessage de type ObservableCollection. Elle contient l'ensemble des messages reçus du service. Il s'agit de la propriété dont se sert (via le mécanisme du Binding) la vue pour afficher la liste des messages.

//Le proxy pour communiquer avec le service de tchat
private PushDataReceiver _receiver;

// Référence au dispatcher de l'UI associée au controller
private Dispatcher _owner;

/// <summary>
/// Liste des messages (reçus et envoyés) à afficher à l'écran
/// </summary>
public ObservableCollection<ChatMessage> MessageHistory { get; private set; }

/// <summary>
/// Utilisateur courant
/// </summary>
public string CurrentUser{get; private set;}

Le controller possède deux méthodes public qui seront appelées par la vue : Connection et SendMessage.

/// <summary>
/// Méthode de connexion au tchat. À n'appeler qu'une seule fois.
/// </summary>
public void Connection(Dispatcher dispatcher, string userName)
{
if (dispatcher == null)
{
throw new ArgumentNullException("dispatcher");
}
if (string.IsNullOrEmpty(userName))
{
throw new ArgumentNullException("userName");
}
if (_receiver != null)
throw new Exception("PushDataReceiver a déjà été initialisé.");

CurrentUser = userName;
_owner = dispatcher;

AddMessage("Connection au chat...", "Service");

_receiver = new PushDataReceiver("http://localhost:19021/ChatService.svc");

_receiver.OpenChannelCompleted += receiver_OpenChannelCompleted;
_receiver.NewMessageReceived += receiver_NewMessageReceived;
_receiver.ChatError += receiver_ChatError;

_receiver.Start();
}

/// <summary>
/// Envoie un message de la part de l'utilisateur courant
/// </summary>
public void SendMessage(string message)
{
if (_receiver == null)
throw new Exception("PushDataReceiver n'a pas été initialisé. Appelez la méthode Connection en premier.");
_receiver.SendMessage(message);
}

La méthode Connection instancie un objet PushDataReceiver en lui passant en paramètre l'adresse du service puis s'abonne aux différents évènements que peut générer le proxy (ouverture du canal de communication, réception d'un message, erreur). La méthode Start qui permet la connexion au service est ensuite appelée. Si le canal de communication est correctement ouvert, le proxy nous le notifiera via l'évènement OpenChannelCompleted.

La méthode SendMessage prend en paramètre le texte du message à envoyer et appelle la méthode SendMessage de l'objet PushDataReceiver.

La méthode AddMessage permet la création et l'ajout d'un objet ChatMessage à la collection MessageHistory. Par Binding, la vue affichera le message ajouté.

/// <summary>
/// Ajoute un message à MessageHistory
/// </summary>
private void AddMessage(string message, string userName)
{
ChatMessage msg = new ChatMessage { Text = message, UserName = userName };
MessageHistory.Add(msg);
}

Voyons maintenant les méthodes associées à la réception des évènements en provenance de l'objet PushDataReceiver :

void receiver_ChatError(object sender, ChatErrorEventArgs e)
{
_owner.BeginInvoke(
() => AddMessage("Erreur sur le service : " + e.Error, "Service")
);
}

void receiver_NewMessageReceived(object sender, NewMessageReceivedEventArgs e)
{
_owner.BeginInvoke(
() => MessageHistory.Add(e.Message)
);
}

void receiver_OpenChannelCompleted(object sender, OpenChannelCompletedEventArgs e)
{
_owner.BeginInvoke(
() => AddMessage("Connexion au service de chat effectué", "Service")
);

_owner.BeginInvoke(
() => AddMessage("Lancement de la session utilisateur...", "Service")
);

//on envoie le message au service demandant à démarrer une session
_receiver.StartSession(CurrentUser);
}

Nous nous servons ici du Dispatcher que nous avons reçu de la vue afin d'effectuer les différentes opérations sur le Thread associé à la vue.

Quand le controller est notifié d'une erreur, il ajoute un objet chatMessage contenant le message d'erreur à la collection HistoryCollection.

À la notification de la réception d'un nouveau message, nous ajoutons un objet chatMessage contenant le message reçu à la collection HistoryCollection.

Lorsque l'évènement d'ouverture de canal est reçu, nous en informons l'utilisateur via l'ajout d'objets ChatMessage à la collection HistoryCollection. Nous appelons ensuite la méthode StartSession de l'objet PushDataReceiver en lui passant en paramètre le nom de l'utilisateur.

La collection HistoryCollection étant de type ObservableCollection, la vue sera automatiquement mise à jour pour refléter les ajouts d'éléments dans la collection.

III-E - La communication avec le service

Nous avons vu que le controller utilisait un objet de type PushDataReceiver pour envoyer et recevoir des messages. Nous allons maintenant nous intéresser à cette classe et voir son implémentation.

La classe définit trois évènements auxquels le controller s'abonne et qui permettent de le notifier de l'ouverture du canal de communication, de la réception d'un message ou d'une erreur quelconque. Un DataContractSerializer servant à désérialiser les messages en provenance du service ainsi qu'une référence sur le canal de communication sont définis comme membres privés. Enfin, le constructeur prend en paramètre l'adresse du service de chat.

public class PushDataReceiver
{
public event EventHandler<OpenChannelCompletedEventArgs> OpenChannelCompleted;
public event EventHandler<NewMessageReceivedEventArgs> NewMessageReceived;
public event EventHandler<ChatErrorEventArgs> ChatError;

//le DataContractSerializer utilisé pour (dé)sérialiser le corps des messages
private readonly DataContractSerializer _chatMessageSerializer = new DataContractSerializer(typeof(ChatMessage));

//le channel de communication
private IDuplexSessionChannel _channel;

//l'url du service
private readonly string _serviceUrl;

public PushDataReceiver(string url)
{
_serviceUrl = url;
}}

Une fois le PushDataReceiver instancié, le controller doit en premier lieu appeler la méthode Start afin d'initialiser la connexion au service.

public void Start()
{
// Instantiation du binding
PollingDuplexHttpBinding binding = new PollingDuplexHttpBinding();

// Instantiation du channel factory
IChannelFactory<IDuplexSessionChannel> factory =
binding.BuildChannelFactory<IDuplexSessionChannel>(new BindingParameterCollection());

IAsyncResult factoryOpenResult =
factory.BeginOpen(new AsyncCallback(OnOpenCompleteFactory), factory);
if (factoryOpenResult.CompletedSynchronously)
{
CompleteOpenFactory(factoryOpenResult);
}
}

void OnOpenCompleteFactory(IAsyncResult result)
{
if (result.CompletedSynchronously)
return;
CompleteOpenFactory(result);
}

Nous instancions en premier lieu un objet de type PollingDuplexHttpBinding qui est le Binding WCF pour communiquer avec le service en duplex. Il est possible de configurer les propriétés de ce Binding (valeurs de Timeout par exemple), mais nous garderons ici les valeurs par défaut.

Nous construisons ensuite un Channel Factory de manière asynchrone et indiquons la méthode CompleteOpenFactory comme méthode de callback :

void CompleteOpenFactory(IAsyncResult result)
{
IChannelFactory<IDuplexSessionChannel> factory =
(IChannelFactory<IDuplexSessionChannel>)result.AsyncState;

factory.EndOpen(result);

// Le channel factory est créé. Création et ouverture du canal de communication avec le service.
_channel = factory.CreateChannel(new EndpointAddress(_serviceUrl));

IAsyncResult channelOpenResult = _channel.BeginOpen(new AsyncCallback(OnOpenCompleteChannel), _channel);
if (channelOpenResult.CompletedSynchronously)
{
CompleteOpenChannel(channelOpenResult);
}
}

void OnOpenCompleteChannel(IAsyncResult result)
{
if (result.CompletedSynchronously)
return;
CompleteOpenChannel(result);
}

void CompleteOpenChannel(IAsyncResult result)
{
IDuplexSessionChannel channel = (IDuplexSessionChannel)result.AsyncState;

channel.EndOpen(result);

//on notifie le client que le channel est ouvert
OnOpenChannelCompleted(new OpenChannelCompletedEventArgs());

//On commence l'écoute
ReceiveLoop(_channel);
}

La méthode CompleteOpenFactory utilise le Channel Factory afin de créer un canal de communication avec le service (en indiquant l'adresse du service). Cette opération s'effectue elle aussi de manière asynchrone et la méthode CompleteOpenChannel est définie comme méthode de callback. Dans cette méthode nous notifions le controller de l'ouverture du canal via la levée d'un évènement. Puis nous appelons la méthode ReceiveLoop (voir plus bas) dont le rôle sera d'être à l'écoute des messages envoyés par le service.

Lorsque le controller recevra l'évènement OpenChannelCompleted celui-ci appellera ensuite la méthode StartSession (en précisant le pseudo de l'utilisateur) afin d'initialiser la session côté service. Une fois la session démarrée le controller utilisera la méthode SendMessage afin d'envoyer les messages de l'utilisateur au service. Ces deux méthodes publiques font appel à une méthode privée SendMessageToService qui prend en paramètre l'action (opération du contrat de service) et le corps du message.

/// <summary>
/// Démarre une session utilisateur
/// </summary>
/// <param name="username">Le nom de l'utilisateur</param>
public void StartSession(string username)
{
SendMessageToService("ChatSilverlight/IChatService/DemarrerSession", username);
}

/// <summary>
/// Envoie un message au service
/// </summary>
/// <param name="message">L'action du message</param>
public void SendMessage(string message)
{
SendMessageToService("ChatSilverlight/IChatService/EnvoyerMessage", message);
}

/// <summary>
/// Envoie un message au service
/// </summary>
/// <param name="action">L'action du message</param>
/// <param name="actionData">Le corps du message</param>
private void SendMessageToService(string action, string actionData)
{
Message message = Message.CreateMessage(_channel.GetProperty<MessageVersion>(), action, actionData);
IAsyncResult result = _channel.BeginSend(message, new AsyncCallback(OnSend), _channel);
if (result.CompletedSynchronously)
{
CompleteOnSend(result);
}
}

void OnSend(IAsyncResult result)
{
if (result.CompletedSynchronously)
return;
CompleteOnSend(result);
}

void CompleteOnSend(IAsyncResult result)
{
IDuplexSessionChannel channel = (IDuplexSessionChannel)result.AsyncState;
channel.EndSend(result);
}

La méthode SendMessageToService crée un objet de type Message en précisant la version SOAP à utiliser (ici SOAP 1.1), l'action et le corps du message (comme il s'agit d'un objet de type String nous n'avons pas besoin d'utiliser un DataContractSerializer). Puis le message est envoyé de façon asynchrone.

Enfin, voyons le contenu de la méthode ReceiveLoop qui permet l'écoute de messages provenant du service.

void ReceiveLoop(IDuplexSessionChannel channel)
{
// On commence l'écoute de callbacks provenant du service
IAsyncResult result = channel.BeginReceive(new AsyncCallback(OnReceiveComplete), channel);
if (result.CompletedSynchronously)
CompleteReceive(result);
}

void OnReceiveComplete(IAsyncResult result)
{
if (result.CompletedSynchronously)
return;
CompleteReceive(result);
}

void CompleteReceive(IAsyncResult result)
{
// Un callback a été reçu
IDuplexSessionChannel channel = (IDuplexSessionChannel)result.AsyncState;

try
{
Message receivedMessage = channel.EndReceive(result);

if (receivedMessage != null)
{
ChatMessage msg = receivedMessage.GetBody<ChatMessage>(_chatMessageSerializer);
OnNewMessageReceived(new NewMessageReceivedEventArgs(msg));

// on continue l'écoute
ReceiveLoop(_channel);
}
}
catch (CommunicationObjectFaultedException ex)
{
// The channel inactivity time-out was reached.
OnChatError(new ChatErrorEventArgs(ex.Message));
}
}

Dans la méthode ReceiveLoop nous démarrons une opération asynchrone de réception d'un message en provenance du service et spécifions la méthode CompleteReceive comme méthode de callback.

Nous récupérons le message reçu et si celui-ci n'est pas null nous utilisons la méthode générique GetBody afin d'extraire le corps du message. Le corps du message est un objet ChatMessage qui a été sérialisé par le service en utilisant un DataContractSerializer. Il faut donc le spécifier à la méthode GetBody afin qu'elle puisse faire la désérialisation correctement.

Une fois l'objet ChatMessage récupéré nous lançons l'évènement NewMessageReceived afin d'en avertir le controller. Puis nous recommençons l'écoute.

IV - Exemple en image

Voici un exemple de conversation entre deux utilisateurs.

Connexion des utilisateurs :

Discussion :

Conclusion

Nous voici arrivés au terme de cet article. Nous avons pu voir que grâce à la prise en charge du protocole Make-Connection, Silverlight 2 et WCF offraient une alternative relativement simple à l'utilisation des sockets pour des communications bidirectionnelles entre un client et un serveur.

N'hésitez pas à suivre les liens présentés ci-dessous afin d'approfondir le sujet.

Sources

Téléchargez les sources de la solution donnée en exemple.

Liens

Une série d'articles très intéressants de Peter McGrattan sur le sujet

Spécifications du protocole Web Services Make-Connection

How to: Build a Duplex Service (MSDN)

How to: Access a Duplex Service with the Channel Model (MSDN)

Remerciements

J'adresse ici tous mes remerciements à l'équipe de rédaction de « developpez.com » pour le temps qu'ils ont bien voulu passer à la correction et à l'amélioration de cet article.

Contact

Si vous constatez une erreur dans le tutoriel, dans les sources, dans la programmation ou pour toutes informations, n'hésitez pas à me contacter par le forum.