Добавить комментарий

GRASP: как правильно распределять ответственность между объектами

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

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

Что такое GRASP?

GRASP расшифровывается как General Responsibility Assignment Software Patterns — общие шаблоны распределения ответственности в программном обеспечении.

Главная идея GRASP достаточно простая: когда мы проектируем систему, нам постоянно приходится отвечать на вопрос:

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

Например, у нас есть заказ. Кто должен рассчитывать его итоговую стоимость? Сам заказ? Отдельный сервис? Контроллер? Репозиторий?

Именно для подобных решений GRASP и дает набор принципов, которые помогают принимать более обоснованные архитектурные решения.

GRASP и SOLID — в чем разница?

GRASP и SOLID — это не одно и то же, хотя между ними существует очень тесная связь.

SOLID дает общие принципы, которые помогают создавать более гибкий, поддерживаемый и расширяемый объектно-ориентированный код.

GRASP больше концентрируется на вопросе распределения ответственности между объектами.

Если очень упрощенно:

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

Некоторые принципы напрямую пересекаются. Например, Polymorphism хорошо сочетается с OCP и DIP из SOLID, Low Coupling — с идеей уменьшения зависимостей, а Protected Variations во многом перекликается с Dependency Inversion Principle.

Но GRASP не стоит воспринимать как еще один вариант SOLID. Это скорее другой взгляд на проектирование объектной модели.

1. Information Expert — информационный эксперт

Этот принцип говорит: ответственность должна находиться у того объекта, который обладает необходимой для ее выполнения информацией.

Например, у нас есть объект заказа, который содержит товары заказа:

class Order
{
    private array $items;
 
    public function total(): float
    {
        // расчет общей стоимости заказа
    }
}

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

Логично, что сам Order, потому что именно он знает, какие товары входят в заказ.

$total = $order->total();

А не какой-нибудь внешний класс:

$total = $orderService->calculateTotal($order);

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

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

2. Creator — создатель

Следующий вопрос — кто должен создавать объект?

GRASP предлагает несколько признаков, по которым можно определить подходящего создателя. Например, если объект A содержит объекты B, использует их или тесно связан с ними, A может быть хорошим кандидатом для создания B.

Например, заказ содержит позиции заказа:

class Order
{
    public function addProduct(Product $product, int $quantity): void
    {
        $item = new OrderItem($product, $quantity);
 
        // ...
    }
}

В таком случае Order вполне логично может отвечать за создание OrderItem.

При этом Creator не означает, что мы обязательно должны писать new непосредственно в этом классе. В Laravel создание объекта может выполнять контейнер зависимостей, фабрика или другой механизм.

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

3. Controller — контроллер

Здесь есть важный момент: GRASP Controller не совсем то же самое, что MVC Controller.

В GRASP Controller — это объект, который получает системное событие и передает выполнение дальше.

Например, пользователь оформляет заказ. Контроллер может принять HTTP-запрос и передать выполнение сервису:

class OrderController
{
    public function store(Request $request)
    {
        return $this->orderService->create(
            $request->validated()
        );
    }
}

Контроллер при этом не должен превращаться в место, где находится вся бизнес-логика приложения.

Одна из распространенных проблем — так называемый God Object, когда один класс начинает знать и делать абсолютно все.

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

4. Low Coupling — низкая связанность

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

Например, не очень хорошо, если наш OrderService напрямую создает конкретную реализацию платежной системы:

class OrderService
{
    public function pay(Order $order): void
    {
        $stripe = new StripePaymentGateway();
 
        $stripe->pay($order);
    }
}

Теперь OrderService напрямую зависит от Stripe.

Лучше зависеть от абстракции:

class OrderService
{
    public function __construct(
        private PaymentGateway $paymentGateway
    ) {
    }
 
    public function pay(Order $order): void
    {
        $this->paymentGateway->pay($order);
    }
}

Теперь конкретную реализацию можно заменить:

StripePaymentGateway
PayPalPaymentGateway
LiqPayPaymentGateway

Dependency Injection здесь является одним из инструментов, который помогает уменьшить связанность. Но важно понимать разницу: DI — это механизм, а Low Coupling — архитектурная цель.

5. High Cohesion — высокая связность

Если Low Coupling говорит о том, насколько сильно класс зависит от других классов, то High Cohesion говорит о том, насколько хорошо связаны между собой обязанности внутри самого класса.

Класс должен иметь набор тесно связанных между собой обязанностей.

Например, если у нас появляется огромный OrderService:

class OrderService
{
    public function createOrder()
    {
    }
 
    public function calculateTotal()
    {
    }
 
    public function sendEmail()
    {
    }
 
    public function generatePdf()
    {
    }
 
    public function chargePayment()
    {
    }
 
    public function exportToXml()
    {
    }
 
    public function synchronizeWithWarehouse()
    {
    }
}

Скорее всего, у такого класса уже проблемы с cohesion.

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

High Cohesion во многом напоминает принцип Single Responsibility Principle из SOLID, но это не абсолютно одно и то же. Они смотрят на проблему с разных сторон и хорошо дополняют друг друга.

6. Polymorphism — полиморфизм

Если поведение системы зависит от типа объекта, GRASP предлагает использовать полиморфизм вместо большого количества условий.

Например, у нас есть разные платежные системы:

interface PaymentGateway
{
    public function pay(Order $order): void;
}

class StripePaymentGateway implements PaymentGateway
{
    public function pay(Order $order): void
    {
        // оплата через Stripe
    }
}

class PayPalPaymentGateway implements PaymentGateway
{
    public function pay(Order $order): void
    {
        // оплата через PayPal
    }
}

Теперь основной код работает с абстракцией:

$paymentGateway->pay($order);

А не делает что-то подобное:

if ($paymentType === 'stripe') {
    // ...
} elseif ($paymentType === 'paypal') {
    // ...
} elseif ($paymentType === 'liqpay') {
    // ...
}

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

Этот принцип хорошо связан с Open/Closed Principle и Dependency Inversion Principle из SOLID.

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

7. Pure Fabrication — искусственная сущность

Этот принцип мне кажется особенно интересным.

Иногда мы понимаем, что определенную ответственность не очень удобно помещать ни в один из объектов предметной области.

Например, есть операция сохранения заказа в базу данных.

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

Вместо этого мы можем создать отдельный объект:

class OrderRepository
{
    public function save(Order $order): void
    {
        // сохранение заказа
    }
}

OrderRepository — это не объект из предметной области. Это искусственно созданный программный объект, который существует для правильного распределения ответственности.

То же самое может быть с:

  • OrderRepository
  • PaymentService
  • OrderMailer
  • InvoiceGenerator
  • XmlExporter

И здесь есть важный момент: Pure Fabrication не означает «давайте все вынесем в Service».

Смысл в том, чтобы создать отдельный программный объект тогда, когда это позволяет получить более понятную архитектуру, высокую cohesion и низкую coupling.

8. Indirection — посредник

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

A → C → B

Вместо:

A → B

Например:

OrderService → PaymentGateway → Stripe

OrderService не знает непосредственно о Stripe. Между ними находится PaymentGateway.

Еще один хороший пример — события:

OrderService → OrderCreated → Listener → Email

OrderService не должен знать, какой именно класс отправит письмо. Он просто сообщает, что заказ создан.

При этом один и тот же механизм может одновременно реализовывать несколько принципов GRASP.

Например, PaymentGateway является посредником согласно Indirection, уменьшает связанность согласно Low Coupling и защищает систему от изменения конкретного платежного провайдера согласно Protected Variations.

9. Protected Variations — защищенные вариации

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

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

Сегодня используется Stripe, завтра может появиться PayPal или другой провайдер.

Мы можем выделить точку изменения:

Stripe
PayPal
LiqPay

И защитить остальную систему абстракцией:

OrderService → PaymentGateway → Stripe
                               PayPal
                               LiqPay

Теперь изменение конкретного платежного провайдера не должно затрагивать OrderService.

Здесь можно говорить о двух понятиях: Point of Variation — точка возможного изменения, и Point of Protection — место, через которое мы защищаем систему от этого изменения.

Этот принцип очень хорошо перекликается с Dependency Inversion Principle из SOLID.

Как все эти принципы работают вместе?

На практике мы редко применяем только один принцип GRASP.

Например, представим оформление и оплату заказа в интернет-магазине.

  1. Information Expert — Order знает свои позиции и может рассчитывать свою стоимость.
  2. Creator — Order может создавать OrderItem.
  3. Controller — OrderController получает запрос пользователя и передает выполнение дальше.
  4. Low Coupling — OrderService не зависит напрямую от Stripe.
  5. High Cohesion — каждая часть системы занимается близкими по смыслу обязанностями.
  6. Polymorphism — разные платежные провайдеры реализуют общий PaymentGateway.
  7. Pure Fabrication — для инфраструктурных задач создаются отдельные классы, например OrderRepository.
  8. Indirection — PaymentGateway становится посредником между бизнес-логикой и платежным провайдером.
  9. Protected Variations — изменение Stripe на другой платежный сервис не требует переписывать OrderService.

В итоге получается не набор разрозненных правил, а взаимосвязанная система принципов.

Краткая шпаргалка по GRASP

Принцип Главный вопрос
Information Expert Кто обладает необходимой информацией?
Creator Кто должен создавать этот объект?
Controller Кто должен обрабатывать системное событие?
Low Coupling Как уменьшить ненужные зависимости?
High Cohesion Насколько связаны обязанности внутри класса?
Polymorphism Можно ли заменить условную логику полиморфизмом?
Pure Fabrication Нужно ли создать отдельный программный объект для этой ответственности?
Indirection Можно ли добавить посредника и уменьшить прямую зависимость?
Protected Variations Что может измениться и как защитить от этого остальную систему?

Не стоит учить GRASP как список правил

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

Когда появляется новая задача, можно просто задавать себе несколько вопросов:

  • Кто должен отвечать за это?
  • У кого есть необходимая информация?
  • Кто должен создавать этот объект?
  • Не слишком ли сильно этот класс связан с другими?
  • Не слишком ли много разных обязанностей находится в одном классе?
  • Можно ли использовать полиморфизм?
  • Стоит ли вынести ответственность в отдельный объект?
  • Можно ли добавить посредника?
  • Что в будущем может измениться и как защитить от этого остальную систему?

И постепенно эти вопросы начинают возникать автоматически во время проектирования.

Зачем изучать GRASP, если уже есть SOLID?

На мой взгляд, GRASP и SOLID хорошо дополняют друг друга.

SOLID дает достаточно мощный набор принципов для понимания качественного объектно-ориентированного дизайна.

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

Именно здесь GRASP оказывается очень полезным.

Он заставляет не просто думать «как сделать код красивее», а задавать более конкретные вопросы:

Кто должен это делать?
Кто обладает необходимыми данными?
От кого этот объект должен зависеть?
Что произойдет, если эта часть системы изменится?

Заключение

Для меня GRASP в первую очередь оказался способом немного по-другому смотреть на проектирование классов.

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

Именно на это и направлены принципы GRASP.

Поэтому, когда я проектирую новую функциональность, полезно остановиться и спросить себя:

Кто действительно должен это делать?
У кого есть необходимые данные?
Кто должен создавать этот объект?
От кого он должен зависеть?
Что произойдет, если эта часть системы изменится?

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

Тэги: 
CAPTCHA
Защита от спама
Target Image