ROP con c#9

Recentemente abbiamo avuto l’esigenza di integrare un servizio di firma digitale in un nostro progetto. Il servizio richiede l’esecuzione di diversi step consecutivi per poter essere completato:

  • autorizzazione
  • configurazione del processo e caricamento dei files
  • invio richiesta di firma

In caso di risultato positivo l’applicativo deve propagare l’identificativo del processo di firma al resto del “programma”; in caso di errore deve rilevare con il maggior dettaglio possibile la motivazione e lo step nel quale il processo si è interrotto.

Per poter interagire con le API del provider abbiamo utilizzato un SDK che fornisce un metodo per ciascuna delle operazioni da svolgere. Ogni metodo restituisce un risultato in caso di successo oppure un’eccezione SignDocumentException in caso di errore.

Per nostra sfortuna, l’eccezione è identica per tutte le operazioni e di conseguenza “catchare” SignDocumentException non è sufficiente per distinguere il tipo di errore che si è verificato e differenziare la sua gestione (come ad esempio restituire un feedback all’utente che permetta di capire dove il processo si è interrotto). Una prima soluzione al problema ha portato al seguente codice:

static Guid SignDocument(string fileName, string userName)
{
    Guid token;
    LoadResult loadResult;
    try
    {
        token = SignDocument.Authorize(userName);
    }
    catch (SignDocumentException exc)
    {
        throw new Exception("Errore durante la fase di autorizzazione.", exc);
    }

    try
    {
        loadResult = SignDocument.Load(token, fileName
    }
    catch (SignDocumentException exc)
    {
        throw new Exception("Errore durante la fase di caricamento del file.", exc);
    }

    try
    {
        return SignDocument.Sign(loadResult);
    }
    catch (SignDocumentException exc)
    {
        throw new Exception("Errore durante la fase di invio del file al provider di firma digitale.", exc);
    }
}


Ogni step del flusso è implementato con un blocco try… catch ad-hoc che racchiude il metodo esposto dall’SDK. Ogni blocco può restituire un risultato oppure intercettare l’eccezione e lanciarne un’altra con un messaggio più appropriato.


L’implementazione è di per sé “pulita”, ma la ripetizione del costrutto try … catch ci ha fatto “storcere il naso” e ci ha spinto ad investigare soluzioni alternative. Ci siamo chiesti se con le nuove funzionalità introdotte da .net 5 e C# 9, ed in particolare con l’uso del pattern matching, fosse possibile migliorare l’implementazione.

Railway Oriented Programming

(grassetti) Cercando ulteriori soluzioni al nostro “problema”, siamo venuti a conoscenza della tecnica Railway Oriented Programming, tipica della programmazione funzionale. Il modo in cui essa consente di modellizzare questo genere di scenari viene solitamente descritto con la metafora di una linea ferroviaria costituita da due binari (tracks): il binario verde che rappresenta il successo ed il binario rosso che rappresenta l’errore. Il programma inizia ad eseguire la sequenza di operazioni previste lungo il binario verde; quando un’operazione fallisce, l’esecuzione si sposta lungo il binario rosso fino al termine della sequenza delle operazioni.

Ogni step rappresenta un metodo e può essere visto come un raccordo che si “collega” ad entrambi i binari della linea; in caso di errore, comporta il dirottamento del flusso di esecuzione dal binario del successo al binario dell’errore.

Ogni metodo/blocco ha la facoltà di:

  • non eseguire alcuna operazione se uno dei metodi precedenti ha prodotto un errore;
  • eseguire l’operazione se tutti i metodi precedenti sono stati completati con successo e produrre un nuovo risultato, eventualmente utilizzando il risultato del metodo precedente.

Cercando di applicare questo approccio al nostro scenario e sfruttando i costrutti di .net 5 e C# 9, abbiamo evoluto il programma fino alla seguente soluzione:

    public static Result<SignData> SignDocument(string fileName, string userName)
    {
        SignData input = new SignData(fileName, userName);
        return Authorize(input)
            .Then(Load)
            .Then(Sign)
            .ToResult();
    }

Come siamo arrivati a questo risultato? Siamo partiti rappresentando i binari (tracks) attrverso le interfacce ISuccessfulTrack (binario verde) e IErrorTrack (binario rosso).

    public interface ITrack<T> { }
    
    public interface ISuccessfulTrack<T> : ITrack<T>
    {
        T GetResult();
    }
    
    public interface IErrorTrack<T> : ITrack<T>
    {
        string GetError();
    }


Per l’implementazione dei binari abbiamo introdotto l’uso dei record. Ecco i nostri binari:

    public record Success<T>(T Result) : ISuccessfulTrack<T>
    {
        public T GetResult() => Result;
    }

    public record Error<T>(string ErrorMessage) : IErrorTrack<T>
    {
        public string GetError() => ErrorMessage;
    }

Il metodo Then, sfruttando la potenza del pattern matching di C#9, consente di eseguire la funzione vera e propria oppure, se siamo già sul binario rosso, terminare l’esecuzione del programma “senza passare dal via”.

    public static ITrack<Tout> Then<Tin, Tout>(this ITrack<Tin> prevResult, Func<Tin, ITrack<Tout>> doWork) => prevResult switch
    {
        ISuccessfulTrack<Tin> success => doWork(success.GetResult()),
        IErrorTrack<Tin> error => new Error<Tout>(error.GetError()),
        _ => new Error<Tout>("Invalid input type")
    };


Se siamo sul binario verde, la funzione da eseguire è quella definita dal metodo doWork passato come argomento al metodo Then.

Per rappresentare il risultato finale e quindi “unificare” i due binari abbiamo introdotto la classe Result. La proprietà IsSuccess permette al chiamante di gestire facilmente il risultato del processo.

    public class Result<T>
    {
        public bool IsSuccess { get; init; }
        public T Success { get; init; }
        public string Error { get; init; }

        public static Result<T> FromSuccessfulTrack(ISuccessfulTrack<T> successResult) => new Result<T> { 
            IsSuccess = true,
            Success = successResult.GetResult(),
            Error = string.Empty
        };
        
        public static Result<T> FromErrorTrack(IErrorTrack<T> errorResult) => new Result<T>
        {
            IsSuccess = false,
            Success = default(T),
            Error = errorResult.GetError()
        };
    }


Di seguito il metodo ToResult che unifica i due binari

    public static Result<Tin> ToResult<Tin>(this ITrack<Tin> prevResult) => prevResult switch
    {
        ISuccessfulTrack<Tin> success => Result<Tin>.FromSuccessfulTrack(success),
        IErrorTrack<Tin> error => Result<Tin>.FromErrorTrack(error),
        _ => Result<Tin>.FromErrorTrack(new Error<Tin>("Invalid input type in Result method."))
    };


Il record SignData ha lo scopo di collezionare i dati necessari per l’esecuzione del processo, sia i parametri di input che i risultati parziali dei singoli step.

    public record SignData(string FileName, string UserName)
    {
        public Guid AuthorizationToken { get; init; }
        public LoadResult LoadResult { get; init; }
        public Guid SignId { get; init; }
    };

Ad ogni step viene generata una nuova istanza immutabile di SignData sfruttando il costrutto with di .net 5 e C#9 che consente di valorizzare le informazioni “strada facendo”, proprio come un treno che viaggia lungo i binari.

Ognuno dei metodi che consentono l’implementazione del flusso, restituisce un oggetto Success oppure Error in base all’esito dell’operazione. Ciò consente al blocco successivo di proseguire sul binario verde oppure su quello rosso.

    public static ITrack<SignData> Load(SignInput input)
    {
        try
        {
            LoadResult loadResult = SignDocument.Load(input.AuthorizationToken, input.FileName);
            return new Success<SignData>(input with { LoadResult = loadResult });
        }
        catch (Exception)
        {
            return new Error<SignData>("Errore durante la fase di caricamento del file.");
        }
    }

Conclusioni

Indipendentemente dalla nostra implementazione volutamente semplificata, l’applicazione della tecnica Railway Oriented Programming tipica della programmazione funzionale, ha consentito di standardizzare la modalità con la quale ogni metodo tratta l’errore e di concentrare l’attenzione, in ogni metodo, sulla trasformazione dell’input per la produzione di un risultato intermedio.

Sicuramente la semplicità del risultato finale si ottiene a patto di “scrivere” un numero maggiore di righe codice under-the-hood per la creazione della struttura di gestione delle tracks, che però può essere riutilizzata per gestire scenari analoghi.