Как класс должен сообщить своим пользователям, какое подмножество методов он реализует?

12

сценарий

Веб-приложение определяет интерфейс пользователя IUserBackendс методами

  • GetUser (UID)
  • CreateUser (UID)
  • deleteUser (UID)
  • setPassword (uid, пароль)
  • ...

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

Конкретный класс должен сообщить веб-приложению, что веб-приложению разрешено делать с пользователями бэкэнда.

Известное решение

Я видел решение, в котором IUserInterfaceесть implementedActionsметод, который возвращает целое число, которое является результатом побитового ИЛИ действий побитового И с запрошенными действиями:

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, хотя я ищу общее решение для ОО-языков )

problemofficer
источник
5
Общее решение - разделить интерфейс. Метод IUserBackendне должен содержать deleteUserметод вообще. Это должно быть частью IUserDeleteBackend(или как вы хотите это назвать). Код, который должен удалять пользователей, будет иметь аргументы IUserDeleteBackend, код, который не нуждается в этой функциональности, будет использовать IUserBackendи не будет иметь проблем с нереализованными методами.
Бакуриу
3
Важным соображением при проектировании является то, зависит ли доступность действия от обстоятельств времени выполнения. Это все серверы LDAP , которые не поддерживают удаление? Или это свойство конфигурации сервера, которое может измениться при перезагрузке системы? Должен ли ваш соединитель LDAP автоматически обнаруживать эту ситуацию, или он должен требовать изменения конфигурации для подключения другого соединителя LDAP с другими возможностями? Эти вещи оказывают сильное влияние на то, какие решения являются жизнеспособными.
Себастьян Редл
@SebastianRedl Да, это то, что я не учел. Мне действительно нужно решение для времени выполнения. Поскольку я не хотел лишать законной силы очень хорошие ответы, я открыл новый вопрос, который фокусируется на времени выполнения
problemofficer

Ответы:

24

Вообще говоря, есть два подхода, которые вы можете использовать здесь: test & throw или композиция через полиморфизм.

Тест и бросок

Это подход, который вы уже описали. С помощью некоторых средств вы указываете пользователю класса, реализованы ли некоторые другие методы или нет. Это можно сделать с помощью одного метода и побитового перечисления (как вы описываете) или с помощью ряда supportsDelete()методов etc.

Затем, если 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 за ТВЕРДЫЕ конструктивные соображения. Всегда приятно показывать ответы с разными подходами, которые помогут продвинуться вперед!
Калеб
2
в PHP это было быif (backend instanceof IDelatableUser) {...}
Rad80
Вы уже упоминали о нарушении LSP. Я согласен, но хотел немного добавить к нему: Test & Throw действителен, если входное значение делает невозможным выполнение действия, например, передача 0 в качестве делителя в Divide(float,float)методе. Входное значение является переменной, и исключение охватывает небольшое подмножество возможных исполнений. Но если вы выбрасываете в зависимости от типа реализации, то его неспособность выполнить - это конкретный факт. Исключение охватывает все возможные входы , а не только их подмножество. Это все равно, что ставить знак «мокрый пол» на каждом мокром полу в мире, где каждый этаж всегда мокрый.
Флейтер
Есть исключение (каламбур не предназначен) для принципа не бросать на тип. Для C # это так NotImplementedException. Это исключение предназначено для временных отключений, то есть кода, который еще не разработан, но будет разрабатываться. Это не то же самое, что окончательное решение о том, что данный класс никогда не будет ничего делать с данным методом, даже после завершения разработки.
Флейтер
Спасибо за ответ. Я действительно нуждался в решении времени выполнения, но не смог подчеркнуть его в своем вопросе. Поскольку я не хотел опровергать ваш ответ, я решил создать новый вопрос .
проблемный сотрудник
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 имеет метод ImplectedActions, который возвращает целое число, которое является результатом побитового ИЛИ действий побитового И с запрошенными действиями.

То, что вы по сути хотите сделать, это:

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

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

Это решит проблему, верно? Ну, технически это так. Но теперь вы нарушаете принцип подстановки Лискова (L в SOLID).

Отказавшись от довольно сложного объяснения в Википедии, я нашел достойный пример на 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);
}

Нет технической выгоды делать это вместо разделения на более мелкие куски; но это немного облегчит разработку, поскольку требует меньшего количества компоновки.

Flater
источник
+1 за разделение интерфейсов по поддерживаемому ими поведению, что и является целью интерфейса.
Грег Бургардт,
Спасибо за ответ. Я действительно нуждался в решении времени выполнения, но не смог подчеркнуть его в своем вопросе. Поскольку я не хотел опровергать ваш ответ, я решил создать новый вопрос .
проблемный сотрудник
@problemofficer: оценка времени выполнения для этих случаев редко является лучшим вариантом, но действительно есть случаи для этого. В таком случае вы либо создаете метод, который может быть вызван, но может в итоге ничего не делать (вызывайте его, TryDeleteUserчтобы отразить это); или у вас есть метод преднамеренно выдать исключение, если это возможно, но проблемная ситуация. Использование метода CanDoThing()и DoThing()метода работает, но это потребует от ваших внешних абонентов использовать два вызова (и будут наказаны за невыполнение), что является менее интуитивным и не таким элегантным.
Флейтер
0

Если вы хотите использовать типы более высокого уровня, вы можете выбрать тип набора на своем языке. Надеемся, что он предоставляет некоторый синтаксический сахар для выполнения пересечений множеств и определения подмножеств.

Это в основном то, что Java делает с EnumSet (за исключением синтаксического сахара, но эй, это Java)

Bwmat
источник
0

В мире .NET вы можете украшать методы и классы пользовательскими атрибутами. Это может не относиться к вашему делу.

Мне кажется, что проблема может быть связана с более высоким уровнем дизайна.

Если это функция пользовательского интерфейса, такая как страница редактирования пользователя или компонент, то как маскируются различные возможности? В этом случае тест и бросок будут довольно неэффективным подходом для этой цели. Предполагается, что перед загрузкой каждой страницы вы запускаете фиктивный вызов для каждой функции, чтобы определить, должен ли виджет или элемент быть скрыт или представлен по-другому. В качестве альтернативы, у вас есть веб-страница, которая в основном заставляет пользователя обнаруживать то, что доступно путем ручного «тестирования и выбрасывания», какой бы путь кодирования вы не выбрали, потому что пользователь не обнаруживает, что что-то недоступно, пока не появится всплывающее предупреждение.

Поэтому для пользовательского интерфейса вы можете захотеть посмотреть, как вы управляете функциями, и связать с этим выбор доступных реализаций, а не выбирать, какие реализации реализуют управление функциями. Возможно, вы захотите взглянуть на каркасы для составления функциональных зависимостей и явно определить возможности как сущности в вашей доменной модели. Это может быть даже связано с авторизацией. По сути, решение о том, доступна ли функция или нет на основе уровня авторизации, может быть расширено до принятия решения о том, реализована ли эта возможность, и тогда «функции» пользовательского интерфейса высокого уровня могут иметь явные сопоставления с наборами возможностей.

Если это веб-API, то общий выбор дизайна может быть затруднен из-за необходимости поддерживать несколько общедоступных версий API-интерфейса «Управление пользователем» или ресурса «REST пользователя», поскольку возможности со временем расширяются.

Подводя итог, можно сказать, что в мире .NET вы можете использовать различные способы Reflection / Attribute, чтобы заранее определить, какие классы реализуют что, но в любом случае кажется, что реальные проблемы будут связаны с тем, что вы делаете с этой информацией.

часовой
источник