Migration d'ASP vers ASP.NET : influence des facteurs humains  par Olivier Azeau

Introduction

La version 7.0 de Microsoft IIS (Internet Information Services) sera un tournant dans l'évolution du produit. Si l'on en croit l'interview de Scott Guthrie sur channel 9, contrairement aux versions précédentes, le logiciel a été entièrement revu. L'équipe IIS semble être un véritable moteur pour la crédibilisation de la plateforme .NET en tant que réceptacle de logiciels majeurs dans le monde Windows. La conséquence directe est que IIS 7.0, avec ses 95% de managed code sera une des premières applications .NET à venir jouer dans la cour des logiciels traditionnellement écrits en C++ "classique".

Dans les versions antérieures, c'est à dire celles que l'on utilise aujourd'hui, ASP.NET n'est qu'une extension parmi d'autres. Avec la version 7, cela deviendra le mode natif des applicatifs web tournant sur IIS. Même si ASP sera toujours supporté (pour combien de temps ?), ceux qui voudront profiter des évolutions vont devoir s'adapter.

Les ressources ne manquent pas pour effectuer une telle migration depuis les articles de MSDN jusqu'à la toute récente (et excellente) synthèse de Mark Sorokin sur Dr.Dobb's en passant par des tutoriels divers et variés. Les aspects techniques de la migration y sont amplement étudiés mais, un grand nombre de développeurs n'étant pas des experts .NET, l'aspect humain joue un rôle important dans le changement technologique. Une migration n'est pas complète tant que l'équipe chargée de maintenir et faire évoluer l'application migrée n'est pas autant à l'aise avec la nouvelle application que ce qu'elle l'était avec l'ancienne.

C'est le sujet de cet article.

Big Bang

Prenons le cas d'une application ASP réalisée par une équipe dédiée qui, de manière régulière, rajoute des fonctionnalités et assure la maintenance corrective. L'équipe connaît bien le domaine de l'application et les technologies utilisées mais est novice en .NET.

En observant le problème avec un grand angle, cette équipe, peut envisager trois façons de migrer vers ASP.NET.

Première approche : on oublie le code de l'application existante, on ne conserve que les descriptions des fonctionnalités et on réécrit tout. Une telle approche, assez radicale, peut fonctionner si les fonctionnalités sont extrêmement bien décrites, c'est à dire si les personnes en charge de leur définition sont très disponibles et si on a les moyens de tester, plus ou moins automatiquement, que la nouvelle application va se comporter, d'un point de vue des besoins de l'utilisateur, exactement comme l'application existante. Il est en effet difficilement acceptable pour un utilisateur de migrer sur une version plus récente, qui se veut donc supérieure, en perdant des fonctionnalités !

Le problème avec l'approche "on repart de zéro" est que l'on n'a aucune latitude de migration phasée des utilisateurs : quand on leur met le nouveau jouet entre les mains, il doit faire au moins aussi bien que l'ancien.

"Réécrire" un logiciel ou un bout de celui-ci n'est jamais simple. Comme le dit si joliment Ward Cunningham, "le code apprend". Bien souvent, quand un code a beaucoup vécu, il est truffé de lignes qui passent au premier abord pour autant de "verrues" qu'un bon nettoyage permettrait de supprimer. En réalité, ce sont bien souvent des prises en compte de comportement bien spécifiques, introduits intentionnellement au cours de la vie du produit et qu'une réécriture a toutes les chances d'oublier.Supprimer du code qui fonctionne pour le remplacer par un nouveau n'est pas toujours une bonne idée et prend souvent plus de temps que prévu. Joel Spolsky a bien décrit ce phénomène : Things You Should Never Do.

En supposant que le problème de la réécriture à fonctionnalité constante soit résolu (en utilisant, par exemple, un outil tel que IeUnit -dont nous reparlerons par la suite- et un bon maillage de tests), l'approche a certains aspects séduisants : on peut directement mettre en oeuvre une architecture tirant parti de .NET et ne plus trop se soucier de l'architecture existante. Encore faut-il avoir sous la main les personnes capables de réaliser cela. L'aspect humain est loin d'être négligeable. Un ou deux experts peuvent être introduits pour définir une architecture adéquate et fixer les orientations techniques des nouveaux développements mais le plus gros du travail restera vraisemblablement à faire par des personnes qui ne connaissent que très peu le nouveau contexte technique.

A tout cela s'ajoute bien évidemment la cohabitation des deux applications qui, en fonction des évolutions de l'existant, obligera à effectuer certains travaux en double (tous les rajouts de fonctionnalité) pendant la durée de la migration.

A Kind Of Magic

Pour toutes ces raisons, les risques inhérents à ce type d'approche sont très grands et il ne faut surtout pas se tromper d'objectif. Une application peut être difficile à maintenir et son architecture peut avoir besoin d'un toilettage. Une application peut avoir des problèmes de performance et nécessiter quelques optimisations bien pensées. Ce sont là des objectifs louables et que l'on peut avoir légitimement en tête lors d'une réécriture mais ils n'ont, pour ainsi dire, pas grand-chose à voir avec le sujet. Tentons maintenant de rester focalisés sur la migration vers ASP.NET en elle-même.

Deuxième approche : on conserve le code existant et on l'exécute en .NET. C'est probablement l'approche la plus vendeuse car elle vient avec des arguments de facilité et les outils qui vont avec : un coup de baguette magique et un asp devient aspx. Il y a forcément du vrai là-dedans : .NET a été pensé multi-langages. Dans les faits, VB.NET et JScript.NET sont, syntaxiquement, relativement proches de VBScript et JScript.

Tentons l'expérience sur une petite application de répertoire téléphonique écrite sous la forme d'une page ASP/JScript :

<%@ LANGUAGE = JScript %>
<HTML>
  <HEAD>
    <title>Phone Book</title>
  </HEAD>
  <body>
    <P>Phone Book</P>
    <% // ===== Tools
     
function DbConnection() {
        var cnt = Server.CreateObject("ADODB.Connection");
        
cnt.Open("UID=root;DATABASE=phonebook;Driver={MySQL ODBC 3.51 Driver};PWD=;OPTION=0;Server=localhost");

        this.Execute = function(cmd) { return cnt.Execute(cmd); }
      }
      // ===== Actions processing
      var mainmenu = ( Request.QueryString.Count == 0 ) || ( Request.Form.Count > 0 );
      // ========== Actions : new entry
      if( Request.Form("NewEntrySubmit").Count ) {
        var newName = Request.Form("NewName");
        var newPhone = Request.Form("NewPhone");
        var db = new DbConnection();
        db.Execute("INSERT INTO person(name, phone) VALUES ('"+newName+"', '"+newPhone+"')");
    %>
    <div id="NewEntryResult">Saved new entry name=<%=newName%> and phone=<%=newPhone%></div>
    <%  // ========== Actions : clear all
        } else if( Request.Form("ClearAllSubmit").Count ) {
          var db = new DbConnection();
          db.Execute("DELETE FROM person");
    %>
    <div id="ClearAllResult">Removed all entries from database</div>
    <%  // ========== Actions : search
        } else if( Request.Form("NameSearch").Count ) {
          var searchedName = Request.Form("NameSearch");
          var db = new DbConnection();
          var persons = db.Execute("SELECT name,phone FROM person WHERE name LIKE '%"+searchedName+"%'");
          if(persons.eof) {
    %>
    No result found.
    <%
          } else {
    %>
    <table ID="SearchResult">
      <tr><th>Name</th><th>Phone</th></tr>
    <%      while (!persons.eof) { %>
      <tr><td><%=persons("name")%></td><td><%=persons("phone")%></td></tr>
    <%        persons.MoveNext();
            }
    %>
    </table>
    <%
          }
        }     
        //===== Menu and forms display
        var action = Request.QueryString("action");
        if( mainmenu ) {
    %>
    <UL>
      <LI><a href="phonebook.asp?action=search">Search</a></LI>
      <LI><a href="phonebook.asp?action=newentry">New entry</a></LI>
      <LI><a href="phonebook.asp?action=clearall">Clear database entries</a></LI>
    </UL>
    <%
        } else if( action == "search" ) {
    %>
    <form id="Search" method="post">
      <P>Name : <INPUT id="NameSearch" type="text" size="32" name="NameSearch"></P>
      <P><INPUT id="SearchSubmit" type="submit" value="Submit" name="SearchSubmit"></P>
    </form>
    <%
        } else if( action == "newentry" ) {
    %>
    <form id="NewEntry" method="post">
      <P>Name  : <INPUT id="NewName" type="text" size="32" name="NewName"></P>
      <P>Phone : <INPUT id="NewPhone" type="text" size="32" name="NewPhone"></P>
      <P><INPUT id="NewEntrySubmit" type="submit" value="Submit" name="NewEntrySubmit"></P>
    </form>
    <%
        } else if( action == "clearall" ) {
    %>
    <form id="ClearAll" method="post">
      <P><INPUT id="ClearAllSubmit" type="submit" value="Clear All Database Entries" name="ClearAllSubmit"></P>
    </form>
    <%
        }
    %>
  </body>
</HTML>
v0/phonebook.asp


L'application a trois fonctions : saisie d'une fiche, effacement complet des données et recherche en texte libre. L'affichage est en HTML très dépouillé et la connexion à la base de données en ADO/ODBC. Rien d'extraordinaire.

Pour vérifier son bon comportement, on utilise un script IeUnit qui teste les fonctions de base :

 this.testCaseEnterTwoNewEntriesAndSearchForThem = function() {
   _.clickLink("Clear database entries");
   
_.clickObjById("ClearAllSubmit");

   _.assertTextContains(_.findObjById("ClearAllResult"), "Removed all entries from database");


   _.clickLink("New entry");

   _.setField("NewName", "john doe");
   _.setField("NewPhone", "0156565656");
   _.clickObjById("NewEntrySubmit");
   _.assertTextContains(_.findObjById("NewEntryResult"), "Saved new entry name=john doe and phone=0156565656");
     
   _.clickLink("New entry");
   _.setField("NewName", "jane doe");
   _.setField("NewPhone", "0157575757");
   _.clickObjById("NewEntrySubmit");
   _.assertTextContains(_.findObjById("NewEntryResult"), "Saved new entry name=jane doe and phone=0157575757");
     
   _.clickLink("Search");
   _.setField("NameSearch", "john");
   _.clickObjById("SearchSubmit");
   _.assertTextContains(_.findTableCell("SearchResult",1,0), "john doe");
   _.assertTextContains(_.findTableCell("SearchResult",1,1), "0156565656");
   _.assertNull(_.findTableCell("SearchResult",2,0));
     
   _.clickLink("Search");
   _.setField("NameSearch", "doe");
   _.clickObjById("SearchSubmit");
   _.assertTextContains(_.findTableCell("SearchResult",1,0), "john doe");
   _.assertTextContains(_.findTableCell("SearchResult",1,1), "0156565656");
   _.assertTextContains(_.findTableCell("SearchResult",2,0), "jane doe");
   _.assertTextContains(_.findTableCell("SearchResult",2,1), "0157575757");
};
v0/tests/v0.jst (extrait)

Le test n'est pas exhaustif mais il permet de déterminer les éventuelles erreurs grossières.

L'outil de migration de ASP vers ASP.NET fourni par Microsoft est un bon point de départ pour cette approche : il permet de réaliser automatiquement une partie de la migration. En l'occurrence, sur l'exemple qui nous intéresse, il va (seulement) renommer les références aux URLs '.asp' en '.aspx'.

On lance le script de test sur l'aspx ainsi créé :

Compilation Error

Description: An error occurred during the compilation of a resource required to service this request. Please review the following specific error details and modify your source code appropriately.

Compiler Error Message: JS1151: Objects of type 'System.String' do not have such a member

Source Error:
Line 25: // ========== Actions : new entry
Line 26: if( Request.Form("NewEntrySubmit").Count ) {
Line 27: var newName = Request.Form("NewName");
Line 28: var newPhone = Request.Form("NewPhone");

La bonne nouvelle, c'est que l'on a effectivement basculé dans le monde .NET. La mauvaise c'est que ça ne fonctionne pas et qu'il faut désormais comprendre ce que peut bien être ce 'System.String'...

La réponse se trouve dans les nombreux tutoriels de migration vers ASP.NET et notamment dans celui de gotdotnet.fr : les objets Request, Request.QueryString et Request.Form ont sensiblement évolué et Request.Form("NewEntrySubmit") n'est plus une collection mais une chaîne de caractères. On va se contenter de tester son existence :

      // ========== Actions : new entry
      if( Request.Form("NewEntrySubmit") ) {
        var newName = Request.Form("NewName");

On relance le script de test :

The component 'ADODB.Connection' cannot be created. Apartment threaded components can only be created on pages with an <%@ Page aspcompat=true %> page directive.

Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.

Exception Details: System.Web.HttpException: The component 'ADODB.Connection' cannot be created. Apartment threaded components can only be created on pages with an <%@ Page aspcompat=true %> page directive.

Source Error:
Line 11: function DbConnection() {
Line 12: var cnt = Server.CreateObject("ADODB.Connection");
Line 13: cnt.Open("UID=root;DATABASE=addressbook;Driver={MySQL ODBC 3.51 Driver};PWD=;OPTION=0;Server=localhost");

La bonne nouvelle, c'est que, désormais, notre JScript.NET compile. La mauvaise, c'est que ça ne fonctionne toujours pas et qu'il faut sortir d'un cadre .NET de base pour rentrer dans un mode de "compatibilité ASP". La raison est que l'objet COM ADODB.Connection ne peut être exécuté qu'à travers un thread unique (comme nombre d'objets COM et en particulier ceux écrits en VB6) alors que le mode par défaut de ASP.NET est multi-thread. On pourra par la suite remplacer avantageusement cet objet par du ADO.NET mais il ne s'agit pour l'instant que d'arriver à exécuter le code dans son architecture initiale.

On rajoute l'option de compatibilité et on relance le test :

Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record.

Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.

Exception Details: System.Runtime.InteropServices.COMException: Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record.

Source Error:
Line 57: <% while (!persons.eof) { %>
Line 58: <tr><td><%=persons("name")%></td><td><%=persons("phone")%></td></tr>
Line 59: <% persons.MoveNext();
Line 60: }
Line 61: %>

Cette erreur-là est beaucoup plus subtile et va nécessiter un passage au debugger pour comprendre la séquence d'exécution. Il apparaît que la boucle sur les éléments du RecordSet ne s'arrête pas là où elle le devrait : persons.eof renvoie toujours false. Et pour cause : eof n'est pas un membre de l'objet COM. La casse correcte est EOF.

On remplace eof par EOF, on relance le test et on n'a plus aucune erreur d'exécution. Toutefois, le test ne passe toujours pas : assertTextContains failed: element's innerText doesn't contain 'john doe'

La chaîne affichée ADODB.InternalFields n'est pas (du tout) le john doe attendu. Là encore, un passage au debugger est nécessaire pour comprendre que JScript.NET n'appelle pas le membre par défaut de l'objet COM utilisé mais se contente de l'afficher sous sa forme chaîne de caractère. Ici, on va remplacer :

<tr><td><%=persons("name")%></td><td><%=persons("phone")%></td></tr>

par :

<tr><td><%=persons.Fields("name").Value%></td><td><%=persons.Fields("phone").Value%></td></tr>

On relance le test et là, miracle, tout est OK !

Par cette approche, on arrive assez rapidement à une application ASP.NET entièrement fonctionnelle. Dans son état présent, elle n'exploite aucune des technologies introduites par .NET (puisque le code n'a structurellement pas évolué) mais cela pourra être adressé progressivement par des actions de refactorisation.

Les vraies questions sont ailleurs. Nombre de développeurs entretiennent une relation intime avec leur code et leur environnement d'exécution. Dans l'approche adoptée, le langage est, en surface, resté identique mais, concrètement, le code a été modifié de manière systématique pour tourner dans un environnement qui, lui, a entièrement changé. Un peu à la manière du conducteur dont la nouvelle voiture a, comme l'ancienne, un volant et trois pédales mais pas tout à fait les mêmes réactions, il faudra un peu de temps pour rétablir la confiance entre le développeur et son code. En pratique, cela signifiera que, pendant un temps, les corrections de bug à venir ne seront plus aussi rapides et que les cotations des nouvelles fonctionnalités ne seront plus aussi fiables.

De plus, d'un point de vue connaissance de la plateforme .NET, les modifications opérées globalement sur l'ensemble du code sont loin d'aider les développeurs. Malgré le temps passé à la migration, la nécessité de démarrer de zéro l'apprentissage de la technologie rallongera d'autant la durée totale de l'opération.

Diviser pour régner

Les principaux reproches que l'on peut faire aux approches considérées jusqu'ici sont les potentiels changements brutaux de comportement de l'application (par réécriture ou par changement de plateforme) et l'insuffisante prise en compte des besoins des développeurs pour rentrer en confiance dans la technologie.

Troisième approche : on conserve le code existant exécuté en ASP et on le passe en .NET morceau par morceau.

Pour cela, encore faut-il avoir une application composée de morceaux identifiables séparés par des frontières étanches. L'application existante est peut être composée d'éléments COM qui sont autant de morceaux que .NET va permettre de migrer indépendamment. Si ce n'est pas le cas, il va falloir, dans un premier temps, créer de tels morceaux.

En reprenant le répertoire téléphonique, on peut découpler la couche de gestion des données sous la forme d'un composant séparé et utiliser ce composant dans la page ASP où ne restera que la présentation. Pour ce faire, on va déplacer le code correspondant dans un WSC (Windows Script Component) qui permet d'implémenter un objet COM en conservant le langage JScript :

<?xml version="1.0"?>
<component>
<registration
   description="Phone Book Manager"
   progid="phonebook.manager"
   version="1.0"
   classid="{5AD74375-9BEA-452e-B085-90FA278D160F}">
</registration>
 
<public>
  <method name="ClearAll" dispid="1" />
  <method name="CreateEntry" dispid="2">
    <parameter name="newName" />
    <parameter name="newPhone" />
  </method>
  <method name="Search" dispid="3"><parameter name="searchedName" /></method>
  <method name="Next" dispid="4" />
  <property name="Name" dispid="5"><get /></property>
  <property name="Phone" dispid="6"><get /></property>
</public>
 
<object id="db" progid="ADODB.Connection" />
 
<script language="JScript"><![CDATA[
  db.Open("UID=root;DATABASE=phonebook;Driver={MySQL ODBC 3.51 Driver};PWD=;OPTION=0;Server=localhost");
  var searchResult = null;
   
  function ClearAll() {
    db.Execute("DELETE FROM person");
  }
 
  function CreateEntry(newName,newPhone) {
    db.Execute("INSERT INTO person(name, phone) VALUES ('"+newName+"', '"+newPhone+"')");
  }
 
  function Search(searchedName) {
    searchResult = db.Execute("SELECT name,phone FROM person WHERE name LIKE '%"+searchedName+"%'");
    return !searchResult.eof;
  }
 
  function Next() {
    searchResult.MoveNext();
    return !searchResult.eof;
  }
 
  function get_Name() {
    if( !searchResult )
      return "";
    return searchResult("name").Value;
  }
   
  function get_Phone() {
    if( !searchResult )
      return "";
    return searchResult("phone").Value;
  }
]]></script>
</component>
v1/phonebook.wsc

Pour être sûr que chaque composant répond bien aux attentes, une bonne pratique est d'avoir des tests unitaires pour chacun d'entre eux. Ici, il s'agit de tester un composant COM, on pourrait donc utiliser l'outil COMUnit mais, puisque l'objectif est avant tout de migrer vers .NET, une idée intéressante est de profiter de l'interopérabilité COM/.NET pour utiliser NUnit.

Ce sera pour les développeurs une entrée en douceur dans le monde .NET et cela leur permettra de se faire la main sur du code qui, tout en restant essentiel pour le projet, ne sera pas sous les feux des utilisateurs. A terme, il paraît évident qu'un outil tel que NUnit apportera plus que n'importe quel autre framework de test.

Pour ce qui est de la mise en oeuvre, l'interopérabilité de .NET avec un WSC se heurte toutefois à l'impossibilité d'utiliser un RCW (Runtime Callable Wrapper). On doit se contenter de late binding et, pour rendre le système de test utilisable, on a tout intérêt à envelopper les appels COM dans un proxy .NET. Cette démarche permet notamment de bien préciser l'interface du composant et d'éviter que celle-ci n'expose des types COM non directement compatibles avec .NET.

public class PhoneBook
{
  private object _obj;
  const string _progId = "phonebook.manager";
 
  public PhoneBook()
  {
    Type t = Type.GetTypeFromProgID( _progId );
    _obj = Activator.CreateInstance(t);
  }
 
  public void ClearAll()
  {
    _obj.GetType().InvokeMember(
        "ClearAll", BindingFlags.InvokeMethod, null, _obj, null
     );
  }
 
  public void CreateEntry( string newName, string newPhone )
  {
    object[] args = {newName,newPhone};
    _obj.GetType().InvokeMember(
      "CreateEntry", BindingFlags.InvokeMethod, null, _obj, args
    );
  }
 
  public string Name
  {
    get
    {
      return (string) _obj.GetType().InvokeMember(
        "Name", BindingFlags.GetProperty, null, _obj, null
      );
    }
  }
}
v1/tests/PhoneBook.cs (extrait)

En fonction du besoin, on pourra recourir à l'écriture d'un outil qui automatise la création d'un tel proxy. Après cela, la mise en oeuvre de NUnit est quasi immédiate : on peut reprendre le scénario de test que l'on utilise déjà au niveau de l'interface graphique :

[TestFixture]
public class TestPhoneBookManager
{
  PhoneBook phoneBookManager;
 
  [SetUp]
  public void Init()
  {
    phoneBookManager = new PhoneBook();
  }
   
  [Test(Description="general search test : 2 persons are in the db")]
  public void enterTwoPersonsSearchForOneThenSearchForBoth()
  {
    phoneBookManager.ClearAll();
    phoneBookManager.CreateEntry( "john doe", "0156565656" );
    phoneBookManager.CreateEntry( "jane doe", "0157575757" );
 
    Assert.IsTrue( phoneBookManager.Search( "john" ) );
    Assert.AreEqual( phoneBookManager.Name, "john doe" );
    Assert.AreEqual( phoneBookManager.Phone, "0156565656" );
    Assert.IsFalse( phoneBookManager.Next() );
 
    Assert.IsTrue( phoneBookManager.Search( "doe" ) );
    Assert.AreEqual( phoneBookManager.Name, "john doe" );
    Assert.AreEqual( phoneBookManager.Phone, "0156565656" );
    Assert.IsTrue( phoneBookManager.Next() );
    Assert.AreEqual( phoneBookManager.Name, "jane doe" );
    Assert.AreEqual( phoneBookManager.Phone, "0157575757" );
    Assert.IsFalse( phoneBookManager.Next() );
  }
}
v1/tests/TestPhoneBookManager.cs (extrait)

On notera l'utilisation de C# pour ces codes de test. La proximité syntaxique avec JScript.NET et l'étendue du support et des outils disponibles pour ce langage rendent ce choix presque évident pour l'écriture de nouveau code.

Le composant étant désormais validé, il ne reste plus qu'à faire les (légères) modifications dans la page ASP :

<%
  var phoneBook = Server.CreateObject("phonebook.manager"); 
  // ========== Actions : new entry
  if( Request.Form("NewEntrySubmit").Count ) {
    var newName = Request.Form("NewName");
    var newPhone = Request.Form("NewPhone");
    phoneBook.CreateEntry(newName,newPhone);
%>
<div id="NewEntryResult">Saved new entry name=<%=newName%> and phone=<%=newPhone%></div>
<% // ========== Actions : clear all
  } else if( Request.Form("ClearAllSubmit").Count ) {
    phoneBook.ClearAll();
%>
<div id="ClearAllResult">Removed all entries from database</div>
<% // ========== Actions : search
  } else if( Request.Form("NameSearch").Count ) {
    var searchedName = Request.Form("NameSearch");
    if( phoneBook.Search(searchedName) ) {             
%>
<table ID="SearchResult">
  <tr><th>Name</th><th>Phone</th></tr>
<%    do { %>
<tr><td><%=phoneBook.Name%></td><td><%=phoneBook.Phone%></td></tr>
<%    } while (phoneBook.Next()); %>
</table>
<%
  } else {
%>
No result found.
<%
  }
}
%>
v1/phonebook.asp (extrait)

La confiance que l'on peut avoir dans le code du composant évite, pour ainsi dire, toute utilisation d'outil de débogage pour réaliser une telle opération. On exécutera bien évidemment notre test IeUnit pour s'assurer de la non-régression de l'application.

On peut maintenant passer à l'écriture de code .NET. Le WSC existant est un bon candidat pour démarrer. L'interface avec la page ASP se faisant via COM, il est inutile de conserver ADO. On va (simplement) écrire une classe C# utilisant ADO.NET et respectant la même interface que le proxy précédemment défini. Il suffit d'exposer cette classe en COM en laissant .NET générer un CCW (COM Callable Wrapper) pour la rendre accessible en ASP.

On commence par décrire, en C#, une interface COM similaire à celle du WSC :

[Guid("1BC1BE85-1189-4fb0-AF1B-87377C9820A6")]
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface IPhoneBook
{
  [DispId(1)]
  void ClearAll();
 
  [DispId(2)]
  void CreateEntry( string newName, string newPhone );
 
  [DispId(3)]
  bool Search( string searchedName );
 
  [DispId(4)]
  bool Next();
 
  string Name
  {
    [DispId(5)]
    get;
  }
 
  string Phone
  {
    [DispId(6)]
    get;
  }
}
v2/businessLayer/PhoneBook.cs (extrait)

Ensuite, on écrit la classe PhoneBook qui va remplacer le WSC :

[Guid("2D0DF171-940C-4c18-8846-AA2B0CF95628")]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("phonebook2.manager")]
public class PhoneBook : IPhoneBook
{
  private OdbcConnection _connection;
  private OdbcDataReader _result;
 
  public PhoneBook()
  {
    _result = null;
    _connection = new OdbcConnection("UID=root;DATABASE=phonebook;Driver={MySQL ODBC 3.51 Driver};PWD=;OPTION=0;Server=localhost");
    _connection.Open();
  }
 
  public void ClearAll()
  {
    OdbcCommand cmd = new OdbcCommand( "DELETE FROM person", _connection );
    cmd.ExecuteNonQuery();
  }
 
  public void CreateEntry( string newName, string newPhone )
  {
    OdbcCommand cmd = new OdbcCommand( "INSERT INTO person(name, phone) VALUES ('"+newName+"', '"+newPhone+"')", _connection );
    cmd.ExecuteNonQuery();
  }
 
  public bool Search( string searchedName )
  {
    if( _result != null )
      _result.Close();
    OdbcCommand cmd = new OdbcCommand( "SELECT name,phone FROM person WHERE name LIKE '%"+searchedName+"%'", _connection );
    _result = cmd.ExecuteReader();
    return _result.Read();
  }
 
  public bool Next()
  {
    return _result.Read();
  }
 
  public string Name
  {
    get
    {
      if( _result == null )
        return "";
      return _result.GetString(0);
    }
  }
 
  public string Phone
  {
    get
    {
      if( _result == null )
        return "";
      return _result.GetString(1);
    }
  }
}
v2/businessLayer/PhoneBook.cs (extrait)

On additionne ici plusieurs avantages. D'un point de vue technique, on peut réutiliser le test unitaire existant pour le WSC pour valider le nouveau composant et on n'a aucune modification à apporter à la page ASP pour qu'elle fonctionne (sauf, éventuellement, le ProgId du composant si on décide d'en changer). D'un point de vue humain, on entre insensiblement dans .NET et on a toute confiance en un code entièrement maîtrisé. D'un point de vue gestion de projet, on procède par étapes très élémentaires et on garde l'ensemble des intervenants concentrés sur une seule version de l'application ce qui permet de parer à tout changement de priorité.

L'étape suivante coule de source : il nous reste à créer une "ASP.NET Web Application" dans laquelle un "Web Form" phonebook.aspx remplacera notre phonebook.asp :

<%@ Page language="c#" Codebehind="phonebook.aspx.cs" Inherits="v3.PhoneBook" %>
<HTML>
  <body MS_POSITIONING="GridLayout">
    <form id="Form1" method="post">
      <asp:panel id="MainMenuPanel">
        <UL>
          <LI><asp:LinkButton id="SearchButton">Search</asp:LinkButton></LI>
          <LI><asp:LinkButton id="NewEntryButton">New entry</asp:LinkButton></LI>
          <LI><asp:LinkButton id="ClearDbButton">Clear database</asp:LinkButton></LI>
        </
UL>

      </asp:panel>
      <asp:panel id="ClearAllPanel">
        <asp:Button id="ClearAllSubmit" Text="Clear All Database Entries"></asp:Button>
      </asp:panel>
      <asp:panel id="NewEntryPanel">
        <P>
          Name :
 <asp:TextBox id="NewName"></asp:TextBox><BR>
          Phone : <asp:TextBox id="NewPhone"></asp:TextBox>
          <asp:Button id="NewEntrySubmit" runat="server" Text="Submit"></asp:Button>
        </
P>

      </asp:panel>
      <
asp:panel id="SearchPanel"
>
        Name : 
<asp:TextBox id="NameSearch"></asp:TextBox>
            <asp:Button id="SearchSubmit" Text="Submit"></asp:Button>
      </
asp:panel>
      <
asp:label id="StatusLabel"></asp:label>
      <
asp:datagrid id="SearchResult"></asp:datagrid>
    </
form>
  </body>
</HTML>
v3/phonebook.aspx (extrait : les tags on été dépouillés de nombreux attributs pour plus de lisibilité)

L'implémentation de ce "Web Form" connecte les évènements des "Web Controls" avec la classe PhoneBook existante :

public class PhoneBook : System.Web.UI.Page
{
  private businessLayer.PhoneBook _phoneBook;
 
  private void Page_Load(object sender, System.EventArgs e)
  {
    _phoneBook = new businessLayer.PhoneBook();
    MainMenuPanel.Visible = true;
    SearchPanel.Visible = false;
    SearchResult.Visible = false;
    NewEntryPanel.Visible = false;
    ClearAllPanel.Visible = false;
    StatusLabel.Text = "";
  }
 
  private void SearchButton_Click(object sender, System.EventArgs e)
  {
    SearchPanel.Visible = true;
  }
 
  private void NewEntryButton_Click(object sender, System.EventArgs e)
  {
    NewEntryPanel.Visible = true;
  }
 
  private void ClearDbButton_Click(object sender, System.EventArgs e)
  {
    ClearAllPanel.Visible = true;
  }
 
  private void SearchSubmit_Click(object sender, System.EventArgs e)
  {
    if( _phoneBook.Search( NameSearch.Text ) )
    {
      System.Data.DataTable table = new DataTable();
      table.Columns.Add("Name");
      table.Columns.Add("Phone");
      do
      {
        object[] rowValues = {_phoneBook.Name,_phoneBook.Phone};
        table.Rows.Add(rowValues);
      } while (_phoneBook.Next());
      System.Data.DataSet results = new DataSet();
      results.Tables.Add(table);
      SearchResult.DataSource = results;
      SearchResult.DataBind();
      SearchResult.Visible = true;
    }
    else
    {
      StatusLabel.Text = "No result found.";
    }
  }
 
  private void NewEntrySubmit_Click(object sender, System.EventArgs e)
  {
    _phoneBook.CreateEntry( NewName.Text, NewPhone.Text );
    StatusLabel.Text = "Saved new entry name="+NewName.Text+" and phone="+NewPhone.Text;
  }
 
  private void ClearAllSubmit_Click(object sender, System.EventArgs e)
  {
    _phoneBook.ClearAll();
    StatusLabel.Text = "Removed all entries from database";
  }
}
v3/phonebook.aspx.cs (extrait)

Là encore, le debugger est relativement superflu. Il ne s'agit que d'écrire une couche de présentation sur une classe métier existante et réputée comme fonctionnant correctement dans l'application courante. Moyennant quelques changements mineurs (ClearAllResult et NewEntryResult sont devenus un seul et unique StatusLabel), le script IeUnit est toujours utilisable.

Il resterait probablement d'autres étapes pour faire évoluer l'architecture qui n'est désormais plus contrainte par des interfaces COM-like mais l'essentiel est là. Notre application tourne désormais en .NET et utilise des technologies natives .NET, pas un enrobage d'ASP. Le code a été écrit par les personnes qui connaissaient déjà l'application ASP et qui assurent une continuité de service en termes de corrections et de nouvelles fonctionnalités.

Grandeur Nature

Bien évidemment, l'application qui a servi d'exemple à ces approches d'une migration ASP vers ASP.NET est, de par sa trivialité technique, à des années-lumière d'un cas réel en termes de complexité. Certains points tels que les sessions, dont le partage est une difficulté clef dans la cohabitation ASP-ASP.NET, ont tout simplement été mis de côté. Il n'en est pas moins vrai qu'un tel changement de technologie, comme tout projet logiciel, est avant tout une affaire de personnes.

Aussi bon que soient les organismes de formation, la maîtrise d'un sujet ne vient que par la pratique. L'intervention de compétences externes est essentielle quand on aborde des domaines mal connus mais elle ne peut pas se substituer à la confiance que l'ensemble des intervenants doivent acquérir sur le sujet. La survie du logiciel en dépend.

Une approche "big-bang" peut se révéler judicieuse quand les aléas du projet ont fait perdre une grande partie de la connaissance de l'existant. Elle renforcera la motivation de la nouvelle équipe. Une approche automatisée a aussi ses bons côtés quand on ne prévoit plus d'évolutions et que la maintenance est quasi inexistante. L'effort consacré à la migration sera ainsi considérablement réduit. Mais, dans la majorité des cas, une approche principalement incrémentale restera le meilleur compromis qualité-durée-coût. Au delà de la souplesse de planification qu'apporte le développement en version unique, l'unité et la cohérence d'une équipe autour d'un code maîtrisé est le meilleur facteur de réussite.

Auteur : Olivier Azeau

Copyright © Août 2005

Ressources

Portland Pattern Repository Wiki : http://c2.com/cgi/wiki (la référence ultime des pratiques de développement logiciel)

Blog de Scott Guthrie : http://weblogs.asp.net/scottgu

Blog de Joel Spolsky : http://www.joelonsoftware.com/

ASP.NET sur MSDN : http://msdn.microsoft.com/asp.net/

Windows Script Components sur MSDN : http://msdn.microsoft.com/library/en-us/script56/html/lettitle.asp

Tutoriel ASP.NET sur GotDotNet : http://fr.gotdotnet.com/quickstart/aspplus/doc/quickstart.aspx

NUnit, a unit testing framework for .NET languages : http://www.nunit.org/

IeUnit, web site testing framework : http://ieunit.sourceforge.net/

COMUnit, A Unit Testing Framework for COM : http://comunit.sourceforge.net/

Téléchargez le code source de l'article

phonebooks.zip (46 Ko)