Come dovrebbe una classe comunicare ai propri utenti quale sottoinsieme di metodi implementa?


12

Scenario

Un'applicazione Web definisce un'interfaccia di backend utente IUserBackendcon i metodi

  • getUser (UID)
  • createUser (UID)
  • deleteUser (UID)
  • setPassword (uid, password)
  • ...

Diversi backend utente (ad es. LDAP, SQL, ...) implementano questa interfaccia ma non tutti i backend possono fare tutto. Ad esempio, un server LDAP concreto non consente a questa applicazione Web di eliminare utenti. Quindi la LdapUserBackendclasse che implementa IUserBackendnon verrà implementata deleteUser(uid).

La classe concreta deve comunicare all'applicazione Web cosa può fare l'applicazione Web con gli utenti del back-end.

Soluzione nota

Ho visto una soluzione in cui IUserInterfaceha un implementedActionsmetodo che restituisce un numero intero che è il risultato di OR bit a bit delle azioni AND bit a bit con le azioni richieste:

function implementedActions(requestedActions) {
    return (bool)(
        ACTION_GET_USER
        | ACTION_CREATE_USER
        | ACTION_DELTE_USER
        | ACTION_SET_PASSWORD
        ) & requestedActions)
}

Dove

  • ACTION_GET_USER = 1
  • ACTION_CREATE_USER = 2
  • ACTION_DELETE_USER = 4
  • ACTION_SET_PASSWORD = 8
  • .... = 16
  • .... = 32

eccetera.

Quindi l'applicazione Web imposta una maschera di bit con ciò di cui ha bisogno e implementedActions()risponde in modo booleano se li supporta.

Opinione

Queste operazioni a bit mi sembrano reliquie dell'età C, non necessariamente facili da capire in termini di codice pulito.

Domanda

Qual è un modello moderno (migliore?) Per la classe di comunicare il sottoinsieme dei metodi di interfaccia che implementa? O il "metodo operativo bit" dall'alto è ancora la migliore pratica?

( Nel caso sia importante: PHP, anche se sto cercando una soluzione generale per le lingue OO )


5
La soluzione generale è dividere l'interfaccia. Non IUserBackenddovrebbe contenere affatto il deleteUsermetodo. Questo dovrebbe far parte di IUserDeleteBackend(o come vuoi chiamarlo). Il codice che deve eliminare gli utenti avrà argomenti di IUserDeleteBackend, il codice che non ha bisogno di quella funzionalità utilizzerà IUserBackende non avrà problemi con metodi non implementati.
Bakuriu,

3
Un'importante considerazione di progettazione è se la disponibilità di un'azione dipende dalle circostanze di runtime. Sono tutti i server LDAP che non supportano l'eliminazione? O è una proprietà della configurazione di un server e potrebbe cambiare con un riavvio del sistema? Il tuo connettore LDAP dovrebbe rilevare automaticamente questa situazione o dovrebbe richiedere che la configurazione venga modificata per collegare un connettore LDAP diverso con funzionalità diverse? Queste cose hanno un forte impatto su quali soluzioni sono praticabili.
Sebastian Redl,

@SebastianRedl Sì, è qualcosa che non ho preso in considerazione. In realtà ho bisogno di una soluzione per il runtime. Poiché non volevo invalidare le risposte molto valide, ho aperto una nuova domanda che si concentra sul runtime
problemofficer

Risposte:


24

In generale, ci sono due approcci che puoi adottare qui: test & lancio o composizione tramite polimorfismo.

Prova e lancia

Questo è l'approccio che già descrivi. Tramite alcuni mezzi, si indica all'utente della classe se determinati altri metodi sono implementati o meno. Questo può essere fatto con un singolo metodo e un'enumerazione bit per bit (come descritto) o tramite una serie di supportsDelete()metodi ecc.

Quindi, se supportsDelete()restituisce false, la chiamata deleteUser()potrebbe comportare il NotImplementedExeptionlancio o il metodo semplicemente non fare nulla.

Questa è una soluzione popolare tra alcuni, in quanto è semplice. Tuttavia, molti - me compreso - sostengono che si tratta di una violazione del principio di sostituzione di Liskov (la L in SOLID) e quindi non è una buona soluzione.

Composizione tramite polimorfismo

L'approccio qui è quello di visualizzare IUserBackenduno strumento troppo schietto. Se le classi non riescono sempre a implementare tutti i metodi in quell'interfaccia, suddividere l'interfaccia in parti più mirate. Quindi potresti avere: IGeneralUser IDeletableUser IRenamableUser ... In altre parole, tutti i metodi che tutti i tuoi backend possono implementare entrano IGeneralUsere crei un'interfaccia separata per ciascuna delle azioni che solo alcuni possono eseguire.

In questo modo, LdapUserBackendnon si implementa IDeletableUsere lo si prova usando un test come (usando la sintassi C #):

if (backend is IDeletableUser deletableUser)
{
    deletableUser.deleteUser(id);
}

(Non sono sicuro del meccanismo di PHP per determinare se un'istanza implementa un'interfaccia e come si esegue il cast su tale interfaccia, ma sono sicuro che ci sia un equivalente in quella lingua)

Il vantaggio di questo metodo è che fa buon uso del polimorfismo per consentire al codice di conformarsi ai principi SOLID ed è solo molto più elegante a mio avviso.

Il rovescio della medaglia è che può diventare ingombrante troppo facilmente. Se, ad esempio, si finisce per implementare dozzine di interfacce perché ogni backend concreto ha capacità leggermente diverse, allora questa non è una buona soluzione. Quindi ti consiglio semplicemente di usare il tuo giudizio sul fatto che questo approccio sia pratico per te in questa occasione e di usarlo, se lo è.


4
+1 per considerazioni sulla progettazione SOLID. Sempre bello mostrare le risposte con approcci diversi che manterranno il codice più pulito in avanti!
Caleb,

2
in PHP sarebbeif (backend instanceof IDelatableUser) {...}
Rad80,

Hai già menzionato la violazione di LSP. Sono d'accordo, ma volevo aggiungerlo leggermente: Test & Throw è valido se il valore di input rende impossibile eseguire l'azione, ad esempio passando uno 0 come divisore in un Divide(float,float)metodo. Il valore di input è variabile e l'eccezione copre un piccolo sottoinsieme di possibili esecuzioni. Ma se lanci in base al tipo di implementazione, la sua incapacità di esecuzione è un dato di fatto. L'eccezione copre tutti i possibili input , non solo un sottoinsieme di essi. È come mettere un cartello "pavimento bagnato" su ogni pavimento bagnato in un mondo in cui ogni piano è sempre bagnato.
Flater,

Esiste un'eccezione (gioco di parole non inteso) per il principio di non usare un tipo. Per C #, cioè NotImplementedException. Questa eccezione è prevista per interruzioni temporanee , vale a dire codice non ancora sviluppato ma che verrà sviluppato. Ciò non equivale a decidere definitivamente che una determinata classe non farà mai nulla con un determinato metodo, anche dopo che lo sviluppo è completo.
Flater,

Grazie per la risposta. In realtà avevo bisogno di una soluzione di runtime ma non sono riuscito a enfatizzarlo nella mia domanda. Dato che non volevo invalutare la tua risposta, ho deciso di creare una nuova domanda .
problemofficer

5

La situazione attuale

L'impostazione corrente viola il principio di segregazione dell'interfaccia (I in SOLID).

Riferimento

Secondo Wikipedia, il principio di segregazione dell'interfaccia (ISP) afferma che nessun cliente dovrebbe essere costretto a dipendere da metodi che non utilizza . Il principio di segregazione dell'interfaccia è stato formulato da Robert Martin a metà degli anni '90.

In altre parole, se questa è la tua interfaccia:

public interface IUserBackend
{
    User getUser(int uid);
    User createUser(int uid);
    void deleteUser(int uid);
    void setPassword(int uid, string password);
}

Quindi ogni classe che implementa questa interfaccia deve utilizzare tutti i metodi elencati dell'interfaccia. Nessuna eccezione.

Immagina se esiste un metodo generalizzato:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     backendService.deleteUser(user.Uid);
}

Se dovessi effettivamente farlo in modo che solo alcune delle classi di implementazione siano effettivamente in grado di eliminare un utente, allora questo metodo occasionalmente ti esploderà in faccia (o non farà nulla). Questo non è un buon design.


La tua soluzione proposta

Ho visto una soluzione in cui IUserInterface ha un metodo implementatoActions che restituisce un numero intero che è il risultato di OR bit a bit delle azioni AND bit a bit con le azioni richieste.

Quello che essenzialmente vuoi fare è:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     if(backendService.canDeleteUser())
         backendService.deleteUser(user.Uid);
}

Sto ignorando il modo in cui determiniamo esattamente se una determinata classe è in grado di eliminare un utente. Che si tratti di un booleano, un po 'di bandiera, ... non importa. Tutto si riduce a una risposta binaria: può eliminare un utente, sì o no?

Ciò risolverebbe il problema, giusto? Beh, tecnicamente, lo fa. Ma ora stai violando il principio di sostituzione di Liskov (la L in SOLID).

Rinunciando alla spiegazione piuttosto complessa di Wikipedia, ho trovato un esempio decente su StackOverflow . Prendi nota dell'esempio "cattivo":

void MakeDuckSwim(IDuck duck)
{
    if (duck is ElectricDuck)
        ((ElectricDuck)duck).TurnOn();

    duck.Swim();
}

Presumo che tu veda la somiglianza qui. È un metodo che dovrebbe gestire un oggetto astratto ( IDuck, IUserBackend), ma a causa di un design di classe compromesso, è costretto a gestire prima implementazioni specifiche ( ElectricDuck, assicurarsi che non sia una IUserBackendclasse che non può eliminare gli utenti).

Ciò vanifica lo scopo di sviluppare un approccio astratto.

Nota: l'esempio qui è più semplice da risolvere rispetto al caso. Per l'esempio, è sufficiente ElectricDuckattivare l'attivazione all'interno del Swim()metodo. Entrambe le anatre sono ancora in grado di nuotare, quindi il risultato funzionale è lo stesso.

Potresti voler fare qualcosa di simile. Non farlo . Non puoi semplicemente fingere di eliminare un utente ma in realtà hai un corpo del metodo vuoto. Sebbene funzioni dal punto di vista tecnico, è impossibile sapere se la tua classe di implementazione farà effettivamente qualcosa quando gli viene chiesto di fare qualcosa. Questo è un terreno fertile per il codice non mantenibile.


La mia soluzione proposta

Ma hai detto che è possibile (e corretto) per una classe di implementazione gestire solo alcuni di questi metodi.

Per esempio, diciamo che per ogni possibile combinazione di questi metodi, esiste una classe che lo implementerà. Copre tutte le nostre basi.

La soluzione qui è dividere l'interfaccia .

public interface IGetUserService
{
    User getUser(int uid);
}

public interface ICreateUserService
{
    User createUser(int uid);
}

public interface IDeleteUserService
{
    void deleteUser(int uid);
}

public interface ISetPasswordService
{
    void setPassword(int uid, string password);
}

Nota che avresti potuto vederlo arrivare all'inizio della mia risposta. Il nome del Principio di segregazione dell'interfaccia rivela già che questo principio è progettato per separare le interfacce in misura sufficiente.

Ciò ti consente di combinare le interfacce come preferisci:

public class UserRetrievalService 
              : IGetUserService, ICreateUserService
{
    //getUser and createUser methods implemented here
}

public class UserDeleteService
              : IDeleteUserService
{
    //deleteUser method implemented here
}

public class DoesEverythingService 
              : IGetUserService, ICreateUserService, IDeleteUserService, ISetPasswordService
{
    //All methods implemented here
}

Ogni classe può decidere cosa vogliono fare, senza interrompere il contratto della loro interfaccia.

Ciò significa anche che non è necessario verificare se una determinata classe è in grado di eliminare un utente. Ogni classe che implementa l' IDeleteUserServiceinterfaccia sarà in grado di eliminare un utente = nessuna violazione del principio di sostituzione di Liskov .

public void HaveUserDeleted(IDeleteUserService backendService, User user)
{
     backendService.deleteUser(user.Uid); //guaranteed to work
}

Se qualcuno tenta di passare un oggetto che non viene implementato IDeleteUserService, il programma rifiuterà di compilare. Ecco perché ci piace avere la sicurezza dei tipi.

HaveUserDeleted(new DoesEverythingService());    // No problem.
HaveUserDeleted(new UserDeleteService());        // No problem.
HaveUserDeleted(new UserRetrievalService());     // COMPILE ERROR

Nota

Ho portato l'esempio all'estremo, separando l'interfaccia nei blocchi più piccoli possibili. Tuttavia, se la tua situazione è diversa, puoi cavartela con pezzi più grandi.

Ad esempio, se ogni servizio in grado di creare un utente è sempre in grado di eliminare un utente (e viceversa), è possibile mantenere questi metodi come parte di un'unica interfaccia:

public interface IManageUserService
{
    User createUser(int uid);
    void deleteUser(int uid);
}

Non vi è alcun vantaggio tecnico nel fare questo invece di separarsi dai blocchi più piccoli; ma renderà lo sviluppo leggermente più semplice perché richiede meno galvaniche.


+1 per suddividere le interfacce in base al comportamento che supportano, che è l'intero scopo di un'interfaccia.
Greg Burghardt,

Grazie per la risposta. In realtà avevo bisogno di una soluzione di runtime ma non sono riuscito a enfatizzarlo nella mia domanda. Dato che non volevo invalutare la tua risposta, ho deciso di creare una nuova domanda .
problemofficer

@problemofficer: la valutazione del runtime per questi casi è raramente l'opzione migliore, ma in effetti ci sono casi per farlo. In tal caso, o si crea un metodo che può essere chiamato ma può finire per non fare nulla (chiamarlo TryDeleteUserper riflettere quello); oppure hai il metodo di lanciare intenzionalmente un'eccezione se è una situazione possibile ma problematica. L'uso di un approccio metodo CanDoThing()e DoThing()funziona, ma richiederebbe ai tuoi chiamanti esterni di usare due chiamate (e di essere punito per non averlo fatto), che è meno intuitivo e non così elegante.
Flater

0

Se si desidera utilizzare tipi di livello superiore, è possibile scegliere il tipo impostato nella lingua prescelta. Si spera che fornisca un po 'di zucchero di sintassi per fare intersezioni fisse e determinazione del sottoinsieme.

Questo è fondamentalmente ciò che Java fa con EnumSet (meno lo zucchero della sintassi, ma ehi, è Java)


0

Nel mondo .NET è possibile decorare metodi e classi con attributi personalizzati. Questo potrebbe non essere rilevante per il tuo caso.

Mi sembra però che il problema che hai potrebbe essere più a che fare con un livello superiore del design.

Se questa è una funzionalità dell'interfaccia utente, come una pagina o un componente di modifica degli utenti, come vengono mascherate le diverse funzionalità? In questo caso, "test and throw" sarà un approccio piuttosto inefficiente a tale scopo. Si presuppone che prima di caricare ogni pagina si esegua una chiamata simulata a ciascuna funzione per determinare se il widget o l'elemento deve essere nascosto o presentato in modo diverso. In alternativa, hai una pagina web che costringe sostanzialmente l'utente a scoprire ciò che è disponibile tramite 'test and throw' manuale, qualunque sia il percorso di codifica che prendi, perché l'utente non scopre che qualcosa non è disponibile fino a quando non viene visualizzato un avviso a comparsa.

Quindi, per un'interfaccia utente, potresti voler esaminare il modo in cui gestisci le funzionalità e legare la scelta delle implementazioni disponibili a quella, piuttosto che le implementazioni selezionate guidano quali funzionalità possono essere gestite. Potresti voler esaminare i framework per la composizione delle dipendenze delle funzionalità e definire esplicitamente le funzionalità come entità nel tuo modello di dominio. Questo potrebbe anche essere legato all'autorizzazione. In sostanza, decidere se una funzionalità è disponibile o meno in base al livello di autorizzazione può essere esteso a decidere se una funzionalità è effettivamente implementata, e quindi le "caratteristiche" dell'interfaccia utente di alto livello possono avere mappature esplicite ai set di capacità.

Se si tratta di un'API Web, la scelta di progettazione complessiva potrebbe essere complicata dal fatto di dover supportare più versioni pubbliche dell'API "Gestisci utente" o REST "Utente" man mano che le funzionalità si espandono nel tempo.

Quindi, per riassumere, nel mondo .NET è possibile sfruttare vari modi di riflessione / attributo per determinare in anticipo quali classi implementano cosa, ma in ogni caso sembra che i veri problemi saranno in ciò che si fa con tali informazioni.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.