Je finis parfois avec des services qui encapsulent la responsabilité de faire une sorte de processus métier pour lequel il existe plusieurs sorties possibles. En règle générale, une de ces sorties est le succès et les autres représentent les éventuelles échecs du processus lui-même.
Pour résoudre l'idée, considérez les interfaces et les classes suivantes:
interface IOperationResult
{
}
class Success : IOperationResult
{
public int Result { get; }
public Success(int result) => Result = result;
}
class ApiFailure : IOperationResult
{
public HttpStatusCode StatusCode { get; }
public ApiFailure(HttpStatusCode statusCode) => StatusCode = statusCode;
}
class ValidationFailure : IOperationResult
{
public ReadOnlyCollection<string> Errors { get; }
public ValidationFailure(IEnumerable<string> errors)
{
if (errors == null)
throw new ArgumentNullException(nameof(errors));
this.Errors = new List<string>(errors).AsReadOnly();
}
}
interface IService
{
IOperationResult DoWork(string someFancyParam);
}
Les classes consommant l'abstraction IService
sont nécessaires pour traiter l'instance renvoyée IOperationResult
. Le moyen simple de le faire consiste à écrire une vieille switch
_ énoncé et décidez quoi faire dans chaque cas:
switch (result)
{
case Success success:
Console.WriteLine($"Success with result {success.Result}");
break;
case ApiFailure apiFailure:
Console.WriteLine($"Api failure with status code {apiFailure.StatusCode}");
break;
case ValidationFailure validationFailure:
Console.WriteLine(
$"Validation failure with the following errors: {string.Join(", ", validationFailure.Errors)}"
);
break;
default:
throw new NotSupportedException($"Unknown type of operation result {result.GetType().Name}");
}
Écrire ce type de code dans différents points de la base générale génère rapidement un gâchis, car cela enfreint essentiellement le principe ouvert ouvert.
Chaque fois que la mise en œuvre de IService
est modifiée en introduisant une nouvelle implémentation de IOperationResult
Il existe également plusieurs relevés de commutation qui doivent également être modifiés. Le développeur mettant en œuvre la nouvelle fonctionnalité DOI Soyez conscient de son existence, à moins que des tests bien écrits puissent détecter automatiquement les modifications manquantes dans les points où le code bascule sur IOperationResult
instances.
Peut-être que l'instruction de commutation peut être évitée du tout.
Ceci est facile à faire lorsque IService
est utilisé dans un but spécifique. À titre d'exemple, lorsque j'écris des contrôleurs ASP.NET CORE MVC afin de maintenir les méthodes d'action simples et maigrir, j'injecte un service dans le contrôleur et déléguer à toute la logique de traitement. De cette façon, la méthode d'action ne se soucie que de gérer la demande HTTP, de valider les paramètres et de renvoyer une réponse HTTP à l'appelant. Dans ce scénario, l'instruction switch
peut être évitée du début en utilisant le polymorphisme. L'astuce consiste à modifier IOperationResult
de cette façon:
interface IOperationResult
{
IActionResult ToActionResult();
}
La méthode d'action appelle simplement ToActionResult
sur l'instance IOperationResult
et renvoie le résultat.
Dans certains cas, l'abstraction IService
doit être utilisée par différents appelants et nous devons leur laisser la liberté de décider quoi faire avec le résultat de l'opération.
Une solution possible est la définition d'une fonction d'ordre supérieur, permet de l'appeler processeur pour la simplicité, ayant la responsabilité de traiter une instance donnée de IOperationResult
. C'est quelque chose comme ça:
static class Processors
{
static T Process<T>(
IOperationResult operationResult,
Func<Success, T> successProcessor,
Func<ApiFailure, T> apiFailureProcessor,
Func<ValidationFailure, T> validationFailureProcessor) =>
operationResult switch
{
Success success => successProcessor(success),
ApiFailure apiFailure => apiFailureProcessor(apiFailure),
ValidationFailure validationFailure => validationFailureProcessor(validationFailure),
_ => throw new ArgumentException($"Unknown type of operation result: {operationResult.GetType().Name}")
};
}
Les avantages ici sont les suivants:
switch
est effectuéeIOperationResult
est définie, il n'y a qu'un seul point à modifier. Ce faisant, la signature de la fonction Process
est modifiée également.Process
est appelée. Ces erreurs doivent être réparées, mais nous pouvons faire confiance au compilateur de pouvoir trouver tous les points à modifier.Une alternative plus orientée objet consiste à modifier la définition de IOperationResult
en ajoutant une méthode par chaque utilisation prévue du résultat de l'opération, de sorte que la déclaration switch
puisse être évitée une fois de plus et la seule chose à faire rédige effectivement une nouvelle implémentation de l'interface.
Ceci est un exemple dans l'hypothèse selon laquelle il existe deux consommateurs différents de IService
:
interface IOperationResult
{
string ToEmailMessage(); // used by the email sender service
ICommand ToCommand(); // used by the command sender service
}
Des pensées ? Y a-t-il d'autres alternatives ou meilleures?
Le but d'avoir ces classes de résultat dériver de la même interface est de sorte que l'interface devienne ce que le consommateur sait et travaille avec. Le consommateur ne se soucie pas des classes de mise en œuvre spécifiques.
Cependant, votre interface ne contient rien. Vous l'utilisez comme une interface de marqueur. Si je, en tant que consommateur, recevez un objet IOperationResult
, que puis-je faire avec cela? rien. Parce que l'interface IOperationResult
ne définit aucun contrat.
Cela défait le but d'avoir vos classes de résultats partagent la même interface. Vous les avez essentiellement appliqués tous pour vous conformer au même contrat, mais mettre un contrat vide en place, donc il est littéralement impossible à non en conformité.
Votre classe Processors
tente efficacement de réinventer la roue de la délégation d'événement. Vous définissez des gestionnaires d'événements spécifiques (les objets Func
) qui gèrent chaque résultat.
Mais cela viole encore OCP pour la même raison. Vous avez toujours le commutateur qui doit être développé chaque fois qu'un nouveau type de résultat est développé. Tous les consommateurs devront encore ajouter un nouveau gestionnaire pour un nouveau type de résultat.
C'est toujours le même problème. Vous venez de l'obscurcir avec une complexité supplémentaire.
Vous avez trouvé une nouvelle façon de violer OCP. Maintenant, au lieu d'avoir à développer le code chaque fois qu'un type de résultat est développé, vous devez développer l'interface chaque fois qu'un nouveau consommateur est développé.
C'est le même problème à nouveau.
En plus de cela, votre logique principale doit maintenant être en quelque sorte savoir Comment chaque consommateur veut que son propre résultat soit manipulé, et votre logique principale va avoir Pré-mâcher le résultat exactement comment chaque client en veut.
[.____] Cela conduira à une folie pure dans votre logique principale qui doit maintenant tenir compte de la manipulation de son propre résultat (basé sur chaque individu besoins du consommateur); Ce qui signifie que vous enfreignez SRP sur OCP.
Dans l'ensemble, il semble que vous ayez bien compris la question fondamentale de l'OCP, car vous n'avez pas évité dans aucune des solutions que vous avez affirmées étaient des solutions.
La façon dont vous développez votre interface dépend correctement de ce que vous voulez en faire. Sur la base de votre exemple d'utilisation dans le boîtier de commutation, il semble que vous êtes principalement intéressé par deux choses: s'il s'agissait d'un succès et d'un message possible d'informer le consommateur. Prenant cela, votre interface devient simple:
public interface IOperationResult
{
bool IsSuccess { get; }
string Message { get; }
}
Et vos implémentations deviennent simples:
public class Success : IOperationResult
{
public bool IsSuccess => true;
public string Message { get; set; }
}
public class ApiFailure : IOperationResult
{
public bool IsSuccess => false;
public string Message { get; set; }
}
Cependant, dans l'intérêt de la non-accumulation, il existe une approche plus simple ici. Sur la base de vos besoins actuels, vous n'avez pas vraiment besoin de classes séparées. La seule chose qui est différente va être les valeurs contenues dans l'objet de résultat, et non la structure de l'objet de résultat lui-même.
C'est beaucoup plus propre ici pour éliminer l'interface et utiliser simplement une classe DTO simple. Ce que vous appelez maintenant des dérivations (succès, échec de l'API, ...) peut être exprimé en tant que méthodes statiques que vous utilisez essentiellement comme "constructeurs avec un nom", comme:
public class OperationResult
{
public bool IsSuccess { get; private set; }
public string Message { get; private set; }
//This ensures you can only instantiate an object via the static methods
private OperationResult() {}
public static OperationResult Success()
{
return new OperationResult()
{
IsSuccess = true,
Message = String.Empty
};
}
public static OperationResult ValidationFailure(string message)
{
return new OperationResult()
{
IsSuccess = false,
Message = message
};
}
}
Que vous pouvez ensuite utiliser partout où vous en avez besoin:
if( a == b )
return OperationResult.Success();
else
return OperationResult.ValidationFailure("a does not equal b");
Dans cet exemple, je l'ai fait pour que un message ne soit donné que pour une défaillance. C'est juste un exemple de la manière dont vous pouvez forcer certains résultats à avoir certaines valeurs, ce qui vous permet de définir vos valeurs résultantes exactement comment vous les souhaitez dans tous les cas.
Pour la situation que vous avez décrite dans la question, la classe ci-dessus suffit. En règle générale, ne surgissez pas de choses simples. Il suffit de coûter du temps et des efforts maintenant, et à l'avenir quand vous devez le maintenir.
Il me semble que vous avez massivement de compliquer votre vie dans cet exemple en utilisant des exceptions à la place de votre interface IOperationResult
.
Il suffit de jeter un ValidationException
ou APIErrorException
dans votre service et votre manipulez-vous d'essayer d'attraper comme d'habitude.
En outre, disons que vous l'avez fait, je dirais alors que vous ne devriez pas exposer les internaux de votre service en manipulant le type d'erreur spécifique en dehors de celui-ci.
c'est-à-dire que votre service jette un APIException
ou renvoie un APIFailure
et votre processus dans son ensemble doit être traité de manière spécifique. Mais le code d'appel ne devrait pas se soucier de savoir si le service utilise une API ou non pour obtenir son résultat.
Le service devrait créer une exception et toute logique spéciale qui doit être mise en œuvre à des fins exceptionnelles en raison des défaillances de l'API devrait être traitée dans cette classe de service, peut-être en injectant un gestionnaire.
si vous réorganisez votre service comme celui-ci, vous n'avez plus besoin de cas de sélection ou d'essayer des commutateurs d'appel dans le code d'appel.
public class MyService : IService<Result>
{
private IApiErrorHandler apiErrorHandler;
public Result Process()
{
try
{
/call api
}
catch(Exception ex)
{
apiErrorHandler(ex)
throw;
}
return result;
}
}
public class MyController
public IActionResult MyAction
{
return myService.Process(); //rely on error handler to convert errors to http/json
}
Si c'est purement le formatage de l'exception renvoyé au client qui change, peut-être un code HTTP différent pour les erreurs de validation? Ensuite, vous pouvez gérer tout cela dans le gestionnaire d'erreurs, ou simplement vérifier la validation avant d'appeler le service.
C'est une question longue mais très intéressante, où vous avez exposé votre étape étape par étape.
Résumer le problème:
IService
fait quelque chose de traitement et de retourne un IOperationResult
IOperationResult
IOperationResult
parce que des actions concrètes dépendent du contexte appelant.switch
sont utilisés, mais c'est un problème en vue de OCP , ainsi que peut-être le - LODMalheureusement, il n'y a pas de balle d'argent pour résoudre ce problème de manière générale: le fait que les actions dépendent du contexte et du résultat, signifie que vous devez savoir à la fois pour décider de l'action. Explosion combinatoire.
Vous pouvez peut-être trouver un meilleur endroit ou un meilleur moyen de prendre la décision, mais vous devrez toujours ajouter de nouvelles actions s'il y a de nouveaux IOperationResult
ou s'il y a un nouveau contexte invoquant le service.
Si vous avez des familles d'actions prédéfinies à effectuer à plusieurs endroits, vous pouvez remplacer le commutateur avec une sorte de type prédéfini chaîne de responsabilité pour gérer le IOperationResult
. Mais si les actions sont différentes dans chaque commutateur, cela constituerait simplement une surcharge et au lieu de modifier les commutateurs, vous ajouteriez des gestionnaires. L'interrupteur semblerait une approche très décente alors.
Pour ce type de problèmes, cela peut valoir la peine d'envisager une conception motrice d'événement. L'événement fournirait des informations sur un flux (c'est-à-dire une séquence d'invocations de services connexes, avec certaines informations sur le statut), un processeur dérogerait des événements et les transmettre à une chaîne de responsabilité (à nouveau, une chaîne de responsabilité ou une logique de table). L'événement indiquerait soit des services à être invoqués ou des résultats à digérer. Cela ne rend pas le tout plus simple. Mais lorsque vous ajoutez un nouveau type de IOperationResult
, vous ajouteriez un couple de nouveaux gestionnaires (un pour chaque type d'actions à effectuer), et l'architecture basée sur l'événement fera le reste.
Les camionneurs répondent, tandis que la question correcte pour la question ne résout pas vraiment la question générale.
Et si vous voulez implémenter le même modèle, mais avec un "message" fortement typé au lieu d'une chaîne? Par exemple. IOperationResult
devient IOperationResult<T>
.
Et si vous souhaitez projeter ce "message" à un autre type une fois qu'il est enveloppé dans le paramètre générique?
Une solution (signalée par ewan) est un flux de programmation exceptionnel, qui est généralement considéré comme une mauvaise pratique et non favorisée par la équipe ASP.NET . Il n'obtient aussi que vous êtes une "couche" dans la piqûre de la CallStack. En fonction de la complexité de l'application, cela peut avoir à être attrapé et jeté plusieurs fois de créer plus de surface pour les captures manquées. Bleuh.
Le motif visiteur (anti) pourrait être utilisé mais repose sur diverses hypothèses sur l'architecture de la solution. Par exemple, les implémentations du IOperationResult<T>
Et la classe de visite devra vivre dans la même assemblée afin d'avoir une prise de conscience des autres.
La solution que vous proposez, tandis que laide, fonctionne et peut être un compromis nécessaire en C # sous la forme actuelle (8.0). Il n'y a pas de belle solution en C # tandis que cela ne supporte pas les syndicats discriminés ni les types fermés. Il y a une proposition .
Le mieux que vous puissiez faire pour atténuer les risques de nouvelles implémentations consiste à limiter l'endroit où le switch
se produit, de préférence aux mêmes limites de l'application, définir des extensions communes pour la projection du type et considère soigneusement où vous utilisez le motif. .
Vaut la peine de noter que le capital ouvert/étroit n'est qu'un avantage dans des bases de code plus vastes, par exemple pour les micro-services, il est susceptible d'ajouter une complexité inutile et de violer le baiser.
Il y a 2 décennies, j'ai abandonné des déclarations de commutation, je les utilise récemment dans des bases de code plus petites (plus de C++ comme c ++) ainsi que des méthodes statiques publiques et je suis plus heureuse avec le résultat.