Cet article est susceptible de référencer des images manquantes ou de contenir des erreurs de formatage sur son contenu. Il s'agit d'un import provenant d'un ancien blog.
Je me rends compte que j’utilise ce pattern régulièrement depuis plus de six mois chez mon client actuel. C’est l’occasion d’en faire un retour car on en parle beaucoup moins que l’incontournable IoC. Contrairement à ce dernier, l’intérêt du pattern Strategy dépend beaucoup des projets.
Je me permets d’utiliser certains termes en anglais. Ce sont les plus couramment rencontrés, et je ne suis pas un fan des butineurs et autres coquetels. Je m’en excuse auprès des puristes de la langue française…
Le pattern Strategy porte bien son nom
Il fait partie des behavioral patterns. L’idée est de décomposer un algorithme qui traite différents cas de figure en autant d’algorithmes, plus simples, dans des classes séparées. Chaque classe implémente une stratégie pour traiter un cas bien connu. Le fait de vouloir gérer des cas différents dans un même algorithme mène facilement à du code confus avec un grand nombre de branchements (conditions).
Utile dans…
La mise en œuvre d’opérations qui prennent en compte différents cas de figure
C’est le cas d’utilisation « scolaire » des stratégies.
Prenons l’exemple fictif d’un système de prise de commande :
Il existe différents moyens de paiement, disponibles en fonction du pays d’origine du client. Les commandes peuvent être prises depuis différentes applications et la procédure (tunnel) de commande varie d’un application à l’autre. Des options particulières sont disponibles pour certaines gammes de produit ajoutés à la commande.
Implémenter ce système dans un seul algorithme (disons dans une méthode ou une classe) aboutira nécessairement à traiter beaucoup d’embranchements conditionnels et finalement à un code spaghetti difficile à maintenir.
La maintenance d’un legacy code
Reprenons l’exemple précédent, mais initialement prévu pour un seul cas, et que l’on aurait progressivement enrichi d’année en année, au fur et à mesure de l’évolution commerciale du système.
Il n’est pas rare d’être confronté à un existant difficile à maintenir, confus, et conçu sans avoir fait des tests unitaires un impératif. Une application maintenue plusieurs années ainsi résulte couramment en un code truffé de « verrues » (vous savez, ces nouveaux cas particuliers que le métier vous demande de supporter, alors qu’ils ne sont pas compatibles avec la logique applicative existante). Mal implémentées ou faites trop rapidement, ces verrues font exploser le nombre de conditions des méthodes (ce qui se traduit par une augmentation de la complexité cyclomatique). Dit plus simplement, cela revient à créer de la dette technique pour un code difficilement maintenable.
Exemple solution de mise en œuvre
Dans les deux exemples précédents, une bonne solution est d’isoler les différents cas dans différentes stratégies. Les stratégies qui dépendent de l’application ne seront évidemment pas présentes dans toutes les applications (grâce à l’IoC). Les stratégies disponibles dans l’application seront évaluées au runtime en fonction des données à traiter, par exemple un objet qui représente le panier de la commande et qui sera lui-même transmis à la stratégie choisie pour traiter la prise de commande.
Dans le cas de la maintenance d’un legacy code, on peut même prévoir une legacy strategy qui est une extraction fidèle du code de base, intégré dans une stratégie qui sera utilisée en dernier recours si aucune autre ne supporte les données fournies. C’est un moyen simple de limiter le risque de régression (cependant jamais éliminé).
Code
Le pattern Strategy est très simple.
Le premier exemple de code est un exemple fictif très simplifié d’un code confus, où aucun pattern n’est appliqué. Il faut imaginer la même chose dans une méthode beaucoup plus longue (et à la logique parfois obscure dans les pires cas):
public class Client
{
public void ValidateOrder(Order order)
{
var isSpecialCase = order.Items.Any(t => t is SpecialItemA);
if (isSpecialCase)
{
SomeExternalCode.DoSpecificThings(order);
}
ValidateOrder(order);
SomeExternalCode.ThenDoOtherThings();
if (isSpecialCase)
{
SomeExternalCode.DoSpecificThings(order);
}
}
}
L’exemple ci-dessous combine le pattern Strategy au pattern Proxy, pour le choix de la stratégie à appliquer. Voici une version modifiée du code précédent:
public class Client
{
private readonly IOrderStrategy _orderStrategyProxy;
public Client(IOrderStrategyProxy orderStrategyProxy)
{
_orderStrategyProxy = orderStrategyProxy;
}
public void ValidateOrder(Order order)
{
_orderStrategyProxy.ValidateOrder(order);
}
}
Avec l’implémentation suivante:
#region Contracts
public interface IOrderStrategy
{
bool Matching(Order order);
void ValidateOrder(Order order);
}
public interface IOrderStrategyProxy : IOrderStrategy
{
}
#endregion
#region Strategies
public class StandardOrderStrategy : IOrderStrategy
{
public bool Matching(Order order)
{
return !order.Items.Any(t => t is SpecialItemA);
}
public void ValidateOrder(Order order)
{
ValidateOrder(order);
SomeExternalCode.ThenDoOtherThings();
}
}
public class SpecialCaseOrderStrategy : IOrderStrategy
{
public bool Matching(Order order)
{
return order.Items.Any(t => t is SpecialItemA);
}
public void ValidateOrder(Order order)
{
SomeExternalCode.DoSpecificThings(order);
ValidateOrder(order);
SomeExternalCode.ThenDoOtherThings();
SomeExternalCode.DoSpecificThings(order);
}
}
#endregion
public class OrderStrategyProxy : IOrderStrategyProxy
{
private readonly IOrderStrategy[] _strategies;
public OrderStrategyProxy(IOrderStrategy[] availableStrategies)
{
_strategies = availableStrategies;
}
public bool Matching(Order order)
{
return _strategies.Any(t => t.Matching(order));
}
public void ValidateOrder(Order order)
{
var strategy = _strategies.FirstOrDefault(t => t.Matching(order));
if (strategy == null)
throw new InvalidOperationException("No strategy applicable to the specified order.");
strategy.ValidateOrder(order);
}
}
Plusieurs remarques sur le code ci-dessus
Nous avons défini deux interfaces IOrderStrategy
et IOrderStrategyProxy
. La première définie le contrat principal. Chaque stratégie doit implémenter ce même contrat afin d’être interchangeable au moment de son utilisation.
L’interface IOrderStrategyProxy
dérive du premier contrat et n’expose aucun membre supplémentaire. Bien que pas indispensable dans notre exemple, il est souvent nécessaire de déclarer un autre type pour le proxy, afin de faciliter l’inscription de celui-ci dans le conteneur IoC.
L’interface IOrderStrategyProxy
implémente la méthode IOrderStrategy.Matching(Order)
mais dans l’exemple ci-dessus, cette méthode ne sera jamais appelée sur le proxy. Tel qu’implémenté, l’exemple donné propagera une InvalidOperationException
si aucune stratégie n’est applicable. Selon le comportement souhaité, le client peut appeler IOrderStrategyProxy.Matching(Order)
pour vérifier qu’une stratégie pourra bien être appliquée et agir en conséquence dans le cas contraire.
Dans un cas réel de maintenance applicative, avec un code complexe et un risque important de régression, on pourra implémenter une autre stratégie dans lequel le code original est copié. Ceci afin d’utiliser cette dernière stratégie pour « tous les autres cas non prévus ».
Enfin, dans l’exemple proposé, nous aurions pu simplifier le code de la classe SpecialCaseOrderStrategy
en la faisant dériver de la classe StandardOrderStrategy
. Dans un cas réel, il n’est pas évident que cela soit pertinent car les branchements conditionnels du code initial vont typiquement s’enchevêtrer. J’ai donc préféré ne pas le faire ici.