Як клас повинен повідомляти своїм користувачам про те, який підмножина методів він реалізує?


12

Сценарій

Веб-додаток визначає користувацький інтерфейс користувача IUserBackendіз методами

  • getUser (uid)
  • createUser (uid)
  • deleteUser (uid)
  • setPassword (uid, пароль)
  • ...

Різні програмні засоби користувача (наприклад, LDAP, SQL, ...) реалізують цей інтерфейс, але не кожен сервер може зробити все. Наприклад, конкретний сервер LDAP не дозволяє цій веб-програмі видаляти користувачів. Тож LdapUserBackendклас, який реалізує IUserBackend, не буде реалізований deleteUser(uid).

Конкретний клас повинен повідомити веб-додатку, що веб-додаток може робити з користувачами бекенда.

Відоме рішення

Я бачив рішення, де у методу IUserInterfaceє implementedActionsметод, який повертає ціле число, яке є результатом побітових АБО дій, розрядних ANDed із запитаними діями:

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

Де

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

тощо.

Тож веб-додаток встановлює біт-маску з тим, що їй потрібно, і implementedActions()відповідає булевим, чи підтримує їх.

Думка

Ці бітові операції мені здаються реліквіями з епохи С, не обов'язково легко зрозуміти з точки зору чистого коду.

Питання

Який сучасний (кращий?) Шаблон для класу для передачі підмножини методів інтерфейсів, які він реалізує? Або "метод бітової операції" зверху все ще є найкращою практикою?

( У випадку, якщо це має значення: PHP, хоча я шукаю загальне рішення для мов ОО )


5
Загальне рішення - розділити інтерфейс. Метод IUserBackendвзагалі не повинен містити deleteUser. Це має бути частиною IUserDeleteBackend(або як би ви цього не хотіли назвати). Код, який потребує видалення користувачів, матиме аргументи IUserDeleteBackend, код, який не потребує використання функціональних можливостей, IUserBackendі не матиме проблем із невтіленими методами.
Бакуріу

3
Важливим дизайном є те, чи залежить дія дії від обставин виконання. Чи всі сервери LDAP, які не підтримують видалення? Або це властивість конфігурації сервера і може змінитися при перезапуску системи? Чи повинен ваш роз'єм LDAP автоматично виявляти цю ситуацію, чи потрібно вимагати зміни конфігурації, щоб підключити інший роз'єм LDAP з різними можливостями? Ці речі сильно впливають на те, які рішення є життєздатними.
Себастьян Редл

@SebastianRedl Так, це я не враховував. Мені фактично потрібне рішення для виконання. Оскільки я не хотів визнати недійсними дуже хороші відповіді, я відкрив нове питання, яке зосереджено на режимі виконання
problemofficer

Відповіді:


24

Загалом, тут можна скористатися двома підходами: тест та метання або композиція за допомогою поліморфізму.

Випробування та кидання

Це підхід, який ви вже описали. Через деякі засоби ви вказуєте користувачеві класу, реалізовані чи ні інші методи. Це можна зробити за допомогою одного методу та розрядного перерахування (як ви описуєте), або за допомогою ряду supportsDelete()тощо.

Тоді, якщо supportsDelete()повертається false, виклик deleteUser()може призвести до NotImplementedExeptionтого, що вас кинуть, або метод просто нічого не робить.

Це популярне рішення серед деяких, оскільки це просто. Однак багато хто, включаючи мене, стверджують, що це порушення принципу заміни Ліскова (L у SOLID), і тому це не є приємним рішенням.

Композиція за допомогою поліморфізму

Підхід тут полягає в тому, щоб розглянути IUserBackendяк занадто тупий інструмент. Якщо класи не завжди можуть реалізувати всі методи в цьому інтерфейсі, тоді розбийте інтерфейс на більш цілеспрямовані частини. Отже, у вас може бути: IGeneralUser IDeletableUser IRenamableUser ... Іншими словами, усі методи, які можуть реалізувати всі ваші програми, ви вводите, IGeneralUserі ви створюєте окремий інтерфейс для кожної дії, яку можуть виконувати лише деякі.

Таким чином, LdapUserBackendне реалізується, IDeletableUserі ви перевірите це за допомогою тесту, такого як (використовуючи синтаксис C #):

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

(Я не впевнений у механізмі PHP для визначення, чи екземпляр реалізує інтерфейс, і як ви потім переходите до цього інтерфейсу, але я впевнений, що в цій мові є еквівалент)

Перевага цього методу полягає в тому, що він добре використовує поліморфізм, щоб ваш код відповідав принципам SOLID і, на мій погляд, просто більш елегантний.

Мінус у тому, що він може надто легко стати непростим. Якщо, наприклад, вам доводиться реалізовувати десятки інтерфейсів, оскільки кожен конкретний бекенд має трохи інші можливості, то це не гарне рішення. Тож я б просто порадив вам використовувати своє судження про те, чи практичний цей підхід для вас з цього приводу, і використовувати його, якщо він є.


4
+1 для міркувань проекту SOLID. Завжди приємно показувати відповіді з різними підходами, які дозволять чистоті коду рухатися вперед!
Калеб

2
у PHP це було бif (backend instanceof IDelatableUser) {...}
Rad80

Ви вже згадуєте про порушення ЛСП. Я погоджуюся, але хотів трохи додати його: Test & Throw є дійсним, якщо вхідне значення унеможливлює виконання дії, наприклад, передаючи 0 у якості дільника Divide(float,float)методу. Вхідне значення є змінним, і виняток охоплює невелику підмножину можливих виконань. Але якщо ви кидаєте виходячи з вашого типу реалізації, то його нездатність виконати - це даний факт. Виняток охоплює всі можливі входи , а не лише їх підмножину. Це як ставити знак «мокра підлога» на кожній мокрій підлозі у світі, де кожен поверх завжди мокрий.
Flater

Існує виняток (каламбур не призначений) за принцип не кидання на тип. Для C #, тобто є NotImplementedException. Цей виняток призначений для тимчасових відключень, тобто коду, який ще не розроблений, але буде розроблений. Це не те саме, що остаточно вирішити, що даний клас ніколи нічого не зробить із заданим методом, навіть після завершення розробки.
Flater

Дякую за відповідь. Мені фактично було потрібне рішення для виконання, але я не зміг його наголосити у своєму питанні. Оскільки я не хотів визнати недійсною вашу відповідь, я вирішив створити нове запитання .
problemofficer

5

Нинішня ситуація

Поточна установка порушує Принцип розділення інтерфейсу (I в SOLID).

Довідково

Згідно з Вікіпедією, принцип сегрегації інтерфейсу (ISP) зазначає, що жоден клієнт не повинен бути змушений залежати від методів, які він не використовує . Принцип сегрегації інтерфейсу був сформульований Робертом Мартіном у середині 1990-х.

Іншими словами, якщо це ваш інтерфейс:

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

Тоді кожен клас, який реалізує цей інтерфейс, повинен використовувати кожен перерахований метод інтерфейсу. Не виняток.

Уявіть, чи є узагальнений метод:

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

Якщо ви насправді зробили це так, що тільки деякі з класів реалізації можуть насправді видалити користувача, то цей метод час від часу підірве вам в обличчя (або взагалі нічого не зробить). Це не гарний дизайн.


Ваше запропоноване рішення

Я бачив рішення, де IUserInterface має метод implemenActions, який повертає ціле число, яке є результатом побітових АБО дій, розрядних ANDed із запитаними діями.

По суті, ви хочете зробити це:

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

Я ігнорую, як саме ми визначаємо, чи здатний даний клас видалити користувача. Будь це булевий, трохи прапор, ... не має значення. Все зводиться до двійкової відповіді: чи може він видалити користувача, так чи ні?

Це вирішило б проблему, правда? Ну, технічно це роблять. Але тепер ви порушуєте Принцип заміщення Ліскова (L у твердій формі).

Проводячи досить складне пояснення Вікіпедії, я знайшов гідний приклад на StackOverflow . Зверніть увагу на "поганий" приклад:

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

    duck.Swim();
}

Я припускаю, що ви бачите подібність тут. Це метод, який повинен обробляти абстрагований об'єкт ( IDuck, IUserBackend), але через компрометований дизайн класу він змушений спочатку обробити конкретні реалізації ( ElectricDuckпереконайтеся, що це не IUserBackendклас, який не може видалити користувачів).

Це перемагає мета розробки абстрагованого підходу.

Примітка. Приклад тут легше виправити, ніж ваш випадок. Для прикладу, досить мати ElectricDuckсам поворот на всередині в Swim()методі. Обидві качки ще вміють плавати, тому функціональний результат однаковий.

Можливо, ви захочете зробити щось подібне. Не варто . Ви не можете просто робити вигляд, що видаляєте користувача, але насправді має порожнє тіло методу. Хоча це працює з технічної точки зору, це робить неможливим знати, чи дійсно ваш клас-реалізатор щось зробить, коли його попросять зробити щось. Це поживна середовище для неможливого коду.


Моє запропоноване рішення

Але ви сказали, що для класу-виконавця можна (і правильно) обробляти лише деякі з цих методів.

На приклад, скажімо, що для кожної можливої ​​комбінації цих методів існує клас, який його реалізує. Він охоплює всі наші бази.

Рішення тут - розділити інтерфейс .

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

Зауважте, що ви могли це бачити на початку моєї відповіді. Ім'я Принципу розбиття інтерфейсу вже показує, що цей принцип створений для того, щоб змусити вас досить швидко відокремити інтерфейси .

Це дозволяє вам змінювати і співставляти інтерфейси за вашим бажанням:

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
}

Кожен клас може вирішити, що хоче зробити, не порушуючи договір свого інтерфейсу.

Це також означає, що нам не потрібно перевіряти, чи певний клас здатний видалити користувача. Кожен клас, який реалізує IDeleteUserServiceінтерфейс, зможе видалити користувача = Без порушення Принципу заміни Ліскова .

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

Якщо хтось спробує передати об’єкт, який не реалізується IDeleteUserService, програма відмовиться від компіляції. Ось чому нам подобається безпека типу.

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

Зноска

Я взяв приклад до крайності, розділивши інтерфейс на найменші шматки. Однак якщо у вас ситуація інша, ви можете піти з більшими шматками.

Наприклад, якщо кожна служба, яка може створити користувача, завжди здатна видалити користувача (і навпаки), ви можете зберегти ці методи як частину єдиного інтерфейсу:

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

Немає жодної технічної вигоди робити це замість того, щоб розділяти на менші шматки; але це зробить розробку трохи простішою, тому що вона потребує меншої кількості котлів.


+1 для розбиття інтерфейсів за поведінкою, яку вони підтримують, що і є основною метою інтерфейсу.
Грег Бургхардт

Дякую за відповідь. Мені фактично було потрібне рішення для виконання, але я не зміг його наголосити у своєму питанні. Оскільки я не хотів визнати недійсною вашу відповідь, я вирішив створити нове запитання .
problemofficer

@problemofficer: Оцінка часу виконання для цих випадків рідко є найкращим варіантом, але дійсно є випадки для цього. У такому випадку ви або створюєте метод, який можна викликати, але в кінцевому підсумку нічого не робить (зателефонуйте йому, TryDeleteUserщоб це відобразити); або у вас є метод навмисно кинути виняток, якщо це можлива, але проблемна ситуація. Використання підходу CanDoThing()та DoThing()методу працює, але це вимагатиме від ваших зовнішніх абонентів використовувати два дзвінки (і отримувати покарання за їх невиконання), що є менш інтуїтивно зрозумілим і не таким елегантним.
Flater

0

Якщо ви хочете використовувати типи вищого рівня, ви можете перейти з набором типу на обраній вами мові. Сподіваємось, він пропонує деякий синтаксичний цукор для виконання заданих перетинів та визначення підмножини.

Це в основному те, що робить Java з EnumSet (мінус синтаксичний цукор, але ей, це Java)


0

У світі .NET ви можете прикрашати методи та класи спеціальними атрибутами. Це може не стосуватися Вашого випадку.

Мені здається, що проблема, яку ви маєте, може бути пов'язана з більш високим рівнем дизайну.

Якщо це така функція інтерфейсу, як сторінка редагування користувачів або компонент, то як маскуються різні можливості? У цьому випадку "тест і кидок" буде досить неефективним підходом для цієї мети. Передбачається, що перед завантаженням кожної сторінки ви виконуєте макетний дзвінок до кожної функції, щоб визначити, чи слід віджет або елемент ховати чи подавати по-іншому. Крім того, у вас є веб-сторінка, яка в основному змушує користувача виявити, що доступно вручну "тестувати і кидати", який би шлях кодування ви не взяли, оскільки користувач не виявить, що щось недоступне, поки не з’явиться спливаюче попередження.

Таким чином, для інтерфейсу користувача ви можете вивчити, як ви керуєте функціями, і прив’язати вибір доступних реалізацій до цього, а не вибраними реалізаціями керувати тим, якими функціями можна керувати. Ви можете переглянути рамки для створення залежностей від функцій та чітко визначити можливості як сутності у вашій моделі домену. Це навіть може бути пов'язане з дозволом. По суті, вирішення того, чи є можливість доступна чи не заснована на рівні авторизації, може бути розширено до вирішення, чи реально реалізована здатність, і тоді "функції" інтерфейсу високого рівня можуть мати чітке відображення наборів можливостей.

Якщо це веб-API, то загальний вибір дизайну може бути ускладнений необхідністю підтримки декількох публічних версій API «Управління користувачем» або ресурсу «REST» користувача, оскільки можливості з часом розширюються.

Отже, підсумовуючи, у світі .NET ви можете використовувати різні способи відображення / атрибутів заздалегідь визначити, які класи реалізувати, але, у будь-якому випадку, схоже, справжні проблеми полягають у тому, що ви робите з цією інформацією.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.