Utilisation des patrons de conception Builder et Strategy pour la gestion dynamique d’accès à des objets

Crédit photographie: Marco Salvador

Patron de conception Builder

Le patron de conception Builder est un patron classique de la programmation orientée objet. Il permet de séparer la création d’un objet de sa représentation.

Dans certains cas, la création d’un objet est complexe, car elle nécessite plusieurs étapes intermédiaires. Elle peut être influencée par un contexte (par exemple, le fait d’être en mode sécurisé ou publique), ou plus généralement par un ensemble de conditions. Étant donné que l’objet à créer ne peut être exploité tant que l’ensemble des éléments de construction n’ont pas été pris en compte, on a tendance à encapsuler cette construction au sein d’une méthode: avant l’appel à la méthode, l’objet n’existe pas du tout; après l’appel à la méthode, l’objet est complètement créé. Le problème est que la méthode est aussi complexe que le processus de construction lui-même: elle comporte typiquement de nombreux paramètres pour exprimer les nombreuses conditions de construction, et ces conditions deviennent autant de sauts conditionnels, ce qui complexifie le code.

Le patron Builder propose de partir d’un objet représentant très simplement l’acte de construction, éventuellement même sans aucun élément spécifique. Cet objet dispose de méthodes qui enrichissent la construction de l’objet. Chaque méthode retourne une nouvelle représentation de la construction de l’objet, en tenant compte de l’élément apporté par l’appel à la méthode.

Par exemple, considérons la création d’une requête pour une base de données relationnelle. Il s’agit d’une situation complexe: on peut vouloir récupérer toutes les colonnes d’une table ou seulement certaines d’entre elles. On peut vouloir filtrer les entrées retournées selon certains critères. On peut vouloir récupérer toutes les entrées pertinentes ou seulement les X premières. Etc.

Le builder peut commencer de manière très générale, en précisant sur quelle table travailler (celle des employés d’une entreprise, par exemple). On précise ensuite qu’on ne souhaite récupérer que le prénom et le nom des employés, qu’on ne s’intéresse qu’à ceux du service de comptabilité, et que seuls les 100 premiers résultats nous intéressent.

QueryBuilder builder = new QueryBuilder("employees")
   .selectColumns("firstname", "lastname")
   .filterByValue("department", "accounting")
   .limit(100);                                          

Non seulement cette séquence d’appels de méthodes ressemble vaguement à une requête exprimée en SQL, mais leur enchaînement permet d’enrichir progressivement, appel par appel, la requête à créer. Bien entendu, des objets QueryBuilder intermédiaires peuvent être utilisés, par exemple pour réaliser des appels conditionnels:

boolean allColumns = false;

QueryBuilder builder = new QueryBuilder("employees");

if(allColumns) {
  builder = builder.selectAll();
} else {
  builder = builder.selectColumns("firstname", "lastname");
}
builder = builder.filterByValue("department", "accounting")
   .limit(100);

Finalement, une requête potentiellement complexe est obtenue en faisant travailler le builder:

Query query = builder.build();

Notez qu’un bon builder ne devrait exposer que les méthodes qui ont du sens, étant donnés les enrichissements réalisés au préalable. Par exemple, si la méthode selectAll() a été invoquée, une autre méthode de sélection ne pourrait plus être invocable par la suite. Cela mène à la création d’un DSL interne: une collection de classes fortement liées à un métier, qui, par les méthodes qu’elles exposent, facilitent l’expression de concepts liés à ce métier.

Si cela permet au développeur d’exprimer plus facilement les conditions de création d’un objet, il n’en reste pas moins qu’une bonne partie de la complexité reste en-dehors du builder. Dans notre exemple, c’est le cas du choix de sélectionner toutes les colonnes ou seulement deux d’entre-elles.

Patron de conception Strategy

Le patron de conception Strategy est également bien connu des développeurs orientés objet. Il s’agit de déporter un détail d’implémentation dans une méthode abstraite, et de laisser aux sous-classes le soin d’implémenter la méthode selon la stratégie qui leur est propre. La hiérarchie de classes implémentant ce patron de conception représente donc les différents choix stratégiques, ou modes, qui peuvent décider du comportement à adopter. Dans un mode publique, l’implémentation de la méthode choisira de ne pas chiffrer un fichier, tandis que la classe représentant un mode privé choisira une implémentation de la méthode chiffrant les fichiers qui lui sont soumis.

public interface EncryptionStrategy {
  public void process(File f);
}

public class PublicEncryptionStrategy implements EncryptionStrategy {
  public void process(File f) {
    // do nothing
  }
}

public class SecretEncryptionStrategy implements EncryptionStrategy {
  private void encrypt(File f) { ... };
  
  public void process(File f) {
    encrypt(f);
  }
}

Utilisation conjointe des patrons de conception Builder et Strategy

Abandonnons l’exemple de création de requêtes sur une base de données relationnelle pour nous intéresser à un cas auquel j’ai été confronté: l’accès à des objets dans S3, le service de stockage d’objets proposé par AWS, au travers du SDK officiel en Java.

Ce SDK a systématisé l’utilisation de builders pour la création de requêtes sur ses services. En particulier, si on souhaite récupérer un objet, il est nécessaire d’utiliser un builder afin d’exprimer la requête de récupération. Ce builder propose de nombreuses méthodes pour gérer la panoplie de propriétés associées aux objets eux-mêmes ainsi que les détails concernant leur récupération. En particulier, la méthode sseCustomerKey permet de spécifier la clef à utiliser pour déchiffrer à la volée l’objet récupéré.

Dans notre cas, certains fichiers étaient chiffrés, tandis que d’autres de l’étaient pas, selon le niveau de confidentialité qui leur était conféré. Évidemment, les fichiers sensibles sont chiffrés avec des clefs différentes, selon leurs propriétaires légitimes. Il existe même plusieurs manières de récupérer ces clefs, différentes approches ayant été adoptées au cours de la vie du projet. Cela complexifie la gestion des objets dans S3: nous voudrions pouvoir simplement exprimer « récupère tel objet, étant donné son contexte d’utilisation ».

Nous pouvons nous faciliter la vie en combinant le patron de conception Builder (déjà proposé par le SDK) avec une stratégie: nous créons une interface EncryptionStrategy dont la seule méthode consiste à enrichir le builder de requêtes de récupération d’objet. Une classe implémentant cette interface, PublicStrategy, ne fait rien. Une autre classe, SecretStrategy, définit la clef de chiffrement associée à l’objet, selon une approche qui lui est propre (la récupération de la clef peut être réalisée différemment par des sous-classes de cette classe).

public interface EncryptionStrategy {
  public GetObjectRequest.Builder enrich(GetObjectRequest.Builder builder);
}

public class PublicStrategy implements EncryptionStrategy {
  public GetObjectRequest.Builder enrich(GetObjectRequest.Builder builder){
    return builder;
  }
}

public abstract class SecretStrategy implements EncryptionStrategy { 

  public abstract String getKey();

  public GetObjectRequest.Builder enrich(GetObjectRequest.Builder builder){
    return builder.sseCustomerKey(getKey());
  }
}

Il suffit ensuite de créer le SecretStrategy correspondant au contexte courant, et de l’utiliser pour contribuer à construire la requête de récupération d’objet:

EncryptionStrategy strategy = ...;
GetObjectRequest.Builder builder = ...;
builder = strategy.enrich(builder);

Si, à l’avenir, d’autres manières de gérer le chiffrement des fichiers doivent être prises en compte, il suffit d’étendre la hiérarchie de classes issues de SecretStrategy. Si on souhaite appliquer d’autres enrichissements proposés par le builder (par exemple en ne retournant un objet que s’il a été modifié depuis une date donnée, grâce à la méthode ifModifiedSince), il suffit de créer une nouvelle hiérarchie de stratégies concernant ce critère.

Dans tous les cas, la gestion d’un aspect de la requête ne dépend plus que de l’instanciation de l’objet représentant le contexte approprié, et d’appeler une unique méthode de cet objet pour que la création de la requête soit altérée de manière appropriée. Il s’agit là, je pense, d’une situation plus simple à gérer que la conception et la maintenance que l’usage d’une unique méthode tentant de prendre en compte tous les scénarios d’utilisation possibles.

Laisser un commentaire