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.
[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.
[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 :
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
<
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.
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:
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.
<
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 :
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.
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 :
<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 :
<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 :
<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.
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é :
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 :
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.