blog.pagesd.info

Aller au contenu | Aller au menu | Aller à la recherche

vendredi 23 décembre 2011

Configurer ELMAH pour utiliser SQL Server

Pour avancer un peu plus dans mon utilisation d'ELMAH, j'ai cherché à enregistrer les informations qu'il collecte dans une base de données.

Pour cela, j'ai réalisé quelques essais pour voir comment faire et pour comprendre comment cela fonctionnait :

  • Configuration automatique de SQL Server Compact
  • Configuration semi-automatique SQL Server
  • Configuration manuelle de SQL Server Compact

La suite de ce billet récapitule le fonctionnement propre à chacune de ces méthodes.

ELMAH + Sql Server Compact en automatique

Sous NuGet, je recherche "elmah" puis choisis le package "ELMAH on MS SQL Server Compact". Sa description indique qu'il s'agit d'une configuration pour démarrer rapidement avec une base de données Microsoft SQL Server Compact.

Ca semble être un meilleur choix que "ELMAH on MS SQL Server (requires manual config)" puisque à priori, l'absence de "requires manual config" devant logiquement signifier que je n'aurai rien à faire.

Je clique sur le bouton [Install] et l'installation débute, mais impose l'installation de Sql Server Compact 4.0.8+. Je tique un peu parce qu'il me semblait que j'étais à jour ? Mais bon, [I accept] et le projet référence maintenant 2 packages supplémentaires :

  • ELMAH on MS SQL Server Compact
  • SqlServerCompact

Il suffit alors de relancer l'application et de se rendre sur l'URL elmah.axd pour confirmer que tout a été configuré sans que effectivement j'ai eu quoi que ce soit à faire : "This log is provided by the SQL Server Compact Error Log". Génial !

Si je regarde dans le dossier "App_Data", je constate qu'il contient désormais une base de données "Elmah.sdf". Un double-clic pour l'ouvrir et je peux voir que celle-ci est constitué d'une seule table "ELMAH_Error", avec les colonnes nécessaire pour enregistrer tout le détail des erreurs :

  • ErrorId
  • Application
  • Host
  • Type
  • Source
  • Message
  • User
  • StatusCode
  • TimeUtc
  • Sequence
  • AllXml

Et si maintenant j'étudie ce qui a changé dans l'application, je m'aperçois qu'en fait c'est trois fois rien dans le web.config :

Un :

<connectionStrings>
  ...
  <add name="elmah-sqlservercompact"
       connectionString="Data Source=|DataDirectory|\Elmah.sdf" />
</connectionStrings>

Deux :

<system.data>
  <DbProviderFactories>
    <remove invariant="System.Data.SqlServerCe.4.0" />
    <add name="Microsoft SQL Server Compact Data Provider 4.0"
         invariant="System.Data.SqlServerCe.4.0"
         description=".NET Framework Data Provider for Microsoft SQL Server Compact" type="System.Data.SqlServerCe.SqlCeProviderFactory, System.Data.SqlServerCe, Version=4.0.0.0, Culture=neutral, PublicKeyToken=89845dcd8080cc91" />
  </DbProviderFactories>
</system.data>

Et trois :

<elmah>
  <errorLog type="Elmah.SqlServerCompactErrorLog, Elmah"
            connectionStringName="elmah-sqlservercompact" />
</elmah>

Rollback

Je désinstalle le package "ELMAH on MS SQL Server Compact" (mais pas les packages dont il dépend) puis le package "SqlServerCompact".

Puis je retourne sur elmah.axd où je peux constater que je suis bien revenu à mon point de départ : "This log is provided by the In-Memory Error Log.". Parfait !

ELMAH + Sql Server en semi-automatique

Ce coup-ci je fais un essai avec l'installation du package NuGet "ELMAH on MS SQL Server (requires manual config)".

Une fois terminé, un nouveau dossier "App_Readme" est apparu dans le projet, avec deux fichiers : - Elmah.SqlServer.sql - Elmah.SqlServer.txt

Et le fichier Elmah.SqlServer.txt contient le texte suivant :

Please note that in order to complete the installation of ELMAH.SqlServer you will have to do the following:

1) Run the Elmah.SqlServer.sql script against your database
2) Edit your web.config with the correct settings in the elmah <connectionString> to connect to your database

Malgré tout, le fichier web.config a déjà été pas mal préparé pour simplifier la configuration :

Un :

<connectionStrings>
  ...
  <add name="elmah-sqlserver" 
       connectionString="Data Source=****;User ID=****;Password=****;Initial Catalog=****;"
       providerName="System.Data.SqlClient" />
</connectionStrings>

Et deux :

<elmah>
  <errorLog type="Elmah.SqlErrorLog, Elmah" 
            connectionStringName="elmah-sqlserver" />
</elmah>

Je sais jamais faire, mais sinon, c'est pas compliqué :

  • démarrer SQL Server Management Studio (l'occasion de voir que j'ai des tonnes de base Xxxxx.Models.XxxxxContext crées au cours de différents essais ou tutoriels)
  • créer une nouvelle base "Elmah" (et la passer en Compatibility_Level 80)
  • lancer le script (qui génère pas mal de message d'avertissement)

Ouf ! C'est fait.

Puis enregistrer la chaine de connexion :

<add name="elmah-sqlserver"
     connectionString="Data Source=.\SQLEXPRESS;Integrated Security=SSPI;Initial Catalog=Elmah;"
     providerName="System.Data.SqlClient" />

Je peux alors relancer l'application, provoquer une erreur et aller que la page elmah.axd et vérifier que "This log is provided by the Microsoft SQL Server Error Log.". Bravo !

ELMAH + Sql Server Compact en manuel

Après désinstallation du package "ELMAH on MS SQL Server (requires manual config)", je me retrouve à nouveau avec un ELMAH tout simple qui stocke ses erreurs en mémoire. Et au passage, le dossier App_Readme a disparu. Magnifique !

Je peux donc tenter une modification tout ce qu'il y a de plus manuelle du web.config pour utiliser une base de données Sql Server Compact (celui qui est déjà installé sur mon PC).

Un :

<connectionStrings>
  ...
  <add name="elmah-sqlservercompact"
       connectionString="Data Source=|DataDirectory|\Elmah.sdf" />
</connectionStrings>

Et deux :

<elmah>
  <errorLog type="Elmah.SqlServerCompactErrorLog, Elmah"
            connectionStringName="elmah-sqlservercompact" />
</elmah>

Je relance l'application, tente d'accéder à l'URL index.html qui n'existe pas puis direction elmah.axd et ça marche : "This log is provided by the SQL Server Compact Error Log". Magnifique !

Conclusions

Déjà, le système des packages NuGet est bien foutu (au moins en ce qui concerne ELMAH) :

  • ça installe ce qui est nécessaire et ça fait les modifications et configurations qui vont bien où ça va bien.
  • quand on désinstalle, ça sait remettre tout comme il faut, y compris les modifications apportées au web.config :)

Pour l'instant, je vais en rester à la configuration manuelle pour Sql Server Compact. C'est bien suffisant pour les essais que je fais.

Mais finalement, je pense avoir compris pourquoi le package "ELMAH on MS SQL Server Compact" en automatique installe SqlServerCompact. Au moins, lorsque on déploie sur le serveur de production, ça permet que ELMAH soit prêt à fonctionner sur une base de données SQL Server Compact, même si celui-ci n'est pas installé en production. Pas bête !

jeudi 17 novembre 2011

MVC Scaffolding, Eager loading et ViewModel

Pour tenter de mettre au point une méthode simple(iste) pour construire une application avec ASP.NET MVC, j'essaie de développer une petite application destinées à générer une brochure de voyages . Pour cela, je dois gérer une base de données avec une table "Voyages", une table "Tarifs" pour enregistrer les chaque tarif du voyage et une table "Parties" qui sert à décrire les différentes étapes du voyage.

Eager loading pour les actions Index

Pour l'instant, j'ai plus ou moins laissé MVC Scaffolding écrire mon code pour la classe Voyage associée à la table Voyages. Par défaut, il a généré le code C# suivant pour l'action Index du contrôleur VoyagesController afin retrouver tous les voyages à afficher :

var voyages = voyageRepository
    .AllIncluding(voyage => voyage.Parties, voyage => voyage.Tarifs)
    .OrderBy(voyage => voyage.Position)
    .ThenBy(voyage => voyage.Title);

Ce qui correspond à quelque chose dans ce genre si on ne passe pas par un Repository :

var voyages = context
    .Voyages
    .Include(voyage => voyage.Parties)
    .Include(voyage => voyage.Tarifs)
    .OrderBy(voyage => voyage.Position)
    .ThenBy(voyage => voyage.Title);

Ces deux codes C# ont tous les deux pour effet de générer une (grosse) requête SQL qui charge à la fois le contenu de la table Voyages et par des jointures celui des tables Parties et Tarifs :

SELECT 
[UnionAll1].[VoyageID] AS [C1], 
[UnionAll1].[VoyageID1] AS [C2], 
[UnionAll1].[Position1] AS [C3], 
[UnionAll1].[Title1] AS [C4], 
[UnionAll1].[VoyageType] AS [C5], 
[UnionAll1].[Notes] AS [C6], 
[UnionAll1].[C1] AS [C7], 
[UnionAll1].[PartieID] AS [C8], 
[UnionAll1].[VoyageID2] AS [C9], 
[UnionAll1].[Position2] AS [C10], 
[UnionAll1].[PartieType] AS [C11], 
[UnionAll1].[Content] AS [C12], 
[UnionAll1].[C2] AS [C13], 
[UnionAll1].[C3] AS [C14], 
[UnionAll1].[C4] AS [C15], 
[UnionAll1].[C5] AS [C16], 
[UnionAll1].[C6] AS [C17], 
[UnionAll1].[C7] AS [C18], 
[UnionAll1].[C8] AS [C19], 
[UnionAll1].[C9] AS [C20], 
[UnionAll1].[C10] AS [C21], 
[UnionAll1].[C11] AS [C22]
FROM  (SELECT
        CASE WHEN ([Extent2].[PartieID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1],
        [Extent1].[Position] AS [Position], 
        [Extent1].[Title] AS [Title], 
        [Extent1].[VoyageID] AS [VoyageID], 
        [Extent1].[VoyageID] AS [VoyageID1], 
        [Extent1].[Position] AS [Position1], 
        [Extent1].[Title] AS [Title1], 
        [Extent1].[VoyageType] AS [VoyageType], 
        [Extent1].[Notes] AS [Notes], 
        [Extent2].[PartieID] AS [PartieID], 
        [Extent2].[VoyageID] AS [VoyageID2], 
        [Extent2].[Position] AS [Position2], 
        [Extent2].[PartieType] AS [PartieType], 
        [Extent2].[Content] AS [Content], 
        CAST(NULL AS int) AS [C2], 
        CAST(NULL AS int) AS [C3], 
        CAST(NULL AS nvarchar(1)) AS [C4], 
        CAST(NULL AS nvarchar(1)) AS [C5], 
        CAST(NULL AS real) AS [C6], 
        CAST(NULL AS real) AS [C7], 
        CAST(NULL AS real) AS [C8], 
        CAST(NULL AS real) AS [C9], 
        CAST(NULL AS real) AS [C10], 
        CAST(NULL AS nvarchar(1)) AS [C11] 
        FROM  [Voyages] AS [Extent1] 
        LEFT OUTER JOIN [Parties] AS [Extent2] ON [Extent1].[VoyageID] = [Extent2].[VoyageID]
UNION ALL
        SELECT
        2 AS [C1], 
        [Extent3].[Position] AS [Position], 
        [Extent3].[Title] AS [Title], 
        [Extent3].[VoyageID] AS [VoyageID], 
        [Extent3].[VoyageID] AS [VoyageID1], 
        [Extent3].[Position] AS [Position1], 
        [Extent3].[Title] AS [Title1], 
        [Extent3].[VoyageType] AS [VoyageType], 
        [Extent3].[Notes] AS [Notes], 
        CAST(NULL AS int) AS [C2], 
        CAST(NULL AS int) AS [C3], 
        CAST(NULL AS int) AS [C4], 
        CAST(NULL AS int) AS [C5], 
        CAST(NULL AS nvarchar(1)) AS [C6], 
        [Extent4].[TarifID] AS [TarifID], 
        [Extent4].[VoyageID] AS [VoyageID2], 
        [Extent4].[Title] AS [Title2], 
        [Extent4].[Year] AS [Year], 
        [Extent4].[Prix1] AS [Prix1], 
        [Extent4].[Prix2] AS [Prix2], 
        [Extent4].[Prix3] AS [Prix3], 
        [Extent4].[Prix4] AS [Prix4], 
        [Extent4].[Prix5] AS [Prix5], 
        [Extent4].[Notes] AS [Notes1]
        FROM  [Voyages] AS [Extent3]
        INNER JOIN [Tarifs] AS [Extent4] ON [Extent3].[VoyageID] = [Extent4].[VoyageID]) AS [UnionAll1]
ORDER BY [UnionAll1].[Position] ASC, [UnionAll1].[Title] ASC, [UnionAll1].[VoyageID1] ASC, [UnionAll1].[C1] ASC  

Puis dans la vue Index.cshtml, on utilise le IEnumerable<Voyage> obtenu pour afficher une liste des voyages, avec pour chacun d'entre eux le nombre de parties qui composent sa description détaillée et le nombre de tarifs définis pour ce voyage.

@foreach (var item in Model) {
  <tr>
    <td>
      @Html.ActionLink("Edit", "Edit", new { id = item.VoyageID })
    </td>
    <td>
      @item.Position
    </td>
    <td>
      @Html.ActionLink(@item.Title, "Details", new { id = item.VoyageID })
    </td>
    <td>
      @Html.DisplayTextFor(_ => item.TypeVoyage).ToString()
    </td>
    <td>
      @(item.Parties == null ? "" : item.Parties.Count.ToString())
    </td>
    <td>
      @(item.Tarifs == null ? "" : item.Tarifs.Count.ToString())
    </td>
    <td>
      @Html.ActionLink("Delete", "Delete", new { id = item.VoyageID })
    </td>
  </tr>
}

Ce qui est intéressant, c'est que MVC Scaffolding fait automatiquement du "Eager Loading" pour que les instructions item.Parties.Count.ToString() et item.Tarifs.Count.ToString() en boucle ne provoquent pas autant d'accès à la base de données qu'il y a de voyage à afficher.

Lazy loading et SELECT N+1

En effet, si par défaut, MVC Scaffolding n'avait pas fait le lien avec les parties et les tarifs et qu'il se soit contenté de générer le code C# suivant :

var voyages = voyageRepository
    .All
    .OrderBy(voyage => voyage.Position)
    .ThenBy(voyage => voyage.Title);

Ou sans le Repository :

var voyages = context
    .Voyages
    .OrderBy(voyage => voyage.Position)
    .ThenBy(voyage => voyage.Title);

Alors la consultation de la page Index n'aurait pas généré 1 seule requête, mais 71 !!!

Soit 1 première requête toute simple pour charger la liste des 35 voyages :

SELECT 
[Extent1].[VoyageID] AS [VoyageID], 
[Extent1].[Position] AS [Position], 
[Extent1].[Title] AS [Title], 
[Extent1].[VoyageType] AS [VoyageType], 
[Extent1].[Notes] AS [Notes]
FROM [Voyages] AS [Extent1]
ORDER BY [Extent1].[Position] ASC, [Extent1].[Title] ASC 

Puis 35 requêtes pour charger successivement les parties de chacun des 35 voyages :

SELECT 
[Extent1].[PartieID] AS [PartieID], 
[Extent1].[VoyageID] AS [VoyageID], 
[Extent1].[Position] AS [Position], 
[Extent1].[PartieType] AS [PartieType], 
[Extent1].[Content] AS [Content]
FROM [Parties] AS [Extent1]
WHERE [Extent1].[VoyageID] = @EntityKeyValue1  

Et encore 35 requêtes pour charger successivement les tarifs de chacun des 35 voyages :

SELECT 
[Extent1].[TarifID] AS [TarifID], 
[Extent1].[VoyageID] AS [VoyageID], 
[Extent1].[Title] AS [Title], 
[Extent1].[Year] AS [Year], 
[Extent1].[Prix1] AS [Prix1], 
[Extent1].[Prix2] AS [Prix2], 
[Extent1].[Prix3] AS [Prix3], 
[Extent1].[Prix4] AS [Prix4], 
[Extent1].[Prix5] AS [Prix5], 
[Extent1].[Notes] AS [Notes]
FROM [Tarifs] AS [Extent1]
WHERE [Extent1].[VoyageID] = @EntityKeyValue1   

Par rapport à l'eager loading, ici on a fait du "lazy loading" : les entités enfants ne sont chargées que lorsque on y accède. Des fois c'est bien. Des fois c'est mal. Mais heureusement, MVC Scaffolding qui a pensé à tout est est assez malin pour nous éviter l'écueil du SELECT N+1 quand on génère l'action Index (merci Scott).

Lazy loading et virtual

Pour être complet, c'est parce que les propriétés Parties et Tarifs de la classe Voyage sont déclarées en tant que propriétés virtuelles, que Entity Framework a fait du "lazy loading".

Ainsi, si au lieu d'utiliser virtual pour les déclarer :

public virtual ICollection<Partie> Parties { get; set; }
public virtual ICollection<Tarif> Tarifs { get; set; }

On s'était contenté de :

public ICollection<Partie> Parties { get; set; }
public ICollection<Tarif> Tarifs { get; set; }

Alors la consultation de la page Index n'aurait plus généré qu'une seule requête pour charger uniquement les données de la table des voyages (pas de eager loading puisque on n'a pas d'Include) :

SELECT 
[Extent1].[VoyageID] AS [VoyageID], 
[Extent1].[Position] AS [Position], 
[Extent1].[Title] AS [Title], 
[Extent1].[VoyageType] AS [VoyageType], 
[Extent1].[Notes] AS [Notes]
FROM [Voyages] AS [Extent1]
ORDER BY [Extent1].[Position] ASC, [Extent1].[Title] ASC 

Et puis c'est tout ! Comme on n'a pas de virtual, Entity Framework ne fait pas non plus de lazy loading et ne cherche donc pas à charger les parties et les tarifs.

C'est pour cela que les colonnes nombre de parties et nombre de tarifs restent vides puisque les instructions item.Parties == null ? "" : item.Xxxxxx.Count.ToString() sont là pour gérer le fait que les collections voyage.Parties et voyage.Tarifs sont nulles.

Par contre, même sans le virtual, si on demande à faire de l'eager loading à grand coups de Include, on retombe bien sur la grosse requête qui accède par jointure aux tables Voyages, Parties et Tarifs.

Action Index et ViewModel

A titre personnel, je trouve malgré tout que la requête SQL générée par Entity Framework est un peu lourde pour ce qu'on en fait. Alors que le but est simplement d'afficher le nombre de parties et de tarifs d'un voyage, on se retrouve quand même à charger toutes les parties et tous les tarifs de tous les voyages, ce qui revient donc à charger l'intégralité des tables Parties et Tarifs.

Note : Si on avait une pagination par paquet de 20 voyages, ça serait un peu mieux puisque cela reviendrait "seulement" à charger toutes les parties et tous les tarifs correspondant à 20 voyages uniquement.

Dans l'idéal, il faudrait donc récupérer les informations de base de chaque voyage sans ses propriétés voyage.Parties et voyage.Tarifs, plus le nombre de ses parties et le nombre de ses tarifs. Ce qui pourrait être représenté par la classe ViewModel suivante :

public class VoyageViewModel
{
    public int VoyageID { get; set; }
    public int Position { get; set; }
    public string Title { get; set; }
    public int VoyageType { get; set; }
    public int TarifsCount { get; set; }
    public int PartiesCount { get; set; }
}

Le fait d'utiliser un ViewModel est d'autant plus intéressant qu'une liste n'est pas là pour afficher la totalité des propriétés d'une entité, mais seulement quelques informations essentielles pour remplir son rôle, à savoir permettre de retrouver d'un coup d'oeil les informations qui comptent et éventuellement accéder au détail de la fiche pour consulter l'ensemble de ses données.

Par exemple, si j'ai une table Contacts avec civilité, nom, prénom, téléphone, portable, fax, email, adresse, code postal, ville, pays, format pour les emails, date de naissance, etc... je ne veux pas d'une liste qui fassent 50 colonnes pour afficher tout ça. Je préfère sacrifier quelques détails et me contenter d'une liste édulcorée mais lisible.

Pour résumer, je pense que dans le cas d'une liste (au moins), il est préférable d'employer un objet ViewModel spécifique pour collecter les données importantes puis de transmettre cet objet à la vue pour qu'elle présente ces informations à l'utilisateur.

Cela implique donc de créer une classe ViewModel "VoyagesIndex" (nommée d'après le nom du contrôleur (VoyagesController) et de l'action (Index) qui va l'utiliser) :

public class VoyagesIndex
{
    public int VoyageID { get; set; }
    public int Position { get; set; }
    public string Title { get; set; }
    public int VoyageType { get; set; }
    public int TarifsCount { get; set; }
    public int PartiesCount { get; set; }

    public VoyageType TypeVoyage
    {
        get { return (VoyageType)VoyageType; }
        set { VoyageType = (int)value; }
    }
}

Note : public VoyageType TypeVoyage est une bidouille qui n'a rien à voir avec le sujet et que j'essairai d'expliquer plus tard si je persiste à passer par elle.

Il faut ensuite modifier le code de l'action Index pour qu'elle récupère une collection de VoyagesIndex et plus une collection d'objets Voyage :

var voyages = voyageRepository
    .All
    .OrderBy(voyage => voyage.Position)
    .ThenBy(voyage => voyage.Title)
    .Select(voyage => new VoyagesIndex
    {
        VoyageID = voyage.VoyageID
        Position = voyage.Position,
        Title = voyage.Title,
        VoyageType = voyage.VoyageType,
        TarifsCount = voyage.Tarifs.Count(),
        PartiesCount = voyage.Parties.Count()
    }).ToList();

Et pour finir modifier la vue Index.cshtml pour qu'elle utilise désormais un IEnumerable<VoyagesIndex> pour afficher son contenu :

@foreach (var item in Model) {
  <tr>
    <td>
      @Html.ActionLink("Edit", "Edit", new { id = item.VoyageID })
    </td>
    <td>
      @item.Position
    </td>
    <td>
      @Html.ActionLink(@item.Title, "Details", new { id = item.VoyageID })
    </td>
    <td>
      @item.TypeVoyage.ToString()
    </td>
    <td>
      @item.PartiesCount
    </td>
    <td>
      @item.TarifsCount
    </td>
    <td>
      @Html.ActionLink("Delete", "Delete", new { id = item.VoyageID })
    </td>
  </tr>
}

Grâce à quoi la requête SQL générée quand on consulte la page Index est un peu plus simple à lire, et normalement plus efficace puisqu'elle ne cherche pas à charger les tables Parties et Tarifs.

SELECT 
[Project2].[VoyageID] AS [VoyageID], 
[Project2].[Position] AS [Position], 
[Project2].[Title] AS [Title], 
[Project2].[VoyageType] AS [VoyageType], 
[Project2].[C1] AS [C1], 
[Project2].[C2] AS [C2]
FROM ( SELECT 
        [Project1].[VoyageID] AS [VoyageID], 
        [Project1].[Position] AS [Position], 
        [Project1].[Title] AS [Title], 
        [Project1].[VoyageType] AS [VoyageType], 
        [Project1].[C1] AS [C1], 
        [SSQTAB1].[A1] AS [C2] 
        FROM ( SELECT 
                [Extent1].[VoyageID] AS [VoyageID], 
                [Extent1].[Position] AS [Position], 
                [Extent1].[Title] AS [Title], 
                [Extent1].[VoyageType] AS [VoyageType], 
                [SSQTAB1].[A1] AS [C1] 
                FROM [Voyages] AS [Extent1] 
                 OUTER APPLY 
                (SELECT 
                        COUNT(1) AS [A1] 
                        FROM [Tarifs] AS [Extent2] 
                        WHERE [Extent1].[VoyageID] = [Extent2].[VoyageID]) AS [SSQTAB1]
        )  AS [Project1]
         OUTER APPLY
        (SELECT
                COUNT(1) AS [A1]
                FROM [Parties] AS [Extent3]
                WHERE [Project1].[VoyageID] = [Extent3].[VoyageID]) AS [SSQTAB1]
)  AS [Project2]
ORDER BY [Project2].[Position] ASC, [Project2].[Title] ASC   

mardi 18 octobre 2011

Utilisation d’expressions lambda en 10 étapes

Point de départ : 3 fonctions avec du code répétitif ( !DRY)

PrevisionFacturationSrv.cs

PrevSrv.UpdateEndOfContract(PrevFinContrat);
PrevSrv.UpdateEndOfMonth(PrevFinMois);
PrevSrv.UpdatePlacement(PrevPlacement, PonderationPlacement);

PrevisionSrv.cs

/// <summary>
/// Màj de la prévision de facturation fin de contrat pour un ensemble de Siren / Société
/// </summary>
/// <param name="list">Liste des couples Siren / Société et des montants à traiter</param>
public void UpdateEndOfContract(IList<PrevisionFacturation> list)
{
  foreach (var pf in list)
  {
    var Prevision = this.GetBySirenAndSociety(pf.Siren, pf.Societe);

    Prevision.FinContrat = pf.Montant < 0 ? 0 : Math.Round(pf.Montant, 2);

    this.CreateOrUpdate(Prevision);
  }
}

/// <summary>
/// Màj de la prévision de facturation fin de mois pour un ensemble de Siren / Société
/// </summary>
/// <param name="list">Liste des couples Siren / Société et des montants à traiter</param>
public void UpdateEndOfMonth(IList<PrevisionFacturation> list)
{
  foreach (var pf in list)
  {
    var Prevision = this.GetBySirenAndSociety(pf.Siren, pf.Societe);

    Prevision.FinMois = pf.Montant < 0 ? 0 : Math.Round(pf.Montant, 2);
    Prevision.NbContrats = pf.Nombre;

    this.CreateOrUpdate(Prevision);
  }
}

/// <summary>
/// Màj de la prévision de facturation placement pour un ensemble de Siren / Société
/// </summary>
/// <param name="list">Liste des couples Siren / Société et des montants à traiter</param>
/// <param name="ponderation">Taux de pondération pour la prise en compte du placement</param>
public void UpdatePlacement(IList<PrevisionFacturation> list, int ponderation)
{
  foreach (var pf in list)
  {
    var Prevision = this.GetBySirenAndSociety(pf.Siren, pf.Societe);

    Prevision.Placement = pf.Montant < 0 ? 0 : Math.Round(pf.Montant * ponderation / 100, 2);

    this.CreateOrUpdate(Prevision);
  }
}

Source d'inspiration (à lire !)

Introduction au délégués en C#, un article en français rédigé par Fabien Guillot pour expliquer aux débutants comment fonctionnent les délégués en C#, quelle est leur utilité, et quelle a été leur évolution avec les différentes versions du Framework .NET.

Les 10 étapes

  • 1° étape : Mise en commun du code (ancien style)
  • 2° étape : Isolation des traitements spécifiques dans des fonctions séparées
  • 3° étape : Mise en place des délégués (enfin !)
  • 4° étape : Simplification des délégués grâce à l’inférence de type
  • 5° étape : Arrivée des méthodes anonymes (avec .NET 2)
  • 6° étape : Simplification des délégués anonymes grâce à l’inférence de type
  • 7° étape : Retour sur le code : « ref » ne sert à rien (merci Nicolas)
  • 8° étape : Arrivée des expressions lambdas (avec .NET 3.5)
  • 9° étape : Simplification des expressions lambdas
  • 10° étape : Utilisation de paramètres optionnels

Consulter éventuellement la version PDF pour voir en détail comment se sont déroulées ces étapes : Utilisation_Expressions_Lambda_en_10_etapes.pdf.

Le résultat

PrevisionFacturationSrv.cs

PrevSrv.UpdatePrevision(PrevFinContrat, (p, m, n) => { p.FinContrat = m; });
PrevSrv.UpdatePrevision(PrevFinMois, (p, m, n) => { p.FinMois = m; p.NbContrats = n; });
PrevSrv.UpdatePrevision(PrevPlacement, (p, m, n) => { p.Placement = m; }, PonderationPlacement);

PrevisionSrv.cs

/// <summary>
/// Mise à jour de la prévision de facturation pour un ensemble de Siren / Société
/// </summary>
/// <param name="list">Liste des couples Siren / Société et des montants à traiter</param>
/// <param name="assign">Méthode pour réaliser le traitement spécifique</param>
/// <param name="ponderation">Taux de pondération pour le type de prévision</param>
public void UpdatePrevision(IList<PrevisionFacturation> list, UpdateAssign assign, int ponderation = 100)
{
  foreach (var pf in list)
  {
    pf.Montant = pf.Montant < 0 ? 0 : Math.Round(pf.Montant * ponderation / 100, 2);

    var Prevision = this.GetBySirenAndSociety(pf.Siren, pf.Societe);

    assign(Prevision, pf.Montant, pf.Nombre);

    this.CreateOrUpdate(Prevision);
  }
}

/// <summary>
/// Signature des fonctions d'affectation spécifiques
/// </summary>
/// <param name="prevision">Objet Prevision à mettre à jour</param>
/// <param name="montant">Montant de la prévision</param>
/// <param name="nombre">Nombre de contrats traités (ssi fin de mois)</param>
public delegate void UpdateAssign(Prevision prevision, decimal montant, int nombre);

lundi 17 octobre 2011

Personnalisation de la CSS pour ASP.NET MVC

Ce billet est avant tout un pense-bête personnel pour noter les quelques modifications que j'apporte assez régulièrement à la feuille de style fournie par défaut quand on génère un nouveau projet ASP.NET MVC3 dans Visual Studio.

En général, lorsque je débute un nouveau projet, j'ai tendance à conserver la feuille de style telle que Visual Studio l'a créée. Puis quand je bloque sur des fonctionnalités ou des problèmes techniques, je m'accorde un petit moment de détente en adaptant « Site.css » pour essayer d'embellir mon projet avant de repartir de plus belle.

mysite01.png

Normalize

Avant toute chose, et pour partir du bon pied, je démarre par un petit reset.css, ou ces derniers temps un normalize.css dans la vue _Layout.cshtml :

<head>
  ...
  <link href="@Url.Content("~/Content/normalize.css")" rel="stylesheet" type="text/css" />
  <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
  ...
</head>

Undo

Comme j'aime très moyennement les coins arrondis, je commence par m'en débarrasser. Ca m'amuse toujours de commencer de façon radicale en lançant un rechercher / remplacer de -radius par -no-radius. Cela me permet de venir à bout de border-radius, -moz-border-radius et autre -webkit-border-radius. Mais au final, je fais ça de façon plus civilisée en redéfinissant tous les styles « xxxxx-border-radius » à 0.

/* Suppression des coinzarrondis */
#main,
footer, 
#footer,
ul#menu li a
{
  border-radius: 0;
  -webkit-border-radius: 0;
  -moz-border-radius: 0;
}

Il y a aussi l'effet d'ombre 3D sur le titre « My MVC Application » que je trouve un peu lourd. Là encore, un rechercher / remplacer à l'arrache de -shadow par -no-shadow ne fait pas de mal. Ou plus proprement je redéfini le style « text-shadow » de « header h1 ».

/* Suppression de l'effet 3D sur le titre */
header h1,
#header h1
{
  text-shadow: 0 0 0 transparent;
}

Font

Une fois défoulé, j'apporte encore quelques modifications encore plus personnelles pour obtenir un résultat un peu plus à mon goût.

Comme je suis toujours dans ma période Century Gothic is Beautiful, c'est elle qui gagne par rapport à la police « Trebuchet MS » utilisée par défaut.

/* Utilisation de ma police de prédilection */
body
{
  font-family: "Century Gothic", "Trebuchet MS", Verdana, Helvetica, Sans-Serif;
}

Color

Puis, même si j'aime beaucoup le bleu assez soutenu utilisé comme couleur de fond, je le change pour une couleur plus claire et neutre.

Ensuite, je change les couleurs de titres au gré de l'humeur du jour ou des « Oh, c'est quoi cette joli couleur » trouvés sur d'autres sites. Ou plus simplement, c'est le titre général en chocolat, le titre de page en gris clair et les sous-titre en vert.

Après, j'ai encore un problème avec le menu qui est bleuté par défaut parce que je n'ai rien trouvé qui me plaise vraiment. Pour l'instant, je me contente de le faire ressortir en le passant en blanc sur fond vert avec un effet chocolat quand on passe la souris dessus.

/* Changement du jeu de couleurs */
body { background-color: #e0e0e0; } /* Un fond de page gris clair */
header h1, #header h1, h1 { color: #d2691e; } /* chocolat */
h2 { color: #888888; } /* gris */
h3 { color: #39b449; } /* vert */
ul#menu li a { color: #fff; background-color: #39b449; } /* Menu blanc/vert */
ul#menu li a:hover { background-color: #d2691e; } /* Et survolé en chocolat */
ul#menu { border-bottom: 0; } /* Tant qu'à faire */

Table

Une fois zen, je pars à l'assaut des tables. Déjà c'est du n'importe quoi. Pour éviter de m'emporter, je commence par un table { width : 100% } pour qu'elles s'étalent par défaut sur toute la largeur de la page.

/* Par défaut les tables occupent toute la largeur de la page */
table
{
  width: 100%;
}

Puis j'aère les cellules parce que ça sert à rien de se payer des 21 pouces si c'est pour rester tout riquiqui et tassé.

/* Aération des cellules */
thead th,
tbody td,
tfoot th
{
  border: 1px dotted #aaa;
  padding: 0.5em;
  text-align: left;
  vertical-align: top;
}

En parlant de thead et autres, je me plonge dans toutes les vues d'ores et déjà générées par défaut pour rajouter les balises <thead> et <tbody> que Visual Studio s'acharne à oublier. Y m'énerve ! C'est pourtant pas compliqué de comprendre que :

  • C'est quand même beaucoup plus simple à styler
  • C'est bien plus pratique pour utiliser la plupart des plugins jQuery

Après ça, je peux facilement colorer les en-têtes et pieds de tables en reprenant la couleur du menu d'onglets :

/* Redéfini les couleurs de l'en-tête et du pied des tables */
thead tr th,
tfoot tr th
{
  background-color: #3399ff;
  color: #fff;
}

Puis là, comme j'en ai rien à faire des vieux navigateurs, j'alterne la couleur de fond des lignes avec du CSS3 et un fond moins blanc que blanc.

/* Alterne la couleur de fond des lignes d'une table */
tbody tr:nth-child(odd)
{
  background-color: #f0f0f0;
}

Pour continuer à animer et égayer un peu les tableaux, je fais mon petit effet au passage de la souris en changeant la couleur de fond de la ligne pour que ça pète un peu plus. Là aussi, la couleur dépend des jours et de l'inspiration. Et aujourd'hui c'est jaune clair parce qu'il a l'avantage d'avoir un petit effet orangé sur mon portable.

/* Ajoute un effet lors du survol d'une ligne */
tbody tr:hover
{
  background-color: #ffff80;
}

Form

Là aussi, il y aurait pas mal à dire du choix du balisage. Le problème, c'est que c'est tout un ensemble entre les helpers et le système de validation alors c'est un peu compliqué et que je résiste encore pour conserver tel quel le html généré par Visual Studio. Ce qui fait que je n'arrive pas à me fixer sur quelque chose de « définitif ». Enfin bref, y'a rien qui me plait pour l'instant. A suivre donc…

mysite02.png

jeudi 6 octobre 2011

Générer des URLs en minuscules avec ASP.NET MVC

Un des trucs bien de ASP.NET MVC c'est que son système de routes sert aussi pour générer automatiquement les URLs correctes pour les actions des contrôleurs à partir de méthodes telles que Html.ActionLink(), Url.Action()… Un des trucs crispant de ASP.NET MVC, c'est qu'il génère ces URLs en conservant les noms des contrôleurs et des actions tels quels.

Ainsi, si on a une action "Update" dans un contrôleur "ContactsController", cela génèrera l'URL "/Contacts/Update" alors que cela aurait été tellement plus joli de générer une URL "/contacts/update" !

Ce qui laisse 2 solutions :

  • Attendre le jour où ASP.NET MVC gèrera ça de lui-même
  • Se dire que quelques majuscules par ci par là c'est pas si moche que ça
  • N'utiliser que des minuscules pour les noms de contrôleurs et d'actions
  • Améliorer le système de routes pour minusculiser automatiquement les noms des contrôleurs et des actions

Du temps de MVC 1, j'avais déjà fait ça pour certains projets, en reprenant le code proposé par Graham O'Neale dans son billet Lowercase Route URL's in ASP.NET MVC.

Mais je trouve que c'est typiquement le genre de petits trucs sur lequel il ne faut pas perdre de temps et qu'il ne faut pas chercher à gérer soi-même. Et dans ce cas là, l'utilisation d'un package NuGet tout prêt est la solution idéale.

Ce qui tombe bien, puisque Lee Dumond a justement créé un package LowercaseRouteMVC exprès pour ça. Son utilisation est très simple puisqu'il suffit de référencer sa DLL puis de légèrement modifier la façon de déclarer les routes dans le Globas.asax.

Installer

  • Faire un clic droit sur la branche "Référence" du projet et sélectionner le sous-menu "Manage NuGet Packages…".
  • Rechercher "lowercase"
  • Demander à installer le package "LowercaseRouteMVC"
  • Lire et accepter la licence

Modifier

  • Ouvrir le fichier "Global.asax"
  • Ajouter un using LowercaseRouteMVC; au bon endroit
  • Modifier la procédure "RegisterRoutes" pour remplacer route.MapRoute par route.MapRouteLowercase
using LowercaseRoutesMVC;
  ...
public static void RegisterRoutes(RouteCollection routes)
{
  routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
  
  routes.MapRouteLowercase(
    "Default", // Route name
    "{controller}/{action}/{id}", // URL with parameters
    new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
  );

Terminé

  • Lancer l'exécution par F5
  • Les URLs générées par l'application ne contiennent plus que des noms de contrôleurs et d'actions en minuscules :)

jeudi 29 septembre 2011

Configurer ELMAH pour envoyer les erreurs par email

Après avoir vu comment installer ELMAH avec NuGet, j'ai fait quelques tests avec pour voir ce que ça pouvait donner avec différents types d'erreurs.

Et quand je repense au peu d'effort que ça m'a demandé pour l'installer, le résultat est quand même assez bluffant. Au moins ça donne envie d'aller plus loin et d'étudier un peu mieux comment utiliser "réellement" ELMAH dans une application.

Un premier truc intéressant, c'est de s'occuper de paramétrer ELMAH afin de recevoir un email quand qu'il détecte un problème. De cette façon, je serai averti quasiment instantanément chaque fois que mon application provoquera une exception. Ca fait un peu peur quand même...

Concrètement, ELMAH est plutôt bien fait et il n'y a pas besoin de grand chose pour paramétrer l'envoi d'email. Mais ce qui est encore plus génial, c'est que l'essentiel du travail de paramétrage a déjà été fait lors de l'installation via NuGet. Pour mémoire, il avait ajouté les 3 lignes suivantes aux bons endroits dans le Web.config :

  • <section name="errorMail" requirePermission="false" type="Elmah.ErrorMailSectionHandler, Elmah" />
  • <add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" />
  • <add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" preCondition="managedHandler" />

Pour ma part, il ne me reste plus qu'à ajouter une section <elmah> avec des informations personnelles :

<configuration>
  ...
  <elmah>
    <errorMail
       from="go@gmail.com"
       to="michel@gmail.com"
       subject="Application Exception"
       async="true"
       smtpPort="587"
       useSsl="true"
       smtpServer="smtp.gmail.com"
       userName="go@mail.com"
       password="mot_de_passe">
    </errorMail>
  </elmah>

Note : étant donné que j'utilise GMail comme serveur SMTP, il est nécessaire d'activer SSL (d'où le useSsl="true") et de préciser qu'il faut utiliser le port 587 (quoique ?, voir mon billet System.Net.Mail et smtp.gmail.com).

Après ça, je n'ai plus qu'à relancer mon application et à provoquer quelques erreurs pour aussitôt voir ma boite mail se remplir. Heureusement que c'est pour du test...

Et pour être complet, il est sans doute préférable d'extraire le paramétrage du serveur SMTP de la configuration spécifique d'ELMAH et de le définir au niveau de la section prévue à cet effet dans le Web.config :

<configuration>
  ...
  <elmah>
    <errorMail
       from="go@gmail.com"
       to="michel@gmail.com"
       subject="Application Exception"
       async="true"
       smtpPort="0"
       useSsl="true">
    </errorMail>
  </elmah>
  ...
  <system.net>
    <mailSettings>
      <smtp deliveryMethod ="Network">
        <network host="smtp.gmail.com" 
                 port="587"
                 userName="go@gmail.com"
                 password="mot_de_passe" />
      </smtp>
    </mailSettings>
  </system.net>

Dans ce cas, il faut faire attention aux deux points suivants :

  • paramétrer le port "0" au niveau de la section <errorMail> pour que ce le port défini au niveau de la section <network> soit pris en compte,
  • l'activation du SSL se fait toujours dans la section <errorMail>.

mercredi 28 septembre 2011

Installer ELMAH avec NuGet

Jusqu'à présent, j'ai un peu tendance à utiliser NuGet uniquement pour mettre à jour Entity Framework, jQuery et consorts et dans mes très grands jours pour voir ce que donne un package de ci de là.

Après avoir terminé le chapitre de Professional ASP.NET MVC 3 consacré à NuGet, je me lance et j'en profite pour installer ELMAH. C'est l'exemple donné dans le livre et c'est un outil que j'avais déjà essayé d'utiliser du temps de mes WebForms puis de MVC 1.0 mais que j'avais chaque fois laissé tombé par manque de temps.

Je vais éviter la console pour l'instant et faire l'installation par un clic-droit sur la branche "Références" de mon projet puis en sélectionnant l'option "Manage NuGet Packages...".

elmah01.png

Note : cette option est également disponible quand on fait un clic droit sur la solution ou le nom du projet, mais dans ce cas elle plus difficile à repérer étant donné le plus grand nombre d'options proposées.

Il apparait alors la fenêtre "Manage NuGet Packages" qui liste par défaut tous les packages du monde en commençant par les plus téléchargés. Une fois là, le plus simple est d'utiliser la zone de recherche en haut à droite et d'y taper "elmah" pour n'afficher que les packages appropriés. Malgré tout, il reste encore une quinzaine de packages dans la liste !

elmah02.png

En cliquant sur un package, la colonne de droite est mise à jour et affiche tout un tas d'informations sur le package, avec entre autre sa version, sa description et ses dépendances. Comme rien n'est précisé dans le livre, j'installe le premier résultat obtenu (et donc le plus téléchargé), à savoir "ELMAH" (tout court) en cliquant sur le bouton "Install" qui apparait opportunément lorsque la souris le survole. Et c'est parti !

elmah03.png

Note : si j'avais accédé à la fenêtre "Manage NuGet Packages" par un clic-droit sur la solution, j'aurai eu droit à un préliminaire me demandant d'indiquer pour quels projet je souhaitais installer le package :

elmah04.png

Une fois que l'installation (très rapide) est terminée, la fenêtre "Manage NuGet Packages" est rafraichie et je peux constater par une coche verte que le package "ELMAH" est désormais installé, ainsi que le package "ELMAH Core Library (no config)" dont il dépend.

Je peux alors fermer NuGet en cliquant sur le bouton "Close" en bas à gauche et constater que la librairie "Elmah" est maintenant référencée dans mon projet.

elmah05.png

Et ce n'est pas tout ! Le fichier Web.config a été modifié pour incorporer tous les paramètres nécessaires au bon fonctionnement de Elmah

...
<configuration>
  <configSections>
    <sectionGroup name="elmah">
      <section name="security" requirePermission="false" type="Elmah.SecuritySectionHandler, Elmah" />
      <section name="errorLog" requirePermission="false" type="Elmah.ErrorLogSectionHandler, Elmah" />
      <section name="errorMail" requirePermission="false" type="Elmah.ErrorMailSectionHandler, Elmah" />
      <section name="errorFilter" requirePermission="false" type="Elmah.ErrorFilterSectionHandler, Elmah" />
    </sectionGroup>
...
    <httpModules>
      <add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah" />
      <add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" />
      <add name="ErrorFilter" type="Elmah.ErrorFilterModule, Elmah" />
    </httpModules>
    <httpHandlers>
      <add verb="POST,GET,HEAD" path="elmah.axd" type="Elmah.ErrorLogPageFactory, Elmah" />
    </httpHandlers>
  </system.web>
...
  <system.webServer>
    <modules runAllManagedModulesForAllRequests="true">
      <add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah" preCondition="managedHandler" />
      <add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" preCondition="managedHandler" />
      <add name="ErrorFilter" type="Elmah.ErrorFilterModule, Elmah" preCondition="managedHandler" />
    </modules>
    <handlers>
      <add name="Elmah" path="elmah.axd" verb="POST,GET,HEAD" type="Elmah.ErrorLogPageFactory, Elmah" preCondition="integratedMode" />
    </handlers>
  </system.webServer>

Je peux alors lancer l'application et me rendre à l'URL /elmah.axd pour constater que tout fonctionne sans encombre :

elmah06.png

Il ne me reste plus qu'à provoquer quelques erreurs :

  • accéder à un fichier /readme.txt inexistant
  • tester une route /Foo/Bar/1 qui n'existe pas

Puis retourner à l'adresse /elmah.axd pour m'assurer que ces deux erreurs ont bien été prises en compte :

elmah07.png

C'est déjà pas mal pour un début.

Il faut juste que je revois un peu la configuration pour que tout ça soit géré de façon plus pérenne et ne plus avoir "This log is provided by the In-Memory Error Log". Et aussi voir comment faire pour recevoir automatiquement un mél d'Elmah quand un problème survient.

vendredi 23 septembre 2011

Comic strip

Go !

Add Controller

Splatch !

Error

Wizzzz !

ContactsModel.cs

Humpf...

Unable to retrieve metadata for 'Go.Models.ContactsModel+Group'. The 
type 'Go.Models.ContactsModel+Contact' was not mapped. Check that 
the type has not been explicitly excluded by using the Ignore method 
or NotMappedAttribute data annotation. Verify that the type was 
defined as a class, is not primitive, nested or generic, and does 
not inherit from EntityObject.

(au moins Goggle me ramènera ici la prochaine fois que j'aurai cette erreur)

jeudi 16 juin 2011

Utiliser jQuery UI Datepicker avec ASP.NET MVC

Normalement, je n'aime pas trop proposer un calendrier pour permettre la saisie de dates dans un formulaire. Mais comme le widget Datepicker de jQuery UI n'est pas trop envahissant ou irritant, j'ai malgré tout décidé de lui laisser une chance. Et surtout, je vois ça comme un bon moyen d'aborder les templates Editor et Display de ASP.NET MVC et d'étudier un peu plus correctement comment les utiliser.

Un projet MVC juste pour tester

Pour commencer, j'ai échafaudé vite fait une application de gestion de livres à partir de la classe suivante :

public class Livre
{
    public int ID { get; set; }
    public string Titre { get; set; }
    public DateTime Edition { get; set; }
}

Pour faire vite, j'ai simplement aménagé l'exemple EF Code First and Data Scaffolding with the ASP.NET MVC 3 Tools Update de Scott Guthrie. Après avoir bricolé un lien vers l'action "Index" de mon nouveau contrôleur "Livres" dans le fichier "_Layout.cshtml", j'ai pu accéder à mon écran de création d'un nouveau livre :

jquery-datepicker-mvc-01.jpg

Deux templates DateTime.cshtml

J'ai alors pu passer à l'exemple Create user friendly date fields with ASP.NET MVC EditorTemplates & jQueryUI donné par Rachel Appel pour créer les deux templates "DateTime.cshtml" dans les sous-répertoires "DisplayTemplates" et "EditorTemplates".

\Views\Shared\DisplayTemplates\DateTime.cshtml

@model DateTime   
@String.Format("{0:d}", Model.Date)

\Views\Shared\EditorTemplates\DateTime.cshtml

@model DateTime
@Html.TextBox("", String.Format("{0:d}", Model.Date.ToShortDateString()))

Ce qui m'a permi d'avoir des dates "propres" (sans avoir l'heure à 00:00:00) que ce soit en affichage ou en saisie :

jquery-datepicker-mvc-02.jpg

L'avantage, c'est que maintenant je n'ai rien d'autre à faire pour que toutes les zones de dates existantes ou à venir dans mon application Livres bénéficient de la même présentation.

Ajout de jQuery UI Datepicker

Je continue pas à pas le tutoriel de Rachel Appel en référençant la librairie jQuery UI (et sa CSS) dans mon fichier "_Layout.cshtml" :

<link href="@Url.Content("~/Content/themes/base/jquery.ui.all.css")" rel="stylesheet" type="text/css" />
<script src="@Url.Content("~/Scripts/jquery-ui-1.8.11.min.js")" type="text/javascript"></script>

Puis je fais évoluer mon EditorTemplate pour ajouter une classe CSS afin que jQuery puisse identifier les zones de saisie de date :

@model DateTime
@Html.TextBox("", String.Format("{0:d}", Model.Date.ToShortDateString()), new { @class = "datefield" })

Et je n'ai plus qu'à utiliser la fonction jQuery ready pour indiquer que tous les éléments qui ont la classe CSS "datefield" doivent être complété d'un calendrier :

<script type="text/javascript">
$(function () {
    $(".datefield").datepicker();
});
</script>

Personnellement, j'ai placé ce script dans mon fichier "_Layout.cshtml" plutôt que dans le template "DateTime.cshtml" pour éviter qu'il soit répété (et donc ré-exécuté) autant de fois qu'il y a de date dans mon formulaire de saisie.

Et maintenant, quand je suis en saisie d'une date, j'ai le calendrier de jQuery UI qui apparait :

jquery-datepicker-mvc-03.jpg

Un calendrier en français

C'est pas mal, mais c'est tout en anglais :) Heureusement, il y a moyen d'avoir une version traduite en français très facilement. Il suffit de récupérer le fichier "jquery.ui.datepicker-fr.js" dans le référentiel Git de jQuery UI : https://github.com/jquery/jquery-ui/blob/master/ui/i18n/ puis de l'enregistrer dans le répertoire "Scripts" de la solution (et de penser à l'inclure dans le projet).

Il ne reste alors plus qu'à référencer ce script (après le script pour jQuery UI ?) dans le layout :

<script src="@Url.Content("~/Scripts/jquery.ui.datepicker-fr.js")" type="text/javascript"></script>

Et cerise sur le gâteau, le calendrier est maintenant en mesure de reconnaitre la date en cours et de s'y positionner correctement :

jquery-datepicker-mvc-04.jpg

Le cas des dates nullables

En creusant un peu sur différents exemples d'utilisation du Datepicker de jQuery UI avec ASP.NET MVC, je suis tombé sur des démos qui allaient un peu plus loin et qui prenaient en compte le cas où la date était nulle.

Dans ce cas là, il faut que les templates "Datetime.cshtml" héritent de l'objet DateTime? et plus de l'objet DateTime. Et donc modifier le code pour gérer le fait qu'on a à faire à un objet nullable, ce qui au final donne les templates suivants :

\Views\Shared\DisplayTemplates\DateTime.cshtml

@model System.DateTime?
@(Model.HasValue ? Model.Value.Date.ToShortDateString() : string.Empty)

\Views\Shared\EditorTemplates\DateTime.cshtml

@model System.DateTime?
@Html.TextBox("", Model.HasValue ? Model.Value.Date.ToShortDateString() : string.Empty, new { @class = "datefield" })

Pour tester que ça marchait, j'ai dû ajouter un seul "?" à ma classe "Livre" :

public class Livre
{
    public int ID { get; set; }
    public string Titre { get; set; }
    public DateTime? Edition { get; set; }
}

Et après ça j'ai dû supprimer ma base de données (le fichier App_Data\Livres.sdf dans mon cas) puis qu'elle avait changé. Chercher "Changing our Model and Database Schema" sur le billet VS 2010 SP1 and SQL CE de Scott Guthrie pour plus d'explications.

Note : j'ai vu des exemples qui initialisent une valeur par défaut lorsque la date est nulle (genre Model.HasValue ? Model.Value.Date.To...() : DateTime.Today.To...()). Mais selon moi, ce n'est pas quelque chose qui doit être décidé et accompli au niveau d'un template. Il est préférable de prévoir ce genre d'initialisation dans une classe ViewModel.

Moderniser le code HTML

Le déclenchement du calendrier est basé sur la présence de la classe CSS "datefield" (ce que fait $(".datefield").datepicker();). Mais c'est quasiment la préhistoire du Javascript non intrusif. Je pense qu'actuellement, il vaut bien mieux se baser sur l'attribut type qui est justement prévu pour définir une saisie de date en HTML5.

Et donc, plutôt que d'ajouter une classe CSS "datefield", le template Editor va directement ajouter un attribut type="date" :

@model System.DateTime?
@Html.TextBox("", Model.HasValue ? Model.Value.Date.ToShortDateString() : string.Empty, new { @type = "date" })

Il faut alors revoir la fonction jQuery ready pour que désormais elle prenne en compte les éléments ayant ce type :

<script type="text/javascript">
$(function () {
    $("input[type=date]").datepicker();
});
</script>

Et on va même plus loin en utilisant la librairie Modernizr promue par ASP.NET MVC pour appliquer le calendrier de jQuery UI uniquement lorsque le navigateur ne prend pas en charge la saisie des dates :

<script type="text/javascript">
$(function () {
    if (!Modernizr.inputtypes.date) {
        $("input[type=date]").datepicker();
    }
});
</script>

Conclusion

Finalement, c'est pas hyper compliqué de faire des templates. Et en y réfléchissant un peu mieux, je pense que c'est une solution qui devrait me plaire parce qu'elle est totalement "discrète" :

  • côté client, le calendrier est appliqué de façon non intrusive : le code de la balise input n'a pas été affublé d'un onclick pour lui attacher un calendrier.
  • côté serveur, le calendrier est généré de façon non intrusive : le formulaire a conservé @Html.EditorFor(model => model.Edition) sans qu'on ait à le défigurer avec un helper spécifique genre @Html.DateTimeFor(model => model.Edition).

mercredi 15 juin 2011

Télécharger des fichiers avec Sinatra

Ceci est la traduction du tutoriel "Uploading Files In Sinatra" de Darren Jones.

Il arrive fréquemment qu'on ait besoin de télécharger un fichier dans nos applications, que ce soit une image ou un fichier PDF. Dans ce tutoriel, je vais vous présenter le code nécessaire pour télécharger des fichiers avec votre application Sinatra. C'est une solution toute simple, mais ça fait ce qu'on lui demande.

L'application Sinatra

Pour commencer, nous avons besoin de créer une application Sinatra basique avec une page "upload" :

require 'rubygems'
require 'sinatra'

get '/upload' do
  haml :upload
end

__END__

@@upload
%h2 Upload
%form{:action=>"/upload",:method=>"post",:enctype=>"multipart/form-data"}
  %input{:type=>"file",:name=>"file"}
  %input{:type=>"submit",:value=>"Télécharger"}

(Remarque : je n'ai pas défini de layout pour cet exemple)

Cette page va afficher un formulaire tout simple qui va nous permettre de sélectionner un fichier sur notre PC. Le truc le plus important, c'est l'attribut "enctype" qui doit impérativement être initialisé à "multipart/form-data".

Nous devons ensuite gérer ce qui se passe lorsque le formulaire est validé en créant un autre handler (à placer juste avant la déclaration __END__) :

post '/upload' do
  unless params[:file] && (tmpfile = params[:file][:tempfile]) && (name = params[:file][:filename])
    return haml(:upload)
  end
  while blk = tmpfile.read(65536)
    File.open(File.join(Dir.pwd,"public/uploads", name), "wb") { |f| f.write(tmpfile.read) }
  end
 'terminé'
end

Etudions un peu ce code pour voir comment ça marche. Après avoir sélectionné un fichier et cliqué sur le bouton "Télécharger", un objet file est envoyé au serveur via le hash "params". Cet objet file contient une propriété "tempfile" qui correspond au fichier temporaire créé pendant le téléchargement du fichier. Il contient aussi une propriété "filemane" qui correspond au nom du fichier tel qu'il était défini sur votre système. La première ligne de code vérifie que l'on a bien sélectionné un fichier et utilise les 2 propriétés "tempfile" et "filename" pour initialiser deux variables du même nom. Dans le cas où aucun fichier n'a été sélectionné, le formulaire de téléchargement est simplement ré-affiché (ligne 2).

La boucle while est alors utilisé pour gérer le contenu du fichier au fur et à mesure de son chargement. On fait cela par bloc de 65536 octets qui selon moi devrait servir de tampon pour limiter l'utilisation de la RAM. Ces blocs sont enregistrés dans un fichier dans le répertoire public nommé "uploads". Il est nécessaire d'avoir au préalable créé vous-même ce sous-répertoire "uploads" dans le répertoire "public" de votre application. Le paramètre "wb" indique à Ruby qu'il s'agit d'un fichier binaire (le "b") et qu'il est en écriture seule (le "w"). Vous pouvez retrouver les différentes valeurs acceptées par de paramètre qui défini le mode du fichier au début de la documentation de la classe IO de Ruby. Et pour finir, une fois que le fichier a été entièrement chargé, on se contente pour l'instant d'afficher un simple message "terminé", mais il est bien évident qu'on pourrait faire mieux.

Pour avoir plus d'informations sur l'objet file en Ruby, je vous invite à consulter le chapitre qui est consacré à la classe File sur la version en ligne du Ruby Pickaxe Book.

Note du traducteur : plutôt que de copier "manuellement" le contenu du fichier, on aurait pu utiliser la méthode cp (ou copy) de la classe FileUtils :

  fullname = File.join(Dir.pwd, "public/uploads", name)
  FileUtils.cp(tmpfile.path, fullname)

Heroku

Le code présenté ci-dessus est tout à fait correct et fonctionnel, mais Heroku ne vous permet pas de stocker beaucoup de fichiers sur leurs serveurs (seuls les fichiers relatifs au code de votre application devraient s'y trouver). Par conséquent, vous êtes censés héberger vos fichiers ailleurs. La solution qui vient à l'esprit, c'est d'utiliser Amazon’s Simple Storage Service (S3). Cela vous permet de stocker vos fichiers sur les serveurs d'Amazon pour un coût extrêmement minime. Heroku étant également hébergé sur les serveurs Amazon, en y hébergeant vos propres fichiers vous devriez réduire au maximum les temps de latence pour récupérer vos fichiers.

Inscription à Amazon S3

La première chose à faire, c'est de vous enregistrer sur S3, ce que vous pouvez faire en vous rendant sur le site et en cliquant le bouton "Inscrivez-vous à Amazon S3". Si vous êtes déjà un client d'Amazon, vous pouvez ré-utiliser les informations de votre compte. Une fois que vous avez terminé le processus d'inscription, vous pouvez commencer à ajouter des fichiers à votre compte S3. Pour cela, Amazon propose une console web ou si vous préférez, vous pouvez employer l'extension S3Fox pour Firefox. Amazon S3 utilise un concept de compartiment (bucket) pour gérer vos fichiers. Et vous pouvez créer des dossiers à l'intérieur de chaque compartiment. Personnellement, j'ai l'habitude de définir un compartiment pour chacun de mes sites web.

Le code source Sinatra

Maintenant que vous disposez d'un compte Amazon S3 en état de marche, vous allez pouvoir y enregistrer vos fichiers en utilisant son interface web et en apportant quelques modifications à votre code Sinatra. Pour commence, il faut installer la librairie aws-s3:

C:\Ruby>gem install aws-s3

Lorsque vous vous êtes inscrit à Amazon S3, vous devez avoir reçu deux informations importante : un Access Key ID de 20 chiffres et une Secret Access Key. Vous poiuvez les obtenir en vous identifiant sur la page Amazon Web Services puis en cliquant sur l'onglet "Compte". Puis vous devez cliquer sur le lien "Identification de sécurité". Les informations dont vous avez besoin apparaissent dans une boite au milieu de la page (vous devrez cliquer pour faire apparaitre la clé secrète qui fait 40 caractères).

Une fois que vous avez récupéré ces informations, vous allez pouvoir la commande set de Sinatra pour les enregistrer. Et nous allons en profiter pour ajouter une instruction require afin que la librairie aws-s3 soit inclue dans notre code source :

require 'rubygems'
require 'sinatra'
require 'aws/s3'

set :bucket, 'mybucket'
set :s3_key, THISISANEXAMPLEKEYID
set :s3_secret, Thi$isJu5taNExamp/etO0itSh0u1dBel0NgeR

Bien entendu, pour un site en production il faut définir ces variables en passant par des paramètres d'environnement de Heroku pour que vos clés restent secrètes. Vous pouvez vous reporter au billet Configuration et paramétrage avec Sinatra pour avoir plus d'explications à ce sujet.

Nous devons ensuite nous occuper de nos handlers. Le premier handler pour le GET reste tel quel alors que le second pour le POST doit être légèrement modifié de façon à utiliser la librairie aws-s3 pour établir la connexio avec Amazon S3 et enregistrer le fichier. Vous pouvez voir que nous utilisons pour cela les variables définies auparavant et qu'à part cela, le code Sinatra final reste tel qu'il était :

get '/upload' do
  haml :upload
end

post '/upload' do
  unless params[:file] && (tmpfile = params[:file][:tempfile]) && (name = params[:file][:filename])
    return haml(:upload)
  end
  while blk = tmpfile.read(65536)
    AWS::S3::Base.establish_connection!(
      :access_key_id     => settings.s3_key,
      :secret_access_key => settings.s3_secret)
    AWS::S3::S3Object.store(name,open(tmpfile),settings.bucket,:access => :public_read)     
  end
 'terminé'
end

__END__

@@upload
%h2 Upload
%form{:action=>"/upload",:method=>"post",:enctype=>"multipart/form-data"}
  %input{:type=>"file",:name=>"file"}
  %input{:type=>"submit",:value=>"Télécharger"}

Et c'est tout ! Tout cela devrait amplement vous suffire pour gérer les bases du téléchargement de fichiers avec Sinatra. Il est bien évident que cela peut être amélioré, notamment dans les domaines suivants :

  • Un formulaire un peu plus sexy
  • Un meilleur retour à la fin du téléchargement (y compris dans le cas où aucun fichier n'a été sélectionné)
  • Une barre de progression au fur et à mesure du téléchargement du fichier
  • La possibilité de télécharger dans différents dossiers
  • Un contrôle du type de fichier téléchargé
  • Un traitement des images téléchargées (créer une vignette automatiquement par exemple), bien qu'il s'agisse là d'un sujet qui mérite un billet à lui tout seul,
  • La possibilité de pouvoir télécharger plusieurs fichiers à la fois

mercredi 8 juin 2011

Utiliser Rack Flash avec Sinatra

Ceci est la traduction du tutoriel Sinatra "Rack Flash" de Darren Jones.

The Flash

Flash est un tableau hash bien pratique que l'on trouve dans Rails. Il ne s'agit pas du super héros qui se déplace à la vitesse de l'éclair mais d'une astuce pour stocker temporairement des informations entre deux requêtes HTTP. Si par exemple votre application enregistre une nouvelle fiche puis redirige l'utilisateur vers une autre page, vous pouvez utiliser cette mémoire flash pour stocker un message indiquant que la fiche a bien été enregistrée. Ce message sera ensuite affiché sur la nouvelle page (après le redirect).

On peut avoir la même fonctionnalité avec Sinatra en s'appuyant sur Rack Flash.

Tout ce qu'il y a à faire, c'est d'installer le gem :

C:\Ruby>gem install rack-flash

Puis ajouter les quelques lignes suivantes dans notre application Sinatra :

require 'rack-flash'
use Rack::Flash
enable :sessions

(Rack Flash a besoin des sessions pour stocker le tableau hash et par défaut elles sont désactivées dans Sinatra)

Pour utiliser le hash "flash", il suffit par exemple de définir le message dans le handler de départ :

post '/notes/save' do
  flash[:notice] = "Votre fiche a été enregistrée"
  redirect '/'
end

Puis il faut afficher ce message dans la vue correspondant au handler pour l'URL "/" :

<div id='flash' class='notice'>
  <%= flash[:notice] %>
</div>

Et maintenant, quand quelqu'un enregistre une note, il verra le message "Votre fiche a été enregistrée" après avoir été redirigé sur la page index "/".

Vous pouvez associer une clé au message flash pour faire la distinction entre différents types de message, comme par exemple :

flash[:notice]
flash[:warning]
flash[:error]

Si vous souhaitez afficher le flash dans la requête en cours, vous devez utiliser flash.now :

get '/' do flash.now:notice = "Pas de message" unless flash:notice end

Cela affichera un message indiquant qu'il n'y a pas de message stocké dans le hash flash par la requête précédente.

Les messages restent dans le tableau hash tant qu'ils ne sont pas affichés, comme vous pouvez le voir dans le code ci-dessous :

get '/un' do
  flash[:notice] = "Coucou !"
  flash[:error] = "erreur de type 1"
  flash[:warning] =  "Attention au chien"
  redirect '/deux' 
end

get '/deux' do
  flash[:error] = "erreur de type 2"
  redirect '/trois' 
end

get '/trois' do
  flash[:notice] = "Salut !"
  redirect '/' 
end

get '/' do
  erb :index
end

Lorsque nous atteindrons enfin l'URL "/" et que la vue "index" sera affichée, on aura flash[:error] = "erreur de type 2", flash[:notice] = "Salut !" et flash[:warning] sera toujours "Attention au chien".

Si vous ne voulez pas que les messages s'éternisent dans le hash flash jusqu'à ce qu'ils soient utilisé, vous pouvez vous en débarrasser en utilisant l'option "sweep" :

use Rack::Flash, :sweep => true

Grace à elle, le hash flash est nettoyé après chaque requête, que les messages aient été affichés ou non. Dans l'exemple précédent, le seul élément qui restera dans le hash sera flash[:notice] = "Salut !".

Et pour finir, un bout de code Erb prêt à insérer dans votre layout pour afficher un message flash lorsqu'il en existe, en définissant une classe CSS qui dépend du type de message :

<% flash.each do |key,msg| %>
  <div id='flash' class='<%= key %>'>
    <%= msg %>
  </div>
<% end %>

Ou si vous préférez le Haml :

- flash.each do |key,msg|
  #message{:class => key}= msg

Cela a pour effet de créer une balise <div> pour chaque message stocké dans le hash flash, en utilisant sa clé comme nom de classe CSS.

En espérant que ça puisse vous servir : la plupart des applications utilise cette technique, alors n'hésitez pas à l'essayer dans vos applications Sinatra.

mardi 17 mai 2011

Authentification super simple avec Sinatra

Ceci est la traduction du tutoriel "Really Simple Authentication in Sinatra" de Darren Jones.

Une demande répétée des lecteurs de I Dit It My Way est un billet sur l'authentification. Il existe des tas de gems qui permettent de faire ça, mais je préfère programmer ça par moi-même. Voici donc une méthode vraiment simple pour construire le côté administration d'un site web avec Sinatra.

Pour démarrer, il faut créer quelques paramètres :

set :username,'michel'
set :password,'topsecret'
set :token,'1truklong&complike@$e$ouven!r'

Puis les handlers pour les différentes routes. En commençant par l'URL "/admin" pour pouvoir se connecter.

get('/admin') { haml :admin }

Ca ne prend qu'une ligne parce que tout ce qu'il y a à faire c'est d'afficher la vue "admin.haml" :

%form(action="/login" method="post")
  %label(for="username")Code utilisateur :
  %input#username(type="text" name="username")
  %label(for="password")Mot de passe :
  %input#password(type="password" name="password")
  %input(type="submit" value="Connexion") or <a href="/">Annuler</a>

C'est un formulaire tout simple qui contient deux zones de texte pour saisir le code utilisateur et le mot de passe plus un bouton pour valider. Ce formulaire est renvoyé à l'aide d'une requête POST vers l'URL "/login" et nous devons donc ajouter un handler pour la traiter. C'est à ce niveau que nous allons accomplir le plus gros du travail pour authentifier l'utilisateur.

post '/login' do
  if params['username'] == settings.username && params['password'] == settings.password
      response.set_cookie(settings.username,settings.token) 
      redirect '/'
    else
      "Code utilisateur ou mot de passe incorrect"
    end
end

Ce code vérifie tout d'abord si le code utilisateur et le mot de passe saisi via le formulaire (stockés dans le hash params) correspondent à ceux définis dans le paramétrage (les valeurs settings.xxxxx). Si c'est le cas, on crée un cookie en utilisant le code utilisateur comme clé et pour la valeur le "token" qu'on avait configuré au tout début. On pourrait se contenter d'utiliser "true" pour la valeur, mais le fait d'utiliser un token renforce le niveau de sécurité (si quelqu'un sait expliquer comment ?). Lorsque l'utilisateur n'a pas donné le bon code utilisateur ou le bon mot de passe, on renvoie simplement un message d'avertissement.

Et maintenant, tout ce qu'il nous reste à faire c'est de donner la possibilité de se déconnecter. Ce que nous allons faire en gérant l'URL "/logout".

get('/logout') { response.set_cookie(settings.username, false) ; redirect '/' }

Là on modifie le cookie correspondant à l'utilisateur en lui donnant la valeur "false" puis on le renvoie à la racine du site.

On a donc géré tout ce qui concerne l'authentification d'un utilisateur. Il nous reste à traiter la partie autorisation pour réellement autoriser ou interdire l'accès à certaines pages. Pour cela, on va simplement ajouter deux fonctions helper à notre code Ruby :

helpers do
  def admin? ; request.cookies[settings.username] == settings.token ; end
  def protected! ; halt [ 401, 'Not Authorized' ] unless admin? ; end
end

Le premier helper, nommé "admin?", va nous servir pour vérifier si l'utilisateur est connecté ou non, en contrôlant que la valeur du cookie correspond à la valeur configurée pour le token. On pourra l'utiliser cet helper dans le code des handlers ou des vues pour par exemple afficher un message différent selon que l'utilisateur est connecté ou non.

Le second helper, nommé "protected!", utilise le premier helper pour vérifier si l'utilisateur est connecté et si ce n'est pas le cas il interrompt le code te renvoie une erreur HTTP 401 pour indiquer que l'action n'est pas autorisée. On l'utilisera au début du handler d'une route pour indiquer que l'utilisateur doit être connecté pour visualiser ce contenu.

Et pour finir le code source complet d'une application qui regroupe tout le code présenté ci-dessus. Libre à vous de à l'utiliser et de faire vos propres essais avec.

require 'rubygems'
require 'sinatra'

set :username,'Bond'
set :password,'007'
set :token,'osh@kerp@$@l@cuill3re'

helpers do
  def admin? ; request.cookies[settings.username] == settings.token ; end
  def protected! ; halt [ 401, 'Not Authorized' ] unless admin? ; end
end

get '/' do
  haml :index
end

get('/admin') { haml :admin }

post '/login' do
  if params['username'] == settings.username && params['password'] == settings.password
    response.set_cookie(settings.username,settings.token) 
    redirect '/'
  else
      "Code utilisateur ou mot de passe incorrect"
  end
end

get('/logout') { response.set_cookie(settings.username, false) ; redirect '/' }

get '/public' do
  'Tout le monde peut voir ça'
end

get '/private' do
  protected!
  'Rien que pour vos yeux !'
end

__END__
@@layout
!!! 5
%html
  %head
    %meta(charset="utf-8")
    %title Authentication Super Simple
  %body
    %a(href='/admin')Connexion
    %a(href='/logout')Déconnexion
    %a(href='/public')Public
    %a(href='/private')Privé
    = yield
@@admin
%form(action="/login" method="post")
  %label(for="username")Code utilisateur :
  %input#username(type="text" name="username")
  %label(for="password")Mot de passe :
  %input#password(type="password" name="password")
  %input(type="submit" value="Connexion") or <a href="/">Cancel</a>
@@index
-if admin?
  %h1 Bienvenue 007 !
-else
  %h1 Bienvenue !

C'est un exemple simple et sans prétention si vous souhaitez expérimenter un système d'authentification et d'autorisations sous Sinatra. Et si vous avez des idées pour le faire évoluer ou l'améliorer, n'hésitez pas à en faire part dans les commentaires ci-dessous.

mercredi 4 mai 2011

Erreurs 404 et autres avec Sinatra

Ceci est la traduction du tutoriel "404 and other Errors in Sinatra" de Darren Jones.

Un truc super bien quand on débute avec Sinatra c'est sa page d'erreur :

Erreur 404

Les applications web ont toutes besoin d'afficher des pages d'erreurs et c'est quelque chose de très simple à faire avec Sinatra.

Les erreurs 404

Elles se produisent lorsque l'URL appelée ne peut pas être trouvée. On peut gérer cette erreur à l'aide du code Ruby suivant :

not_found do
  "Votre page n'a pas pu être trouvée"
end

Cela affichera tout simplement le message "Votre page n'a pas pu être trouvée". On peut tout de même avoir quelque chose d'un peu plus sophistiqué et employer une vue "not_found.erb" qui aura l'avantage d'être rendue avec le "layout.erb" habituel.

not_found do
  erb :not_found
end

On peut même faire tenir ça sur une seule ligne :

not_found { erb :not_found }

Si vous préférez appeler votre fichier "404.erb" au lieu de "not_found.erb", vous devrez simplement le mettre entre apostrophe étant donné que les symboles ne peuvent pas commencer par un chiffre :

not_found do
  erb :'404'
end

Les erreurs 500

Ces erreurs surviennent en cas d'erreur interne du serveur, généralement lorsque quelque chose a planté dans l'application. Le code suivant sert pour gérer ce genre d'erreur :

error do
  @error = request.env['sinatra_error']
  haml :'500'
end

Ce code initialise une variable d'instance nommée @error qu'il sera possible d'utiliser dans la vue pour détailler l'erreur à l'aide des méthodes @error.name et @error.message.

Il est aussi possible de créer des erreurs personnalisées en utilisant le code suivant :

error BigError do
   "BOUM ! Il y a eu un gros souci ! " + request.env['sinatra.error'].message
end

On peut alors renvoyer cette erreur avec un message adéquat :

get '/' do
  raise BigError, 'Est-ce que vous aviez convenablement fermé toutes vos accolades ?'
end

L'accès à l'URL "/" provoquera alors l'affichage du message suivant :

BOUM ! Il y a eu un gros souci ! Est-ce que vous aviez convenablement fermé toutes vos accolades ?

Selon moi, il est important de gérer les erreurs dès le tout début du projet. Pour cela, je colle généralement les deux lignes suivantes au début de mon fichier :

not_found { haml :'404' }
error { @error = request.env['sinatra_error'] ; haml :'500' }

mardi 3 mai 2011

Configuration et paramétrage avec Sinatra

Ceci est la traduction du tutoriel "Sinatra Settings and Configuration" de Darren Jones.

Définir des paramètres

Pour définir des variables session d'application qui seront accessible partout dans une application, on peut utiliser la commande set en respectant la syntaxe suivante :

set :name, 'MonSite'
set :author, 'Michel'

Ces variables sont ensuite utilisables dans notre code Ruby sous la forme settings.name et settings.author :

get '/hello' do
  "Cette page a été rédigée par " + settings.author
end

Puis en se connectant à l'URL "/hello" on obtient alors :

Cette page a été rédigée par Michel.

On peut également utiliser la commande set pour modifier les paramètres de certains gems, comme par exemple :

set :haml, { :format => :html5 }

Grâce à quoi Haml génèrera du code HTML5 avec le doctype correct, pas de barre fermante pour la balise image, etc...

Il est même possible de modifier les paramètres par défaut de Sinatra. C'est ce qui permet par exemple de redéfinir le répertoire "public" :

set :public, Proc.new { root }

De cette façon, le répertoire "public" sera identique au répertoire racine du site.

Et pour modifier le répertoire où sont enregistrées les vues :

set :views, Proc.new { File.join(root, "templates") }

Après cela, Sinatra recherchera dans le dossier "templates" pour tous les fichiers de vues de notre application (en lieu et place du répertoire "views" utilisé par défaut).

Quand le paramètre correspond à un booléen, il est possible d'utiliser les commandes enable et disable plutôt que la commande set :

enable :sessions
disable :twitter

Ce qui est exactement identique à :

set :sessions, true
set :twitter, false

La documentation officielle de Sinatra fourni d'autres explications et des exemple, entre autre la liste des paramètres internes de Sinatra.

Les blocs de configuration

Sinatra permet de regrouper un ensemble de paramètres dans un bloc de configuration :

configure do
  set :name, 'michel'
  set :haml, { :format => :html5 }
end

Cela va initialiser tous les paramètres ainsi que tout ce qui est nécessaire au démarrage.

Et cerise sur le gâteau, on peut définir un bloc de configuration pour chaque environnement :

configure :development do
  set :db, File.join("sqlite3://",settings.root, "development.db"
end

configure :test do
  set :db, File.join("sqlite3://",settings.root, "test.db"
end

configure :production do
  File.join("sqlite3://",settings.root, "production.db"
  set :sass, { :style => :compressed }
end

Comme vous pouvez le voir, j'ai configuré Sass pour que sa sortie soit compressée en production et que le fichier généré soit le plus petit possible. Il existe tout un tas d'autres options pour Sass et Haml qui peuvent être initialisées de cette façon.

Gérer des paramètres d'environnement sur Heroku

Pour finir, on a la possibilité de définir des variables d'environnement directement sur Heroku, ce qui nous évite d'avoir à rendre la valeur de certains paramètres publics. Supposons que nous ayons le code ci-dessous :

set :password, 't0psecret'

Le hic c'est que si on compte partager notre code, les autres personnes auront accès à notre mot de passe top secret ! La solution dans ce cas là c'est de définir une variable d'environnement appelée "PASSWORD", chose de très simple à faire depuis une console bash. Il suffit de se placer dans le répertoire de l'application et d'entrer le code ci-dessous :

$ heroku config:add PASSWORD=t0psecret

Ce qui doit renvoyer le message suivant :

Adding config vars:
  PASSWORD    => t0psecret
Restarting app...done.

Il faut utiliser la commande config seule pour lister toutes les variables d'environnement existantes sur Heroku :

$ heroku config

Comme on peut le voir suite à cette commande, Heroku défini déjà par lui-même un certain nombre de variables d'environnement de façon automatique dans le but de nous faciliter la vie :

PASSWORD => t0psecret
DATABASE_URL => postgres://ibzju...s.com/ibzjubamts
RACK_ENV     => production
URL          => http://bloggl.heroku.com

Si on a besoin de se débarrasser de toutes les variables de configuration, on peut employer la commande suivante :

$ heroku config:clear

Ce que Heroku confirmera avant de redémarrer l'application :

Clearing all config vars and restarting app...done.

Après avoir créé nos variables d'environnement sur Heroku, il ne nous reste plus qu'à modifier notre code source pour en tirer parti :

set :password, ENV['PASSWORD'] || 'secret'

Ce code va utiliser la variable d'environnement enregistrée dans ENV['PASSWORD'] si elle est définie ou sinon se contenter du simple mot de passe "secret" défini en dur dans le code. Grâce à cette méthode vous ne risquez pas de divulguer quoique ce soit d'important ou de personnel dans votre code source Ruby.

Pour être complet, le Dev Center d'Heroku présente d'autres informations concernant l'utilisation des variables de configuration sur Heroku.

vendredi 29 avril 2011

Un formulaire de contact avec Sinatra

Ceci est la traduction du tutoriel "An Email Contact Form in Sinatra" de Darren Jones.

Dans cet épisode, je vais vous montrer les étapes nécessaires pour créer une page avec un formulaire de contact qui vous enverra un email. Cet exemple peut facilement être adapté pour envoyer tout autre type d'email à partir d'une interface web. Dans ce tutoriel, je vais utiliser mon compte GMail pour réaliser l'envoi du mail.

Nous utiliserons également la librairie Pony pour envoyer les mails et il faut donc commencer par installer le gem Pony :

C:\Ruby>gem install pony

Pour démarrer, nous allons nous occuper du formulaire de contact. Celui-ci sera disponible à l'URL "/contact" grâce au gestionnaire suivant :

get '/contact' do
  erb :contact
end

Puis nous enregistrons la vue "contact.erb" dans le répertoire "views" :

<p>Vous pouvez utiliser le formulaire ci-dessous pour nous contacter :</p>
<form action="/contact" method="post">
  <label for="name">Votre nom :</label>
  <input type="text" name="name">
  <label for="email">Votre adresse mél :</label>
  <input type="text" name="email">
  <label for="to">Votre message :</label>
  <textarea name="message" rows="16" cols="28">
  <input type="submit" value="Envoyer">
</form>

Nous devons maintenant gérer les données du formulaire lorsqu'il est validé. Pour cela, nous allons créer un gestionnaire de type POST pour la même URL "/contact" (celle que nous avons indiquée dans l'attribut action du formulaire web) :

post '/contact' do 
    require 'pony'
    Pony.mail(
      :from => params[:name] + "<" + params[:email] + ">",
      :to => 'adresseperso@gmail.com',
      :subject => "Vous avez un message de " + params[:name],
      :body => params[:message],
      :port => '587',
      :via => :smtp,
      :via_options => { 
        :address              => 'smtp.gmail.com', 
        :port                 => '587', 
        :enable_starttls_auto => true, 
        :user_name            => 'adresseperso', 
        :password             => 'p@55w0rd', 
        :authentication       => :plain, 
        :domain               => 'localhost.localdomain'
      })
    redirect '/success' 
end

Note : en local sur mon PC j'ai dû utiliser le port 25 au lieu du port 587 pour que l'envoi de mail marche avec le SMTP de GMail.

Grosso modo, nous nous contentons de remplir les informations attendues par Pony et d'envoyer le mail en utilisant notre compte GMail personnel (par conséquent pensez à employer vos propres identifiants !). Si cela fonctionne correctement, on doit recevoir un email avec le sujet "Vous avez un message de Daz". Al la fin du code, l'utilisateur est redirigé vers l'URL "/success" que nous prenons en charge de la façon suivante :

get('/success') {"Merci pour votre message. Nous vous contacterons bientôt."}

C'est clair qu'il faudrait développez un peu plus pour une vrai application, mais vous voyez l'idée.

Heroku

Heroku vous permet d'utiliser l'option Sendgrid pour envoyer vos emails. Vous pouvez vous inscrire pour bénéficier d'un compte gratuit (limité à 200 méls par jour) à l'aide de la commande suivante (à lancer via une console Git bash) :

$ heroku addons:add sendgrid:free

Pour utiliser Sendgrid, vous devez modifier le paramétrage de Pony de la façon suivante :

post '/contact' do 
    require 'pony'
     Pony.mail(
      :from => params[:name] + "<" + params[:email] + ">",
      :to => 'adresseperso@gmail.com',
      :subject => "Vous avez un message de " + params[:name],
      :body => params[:message],
      :port => '587',
      :via => :smtp,
      :via_options => { 
        :address              => 'smtp.sendgrid.net', 
        :port                 => '587', 
        :enable_starttls_auto => true, 
        :user_name            => ENV['SENDGRID_USERNAME'], 
        :password             => ENV['SENDGRID_PASSWORD'], 
        :authentication       => :plain, 
        :domain               => ENV['SENDGRID_DOMAIN']
      })
    redirect '/success' 
end

ENV['SENDGRID_USERNAME], ENV['SENDGRID_PASSWORD'] et ENV['SENDGRID_DOMAIN'] sont trois variables d'environnement de Heroku qui sont définies automatiquement lorsque vous installez l'option Sendgrid.

En pratique, il serait plus judicieux de définir ces informations séparément, en utilisant la commande "set" de Sinatra :

set :email_username, ENV['SENDGRID_USERNAME] || 'adresseperso'
set :email_password, ENV['SENDGRID_PASSWORD'] || 'p@55w0rd'
set :email_address, 'daz@gmail.com'
set :email_service, ENV['EMAIL_SERVICE'] || 'gmail.com'
set :email_domain, ENV['SENDGRID_DOMAIN'] || 'localhost.localdomain'

La variable d'environnement ENV['EMAIL_SERVICE'] n'étant pas définie automatiquement, vous devez penser à l'initialiser vous-même avec la commande suivante :

$ heroku config:add EMAIL_SERVICE=sendgrid.net

Et après cela, vous pouvez mettre à jour votre code pour envoyer un email en utilisant Pony :

post '/contact' do 
    require 'pony'
     Pony.mail(
      :from => params[:name] + "<" + params[:email] + ">",
      :to => settings.email_address,
      :subject => "Vous avez un message de " + params[:name],
      :body => params[:message],
      :port => '587',
      :via => :smtp,
      :via_options => { 
        :address              => 'smtp.' + settings.email_service, 
        :port                 => '587', 
        :enable_starttls_auto => true, 
        :user_name            => settings.email_username, 
        :password             => settings.email_password, 
        :authentication       => :plain, 
        :domain               => settings.email_domain
      })
    redirect '/success' 
end

Cette méthode vous permet d'utiliser votre formulaire de contact en local (en utilisant votre adresse GMail perso) ou depuis Heroku (en passant par Sendgrid).

jeudi 28 avril 2011

Changer de fenêtre sous VS 2010

Suite au visionnage de la présentation @:The Time is Now de Phil Haack au MIX11, j'ai farfouillé dans Visual Studio 2010 et fureté un peu sur l'internet afin de découvrir comment il s'y prenait pour afficher une liste des fichiers ouverts.

Et j'ai trouvé ! Il faut faire Ctrl+Tab :

CtrlTabVs2010.png

mercredi 27 avril 2011

Jouer avec le feu

It’s rather disturbing how many problems are caused by ORMs, and how developers will incessantly focus on data access rather than the business problems they’re supposed to solve.

Select N+1 Is a Problem We Made For Ourselves

vendredi 8 avril 2011

Gérer les virgules avec les Data Annotations

Après avoir "réparé" mon Visual Studio 2010, j'ai pu me remettre à la version 2 du tutoriel MVC Music Store pour me confronter aux dernières (pour moi) technologies ASP.NET MVC 3 et entre autre Razor et les Data Annotations.

Et justement, j'ai été un peu embarrassé par le fonctionnement de la validation via les Data Annotations dès lors qu'on n'est pas des yankees pure souche.

Par exemple, si je veux passer le prix d'un album de 8,99 à 8,90 j'obtiens l'erreur "The field Prix must be a number." de la part du plugin jQuery Validation.

Et si j'essaie de contourner en saisissant 8.90 (avec un point au lieu de la virgule), c'est l'erreur "The value '8.90' is not valid for Prix." qui prend le relai. Mais dans ce cas, cette erreur n'est pas renvoyée par jQuery Validation mais par la méthode TryUpdateModel() dans le contrôleur : mon PC étant en français, le .NET exige une virgule comme séparateur décimal.

Zut ! Déjà les messages en anglais c'est pas tip-top. Mais que ça m'affiche des valeurs numériques avec des virgules et que ça me gueule dessus quand j'essaie de saisir c'est un peu pénible quand même.

Jusqu'à présent, plutôt que de chercher à gérer le problème virgule, je me contentais de bidouiller la section «globalization» dans le fichier web.config pour que ASP.NET prenne lui aussi le "." comme séparateur décimal :

<configuration>
   <system.web>
      <globalization culture="en-US" />
   </system.web>
</configuration>

Mais cette fois-ci, je me suis dit que j'allai creuser un peu plus sinon ça enlève pas mal d'intérêt aux Data Annotations.

Localiser les messages du plugin jQuery Validation

Déjà, quand on fait des recherches sur la localisation de jQuery Validation, on se rend compte que c'est un problème général qui semble avoir été un peu laissé de côté...

Par contre, si on regarde dans le repository du plugin, il existe un répertoire localization qui contient un fichier messages_fr.js avec les messages d'erreur en français.

Super ! Mais ça ne sert pas à grand chose parce que les messages d'erreurs sont déjà initialisés directement par ASP.NET MVC :(

<input data-val="true" 
       data-val-number="The field Prix must be a number."
       data-val-range="Le champ Prix doit &amp;#234;tre compris entre 0,01 et 100."
       data-val-range-max="100" 
       data-val-range-min="0.01" 
       data-val-required="Le champ Prix est requis." 
       id="Price" name="Price" type="text" value="8,99" />

C'est malin ça de définir les messages d'erreur alors que le plugin jQuery Validation les initialise déjà de son côté. Et c'est encore plus rigolo d'en mettre certains en français et d'autres en anglais :)

Localiser "The field Xxxxx must be a number."

Première méthode pour traduire ce message : supprimer l'attribut "data-val-number" de toutes les zones de saisie et inclure le fichier messages_fr.js (attention, l'ordre des scripts est important) :

<script type="text/javascript">
    $(document).ready(function () {
        $(":input[data-val-number]").attr("data-val-number", "");
    });
</script>

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>

<script type="text/javascript">

    /* ----- messages_fr.js ----- */
    /*
    * Translated default messages for the jQuery validation plugin.
    * Locale: FR
    */
    jQuery.extend(jQuery.validator.messages, {
        required: "Ce champ est requis.",
        remote: "Veuillez remplir ce champ pour continuer.",
        email: "Veuillez entrer une adresse email valide.",
        url: "Veuillez entrer une URL valide.",
        date: "Veuillez entrer une date valide.",
        dateISO: "Veuillez entrer une date valide (ISO).",
        number: "Veuillez entrer un nombre valide.",
...

Après ça, la saisie d'une valeur incorrecte dans la zone prix n'affiche plus l'erreur "The field Prix must be a number." mais "Veuillez entrer un nombre valide.".

Deuxième méthode : si on veut continuer à indiquer le nom du champ qui pose problème dans le message d'erreur, il faut sortir l'artillerie lourde et utiliser les expressions régulières (l'ordre des scripts est toujours important) :

<script type="text/javascript">
    $(document).ready(function () {
        var reg_us = /The field (.+) must be a number\./gi;
        var msg_fr = "Le champ $1 doit être un nombre.";
        $(":input[data-val-number]").each(function () {
            var message = $(this).attr("data-val-number");
            message = message.replace(reg_us, msg_fr);
            $(this).attr("data-val-number", message);
        });
    });
</script>

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>

<script type="text/javascript">

    /* ----- messages_fr.js ----- */
    /*
    * Translated default messages for the jQuery validation plugin.
    * Locale: FR
    */
    jQuery.extend(jQuery.validator.messages, {
        required: "Ce champ est requis.",
...

Dans ce cas, la saisie d'une valeur incorrecte dans le prix affiche le message "Le champ Prix doit être un nombre.".

Faire accepter les nombres à virgule à jQuery Validation

C'est bien beau de parler à l'utilisateur en français, mais c'est quand même plus important de lui permettre de pouvoir saisir le prix qu'il veut sans avoir à abandonner les valeurs décimales.

Et là, le plugin jQuery Validation a tout prévu puisque le répertoire localisation contient également des fichiers methods_de.js, methods_nl.js et methods_pt.js. Mais malheureusement pour moi, pas de methods_fr.js en vue :(

En y regardant de plus près, le fichier methods_de.js devrait faire l'affaire. Ce qui se confirme sur le forum developpez.com.

Ni une, ni deux, il suffit de compléter les scripts de la façon suivante (l'ordre des scripts est important) :

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>

<script type="text/javascript">
jQuery.extend(jQuery.validator.methods, {
    date: function(value, element) {
        return this.optional(element) || /^\d\d?\.\d\d?\.\d\d\d?\d?$/.test(value);
    },
    number: function(value, element) {
        return this.optional(element) || /^-?(?:\d+|\d{1,3}(?:\.\d{3})+)(?:,\d+)?$/.test(value);
    }
});
</script>

Et maintenant si je change mon prix en 8,90, je n'ai plus le message "Le champ Prix doit être un nombre.". Non. Maintenant ça me dit que "Le champ Prix doit être compris entre 0,01 et 100.".

C'est des coriaces !

Gérer les virgules dans jQuery Validation

Si c'est ça, je regarde dans le source de jquery.validate.js. Ah ben bien sûr, y'a un problème avec le "range" :

range: function( value, element, param ) {
    return this.optional(element) || ( value >= param[0] && value <= param[1] );
},

Il compare value (le 8,90 que j'ai saisi) avec param[0] (la valeur minimum de 0.01) et param[1] (la valeur maximum de 100). Personne lui a jamais dit qu'on ne peut pas comparer les points et les virgules ?

Et le pire, c'est qu'il fait pareil avec les fonctions "min" et max" le bougre ! Ca va pas se passer comme ça :

/* ----- methods_fr.js ----- */
/*
* Localized default methods for the jQuery validation plugin.
* Locale: FR
*/
jQuery.extend(jQuery.validator.methods, {
    min: function (value, element, param) {
        return this.optional(element) || replaceComma(value) >= replaceComma(param);
    },
    max: function (value, element, param) {
        return this.optional(element) || replaceComma(value) <= replaceComma(param);
    },
    range: function (value, element, param) {
        value = replaceComma(value);
        return this.optional(element) || (value >= replaceComma(param[0]) && value <= replaceComma(param[1]));
    },
    date: function (value, element) {
        return this.optional(element) || /^\d\d?\.\d\d?\.\d\d\d?\d?$/.test(value);
    },
    number: function (value, element) {
        return this.optional(element) || /^-?(?:\d+|\d{1,3}(?:\.\d{3})+)(?:,\d+)?$/.test(value);
    }
});

function replaceComma(value) {
    // Quick & Dirty replace "," by "." as decimal separators
    return value.replace(",", ".");
}

Et après ça, 8,90 passe enfin alors que 100,01 est bien refusé !

Ouf ! Ca devrait faire l'affaire jusqu'à la sortie de jQuery Validation 2.0.

Mise à jour : si j'avais cherché mieux, j'aurais pu trouver le billet Using MVC 3 with non-English Locales de Rick Anderson, le co-auteur du tutoriel Getting Started With MVC3.

mercredi 30 mars 2011

VS 2010, SQL Server 2008 Express et Windows 7 64 bits

J'ai rechuté. Après mes problèmes d'incompatibilités entre VS 2008, SQL Server et Windows 7 64 bits, j'ai encore trouvé le moyen d'avoir un autre problème tout aussi bizarroïde.

Suite à je ne sais trop quelles mises à jour sur mon PC (peut-être .NET 4 ou MVC3 ?) je me suis retrouvé dans l'impossibilité d'utiliser SQL Server 2008 avec Visual Studio 2010, notamment en essayant de refaire le tutoriel MVC Music Store V2. Alors que mes collègues n'avaient pas ce problème :(.

Dès que j'essayais d'accéder à une base de données, je tombais immanquablement sur le message d'erreur suivant :

Le fournisseur de données .Net Framework demandé est introuvable. Il n'est peut-être pas installé.

Malgré des tas de recherches sur Google (s*perie de messages en français), impossible de trouver quoique ce soit de valable à ce sujet. J'en étais presque arrivé au point de vouloir tout ré-installer (parce que quand on n'installe pas dans le bon ordre c'est déjà galère, alors j'ose même pas imaginer ce que ça donnerait de dés-installer / ré-installer morceau par morceau...).

Jusqu'au moment où j'ai découvert fortuitement que le problème n'était pas lié à SQL Server, mais plutôt à Entity Framework. En effet, en transformant une solution VS 2008 sous VS 2010 je me suis rendu compte qu'elle fonctionnait correctement alors qu'elle utilisait bel et bien une base de données SQL Server ! La seule différence, c'est qu'elle utilisait LINQ to SQL et pas EF.

A partir de ça j'ai réussi à trouver comment régler le problème en réparant le client .NET Framework 4, ce qui a eu pour effet de re-créer le fichier machine.config : Visual Studio 2010 Add Connection dialogue not populating databases :

I managed to solve the problem by moving the machine.config file from "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Config" and "C:\Windows\Microsoft.NET\Framework\v4.0.30319\Config" to another location (as a backup) and then ran a repair on the .NET Framework 4 Client Profile. This rebuilt the machine.config file and now everything works fine.

Merci StackOverflow et Zoran.

mercredi 9 mars 2011

Introduction à Haml

J'aime bien les vidéos de Screencasts.org, surtout leur côté 5/10 minutes vite fait pour aborder (ou réviser) un sujet. Après l'introduction à Sinatra, j'ai donc décidé de suivre l'introduction à Haml. Haml (qui se prononce AMeul, comme dans meule) est un langage de template très concis destiné à remplacer HTML dans les applications Rails ou Sinatra.

Cette fois-ci, la vidéo est accompagnée de sa retranscription complète ce qui rend son visionnage encore plus facile.

Après 10 secondes de publicité, on enchaine sur un rappel de la dure condition des développeurs en quête de beauté et de simplicité dans un monde d'IE6, SOAP et autre Dreamweaver.

On rentre ensuite dans le vif du sujet en démarrant avec un petit morceau de code HTML (beurk beurk beurk) tel qu'on en voit souvent :

<div id="profile">
  <div class="left column">
    <div id="date"> 11 November 2010 </div>
    <div id="address"> 1 Infinite Loop </div>
  </div>
  <div class="right column">
    <div id="email"> steve@apple.com </div>
    <div id="bio"> Makes magical tablets </div>
  </div>
</div>

Puis, à partir de ce truc, on nous retrace par petites touches successives la genèse de Haml (miam miam miam) :

#profile
  .left.column
    #date 11 November 2010
    #address 1 Infinite Loop
  .right.column
    #email steve@apple.com
    #bio Makes magical tablets

Pour bien enfoncer le clou, on fait aussi le parallèle entre le template Erb correspondant (ouuh !) :

<div id="profile">
  <div class="left column">
    <div id="date"><%= print_date %></div>
    <div id="address"><%= user.address %></div>
  </div>
  <div class="right column">
    <div id="email"><%= user.email %></div>
    <div id="bio"><%= user.bio %></div>
  </div>
</div>

Et l'équivalent sous Haml (ouah !) :

#profile
  .left.column
    #date= print_date
    #address= user.address
  .right.column
    #email= user.email
    #bio= user.bio

Viennent ensuite quelques explications sur la syntaxe :

  • !!! pour déclarer du doctype
  • %element pour définir un élément (%html pour la balise <html ...>, %p pour la balise <p>...)
  • %element#id pour définir un identifiant
  • %element.class pour définir une classe
  • possibilité d'omettre %div qui est la balise par défaut
  • ajouter des attributs sous la forme (attribut="valeur")
  • importance de l'indentation pour imbriquer les balises

Le tutoriel se termine par l'installation d'Haml (gem install haml), un exemple d'utilisation en ligne de commande et un lien pour tester Haml en ligne.

Pour l'instant, je suis dubitatif et je me demande si c'est une vrai bonne idée de masquer autant que ça HTML ?

Juste pour référence, voilà ce que l'exemple de code donnerait avec Razor où on reste plus calqué sur le code HTML :

<div id="profile">
  <div class="left column">
    <div id="date">@Me.print_date()</div>
    <div id="address">@user.address</div>
  </div>
  <div class="right column">
    <div id="email">@user.email</div>
    <div id="bio">@user.bio</div>
  </div>
</div>

Malgré tout (pour s'entrainer en attendant de suivre l'introduction à Sass) le site Html2Haml permet de convertir du code HTML en Haml et un tutoriel en français sur Haml.

- page 1 de 34