Wie soll eine Klasse ihren Benutzern mitteilen, welche Untergruppe von Methoden sie implementiert?


12

Szenario

Eine Webanwendung definiert eine Benutzer-Backend-Oberfläche IUserBackendmit den Methoden

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

Verschiedene Benutzer-Backends (zB LDAP, SQL, ...) implementieren diese Schnittstelle, aber nicht jedes Backend kann alles. Beispielsweise erlaubt ein konkreter LDAP-Server dieser Webanwendung nicht, Benutzer zu löschen. Die LdapUserBackendimplementierende Klasse IUserBackendwird also nicht implementiert deleteUser(uid).

Die konkrete Klasse muss der Webanwendung mitteilen, was die Webanwendung mit den Benutzern des Backends tun darf.

Bekannte Lösung

Ich habe eine Lösung gesehen, bei der IUserInterfacedas eine implementedActionsMethode hat, die eine Ganzzahl zurückgibt, die das Ergebnis von bitweisen ODER-Verknüpfungen der Aktionen ist, die bitweise UND-verknüpft sind mit den angeforderten Aktionen:

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

Wo

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

etc.

Die Webanwendung legt also eine Bitmaske mit den benötigten Informationen fest und implementedActions()antwortet mit einem Booleschen Wert, ob sie diese unterstützt.

Meinung

Diese Bit-Operationen sehen für mich wie Relikte aus dem C-Zeitalter aus, die in Bezug auf sauberen Code nicht unbedingt leicht zu verstehen sind.

Frage

Was ist ein modernes (besseres?) Muster für die Klasse, um die Teilmenge der implementierten Schnittstellenmethoden zu kommunizieren? Oder ist die "Bitoperationsmethode" von oben immer noch die beste Praxis?

( Falls es darauf ankommt: PHP, obwohl ich nach einer allgemeinen Lösung für OO-Sprachen suche )


5
Die allgemeine Lösung besteht darin, die Schnittstelle zu teilen. Die IUserBackendsollte die deleteUserMethode überhaupt nicht enthalten . Das sollte Teil sein IUserDeleteBackend(oder wie auch immer du es nennen willst). Code, der Benutzer löschen muss, hat Argumente von IUserDeleteBackend, Code, der diese Funktionalität nicht benötigt, wird IUserBackendProbleme mit nicht implementierten Methoden haben.
Bakuriu

3
Ein wichtiger Entwurfsaspekt ist, ob die Verfügbarkeit einer Aktion von den Laufzeitbedingungen abhängt. Sind es alle LDAP-Server, die das Löschen nicht unterstützen? Oder ist das eine Eigenschaft der Konfiguration eines Servers und könnte sich bei einem Systemneustart ändern? Sollte Ihr LDAP-Connector diese Situation automatisch erkennen oder muss die Konfiguration geändert werden, um einen anderen LDAP-Connector mit unterschiedlichen Funktionen anzuschließen? Diese Dinge haben einen starken Einfluss darauf, welche Lösungen realisierbar sind.
Sebastian Redl

@SebastianRedl Ja, das habe ich nicht berücksichtigt. Ich brauche eigentlich eine Lösung zur Laufzeit. Da ich die sehr guten Antworten nicht für ungültig erklären wollte, öffnete ich eine neue Frage , die sich auf die Laufzeit konzentriert
problemofficer

Antworten:


24

Grundsätzlich gibt es hier zwei Ansätze: Test & Wurf oder Komposition über Polymorphismus.

Testen & werfen

Dies ist der Ansatz, den Sie bereits beschrieben haben. Auf irgendeine Weise geben Sie dem Benutzer der Klasse an, ob bestimmte andere Methoden implementiert sind oder nicht. Dies kann mit einer einzelnen Methode und einer bitweisen Aufzählung (wie Sie beschreiben) oder über eine Reihe von supportsDelete()etc-Methoden erfolgen.

Wenn dies der supportsDelete()Fall ist false, kann der Aufruf deleteUser()dazu führen, dass ein NotImplementedExeptionFehler ausgelöst wird, oder die Methode führt einfach nichts aus.

Dies ist eine beliebte Lösung bei einigen, da es einfach ist. Viele - ich selbst eingeschlossen - argumentieren jedoch, dass dies eine Verletzung des Liskovschen Substitutionsprinzips (das L in SOLID) darstellt und daher keine gute Lösung darstellt.

Zusammensetzung durch Polymorphismus

Hier geht es darum, IUserBackendein Instrument als viel zu stumpf anzusehen. Wenn Klassen nicht immer alle Methoden in dieser Schnittstelle implementieren können, teilen Sie die Schnittstelle in stärker fokussierte Teile auf. Sie haben also möglicherweise folgende Möglichkeiten: IGeneralUser IDeletableUser IRenamableUser ... Mit anderen Worten, alle Methoden, die alle Ihre Backends implementieren können, werden übernommen, IGeneralUserund Sie erstellen eine separate Schnittstelle für jede der Aktionen, die nur einige ausführen können.

Auf diese Weise wird LdapUserBackendes nicht implementiert IDeletableUserund Sie testen es mit einem Test wie (mit C # -Syntax):

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

(Ich bin mir nicht sicher, welchen Mechanismus PHP verwendet, um festzustellen, ob eine Instanz eine Schnittstelle implementiert, und wie Sie sie dann in diese Schnittstelle umwandeln, aber ich bin mir sicher, dass es in dieser Sprache eine Entsprechung gibt.)

Der Vorteil dieser Methode besteht darin, dass sie den Polymorphismus nutzt, um zu ermöglichen, dass Ihr Code den SOLID-Grundsätzen entspricht, und meiner Ansicht nach viel eleganter ist.

Der Nachteil ist, dass es allzu leicht unhandlich werden kann. Wenn Sie beispielsweise Dutzende von Schnittstellen implementieren müssen, weil jedes konkrete Backend leicht unterschiedliche Funktionen aufweist, ist dies keine gute Lösung. Daher rate ich Ihnen, anhand Ihres Urteils zu beurteilen, ob dieser Ansatz für Sie in diesem Fall praktikabel ist, und ihn gegebenenfalls anzuwenden.


4
+1 für SOLID-Entwurfsüberlegungen. Immer schön, Antworten mit verschiedenen Ansätzen zu zeigen, die den Code sauberer machen!
Caleb

2
in PHP wäre esif (backend instanceof IDelatableUser) {...}
Rad80

Sie erwähnen bereits eine Verletzung von LSP. Ich stimme zu, wollte aber etwas hinzufügen: Test & Throw ist gültig, wenn der Eingabewert es unmöglich macht, die Aktion auszuführen, z. B. eine 0 als Divisor in einer Divide(float,float)Methode zu übergeben. Der Eingabewert ist variabel und die Ausnahme deckt eine kleine Teilmenge möglicher Ausführungen ab. Wenn Sie jedoch basierend auf Ihrem Implementierungstyp werfen, ist die Unfähigkeit, ihn auszuführen, eine gegebene Tatsache. Die Ausnahme deckt alle möglichen Eingaben ab , nicht nur eine Teilmenge davon. Das ist, als würde man auf jedem nassen Boden in einer Welt, in der jeder Boden immer nass ist, ein "nasser Boden" -Schild anbringen.
Flater

Es gibt eine Ausnahme (Wortspiel nicht vorgesehen) für das Prinzip, auf einen Typ nicht zu werfen. Für C # ist das NotImplementedException. Diese Ausnahme wird bei beabsichtigtem vorübergehenden Ausfällen, dh Code, ist noch nicht entwickelt , sondern wird weiterentwickelt werden. Das ist nicht dasselbe, als definitiv zu entscheiden, dass eine bestimmte Klasse mit einer bestimmten Methode niemals etwas anfangen wird, selbst wenn die Entwicklung abgeschlossen ist.
Flater

Danke für die Antwort. Ich brauchte tatsächlich eine Laufzeitlösung, konnte sie aber in meiner Frage nicht hervorheben. Da ich Ihre Antwort nicht verschlechtern wollte, habe ich beschlossen, eine neue Frage zu erstellen .
problemofficer

5

Die aktuelle Situation

Das aktuelle Setup verstößt gegen das Prinzip der Schnittstellentrennung (das I in SOLID).

Referenz

Laut Wikipedia sollte nach dem Prinzip der Schnittstellentrennung (ISP) kein Client gezwungen werden, von Methoden abhängig zu sein, die er nicht verwendet . Das Prinzip der Grenzflächentrennung wurde Mitte der 90er Jahre von Robert Martin formuliert.

Mit anderen Worten, wenn dies Ihre Schnittstelle ist:

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

Dann muss jede Klasse, die diese Schnittstelle implementiert, jede aufgeführte Methode der Schnittstelle verwenden. Keine Ausnahmen.

Stellen Sie sich vor, es gibt eine verallgemeinerte Methode:

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

Wenn Sie es tatsächlich so machen, dass nur einige der implementierenden Klassen tatsächlich in der Lage sind, einen Benutzer zu löschen, wird diese Methode gelegentlich in die Luft jagen (oder gar nichts tun). Das ist kein gutes Design.


Ihre vorgeschlagene Lösung

Ich habe eine Lösung gesehen, bei der das IUserInterface eine implementierteActions-Methode hat, die eine Ganzzahl zurückgibt, die das Ergebnis von bitweisen ODER-Verknüpfungen der Aktionen ist, die bitweise mit den angeforderten Aktionen UND-verknüpft sind.

Was Sie im Wesentlichen tun möchten, ist:

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

Ich ignoriere, wie genau wir feststellen, ob eine bestimmte Klasse einen Benutzer löschen kann. Ob es ein Boolescher Wert ist, ein bisschen Flagge, ... spielt keine Rolle. Alles läuft auf eine binäre Antwort hinaus: Kann ein Benutzer gelöscht werden, ja oder nein?

Das würde das Problem lösen, oder? Nun, technisch gesehen schon. Aber jetzt verletzen Sie das Liskov-Substitutionsprinzip (das L in SOLID).

Auf die recht komplexe Wikipedia-Erklärung verzichtend, fand ich ein anständiges Beispiel für StackOverflow . Beachten Sie das "schlechte" Beispiel:

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

    duck.Swim();
}

Ich nehme an, Sie sehen die Ähnlichkeit hier. Es ist eine Methode, die ein abstrahiertes Objekt ( IDuck, IUserBackend) behandeln soll, aber aufgrund eines beeinträchtigten Klassendesigns ist sie gezwungen, zuerst bestimmte Implementierungen zu behandeln ( ElectricDuckstellen Sie sicher, dass es keine IUserBackendKlasse ist, die Benutzer nicht löschen kann).

Dies ist gegen den Zweck der Entwicklung eines abstrakten Ansatzes gerichtet.

Hinweis: Das Beispiel hier ist einfacher zu reparieren als Ihr Fall. Für das Beispiel ist es ausreichend, die ElectricDuckOption selbst in der Swim()Methode zu aktivieren. Beide Enten können noch schwimmen, das funktionale Ergebnis ist also dasselbe.

Möglicherweise möchten Sie etwas Ähnliches tun. Nicht . Sie können nicht nur so tun , als ob Sie einen Benutzer löschen würden, sondern haben in Wirklichkeit einen leeren Methodenkörper. Dies funktioniert zwar aus technischer Sicht, macht es jedoch unmöglich zu wissen, ob Ihre implementierende Klasse tatsächlich etwas tut, wenn Sie aufgefordert werden, etwas zu tun. Das ist ein Nährboden für unerreichbaren Code.


Mein Lösungsvorschlag

Sie sagten jedoch, dass es für eine implementierende Klasse möglich (und richtig) ist, nur einige dieser Methoden zu verarbeiten.

Nehmen wir zum Beispiel an, dass es für jede mögliche Kombination dieser Methoden eine Klasse gibt, die sie implementiert. Es deckt alle unsere Basen ab.

Die Lösung besteht darin , die Schnittstelle zu teilen .

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);
}

Beachten Sie, dass Sie dies am Anfang meiner Antwort gesehen haben könnten. Die Schnittstelle Segregationsprinzip Name verrät bereits , dass dieses Prinzip entworfen, um Sie zu machen die Schnittstellen entmischen in ausreichendem Maße.

Auf diese Weise können Sie Schnittstellen beliebig kombinieren:

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
}

Jede Klasse kann entscheiden, was sie tun möchte, ohne den Vertrag ihrer Schnittstelle zu brechen.

Dies bedeutet auch, dass wir nicht prüfen müssen, ob eine bestimmte Klasse einen Benutzer löschen kann. Jede Klasse, die die IDeleteUserServiceSchnittstelle implementiert , kann einen Benutzer löschen = Keine Verletzung des Liskov-Substitutionsprinzips .

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

Wenn jemand versucht, ein Objekt zu übergeben, das nicht implementiert ist IDeleteUserService, lehnt das Programm die Kompilierung ab. Deshalb haben wir gerne Typensicherheit.

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

Fußnote

Ich habe das Beispiel auf die Spitze getrieben und die Schnittstelle in die kleinstmöglichen Teile aufgeteilt. Wenn Ihre Situation jedoch anders ist, können Sie mit größeren Stücken davonkommen.

Wenn beispielsweise jeder Dienst, der einen Benutzer erstellen kann , einen Benutzer immer löschen kann (und umgekehrt), können Sie diese Methoden als Teil einer einzelnen Schnittstelle beibehalten:

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

Es gibt keinen technischen Vorteil, dies zu tun, anstatt sich in die kleineren Stücke zu trennen. Dies wird jedoch die Entwicklung etwas erleichtern, da weniger Boilerplating erforderlich ist.


+1 für die Aufteilung von Schnittstellen nach dem von ihnen unterstützten Verhalten. Dies ist der gesamte Zweck einer Schnittstelle.
Greg Burghardt

Danke für die Antwort. Ich brauchte tatsächlich eine Laufzeitlösung, konnte sie aber in meiner Frage nicht hervorheben. Da ich Ihre Antwort nicht verschlechtern wollte, habe ich beschlossen, eine neue Frage zu erstellen .
problemofficer

@problemofficer: Laufzeitauswertung für diese Fälle ist selten die beste Option, aber es gibt tatsächlich Fälle dafür. In einem solchen Fall erstellen Sie entweder eine Methode, die aufgerufen werden kann, aber möglicherweise nichts unternimmt (rufen Sie sie TryDeleteUserauf, um dies widerzuspiegeln). oder Sie haben die Methode absichtlich eine Ausnahme ausgelöst, wenn es sich um eine mögliche, aber problematische Situation handelt. Die Verwendung der Methode CanDoThing()und DoThing()funktioniert, aber Ihre externen Anrufer müssen zwei Anrufe tätigen (und werden dafür bestraft), was weniger intuitiv und nicht so elegant ist.
Flater

0

Wenn Sie übergeordnete Typen verwenden möchten, können Sie den Settyp in der Sprache Ihrer Wahl verwenden. Hoffentlich bietet es etwas Syntaxzucker für die Erstellung von Mengenschnittpunkten und die Bestimmung von Teilmengen.

Dies ist im Grunde, was Java mit EnumSet macht (abzüglich der Syntax sugar, aber hey, es ist Java)


0

In der .NET-Welt können Sie Methoden und Klassen mit benutzerdefinierten Attributen dekorieren. Dies ist möglicherweise für Ihren Fall nicht relevant.

Es scheint mir jedoch, dass das Problem, das Sie haben, mehr mit einer höheren Ebene des Designs zu tun haben könnte.

Wenn es sich um eine Benutzeroberflächenfunktion handelt, z. B. eine Seite oder Komponente zum Bearbeiten von Benutzern, wie werden dann die verschiedenen Funktionen ausgeblendet? In diesem Fall ist "Test and Throw" ein ziemlich ineffizienter Ansatz für diesen Zweck. Es wird davon ausgegangen, dass Sie vor dem Laden jeder Seite einen Scheinaufruf für jede Funktion ausführen, um zu bestimmen, ob das Widget oder Element ausgeblendet oder anders dargestellt werden soll. Alternativ haben Sie eine Webseite, die den Benutzer grundsätzlich dazu zwingt, durch manuelles Testen und Werfen zu ermitteln, welche Codierungsroute Sie verwenden, da der Benutzer erst dann feststellt, dass etwas nicht verfügbar ist, wenn eine Popup-Warnung angezeigt wird.

Für eine Benutzeroberfläche empfiehlt es sich daher, sich mit der Funktionsverwaltung zu befassen und die Auswahl der verfügbaren Implementierungen darauf abzustimmen, anstatt von den ausgewählten Implementierungen zu bestimmen, welche Funktionen verwaltet werden können. Möglicherweise möchten Sie sich die Frameworks zum Erstellen von Feature-Abhängigkeiten ansehen und die Funktionen explizit als Entitäten in Ihrem Domänenmodell definieren. Dies könnte sogar an eine Autorisierung gebunden sein. Grundsätzlich kann die Entscheidung, ob eine Funktion verfügbar ist oder nicht, basierend auf der Berechtigungsstufe auf die Entscheidung ausgeweitet werden, ob eine Funktion tatsächlich implementiert ist, und dann können übergeordnete Benutzeroberflächen- "Funktionen" explizite Zuordnungen zu Funktionssätzen aufweisen.

Wenn es sich um eine Web-API handelt, kann die Auswahl des Gesamtentwurfs kompliziert sein, da mehrere öffentliche Versionen der API "Benutzer verwalten" oder der REST-Ressource "Benutzer" unterstützt werden müssen, da die Funktionen im Laufe der Zeit erweitert werden.

Zusammenfassend lässt sich sagen, dass Sie in der .NET-Welt verschiedene Reflection / Attribute-Methoden nutzen können, um im Voraus zu bestimmen, welche Klassen was implementieren, aber auf jeden Fall scheinen die eigentlichen Probleme darin zu liegen, was Sie mit diesen Informationen tun.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.