Costruire una DataSession custom con Chakra.Core

Lo scopo primario del framework Chakra è sempre stato quello di fornire delle strutture e guidelines per la scrittura di applicazioni enterprise che fossero al tempo stesso robuste, modulari e facilmente manutenibili nel corso del tempo.

Ma, visto che il tutto è farina del mio sacco, e che mi ritengo uno sviluppatore assolutamente nella media, non ho mai pensato che bastassero un pugno di classi ed interfacce per mettere in piedi una struttura tanto ambiziosa.

E’ con questo spirito che va approcciato questo framework. Farsi aiutare da alcune linee guida che indirizzano lo sviluppo, con la maggior parte del lavoro demandata alle qualità dello sviluppatore che scrive il codice: è lui l’unico responsabile della qualità del prodotto finale.

In questo post il mio scopo è mostrare come è possibile utilizzare alcune delle interfacce “meno conosciute” contenute in Chakra, per realizzare un provider custom di accesso dati. L’obiettivo è realizzare una di quelle feature tanto care a Chakra che è la strutturazione di un sistema completamente “Database Agnostic”.

Prima di tutto partiamo da un semplice esempio di come avviene l’inizializzazione di una Data Session fatta con un provider nativo già presente nel framework, quello “Mock”:

IDataSession dataSession = SessionFactory.OpenSession<MockupDataSession>()

L’istruzione utilizza la classe statica SessionFactory per aprire una nuova sessione dati - immaginatela come una astrazione di una connessione al vostro database - e manda in uscita un’istanza della sessione stessa attraverso la sua interfaccia generica.

L’istruzione successiva per mandare in esecuzione un discovery dei repository che implementano quel particolare provider di riferimento (quello “Mock” in questo caso) è la seguente:

var productRepository = dataSession.ResolveRepository<IProductRepository>();

Come è ipotizzabile, se non avete nel vostro progetto o nei vostri riferimenti alcuna classe che implementa IProductRepository e sia formalmente legata al provider Mock, otterrete un errore a runtime che vi informa di questa situazione.

La vera domanda è questa: dove è nascosta la dipendenza di MockupDataSession - che abbiamo utilizzato prima per indirizzare la creazione dell’istanza di DataSession - e l’istanza del repository che implementa IProductRepository e che sarà realizzato usando la classe MockProductRepository?

La risposta è nascosta dalle interfacce che implementano sia MockupDataSession che MockProductRepository, passando per MockupDataTransation (di cui non abbiamo ancora parlato e parleremo solo marginalmente).

Vediamo un esempio pratico, partendo dalla nostra volontà di realizzare un provider custom che invece che andare su un database locale, utilizza come storage un servizio web remoto. L’obiettivo è che ogni entità che passa dai repository possa essere “creata-letta-aggiornata-cancellata” (C.R.U.D.) da dal nostro servizio web remoto.

Partiamo con l’implementazione di una DataSession custom che chiameremo RemoteApiDataSession.

public class RemoteApiDataSession : IDataSession
{
    private bool _IsDisposed;
    public HttpClient Client { get; }

    public RemoteApiDataSession()
    {
        //Inizializzazione del client HTTP con l'indirizzo base
        //che magari è meglio mettere nei settings ;)
        var baseUrl = "http://remoteapi.something.com/";
        Client = new HttpClient { BaseAddress = new Uri(baseUrl) };
    }

    public TRepositoryInterface ResolveRepository<TRepositoryInterface>()
        where TRepositoryInterface : IRepository
    {
        //Utilizzo il metodo presente sull'helper per generare il repository
        return RepositoryHelper.Resolve<TRepositoryInterface, IRemoteApiRepository>(this);
    }

    public IDataTransaction Transaction { get; private set; }

    public IDataTransaction BeginTransaction()
    {
        return new RemoteApiDataTransaction(this);
    }

    public TOutput As<TOutput>()
        where TOutput : class
    {
        //Se il tipo di destinazione non è lo stesso dell'istanza 
        //corrente emetto un'eccezione per indicare errore
        if (GetType() != typeof(TOutput))
            throw new InvalidCastException(string.Format("Unable to convert data session of " +
                "type '{0}' to requested type '{1}'.", GetType().FullName, typeof(TOutput).FullName));

        //Eseguo la conversione e ritorno
        return this as TOutput;
    }

    public void SetActiveTransaction(IDataTransaction dataTransaction)
    {
        //Validazione argomenti
        if (dataTransaction == null) throw new ArgumentNullException(nameof(dataTransaction));

        //Imposto la transazione
        Transaction = dataTransaction;
    }

    ~RemoteApiDataSession()
    {
        //Richiamo i dispose implicito
        Dispose(false);
    }

    public void Dispose()
    {
        //Eseguo una dispose esplicita
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool isDisposing)
    {
        //Se l'oggetto è già rilasciato, esco
        if (_IsDisposed)
            return;

        //Se è richiesto il rilascio esplicito
        if (isDisposing)
        {
            //Rilascio eventualmente il client
        }

        //Marco il dispose e invoco il GC
        _IsDisposed = true;
    }
}

In questa implementazione troviamo già un paio di interfacce/classi che normalmente non si vedono o utilizzano nell’inizializzazione dei repository e delle sessioni dati, ma che sono fondamentali per creare un legame tra la sessione stessa e l’istanza di repository: RemoteApiDataTransaction e IRemoteApiRepository.

public class RemoteApiDataTransaction: IDataTransaction
{
    private bool _IsDisposed;
    private readonly RemoteApiDataSession _DataSession;
    public bool IsActive { get; private set; }
    public bool WasRolledBack { get; private set; }
    public bool WasCommitted { get; private set; }
    public bool IsTransactionOwner { get; protected set; }

    public RemoteApiDataTransaction(RemoteApiDataSession dataSession)
    {
        //Validazione argomenti
        if (dataSession == null) throw new ArgumentNullException(nameof(dataSession));

        //Imposto lo stato iniziale
        IsActive = true;
        IsTransactionOwner = true;
        WasCommitted = false;
        WasRolledBack = false;

        //Imposto la data session
        _DataSession = dataSession;

        //Se già esiste una transanzione sull'holder, esco
        if (_DataSession.Transaction != null)
            return;

        //Imposto l'istanza corrente
        _DataSession.SetActiveTransaction(this);
    }

    public void Commit()
    {
        //Se l'istanza è la proprietaria della transazione
        if (IsTransactionOwner)
        {
            //Imposto i flag per commit
            IsActive = false;
            WasCommitted = true;
            WasRolledBack = false;

            //Rimuovo il riferimento alla transazione
            _DataSession.SetActiveTransaction(this);
        }
    }

    public void Rollback()
    {
        //Se l'istanza è la proprietaria della transazione
        if (IsTransactionOwner)
        {
            //Imposto i flag per rollbak
            IsActive = false;
            WasCommitted = false;
            WasRolledBack = true;

            //Rimuovo il riferimento alla transazione
            _DataSession.SetActiveTransaction(this);
        }
    }

    ~RemoteApiDataTransaction()
    {
        //Richiamo i dispose implicito
        Dispose(false);
    }

    public void Dispose()
    {
        //Eseguo una dispose esplicita
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool isDisposing)
    {
        //Se l'oggetto è già rilasciato, esco
        if (_IsDisposed)
            return;

        //Se è richiesto il rilascio esplicito
        if (isDisposing)
        {
            //Se l'istanza è proprietaria e non ho chiuso, eccezione
            if (IsTransactionOwner && IsActive)
                throw new InvalidOperationException("Transaction was opened but never commited or rolled back.");
        }

        //Marco il dispose e invoco il GC
        _IsDisposed = true;
    }
}

La struttura RemoteApiDataTransaction immaginiamola come se fosse una transazione dati su cui è possibile fare “commit” o “rollback”. Si tratta però solo di una astrazione che serve per indirizzare quei “motori di database” che sono in grado di supportare le transazioni nativamente; solitamente solo i database relazionali (ma non tutti) e secondo modalità e stati che dipendono fortemente dalla natura del database stesso. Questa classe deve implementare IDataTransaction e tutte le sue caratteristiche.

Nel nostro caso, essendo il nostro “database” un servizio web, la transazione non esiste, e quindi l’implementazione presentata è assolutamente innoqua. Ma è necessario averla perchè la struttura di Chakra prevede che si approcci lo sviluppo del Business Layer applicativo senza conoscere la natura dello storage che sta sotto, e che quindi può essere cambiato a piacimento senza impattare minimamente sul funzionamento del sistema.

Cosa assolutamente differente è l’interfaccia IRemoteApiRepository che rappresenta di fatto la base per tutte le classi di repository che saranno afferenti alla data session RemoteApiDataSession.

public interface IRemoteApiRepository: IRepository
{
}

E’ semplicissima: implementa l’interfaccia base IRepository e non fornisce alcun metodo custom, ma serve unicamente come placeholder per permettere al motore di Chakra di fare il discovery dinamico delle implementazioni legate a RemoteApiDataSession.

Se ritorniamo rapidamente ad un metodo implementato in RemoteApiDataSession possiamo notare una cosa fondamentale; l’interfaccia in questione è utilizzata da un helper interno di Chakra per identificare la classe in questione sulla base di due vincoli:

  • Cerca una classe che implementi TRepositoryInterface, dove il tipo generico è l’interfaccia del repository da risolvere (es. IProductRepository).
  • Cerca una classe che implementi IRemoteApiRepository
public TRepositoryInterface ResolveRepository<TRepositoryInterface>()
        where TRepositoryInterface : IRepository
{
    return RepositoryHelper.Resolve<TRepositoryInterface, IRemoteApiRepository>(this);
}

Quindi, quello che rimane da fare è realizzare una classe con quelle caratteristiche, che quindi possa essere scoperta e innestata nel momento in qui la data session chiede la “Resolve” sul repository stesso.

Facciamo le cose per bene e facciamo una classe base, che tutti i repository con le stesse caratteristiche implementeranno:

public abstract class RemoteApiRepositoryBase<TEntity> : IRepository<TEntity>, IRemoteApiRepository
    where TEntity : class, IEntity, new()
{
    private bool _IsDisposed;
    protected RemoteApiDataSession DataSession { get; }

    protected RemoteApiRepositoryBase(IDataSession dataSession)
    {
        //Validazione argomenti
        if (dataSession == null) throw new ArgumentNullException(nameof(dataSession));

        //Tento il cast della sessione generica a RemoteApiDataSession
        if (!(dataSession is RemoteApiDataSession currentSession))
            throw new InvalidCastException(string.Format("Specified session of type '{0}' cannot be converted to type '{1}'.",
                dataSession.GetType().FullName, typeof(RemoteApiDataSession).FullName));

        //Imposto la proprietà della sessione
        DataSession = currentSession;
    }

    public virtual TEntity GetSingle(Expression<Func<TEntity, bool>> expression)
    {
        //TODO Implementare la chiamata remota usando l'HttpClient presente nella DataSession locale
    }

    public virtual IList<TEntity> Fetch(Expression<Func<TEntity, bool>> filterExpression = null, int? startRowIndex = null,
        int? maximumRows = null, Expression<Func<TEntity, object>> sortExpression = null, bool isDescending = false)
    {
        //TODO Implementare la chiamata remota usando l'HttpClient presente nella DataSession locale
    }

    public virtual int Count(Expression<Func<TEntity, bool>> filterExpression = null)
    {
        //TODO Implementare la chiamata remota usando l'HttpClient presente nella DataSession locale
    }

    public virtual void Save(TEntity entity)
    {
        //TODO Implementare la chiamata remota usando l'HttpClient presente nella DataSession locale
    }

    public bool IsValid(TEntity entity)
    {
        //Validazione argomenti
        if (entity == null) throw new ArgumentNullException(nameof(entity));

        //Utilizzo il metodo di validazione
        var validations = Validate(entity);

        //E' valido se non ho validazioni errate
        return validations.Count == 0;
    }

    public IList<ValidationResult> Validate(TEntity entity)
    {
        //Validazione argomenti
        if (entity == null) throw new ArgumentNullException(nameof(entity));

        //Utilizzo l'helper per eseguire l'operazione
        return RepositoryHelper.Validate(entity, DataSession);
    }

    public virtual void Delete(TEntity entity)
    {
        //TODO Implementare la chiamata remota usando l'HttpClient presente nella DataSession locale
    }

    ~RemoteApiRepositoryBase()
    {
        //Richiamo i dispose implicito
        Dispose(false);
    }

    public void Dispose()
    {
        //Eseguo una dispose esplicita
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Performs application-defined tasks associated with freeing, 
    /// releasing, or resetting unmanaged resources.
    /// </summary>
    /// <param name="isDisposing">Explicit dispose</param>
    protected virtual void Dispose(bool isDisposing)
    {
        //Se l'oggetto è già rilasciato, esco
        if (_IsDisposed)
            return;

        //Se è richiesto il rilascio esplicito
        if (isDisposing)
        {
            //Rilascio della logica non finalizzabile
            //=> Qui non è necessario
        }

        //Marco il dispose e invoco il GC
        _IsDisposed = true;
    }
}

Avere un repository di base ci semplifica drasticamente l’implementazione dei repository reali se questi non hanno metodi custom:

[Repository]
public class RemoteApiProductRepository: RemoteApiRepositoryBase<Product>, IProductRepository
{
    public RemoteApiProductRepository(IDataSession dataSession)
        : base(dataSession) { }
}

Attenzione! Ricordatevi sempre di marcare il repository appena creato con l’attributo [Repository] (lo trovate nel namespace ZenProgramming.Chakra.Core.Data.Repositories.Attributes) per assicurarsi che il motore di discovery di Chakra sia in grado di fare una ricerca ottimizzata considerando solo le classi opportunamente contrassegnate con quell’attributo.

Fatto tutto quanto sopra - dopo aver ovviamente interfacciato l’HttpClient per parlare con i servizio remoto - siamo pronti per fare quanto segue:


//Registrazione della sessione dati di default ad inizializzazione della nostra applicazione
SessionFactory.RegisterDefaultSession<RemoteApiDataSession>();

using (IDataSession dataSession = SessionFactory.OpenSession())
{
    var productRepository = dataSession.ResolveRepository<IProductRepository>();

    var entities = productRepository.GetSingle(p => p.Code == "001");
}

Questo avendo la certezza che la variabile productRepository sia un’istanza della classe RemoteApiProducRepository che abbiamo creato.

Chiudo con una nota. In questo post abbiamo realizzato un provider che lavora con uno storage remoto sotto forma di servizio web. Ma l’approccio è esattamente lo stesso se si volesse, per esempio, implementare un provider per MongoDB oppure per CouchDb; si tratta di recuperare da NuGet i driver specifici per l’engine, e “wrapparli” all’interno di ipotetiche implementazioni di MongoDbDataSession utilizzando le stesse accortezze che ho usato io nell’implementazione presentata.

Alla prossima!
M.

Commenti

  1. Buongiorno Mauro, ottimo articolo. Ti faccio i complimenti per aver implementato Chakra, che sto a poco a poco cercando di impare. A tal proposito ti chiedo se hai pubblicato qualche progetto "demo" online da cui poter trarre spunto per lo sviluppo.

    Inoltre, ho provato a seguire il tuo tutorial ma non mi sono chiari due punti:
    - l'attributo [Repository] deve essere implementato dalla classe Attribute di .NET oppure la sua definizione è già presente all'interno di chakra?
    - Nel codice, al momento della definizione di RemoteApiProductRepository viene riportata l'interfaccia ICountryRepository che però non è mai stata definita. Credo si tratti di un refuso del codice.

    Grazie in anticipo.
    ciao

    RispondiElimina
    Risposte
    1. Ciao. Ti ringrazio davvero per i tuoi apprezzamenti: ti assicuro che non sono per nulla meritati, ma molto apprezzati ;).
      La tua segnalazione riguardante ICountryRepository è corretta; si tratta di un refuso del "copia&incolla&replace" di una soluzione esistente: errore mio, pardon.
      Ho anche integrato un paio di informazioni in più riguardo la posizione dell'attributo [Repository], che trovi all'interno di Chakra (il namespace intero è nell'articolo a beneficio di tutti).
      Purtroppo non ho ancora avuto il tempo materiale di raffinare la demo di utilizzo di Chakra; lo farò nelle prossime settimane appena ho qualche ora di tempo; poi il tutto finirà su GitHub, al pari del codice sorgente di Chakra: anche questo necessita di un paio di raffinamenti prima del "go live"...
      Grazie ancora e alla prossima.
      M.

      Elimina
  2. very informative blog and useful article thank you for sharing with us , keep posting learn more AngularJS5 Online Training Bangalore

    RispondiElimina
  3. Hi,
    Sorry, I don't understand how this framework would help to implement "database agnostic" applications.
    We are used to rely on interfaces to de-couple the main application from the actual database implementation: isn't an IRepository<> interface already well enough to be database-agnostic?
    Further more: using a reflection-based repository resolver instead of dependency injection in my opinion makes unit testing harder. How do you approach unit testing of objects that depend on the DataSession and the underlying repositories?

    Your feedback greatly appreciated

    Thank you, bye

    RispondiElimina

Posta un commento

Post popolari in questo blog

Cancellazione fisica vs cancellazione logica dei dati

Restore di un database SQL Server in un container Docker