Квест → Как хакнуть форму
Прошли: 77
Некоторое время назад я начал работать над уже существующим проектом. Прежде чем погрузиться в него, я прочитал документацию. В самом начале файла contributing.md
мне встретилось следующее высказывание: "Абстракция везде, где это возможно". Вскоре я заметил, что в этом проекте гораздо больше абстрактных классов, чем обычно. Зачастую это приводит к появлению кода, который невозможно изменять и обладающего лишними зависимостями.
В этом посте я объясню, почему "абстракция везде, где это возможно" далеко не лучший совет. Это касается не только PHP, а и остальных языков программирования.
Сначала я расскажу об ошибках при работе с абстрактными классами. После этого покажу, когда создавать их будет ОК )
Я приведу несколько примеров с кодом.
Первый пример—базовый класс со сверхусложненными методами:
abstract class AbstractRepository { public function __construct(Model $models) { $this->models = $models; } public function get(array $options = []) { if (isset($options['sort'])) { //... } if (isset($options['filters'])) { //... } return $this->models->get(); } }
Что здесь происходит: в базовом классе есть метод get
, который возвращает список моделей. Чем больше возникает вариантов использования этого базового класса, тем менее гибким он становится. Метод get
получает все больше и больше опциональности и приобретает новый аргумент $options
. А вместе с ростом количества применений, растет и количество строк кода. В конце концов, в методе get
куча багов, его трудно тестировать и тяжело адаптировать, не нарушая его структуры. Разработчики, которым понадобится немного другое поведение AbstractRepository
просто переопределят get
в более простой, в соответствии со своими потребностями. В итоге: более простой метод с меньшим количеством фич, следовательно теряется возможность переиспользования функционала.
Как решить эту проблему? При вышеописанном подходе возникает ситуация, когда для внесения новой функциональности вам приходится искать "то самое" место в коде и постоянно дописывать этот функционал именно тут. Постарайтесь замечать такие моменты (появление массива $options
—верный признак этого) и делайте рефакторинг путем перемещения реализаций в дочерние классы. Позвольте им самим позаботиться о своих собственных кейсах, таких как сортировка.
Если сортировка должна быть добавлена более чем в одном хранилище, просто для начала продублируйте код, а не делайте сразу абстракции. После того как вы закончили дублировать код и все работает, можно начать добавлять специализированные классы для сортировки и по их использованию задать ей логику. В идеале применить сортировку для какого-либо хранилища займет всего несколько строк.
Второй пример—базовая реализация, определяющая зависимости для своих подклассов, даже если эти зависимости не используются в абстрактном классе.
abstract class CsvExporter { protected $repostory; protected $file; protected $writer; protected $parameters; public function __construct(RepositoryInterface $repository, TransformerInterface $transformer, array $parameters) { $this->repository = $repository; $this->parameters = $parameters; $this->file = new SplTempFileObject(); $this->writer = Writer::createFromFileObject($this->file); $this->writer->insertOne($transformer->getHeaders()); $this->writer->addFormatter([$transformer, 'transform']); } }
Это базовый класс, позволяющий подклассам сбрасывать данные из хранилища в файл CSV
. Он предусматривает переиспользование, но что если вам когда-нибудь понадобится делать дамп данных не происходящих из репозитория, а откуда-то еще (например, из списка файлов в каталоге)? Этот базовый класс знает о классе, пишущем в CSV
, о хранилище и о преобразователе. Не слишком ли много?
Какое решение тут подойдет лучше? Конечно же, трейты! Каждый класс, делающий экспорт в CSV
, должен иметь доступ к SplFileObject
и объекту Writer
(из league/csv
), все остальное здесь — детали реализации. Вынесем в трейт оба свойства - $file
и $writer
.
trait CsvWriter { private $file; private $writer; }
Да и, вообще, чтоб не заморачиваться с инициализацией этих свойств, вынесем в трейт заодно и setup
trait CsvWriter { private $file; private $writer; private function setup() { $this->file = new SplTempFileObject(); $this->writer = Writer::createFromFileObject($this->file); } }
Если класс, использующий трейт, не хочет самостоятельно форматировать данные для CSV экспорта, можно добавить трансформер, задающий заголовки, вставляемые в первую строку файла. И напоследок переиспользуемый метод с трейтом:
private function setTransformer(TransformerInterface $transformer) { $this->writer->insertOne($transformer->getHeaders()); $this->writer->addFormatter([$transformer, 'transform']); }
Теперь класс-экспортер легко использует класс Writer
(league/csv
) c помощью трейта
class UserExporter { use CsvWriter; public function __construct(User $users, UserTransformer $transformer) { $this->setup(); $this->setTransformer($transformer); $this->users = $users; } public function getFile() { $this->users->chunk(100, function ($users) { $this->writer->insertAll($users); }); return $this->file; } }
Вот так просто: теперь и трейт и каждый класс независимы, тестируемы и могут быть многократно используемы.
Примечание: это всё реальные примеры. Они работают и решают задачи, для которых предназначены. Хотя, при изменении к ним требований, некоторые из приведенных решений адаптируются не так легко, как хотелось бы.
Вот что я хочу показать этими примерами—можно написать код, где будет меньше ненужных зависимостей, и он даже будет более гибким и универсальным для постоянно меняющихся потребностей ваших проектов.
Они хороши для создания частичных реализаций интерфейсов с несколькими методами, которые могут иметь реализацию — вот тогда абстрактные классы подходят для 90% реализаций интерфейса.
namespace League\Event; interface ListenerInterface { /** * Handle an event. * * @param EventInterface $event * * @return void */ public function handle(EventInterface $event); /** * Check whether the listener is the given parameter. * * @param mixed $listener * * @return bool */ public function isListener($listener); }
Это класс ListenerInterface
из пакета league/event
. Почти для каждой реализации Listner
’а, метод isListner
имеет одинаковую реализацию, поэтому в пакет league/event
отправляется и этот класс AbstractListener
namespace League\Event; abstract class AbstractListener implements ListenerInterface { /** * {@inheritdoc} */ public function isListener($listener) { return $this === $listener; } }
Если по какой-то причине вы хотите использовать существующий класс (уже являющийся частью собственного дерева наследования и, таким образом, имеющий свой собственный родительский класс), чтобы он стал listner
’ом, и осуществлял ListenerInterface
, вам нужно будет реализовать собственный метод isListener
. В качестве альтернативы вы можете создать и использовать простенький трейт вроде этого:
trait PartialListener { /** * {@inheritdoc} */ public function isListener($listener) { return $this === $listener; } }
Резюмируем