Sébastien Putier

Another tech blog, but this one is mine.

Sébastien

8 minutes

Le design pattern Builder est un pattern de création simple mais puissant. Cet article montre comment l’utiliser pour encapsuler la complexité nécessaire à la création d’un objet.

Note

Cet article fait partie d'une série sur les Design Patterns. Vous trouverez la liste des articles publiés ici.

Le problème

Notre application utilise des chaînes de connexion dynamiques pour accéder à différentes bases de données. La liste ci-dessous montre certaines des dernières chaînes de connexion que l’application a généré :

  • Data Source=myServerAddress;Initial Catalog=myDatabase;MultipleActiveResultSets=True;Trusted_Connection=True;
  • Data Source=myServerAddress;Initial Catalog=myDatabase;Integrated Security=SSPI;Password=myPassword;User ID=myDomain\myUsername;
  • Data Source=myServerAddress;Initial Catalog=myDatabase;Packet Size=4096;Password=myPassword;Trusted_Connection=False;User ID=myUsername;

Jetons un oeil attentif à cette liste… Que remarque t-on ? Les chaînes de connexion contiennent différents paramètres (ordonnés selon leur nom), mais elles n’utilisent pas toutes les mêmes. Le tableau suivant met en évidence les différences qui existent entre les 3 chaînes de connexion.

Paramètre Chaîne 1 Chaîne 2 Chaîne 3
Data Source myServerAddress myServerAddress myServerAddress
Initial Catalog myDatabase myDatabase myDatabase
Trusted_Connection True - False
MultipleActiveResultSets True - -
Integrated Security - SSPI -
User ID - myDomain\myUsername myUsername
Password - myPassword myPassword
PacketSize - - 4096

Quelque part dans le code de l’application, un développeur a écrit une fonction destinée à générer les chaînes de connexion. Voilà son implémentation :


public static string GetConnectionString(string dataSource,
                                            string initialCatalog,
                                            bool? trustedConnection = null,
                                            bool? multipleActiveResultSets = null,
                                            string integratedSecurity = null,
                                            string userId = null,
                                            string password = null,
                                            int? packetSize = null)
{
    if (dataSource == null)
        throw new ArgumentNullException(nameof(dataSource));
    if (initialCatalog == null)
        throw new ArgumentNullException(nameof(initialCatalog));

    if (trustedConnection.HasValue && trustedConnection.Value && (userId != null || password != null))
        throw new ArgumentException($"{nameof(userId)} and {nameof(password)} cannot be set when {nameof(trustedConnection)} is set to True.");


    string result = $"Data Source={dataSource};Initial Catalog={initialCatalog};";

    if (integratedSecurity != null)
        result += $"Integrated Security={integratedSecurity};";
    if (multipleActiveResultSets.HasValue)
        result += $"MultipleActiveResultSets={(multipleActiveResultSets.Value ? "True" : "False")};";
    if (packetSize.HasValue)
        result += $"Packet Size={packetSize.Value};";
    if (password != null)
        result += $"Password={password};";
    if (trustedConnection.HasValue)
        result += $"Trusted_Connection={(trustedConnection.Value ? "True" : "False")};";
    if (userId != null)
        result += $"User ID={userId};";

    return result;
}

Le plus gros problème de cette fonction, c’est sa signature : elle a 8 paramètres, dont 6 qui sont optionnels. Niveau lisibilité, c’est pas top, et on se retrouve avec une certaine complexité à chaque endroit où elle est appelée. En plus de ça, cette fonction a probablement trop de responsabilités : elle valide les données, fait de nombreux choix concernant le flux d’exécution, et construit la chaîne de connexion.

D’une point de vue plus fonctionnel, on remarque que la chaîne de connexion résultante est adaptée à SQL Server. Et si on veut rajouter le support de MySQL ou Oracle dans l’application ?

Note

Evitons de suite de parler de la concaténation de chaînes utilisée dans l'exemple, qui est évidemment une mauvaise idée. Si jamais vous ne le savez pas, utiliser une objet de type StringBuilder est bien plus efficace en termes d'utilisation CPU et mémoire : moins d'allocations de mémoire, moins de copies de données. Mais pour cet article, la concaténation a l'avantage non négligeable d'être plus lisible.

Une meilleure solution avec le pattern Builder

Tout d’abord, commençons par essayer de définir correctement le pattern Builder : c’est un modèle utilisé pour contrôler la création d’objets qui nécessitent des opérations complexes d’initialisation et de configuration. En d’autres termes, il orchestre l’initialisation et l’assemblage des éléments constitutifs de l’objet souhaité tout en masquant ce processus derrière des étapes simples de configuration, comme la valorisation de propriétés ou l’appels de méthodes.

Et pour notre exemple, qu’est ce que ça signifie ? Le Builder que nous allons écrire exposera quelques propriétés (correspondant aux différents paramètres de chaîne de connexion) et une méthode Build qui devra générer la chapine attendue à partir des valeurs de propriétés.

Ci-dessous, une première implémentation plutôt simpliste de notre Builder :

public class SimpleConnectionStringBuilder
{
    public string DataSource { get; set; }
    public string InitialCatalog { get; set; }
    public bool? TrustedConnection { get; set; }
    public bool? MultipleActiveResultSets { get; set; }
    public string IntegratedSecurity { get; set; }
    public string UserId { get; set; }
    public string Password { get; set; }
    public int? PacketSize { get; set; }

    private void CheckDataValidity()
    {
        if (DataSource == null)
            throw new ArgumentNullException(nameof(DataSource));
        if (InitialCatalog == null)
            throw new ArgumentNullException(nameof(InitialCatalog));

        if (TrustedConnection.HasValue && TrustedConnection.Value && (UserId != null || Password != null))
            throw new ArgumentException($"{nameof(UserId)} and {nameof(Password)} cannot be set when {nameof(TrustedConnection)} is set to True.");
    }

    public string Build()
    {
        CheckDataValidity();

        string result = $"Data Source={DataSource};Initial Catalog={InitialCatalog};";

        if (IntegratedSecurity != null)
            result += $"Integrated Security={IntegratedSecurity};";
        if (MultipleActiveResultSets.HasValue)
            result += $"MultipleActiveResultSets={(MultipleActiveResultSets.Value ? "True" : "False")};";
        if (PacketSize.HasValue)
            result += $"Packet Size={PacketSize.Value};";
        if (Password != null)
            result += $"Password={Password};";
        if (TrustedConnection.HasValue)
            result += $"Trusted_Connection={(TrustedConnection.Value ? "True" : "False")};";
        if (UserId != null)
            result += $"User ID={UserId};";

        return result;
    }
}

La complexité est maintenant masquée derrière 8 propriétés et une méthode sans paramètre. C’est bien mieux ! Maintenant, la générationd ‘une chaîne de connexion est bien plus simple : instanciation d’une objet SimpleConnectionBuilder, valorisation des propriétés qui doivent l’être, et enfin appel de la méthode Build.

var builder = new SimpleConnectionStringBuilder();
builder.DataSource = "myServerAddress";
builder.InitialCatalog = "myDatabase";
builder.MultipleActiveResultSets = false;
builder.TrustedConnection = true;
builder.PacketSize = 4096;

var result = builder.Build();
// result equals "Data Source=myServerAddress;Initial Catalog=myDatabase;MultipleActiveResultSets=False;Packet Size=4096;Trusted_Connection=True;"

Même si jusque là, les choses avancent bien, on a toujours un problème : notre code n’est pas capable de gérer simplement les chaînes de connexion pour différents types de bases de données. Par exemple, pour indiquer l’adresse d’un serveur de base de données SQL Server, on utilise le paramètre Data Source, alors qu’on utilise Server pour MySQL. Et on ne peut décemment pas tout implémenter au sein du même type en vertu du principe de séparation des responsabilités (génrer une chaîne de connexion SQL Server n’est pas la même chose que générer une chaîne de connexion MySQL).

Pour résoudre ce problème, on peut créer une hioérarchie de classes avec une classe parente abstraite ConnectionStringBuilder, and diffrents types enfants spécifiques aux types de bases de données. Pour maximiser le partage de code, le type parent devrait être responsable du processus de génération. Par conséquent, il doit être capable de travailler sur une liste dynamique de paramètres (au niveau des collections _connectionStringKeywords et _connectionStringValues dans l’implémentation ci-dessous). Les classes enfants n’ont alors plus qu’à fournir les noms de paramètres pour les propriétés communes (si besoin) et spécifiques.

public abstract class ConnectionStringBuilder
{
    private readonly Dictionary<string, string> _connectionStringKeywords = new Dictionary<string, string>();
    private readonly Dictionary<string, object> _connectionStringValues = new Dictionary<string, object>();

    public virtual string Server { get => GetValue<string>(); set => SetValue(value); }
    public string DatabaseName { get => GetValue<string>(); set => SetValue(value); }
    public string Login { get => GetValue<string>(); set => SetValue(value); }
    public string Password { get => GetValue<string>(); set => SetValue(value); }

    protected T GetValue<T>([CallerMemberName] string key = null)
        => (T)_connectionStringValues.GetValueOrDefault(key);

    protected void SetValue(object value, [CallerMemberName] string key = null)
        => _connectionStringValues[key] = value;

    public ConnectionStringBuilder() { }

    protected void SetParameterNameForProperty(string propertyName, string parameterName)
        => _connectionStringKeywords[propertyName] = parameterName;

    protected virtual void CheckDataValidity()
    {
        if (Server == null)
            throw new ArgumentNullException(nameof(Server));
        if (DatabaseName == null)
            throw new ArgumentNullException(nameof(DatabaseName));
    }

    public string Build()
    {
        CheckDataValidity();

        var keyValues = _connectionStringKeywords.Join(_connectionStringValues,
                                                        cskKvp => cskKvp.Key,
                                                        csvKvp => csvKvp.Key,
                                                        (csk, csv) => new { Keyword = csk.Value, Value = csv.Value });

        string result = string.Empty;
        foreach (var keyValue in keyValues.OrderBy(kv => kv.Keyword))
        {
            if (keyValue.Value == null)
                continue;

            result += $"{keyValue.Keyword}={keyValue.Value};";
        }

        return result;
    }
}

public class SqlServerConnectionStringBuilder : ConnectionStringBuilder
{
    public bool? TrustedConnection { get => GetValue<bool?>(); set => SetValue(value); }
    public bool? MultipleActiveResultSets { get => GetValue<bool?>(); set => SetValue(value); }
    public string IntegratedSecurity { get => GetValue<string>(); set => SetValue(value); }
    public int? PacketSize { get => GetValue<int?>(); set => SetValue(value); }

    public SqlServerConnectionStringBuilder()
    {
        SetParameterNameForProperty(nameof(Server), "Data Source");
        SetParameterNameForProperty(nameof(DatabaseName), "Initial Catalog");
        SetParameterNameForProperty(nameof(Login), "User ID");
        SetParameterNameForProperty(nameof(Password), "Password");
        SetParameterNameForProperty(nameof(TrustedConnection), "Trusted_Connection");
        SetParameterNameForProperty(nameof(MultipleActiveResultSets), "MultipleActiveResultSets");
        SetParameterNameForProperty(nameof(IntegratedSecurity), "Integrated Security");
        SetParameterNameForProperty(nameof(PacketSize), "Packet Size");
    }

    protected override void CheckDataValidity()
    {
        base.CheckDataValidity();

        if (TrustedConnection.HasValue && TrustedConnection.Value && (Login != null || Password != null))
            throw new ArgumentException($"{nameof(Login)} and {nameof(Password)} cannot be set when {nameof(TrustedConnection)} is set to True.");
    }
}

public class MySqlConnectionStringBuilder : ConnectionStringBuilder
{
    public bool? UseCompression { get => GetValue<bool?>(); set => SetValue(value); }
    public bool? UseUsageAdvisor { get => GetValue<bool?>(); set => SetValue(value); }
    public bool? UsePerformanceMonitor { get => GetValue<bool?>(); set => SetValue(value); }

    public MySqlConnectionStringBuilder()
    {
        SetParameterNameForProperty(nameof(Server), "Server");
        SetParameterNameForProperty(nameof(DatabaseName), "Database");
        SetParameterNameForProperty(nameof(Login), "Uid");
        SetParameterNameForProperty(nameof(Password), "Pwd");
        SetParameterNameForProperty(nameof(UseCompression), "UseCompression");
        SetParameterNameForProperty(nameof(UseUsageAdvisor), "UseUsageAdvisor");
        SetParameterNameForProperty(nameof(UsePerformanceMonitor), "UsePerformanceMonitor");
    }
}

Conclusion

L’utilisation du pattern Builder est une technique très intéressante pour gérer les algorithmes de création d’objets complexes. Il permet non seulement de construire un objet par configuration, mais avec un peu d’héritage, il permet de gérer facilement des cas similaires mais différents. Bien sur, notre exemple est passé de 44 à 96 lignes, mais les modifications effectuées offrent un certain nombre d’avantages :

  • amélioration de la lisibilité en supprimant de nombreux paramètres (notamment optionnels) au niveau des appels de fonction
  • amélioration de la maintenabilité en déplaçant (et en masquant) la complexité dans un type indépendant
  • ajout du support des chaînes des connexion pour MySQL
  • ajout d’un point d’extensibilité : ajouter le support d’un nouveau type de base de données ne nécessite que de créer une classe hériant de ConnectionStringBuilder, et ajouter un nouveau paramètre de chaîne de connexion ne nécessite que de créer une propriété et ajouter un appel à la méthode SetParameterNameForProperty.
comments powered by Disqus

A propos

• French dev
• Mainly .NET
• Trying to learn everything

Posts récents

Catégories