Comment exposer une application WCF RIA Services à d’autres clients : SOAP endpoint (3/5)

BookClubScreen021Nous allons ici voir comment ouvrir l’application vue dans les 2 articles précédents avec un point d’accès de type SOAP pour permettre des appels WCF “classiques”. Nous verrons alors comment consommer ce nouveau point d’accès depuis un client WPF pour effectuer des ajouts ou suppressions de données. Nous verrons ensuite comment faire la même chose depuis un client Windows Phone 7. La subtilité dans les 2 cas sera de passer l’authentification par formulaire puis de gérer le cookie d’authentification pour chaque appel vers une méthode nécessitant une authentification ou un rôle particulier. Vous trouverez la solution Visual Studio 2010 résultant du suivi de ces 3 articles (application SL Business App, client WPF et client Windows Phone 7) à télécharger à la fin de cet article. Vous pourrez également voir les 3 clients en action à travers une vidéo de démonstration.

Cet article est le troisième d’une série de 5 :

1 – Revue de l’application initiale
2 – Exposition du service sous la forme d’un flux OData consommé par Excel puis par une application écrite avec WebMatrix
3 – Exposition du service en WCF “classique” pour une consommation depuis WPF puis depuis Windows Phone 7 (cet article)
4 – Exposition du service en JSON pour une consommation par une application HTML5/jQuery
5 – Les étapes à suivre pour porter cette application vers Windows Azure et SQL Azure

Activation d’un endpoint SOAP sur un DomainService WCF RIA Services

Cela va se faire naturellement dans le web.config. Par contre, par défaut, une installation des outils Silverlight 4 pour Visual Studio 2010 ne contient pas les librairies nécessaires à l’activation de ce endpoint. Il faut télécharger pour cela le toolkit pour WCF RIA Services.

Une fois le toolkit installé, ajoutez une référence à la librairie Microsoft.ServiceModel.DomainServices.Hosting puis rendez-vous dans le web.config et sous le endpoint OData, ajoutez ce nouvel endpoint :

 <add name="Soap" type="Microsoft.ServiceModel.DomainServices.Hosting.SoapXmlEndpointFactory, 
                       Microsoft.ServiceModel.DomainServices.Hosting, Version=4.0.0.0, Culture=neutral, 
                       PublicKeyToken=31bf3856ad364e35" />

Une fois cette opération effectuée, vous pourrez alors rentrer cette URL :

https://nomdevotreserveur:port/ClientBin/NomDeLaSolution-Web-NomDuDomainService.svc

Pour générer un proxy en faisant un bon vieux “Add Service Reference…” sous Visual Studio. Vous pouvez par exemple ajouter une référence à mon service déployé dans Azure ici :

https://bookclub.cloudapp.net/ClientBin/BookShelf-Web-Services-BookClubService.svc

Gestion du endpoint SOAP depuis un client WPF

Nous allons voir ici comment appeler les méthodes distantes présentes dans notre DomainService via WCF depuis un client WPF très simple graphiquement (== moche), le but étant de se concentrer sur l’essentiel et pas sur l’esthétique. Malgré tout, pour ne pas trop vous piquer les yeux, je l’ai quand même agrémenté d’un thème sympa en utilisant la technique que j’avais décrite il y a longtemps ici : Comment rendre vos applications WPF plus attrayantes sans toucher à votre code? Via des thèmes gratuits!

1 – Pour simplifier également la compréhension, nous allons manipuler en lecture/écriture les catégories et non pas les livres. Or, pour l’instant dans le code fourni initialement, il n’y a qu’une méthode pour la lecture seule dans le DomainService pour récupérer les catégories. Il faut donc fournir la logique d’ajout, de modification et de suppression des catégories. Pour cela, ajoutez ce code dans la classe BookClubService :

 [RequiresRole("Admin", 
ErrorMessage = "You must be part of the Administrator role to insert, update or delete a category.")]
public void InsertCategory(Category category)
{
    if ((category.EntityState != EntityState.Detached))
    {
        this.ObjectContext.ObjectStateManager.ChangeObjectState(category, EntityState.Added);
    }
    else
    {
        this.ObjectContext.Categories.AddObject(category);
    }
}

[RequiresRole("Admin", 
ErrorMessage = "You must be part of the Administrator role to insert, update or delete a category.")]
public void UpdateCategory(Category category)
{
    this.ObjectContext.Categories.AttachAsModified(category, this.ChangeSet.GetOriginal(category));
}

[RequiresRole("Admin", 
ErrorMessage = "You must be part of the Administrator role to insert, update or delete a category.")]
public void DeleteCategory(Category category)
{
    if ((category.EntityState == EntityState.Detached))
    {
        this.ObjectContext.Categories.Attach(category);
    }
    this.ObjectContext.Categories.DeleteObject(category);
}

Ces 3 méthodes nous permettent respectivement d’ajouter, de mettre à jour et de supprimer une catégorie. Ces méthodes ne sont appelables que depuis des clients authentifiés et membres du groupe “Admin”. Nous laissons donc à la couche ASP.NET et RIA Services la charge de vérifier la nature des appels entrant.

2 – L’étape suivante consiste à ajouter une référence aux 2 services qui seront disponibles suite à l’ajout du endpoint SOAP. Le 1er est celui que je vous ai donné juste au-dessus et donne accès aux méthodes de manipulation des livres et des catégories. Le 2ème permet de gérer l’authentification et est mis en place par RIA Services via le code suivant :

 [EnableClientAccess]
public class AuthenticationService : AuthenticationBase<User> { }

Il est disponible sur cette URL dans Azure : https://bookclub.cloudapp.net/ClientBin/BookShelf-Web-AuthenticationService.svc

Nommez les 2 références à ces services respectivement BookClubService et BookClubAuthService.

3 – Une fois ces références faites, on va pouvoir commencer à interroger nos services depuis notre client WPF. Déclarez ces 3 membres privés et changer le constructeur par défaut de la fenêtre principale de votre application WPF par celui-ci :

 BookClubAuthService.AuthenticationServiceSoapClient authClient;
BookClubService.BookClubServiceSoapClient bookClubClient;
BookClubAuthService.User currentAuthUser;

public MainWindow()
{
    InitializeComponent();
    ThemeManager.ApplyTheme(this, "ShinyBlue");
    authClient = new BookClubAuthService.AuthenticationServiceSoapClient();
    bookClubClient = new BookClubService.BookClubServiceSoapClient();
}

Nous avons donc instancié les 2 proxies client. Si l’on souhaite récupérer l’ensemble des catégories disponibles et les afficher dans une ListBox, voici le code behind :

 var categories = bookClubClient.GetCategories();
lstCategories.ItemsSource = categories.RootResults;

Et voici le XAML associé :

 <ListBox Name="lstCategories">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <TextBlock Text="{Binding CategoryName}" />
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

A noter que j’appelle ici les méthodes synchrones générées par le proxy pour WPF mais que dans un client Windows Phone 7 ou Silverlight, je serais obligé de faire des appels asynchrones. Dans mon cas, cela donne ce genre de résultat :

BookClubScreen016

4 – Ok, pour l’instant, tout était parfaitement simple et on pouvait obtenir la même chose via le endpoint OData. Si l’on souhaite désormais aller plus loin et mettre à jour les catégories, il faut que nous soyons authentifiés et membres du groupe “Admin”. Pour l’authentification, rien de plus simple, il suffit d’utiliser le 2ème proxy vers le service d’authentification qui nous fournit les méthodes Login() et Logout() avec le code suivant :

 BookClubAuthService.QueryResultOfUser result = 
              authClient.Login(txtUsername.Text, txtPassword.Password, true, "");

if (result.RootResults != null && result.RootResults.Count() > 0)
{
    currentAuthUser = result.RootResults.First();
    loginStatus.Content = "Welcome " + currentAuthUser.DisplayName;
    imgUser.Source = new BitmapImage(new Uri(currentAuthUser.PictureURL));
}
else
{
    loginStatus.Content = "Error while logging " + txtUsername.Text;
}

On peut même récupérer l’image du profile de l’utilisateur que ce dernier aura créé avec l’utilisation de la Webcam via l’application Silverlight principale. Cela donne ce genre de résultat :

BookClubScreen017

A ce stade, on pourrait penser que c’est donc gagné :

- Nous nous sommes correctement connectés et authentifiés via le service BookClubAuthService
- Nous pouvons donc faire des appels vers le service BookClubService suite à ce 1er appel ?

Et bah non. En effet, lorsque l’on s’authentifie via un mécanisme de FBA (Forms Based Authentication), un cookie est ensuite transféré entre chaque appel pour “prouver” que l’appelant est bien celui qui vient de s’authentifier. Ces échanges sont masqués lorsque vous faites une application de type “Silverlight Business Application” et vous n’avez pas à vous en soucier.

Mais dans le cas présent, il va falloir trouver comment partager ce cookie entre le service d’authentification et les appels que vous ferez vers le service de manipulation des catégories et des livres.

Voici une 1ère façon de faire :

 string sharedCookie;

BookClubAuthService.AuthenticationServiceSoapClient authClient = 
                new BookClubAuthService.AuthenticationServiceSoapClient();

using (new OperationContextScope(authClient.InnerChannel))
{
    authClient.Login("theuser", @"thepassword", true, "");

    // Extract the cookie embedded in the received web service response
    // and stores it locally
    HttpResponseMessageProperty response = (HttpResponseMessageProperty)
    OperationContext.Current.IncomingMessageProperties[
        HttpResponseMessageProperty.Name];
    sharedCookie = response.Headers["Set-Cookie"];
}

BookClubServiceSoapClient client = new BookClubServiceSoapClient();
QueryResultOfCategory result;

using (new OperationContextScope(client.InnerChannel))
{
    // Embeds the extracted cookie in the next web service request
    // Note that we manually have to create the request object since
    // since it doesn't exist yet at this stage 
    HttpRequestMessageProperty request = new HttpRequestMessageProperty();
    request.Headers["Cookie"] = sharedCookie;
    OperationContext.Current.OutgoingMessageProperties[
        HttpRequestMessageProperty.Name] = request;

    result = client.GetCategories();
}

Ce code nous permet par exemple de faire appel à la méthode GetCategories() même si cette dernière requiert une authentification préalable. Cependant, cette technique impose d’utiliser cette lourdeur d’écriture à chaque appel de méthodes pour transmettre le cookie initial !

Pour éviter cela, il faudrait se débrouiller pour que le cookie soit extrait et réinjecté entre les requêtes HTTP entrantes et sortantes quelque soit le service distant appelé. Pour cela, on peut utiliser le mécanisme de “messages inspector” de WCF. Ce dernier se trouve entre la sortie du tuyau et le code de notre client. Il s’occupe donc de regarder le flux avant de l’envoyer vers le réseau puis s’occupe de regarder la réponse avant de la transmettre au client. L’idée est donc d’obtenir quelque chose ressemblant à ce diagramme :

InspectorVisio

Je vous conseille ainsi la lecture de l’excellent article d’Enrico Campidoglio : Managing shared cookies in WCF. J’ai tout simplement implémenté sa solution. Voici les étapes à suivre pour l’utiliser.

Il faut d’abord copier les 3 fichiers de sa solution : CookieManagerBehaviorExtension.cs, CookieManagerEndpointBehavior.cs et CookieManagerMessageInspector.cs dans le projet de votre client WPF et changer éventuellement les namespaces avec celui de votre projet WPF. Ils contiennent le code qui va aller inspecter tous les messages en entrée/sortie pour snifer la présence d’un cookie et le réinjecter en sortie si besoin.

Ensuite, rendez-vous dans le fichier app.config pour indiquer que vous souhaitez utiliser cela et sous :

 <system.serviceModel>

Ajoutez ces déclarations :

 <behaviors>
  <endpointBehaviors>
    <behavior name="EnableCookieManager">
      <cookieManager />
    </behavior>
  </endpointBehaviors>
</behaviors>
<extensions>
  <behaviorExtensions>
    <add name="cookieManager" type="WpfBookShelf.CookieManagerBehaviorExtension, WpfBookShelf, 
               Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
  </behaviorExtensions>
</extensions>

Pour terminer, demandez aux 2 endpoints d’utiliser cette configuration de comportement en leur ajoutant :

 behaviorConfiguration="EnableCookieManager"

Ce qui au final dans mon cas donne les 2 déclarations suivante :

 <endpoint address="https://bookclub.cloudapp.net/Services/BookShelf-Web-AuthenticationService.svc/Soap"
    behaviorConfiguration="EnableCookieManager"
    binding="basicHttpBinding" bindingConfiguration="BasicHttpBinding_AuthenticationServiceSoap"
    contract="BookClubAuthService.AuthenticationServiceSoap" name="BasicHttpBinding_AuthenticationServiceSoap" />
<endpoint address="https://bookclub.cloudapp.net/Services/BookShelf-Web-Services-BookClubService.svc/Soap"
    behaviorConfiguration="EnableCookieManager"
    binding="basicHttpBinding" bindingConfiguration="BasicHttpBinding_BookClubServiceSoap"
    contract="BookClubService.BookClubServiceSoap" name="BasicHttpBinding_BookClubServiceSoap" />

Une fois ces opérations effectuée, l’écriture du code est nettement plus agréable ! Voici par exemple comment enchainer une authentification, un ajout, une suppression d’une catégorie puis une déconnection :

 //Using the first proxy to log in
BookClubAuthService.AuthenticationServiceSoapClient authClient = new BookClubAuthService.AuthenticationServiceSoapClient();
authClient.Login("adminuser", "password", true, "");

//Using the second proxy to manipulate the data
//exposed by RIA Services. The cookie retrieved by the first call
//to the Login() method will be reinjected for you by the WCF Message Inspector
BookClubServiceSoapClient client = new BookClubServiceSoapClient();
QueryResultOfCategory result;

ChangeSetEntry[] returnChangeSet;
ChangeSetEntry[] newItems = new ChangeSetEntry[1];
newItems[0] = new ChangeSetEntry();

//Adding a new category
Category newCategory = new Category();
newCategory.CategoryName = newCategoryName;
newItems[0].OriginalEntity = null;
newItems[0].Entity = newCategory;
newItems[0].Operation = DomainOperation.Insert;
returnChangeSet = client.SubmitChanges(newItems);

//Reloading the Category Set
result = client.GetCategories();

//Retrieving the just added category
var cToDelete = (from c in result.RootResults
            where c.CategoryName.Equals(newCategoryName)
            select c).FirstOrDefault();

//Deleting the just added category
newItems[0].OriginalEntity = cToDelete;
newItems[0].Entity = cToDelete;
newItems[0].Operation = DomainOperation.Delete;
returnChangeSet = client.SubmitChanges(newItems);

authClient.Logout();

Sympa non ? Dans le petit client WPF que vous trouverez dans la solution à télécharger, cela donne ce genre de résultat. Si j’essaie de mettre à jour sans être logué ou si je suis logué avec un compte non admin :

BookClubScreen018

La couche RIA Services a donc bien joué son rôle en vérifiant que l’utilisateur était bien membre du rôle “Admin” avant de tenter de faire un ajout. Une fois logué avec le bon compte, je peux ajouter une nouvelle catégorie depuis mon client WPF :

BookClubScreen019

Et elle est bien évidemment immédiatement disponible dans l’application principale Silverlight :

BookClubScreen020

Voilà, vous avez tout en main pour faire un client WPF complet pour vos couches WCF RIA Services. Regardons maintenant du côté de Windows Phone 7 ce qu’il faut faire.

Gestion du endpoint SOAP depuis un client Windows Phone 7

La connexion aux services WCF exposés par RIA services va être très similaire à ce que nous venons de voir en WPF. Il y a cependant 2 petites nuances avec Silverlight en Windows Phone 7 :

1 - la couche WCF cliente de Windows Phone est moins riche que celle du framework .NET “complet” sous Windows et ne supporte par les “messages inspectors”. Il va donc falloir trouver un autre moyen de faire voyager le cookie d’authentification
2 - tous les appels WCF sont forcément asynchrones en Silverlight et donc sous Windows Phone 7

Pour le 1er point, cela se gère heureusement très facilement sous Windows Phone 7 grâce à l’utilisation de ce que l’on appelle le CookieContainer. Pour pouvoir l’utiliser, il faut ajouter cela aux 2 bindings dans le fichier de configuration XML de WCF :

 enableHttpCookieContainer="true"

Cela vous donne alors ce genre de résultat :

 <binding name="BasicHttpBinding_AuthenticationServiceSoap" maxBufferSize="2147483647"
    enableHttpCookieContainer="true"
          maxReceivedMessageSize="2147483647">
    <security mode="None" />
</binding>
<binding name="BasicHttpBinding_BookClubServiceSoap" maxBufferSize="2147483647"
    enableHttpCookieContainer="true"
          maxReceivedMessageSize="2147483647">
    <security mode="None" />
</binding>

Une fois cette opération effectuée, ce genre de code devrait fonctionner :

 CookieContainer cookieContainer = null;

private void btnLogin_Click(object sender, RoutedEventArgs e)
{
    authClient = new BookClubAuthService.AuthenticationServiceSoapClient();

    authClient.LoginCompleted += 
               new EventHandler<BookClubAuthService.LoginCompletedEventArgs>(authClient_LoginCompleted);
    status.Text = "Logging " + username + "...";
    authClient.LoginAsync(username, password, true, "");
}

void authClient_LoginCompleted(object sender, BookClubAuthService.LoginCompletedEventArgs e)
{
    if (!e.Cancelled)
    {
        if (e.Error == null)
        {
            if (e.Result.RootResults != null && e.Result.RootResults.Count() > 0)
            {
                BookClubAuthService.User user = e.Result.RootResults.First();
                status.Text = user.DisplayName + " logged.";
                cookieContainer = authClient.CookieContainer;

                client = new BookClubService.BookClubServiceSoapClient { CookieContainer = cookieContainer };
                client.GetCategoriesCompleted += 
                          new EventHandler<BookClubService.GetCategoriesCompletedEventArgs>(client_GetCategoriesCompleted);
                client.GetCategoriesAsync();
            }
            else
            {
                status.Text = "Failed logging " + username + ".";
            }
        }
    }
}

void client_GetCategoriesCompleted(object sender, BookClubService.GetCategoriesCompletedEventArgs e)
{
    if (e.Result != null)
    {
        ObservableCollection<Category> list = e.Result.RootResults;
        listBox1.ItemsSource = list;
    }
}

Ce code met en place la séquence suivante :

1 – Lorsque l’utilisateur clique sur le bouton de login, on instancie le proxy client WCF vers le service d’authentification de RIA Services (présent par défaut dans toutes les solutions Silverlight Business Application), on s’abonne à l’évènement LoginCompleted qui nous indiquera si l’authentification a réussie ou non puis on lance la méthode de login de manière asynchrone via LoginAsync.

2 – On est alors rappelé dans la méthode authClient_LoginCompleted où l’on vérifie s’il n’y a pas eu de problèmes et on extrait l’objet CookieContainer contenant le cookie d’authentification généré et retourné par la couche ASP.NET de WCF RIA Services. On passe alors cet objet au 2ème proxy client WCF qui pourra donc faire des appels authentifiés vers les méthodes de manipulation de livres et de catégories.

Le code d’ajout, mise à jour ou suppression est identique à celui que nous avons vu avec WPF sauf que les appels se feront en asynchrone. Voici par exemple mon code permettant de supprimer une catégorie puis vérifier ce qu’il s’est passé en retour :

 public void DeleteCategory(string categoryNameToDelete)
{
    try
    {
        ObservableCollection<ChangeSetEntry> deletedItems = new ObservableCollection<ChangeSetEntry>();
        ChangeSetEntry newChangeSetEntry = new ChangeSetEntry();

        Category categoryToDelete = (from c in _categories
                                        where c.CategoryName.Equals(categoryNameToDelete)
                                        select c).FirstOrDefault();

        if (categoryToDelete != null)
        {
            newChangeSetEntry.OriginalEntity = categoryToDelete;
            newChangeSetEntry.Entity = categoryToDelete;
            newChangeSetEntry.Operation = DomainOperation.Delete;

            deletedItems.Add(newChangeSetEntry);
            bookClubClient.SubmitChangesAsync(deletedItems);
        }
        else
        {
            MessageBox.Show("Category not found.");
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}
 void bookClubClient_SubmitChangesCompleted(object sender, SubmitChangesCompletedEventArgs e)
{
    if (e.Error == null)
    {
        ObservableCollection<ChangeSetEntry> returnChangeSet = e.Result;
        var operation = returnChangeSet.FirstOrDefault().Operation;
        if (operation.Equals(DomainOperation.Insert))
            MessageBox.Show("Category added successfully.");
        if (operation.Equals(DomainOperation.Delete))
            MessageBox.Show("Category deleted successfully.");

        LoadCategoriesData();
    }
    else
    {
        MessageBox.Show(e.Error.Message);
    }
}

Du coup, avec tout cela, vous pouvez rapidement arriver à ce genre de résultat (fourni dans le code source à télécharger à la fin de ce billet) avec une application de type “Windows Phone Pivot Application”.

Affichage des catégories et des livres :

BookClubScreen022BookClubScreen023

Tentative d’insertion ou suppression sans être logué :

BookClubScreen024

Ecran de login avec récupération de la photo de l’utilisateur généré à partir de la Webcam de l’application Silverlight 4. Cela nous donne alors la possibilité d’ajouter/supprimer des enregistrements :

BookClubScreen025BookClubScreen026

Voilà, vous retrouverez tout cela en démonstration vidéo ci-dessous où je vous montre comment créer un nouvel utilisateur avec l’application Silverlight 4, comment le promouvoir “Administrateur”, comment réutiliser son identité dans les applications WPF et Windows Phone 7 pour manipuler les données exposées par WCF RIA Services et ses endpoints SOAP :

Note : cette vidéo h264 s’affiche en HTML5 sous IE9 & Chrome et avec le player Silverlight sous les autres comme IE8 par exemple. 

Voici le code source de la solution Visual Studio 2010 contenant l’ensemble des projets démontrés jusqu’à présent :

Conclusion

Nous avons vu à travers ces 3 premiers articles la mise en place d’une solution qui m’est souvent demandée. Silverlight 4 couplé au framework WCF RIA Services reposant sur ASP.NET proposera ainsi la productivité la plus forte pour les développeurs se chargeant d’écrire l’application Web RIA chargée d’exposer les données métiers.

L’ajout d’un endpoint OData permettra une exploitation des données en lecture seule pour des populations non développeurs à travers l’add-in Pivot d’Excel ou pour des applications relativement simple comme celle que nous avons vu avec WebMatrix.

Pour finir, l’endpoint SOAP permettra à des clients de type WPF, Windows Phone 7, Java ou autre de modifier les données à travers le référencement des services de RIA Services en WCF “classique”. L’expérience et la productivité du développeur sera inférieure à celui pouvant exploiter le couple SL4/RIA Services. Il ne pourra pas par exemple exprimer des requêtes côté client en LINQ pouvant ensuite être automatiquement envoyée à la couche serveur pour exploitation. Mais cela lui permettra malgré tout de mettre en place des scénarios avancés de manipulation de données. Rien n’empêche en effet d’enrichir ensuite le DomainService de RIA Services de méthodes supplémentaires pour palier à ces soucis de requêtages/filtrages des clients non Silverlight. Par exemple, si l’on souhaite récupérer les livres n’appartenant qu’à une catégorie donnée comme le fait le client Silverlight démontré dans le 1er article, voici le genre de méthode qu’il faudrait ajouter au DomainService pour les clients WPF/WP7 :

 public IQueryable<Book> GetBooksByCategoryId(int categoryId)
{
    return this.ObjectContext.Books
        .Where(b => b.CategoryID.Equals(categoryId))
        .OrderBy(b => b.Title);
}

La génération du proxy client WCF proposera alors une nouvelle méthode GetBooksByCategoryId() nous permettant de mettre en place une partie de la logique automatiquement accessible dans la partie riche Silverlight 4/RIA Services.

Avec tout cela, vous devriez être armé pour implémenter de nombreux scénarios où WCF RIA Services sera un ajout non négligeable à votre bien être. Clignement d'œil

Allez, rendez-vous au 4ème article où nous allons voir comment consommer un endpoint JSON depuis une application HTML5 grâce à jQuery.

David