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

12

Scenario

Un'applicazione web definisce un'interfaccia di backend utente IUserBackend con i metodi

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

Diversi backend dell'utente (ad esempio 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. Pertanto la classe LdapUserBackend che implementa IUserBackend non implementerà deleteUser(uid) .

La classe concreta deve comunicare all'applicazione web ciò che l'applicazione Web è autorizzata a fare con gli utenti del back-end.

Soluzione nota

Ho visto una soluzione in cui IUserInterface ha un metodo implementedActions che restituisce un 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

ecc.

Quindi l'applicazione web imposta una maschera di bit con ciò di cui ha bisogno e implementedActions() risponde con un booleano indipendentemente dal fatto che li supporti.

PARERE

Queste operazioni bit mi sembrano reliquie dal C age, non necessariamente facili da capire in termini di codice pulito.

Domanda

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

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

    
posta problemofficer 27.04.2018 - 04:22
fonte

4 risposte

24

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

Test & gettare

Questo è l'approccio che già descrivi. Con 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 a bit (come descrivi), o tramite una serie di metodi supportsDelete() etc.

Quindi, se supportsDelete() restituisce false , chiamare deleteUser() potrebbe causare il lancio di una NotImplementedExeption , oppure il metodo non sta facendo nulla.

Questa è una soluzione popolare tra alcune, 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 soluzione piacevole.

Composizione tramite polimorfismo

L'approccio qui è di visualizzare IUserBackend come strumento troppo smussato. Se le classi non possono sempre implementare tutti i metodi nell'interfaccia, suddividere l'interfaccia in parti più focalizzate. Quindi potresti avere: %codice% In altre parole, tutti i metodi che tutti i tuoi back-end possono implementare vanno in IGeneralUser IDeletableUser IRenamableUser ... e tu crei un'interfaccia separata per ciascuna delle azioni che solo alcuni possono eseguire.

In questo modo, IGeneralUser non implementa LdapUserBackend e tu prova per quello usando un test come (usando la sintassi di C #):

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

(Non sono sicuro del meccanismo in PHP per determinare se un'istanza implementa un'interfaccia e come tu lanci su quell'interfaccia, ma sono sicuro che c'è un equivalente in quella lingua)

Il vantaggio di questo metodo è che fa buon uso del polimorfismo per consentire al codice di rispettare i principi SOLID ed è semplicemente più elegante a mio avviso.

Il rovescio della medaglia è che può diventare ingombrante fin troppo facilmente. Se, per esempio, si finisce per dover 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 su se questo approccio è pratico per te in questa occasione e usarlo, se lo è.

    
risposta data 27.04.2018 - 07:32
fonte
5

La situazione attuale

La configurazione corrente viola il principio di segregazione dell'interfaccia (I in SOLID).

Reference

According to Wikipedia the interface segregation principle (ISP) states that no client should be forced to depend on methods it does not use. The interface segregation principle was formulated by Robert Martin in the mid 1990s.

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 la classe ogni che implementa questa interfaccia deve utilizzare il ogni metodo elencato 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, questo metodo a volte esploderà in faccia (o non farà nulla) . Non è un buon design.

La tua soluzione proposta

I have seen a solution where the IUserInterface has a implementedActions method that returns a integer which is the result of bitwise ORs of the actions bitwise ANDed with the requested actions.

Ciò che essenzialmente vuoi fare è:

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

Sto ignorando in che modo 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ò cancellare un utente, sì o no?

Questo risolverebbe il problema, giusto? Bene, tecnicamente, lo fa. Ma ora stai violando il Principio di sostituzione di Liskov (la L in SOLID).

Rinunciare alla spiegazione di Wikipedia piuttosto complessa, 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();
}

Suppongo 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 specifico implementazioni ( ElectricDuck , assicurati che non sia una classe IUserBackend che non può eliminare utenti).

Questo sconfigge lo scopo di sviluppare un approccio astratto.

Nota: l'esempio qui è più facile da correggere rispetto al tuo caso. Per l'esempio, è sufficiente che ElectricDuck giri su dentro il metodo Swim() . Entrambe le anatre sono ancora in grado di nuotare, quindi il risultato funzionale è lo stesso.

Potresti voler fare qualcosa di simile. Non . Non puoi semplicemente fingere di eliminare un utente, ma in realtà avere un corpo di metodo vuoto. Sebbene funzioni da un punto di vista tecnico, rende impossibile sapere se la tua classe di implementazione farà effettivamente qualcosa quando viene richiesto 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 combinazione possibile di questi metodi, c'è una classe che la 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 vedere questo arrivo all'inizio della mia risposta. Il nome Interfaccia Segregation Principle rivela già che questo principio è progettato per farti 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 quale vuole fare, senza interrompere il contratto della sua interfaccia.

Questo significa anche che non è necessario verificare se una determinata classe è in grado di eliminare un utente. Ogni classe che implementa l'interfaccia IDeleteUserService 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 implementa IDeleteUserService , il programma rifiuterà di compilare. Questo è il motivo per cui ci piace avere la sicurezza del tipo.

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

nota

Ho portato l'esempio a un estremo, separando l'interfaccia in blocchi più piccoli possibili. Tuttavia, se la tua situazione è diversa, puoi farla franca con pezzi più grandi.

Ad esempio, se ogni servizio che crea un utente è sempre in grado di eliminare un utente (e viceversa), puoi mantenere questi metodi come parte di un interfaccia singola:

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

Non c'è alcun vantaggio tecnico a fare questo invece di separare i pezzi più piccoli; ma renderà lo sviluppo un po 'più facile perché richiede meno calcoli.

    
risposta data 27.04.2018 - 14:14
fonte
0

Se si desidera utilizzare tipi di livello superiore, è possibile utilizzare il tipo di set nella lingua preferita. Speriamo che fornisca dello zucchero di sintassi per le intersezioni e la determinazione del sottoinsieme.

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

    
risposta data 27.04.2018 - 07:30
fonte
0

Nel mondo .NET puoi decorare metodi e classi con attributi personalizzati. Questo potrebbe non essere pertinente al tuo caso.

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

Se questa è una caratteristica dell'interfaccia utente, ad esempio una pagina o un componente utente di modifica, come vengono mascherate le diverse funzionalità? In questo caso 'test and throw' sarà un approccio abbastanza 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 debba essere nascosto o presentato in modo diverso. In alternativa, si dispone di una pagina Web che fondamentalmente obbliga l'utente a scoprire ciò che è disponibile manualmente "test and throw", a prescindere dal percorso di codifica che si intraprende, poiché l'utente non scopre che qualcosa non è disponibile fino a quando non viene visualizzato un avviso a comparsa.

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

Se si tratta di un'API Web, la scelta generale di progettazione 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 puoi sfruttare vari modi di Reflection / Attribute per determinare in anticipo quali classi implementano cosa, ma in ogni caso sembra che i veri problemi si trovino in quello che fai con quelle informazioni .

    
risposta data 27.04.2018 - 09:03
fonte

Leggi altre domande sui tag