Ми}{@лbI4

Блог хеллоуворлдщика

Еще один MVC пример для PHP

11.06.2020 mvc, php, pattern

Начнем, пожалуй, с того, что MVC как такового в PHP нет в виду невозможности реализации. Правда, эта информация может лишь понадобится на собеседовании или общении между разработчиками, чем для реальной жизни. В повседневности для вас будет стоять задача не смешивать бизнес-логику и представление. А каким именно шаблоном вы будете пользоваться не так важно.

Надеюсь, вы уже ознакомились с какой-либо информацией касательно MVC или какой-то информацией для отделения бизнес-логики от представления и т.п. Возможно, прочитали, что все приложения состоят из этих трех частей. Хотя на самом деле это не совсем так. MVC необходим только для взаимодействия с пользовательским интерфейсом (UI), чтобы бизнес-логика не проникала в представление, а представление в бизнес-логику, и больше ни для чего. Помимо шаблона MVC в приложениях, как правило, используется еще множество других шаблонов проектирования, а вся бизнес-логика находится не в модели. Модель лишь транспортирует данные и служит связующим звеном с представлением.

Самое главное, по моему мнению, в изучении парадигм вида MVC, MVP, MVVP/etc и любых других шаблонов.: не пытаться ограничивать себя в реализации. Самого правильного варианта не существует, есть лишь общая модель, идея. А как это называется не особо важно. Не стоит сжимать себя до терминов и определений.

Что есть представление (View)

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

Я предполагаю, что читатель знаком с ООП и уже может что-то писать на PHP. Если нет, то материал в данной статье может вызвать вопросы. Для примеров нужно быть знакомым с PHP 7.1 или выше.

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

Наше представление для корзины выглядело бы как-то так:

// /views/cart.php

<DOCTYPE! html>
<html lang="ru">
    <head>
      <meta charset="utf-8">
      <title>Корзина</title>
    </head>
    <body>
        <h1>Корзина</h1>
        
        <table>
          <thead>
              <tr>
                <th>№</th>
                <th>Наименование</th>
                <th>Количество</th>
                <th>Сумма</th>
              </tr>
          </thead>
          <tbody>
              <?php foreach ($cart->getItems() as $index => $data) : ?>
                  <tr>
                    <td><?= $index + 1; ?></td>
                    <td><?= htmlspecialchars($data->getName(), ENT_QUOTES | ENT_SUBSTITUTE); ?></td>
                    <td><?= $data->getQuantity(); ?></td>
                    <td><?= $data->getSum(); ?></td>
                  </tr>
              <?php endforeach; ?>
          </tbody>
          <tfoot>
              <tr>
                <td colspan="2">Итого</td>
                <td><?= $cart->getTotalQuantity(); ?></td>
                <td><?= $cart->getTotalSum(); ?></td>
              </tr>
          </tfoot>
        </table>
    </body>
</html>

Что мы видим в этом куске кода:

  • одна переменная-объект $cart со всеми данными;
  • цикл для отображения списка добавленных товаров в корзину;
  • функцию htmlspecialchars() с константами ENT_QUOTES и ENT_SUBSTITUTE для защиты от XSS-аттаки и просто защиты от "поехавшей" верстки;
  • строку итого с общим количеством и суммой.

Как можно заметить никаких вычислений нет. Потому что они сделаны до того, как все попало в представление. Именно таким прозрачным и чистым оно должно быть. Никакой логики. Никаких вычислений.

Что есть модель (Model)

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

Для большинства, прочитавших принципы и идею работы MVC/MVP/etc, сложилось стойкое правило, что там должна быть бизнес-логика: обращение к базе данных, наличие ключевых логических функций, валидация входящих данных и т.п. На самом деле, это не так (если, конечно, это не упрощенная модель парадигмы MVC/MVP/etc; хотя и в данном случае все получается более запутанным, нежели простым). Если вернуться в начало статьи, где я говорю, что MVC это шаблон взаимодействия с пользовательским интерфейсом (UI), то все становится ясным: бизнес-логики в модели быть не может.

Давайте напишем модель для нашего представления из примера выше:

// /src/View/Model/CartItem.php

namespace App\View\Model;

class CartItem
{
    /**
     * @var string
     */
    private $name = '';
    /**
     * @var int
     */
    private $quantity = 0;
    /**
     * @var float
     */
    private $sum = 0.0;

    public function __construct(string $name, int $quantity, float $sum)
    {
        $this->name = $name;
        $this->quantity = $quantity;
        $this->sum = $sum;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): CartItem
    {
        $this->name = $name;
        return $this;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function setQuantity(int $quantity): CartItem
    {
        $this->quantity = $quantity;
        return $this;
    }

    public function getSum(): float
    {
        return $this->sum;
    }

    public function setSum(float $sum): CartItem
    {
        $this->sum = $sum;
        return $this;
    }
}
// /src/View/Model/Cart.php

namespace App\View\Model;

class Cart
{
    /**
     * @var CartItem[]
     */
    private $items = [];
    
    /**
     * @return CartItem[]
     */
    public function getItems(): array
    {
        return $this->items;
    }

    public function addItem(CartItem $item): Cart
    {
        $this->items[] = $item;
        return $this;
    }

    public function getTotalQuantity(): int
    {
        return array_sum(array_map(function (CartItem $item) {
            return $item->getQuantity();
        }, $this->items));
    }

    public function getTotalSum(): float
    {
        return array_sum(array_map(function (CartItem $item) {
            return $item->getSum();
        }, $this->items));
    }
}

В примере валюта хранится в float в у.е. В реальных проектах удобно хранить в integer в минимальной денежной единице (для рубля РФ это копейки). Еще более удобней в fixed-point - числах с фиксированной точкой. А для работы с такими значениями используют BCMath или GMP.

У нас получилось два класса. Cart содержит все необходимые данные для отрисовки корзины, а CartItem содержит данные позиции товара в корзине. Друг без друга эти два класса смысла не имеют. И используются они только в паре. Вся работа модели сводится к хранению и/или транспортировке. При необходимости модель может что-то считать используя уже полученные данные (как в Cart::getTotalQuantity() и Cart::getTotalSum()).

Что есть Контроллер (Controller)

Как я уже сказал выше, реализация MVC в PHP невозможно в силу особенности языка: на каждый запрос создается отдельный экземпляр PHP и по окончанию выполнения всей работы он "погибает". Поэтому нельзя запустить приложении и повешать наблюдателей на модель, чтобы можно было обновлять представление. MVC в PHP не совсем то, как это в теории, потому что:

  1. контроллер в курсе, какое представление он использует, а не должен;
  2. связь между контроллером и представлением получается двунаправленная, а не однонаправленная от представления к контроллеру.

Но исторически сложилось так, что в PHP это называют MVC. В целом, задача по разделению зон ответственностей не меняется и результат не страдает. А более и не нужно.

Контроллер играет роль посредника и сам по себе ничего не умеет. Он всегда делегирует работу. Делегаторами (не путать с шаблоном Delegator) могут выступать репозитории, службы, различные клиенты и команды. Все напрямую зависит от поставленной задачи.

После того, как вся работа контроллером выполнена он передает полученные данные в представление. Ну, или просто вызывает его без ничего. Всякое бывает. Иногда и данных-то никаких нет.

Для нашего примера контроллер будет иметь следующий вид:

// /src/Controller/CartController.php

namespace App\Controller;

use App\View\Model\Cart;
use App\View\Model\CartItem;

class CartController
{
    public function load(): void
    {
        $cart = new Cart();
        $cart->addItem(new CartItem('Item 1', 1, 600.96));
        $cart->addItem(new CartItem('Item 2', 2, 399.00));
        
        ob_start();
        require __DIR__ . '/views/cart.php';
        echo ob_get_clean();
    }   
}

У нас получился класс с одним методом load() в котором мы наполнили данными нашу модель и отрисовали представление. В реальности, конечно, у нас не будет статических данных. Как минимум номера номенклатуры и количество будут храниться в куках (cookies), а сама номенклатура в базе данных.

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

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

// /src/Service/CartService.php

namespace App\Service;

use App\View\Model\Cart;
use App\View\Model\CartItem;

class CartService
{
    public function getData(): Cart
    {
        $cart = new Cart();
        $cart->addItem(new CartItem('Item 1', 1, 600.96));
        $cart->addItem(new CartItem('Item 2', 2, 399.00));
        return $cart;
    }
}
// /src/Controller/CartController.php

namespace App\Controller;

use App\Service\CartService;

class CartController
{
    /**
     * @var CartService
     */
    private $cartService;

    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    public function load(): void
    {
        $cart = $this->cartService->getData();

        ob_start();
        require __DIR__ . '/views/cart.php';
        echo ob_get_clean();
    }
}

Конечно, этот пример с службой также содержит статически данные, но мы скрыли реализацию того откуда поступают данные и все свели к результату. Нет никаких проблем, чтобы позднее формировать данные на основе кук и базы данных, ведь всегда будет возвращаться один и тот же тип данных, что не приведет к неожиданным ситуациям. Плюсом, мы сузили ответственность контроллера. Теперь он обычный "дирижер", каким и должен быть.

И вроде бы проблема решена. Но нет. Мы "размыли" границу между слоем модели и бизнес-логикой: модель пользовательского интерфейса используется в бизнес-логике. А такого быть не должно из-за правила взаимодействия со слоями приложения (об этом мы поговорим в другой раз). Отсюда следует, что для того, чтобы передать данные в представление, нам нужно, как минимум, две модели в разных слоях приложения: там где наша бизнес-логика и там где пользовательский интерфейс. Сделаем так.

// /src/Model/CartItem.php

namespace App\Model;

class CartItem
{
    /**
     * @var string
     */
    private $name = '';
    /**
     * @var int
     */
    private $quantity = 0;
    /**
     * @var float
     */
    private $sum = 0.0;

    public function __construct(string $name, int $quantity, float $sum)
    {
        $this->name = $name;
        $this->quantity = $quantity;
        $this->sum = $sum;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): CartItem
    {
        $this->name = $name;
        return $this;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function setQuantity(int $quantity): CartItem
    {
        $this->quantity = $quantity;
        return $this;
    }

    public function getSum(): float
    {
        return $this->sum;
    }

    public function setSum(float $sum): CartItem
    {
        $this->sum = $sum;
        return $this;
    }
}
// /src/Model/Cart.php

namespace App\Model;

class Cart
{
    /**
     * @var CartItem[]
     */
    private $items = [];
    
    /**
     * @return CartItem[]
     */
    public function getItems(): array
    {
        return $this->items;
    }

    public function addItem(CartItem $item): Cart
    {
        $this->items[] = $item;
        return $this;
    }
}

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

// /src/Service/CartService.php

namespace App\Service;

use App\Model\Cart;
use App\Model\CartItem;

class CartService
{
    public function getData(): Cart
    {
        $cart = new Cart();
        $cart->addItem(new CartItem('Item 1', 1, 600.96));
        $cart->addItem(new CartItem('Item 2', 2, 399.00));
        return $cart;
    }
}
// /src/Controller/CartController.php

namespace App\Controller;

use App\Service\CartService;
use App\View\Model\Cart as ViewModelCart;
use App\View\Model\CartItem as ViewModelCartItem;
use App\Model\Cart;

class CartController
{
    /**
     * @var CartService
     */
    private $cartService;

    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    public function load(): void
    {
        $cart = $this->createViewModel($this->cartService->getData());

        ob_start();
        require __DIR__ . '/views/cart.php';
        echo ob_get_clean();
    }

    private function createViewModel(Cart $cart): ViewModelCart
    {
        $viewModelCart = new ViewModelCart();
        foreach ($cart->getItems() as $item) {
            $viewModelCart->addItem(new ViewModelItem($item->getName(), $item->getQuantity(), $item->getSum()));
        }
        return $viewModelCart;
    }
}

Проблема "размытой" границы слоев решена. Появилась другая. Избыточность (или "бойлерплейт"). Теперь нам нужно проделывать двойную работу, чтобы передавать данные из одной части приложения в другую. Резонный вопрос возникающий в голове программиста: можно ли как-то это сделать лучше? Да, можно, если мы говорим о PHP.

Как вы уже заметили, данные в представление попадают в виде переменных и позже используются для отрисовки. И на объекты, содержащие данные, никто не вешает наблюдателей за изменениями. Это просто не нужно в виду особенности работы PHP: после каждого запроса "умирать". Именно поэтому данные идут в одном направлении и достигая "конечной" прекращают свое существование. Если бы это было приложение на Java, то там наличие модели для представления является обязательным для реализации MVC. В PHP это аппендикс.

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

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

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

Упраздним модели представления и внесем изменения:

// /src/Model/Cart.php

namespace App\Model;

class Cart
{
    /**
     * @var CartItem[]
     */
    private $items = [];
    
    /**
     * @return CartItem[]
     */
    public function getItems(): array
    {
        return $this->items;
    }

    public function addItem(CartItem $item): Cart
    {
        $this->items[] = $item;
        return $this;
    }

    public function getTotalQuantity(): int
    {
        return array_sum(array_map(function (CartItem $item) {
            return $item->getQuantity();
        }, $this->items));
    }

    public function getTotalSum(): float
    {
        return array_sum(array_map(function (CartItem $item) {
            return $item->getSum();
        }, $this->items));
    }
}
// /src/Controller/CartController.php

namespace App\Controller;

use App\Service\CartService;

class CartController
{
    /**
     * @var CartService
     */
    private $cartService;

    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    public function load(): void
    {
        $cart = $this->cartService->getData();

        ob_start();
        require __DIR__ . '/views/cart.php';
        echo ob_get_clean();
    }
}

Теперь моделью пользовательского интерфейса являются наши переменные передаваемые в представления. Сами переменные могут содержать любые наборы данных необходимых для отрисовки.

Добавим точку входа для запуска:

// /public/index.php

header('HTTP/1.1 200 OK');
header('Content-type: text/html; charset=UTF-8');

$ctrl = new App\Controller\CartController(new App\Service\CartService());
$ctrl->load();

exit(0);

И запустим:

$ php -S 127.0.0.1:8080 -t public

Такой вот MVC в PHP.

Заключение

Тема шаблона MVC в PHP заезжена вдоль и поперек. Множество реализаций и мнений. Да и саму тему охватить в одной статье тяжело. Много нюансов, много особенностей. В своей статье я хотел дать пищу для размышления и рассуждения в контексте PHP, сделал упор на то, что это шаблон пользовательского интерфейса и никак не относится к бизнес-логике; бизнес-логика находится вне шаблона MVC и в основу её лежит огромный пласт других практик и шаблонов проектирования с которыми стоит ознакомиться; и что MVC не панацея и на нем не держится вся архитектура приложения.

Если у вас остались вопросы - пишите в комментариях и я обязательно отвечу на них.