Пишем правило для PHPStan по запрету логики в контроллерах
В этой статье мы будем использовать PHPStan, чтобы найти в нашем коде все контроллеры, где хранится какая-то бизнес-логика. Таким образом мы заставим разработчиков использовать сервисы или actions. Чтобы реализовать это правило, у вас должен быть уже установленный и настроенный PHPStan.
Напомню, что PHPStan - это статический анализатор кода. Это означает, что он не запускает код, а только читает его. Он осуществляет ряд проверок, которые называются правилами. Например, если код вызывает какой-то метод, он проверяет, что его вызов соответствует аргументам метода и что эти аргументы совпадают по типам. Если будут найдены какие-то проблемы, программа сообщит об этом в итоговом отчёте.
Информацию по установке PHPStan вы можете найти на их официальном сайте, но базовые шаги это:
- Установить пакет через Composer:
composer require --dev phpstan/phpstan
.
- Создать файл phpstan.neon в корне вашего проекта.
- Запустить 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/). Вам не понадобится много времени, чтобы изучить дополнительные возможности по разработке правил.