W jaki sposób klasa powinna komunikować się z użytkownikami, który podzbiór metod wdraża?

12

Scenariusz

Aplikacja internetowa definiuje interfejs zaplecza użytkownika IUserBackendza pomocą metod

  • getUser (uid)
  • createUser (uid)
  • deleteUser (identyfikator użytkownika)
  • setPassword (identyfikator użytkownika, hasło)
  • ...

Różne interfejsy użytkownika (np. LDAP, SQL, ...) implementują ten interfejs, ale nie każdy backend może zrobić wszystko. Na przykład konkretny serwer LDAP nie pozwala tej aplikacji internetowej na usuwanie użytkowników. Zatem LdapUserBackendklasa, która implementuje IUserBackend, nie będzie implementować deleteUser(uid).

Konkretna klasa musi zakomunikować aplikacji internetowej, co aplikacja internetowa może robić z użytkownikami backendu.

Znane rozwiązanie

Widziałem rozwiązanie, w którym metoda IUserInterfacema implementedActionsmetodę, która zwraca liczbę całkowitą, która jest wynikiem bitowych OR operacji bitowych AND z żądanymi akcjami:

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

Gdzie

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

itp.

Tak więc aplikacja internetowa ustawia maskę bitową z tym, czego potrzebuje i implementedActions()odpowiada za pomocą logicznej wartości logicznej, czy ją obsługuje.

Opinia

Te operacje bitowe dla mnie wyglądają jak relikty z epoki C, niekoniecznie łatwe do zrozumienia pod względem czystego kodu.

Pytanie

Jaki jest nowoczesny (lepszy?) Wzorzec dla klasy do komunikowania podzbioru metod interfejsów, które implementuje? Czy też „metoda operacji bitowej” z góry jest nadal najlepszą praktyką?

( W przypadku, gdy ma to znaczenie: PHP, chociaż szukam ogólnego rozwiązania dla języków OO )

problemator
źródło
5
Ogólnym rozwiązaniem jest podzielenie interfejsu. Nie IUserBackendpowinna w ogóle zawierać tej deleteUsermetody. To powinno być częścią IUserDeleteBackend(lub jakkolwiek chcesz to nazwać). Kod, który musi usunąć użytkowników, będzie miał argumenty IUserDeleteBackend, kod, który nie potrzebuje tej funkcji, będzie używał IUserBackendi nie będzie miał problemów z niezaimplementowanymi metodami.
Bakuriu
3
Ważną kwestią do rozważenia przy projektowaniu jest to, czy dostępność akcji zależy od okoliczności środowiska wykonawczego. Czy to wszystkie serwery LDAP, które nie obsługują usuwania? Czy jest to właściwość konfiguracji serwera i może ulec zmianie przy ponownym uruchomieniu systemu? Czy łącznik LDAP powinien automatycznie wykryć tę sytuację, czy powinien wymagać zmiany konfiguracji, aby podłączyć inny łącznik LDAP o różnych możliwościach? Te rzeczy mają silny wpływ na to, które rozwiązania są realne.
Sebastian Redl,
@SebastianRedl Tak, tego nie wziąłem pod uwagę. Naprawdę potrzebuję rozwiązania dla środowiska uruchomieniowego. Ponieważ nie chciałem unieważniać bardzo dobrych odpowiedzi, otworzyłem nowe pytanie, które koncentruje się na środowisku
uruchomieniowym

Odpowiedzi:

24

Mówiąc ogólnie, można tu zastosować dwa podejścia: test i rzut lub kompozycja poprzez polimorfizm.

Testuj i rzucaj

Takie podejście już opisałeś. W jakiś sposób wskazujesz użytkownikowi klasy, czy pewne inne metody są zaimplementowane, czy nie. Można to zrobić za pomocą jednej metody i wyliczenia bitowego (jak opisano) lub za pomocą szeregu supportsDelete()metod itp.

Następnie, jeśli supportsDelete()zwraca false, wywołanie deleteUser()może spowodować NotImplementedExeptionwyrzucenie lub metoda po prostu nic nie robi.

Jest to popularne rozwiązanie wśród niektórych, ponieważ jest proste. Jednak wielu - łącznie ze mną - argumentuje, że jest to naruszenie zasady substytucji Liskowa (L w SOLID) i dlatego nie jest dobrym rozwiązaniem.

Kompozycja poprzez polimorfizm

Podejście tutaj polega na tym, aby uznać IUserBackendinstrument za zbyt tępy. Jeśli klasy nie zawsze mogą zaimplementować wszystkie metody w tym interfejsie, podziel interfejs na bardziej skoncentrowane części. Być może masz: IGeneralUser IDeletableUser IRenamableUser ... Innymi słowy, wszystkie metody, które mogą zaimplementować wszystkie twoje backendy, wchodzą IGeneralUseri tworzysz osobny interfejs dla każdej akcji, którą tylko niektóre mogą wykonać.

W ten sposób LdapUserBackendnie implementuje się IDeletableUseri testujesz to za pomocą testu takiego jak (używając składni C #):

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

(Nie jestem pewien mechanizmu w PHP do określania, czy instancja implementuje interfejs i jak następnie rzutujesz na ten interfejs, ale jestem pewien, że istnieje odpowiednik w tym języku)

Zaletą tej metody jest to, że dobrze wykorzystuje polimorfizm, aby umożliwić Twojemu kodowi zgodność z zasadami SOLID, i jest moim zdaniem o wiele bardziej elegancki.

Minusem jest to, że zbyt łatwo może stać się nieporęczny. Jeśli, na przykład, będziesz musiał wdrożyć dziesiątki interfejsów, ponieważ każdy konkretny backend ma nieco inne możliwości, to nie jest to dobre rozwiązanie. Radzę więc, abyś wykorzystał swój osąd, czy takie podejście jest dla ciebie praktyczne przy tej okazji i zastosuj go, jeśli tak jest.

David Arno
źródło
4
+1 za rozważania dotyczące projektu SOLID. Zawsze miło jest wyświetlać odpowiedzi z różnymi podejściami, dzięki którym kod będzie czystszy!
Caleb
2
w PHP byłoby toif (backend instanceof IDelatableUser) {...}
Rad80
Wspomniałeś już o naruszeniu LSP. Zgadzam się, ale chciałem nieco dodać: Test i wyrzucanie jest poprawne, jeśli wartość wejściowa uniemożliwia wykonanie akcji, np. Przekazanie 0 jako dzielnika w Divide(float,float)metodzie. Wartość wejściowa jest zmienna, a wyjątek obejmuje niewielki podzbiór możliwych wykonań. Ale jeśli rzucasz w oparciu o typ implementacji, to niemożność wykonania jest faktem. Wyjątek obejmuje wszystkie możliwe dane wejściowe , a nie tylko ich podzbiór. To tak, jakby umieścić znak „mokrej podłogi” na każdej mokrej podłodze w świecie, w którym każda podłoga jest zawsze mokra.
Flater
Istnieje wyjątek (kalambur nieprzeznaczony) dla zasady nie rzucania na typ. To znaczy dla C # NotImplementedException. Ten wyjątek jest przeznaczony do tymczasowych wyłączeń, tj. Kodu, który nie został jeszcze opracowany, ale zostanie opracowany. To nie to samo, co definitywne stwierdzenie, że dana klasa nigdy nie zrobi nic z daną metodą, nawet po zakończeniu rozwoju.
Flater
Dziękuję za Twoją odpowiedź. Właściwie potrzebowałem rozwiązania uruchomieniowego, ale nie podkreśliłem go w moim pytaniu. Ponieważ nie chciałem unieważniać twojej odpowiedzi, postanowiłem utworzyć nowe pytanie .
problemofficer
5

Obecna sytuacja

Obecna konfiguracja narusza zasadę segregacji interfejsu (I w SOLID).

Odniesienie

Według Wikipedii zasada segregacji interfejsów (ISP) stanowi, że żaden klient nie powinien być zmuszany do polegania na metodach, których nie używa . Zasadę segregacji interfejsów sformułował Robert Martin w połowie lat 90.

Innymi słowy, jeśli jest to twój interfejs:

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

Następnie każda klasa, która implementuje ten interfejs, musi korzystać z każdej wymienionej metody interfejsu. Bez wyjątku.

Wyobraź sobie, że istnieje ogólna metoda:

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

Jeśli miałbyś to zrobić tak, aby tylko niektóre klasy implementujące były w stanie usunąć użytkownika, wtedy ta metoda czasami wysadzi ci się w twarz (lub nic nie zrobisz). To nie jest dobry projekt.


Twoje proponowane rozwiązanie

Widziałem rozwiązanie, w którym IUserInterface ma zaimplementowaną metodęAction, która zwraca liczbę całkowitą, która jest wynikiem bitowych OR operacji bitowo AND z żądanymi akcjami.

Zasadniczo chcesz:

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

Ignoruję, jak dokładnie ustalamy, czy dana klasa jest w stanie usunąć użytkownika. Niezależnie od tego, czy jest to wartość logiczna, trochę flaga ... nie ma znaczenia. Wszystko sprowadza się do odpowiedzi binarnej: czy może usunąć użytkownika, tak czy nie?

To rozwiązałoby problem, prawda? Technicznie tak. Ale teraz naruszasz zasadę substytucji Liskowa (L w wersji SOLID).

Pomijając dość skomplikowane wyjaśnienie Wikipedii, znalazłem dobry przykład na StackOverflow . Zwróć uwagę na „zły” przykład:

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

    duck.Swim();
}

Zakładam, że widzisz tutaj podobieństwo. Jest to metoda, która ma obsługiwać obiekt abstrakcyjny ( IDuck, IUserBackend), ale z powodu kompromisowego projektu klasy musi najpierw obsłużyć określone implementacje ( ElectricDuckupewnij się, że nie jest to IUserBackendklasa, która nie może usuwać użytkowników).

Jest to sprzeczne z celem opracowania abstrakcyjnego podejścia.

Uwaga: przykład tutaj jest łatwiejszy do naprawienia niż Twoja sprawa. Dla przykładu, wystarczy mieć ElectricDucksamą kolej na środku tej Swim()metody. Obie kaczki nadal potrafią pływać, więc wynik funkcjonalny jest taki sam.

Możesz zrobić coś podobnego. Nie robić . Nie możesz udawać, że usuwasz użytkownika, ale w rzeczywistości masz pustą treść metody. Chociaż działa to z technicznego punktu widzenia, uniemożliwia ustalenie, czy klasa implementująca rzeczywiście zrobi coś, gdy zostanie o to poproszona. Jest to wylęgarnia nieusuwalnego kodu.


Moje proponowane rozwiązanie

Powiedziałeś jednak, że jest możliwe (i poprawne), że klasa implementująca obsługuje tylko niektóre z tych metod.

Na przykład, powiedzmy, że dla każdej możliwej kombinacji tych metod istnieje klasa, która ją zaimplementuje. Obejmuje wszystkie nasze bazy.

Rozwiązaniem jest tutaj podział interfejsu .

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

Zauważ, że widziałeś to na początku mojej odpowiedzi. Nazwa zasady segregacji interfejsów już pokazuje, że zasada ta została zaprojektowana, aby segregować interfejsy w wystarczającym stopniu.

Pozwala to na łączenie i łączenie interfejsów według własnego uznania:

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
}

Każda klasa może zdecydować, co chce zrobić, bez łamania umowy o interfejsie.

Oznacza to również, że nie musimy sprawdzać, czy dana klasa jest w stanie usunąć użytkownika. Każda klasa, która implementuje IDeleteUserServiceinterfejs, będzie mogła usunąć użytkownika = Bez naruszenia zasady substytucji Liskowa .

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

Jeśli ktoś spróbuje przekazać obiekt, który się nie implementuje IDeleteUserService, program odmówi kompilacji. Dlatego lubimy mieć bezpieczeństwo typu.

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

Notatka

Doszedłem do skrajności tego przykładu, dzieląc interfejs na możliwie najmniejsze części. Jeśli jednak Twoja sytuacja jest inna, możesz uciec od większych kawałków.

Na przykład jeśli każda usługa, która może utworzyć użytkownika, jest zawsze w stanie usunąć użytkownika (i odwrotnie), możesz zachować te metody jako część jednego interfejsu:

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

Robienie tego nie ma żadnej korzyści technicznej zamiast rozdzielania na mniejsze fragmenty; ale sprawi, że rozwój będzie nieco łatwiejszy, ponieważ wymaga mniej kotłów.

Flater
źródło
+1 za podział interfejsów według obsługiwanego przez nich zachowania, co jest głównym celem interfejsu.
Greg Burghardt
Dziękuję za Twoją odpowiedź. Właściwie potrzebowałem rozwiązania uruchomieniowego, ale nie podkreśliłem go w moim pytaniu. Ponieważ nie chciałem unieważniać twojej odpowiedzi, postanowiłem utworzyć nowe pytanie .
problemofficer
@problemofficer: Ocena czasu wykonania dla tych przypadków rzadko jest najlepszą opcją, ale w rzeczywistości istnieją takie przypadki. W takim przypadku albo tworzysz metodę, którą można wywołać, ale może nic nie robić (nazwij to, TryDeleteUseraby to odzwierciedlić); lub masz metodę celowo zgłaszającą wyjątek, jeśli jest to możliwa, ale problematyczna sytuacja. Korzystanie z metody CanDoThing()i DoThing()działa, ale wymagałoby to od zewnętrznych rozmówców użycia dwóch połączeń (i ukarania ich za niepowodzenie), co jest mniej intuicyjne i nie tak eleganckie.
Flater
0

Jeśli chcesz używać typów wyższego poziomu, możesz wybrać typ zestawu w wybranym języku. Mam nadzieję, że zapewnia on cukier składniowy do wykonywania określonych skrzyżowań i określania podzbiorów.

Tak właśnie robi Java z EnumSet (bez cukru składniowego, ale hej, to Java)

Bwmat
źródło
0

W świecie .NET możesz dekorować metody i klasy niestandardowymi atrybutami. Może to nie dotyczyć twojej sprawy.

Wydaje mi się jednak, że problem, który masz, może dotyczyć wyższego poziomu projektu.

Jeśli jest to funkcja interfejsu użytkownika, taka jak strona edycji użytkownika lub komponent, to w jaki sposób maskowane są różne możliwości? W tym przypadku „test i rzut” będzie dość nieefektywnym podejściem do tego celu. Zakłada się, że przed załadowaniem każdej strony uruchamia się próbne wywołanie każdej funkcji w celu ustalenia, czy widżet lub element powinien być ukryty, czy przedstawiony inaczej. Alternatywnie, masz stronę internetową, która w zasadzie zmusza użytkownika do odkrycia tego, co jest dostępne przez ręczne „przetestuj i wyrzuć”, jakąkolwiek drogę kodowania wybierzesz, ponieważ użytkownik nie odkryje, że coś jest niedostępne, dopóki nie pojawi się wyskakujące ostrzeżenie.

W przypadku interfejsu użytkownika możesz chcieć przyjrzeć się zarządzaniu funkcjami i powiązać z tym wybór dostępnych implementacji, zamiast wybierać implementacje, które określają, którymi funkcjami można zarządzać. Możesz przyjrzeć się ramom do tworzenia zależności funkcji i jawnie zdefiniować możliwości jako byty w modelu domeny. Można to nawet powiązać z autoryzacją. Zasadniczo, podejmowanie decyzji, czy zdolność jest dostępna, czy nie, w oparciu o poziom autoryzacji, można rozszerzyć na decyzję, czy zdolność jest rzeczywiście zaimplementowana, a następnie „funkcje” interfejsu użytkownika na wysokim poziomie mogą mieć wyraźne odwzorowania na zestawy zdolności.

Jeśli jest to interfejs API sieci Web, ogólny wybór projektu może być skomplikowany przez konieczność obsługi wielu publicznych wersji interfejsu API „Zarządzaj użytkownikiem” lub zasobu REST „Użytkownik” w miarę rozszerzania się możliwości.

Podsumowując, w świecie .NET możesz wykorzystać różne sposoby Refleksji / Atrybutu, aby z góry określić, które klasy zaimplementować, ale w każdym razie wydaje się, że prawdziwe problemy będą związane z tym, co robisz z tymi informacjami.

strażnik
źródło