[webshake] Продвинутый курс «ООП в PHP» - Часть 4

Пишем регистрацию на сайте на PHP

Ну вот мы с вами и закончили изучение основных возможностей объектно-ориентированного программирования. Теперь мы будем учиться применять все эти возможности в деле. А именно – напишем движок для блога на чистом PHP. И в сегодняшнем уроке мы узнаем как сделать регистрацию на сайте . Благодаря ей новые пользователи смогут регистрироваться на нашем сайте. Поехали.

В одном из прошлых уроков мы с вами уже создавали таблицу users, которая имеет следующий вид:

Именно её мы и будем использовать для регистрации и авторизации пользователей c помощью PHP и MySQL . Давайте первым делом составим список полей, которые мы должны будем принимать от пользователя для регистрации:

  • nickname – должен быть уникальным и содержать только символы латинского алфавита и цифры;
  • email – должен быть уникальным и быть корректным email-ом;
  • password – должен быть не менее 8 символов (будет захеширован и будет храниться в поле password_hash).

Все остальные поля будут заполняться значениями по-умолчанию на стороне сервера.

Создаём контроллер

Итак, давайте создадим контроллер для работы с пользователями.

src/MyProject/Controllers/UsersController.php

<?php

namespace MyProject\Controllers;

use MyProject\View\View;

class UsersController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function signUp()
    {
        echo 'здесь будет код для регистрации пользователей';
    }
}

Добавляем роут

Теперь давайте пропишем роутинг для данной странички. Пусть это будет myproject.loc/users/register

src/routes.php

<?php

return [
    '~^articles/(\d+)$~' => [\MyProject\Controllers\ArticlesController::class, 'view'],
    '~^articles/(\d+)/edit$~' => [\MyProject\Controllers\ArticlesController::class, 'edit'],
    '~^articles/add$~' => [\MyProject\Controllers\ArticlesController::class, 'add'],
    '~^users/register$~' => [\MyProject\Controllers\UsersController::class, 'signUp'],
    '~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];

Проверим, что всё работает:

Создаём шаблон

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

templates/users/signUp.php

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Регистрация</h1>
        <form action="/users/register" method="post">
            <label>Nickname <input type="text" name="nickname"></label>
            <br><br>
            <label>Email <input type="text" name="email"></label>
            <br><br>
            <label>Пароль <input type="password" name="password"></label>
            <br><br>
            <input type="submit" value="Зарегистрироваться">
        </form>
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

И теперь отрендерим этот шаблон в нашем контроллере:

src/MyProject/Controllers/UsersController.php

<?php

namespace MyProject\Controllers;

use MyProject\View\View;

class UsersController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function signUp()
    {
        $this->view->renderHtml('users/signUp.php');
    }
}

Проверяем, что все работает

Пишем логику регистрации

Итак, заготовочку сделали, теперь – самое интересное. Нам нужно обработать данные, пришедшие от пользователя. Принимать данные из запроса мы будем внутри контроллера, однако вся логика по проверке этих данных будет находиться внутри модели пользователя. То есть принять данные из запроса – это всегда ответственность контроллера. Далее его задача – передать эти данные модели, чтобы она произвела с ними какие-то действия(проверила на валидность, сохранила в базу). Контроллеры не должны содержать в себе бизнес-логику (то есть то, как должны обрабатываться и храниться данные). За счет того, что такая логика всегда содержится в моделях, код становится проще реиспользовать (использовать в нескольких местах). Например, мы можем из разных контроллеров обращаться к одному и тому же коду, который хранится в модели. Итак, давайте создадим в модели пользователя статический метод, который будет принимать на вход массив с данными, пришедшими от пользователя, и будет пытаться создать нового пользователя и сохранить его в базе данных.

src/MyProject/Models/Users/User.php

...
public static function signUp(array $userData)
{
    var_dump($userData);
}

Именно этот код мы и будем вызывать в контроллере, если пришел POST-запрос.

src/MyProject/Controllers/UsersController.php

    public function signUp()
    {
        if (!empty($_POST)) {
            $user = User::signUp($_POST);
        }

        $this->view->renderHtml('users/signUp.php');
    }

Проверка входных данных на пустоту

Итак, приступаем к написанию логики в модели. Для начала стоит убедиться в том, что все необходимые данные были переданы в запросе. Если это не так – будем кидать исключение. Для такого рода ошибок заведем отдельное исключение:

src/MyProject/Exceptions/InvalidArgumentException.php

<?php

namespace MyProject\Exceptions;

class InvalidArgumentException extends \Exception
{
}

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

Итак, пишем проверки на то, что все данные были переданы:

src/MyProject/Models/Users/User.php

public static function signUp(array $userData)
{
    if (empty($userData['nickname'])) {
        throw new InvalidArgumentException('Не передан nickname');
    }

    if (empty($userData['email'])) {
        throw new InvalidArgumentException('Не передан email');
    }

    if (empty($userData['password'])) {
        throw new InvalidArgumentException('Не передан password');
    }
}

В контроллере теперь нужно научиться обрабатывать эти исключения:

src/MyProject/Controllers/UsersController.php

public function signUp()
{
    if (!empty($_POST)) {
        try {
            $user = User::signUp($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/signUp.php', ['error' => $e->getMessage()]);
            return;
        }
    }

    $this->view->renderHtml('users/signUp.php');
}

Видите, мы начали передавать в шаблон переменную error? Нужно бы уметь выводить её в шаблоне, если она не пустая:

templates/users/signUp.php

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Регистрация</h1>
        <?php if (!empty($error)): ?>
            <div style="background-color: red;padding: 5px;margin: 15px"><?= $error ?></div>
        <?php endif; ?>
        <form action="/users/register" method="post">
            <label>Nickname <input type="text" name="nickname"></label>
            <br><br>
            <label>Email <input type="text" name="email"></label>
            <br><br>
            <label>Пароль <input type="password" name="password"></label>
            <br><br>
            <input type="submit" value="Зарегистрироваться">
        </form>
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

Теперь попробуем отправить форму регистрации с пустыми полями:

Увидим, что код упал на первой же проверке.

Попробуем заполнить теперь поле nickname и снова отправим запрос.

Как видим, теперь уже ошибка о том, что мы не заполнили email. Отлично, значит наш код действительно проверяет наличие полей. Однако данные, которые мы заполняли перед отправкой формы потеряны, что, согласитесь неудобно, так как придется возвращаться назад. Давайте будем выводить в шаблоне данные, которые были переданы в запросе. Для этого используется атрибут value у тегов input.

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Регистрация</h1>
        <?php if (!empty($error)): ?>
            <div style="background-color: red;padding: 5px;margin: 15px"><?= $error ?></div>
        <?php endif; ?>
        <form action="/users/register" method="post">
            <label>Nickname <input type="text" name="nickname" value="<?= $_POST['nickname'] ?? '' ?>"></label>
            <br><br>
            <label>Email <input type="text" name="email" value="<?= $_POST['email'] ?? '' ?>"></label>
            <br><br>
            <label>Пароль <input type="password" name="password" value="<?= $_POST['password'] ?? '' ?>"></label>
            <br><br>
            <input type="submit" value="Зарегистрироваться">
        </form>
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

Попробуем снова отправить форму с заполненным полем nickname.

Видим всё ту же ошибку, но теперь данные формы не потерялись. А это значит, что наш интерфейс стал гораздо более удобным для пользователя – если возникнет ошибка, ему не нужно будет вводить всё заново.

Проверка данных на валидность

Добавим также проверку на то, что длина пароля не менее восьми символов, а также что nickname содержит только допустимые символы, а email является корректным email-ом:

src/MyProject/Models/Users/User.php

public static function signUp(array $userData)
{
    if (empty($userData['nickname'])) {
        throw new InvalidArgumentException('Не передан nickname');
    }

    if (!preg_match('/[a-zA-Z0-9]+/', $userData['nickname'])) {
        throw new InvalidArgumentException('Nickname может состоять только из символов латинского алфавита и цифр');
    }

    if (empty($userData['email'])) {
        throw new InvalidArgumentException('Не передан email');
    }

    if (!filter_var($userData['email'], FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException('Email некорректен');
    }

    if (empty($userData['password'])) {
        throw new InvalidArgumentException('Не передан password');
    }

    if (mb_strlen($userData['password']) < 8) {
        throw new InvalidArgumentException('Пароль должен быть не менее 8 символов');
    }
}

Поиск дубликатов

Теперь стоит проверить, что пользователя с такими email и nickname нет в базе. Для этого нам понадобится метод, позволяющий находить записи в базе по какому-то одному столбцу. Давайте добавим его в класс ActiveRecordEntity.

src/MyProject/Models/ActiveRecordEntity.php

public static function findOneByColumn(string $columnName, $value): ?self
{
    $db = Db::getInstance();
    $result = $db->query(
        'SELECT * FROM `' . static::getTableName() . '` WHERE `' . $columnName . '` = :value LIMIT 1;',
        [':value' => $value],
        static::class
    );
    if ($result === []) {
        return null;
    }
    return $result[0];
}

Этот метод будет принимает два параметра:

  1. имя столбца, по которому искать;
  2. значение, которое мы ищем в этом столбце.

Если ничего не найдено – вернётся null. Если же что-то нашлось – вернётся первая запись.
С помощью этого метода мы сможем искать пользователей по email и nickname:

src/MyProject/Models/Users/User.php

public static function signUp(array $userData)
{
    ...

    if (static::findOneByColumn('nickname', $userData['nickname']) !== null) {
        throw new InvalidArgumentException('Пользователь с таким nickname уже существует');
    }

    if (static::findOneByColumn('email', $userData['email']) !== null) {
        throw new InvalidArgumentException('Пользователь с таким email уже существует');
    }
}

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

public static function signUp(array $userData): User
{
    // ... тут все проверки

    $user = new User();
    $user->nickname = $userData['nickname'];
    $user->email = $userData['email'];
    $user->passwordHash = password_hash($userData['password'], PASSWORD_DEFAULT);
    $user->isConfirmed = false;
    $user->role = 'user';
    $user->authToken = sha1(random_bytes(100)) . sha1(random_bytes(100));
    $user->save();

    return $user;
}

Тут думаю всё понятно, кроме параметра authToken – это специально случайным образом сгенерированный параметр, с помощью которого пользователь будет авторизовываться. Мы не будем передавать после того как вошли на сайт в cookie ни пароль, ни его хеш. Мы будем использовать только этот токен, который у каждого пользователя будет свой и он никак не будет связан с паролем – так безопаснее.

В конце метода мы сохраняем этого нового пользователя в базу и возвращаем его из метода. Обратите внимание, что у метода появился тип возвращаемого значения: User – не забывайте их указывать.
Теперь в контроллере нужно получать результат этого метода и проверять, что нам вернулся только что созданный пользователь. Если всё ок – писать об этом на форме регистрации.

src/MyProject/Controllers/UsersController.php

public function signUp()
{
    if (!empty($_POST)) {
        try {
            $user = User::signUp($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/signUp.php', ['error' => $e->getMessage()]);
            return;
        }

        if ($user instanceof User) {
            $this->view->renderHtml('users/signUpSuccessful.php');
            return;
        }
    }

    $this->view->renderHtml('users/signUp.php');
}

Я решил не загромождать наш шаблон всем подряд и создал для успешной регистрации отдельный шаблончик - signUpSuccessful.php. Как видите, именно его я и рендерю в контроллере при успешном исходе.

templates/users/signUpSuccessful.php

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Регистрация прошла успешно!</h1>
        Ссылка для активации вашей учетной записи отправлена вам на email.
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

Пробуем теперь заполнить нашу формочку корректными данными и отправляем запрос.

Заглянем теперь в базу и убедимся, что действительно появился новый пользователь

Вот мы и сделали регистрацию для нашего блога. В следующем уроке мы сделаем активацию пользователя по email-у.

Система активации пользователей по email на PHP

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

Прежде чем начать, вам нужно настроить OpenServer для отправки писем.

После того, как вы это сделали, можно приступать к написанию кода. Первое, что нам нужно – создать новую табличку, в которой мы будем хранить коды для активации пользователей.
Называем её «users_activation_codes», и указываем, что нам требуются три столбца:

  • id – это просто id записи в таблице;
  • user_id – id пользователя;
  • code – код для активации этого пользователя.

Таблица с кодами активации

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

src/MyProject/Models/Users/UserActivationService.php

<?php

namespace MyProject\Models\Users;

use MyProject\Services\Db;

class UserActivationService
{
    private const TABLE_NAME = 'users_activation_codes';

    public static function createActivationCode(User $user): string
    {
        // Генерируем случайную последовательность символов, о функциях почитайте в документации
        $code = bin2hex(random_bytes(16));

        $db = Db::getInstance();
        $db->query(
            'INSERT INTO ' . self::TABLE_NAME . ' (user_id, code) VALUES (:user_id, :code)',
            [
                'user_id' => $user->getId(),
                'code' => $code
            ]
        );

        return $code;
    }

    public static function checkActivationCode(User $user, string $code): bool
    {
        $db = Db::getInstance();
        $result = $db->query(
            'SELECT * FROM ' . self::TABLE_NAME . ' WHERE user_id = :user_id AND code = :code',
            [
                'user_id' => $user->getId(),
                'code' => $code
            ]
        );
        return !empty($result);
    }
}

И теперь нам нужно сделать ещё один сервис, который будет предназначен для отправки email-сообщений.

src/MyProject/Services/EmailSender.php

<?php

namespace MyProject\Services;

use MyProject\Models\Users\User;

class EmailSender
{
    public static function send(
        User $receiver,
        string $subject,
        string $templateName,
        array $templateVars = []
    ): void {
        extract($templateVars);

        ob_start();
        require __DIR__ . '/../../../templates/mail/' . $templateName;
        $body = ob_get_contents();
        ob_end_clean();

        mail($receiver->getEmail(), $subject, $body, 'Content-Type: text/html; charset=UTF-8');
    }
}

Здесь вам все функции уже знакомы – мы использовали похожий функционал для рендеринга шаблонов. Теперь у нас появится еще один тип шаблонов – специально для email-ов.

Давайте создадим наш первый шаблон, который будет предназначен для писем активации.

templates/mail/userActivation.php

Добро пожаловать на наш портал!<br>
Для активации вашего аккаунта нажмите <a href="http://myproject.loc/users/<?=$userId?>/activate/<?=$code?>">сюда</a>.

Ну и, собственно, остаётся только взять эти компоненты и собрать воедино. Делаем это в контроллере пользователя в экшене с регистрацией.

src/MyProject/Controllers/UsersController.php

public function signUp()
{
    if (!empty($_POST)) {
        try {
            $user = User::signUp($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/signUp.php', ['error' => $e->getMessage()]);
            return;
        }

        if ($user instanceof User) {
            $code = UserActivationService::createActivationCode($user);

            EmailSender::send($user, 'Активация', 'userActivation.php', [
                'userId' => $user->getId(),
                'code' => $code
            ]);

            $this->view->renderHtml('users/signUpSuccessful.php');
            return;
        }
    }

    $this->view->renderHtml('users/signUp.php');
}

Теперь пробуем зарегистрироваться на свою почту в нашей системе.
Регистрация в системе на реальную почту

И после этого смотрим в базу данных.
Пользователи:
Зарегистрированный пользователь

Коды активации:
Код активации

Как видим, все успешно отработало, и кроме того, нам пришло письмо на почту!
Письмо с активацией

После перехода по ссылке мы видим, что такой страницы не существует.
Отсутствие страницы с активацией

Еще бы, ведь мы не добавляли для нее соответствующий роутинг. Добавляем его.

src/routes.php

...
'~^users/(\d+)/activate/(.+)$~' => [\MyProject\Controllers\UsersController::class, 'activate'],
...

И добавляем соответствующий экшен в контроллере:

src/MyProject/Controllers/UsersController.php

public function activate(int $userId, string $activationCode)
{
    $user = User::getById($userId);
    $isCodeValid = UserActivationService::checkActivationCode($user, $activationCode);
    if ($isCodeValid) {
        $user->activate();
        echo 'OK!';
    }
}

И добавляем у модели пользователя метод activate().

src/MyProject/Models/Users/User.php

public function activate(): void
{
    $this->isConfirmed = true;
    $this->save();
}

Снова пробуем обновить страничку для активации.

Успешная активация пользователя по email

Видим заветное “OK!”.

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

Успех! Теперь осталось довести систему до ума – создать нормальные шаблоны для странички активации и обрабатывать возможные ошибки. Это вам предоставляется сделать самостоятельно в домашнем задании.

Домашнее задание

Реализуйте следующий функционал:

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

Пишем систему авторизации на PHP

Сегодня мы напишем авторизацию пользователя на сайте . Вся система будет работать следующим образом: пользователь вводит логин и пароль на форме входа, если они правильные – в Cookie браузера будет установлена специальная запись – auth token (авторизационный токен). При дальнейших запросах на сервер этот токен будет проверяться и если он будет правильным, то пользователь считается авторизованным.

Первым делом решаем, что страница с формой логина и пароля будет находиться по адресу http://myproject.loc/users/login. Создаём соответствующий роут.

src/routes.php

...
'~^users/login~' => [\MyProject\Controllers\UsersController::class, 'login'],
...

После этого добавляем новый экшен в контроллере.

src/MyProject/Controllers/UsersController.php

public function login()
{
    $this->view->renderHtml('users/login.php');
}

И, наконец, создаём шаблон с формой для этого экшена.

templates/users/login.php

<?php include __DIR__ . '/../header.php'; ?>
    <div style="text-align: center;">
        <h1>Вход</h1>
        <?php if (!empty($error)): ?>
            <div style="background-color: red;padding: 5px;margin: 15px"><?= $error ?></div>
        <?php endif; ?>
        <form action="/users/login" method="post">
            <label>Email <input type="text" name="email" value="<?= $_POST['email'] ?? '' ?>"></label>
            <br><br>
            <label>Пароль <input type="password" name="password" value="<?= $_POST['password'] ?? '' ?>"></label>
            <br><br>
            <input type="submit" value="Войти">
        </form>
    </div>
<?php include __DIR__ . '/../footer.php'; ?>

Теперь можно зайти в браузер и убедиться, что форма открывается.
Форма авторизации

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

src/MyProject/Models/Users/User.php

public static function login(array $loginData): User
{
    if (empty($loginData['email'])) {
        throw new InvalidArgumentException('Не передан email');
    }

    if (empty($loginData['password'])) {
        throw new InvalidArgumentException('Не передан password');
    }

    $user = User::findOneByColumn('email', $loginData['email']);
    if ($user === null) {
        throw new InvalidArgumentException('Нет пользователя с таким email');
    }

    if (!password_verify($loginData['password'], $user->getPasswordHash())) {
        throw new InvalidArgumentException('Неправильный пароль');
    }

    if (!$user->isConfirmed) {
        throw new InvalidArgumentException('Пользователь не подтверждён');
    }

    $user->refreshAuthToken();
    $user->save();

    return $user;
}

public function getPasswordHash(): string
{
    return $this->passwordHash;
}

public function refreshAuthToken()
{
    $this->authToken = sha1(random_bytes(100)) . sha1(random_bytes(100));
}

Обратите вниимание – при успешном входе auth token пользователя в базе обновляется – все его предыдущие сессии станут недействительными.

src/MyProject/Controllers/UsersController.php

public function login()
{
    if (!empty($_POST)) {
        try {
            $user = User::login($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/login.php', ['error' => $e->getMessage()]);
            return;
        }
    }
    $this->view->renderHtml('users/login.php');
}

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

Нет пользователя с таким email

Неправильный пароль

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

Создадим специальный сервис, который будет работать с пользовательскими сессиями через Cookie. Назовём его UsersAuthService.

src/MyProject/Services/UsersAuthService.php

<?php

namespace MyProject\Services;

use MyProject\Models\Users\User;

class UsersAuthService
{
    public static function createToken(User $user): void
    {
        $token = $user->getId() . ':' . $user->getAuthToken();
        setcookie('token', $token, 0, '/', '', false, true);
    }

    public static function getUserByToken(): ?User
    {
        $token = $_COOKIE['token'] ?? '';

        if (empty($token)) {
            return null;
        }

        [$userId, $authToken] = explode(':', $token, 2);

        $user = User::getById((int) $userId);

        if ($user === null) {
            return null;
        }

        if ($user->getAuthToken() !== $authToken) {
            return null;
        }

        return $user;
    }
}

А теперь мы можем использовать его для удобного создания нужной Cookie в контроллере.

src/MyProject/Controllers/UsersController.php

public function login()
{
    if (!empty($_POST)) {
        try {
            $user = User::login($_POST);
            UsersAuthService::createToken($user);
            header('Location: /');
            exit();
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('users/login.php', ['error' => $e->getMessage()]);
            return;
        }
    }

    $this->view->renderHtml('users/login.php');
}

Теперь откроем консоль разработчика в Google Chrome и введем правильные логин и пароль. Видим, что нас перекинуло на главную страницу нашего блога, и что была установлена Cookie с именем token.

Авторизационный токен в Cookie

Теперь нам нужно научиться передавать пользователя во View. Согласитесь, пользователь нам понадобится почти на каждой странице сайта, в каждом экшене. И будет неудобно каждый раз при рендеринге шаблона прокидывать пользователя:

src/MyProject/Controllers/MainController.php

public function main()
{
    $articles = Article::findAll();
    $this->view->renderHtml('main/main.php', [
        'articles' => $articles,
        'user' => UsersAuthService::getUserByToken()
    ]);
}

Поэтому мы сделаем во View возможность добавлять переменные еще перед рендерингом, вот так:

src/MyProject/View/View.php

<?php

namespace MyProject\View;

class View
{
    private $templatesPath;

    private $extraVars = [];

    public function __construct(string $templatesPath)
    {
        $this->templatesPath = $templatesPath;
    }

    public function setVar(string $name, $value): void
    {
        $this->extraVars[$name] = $value;
    }

    public function renderHtml(string $templateName, array $vars = [], int $code = 200)
    {
        http_response_code($code);

        extract($this->extraVars);
        extract($vars);

        ob_start();
        include $this->templatesPath . '/' . $templateName;
        $buffer = ob_get_contents();
        ob_end_clean();

        echo $buffer;
    }
}

И теперь мы можем в контроллерах прямо в конструкторах задать нужные переменные.

src/MyProject/Controllers/MainController.php

/** @var User|null */
private $user;

public function __construct()
{
    $this->user = UsersAuthService::getUserByToken();
    $this->view = new View(__DIR__ . '/../../../templates');
    $this->view->setVar('user', $this->user);
}

И добавить в шапке сайта (в шаблонах) вывод пользователя, если он был передан во View:

templates/header.php

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Мой блог</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>

<table class="layout">
    <tr>
        <td colspan="2" class="header">
            Мой блог
        </td>
    </tr>
    <tr>
        <td colspan="2" style="text-align: right">
            <?= !empty($user) ? 'Привет, ' . $user->getNickname() : 'Войдите на сайт' ?>
        </td>
    </tr>
    <tr>
        <td>

Обновим теперь страничку и увидим приветствие.
Приветствие пользователя

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

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

src/MyProject/Controllers/ArticlesController.php

/** @var User|null */
private $user;

public function __construct()
{
    $this->user = UsersAuthService::getUserByToken();
    $this->view = new View(__DIR__ . '/../../../templates');
    $this->view->setVar('user', $this->user);
}

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

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

src/MyProject/Controllers/AbstractController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Users\User;
use MyProject\Services\UsersAuthService;
use MyProject\View\View;

abstract class AbstractController
{
    /** @var View */
    protected $view;

    /** @var User|null */
    protected $user;

    public function __construct()
    {
        $this->user = UsersAuthService::getUserByToken();
        $this->view = new View(__DIR__ . '/../../../templates');
        $this->view->setVar('user', $this->user);
    }
}

Обратите внимание, свойства user и view теперь с типом protected – они будут доступны в наследниках. Ну а теперь нам достаточно просто отнаследоваться в наших контроллерах от этого класса и можно удалить в них конструкторы и свойства view и user – они будут унаследованы от AbstractController. Это существенно упростит их код.

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;

class MainController extends AbstractController
{
    public function main()
    {
        $articles = Article::findAll();
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Exceptions\NotFoundException;
use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;

class ArticlesController extends AbstractController
{
    public function view(int $articleId)
    {
    ...

src/MyProject/Controllers/UsersController.php

<?php

namespace MyProject\Controllers;

use MyProject\Exceptions\InvalidArgumentException;
use MyProject\Models\Users\User;
use MyProject\Models\Users\UserActivationService;
use MyProject\Services\EmailSender;
use MyProject\Services\UsersAuthService;

class UsersController extends AbstractController
{
    public function signUp()
    {
    ...

Теперь можно пройтись по всем страничкам сайта и убедиться, что всё по-прежнему работает. Если теперь нам нужно будет добавить какой-то функционал для всех контроллеров, то мы просто сделаем это в AbstractController.

Ну вот и всё – наша система авторизации готова! Разумеется, есть еще несколько вещей, которые нужно сделать. Их вы реализуете самостоятельно в домашнем задании.

Домашнее задание

  • Если пользователь залогинен, то сделайте рядом с приветствием ссылку для выхода из этой учетной записи. Реализуйте функционал для разлогинивания (роутинг + экшен, удаляющий cookie).
    Разлогин
  • Если пользователь не залогинен, показывайте ссылки для регистрации и входа на сайт.

Добавляем статьи в блог на PHP

В этом уроке мы с вами добавим самый главный функционал блога – добавление новых записей.

В одном из прошлых уроков мы уже добавили роут /articles/add и соответствующий экшен ArticlesController->add(). Так он выглядит сейчас:

src/MyProject/Controllers/ArticlesController.php

public function add(): void
{
    $author = User::getById(1);

    $article = new Article();
    $article->setAuthor($author);
    $article->setName('Новое название статьи');
    $article->setText('Новый текст статьи');

    $article->save();

    $article->delete();

    var_dump($article);
}

Теперь давайте сделаем форму, которую можно будет заполнить в браузере и создавать через неё новые записи.

templates/articles/add.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1>Создание новой статьи</h1>
    <?php if(!empty($error)): ?>
        <div style="color: red;"><?= $error ?></div>
    <?php endif; ?>
    <form action="/articles/add" method="post">
        <label for="name">Название статьи</label><br>
        <input type="text" name="name" id="name" value="<?= $_POST['name'] ?? '' ?>" size="50"><br>
        <br>
        <label for="text">Текст статьи</label><br>
        <textarea name="text" id="text" rows="10" cols="80"><?= $_POST['text'] ?? '' ?></textarea><br>
        <br>
        <input type="submit" value="Создать">
    </form>
<?php include __DIR__ . '/../footer.php'; ?>

Теперь сделаем набросок для контроллера, чтобы просто увидеть нашу форму.

src/MyProject/Controllers/ArticlesController.php

public function add(): void
{
    $this->view->renderHtml('articles/add.php');
}

Посмотрим на результат:
Форма добавления статей

Итак, в форме у нас есть 2 поля: name и text. На их основе мы должны научиться создавать статьи. Кроме того, в контроллере у нас также есть свойство user – текущий пользователь. Его нужно будет указывать автором при создании статей. Если мы соберем все эти три значения – мы сможем создать статью.

Первым делом нужно убедиться, что пользователь авторизован. Если это не так – будем кидать исключение в контроллере и ловить его во фронт-контроллере. Назовём такое исключение UnauthorizedException.

src/MyProject/Exceptions/UnauthorizedException.php

<?php

namespace MyProject\Exceptions;

class UnauthorizedException extends \Exception
{
}

Добавляем проверку в самом начале экшена.

src/MyProject/Controllers/ArticlesController.php

public function add(): void
{
    if ($this->user === null) {
        throw new UnauthorizedException();
    }

    $this->view->renderHtml('articles/add.php');
    return; 
}

Обрабатываем исключение во фронт-контроллере. Добавляем в конце еще один catch.

www/index.php

...
} catch (\MyProject\Exceptions\UnauthorizedException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->renderHtml('401.php', ['error' => $e->getMessage()], 401);
}

И, наконец, добавляем шаблон для ошибки.

templates/errors/401.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1>Вы не авторизованы</h1>
    Для доступа к этой странице нужно <a href="/users/login">войти на сайт</a>
<?php include __DIR__ . '/../footer.php'; ?>

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

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

Теперь можно создать в модели статьи метод для создания новой статьи.

src/MyProject/Models/Articles/Article.php

public static function createFromArray(array $fields, User $author): Article
{
    if (empty($fields['name'])) {
        throw new InvalidArgumentException('Не передано название статьи');
    }

    if (empty($fields['text'])) {
        throw new InvalidArgumentException('Не передан текст статьи');
    }

    $article = new Article();

    $article->setAuthor($author);
    $article->setName($fields['name']);
    $article->setText($fields['text']);

    $article->save();

    return $article;
}

Дописываем экшен:

src/MyProject/Controllers/ArticlesController.php

public function add(): void
{
    if ($this->user === null) {
        throw new UnauthorizedException();
    }

    if (!empty($_POST)) {
        try {
            $article = Article::createFromArray($_POST, $this->user);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('articles/add.php', ['error' => $e->getMessage()]);
            return;
        }

        header('Location: /articles/' . $article->getId(), true, 302);
        exit();
    }

    $this->view->renderHtml('articles/add.php');
}

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

Заполняем формочку по адресу http://myproject.loc/articles/add
Создание статьи

И вуаля – создалась новая статья.

Созданная статья

А теперь – за домашку.

Домашнее задание

Сделайте так, чтобы добавлять статьи могли только пользователи с правами админа. Если это не так - бросайте исключение с новым типом - Forbidden. При этом страница должна вернуть код 403.

Редактируем статьи в блоге на PHP

Сегодня мы создадим еще один компонент нашего блога – систему редактирования статей.

В одном из прошлых уроков мы уже добавляли роутинг для редактирования статей:

src/routes.php

'~^articles/(\d+)/edit$~' => [\MyProject\Controllers\ArticlesController::class, 'edit'],

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

src/MyProject/Controllers/ArticlesController.php

public function edit(int $articleId)
{
    $article = Article::getById($articleId);

    if ($article === null) {
        throw new NotFoundException();
    }

    $article->setName('Новое название статьи');
    $article->setText('Новый текст статьи');

    $article->save();
}

Нам требуется его переписать, чтобы статья обновлялась данными из POST-запроса. Делаем:

public function edit(int $articleId)
{
    $article = Article::getById($articleId);

    if ($article === null) {
        throw new NotFoundException();
    }

    if ($this->user === null) {
        throw new UnauthorizedException();
    }

    if (!empty($_POST)) {
        try {
            $article->updateFromArray($_POST);
        } catch (InvalidArgumentException $e) {
            $this->view->renderHtml('articles/edit.php', ['error' => $e->getMessage()]);
            return;
        }

        header('Location: /articles/' . $article->getId(), true, 302);
        exit();
    }

    $this->view->renderHtml('articles/edit.php', ['article' => $article]);
}

Теперь давайте добавим соответсвующий шаблон.

templates/articles/edit.php

<?php
/**
 * @var \MyProject\Models\Articles\Article $article
 */
include __DIR__ . '/../header.php';
?>
    <h1>Редактирование статьи</h1>
    <?php if(!empty($error)): ?>
        <div style="color: red;"><?= $error ?></div>
    <?php endif; ?>
    <form action="/articles/<?= $article->getId() ?>/edit" method="post">
        <label for="name">Название статьи</label><br>
        <input type="text" name="name" id="name" value="<?= $_POST['name'] ?? $article->getName() ?>" size="50"><br>
        <br>
        <label for="text">Текст статьи</label><br>
        <textarea name="text" id="text" rows="10" cols="80"><?= $_POST['text'] ?? $article->getText() ?></textarea><br>
        <br>
        <input type="submit" value="Обновить">
    </form>
<?php include __DIR__ . '/../footer.php'; ?>

А теперь добавим соответствующий метод в модели:

src/MyProject/Models/Articles/Article.php

public function updateFromArray(array $fields): Article
{
    if (empty($fields['name'])) {
        throw new InvalidArgumentException('Не передано название статьи');
    }

    if (empty($fields['text'])) {
        throw new InvalidArgumentException('Не передан текст статьи');
    }

    $this->setName($fields['name']);
    $this->setText($fields['text']);

    $this->save();

    return $this;
}

Заходим теперь на страничку редактирования статьи:
http://myproject.loc/articles/14/edit

Редактирование статьи

Вводим какой-нибудь новый текст и заголовок.
Изменяем статью

Нажимаем на кнопку обновления и вуаля – статья обновлена.
Обновленная статья

Домашнее задание

  1. При попытке обновления статьи НЕ админом – бросайте исключение ForbiddenException, как в прошлом уроке.
  2. Добавьте ссылку на странице показа статьи с текстом «Редактировать», которая будет вести на страницу редактирования этой статьи.
  3. Сделайте так, чтобы эта ссылка показывалась только если пользователь залогинен и он админ.

Задание для самостоятельной работы – доводим блог до ума

Сегодня в рамках данной статьи вам будет дано задание самостоятельно доработать блог до конца. Все необходимые знания для данного урока у вас уже имеются, если вам непонятно как делать то или иное задание – рекомендую вам пройтись по списку всех уроков – скорее всего вы просто что-то подзабыли.

Итак, что же нам требуется сделать для того, чтобы наш блог стал похож на настоящий блог.

  1. Комментарии.
    Добавьте возможность добавлять комментарии к статьям. Для хранения комментариев заведите отдельную таблицу – comments. В ней будут храниться:
  • id комментария
  • автор комментария (id пользователя),
  • id статьи, к которой он написан,
  • текст комментария,
  • дата его публикации.

Для добавления комментария к статье стоит использовать url типа /articles/123/comments – сюда будет отправляться POST-запрос. Саму же форму добавления комментария можно сделать непосредственно на странице со статьей. Чтобы было проще – показывайте эту форму только авторизованным пользователям, а для неавторизованных просто пишите текст о том, что нужно зарегистрироваться для добавления комментария. Если POST-запрос выполнился успешно – отправляйте пользователя на страницу статьи с якорем для добавленного комментария. Типа: /articles/123#comment1.

Автор комментария и администратор могут редактировать комментарии по url типа такого: / comments/456/edit. Соответственно, ссылку «Редактировать» рядом с комментом стоит показывать только админам и авторам комментария.

  1. Админка.
    В админку могут попасть только администраторы сайта. Это стоит проверять в контроллере самой админки. Кроме того, в шаблоне стоит проверять является ли текущий пользователь админом. В админке для начала сделайте две страницы – список последних статей и список последних комментариев. Напротив каждой из этих сущностей – кнопку для редактирования. Статьи в админке не нужно показывать целиком – выводите только первые 100 символов текста – для этого в модели Article добавьте метод для получения короткой ссылки.
  2. Включайте фантазию.
    Теперь добавьте что-нибудь от себя – добавьте возможность загружать аватары для пользователей и выводите их в комментариях, добавьте в админке возможность менять название сайта и т.д. – что вам больше понравится, то и делайте. Если фантазия подводит – можно взять блоговый движок, например wordpress, и попробовать реализовать какой-нибудь функционал оттуда.

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

Этот урок в этом курсе не последний, но, пожалуй, самый важный. Займитесь им основательно, а затем переходите к следующим. До встречи!

Command Line Interface в PHP

До этого момента мы с Вами знали, что PHP работает на сервере. Клиент обращается к серверу по протоколу HTTP с каким-либо запросом, запрос на сервере обрабатывается и формируется ответ. После этого клиенту снова по протоколу HTTP в ответе отдаётся сформированный ответ. Однако, если взять какой-нибудь более-менее продвинутый сайт, то мы увидим, что есть задачи, которые не решаются стандартным клиент-серверным путем. Например: поздравлять пользователей с днём рождения и дарить им скидку на какой-нибудь продукт. Для того, чтобы это сделать, нам придется обновлять раз в день php-скрипт в браузере, чтобы он выбирал пользователей, у которых сегодня ДР, затем создавал для них скидки, и отправлял им сообщения по почте. Согласитесь, неудобно это делать вручную и в браузере. Для таких случаев в PHP предусмотрен Command Line Interface (CLI) – интерфейс командной строки.

CLI позволяет запускать программы на PHP не через привычную нам клиент-серверную архитектуру, а как простые программы в командной строке. Давайте создадим простейший скрипт, чтобы показать, как это работает. Создаём новую папку bin в корне проекта, а в ней файл – cli.php.

CLI контроллер

Пишем простейший код:

bin/cli.php

<?php

echo 2 + 2;

А теперь запускаем консоль из OpenServer:
OpenServer cmd

Переходим в папку с нашим проектом, выполнив:

cd domains\myproject.loc

И пишем следующую команду:

php bin/cli.php

В ответ получаем:
Вывод результата в терминал

Написали простейшее консольное приложение! Уже неплохо. Но что если мы захотим сложить 2 числа, которые нужно передать скрипту? Как Вы понимаете, сделать это с помощью GET- или POST- запросов уже не получится. Так как же быть?

Аргументы консольного приложения

На помощь нам приходят аргументы, которые мы можем передать в скрипт, указав их после имени скрипта в командной строке. Вот так:
Аргументы консольного приложения

А для того, чтобы получить к ним доступ из php-скрипта используется магическая переменная $argv. Она представляет собой массив, в котором нулевой элемент – это путь до скрипта, а все последующие – это его аргументы в консоли.

bin/cli.php

<?php

var_dump($argv);

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

Как видим, наши аргументы попали в этот массив. Давайте напишем простейший скрипт, который будет складывать все переданные ему аргументы.

<?php

unset($argv[0]);

$sum = 0;

foreach ($argv as $item) {
    $sum += $item;
}

echo $sum;

Запустим его, и убедимся, что все работает:
Сумма всех аргументов

И он действительно работает: 3 + 4 + 5 = 12.

А что если мы хотим передавать аргументы с именами? Вроде такого:
Именованные аргументы

И затем в коде получать их в коде по их именам? Для этого нам следует написать простейший парсер, который будет находить вот такие именованные параметры и их значения. Пишем.

<?php

unset($argv[0]);

$params = [];

foreach ($argv as $argument) {
    preg_match('/^-(.+)=(.+)$/', $argument, $matches);
    if (!empty($matches)) {
        $paramName = $matches[1];
        $paramValue = $matches[2];

        $params[$paramName] = $paramValue;
    }
}

var_dump($params);

И проверяем его работу:
аргументы в массиве

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

CLI и ООП

Мы с вами изучили некоторые основы работы с CLI. Давайте теперь перенесем эти знания на объектно-ориентированный подход и научимся работать через интерфейс командной строки с объектами.

Для этого нам понадобится создать отдельную директорию под «команды». Команды – так мы будем называть наши специальные классы, которые будут выполнять какой-то код через запуск из командной строки. Создаем новую директорию: src/MyProject/Cli.
Папка для команд

И теперь создадим наш первый класс, который будет заниматься тем, что считает сумму переданных в него аргументов: -a и -b.

src/MyProject/Cli/Summator.php

<?php

namespace MyProject\Cli;

use MyProject\Exceptions\CliException;

class Summator
{
    /** @var array */
    private $params;

    public function __construct(array $params)
    {
        $this->params = $params;
        $this->checkParams();
    }

    public function execute()
    {
        echo $this->getParam('a') + $this->getParam('b');
    }

    private function checkParams()
    {
        $this->ensureParamExists('a');
        $this->ensureParamExists('b');
    }

    private function getParam(string $paramName)
    {
        return $this->params[$paramName] ?? null;
    }

    private function ensureParamExists(string $paramName)
    {
        if (!isset($this->params[$paramName])) {
            throw new CliException('Param with name "' . $paramName . '" is not set!');
        }
    }
}

В конструкторе класса мы принимаем список параметров, сохраняем их, а затем вызываем метод checkParams(), который проверяет наличие обязательных параметров для этого скрипта. В нём просто поочередно вызывается метод для проверки в массиве нужных ключей. Если их нет – метод кинет исключение. И, наконец, есть метод execute(), который содержит бизнес-логику. В нем используется метод getParam(), который вернет параметр (при его наличии), либо вернет null (при его отсутствии).

И также создаём исключение, специально для ошибок, возникающих при работе с CLI.

src/MyProject/Exceptions/CliException.php

<?php

namespace MyProject\Exceptions;

class CliException extends \Exception
{
}

Теперь давайте снова вернемся в нашу точку входа для консольных приложений cli.php. Этот файл можно назвать фронт-контроллером для консольных команд, он как index.php в случае с клиент-серверным подходом будет создавать другие объекты и запускать весь процесс.

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

bin/cli.php

<?php

try {
    unset($argv[0]);

    // Регистрируем функцию автозагрузки
    spl_autoload_register(function (string $className) {
        require_once __DIR__ . '/../src/' . $className . '.php';
    });

    // Составляем полное имя класса, добавив нэймспейс
    $className = '\\MyProject\\Cli\\' . array_shift($argv);
    if (!class_exists($className)) {
        throw new \MyProject\Exceptions\CliException('Class "' . $className . '" not found');
    }

    // Подготавливаем список аргументов
    $params = [];
    foreach ($argv as $argument) {
        preg_match('/^-(.+)=(.+)$/', $argument, $matches);
        if (!empty($matches)) {
            $paramName = $matches[1];
            $paramValue = $matches[2];

            $params[$paramName] = $paramValue;
        }
    }

    // Создаём экземпляр класса, передав параметры и вызываем метод execute()
    $class = new $className($params);
    $class->execute();
} catch (\MyProject\Exceptions\CliException $e) {
    echo 'Error: ' . $e->getMessage(); 
}

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

Если мы захотим создать еще один класс, в котором мы будем вычитать из аргумента a аргумент b, то нам нужно будет продублировать довольно большой объем кода. Но ведь если присмотреться – большую часть кода из класса Summator можно вынести в отдельный класс и использовать его повторно.

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

src/MyProject/Cli/AbstractCommand.php

<?php

namespace MyProject\Cli;

use MyProject\Exceptions\CliException;

abstract class AbstractCommand
{
    /** @var array */
    private $params;

    public function __construct(array $params)
    {
        $this->params = $params;
        $this->checkParams();
    }

    abstract public function execute();

    abstract protected function checkParams();

    protected function getParam(string $paramName)
    {
        return $this->params[$paramName] ?? null;
    }

    protected function ensureParamExists(string $paramName)
    {
        if (!isset($this->params[$paramName])) {
            throw new CliException('Param with name "' . $paramName . '" is not set!');
        }
    }
}

Теперь нам в классе Summator достаточно отнаследоваться от этого класса и он значительно упростится:

src/MyProject/Cli/Summator.php

<?php

namespace MyProject\Cli;

class Summator extends AbstractCommand
{
    protected function checkParams()
    {
        $this->ensureParamExists('a');
        $this->ensureParamExists('b');
    }

    public function execute()
    {
        echo $this->getParam('a') + $this->getParam('b');
    }
}

Запустим скрипт снова и убедимся, что все успешно отработало:
Результат суммы в терминале

Давайте создадим по аналогии скрипт, который будет вычитать из аргумента x аргумент y.

src/MyProject/Cli/Minusator.php

<?php

namespace MyProject\Cli;

class Minusator extends AbstractCommand
{
    protected function checkParams()
    {
        $this->ensureParamExists('x');
        $this->ensureParamExists('y');
    }

    public function execute()
    {
        echo $this->getParam('x') - $this->getParam('y');
    }
}

Проверим его в деле:
Разница в консоли

А теперь давайте попробуем не указать один из аргументов – получим ошибку.
Ошибка об отсутствии обязательного аргумента

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

Домашнее задание

В файле cli.php добавьте проверку на то, что класс, указанный в качестве аргумента, является наследником класса AbstractCommand. Проверку нужно осуществлять ещё до создания объекта, имея только имя класса.

Планировщик заданий cron

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

cron – это такая программа в операционных системах Unix, которая позволяет очень простым способом запускать различные программы по расписанию. Для этого нужно в специальный конфигурационный файл этой программы прописать нужные настройки. В этом файле настроек действует простое правило – одна строчка в файле = одно запланированное действие. То есть если мы хотим по расписанию запускать какую-то одну программу, то нам нужно создать для нее одну строку в файле настроек. Нужна еще одна программа с другим расписанием? Не вопрос – просто добавляем еще одну строку в этот файлик.

Каждая строка состоит при этом из двух частей:

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

И если команда в большинстве случаев – это просто путь к нужной программе с передачей в нее аргументов, то расписание в cron – вещь к которой нужно привыкнуть. Ведь представляет оно собой ни что иное, как 5 выражений, разделенных пробелами. Вот простейший пример файла конфигурации для cron:

0 0 * * * php c:\OSPanel\domains\myproject.loc\bin\cli.php Minusator -x=20 -y=17

Где цифры обозначают:

0 0 * * * команда_для_запуска
- - - - -
| | | | |
| | | | - День недели (0 - 7) (воскресенье = 0 или 7)
| | | --- Месяц (1 - 12)
| | --- День месяца (1 - 31)
| ---- Час (0 - 23)
----- Минута (0 - 59)

В нашем примере команда будет запускаться каждый день каждого месяца в 00:00. Звездочки – значит «каждый» час, день, и т.п. Конкретное значение – это конкретное значение, простите за тавтологию =)

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

* * * * * команда_для_запуска

Если 1-го числа каждого месяца в 04:20, то:

20 4 1 * * команда_для_запуска

Операторы

Кроме вышеупомянутой звездочки есть и другие операторы.

Слэш (/)

*/20 * * * * команда_для_запуска

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

Запятая (,)

1,10,20 * * * * команда_для_запуска

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

Тире (-)

* 9-17 * * * команда_для_запуска

С помощью тире можно указать диапазон. Команда выше будет запускаться каждую минуту с 9:00 до 17:59.

Используем cron

Вообще cron – это утилита из операционной системы Linux. Она поддерживается большинством современных хостингов, и когда вы будете выкладывать свой сайт на хостинг – вы сможете её использовать. Для тех же, кто под виндой, спешу обрадовать – в OpenServer есть своя реализация cron.

Для этого нужно зайти в настройки OpenServer и перейти во вкладку «Планировщик заданий».
cron в OpenServer

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

* * * * * php c:\OSPanel\domains\myproject.loc\bin\cli.php TestCron -x=20 -y=17

Сделайте всё, как на скриншоте и нажмите «Добавить».
добавление команды в планировщик

После чего жмем «Сохранить».
команда в планировщике

Команда успешно добавлена в расписание.
Но у нас пока нет класса TestCron. Давайте его создадим.

src/MyProject/Cli/TestCron.php

<?php

namespace MyProject\Cli;

class TestCron extends AbstractCommand
{
    protected function checkParams()
    {
        $this->ensureParamExists('x');
        $this->ensureParamExists('y');
    }

    public function execute()
    {
        // чтобы проверить работу скрипта, будем записывать в файлик 1.log текущую дату и время
        file_put_contents('C:\\1.log', date(DATE_ISO8601) . PHP_EOL, FILE_APPEND);
    }
}

Ждём пару минут и проверяем файлик на диске C:.

C:\1.log

2018-10-20T20:09:02+0300
2018-10-20T20:10:02+0300

Как видим, наш скрипт выполнялся с интервалом в одну минуту.

Теперь вы знаете, как использовать планировщик cron для своих целей. Он используется абсолютно для разных задач – начиная от рассылок писем с поздравлениями и заканчивая выгрузками статистики из базы данных несколько раз в день. А один мой товарищ использует cron для полива цветов раз в день – для этого он написал небольшую программку для Raspberry PI и использует крон для её запуска по расписанию. Идеи могут быть самыми разными – всё, что нужно запускать по расписанию, можно сделать с помощью этого мощного и в то же время простого инструмента.

Домашнее задание

Есть задача - нужно запускать php-скрипт каждые 20 секунд. Как это сделать?

Стандарты PSR

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

PSR расшифровывается как PHP Standards Recommendations, что переводится как «Рекомендованные стандарты PHP». Эти стандарты создаются и поддерживаются сообществом опытных PHP-программистов со следующей целью: нужно писать код на языке PHP в одном стиле так, чтобы его без проблем могли читать другие программисты. Иными словами, это такой набор правил, следуя которым, код становится более понятным и единообразным, когда над ним работают несколько людей.

На реальной работе (а именно к ней мы с вами и готовимся, так ведь?) нам нужно будет работать с другими людьми. Во всех нормальных компаниях есть процесс код-ревью – участники проекта просматривают код друг друга, чтобы минимизировать число ошибок. Если при этом каждый будет писать код так, как ему вздумается, то это приведет к тому что другие члены команды будут с трудом понимать код других участников. Поэтому абсолютно во всех компаниях сейчас требуют писать код, придерживаясь определенных стандартов. Для языка PHP – это стандарты PSR.

Какие стандарты бывают

Стандарты PSR разделены на несколько частей. Каждый из стандартов содержит в себе информацию о том, как следует делать какую-то часть в процессе программирования на PHP. Например: как ставить переносы строк, как должны именоваться переменные, как должна быть реализована автозагрузка. За эти разные части отвечают разные стандарты. Каждый стандарт начинается с аббревиатуры PSR, после которой идёт номер стандарта. Например: PSR-1, или PSR-4.

Ознакомиться со всеми актуальными стандартами на сегодняшний день вы можете на этой страничке - https://www.php-fig.org/psr/.

Для вас сейчас достаточно будет изучить 3 стандарта: PSR-1, PSR-2 и PSR-4. Остальные пока трогать не стоит.

Коротко о том, что эти стандарты в себе содержат.

PSR-1: Basic Coding Standard

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

PSR-2: Coding Style Guide

Этот стандарт расширяет вешеописанный. Он призван уменьшить сложность восприятия кода, написанного разными авторами. Это достигается путём внедрения правил относительно форматирования PHP-кода.

PSR-4: Autoloader

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

До какой степени и как учить

Будет уже неплохо, если придя на собеседование на должность младшего PHP разработчика, Вы будете хотя бы знать о стандартах PSR и расскажете для чего они нужны. Если же вы сможете привести еще и пару примеров того, о чем в этих стандартах говорится – это вообще супер. Если вы имеете трудности с английским языком – вы можете нагуглить перевод этих стандартов на русский язык, с этим проблем не возникнет. А сейчас – постарайтесь прочитать каждый из стандартов хотя бы пару раз и начать применять эти правила в своём коде.

Менеджер пакетов Composer

В этом уроке речь пойдёт не о написании кода на PHP, а о том, как использовать уже готовый. Для этого в любом языке программирования используются библиотеки – это уже написанный код, который делает какую-то часть работы, и который достаточно подключить в своём проекте чтобы начать использовать. Для многих современных языков программирования есть менеджеры пакетов, которые позволяют находить и устанавливать библиотеки в автоматическом режиме. В PHP для этого используется Composer – это менеджер пакетов, который сам скачивает нужные нам библиотеки и добавляет их в автозагрузку вашего приложения. После этого мы можем сразу использовать уже готовые классы и функции в наших программах. Удобно, да? Кроме того, репозиторий с открытыми бесплатными библиотеками Packagist.org насчитывает на сегодняшний день более 200 000 пакетов. Как вы понимаете, готовый код есть практически на все случаи жизни.

Сам по себе composer представляет собой консольное приложение, написанное на языке PHP. И для того чтобы его использовать, нужен только установленный PHP. Скачать composer можно с официального сайта - https://getcomposer.org/download/. Заходите и скачивайте версию, подходящую для вашей ОС. Если же Вы используете OpenServer, спешу вас обрадовать – composer у Вас уже установлен. Чтобы начать им пользоваться, нужно открыть уже знакомую вам по прошлым урокам консоль OpenServer. Для того, чтобы запустить composer, достаточно простейшей команды:

composer

композер

В ответ мы получаем список доступных команд. Первое, что нам нужно сделать – это выполнить в директории с нашим проектом (не забудьте в нее перейти с помощью команды cd) команду:

composer init

Эта команда задаст нам несколько вопросов:

  • Package name (<vendor>/<name>) [user/myproject.loc] – в composer название пакета состоит из двух частей: vendor (имя производителя) и name (имя пакета). Я вам предлагаю использовать какой-нибудь свой никнейм или фамилию в качестве вендора, а в качестве имени пакета – myproject. В моём случае ответ для этого пункта будет такой: ivashkevich/myproject.
  • Description []: - описание проекта, можно оставить пустым.
  • Author [, n to skip]: - автор проекта. В формате John Smith john@example.com
  • Minimum Stability []: - минимальная степень стабильности проекта. Об этом позже, пока оставим пустым.
  • Package Type (e.g. library, project, metapackage, composer-plugin) []: - тип пакета. Пишем project.
  • License []: - тип лицензии, по которой будет распространяться наш код. Пишем proprietary.
  • Would you like to define your dependencies (require) interactively [yes]? – Желаете ли Вы определить зависимости для проекта в интерактивном режиме. То есть, хотим ли мы прямо сейчас загружать библиотеки. Отвечаем no.
  • Would you like to define your dev dependencies (require-dev) interactively [yes]? – То же, что и выше, только для разработческого окружения – снова no.
  • Do you confirm generation [yes] – Подтверждаем ли мы генерацию конфигурационного файла. Жмем yes.
  • Would you like the vendor directory added to your .gitignore [yes]? – Нужно ли добавить папку vendor в исключения для git. Yes.

инициализация композер в проекте

После всего этого заходим в папку с проектом, и видим в корне проекта файлик composer.json.

{
    "name": "ivashkevich/myproject",
    "type": "project",
    "license": "proprietary",
    "authors": [
        {
            "name": "Artyom Ivashkevich",
            "email": "x@webshake.ru"
        }
    ],
    "require": {}
}

Это файл, который будет использоваться composer для дальнейшей работы. Он описан в формате JSON – об этом формате я вам советую прочитать прямо сейчас в гугле, чтобы избежать лишних вопросов. Но в принципе – это простейший конфиг, в котором перечислены настройки по типу

ключ: значение

Поиск пакетов

Теперь давайте зайдём в репозиторий пакетов Packagist - https://packagist.org/. И найдём какую-нибудь библиотеку, которую будем использовать в нашем проекте. Я предлагаю взять в качестве примера библиотеку для парсинга markdown-разметки. Если вы не знаете, что это такое – погуглите. Итак, идем на Packagist и вбиваем в поиск слово markdown - https://packagist.org/?query=markdown. И выбираем первый пакет, у которого больше всего скачиваний - https://packagist.org/packages/erusev/parsedown.

На страничке пакета мы можем посмотреть краткую (или не очень) документацию по проекту, посмотреть исходники на github и увидеть информацию об авторе.

Установка пакета

Для того, чтобы установить пакет, используется команда:

composer require вендор/имя_пакета

Конкретно в нашем случае команда будет:

composer require erusev/parsedown

Установка пакета

Первым делом идём в composer.json и видим новую строчку в разделе require:

"erusev/parsedown": "^1.7"

Это наш подключаемый пакет и его версия. Так как мы не указывали конкретную версию, которая нам нужна, была установлена последняя на данный момент. А значок «^» перед цифрами означает, что нам требуется версия пакета не ниже указанной.

Папка vendor

В нашем проекте появилась папка vendor – здесь хранятся все пакеты, которые были скачаны composer-ом.

папка vendor

Кроме того, в ней есть файл autoload.php – его нужно подключить в нашем проекте через require, и после этого мы сможем использовать все файлы из библиотек в нашем проекте! Давайте сделаем это. Подключим этот файл в двух наших фронт-контроллерах: index.php и cli.php.

www/index.php

<?php

require __DIR__ . '/../vendor/autoload.php';
...

bin/cli.php

<?php

require __DIR__ . '/../vendor/autoload.php';
...

Всё! Теперь можно использовать библиотеки!

Использование библиотек

Давайте для постов нашего блога начнем использовать Markdown-разметку. Сделать это с помощью подключенной нами библиотеки проще простого. Смотрим документацию на страничке пакета и просто повторяем для нашей ситуации.

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

src/MyProject/Models/Articles/Article.php

public function getParsedText(): string
{
    $parser = new \Parsedown();
    return $parser->text($this->getText());
}

И добавляем вывод прошедшего парсинг текста в шаблоне:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getParsedText() ?></p>
    <p>Автор: <?= $article->getAuthor()->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

Теперь при выводе статей содержимое поля text будет предварительно пропущено через markdown-парсер.

Пробуем создать новую статью, используя markdown-разметку.

Редактируем пост в markdown разметке

Получаем на выходе отформатированный HTML-тегами текст.
Отформатированный текст

Делаем то же самое для списка статей и вуаля – у нас блог, редактируемый с помощью markdown-разметки.

Автозагрузка для файлов проекта

Кроме того, что композер создает файл с автозагрузкой для библиотек, он позволяет включить в эту автозагрузку еще и файлы нашего проекта. Для этого нужно в composer.json:

  1. указать стандарт, который используется для автозагрузки. В нашем случае – PSR-4.
  2. указать корневую папку и неймспейс для проекта.

composer.json

{
    "name": "ivashkevich/myproject",
    "type": "project",
    "license": "proprietary",
    "authors": [
        {
            "name": "Artyom Ivashkevich",
            "email": "x@webshake.ru"
        }
    ],
    "autoload": {
        "psr-4": {
            "MyProject\\": "src/MyProject/"
        }
    },    "require": {
        "erusev/parsedown": "^1.7"
    }
}

После этого следует выполнить команду:

composer install

Обновление загрузчика composer

Эта команда устанавливает все заданные нами ранее зависимости (если они еще не установлены) и перегенерирует файл автозагрузки. После этого нужно в наших фронт-контроллерах убрать функции автозагрузки, которые мы ранее писали сами:

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . $className . '.php';
});

Убираем этот код из index.php и cli.php, после чего проверяем работоспособность сайта. Он по-прежнему работает, но теперь за автозагрузку функций отвечает код, сгенерированный композером.

composer.lock

Кроме того, в корне проекта появился еще один файл – composer.lock. Это очень важный файл – в нем содержится конкретная версия пакета, которая использовалась в момент выполнения команды composer require.

В дальнейшем, если мы будем выкладывать проект куда-либо, мы не будем выкладывать папку vendor – ведь достаточно выполнить composer install и она снова появится. Нет смысла гонять туда-сюда большую папку с библиотеками. Можем проверить прямо сейчас – удалите папку vendor и выполните команду:

composer install

скачивание пакетов

Библиотеки снова скачались. Так вот сейчас была установлена ровно та версия библиотеки, которая задана в composer.lock. Если библиотека обновится и авторы что-то в ней изменят (сломают), то нам это не страшно – мы будем использовать ровно ту версию, которую использовали при разработке. В дальнейшем вы столкнетесь с необходимостью в этом, когда будете выкладывать ваши рабочие проекты на боевые сервера – очень важно чтобы на боевом сервере, который приносит бизнесу деньги, вдруг не обновилась библиотека и всё не рухнуло.

Если же вы хотите обновить какую-то библиотеку – нужно просто выполнить команду:

composer update имя_библиотеки

Эта команда проверит наличие новой версии пакета, и если такой есть, то скачает и установит его. При этом также обновится и composer.lock.

Этот файл должен всегда сопровождаться вместе с вашим приложением. Он – гарантия того, что приложение на сервере будет работать с теми же пакетами, которые вы использовали, когда писали код. Он должен быть и в репозитории git вместе с проектом, и на сервере, и у разработчика. Проект и composer.lock должны всегда находиться рядом.

В заключение скажу - изучайте официальную документацию Composer-а и привыкайте использовать его - в реальных проектах он использует

Взаимодействие сервисов и REST API

В современной веб-разработке принято разделять backend-разработку (то, что выполняется на сервере – например, приложение на PHP) от frontend-разработки (то, что выполняется в браузере пользователя – JavaScript). Frontend выполняет запросы на backend и отрисовывает данные, которые backend ему возвращает. Но каким образом происходит этот обмен? Чем они обмениваются? Как выглядят данные, которые передаются между бэкендом и фронтендом? Об этом и пойдёт речь в данном уроке.

JSON

В уроке про composer мы с вами уже сталкивались с форматом JSON. И я вам в том уроке советовал погуглить об этом формате. Еще не сделали этого? Тогда сейчас – самое время.

Вжух!

Итак, вы уже знаете о формате JSON. Так вот, этот формат – это номер 1 среди форматов для обмена между современными приложениями. При этом бэкенд, который обменивается с клиентом в формате JSON, называется API (англ. application programming interface - программный интерфейс приложения). API принимает в качестве запроса JSON и отвечает тоже JSON-ом. Ну, точнее, не всегда именно JSON-ом, он может работать и в другом формате – XML, например. Но вся суть API в том, что он работает не с HTML, который красиво рендерится в браузере и приятен для восприятия человеком. API работает в формате, с которым удобно работать другим программам. Одна программа передаёт JSON в API, и получает от него ответ в формате JSON.

Пишем API

В этом уроке мы с вами напишем простейшее API для работы со статьями.

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

Создаём в папке www папку api. А внутри нее – файл .htaccess:

www/api/.htaccess

RewriteEngine On

RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f

RewriteRule ^(.*)$ ./index.php?route=$1 [QSA,L]

И рядом с ним файл index.php

www/api/index.php

<?php
echo 123;

Проверяем, что всё работает, перейдя по адресу: http://myproject.loc/api/

Ответ API

Теперь попробуем вывести что-нибудь в формате json.

В PHP есть встроенные функции для работы с json. Нас будут интересовать прежде всего две: json_encode() и json_decode(). Первая позволяет представить какую-то сущность в json-формате.

www/api/index.php

<?php

$entity = [
    'kek' => 'cheburek',
    'lol' => [
        'foo' => 'bar'
    ]
];

echo json_encode($entity);

Обновим страничку и увидим следующее:

Ответ API JSON

Кроме того, когда сервер отвечает в фомате JSON, стоит отправлять соответствующий заголовок клиенту:

www/api/index.php

<?php

require __DIR__ . '/../../vendor/autoload.php';

$entity = [
    'kek' => 'cheburek',
    'lol' => [
        'foo' => 'bar'
    ]
];

header('Content-type: application/json; charset=utf-8');
echo json_encode($entity);

Теперь поставьте в свой браузер расширение JSON formatter.

И снова обновите страничку. Вы увидите, что ответ сервера стало гораздо проще читать – это расширение добавляет форматирование, чтобы ответ было легче воспринимать человеку.

Форматированный JSON

Теперь давайте сделаем наш API в ООП-стиле. Мы будем использовать ту же архитектуру MVC, в которой компонент View вместо рендеринга HTML-шаблонов будет выводить JSON. Давайте сделаем у View метод для вывода JSON-а.

src/MyProject/View/View.php

public function displayJson($data, int $code = 200)
{
    header('Content-type: application/json; charset=utf-8');
    http_response_code($code);
    echo json_encode($data);
}

Теперь создадим контроллер, который позволит работать со статьями через API. Создаём сначала папку Api внутри Controllers, а затем добавляем наш новый контроллер:

src/MyProject/Controllers/Api/ArticlesApiController.php

<?php

namespace MyProject\Controllers\Api;

use MyProject\Controllers\AbstractController;
use MyProject\Exceptions\NotFoundException;
use MyProject\Models\Articles\Article;

class ArticlesApiController extends AbstractController
{
    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            throw new NotFoundException();
        }

        $this->view->displayJson([
            'articles' => [$article]
        ]);
    }
}

Теперь создаём отдельный роутинг для API:

src/routes_api.php

<?php

return [
    '~^articles/(\d+)$~' => [\MyProject\Controllers\Api\ArticlesApiController::class, 'view'],
];

И, наконец, пишем фронт-контроллер для API.

www/api/index.php

<?php

require __DIR__ . '/../../vendor/autoload.php';

try {
    $route = $_GET['route'] ?? '';
    $routes = require __DIR__ . '/../../src/routes_api.php';

    $isRouteFound = false;
    foreach ($routes as $pattern => $controllerAndAction) {
        preg_match($pattern, $route, $matches);
        if (!empty($matches)) {
            $isRouteFound = true;
            break;
        }
    }

    if (!$isRouteFound) {
        throw new \MyProject\Exceptions\NotFoundException('Route not found');
    }

    unset($matches[0]);

    $controllerName = $controllerAndAction[0];
    $actionName = $controllerAndAction[1];

    $controller = new $controllerName();
    $controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->displayJson(['error' => $e->getMessage()], 500);
} catch (\MyProject\Exceptions\NotFoundException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->displayJson(['error' => $e->getMessage()], 404);
} catch (\MyProject\Exceptions\UnauthorizedException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->displayJson(['error' => $e->getMessage()], 401);
}

Всё, теперь можно зайти на наш API и проверить как выводится статья: http://myproject.loc/api/articles/1

Статья но не вся

Но вот незадача – вместо полей статьи мы видим только две фигурные скобки - {}. А всё потому, что функция json_encode не умеет преобразовывать в JSON объекты. Однако, можно её «научить». Для этого нужно чтобы класс реализовывал специальный интерфейс – JsonSerializable и содержал метод jsonSerialize(). Этот метод должен возвращать представление объекта в виде массива. Я предлагаю сделать такой метод на уровне ActiveRecordEntity, чтобы все его наследники автоматически могли преобразовываться в JSON.

Добавляем реализацию интерфейса:

src/MyProject/Models/ActiveRecordEntity.php

abstract class ActiveRecordEntity implements \JsonSerializable

и добавляем метод, который представит объект в виде массива:

public function jsonSerialize()
{
    return $this->mapPropertiesToDbFormat();
}

Обновляем страничку http://myproject.loc/api/articles/1 и вуаля - статья в JSON-формате!

Статья в формате JSON

Postman

Но что, если мы захотим изменить нашу статью с помощью API? Для этого нам нужно отправить в API запрос в формате JSON. В реальном приложении для этого используется фронтенд на JS. А в целях разработки – специальные инструменты, позволяющие отпралять такие запросы. Одним из таких инструментов является приложение Postman. Скачайте, установите и запустите.

В контроллере добавим еще один метод:

src/MyProject/Controllers/Api/ArticlesApiController.php

public function add()
{
    $input = json_decode(
        file_get_contents('php://input'),
        true
    );
    var_dump($input);
}

Здесь php://input – это входной поток данных. Именно из него мы и будем получать JSON из запроса. file_get_contents – читает данные из указанного места, в нашем случае из входного потока. А json_decode декодирует json в структуру массива. После чего мы просто выводим массив с помощью var_dump().

Добавляем для него роут:

src/routes_api.php

<?php

return [
    '~^articles/(\d+)$~' => [\MyProject\Controllers\Api\ArticlesApiController::class, 'view'],
    '~^articles/add$~' => [\MyProject\Controllers\Api\ArticlesApiController::class, 'add'],
];

И заполняем Postman данными, как на скриншоте:
Postman

После этого жмём кнопку Send. Прокручиваем ниже до ответа и выбираем вкладку Preview.
Ответ API

Тут мы видим вывод var_dump той структуры, которую мы отправили в POST-запросе в формате JSON.
Давайте вынесем функционал чтения входных данных в абстрактный контроллер:

src/MyProject/Controllers/AbstractController.php

protected function getInputData()
{
    return json_decode(
        file_get_contents('php://input'),
        true
    );
}

И теперь во всех контроллерах мы сможем получать входные данные вот так:

src/MyProject/Controllers/Api/ArticlesApiController.php

public function add()
{
    $input = $this->getInputData();
    var_dump($input);
}

Давайте теперь сделаем функционал, который позволит сохрянять в базу данных статью, пришедшую в формате JSON.

src/MyProject/Controllers/Api/ArticlesApiController.php

public function add()
{
    $input = $this->getInputData();
    $articleFromRequest = $input['articles'][0];

    $authorId = $articleFromRequest['author_id'];
    $author = User::getById($authorId);

    $article = Article::createFromArray($articleFromRequest, $author);
    $article->save();

    header('Location: /api/articles/' . $article->getId(), true, 302);
}

Разумеется, здесь также стоит добавить авторизацию и проверять, является ли авторизованный пользователь тем, кто указан в авторе статьи. Но это учебный и упрощенный пример, который показывает сам принцип работы с JSON-API.

Снова возвращаемся в Postman и повторно жмем Send.

Прокручиваем вниз до ответа, но на этот раз переходим во вкладку Pretty.
Ответ API в формате JSON

Как видим, статья успешно добавилась и выводится в формате JSON по адресу http://myproject.loc/api/articles/id_статьи.

REST API

То что мы сейчас с вами написали – это простейший учебный пример API. Есть более сложные системы для реализации API. Они позволяют привязывать роутинг к конкретному типу запроса. Например, POST-запрос по адресу http://myproject.loc/api/articles/1 вызовет в контроллере экшн update, который будет обновлять статью с id=1. А GET-запрос по тому же адресу будет вызывать экшн view, который будет просто возвращать статью.

То есть для одного и того же адреса мы отправляем разные типы запросов – POST, GET, PUT, DELETE. И в зависимости от типа запроса будут вызваны разные экшены. В рамках текущего курса мы этого делать не будем – ограничимся простым примером, чтобы вы просто понимали концепцию.

При этом структура запроса и ответа как правило одинаковые – мы можем посмотреть статью в формате JSON. Чтобы обновить её – мы тоже отправляем статью в формате JSON, с теми же полями.

Вот этот стиль взаимодействия с API в формате JSON, когда мы используем одну и ту же структуру данных для запроса и ответа, и используем разные типы запросов для разных действий – называется REST API. Запомните это, об этом могут спросить на собеседовании: «Что такое REST API». И вы скажете, что это когда:

  1. Запрос и ответ имеют одинаковую структуру
  2. Используются разные типы запросов (GET, POST, PUT, DELETE и другие).
  3. Используется формат, с которым удобно работать другим программам (чаще всего JSON, но могут быть и другие – например, XML).

Заключение

Стоит отметить, что API используется не только для взаимодействия между фронтендом и бэкендом, но еще и для взаимодействия между разными сервисами на бэкенде. В одном проекте может быть несколько приложений на бэкенде, которые общаются между собой по API. Один сервис отправляет в другой сервис сообщение в JSON-формате. Тот его принимает и преобразует JSON в данные для работы.

Конечно, тут все зависит от компании – где-то вообще не используют API и рендерят HTML-шаблоны, а где-то наоборот – на бэкенде ни одного HTML-тега. В любом случае, основы HTML вы уже знаете, а большего вам, как бэкендеру, о фронтенде и знать ничего не нужно. Многие когда начинают проходить мои курсы спрашивают - а будет ли курс по CSS. И я отвечаю - нет. Большую часть работы вы будете писать код на PHP, скорее всего разрабатывая API и вообще не касаясь фронтенда.

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

3 Likes