Пишем правило для PHPStan по запрету логики в контроллерах

Пишем правило для PHPStan по запрету логики в контроллерах

Пишем правило для PHPStan по запрету логики в контроллерах

В этой статье мы будем использовать PHPStan, чтобы найти в нашем коде все контроллеры, где хранится какая-то бизнес-логика. Таким образом мы заставим разработчиков использовать сервисы или actions. Чтобы реализовать это правило, у вас должен быть уже установленный и настроенный PHPStan.

Напомню, что PHPStan - это статический анализатор кода. Это означает, что он не запускает код, а только читает его. Он осуществляет ряд проверок, которые называются правилами. Например, если код вызывает какой-то метод, он проверяет, что его вызов соответствует аргументам метода и что эти аргументы совпадают по типам. Если будут найдены какие-то проблемы, программа сообщит об этом в итоговом отчёте.

Информацию по установке PHPStan вы можете найти на их официальном сайте, но базовые шаги это:

  1. Установить пакет через Composer: composer require --dev phpstan/phpstan.
  2. Создать файл phpstan.neon в корне вашего проекта.
  3. Запустить PHPStan: vendor/bin/phpstan.

Пример базовой конфигурации:

includes:
- ./vendor/nunomaduro/larastan/extension.neon

parameters:

paths:
- app/
- src/

# Level 9 is the highest level
level: 9
checkGenericClassInNonGenericObjectType: false

 

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

В этой статье я не буду подробно останавливаться на описании инструмента, перейдём сразу к написанию правил.

Правило в PHPStan - это класс, который имплементирует интерфейс PHPStan\Rules\Rule. Класс должен быть доступен в автозагрузке. Я предпочитаю держать правила в отдельной папке, не смешивая их со всем проектом. Для этого нам нужно будет дополнить секцию autoload-dev в composer.json:

 

"autoload": {
"psr-4": {
"Utils\\PHPStan\\": "utils/PHPStan/src",
"Utils\\PHPStan\\Tests\\": "utils/PHPStan/tests"
},

 

После внесения изменений в файл composer.json, не забудьте запустить команду composer dump-autoload.

Когда вы разрабатываете новое правило, будет хорошей идеей анализировать не весь код, а только отдельные файлы с примером того, где должна отработать ошибка. Вы можете это сделать командой vendor/bin/phpstan analyze [file-path].

Итак, давайте напишем наше правило:

<?php
declare(strict_types=1);

namespace Utils\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;

final class NoBusinessLogicInController implements \PHPStan\Rules\Rule
{
public function __construct(
private readonly ControllerDetermination $determination
)
{
}

public function getNodeType(): string
{
return Class_::class;
}

public function processNode(Node $node, Scope $scope): array
{
$className = (string) $node->namespacedName;

if (
!$this->determination->isController($className)
) {
return [];
}

$methods = $node->getMethods();

foreach ($methods as $method) {
$stmts = $method->getStmts();

foreach ($stmts as $stmt) {
if ($stmt instanceof Node\Stmt\If_
|| $stmt instanceof Node\Stmt\While_
|| $stmt instanceof Node\Stmt\For_
|| $stmt instanceof Node\Stmt\Foreach_
|| $stmt instanceof Node\Stmt\Switch_
) {
return [
RuleErrorBuilder::message(sprintf(
'Method %s in controller %s contains business logic. Move it to a dedicated service or repository.',
$method->name->toString(),
$className
))->build()
];
}
}
}

return [];
}
}

Как вы можете заметить, правило должно обладать двумя методами: getNodeType() и processNode(). Всё это работает по такому же принципу, как event dispatcher: вы регистрируете тип события, который вас интересует и затем вы получаете уведомление о наступившем событии. Для вашего правила вы регистрируете тип ноды, которая вам нужна и когда PHPStan находит данную ноду, он вызывает метод processNode(). Но что такое "нода"?

Ноды - это элементы нашего кода. Например, класс является отдельной нодой. Каждый метод класса тоже является нодой. Каждое ключевое слово или выражение - это тоже нода. Каждый файл PHP может быть определён одним болшим деревом нод.

PHPStan под капотом использует библиотеку PHP-Parser для парсинга PHP файлов и создаёт дерево AST для каждого файла. Оно затем проходит по этому дереву, спрашивая каждое правило, нужна ли им для обработки данная нода или нет (getNodeType())? Если нужна, тогда она им передаётся далее (processNode()). В ответ мы получаем массив с ошибками, если таковые были найдены.

В нашем примере мы будем анализировать контроллеры, поэтому мы берём ноду Class.

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

Давайте попробуем определиться, какой класс мы можем считать контроллером? Здесь мы сталкиваемся с проблемой, ведь от проекта к проекту он может отличаться и если мы пропишем правило жёстко, мы не сможем переиспользовать его в других проектах. Существует множество альтернатив поиска контроллеров:

  • Namespace должен содержать слово Controller
  • Файл находится в папке controllers
  • У класса должна присутствовать аннотация @Route
  • Класс перечислен в файле routing.php.

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

Для начала нам нужно определить интерфейс, назовём его ControllerDetermination.

 

<?php
declare(strict_types=1);

namespace Utils\PHPStan;

interface ControllerDetermination
{
public function isController(string $classReflection): bool;
}

 

Всё что он должен делать - это наличие метода isController. В качестве аргументов мы передаём ему название класса.

Далее мы присоединяем наш интерфейс в конструктор нашего правила (Dependency Injection).

Далее в основном методе processNode мы проверяем, является ли наш класс контроллером. Если нет, то мы используем early return.

Давайте напишем нашу имплементацию интерфейса, назовём его DeterminationBasedOnSuffix

<?php
declare(strict_types=1);

namespace Utils\PHPStan;

final class DeterminationBasedOnSuffix implements ControllerDetermination
{
public function __construct(
private readonly string $suffix = 'Controller'
)
{
}

public function isController(string $classReflection): bool
{
return str_ends_with($classReflection, $this->suffix) || strpos($classReflection, $this->suffix . 's');
}
}

 

Здесь мы проверяем, оканчивается ли название класса на слово Controller.

Чтобы зарегистрировать наше правло, нам нужно прописать его в файле phpstan.neon:

 

 

  services:

    - class: Utils\PHPStan\NoBusinessLogicInController

      tags:

            - phpstan.rules.rule

    - class: Utils\PHPStan\DeterminationBasedOnSuffix

      arguments:

            suffix: "Controller"

 

Если вы любите разработку по TDD, то PHPStan даёт очень прекрасные возможности. Писать тесты здесь очень удобно и приятно.

Правила PHPStan тестируются при помощи PHPUnit. PHPStan поставляется с базовым классом, называемый RuleTestCase, который расширяет базовый TestCase класс. Тест для вашего правила должен называться [RuleClass]Test. Обычно все тесты складываются в отдельную папку и namespace.

У каждого теста также будет отдельная папка под названием Fixtures, содержащая пример кода, который нужно проанализировать. Базовый класс RuleTestCase содержит один абстрактный getRule() метод, который вы должны имплементировать. Этот метод должен возвращать инстанс, который нам нужно протестировать.

 

<?php
declare(strict_types=1);

namespace Utils\PHPStan\Tests\NoBusinessLogicInController;

use PHPStan\Rules\Rule;
use Utils\PHPStan\DeterminationBasedOnSuffix;
use Utils\PHPStan\NoBusinessLogicInController;

final class NoBusinessLogicInControllerTest extends \PHPStan\Testing\RuleTestCase
{
public function testSkipControllerWithNothingInIt(): void
{
$this->analyse(
[
__DIR__ . '/Fixtures/skip-controller-if-everything-ok.php',
],
[
// we expect no errors
]
);
}

public function testSkipLogicInNotController(): void
{
$this->analyse(
[
__DIR__ . '/Fixtures/skip-if-something-in-base-class.php',
],
[
// we expect no errors
]
);
}

public function testFoundErrorInForLoop(): void
{
$this->analyse(
[
__DIR__ . '/Fixtures/method-has-for-loop.php',
],
[
['Method index in controller ErrorsController contains business logic. Move it to a dedicated service or repository.', 4]
],
);
}

public function testFoundErrorInIfStatement(): void
{
$this->analyse(
[
__DIR__ . '/Fixtures/method-has-if-statement.php',
],
[
['Method index in controller ErrorsWithIfController contains business logic. Move it to a dedicated service or repository.', 4]
],
);
}

/**
* @inheritDoc
*/
protected function getRule(): Rule
{
return new NoBusinessLogicInController(new DeterminationBasedOnSuffix());
}
}

Как видно из примера, представленного выше, всё что делают тесты - это принимают фикстуру и сравнивают ответ - возвращается ли ошибка или нет. Если да, то какая. Все фикстуры для экономии места я приводить не буду, приведу одну, основную:

 

<?php
declare(strict_types=1);

class ErrorsController
{
public function __construct(
protected \Illuminate\Cache\Repository $repository
)
{
}

public function index(): array
{
for ($i = 1; $i <= 10; $i++) {
$this->repository->get('some');
}
return [];
}
}

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

 

Нам также нужно внести соответствующие настройки в PHPUnit, чтобы он знал, где найти правила PHPStan.

Для этого внесём запись в файл phpunit.xml:

<testsuites>
<testsuite name="PHPStan rule tests">
<directory>./utils/PHPStan/tests</directory>
</testsuite>
</testsuites>

 

Теперь вы можете запустить тесты PHPStan командой:

vendor/bin/phpunit --testsuite "PHPStan rule tests"

 

Итак, в этой статье мы написали довольно простое, но функциональное правило для phpstan. Мы обсудили основы PHPStan и теперь можем покрывать наш проект более сложными проверками. Я также рекомендую прочитать документацию для разработчиков (https://phpstan.org/). Вам не понадобится много времени, чтобы изучить дополнительные возможности по разработке правил.

 

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235