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

Хранение конкретных реализаций на периферии программного обеспечения — это хорошо известная передовая практика в разработке программного обеспечения, и она обычно хорошо работает в качестве эвристики для создания мощных объектно-ориентированных структур и простого в обслуживании кода.

Общая идея заключается в том, чтобы классы знали интерфейсы друг друга (например, какие операции могут быть вызваны для каждого из них), не зная их конкретной реализации или «типа данных». Например, к какому конкретному классу или подклассу принадлежит объект).

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

Конкретные реализации часто определяются в начале потока выполнения, на самых первых уровнях программного обеспечения, перед входом в основные компоненты. Мы называем эти места «краями программного обеспечения», которые соответствуют фактической точке входа при каждом его выполнении.

Сделав скучное теоретическое введение в ООП, я много раз сталкивался с конкретным проблемным паттерном за несколько лет работы PHP-разработчиком. Здесь я описываю это и даю некоторые идеи, которые помогли мне справиться с этим в различных сценариях, пытаясь придерживаться вышеуказанной практики проектирования.

Проблема

Проблемный шаблон можно описать как наличие класса, который использует результат, предоставляемый другим. Результат определяется в терминах универсального интерфейса, поэтому мы можем знать некоторые операции, которые можно вызывать в экземпляре. Однако результат также содержит некоторые специфические операции и фрагменты данных, которые хочет использовать конечный потребитель.

Потребитель должен иметь возможность работать с различными возможными экземплярами результатов, реализуя различные алгоритмы обработки результатов в зависимости от типа каждого результата. Как мы можем добиться этого, не распространяя детали реализации по всему коду?

Я знаю, что это немного утомительно для понимания, поэтому давайте поработаем над этим на конкретном примере, используя настройку шаблона проектирования Command:

Несмотря на количество файлов, эту настройку легко понять: есть интерфейс Command, который возвращает экземпляр CommandResult в результате выполнения определенной команды. У нас есть класс ExecutionContext, который определяет общую структуру конкретного контекста.

Контекст будет отвечать за обработку (выполнение и обработку результата) каждой команды. Мы видим, что каждый конкретный подкласс результата команды содержит разные операции, но класс ExecutionContext знает только об интерфейсе CommandResult. Как мы могли бы соответствующим образом обработать каждый тип результата команды?

Это было моим заклятым врагом во многих случаях. Отсутствие подсказок полиморфного типа и обобщений в PHP затрудняет работу в этих сценариях; в отличие от Java, у которой есть хорошие собственные решения для таких случаев. В любом случае, я могу выделить три полезных подхода, которые позволили мне успешно решить эту проблему в прошлом:

  • Тип, приводящий общий результат к ожидаемому типу
  • Использование шаблона Visitor
  • Реструктуризация дизайна компонентов

Преобразование общего результата в ожидаемый

Честно говоря, это мое наименее предпочтительное решение. Поскольку мы знаем, что за экземпляром CommandResult будет конкретный экземпляр класса, мы могли бы попытаться предоставить разные реализации для каждого конкретного экземпляра следующим образом:

Реализация processResult спрашивает, какой конкретный тип имеет результат, чтобы обработать его соответствующим образом. Поскольку каждый закрытый метод processCommandResultX ожидает получения экземпляра CommandResultX, как определено в его подписи, наша IDE может помочь нам с автозаполнением и подсказкой типа в каждом из этих методов.

Метод processResult можно определить непосредственно в базовом классе ExecutionContext, оставив определение каждого метода processCommandResultX каждому подклассу.

Более того, базовый класс мог бы предоставлять реализации по умолчанию для каждого конкретного метода, наряду с реализацией по умолчанию, которая будет выполняться в случае, если ни один оператор instanceof не поймает конкретный результат команды (хотя в этом случае мы можем захотеть выдать ошибку, предупреждающую нас, что мы забыли обработать его).

Более мощная и гибкая версия будет использовать шаблон Chain of Responsibility, чтобы определить каждую конкретную обработку как стальную цепь. Таким образом, сталь могла обрабатывать только CommandResultA экземпляров, оставляя все остальные типы результатов для следующих сталей и так далее.

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

Хотя наличие большого количества instanceof проверок и методов обработки может быть немного сложным и многословным, этот первый подход удерживает конкретный код реализации на периферии программного обеспечения, пока мы рассматриваем классы контекста выполнения как часть краев. Таким образом, добавление нового типа результата команды влечет за собой:

  • Добавление нового конкретного метода обработки в иерархию ExecutionContext и реализация его в дочерних классах
  • Добавление новой проверки «instanceof» в методе processResult
  • При использовании цепочки ответственности добавление новой цепной стали и, возможно, изменение некоторых существующих.

Использование шаблона проектирования посетителей

С помощью шаблона проектирования Visitor мы можем добиться более элегантной настройки компонентов, как показано ниже:

Здесь мы позволяем каждому конкретному результату управлять тем, как его следует обрабатывать: каждый ExecutionContext имеет набор определенных методов обработки. Конкретный результат должен выбрать подходящий, реализуя метод processOnContext.

Элегантность обеспечивается отсутствием необходимости проверять тип класса каждого результата с помощью операторов instanceof. Однако этот подход не так уж отличается от предыдущего. Добавление новой команды вместе с новым типом результата команды повлечет за собой:

  • Добавление нового метода processCommandResultX в базовый класс ExecutionContext и реализация его в каждом конкретном подклассе контекста.

Реструктуризация дизайна

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

Во-первых, ExecutionContext был разработан для обработки CommandResult экземпляров. Но мы пытаемся обработать экземпляры CommandResultA и CommandResultB. Следовательно, мы смешиваем два разных уровня абстракции в одном классе. В любой точке кода мы должны работать либо с низким, либо с высоким уровнем абстракции, но не с обоими одновременно.

Во-вторых, CommandResultA определяет совершенно новый метод getString, который не определен на уровне родительского класса. То же самое происходит с CommandResultB. Более того, оба типа результатов команд не взаимозаменяемы друг с другом.

Мы нарушаем принцип замещения Ликскоу, и это исходит из предположения, что CommandResultA и CommandResultB являются разновидностями CommandResult, что, в свою очередь, неверно, поскольку у них нет ничего общего. Не используйте одни и те же поля данных и не выполняйте одни и те же операции.

Если вы заметите эти ошибки проектирования, это может привести к хорошим идеям для рефакторинга и перепроектирования текущего решения. Однако, поскольку я предполагаю, что две команды могут возвращать в качестве результатов разные поля данных, я определенно хочу обрабатывать их по-разному. Типы результатов команд различаются в зависимости от алгоритмов обработки. Следовательно, мы могли бы попытаться держать их обоих на самом краю программного обеспечения, как вы можете видеть ниже:

Я добавил ProcessingStrategy набор классов, которые определяют, как должен обрабатываться каждый конкретный тип результата. ExecutionContext now является основным компонентом и должен быть установлен в качестве стратегии обработки перед отправкой ему команды. Мы сохраняем обработку типов результатов на границах системы, используя этот подход, поскольку они определены только в файле index.php.

Этот подход предполагает наличие одной конкретной стратегии обработки для каждого типа результата команды, хотя метод processResult ожидает экземпляр CommandResult вместо определенного типа результата (например, CommandResultA).

Как следствие, я явно проверяю, что тип передаваемого результата соответствует тому, который я ожидаю получить. Я использую два подхода: ProcessingStrategyA приводит результат к CommandResultA, тогда как ProcessingStrategyB явно проверяет тип данного результата и полагается на PHPDoc для автозаполнения и подсказки типа.

Обратите внимание, что также может быть предоставлена ​​стратегия обработки, которая обрабатывает оба типа результатов. Таким образом, это более гибкий подход, чем другие. Также обратите внимание, что если мы передадим неожиданный тип результата команды в контекст выполнения, это вызовет ошибку. Более безопасная альтернатива будет включать некоторую обработку ошибок и поведенческие механизмы по умолчанию.

Вариантом этого подхода с расслабленным типом может быть тот, в котором класс ExecutionContext использует processingStrategy без явного определения его ожидаемого типа, что вы можете увидеть ниже:

Эта версия позволяет ExecutionContext использовать все, что имеет метод processResult в качестве допустимой стратегии обработки. Ослабив требования к типу, мы можем удалить наследование ProcessingStrategy, тем самым явно заявив, что ProcessingStrategyA instance сможет обрабатывать только CommandResultA instances вместо CommandResult ones. Основным преимуществом здесь является то, что мы избавляемся от instanceof проверок и приведения типов, а ограничение того, как стратегия обработки обрабатывает результат, делается явным.

Добавление нового типа результата команды с этим макетом повлечет за собой:

  • Добавление новой стратегии обработки, которая может работать с новым типом результата, хотя существующую можно использовать повторно.

Я надеюсь, что эта статья была полезной. Спасибо за чтение, и следите за обновлениями!