https://a.storyblok.com/f/270183/1368x665/95d3cfca7c/the-monad-invasion_pt3.png

L'invasion des monades - Partie 3 : Programmation orientée chemin de fer

Publié le November 14, 2024

Temps de lecture : 8 minutes

Bonjour les amis,

Notre précédent article, "L'invasion des monades - Partie 2 : Les monades en action !", présentait un ensemble de monades diverses et la façon dont elles peuvent s'intégrer dans des scénarios du monde réel.

Aujourd'hui, nous nous concentrerons sur la programmation orientée chemin de fer (ROP), une approche fonctionnelle de la gestion des erreurs.

Spoiler: Si vous avez lu nos posts précédents, vous avez observé le RDP sans en être conscient.

Récapitulation rapide

Nous avons utilisé notre authentification à deux facteurs (2FA) comme exemple concret. Ce flux de travail en deux étapes nécessite d'initier une vérification d'abord, puis de vérifier le code envoyé à l'utilisateur.

var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
    .WithBrand("Monads Inc.")
    .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
    .Create() // Build the authentication request
    .BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
    .BindAsync(VerifyCodeAsync) // Start the second step if it's a success
    .Match(AuthenticationSuccessful, AuthenticationFailed);

private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
    var myCode = ... // Receive verification code based on the specified workflow
    return await response
        .BuildVerificationRequest(myCode) // Build the verification request
        .BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}

Comme nous l'avons suggéré précédemment, cet extrait applique déjà la POR. Vous vous posez probablement beaucoup de questions en ce moment. Qu'est-ce que la POR ? Comment fonctionne-t-il ? Pourquoi est-ce utile ?

Commençons par le commencement, voulez-vous ?

L'échec est attendu

A pile of dynamiteIl est strictement impossible de construire un logiciel sans gérer les erreurs. Dans le monde réel, les défaillances peuvent survenir à tout moment de l'exécution et nous ne pouvons pas les ignorer. pendant l'exécution, et nous ne pouvons pas les ignorer.

Et si nous avions le même flux de travail, écrit dans un style impératif ?

var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();  
var response = verifyClient.StartVerificationAsync(request);
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
var result = verifyClient.VerifyCodeAsync(verificationRequest);    
this.AuthenticationSuccessful();

Il semble similaire, mais il manque quelque chose d'important : la gestion des erreurs.
En effet, cet extrait ne montre que le chemin le plus heureux, celui où tout se passe comme prévu.

Bien sûr, ce n'est pas suffisant - nous visons la parité des fonctionnalités entre ces extraits. Dans son état actuel, toute défaillance entraînerait un crash de notre système. Comme nous l'avons mentionné précédemment, ce flux peut échouer à différents endroits :

  • Lors de l'élaboration de la demande d'authentification

  • Lors du traitement de la demande d'authentification

  • Lors de l'élaboration de la demande de vérification

  • Lors du traitement de la demande de vérification

Quelle est la différence si nous décidons de traiter les erreurs dans ce cas ?

var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
if (request.IsFailure)
{
    // Can fail when Brand is invalid
    // Can fail when PhoneNumber is invalid
    return this.AuthenticationFailed(new Failure("Invalid input."));
}

var response = verifyClient.StartVerificationAsync(request);
if (response.IsFailure)
{
     // Can fail when process can't be initiated
     return this.AuthenticationFailed(new Failure("Cannot initiate verification process."));
}

var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
if (response.IsFailure)
{
    // Can fail when the code input is invalid
    return this.AuthenticationFailed(new Failure("Invalid code."));
}

var result = verifyClient.VerifyCodeAsync(verificationRequest);    
if (result.IsFailure)
{
    // Can fail when the verification fails
    return this.AuthenticationFailed(new Failure("Verification failed."));
}

return this.AuthenticationSuccessful();

"That escalated quickly" memeNous devons ajouter un code de base important pour faire face à une défaillance potentielle. Ce code est dense et difficile à lire. Nous avons maintenant plus de code pour gérer les échecs que le chemin heureux lui-même, ce qui n'est pas une coïncidence. Nous n'avons qu'un seul chemin heureux mais de multiples raisons d'échouer.

Il y a une chose importante à observer ici :

  • Cet extrait offre le même comportement que celui de la section "Récapitulation rapide", sauf qu'il prend à peu près trois fois trois fois plus de code.

  • La complexité cyclomatique augmente également de manière significative, de 1 à 6en un clin d'œil.

Ai-je éveillé votre intérêt ? Il est temps de présenter la la programmation axée sur les chemins de fer.

Programmation axée sur les chemins de fer

Railways

Note : Cette section partage les ressources de la section conférence "Railway-Oriented Programming - la gestion des erreurs de manière fonctionnelle" par Scott Wlaschin. Plus de détails sont disponibles sur F#ForFunAndProfit.

La programmation orientée chemin de fer est une approche fonctionnelle de la gestion des erreurs. L'idée principale est de considérer le "chemin heureux" (vert) comme la voie principale, qui fournit le comportement attendu.

Exposing both success and failure tracksDans l'exemple ci-dessus, notre flux de travail se compose de trois étapes :

  • Valider une entrée.

  • Mise à jour d'un enregistrement - si l'entrée est valide.

  • Envoyer une notification - si la mise à jour de l'enregistrement est réussie.

Voici à quoi cela ressemble de manière impérative :

if (this.Validate(input))
{
    try
    {
        var record = this.Update(input);
        this.SendNotification(record);
    }
    catch (Exception)
    {
        ...        
    }
}

Nous considérons que notre flux de travail n'est réussi que si ces trois étapes sont couronnées de succès.

Nous restons sur ce "chemin du bonheur" jusqu'à ce que nous soyons confrontés à une erreur, ce qui nous conduit à un "chemin de l'échec" (rouge). Dans ce cas, nous l'échec jusqu'à la fin du flux. Il est essentiel de s'en tenir au chemin principal, car c'est le seul moyen de garantir l'exécution complète de notre flux de travail. l'exécution complète de notre flux de travail.

Dans l'exemple ci-dessus, nous sautons l'étape de mise à jour lorsque la validation échoue. Cela n'est possible que parce que nos fonctions renvoient plusieurs états possibles : un état succès ou un échec.

Meme skeptical faceCela vous rappelle-t-il quelque chose ? Quelle coïncidence, nous utilisons les monades pour cela ! Vous ne l'avez pas vue venir, ou si ?

Dans notre notre article précédentnous avons vu comment changer le statut d'une Monade d'un état à un autre en utilisant la méthode .Bind() pour passer d'un état à un autre. Ce concept fondamental nous permet de créer cet embranchement entre les voies.

Il n'y a pas de magie ici. La ramification n'a pas disparu par un coup de baguette magique ou quoi que ce soit de ce genre. La ramification est maintenant interne à notre Monade.

Jetez un coup d'œil à la façon dont Bind est mis en œuvre dans notre monade personnalisée :

return this.IsFailure ? Result<TB>.FromFailure(this.failure) : bind(this.success);

Notre mécanisme de décision - un opérateur ternaire - vérifie si l'état actuel est un échec ou une réussite.

  • S'il s'agit d'un échec, nous renvoyons l'échec actuel ; rien ne se passe.

  • En cas de succès, nous appelons la fonction bind pour poursuivre le flux.

Ainsi, nous pouvons enchaîner nos opérations, et le flux basculera automatiquement sur le chemin de l'échec si l'une des opérations échoue. opérations échoue.

Voici le même flux de travail que précédemment, mais cette fois-ci en utilisant la méthode ROP :

var result = input.Bind(Validate).Bind(Update).Bind(SendNotification);

Impressionnant, non ? Nous avons réduit notre code à une seule ligne tout en couvrant les mêmes fonctionnalités.

Jouons à un petit jeu

Voici un exemple de code de notre SDK .NET qui nous permet de nous authentifier pour l'une de nos API de réseau. Pouvez-vous découvrir à quoi ressemblent les voies ferrées ? Où notre flux de travail peut-il échouer ?

Vous n'avez pas besoin de savoir ce qui se passe ici ; il n'y a pas de logique. Concentrez-vous sur le flux.

public Task<Result<AuthenticateResponse>> AuthenticateAsync(Result<AuthenticateRequest> request) =>
    request.Map(BuildAuthorizeRequest)
        .BindAsync(this.SendAuthorizeRequest)
        .Map(BuildGetTokenRequest)
        .BindAsync(this.SendGetTokenRequest)
        .Map(BuildAuthenticateResponse);

La réponse est très simple : notre chemin de fer comporte deux points de bifurcation :

  • Lors de l'envoi de la demande d'autorisation.

  • Lors de l'envoi de la demande de jeton d'accès.

Ceci est explicite car nous nous appuyons sur la méthode Bind pour ces opérations.

Pour une autre comparaison, voici le même code écrit dans un style impératif :

public async Task<Result<AuthenticateResponse>> AuthenticateAsync(AuthenticateRequest request)
{
    var authorizeRequest = BuildAuthorizeRequest(request);
    var authorizeResponse = await this.SendAuthorizeRequest(authorizeRequest);
    if (authorizeResponse.IsFailure)
    {
        return Result<AuthenticateResponse>.FromFailure(authorizeResponse.Failure);
    }

    var getTokenRequest = BuildGetTokenRequest(authorizeResponse.Success);
    var getTokenResponse = await this.SendGetTokenRequest(getTokenRequest);
    if (getTokenResponse.IsFailure)
    {
        return Result<AuthenticateResponse>.FromFailure(getTokenResponse.Failure);
    }

    return BuildAuthenticateResponse(getTokenResponse.Success);
}

Comme vous pouvez le constater, la lecture et la compréhension du flux sont beaucoup plus faciles que dans le style impératif, et la fonction est plus petite. plus petite. Nous y sommes parvenus en réduisant la charge cognitive de notre méthode - la réduction de la quantité requise d'informations techniques (branchement, logique, variables, etc.) aide à faire en sorte que le code "tienne dans la tête" ( voir "Code that fits in your head" par Mark Seemann).

Les processus échouent pour de multiples raisons

Si l'on se réfère à l'exemple précédent, notre flux de travail peut échouer en deux points, ce qui peut nous poser problème.

Notre chemin d'échec porte ce que nous définissons comme un Failure état. Nous avons implémenté notre monade de manière à ce qu'elle puisse porter soit un Success ou une Failure valeur en utilisant des génériques. Nous pouvons l'imaginer comme un Result<TFailure, TSuccess. Vous voyez où je veux en venir ?

En raison du fonctionnement des génériques en C#, une instance de Result doit toujours porter le même type de défaillance. En d'autres termes, toutes nos défaillances doivent avoir le même type - c'est un problème car vous pouvez décider de traiter une erreur d'analyse syntaxique différemment d'une défaillance de l'API.

Nous ne pouvons pas blâmer Monads ou ROP pour ce problème - il s'agit d'une limitation du langage. En comparaison, c'est là qu'un langage comme F# brille, car il nous permet de définir un type d'union discriminé qui peut supporter plusieurs types de défaillances. Encore une fois, l'espoir est permis, l'espoir est permis - les unions discriminées discriminées finiront par arriver en C#.

Bien que nous ne puissions pas ignorer cette limitation, cela ne signifie pas que nous sommes entièrement bloqués. Une façon de regrouper différentes défaillances sous le même type générique est d'utiliser une classe de base ou une interface.

Dans le SDK .NET, j'ai implémenté une fonction IResultFailure interface que toutes les défaillances doivent implémenter. Elle nous permet de regrouper différentes défaillances (ou raisons de défaillance - analyse, authentification, etc.) sur le même chemin d'échec et de définir des comportements dédiés à l'aide de la propriété Type propriété.

public interface IResultFailure
{
    /// <summary>
    ///     The type of failure.
    /// </summary>
    Type Type { get; }

    /// <summary>
    ///     Returns the error message defined in the failure.
    /// </summary>
    /// <returns>The error message.</returns>
    string GetFailureMessage();

    /// <summary>
    ///     Converts the failure to an exception.
    /// </summary>
    /// <returns>The exception.</returns>
    Exception ToException();

    /// <summary>
    ///     Converts the failure to a Result with a Failure state.
    /// </summary>
    /// <typeparam name="T">The underlying type of Result.</typeparam>
    /// <returns>A Result with a Failure state.</returns>
    Result<T> ToResult<T>();
}

Cette solution n'est pas parfaite et n'est probablement pas la plus élégante. Mais cela revient à gérer différents types d'exceptions, comme dans l'exemple suivant exceptions, comme dans l'exemple suivant :

try { ... }
catch (ExceptionA) { ... }
catch (ExceptionB) { ... }
catch (ExceptionC) { ... }

// ---

switch (failure)
{
    case HttpFailure httpFailure:
        DoSomethingWithHttpFailure(httpFailure);
        break;
    case ResultFailure resultFailure:
        DoSomethingWithResultFailure(resultFailure);
        break;
    default:
        DoSomethingWithFailure(failure);
        break;
}

Comme vous pouvez le voir, le ROP nous permet d'utiliser les mêmes fonctionnalités que les exceptions - il ne s'agit pas d'un remplacement, juste d'une alternative à la gestion des erreurs qui semble un peu différente. une alternative à la gestion des erreurs qui se présente de manière un peu différente.

Conclusion

Et voilà notre troisième article de la série "The Monads Invasion". Cette fois-ci, nous nous sommes concentrés sur la programmation orientée (ROP) pour montrer comment vous pouvez utiliser les Monads encore plus loin pour gérer les erreurs.

Ce qu'il faut en retenir ? Il n'y a aucune perte à utiliser les Monads pour la gestion des erreurs par rapport à une approche standard basée sur les exceptions ; tout ce que vous faites avec les exceptions peut être fait avec les Monads. Vous trouverez probablement votre code plus propre, plus facile à lire et plus explicite quant à son intention. plus explicite quant à ses intentions.

Maintenant, la vraie question : êtes-vous prêt à l'essayer ?

Je vous suggère de commencer modestement et d'y aller doucement. Il y a une courbe d'apprentissage comme pour tout ce qui est nouveau, mais persévérez et vous en verrez les avantages. et vous en verrez les avantages.

Si vous avez des questions ou si vous voulez discuter, n'hésitez pas à me contacter sur mon LinkedInou de faire part de vos commentaires sur le .NET SDK repository ou rejoignez-nous sur sur Slack des développeurs de Vonage. Vous pouvez également nous envoyer un message sur @VonageDev sur X. Nous sommes tous dans le même bateau, et votre Voice est importante.

Bon codage, et à plus tard !

Partager:

https://a.storyblok.com/f/270183/384x384/fdffb72c8b/guillaume-faas.png
Guillaume FaasDéveloppeur .Net Senior Advocate

Guillaume est Senior Developer Advocate chez Vonage. Il travaille dans le domaine de .Net depuis près de 15 ans et s'est concentré sur la promotion du Software Craftsmanship au cours des dernières années. Ses sujets de prédilection sont la qualité du code, l'automatisation des tests, le mobbing et les katas du code. En dehors du travail, il aime passer du temps avec sa femme et sa fille, faire de l'exercice ou jouer.