Квест → Как хакнуть форму
Прошли: 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;
}
}
Резюмируем