Для нетерпеливых, весь код тут https://github.com/roman-pankov/class-relations-example
Внимание, ниже много кода и примеров!
Эти три громких слова обозначают разные типы отношений между классами/объектами. Ассоциация - это самый базовый тип отношений. Мы не знаем как выражается связь между двумя классами и потому уточним это позже. Данный базовый тип отношений полезен на ранних этапах продумывания архитектуры кода без уточнения деталей.
Схематично это выглядит вот так:
Данную схему можно читать как класс UserService зависит от UserRepository
Всегда проще всего воспринимать на примерах. Пример оторванный от реальности, по типу "машина, двигатель, колёса" смысла делать нет, поэтому приведу пример из жизни. Допустим нам нужно создать систему, которая будет ходить в разные источники данных курсов валют и сохранять к нам в базу.
Требования:
- На первом этапе будет два источника: ЦБ РФ и Сбербанк. В эти источники будем ходить через REST API
- Нужно чтобы можно было быстро добавить новый источник курсов валют
- Получение курсов валют может быть через разные каналы. REST API, SOAP, GraphQL и даже на почту приходить в виде файла.
Наследование
- Наследование позволяет создавать новый класс на основе уже существующего класса, называемого родительским классом или суперклассом.
- Подкласс или производный класс наследует свойства и методы родительского класса и может добавлять к ним свои собственные.
- Пример: У нас есть класс "Фигура", и у него есть подклассы "Круг" и "Квадрат". Круг и квадрат наследуют общие свойства и методы от класса "Фигура", но могут иметь свои уникальные характеристики.
Новички первым делом бросаются на самый очевидный вариант, наследование. Оно понятное, создаёшь абстрактный класс для всех провайдеров курсов валют и наследуешь конкретные провайдеры.
Пример кода
// Абстрактный класс
namespace App\Inheritance\Provider;
abstract class AbstractProvider { protected function makeHttpRequest(string $url): string { /*
* ТУТ КОД HTTP ЗАПРОСА
*/
// Допустим, что был HTTP запрос и вернулся JSON return <<<JSON [
{
"title": "USD to RUB",
"price": 0.9
},
{
"title": "EUR to RUB",
"price": 1.1
}
]
JSON; }
protected function parseData(string $data): array { return json_decode($data, true); }
abstract public function getRates(): array; }
// Провайдер ЦБ РФ
class CbrfProvider extends AbstractProvider {
public function getRates(): array { $json = $this->makeHttpRequest('https://cbrf.ru/rates.json'); $data = $this->parseData($json);
return $data; } }
// Провайдер для Сбербанка
class SberbankProvider extends AbstractProvider { public function getRates(): array { $json = $this->makeHttpRequest('https://sberbank.ru/rates.json'); $data = $this->parseData($json);
return $data; } }
UML диаграмма
Не закрашенный треугольник обозначает "Расширение". В нашем случаем CbrfProvider и SberbankProvider расширяют AbstractProvider. По простому, наследуют.
На первый взгляд всё хорошо, мы можешь создавать сколько угодно провайдеров и наследоваться от AbstractProvider.
Доработки
Внезапно к нам прибегает менеджер и радостно говорит, нам нужно подключить BleatBank который отправляет файл с курсами валют к нам на почту. Схема работы следующая:
- Зайти на почту
- Взять последнее письмо с файлом
- Распаковать zip архив
- Распарсить csv
- Вернуть курсы валют
Самый просто вариант идти по старой схеме. Создать BleatBankProvider и отнаследоваться от AbstractProvider. В BleatBankProvider будут доступны методы:
protected function makeHttpRequest(): string
protected function parseData(string $data): array
Метод parseData мы ещё как-то можем переопределить и добавить нужную нам логику, но вот метод makeHttpRequest будет висеть мёртвым грузом. Метод makeHttpRequest используется для HTTP запросов и возвращает JSON в виде string, что вообще не подходит для нашей логики. Можно конечно переопределить метод makeHttpRequest и добавить туда логику получения содержимого файла с почты, но у нас сразу проблема в нейминге метода. Метод должен делать HTTP запроса, но он будет ходить на почту.
Немного подумав, можем забыть про метод makeHttpRequest и получится примерно такой код:
namespace App\Inheritance\Provider;
class BleatBankProvider extends AbstractProvider {
public function getRates(): array { $fileContent = $this->getFileContentFromMail( 'email@bleat_bank.ru', 'password123', 'rates.csv' );
$data = $this->parseData($fileContent);
return $data; }
protected function parseData(string $data): array { $parsedData = []; foreach (explode("\n", $data) as $row) { $parsedRow = str_getcsv($row); $parsedData[] = [ 'title' => $parsedRow[0], 'price' => $parsedRow[1], ]; }
return $parsedData; }
private function getFileContentFromMail(string $mail, string $password, string $fileName): string { /*
* ТУТ КОД ПОЛУЧЕНИЯ ФАЙЛА С ПОЧТЫ
*/
// Допустим получили файл с почты и вернули его содержимое return <<<CSV "USD to RUB",0.9
"EUR to RUB",1.1
CSV; } }
Как видим какие-то методы переопределены, какие-то остались в AbstractProvider. При этом у BleatBankProvider будет зависимость от HTTP клиента. Чтобы понять логику работы BleatBankProvider нам нужно прыгать между AbstractProvider и BleatBankProvider. Уже звучит очень сложно и сложно писать под это тесты.
Использование провайдеров
В клиентском коде мы можем вызывать наши провайдеры таким образом
namespace App\Inheritance;
use App\Inheritance\Provider\AbstractProvider; use App\Inheritance\Provider\BleatBankProvider; use App\Inheritance\Provider\CbrfProvider; use App\Inheritance\Provider\SberbankProvider; use App\Inheritance\Repository\RatesRepository;
class Service { private RatesRepository $repository;
/** @var AbstractProvider[] */ private array $providers = [];
public function __construct() { $this->repository = new RatesRepository();
$this->providers[] = new CbrfProvider(); $this->providers[] = new SberbankProvider(); $this->providers[] = new BleatBankProvider(); }
public function refreshRates(): void { foreach ($this->providers as $provider) { $rates = $provider->getRates();
// Сохраняем полученные данные в базу $this->repository->save($rates); } } }
В общем всё красиво и работает. В общем это и есть наследование, где у нас есть AbstractProvider и другие классы наследуют его и все методы из AbstractProvider доступны в класса потомках.
Весь код для Наследования можно посмотреть в репозитории https://github.com/roman-pankov/class-relations-example/tree/main/src/Inheritance
Композиция
- Композиция - это более строгий вид отношения, при котором объекты, составляющие другой объект, существуют только в контексте этого объекта. Если объект уничтожается, его составляющие объекты также уничтожаются.
- Пример: У нас есть класс "Дом", который состоит из объектов класса "Комната". Если дом разрушается, все комнаты внутри него также разрушаются.
В данной главе мы можем избавиться от AbstractProvider и написать более чистый и понятный код.
Немного посмотрев на код, мы понимаем, что чем больше у нас будет провайдеров, тем более получится запутанней код из-за наследования. Например, завтра нам нужно будет брать файл с курсами валют с FTP.
Давайте порефакторим код. Найдём функционал, который можно вынести в отдельные классы. Эти классы будут делать только одно действия и делать это хорошо. Принцип единственной ответственности. При этом мы эти классы можно использовать в разных провайдерах.
- HTTP запросы
- Получение файла с почты
- Парсинг JSON
- Парсинг CSV
Всё, что связано с запросами во вне, вынесем в папку Transport
namespace App\Composition\Transport;
class HttpTransport { public function doRequest(string $url): string { /*
* ТУТ КОД HTTP ЗАПРОСА
*/
// Допустим, что был HTTP запрос и вернулся JSON return <<<JSON [
{
"title": "USD to RUB",
"price": 0.9
},
{
"title": "EUR to RUB",
"price": 1.1
}
]
JSON; } }
class EmailTransport { public function getFileContent(string $email, string $password, string $filename): string { /*
* ТУТ КОД ПОЛУЧЕНИЯ ФАЙЛА С ПОЧТЫ
*/
// Допустим получили файл с почты и вернули его содержимое return <<<CSV "USD to RUB",0.9
"EUR to RUB",1.1
CSV; } }
Всё что связано с парсингом, вынесем в папку Parser
namespace App\Composition\Parser;
class JsonParser { public function parse(string $json): array { return json_decode($json, true); } }
namespace App\Composition\Parser;
class CsvParser { public function parse(string $csvData): array { $parsedData = []; foreach (explode("\n", $csvData) as $row) { $parsedRow = str_getcsv($row); $parsedData[] = [ 'title' => $parsedRow[0], 'price' => $parsedRow[1], ]; }
return $parsedData; } }
Отлично, общие классы которые делают только одно действие у нас написаны, теперь нам нужно это внедрить в наши провайдеры, но перед этим нужно избавиться от AbstractProvider. Немного посмотрев на AbstractProvider, можем увидеть, что это можно заменить на интерфейс.
namespace App\Composition\Provider;
interface ProviderInterface { public function getRates(): array; }
Теперь мы можем переписать провайдеры и заменить AbstractProvider на ProviderInterface и внедрить зависимости в виде Transport и Parser.
namespace App\Composition\Provider;
// Провайдер ЦБ РФ use App\Composition\Parser\JsonParser; use App\Composition\Transport\HttpTransport;
class CbrfProvider implements ProviderInterface { private HttpTransport $httpTransport; private JsonParser $parser;
public function __construct() { $this->httpTransport = new HttpTransport(); $this->parser = new JsonParser(); }
public function getRates(): array { $json = $this->httpTransport->doRequest('https://cbrf.ru/rates.json');
$data = $this->parser->parse($json);
return $data; }
// Провайдер Сбербанк use App\Composition\Parser\JsonParser; use App\Composition\Transport\HttpTransport;
class SberbankProvider implements ProviderInterface { private HttpTransport $httpTransport; private JsonParser $parser;
public function __construct() { $this->httpTransport = new HttpTransport(); $this->parser = new JsonParser(); }
public function getRates(): array { $json = $this->httpTransport->doRequest('https://sberbank.ru/rates.json');
$data = $this->parser->parse($json);
return $data; } }
// Провайдер BleatBank use App\Composition\Parser\CsvParser; use App\Composition\Transport\EmailTransport;
class BleatBankProvider implements ProviderInterface { private EmailTransport $emailTransport; private CsvParser $csvParser;
public function __construct() { $this->emailTransport = new EmailTransport(); $this->csvParser = new CsvParser(); }
public function getRates(): array { $csvData = $this->emailTransport->getFileContent( 'email@bleat_bank.ru', 'password123', 'rates.csv' );
$data = $this->csvParser->parse($csvData);
return $data; } }
Как видим, каждый провайдер имплементирует ProviderInterface, метод getRates и в конструкторе мы добавили наши зависимости. И наши провайдеры стали читать проще, не нужно тратить время и прыгать в абстрактный класс.
UML диаграмма
Классов стало больше, кода стало больше, но сам код стал проще
Использование провайдеров
Клиентский код у нас в общем не изменился. Мы так же создаём провайдеры и бегаем по ним через foreach.
namespace App\Composition;
use App\Composition\Provider\BleatBankProvider; use App\Composition\Provider\CbrfProvider; use App\Composition\Provider\ProviderInterface; use App\Composition\Provider\SberbankProvider; use App\Composition\Repository\RatesRepository;
class Service { private RatesRepository $repository;
/** @var ProviderInterface[] */ private array $providers = [];
public function __construct() { $this->repository = new RatesRepository();
$this->providers[] = new CbrfProvider(); $this->providers[] = new SberbankProvider(); $this->providers[] = new BleatBankProvider(); }
public function refreshRates(): void { foreach ($this->providers as $provider) { $rates = $provider->getRates();
// Сохраняем полученные данные в базу $this->repository->save($rates); } } }
Как видим, тут нет наследования и наши провайдеры теперь составные и содержат зависимости от других классов. При этом эти зависимости живут пока не умрёт провайдер и по факту он контролирует их жизнь. При этом для каждого провайдера создаётся отдельный экземпляр класса Transport и Parser. Если у нас будет 100 классов провайдеров которые используют HttpTransport, то будет создано 100 экземпляров класса HttpTransport. Так же мы не можем нормально написать Unit тесты, т.к. наши вспомогательные классы создаются напрямую в конструкторе и замокать их не сможем.
Это и есть композиция, когда наш класс-провайдер состоит из других вспомогательных классов, при этом вспомогательные классы живут в рамках класса-провайдера.
Весь код для Композиции можно посмотреть в репозитории https://github.com/roman-pankov/class-relations-example/tree/main/src/Composition
Агрегация
- Агрегация - это отношение, при котором один объект содержит другие объекты как части себя. Объекты, которые являются частью другого объекта, могут существовать и без этого объекта.
- Пример: У нас есть класс "Автомобиль", который агрегирует объекты класса "Колесо". Колеса могут быть установлены или сняты с автомобиля, но они могут также существовать независимо.
Как мы посмотрели на композицию, сейчас у нас проблема в том, что у нас будет создаваться много вспомогательных классов для наших провайдеров.
В нашем случаем мы можем легко переписать классы провайдеры и внедрить зависимости в конструктор.
namespace App\Aggregation\Provider;
// Провайдер ЦБ РФ use App\Aggregation\Parser\JsonParser; use App\Aggregation\Transport\HttpTransport;
class CbrfProvider implements ProviderInterface {
public function __construct( private HttpTransport $httpTransport, private JsonParser $parser, ) { }
public function getRates(): array { $json = $this->httpTransport->doRequest('https://cbrf.ru/rates.json');
$data = $this->parser->parse($json);
return $data; } }
// Провайдер Сбербанк use App\Aggregation\Parser\JsonParser; use App\Aggregation\Transport\HttpTransport;
class SberbankProvider implements ProviderInterface {
public function __construct( private HttpTransport $httpTransport, private JsonParser $parser, ) { }
public function getRates(): array { $json = $this->httpTransport->doRequest('https://sberbank.ru/rates.json');
$data = $this->parser->parse($json);
return $data; } }
// Провайдер BleatBank use App\Aggregation\Parser\CsvParser; use App\Aggregation\Transport\EmailTransport;
class BleatBankProvider implements ProviderInterface { public function __construct( private EmailTransport $emailTransport, private CsvParser $csvParser ) { }
public function getRates(): array { $csvData = $this->emailTransport->getFileContent( 'email@bleat_bank.ru', 'password123', 'rates.csv' );
$data = $this->csvParser->parse($csvData);
return $data; } }
По факту это всё изменение. Вспомогательные классы Transport и Parser будут создаваться вне провайдеров и будут жить сами по себе. Теперь если у нас будет 100 провайдеров, которые используют HttpTransport, у нас будет один экземпляр класса HttpTransport, что нас сэкономит память.
UML диаграмма
Классов стало больше, кода стало больше, но сам код стал проще
Обычно за инициализацию объектов отвечает DI контейнер (PHP-DI, Symfony service container и т.д.). В них вы можете сконфигурировать как создавать определенные классы и внедрять их в любое место.
При этом теперь мы можем отлично протестировать это всё. Замокать зависимости которые приходят в конструктор очень легко.
Теперь у нас выполняется условие "отношение, при котором один объект содержит другие объекты как части себя. Объекты, которые являются частью другого объекта, могут существовать и без этого объекта". Наши вспомогательные классы живут отдельно от провайдеров и провайдеры их не контролируют.
Весь код для Агрегации можно посмотреть в репозитории https://github.com/roman-pankov/class-relations-example/tree/main/src/Aggregation
Итог
Наследование самая очевидная вещь которую хочется взять в работу, НО(!) наследования всегда усложняет код. Вложенность дерева наследований может быть и по 10 классов, тогда, чтобы понять код вам нужно будет потратить много времени и не факт, ч то разберётесь полностью.
Между Композицией и Агрегацией очень тонкая грань, но если посмотреть на пример сразу станет понятно, в чём разница.
Конечно нет серебряной пули. Например, когда если вы будете использовать DDD подход, то без наследования и композиции будет затруднительно.
Все подходы нужны, все подходы важны. Иногда немного притормозите и подумайте, в какую сторону может пойти развитие куска кода и что можно сделать, чтобы обезопасить себя в будущем.
Запуск примера
Весь код в репозитории https://github.com/roman-pankov/class-relations-example/tree/main
В файле README.md есть инструкция по запуску https://github.com/roman-pankov/class-relations-example/blob/main/README.md