Полезные практики написания поддерживаемого кода на PHP

eca2cac107db52e9a1113fb32a86e34f

Привет, меня зовут Алексей и я должен признаться, я PHP разработчик. Последние несколько лет плотно занимаюсь проектами на symfony и решил поделиться с сообществом практиками, которые стараюсь соблюдать при работе.

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

1. Зависимости в di указываем явно

Явное лучше неявного. При описании классов не полагаемся на магию symfony di. Вообще мне кажется, что autowiring довольно вредная практика. При явном описании гораздо проще и быстрее при чтении кода понимать какие зависимости использует класс.

Плохо:

App\Services\MyService: ~

Хорошо:

app.services.my_service:
    class: App\Services\MyService
    arguments:
        - '@doctrine.orm.entity_manager'
        - '@app.services.my_service.dependency'

2. В DI подставляем реализации, а не интерфейсы

Так повышается читабельность и поощряется множественные имплементации интерфейсов.

Плохо:

app.services.my_service:
    class: App\Services\MyService
    arguments:
        - '@App\OtherService\Producer\ProducerInterface'

App\OtherService\Producer\ProducerInterface:
    alias: App\OtherService\Producer\Amqp

Хорошо:

app.services.my_service:
    class: App\Services\MyService
    arguments:
        - '@app.other_service.producer'

app.other_service.producer:
    class: App\OtherService\Producer\Amqp

3. Не надо пробрасывать в классы весь контейнер целиком

Плохо:

final class MyService implements MyServiceInterface
{
    public function __construct(private readonly ContainerInterface $container) {}
    public function doSomething()
    {
        $producer = $this->container->get('app.other_service.producer');
        $producer->publish();
    }
}

Хорошо:

final class MyService implements MyServiceInterface
{
    public function __construct(private readonly ProducerInterface $producer) {}
    public function doSomething()
    {
        $producer->publish();
    }
}

4. Каждый класс закрывается интерфейсом

Позволяет разрабатывать отталкиваясь от интерфейса. Если в месте использования
класса по какой-то причине не подходит его реализация, его легко заменить другой.

final class MyService implements MyServiceInterface
{
    // ...
}

5. Чем меньше методов в интерфейсе, тем лучше.

Большие интерфейсы сложнее поддерживать и реализовать. Нарушается принцип единственной ответственности.

Плохо:

interface SomeEntityInterface
{
    public function getId(): int;
    public function getName(): int;
    public function getAge(): int;
}

Хорошо:

interface IdAwareInterface
{
    public function getId(): int;
}
interface NameAwareInterface
{
    public function getName(): int;
}
interface AgeAwareInterface
{
    public function getAge(): int;
}

6. В интерфейсах указываем какие exception могут быть выброшены

Кроме этих exceptions никакие другие выбрасываться не должны. Если у интерфейса нет блока @throws — значит в реализации все exception должны быть обработаны.

7. Максимально строгая типизация

Используем strict_types=1. Типы указываем максимально жестко.

Плохо:

public function doSomething(mixed $id)
{
    return $id;
}

Хорошо:

public function doSomething(int $id)
{
    return $id;
}

8. Флаги в аргументах функций

Нарушается принцип единственной ответственности. Отдельные классы проще тестировать, можно использовать в разных сценариях с помощью композиции.

Плохо:

final class SomeService {
    public function doSomething($force = false)
    {
        if ($force) {
            return $this->doForce()
        }
        
        return $this->do()
    }
}

Хорошо:

final class ForceService {
    public function doSomething()
    {
        return $this->doForce();
    }
}

9. Количество аргументов в функции максимум 3

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

Плохо:

final class SomeService {
    public function doSomething($own, $two, $three, $four)
    {
    }
}

10. Не мутировать объекты переданные в метод/функцию

Это неочевидное поведение для клиентского кода и легко приводит к ошибкам в процессе развития и изменения кода.

Плохо:

$item = new Item();
$this->calculateCount($item);

public function calculateCount(Item $item): void
{
    $count = 0;
    // some logic
    $item->setCount($count);
}

Хорошо:

$item = new Item();
$item->setCount($this->calculateCount());

public function calculateCount(): int
{
    $count = 0;
    // some logic
    return $count;
}

11. Заполнение DTO через конструктор с именованными аргументами, readonly свойствами, без get/set методов

Такая запись читабельна за счёт именованных аргументов, нет лишнего кода get/set, такая dto иммутабельна за счёт readonly.

Хорошо:

final class Item
{
    public function __construct(
        public readonly int $id,
        public readonly int $count,
        public readonly string $originId,
    ) {
    }
}

$item = new Item(
    id: 1,
    count: 2,
    originId: 'aaa-bbb-ccc',
);

12. Разбиваем большие классы и методы

Класс меньше ~150 строк, метод меньше ~30 строк. Это значительно упрощает чтение кода.

13. Unit тесты всегда

На любой код обязательно пишем unit тест (кроме dto). Если есть skiptests — то обязательно добавляем ссылку на то место, где это протестировано.

/**
 * @skiptests tested in {@see \App\SomeOthreService}
 */
final class SomeServiceTest extends TestCase {
}

14. Тестируем поведение, а не детали реализации

Тест должен проверять все варианты поведения модуля, но не должен тестировать внутреннюю реализацию этого поведения.
Не надо в тесте проверять вызов логгера, аргументы исходящих вызовов, вызовы приватных методов, если только в этом не заключается основная логика работы класса. Stubs и dummy заглушки намного предпочтительнее mocks и fakes.

Плохо:

final class SomeServiceTest extends TestCase {
    public function testDoSomething()
    {
        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())
            ->method('info')
            ->with('some message');
        $service = new SomeService($logger);
        
        $service->doSomething();
    }
}

Хорошо:

final class SomeServiceTest extends TestCase {
    public function testDoSomething()
    {
        $service = new SomeService(
            $this->createMock(LoggerInterface::class),
        );
        
        $service->doSomething();
    }
}

15. Композиция, а не наследование

Наследование это максимально сильная связь между классами.
Композиция в подавляющем большинстве случаев предпочтительнее.

Плохо:

final class SomeService extends BaseLogic {
}

Хорошо:

final class SomeService {
    public function __construct(private readonly BaseLogic $logic) {}
    public function execute()
    {
        $this->logic->doSomething();
    }
}

16. По умолчанию указываем final для классов

Такое правило позволяет избежать проблем с наследованием и поощряет использование композиции.

final class SomeService {
}

17. Не работаем с ассоциативными массивами, только с объектами

Приходится писать много проверок, сложно расширять. Используем JMS\Serializer\SerializerInterface и
JMS\Serializer\ArrayTransformerInterface.

Плохо:

$id = $data['id'];

Хорошо:

$id = $data->getId();

18. Соблюдаем закон Деметры

Класс должен взаимодействовать только с известными ему модулями, не взаимодействовать с незнакомцами.

Плохо:

$client = $this->clientFactory->create();
$client->send();

Хорошо:

$this->clientDecorator->send();

19. Минимальная цикломатическая сложность

Код должен быть максимально плоским. Чем меньше сложность, тем проще читать код и понимать, что он делает.
Иногда можно жертвовать микрооптимизациями ради читаемости.

Плохо:

foreach ($items as $item) {
    if ($item->isAvailable()) {
        $availableItems[] = $item;
    }
    if ($item->isAcvite()) {
        $activeItems[] = $item;
    }
}

Хорошо:

$availableItems = $this->filterAvailableItems($items);
$activeItems = $this->filterActiveItems($items);

© Habrahabr.ru