<rmcreative>

RSS

Принципы GRASP

4 июля 2022

Набор принципов GRASP, general responsibility assignment software principles, что переводится как "общие принципы распределения обязанностей", помогает, как следует из названия, правильно выбрать в какой объект или модуль распределить определённую обязанность. Под обязанностью здесь подразумевается знание/хранение информации и/или проведение каких-либо действий.

Принципы сформулированы в 1997 году Крэгом Ларманом в книге "Applying UML and Patterns" (на русском выходила под названием «Применение UML 2.0 и шаблонов проектирования»).

Всего их девять. Четыре основных и пять дополнительных.

Information expert / Информационный эксперт

Как разделить обязанности?

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

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

final class ShoppingCart
{
    /**
     * @var Item[]
     */
    private array $items;
 
    public function __construct(Items $items)
    {
        $this->items = $items;
    }
 
    public function getTotalAmount()
    {
        return array_reduce($this->items, function ($sum, $item) {
            return $sum + $item->getAmount();
        }, 0);
    }
}

Принцип позволяет добиваться большей инкапсуляции. То есть чтобы работающий с публичным API меньше встречался с ситуацией "все кишки наружу". Однако, он не всегда даёт хороший результат. Об этом ниже в "Low Coupling" и "High Cohesion".

Creator / Создатель

Кто должен отвечать за создание нового экземпляра класса?

Класс B должен создавать экземпляры класса A если выполняется одно из следующих условий в порядке важности: - B агрегирует A. - B содержит A. - B записывает экземпляры A. - B активно использует объекты A. - B обладает данными для инициализации экземпляров A.

final class ShoppingCart // B
{
    /**
     * @var Item[]
     */
    private array $items = [];
 
    public function addItem(string $name, int $amount)
    {
        $this->items[] = new Item($name, $amount); // A
    }
}

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

Low coupling / Низкая связанность

Как для зависимости снизить влияние изменений и повысить возможность повторного использования?

Держать степень связанности низкой.

Связанность — мера, определяющая насколько жёстко один элемент связан с другими элементами, либо каким количеством данных о других элементах он обладает:

final class A
{
    private B $b; // <-- A связан с B
}
 
final class A
{
    public function doit()
    {
        (new B())->doit(); // <-- A связан с B
    }
}
 
final class A
{
    public function doit(B $b) // <-- A связан с B
    {
        // ...
    }
}
 
final class A extends B // <-- A связан с B
{
    // ...
}
 
final class A implements B // <-- A связан с B
{
    // ...
}

Низкая связанность означает малое количество таких связей и данных. Высокая — большое. При высокой связанности:

  1. Правки в одной части кода, как правило, затрагивают множество других частей кода.
  2. Тяжело понимать код. Приходится прыгать по разным классам.
  3. Как следствие, тяжело повторно использовать класс.

При применении принципа внимание стоит уделять нестабильным быстро меняющимся частям системы.

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

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

High cohesion / Высокая связность или высокое функциональное зацепление

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

Обязанности внутри элемента должны быть тесно связаны и сфокусированы.

Все ли данные и действия внутри элемента нужны для выполнения основной задачи? Нет ничего лишнего? Не осталось ли что-то вовне?

// Lowest cohesion
final class DataProvider
{
    public function getItemsFromDatabase()
    {
        // ...
    }
 
    public function getItemsFromAPI()
    {
        // ...
    }
}

Градации функционального зацепления таковы:

  • Очень низкое - обязанности из не связанных областей (как выше).
  • Низкое- обязанности из одной области, но их слишком много.
  • Среднее - несложные обязанности из разных областей, логически связанных с концепцией класса, но не связанных между собой.
  • Сильное - среднее количество обязанности из одной области, взаимодействует с другими классами из той же области

Низкое функциональное зацепление нормально в некоторых ситуациях:

  1. Когда это упрощает поддержку одним человеком.
  2. Когда это улучшает производительность в распределённых системах.

Controller / Контроллер

Кто должен отвечать за обработку внешних событий?

Класс, который подходит под одно из условий:

  1. Представляет всю систему в целом, устройство или подсистему.
  2. Представляет собой сценарий в рамках которого выполняется обработка.

Тут речь о, в общем-то, уже классическом понимании контроллера, как оно есть в популярных фреймворках. То есть прослойки между входными (интерфейсом) и, собственно, логикой предметной области. Важный момент в том, что контроллер не должен сам выполнять нужные действия, а должен лишь делегировать и координировать их.

final class PostController
{
    public function actionCreate(
        Request $request,
        CreatePostOperation $operation,
        CreatePostValidator $validator
    )
    {
        $data = $request->getParsedBody;
 
        $validator->validate($data);
 
        return $operation->handle($data);
    }
}

Такое разделение позволяет улучшить повторное использование и тестируемость компонентов, которым в контроллере мы делегируем выполнение.

Indirection / Посредник

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

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

final class Item
{
    public function save()
    {
        (new ItemStorage())->save($this);
    }    
}
 
final class ItemStorage // Посредник
{
    public function save(Item $item)
    {
        (new DatabaseStorage())->save($item);
    }
}
 
final class DatabaseStorage
{
    public function save($object)
    {
        // Логика сохранения в базу
    }
}

Polymorphism / Полиморфизм

Как обрабатывать альтернативные варианты поведения на основе типа? Как создавать подключаемые программные компоненты?

Если поведение объектов класса может измениться, распределяйте обязанности при помощи полиморфных операций.

Алгоритмы с if-then-else приходится модифицировать при изменении вариантов. Это затрудняет поддержку кода. Полиморфизм позволяет добавлять новые способы обработки без модификации основного кода:

interface ItemInterface
{
    public function getType(): string;
}
 
final class MessageSender
{
    public function handle(ItemInterface $item)
    {
        if ($item->getType() === 'email') {
            $this->sendEmail($item);
        } elseif ($item->getType() === 'sms') {
            $this->sendSms($item);
        }
        // Сюда добавляем новые варианты
    }
}
 
// ↓↓↓
 
interface ItemInterface
{
    public function send(): void;
}
 
 
final class MessageSender
{
    public function handle(ItemInterface $item)
       $item->send();
    }
}

Полиморфизм не стоит применять если вероятность вариативного поведения низкая или неизвестная.

Protected variations / Устойчивость к изменениям

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

Идентифицировать точки вариаций (неустойчивости, изменений). Распределить обязанности так, чтобы обеспечить устойчивый интерфейс (контракт). То есть скрыть детали реализации за интерфейсом.

Хороший пример — отсылка SMS. Так как горький опыт говорит нам что шлюзы отказывают, меняют цены и условия, стоит сразу ввести интерфейс:

interface SMSSenderInterface
{
    public function send(string $phone, string $message);
}
 
final class NexmoSmsSender implements SMSSenderInterface
{
    // ...
}
 
final class SmscSmsSender interface SMSSenderInterface
{
    // ...
}

В остальном коде мы везде применяем исключительно SMSSenderInterface, что при смене шлюза позволяет нам не менять ни строчки кода кроме конфигурации контейнера или инициализации где мы выбираем новую реализацию SMSSenderInterface.

Не стоит применять принцип если вы не можете нормально идентифицировать точки изменений в системе.

Если система для вас новая, можно воспользоваться техническими средствами и посмотреть на git churn.

Pure fabrication / Чистая выдумка

Какой класс должен обеспечить high cohesion / low coupling и другие принципы если information expert не даёт ответ на этот вопрос?

Создать новый искусственный класс с высоким зацеплением и слабым связыванием не являющийся частью предметной области.

Как сохранять Item в базу данных? Согласно Information Expert, делать это должен сам Item, но:

  1. В Item окажется множество операций, связанных не с самой доменной моделью, а с записью в базу. То есть низкая степень зацепления.
  2. Класс завязывается на интерфейсы для взаимодействие с базой. То есть возрастает степень связывания.
  3. Задача взаимодействия с базой достаточно общая, её придётся решать каждый раз копированием.

Information Expert тут явно не даёт результата, поэтому создаётся новый класс:

final class Item
{
    // Чистая бизнес-логика
}
 
final class DatabaseStorage
{
    public function save($object)
    {
        // Логика сохранения в базу
    }
}

Классы можно выделять в соответствии с объектами предметной области и в соответствии с поведением. Здесь у нас второй случай

Не стоит злоупотреблять Pure fabrication. Применяйте только если не сработали другие принципы.

Комментарии RSS

  1. Почта опубликована не будет.

  2. Можно использовать синтаксис Markdown или HTML.

  3. Введите ответ в поле. Щёлкните, чтобы получить другую задачу.