## PSR-7 фреймворк урок 1 из 7 "Структура и работа с HTTP" ### Общие принципы фреймворков, их типы и основное отличие от библиотек (компонентов) Сегодня мы начинаем серию уроков по написанию **компонентного http-фреймворка**. Для чего это нам может быть нужно? Чтобы хорошо понять и изучить какую-то сложную архитектуру, необходимо пройти всю эволюцию развития такого рода продукта. И только пройдя этот же путь (что прошел автор) можно осознать и понять многие глубинные вещи таких архитектур. Мы досконально разберемся в устройстве всех современных фреймворков и микрофреймворков. Для начала разберемся с **понятием фреймворк** и его **отличием от такого понятия, как библиотека**. Например, возьмем какой-то абстрактный проект, использующий две библиотеки (это могут быть библиотеки маршрутизатора и шаблонизатора). Таким образом, получается наш проект зависит от этих двух библиотек. Они находятся как бы внутри него. А **фреймворк – это когда мы создаем проект и он будет работать как бы внутри этого фреймворка**. То есть как бы ситуация наоборот – наизнанку. **Фреймворк определяет более высокоуровневую абстракцию**. И фреймворки обычно задают свои правила по структуре проекта, по его архитектуре и также определяют места куда мы можем вставлять наш код (писать логику нашего приложения). Разрабатывая свои классы (свою логику) мы как бы опираемся на какие-то базовые классы самого фреймворка. Например, создавая свой контроллер мы можем наследоваться от базового класса – Controller. Или создавая класс модели, мы наследуемся от базового класса - Model. **Фреймворки предоставляют нам некую экосистему (некую конституцию, свод правил) придерживаясь которой мы строим свой проект**. Обычно у всех современных фреймворков сам исходный код не навязывает какую-то определенную архитектуру по построению проектов. Но эти фреймворки обычно содержат свой **skeleton-приложения**. Некая отправная точка по построению наших проектов. И вот она уже содержит и архитектуру и структуру (философию), которой можно либо жестко придерживаться, либо видоизменять частично или полностью. Фреймворки обычно подразделяются на **монолитные – это когда практически нельзя поменять ни архитектуру проекта, ни добавить или подменить какую-либо функциональную часть** (заменить тот же шаблонизатор или еще что-то такое) и **гибкие (компонентные) – это когда можно изменить в любое время и архитектуру проекта и заменить (подменить) какую-либо его часть функционал**. ### Кратко о развитии языка javascript Абстрагируемся и кратко взглянем на **эволюцию языка javascript**. Когда этот язык только создавался, то максимально на что его хватало – это сделать какую-то простенькую динамику на сайте (вывести часики, навигационное выпадающее или еще какое меню и т.д.). В процессе развития появилась мощная библиотека – **jquery**. С помощью нее можно было строить динамичные полноценные приложения. Работать гибко с DOM-моделью документа, отправлять AJAX-запросы, делать анимации и многое другое. Впоследствии с помощью этого языка стало возможным разрабатывать не только клиентские вещи (изначально javascript был только клиентским языком), но и писать полноценные серверные приложения. Это стало возможным благодаря появлению технологии – **node-js**. Это также позволило развитию этого языка в сторону консольных приложений (те же сборщики, например, **gulp-js** & **webpack-js**). У javascript появился свой пакетный менеджер (ведь без этого инструмента в современной разработке сложно выстраивать и поддерживать архитектуру приложения) – **npm** (впоследствии появился аналог от Facebook’а **yarn**). **Благодаря всем этим технологиям язык javascript в последние годы сумел захватить немалую часть всей веб-разработки**. Т.е. язык сумел превратиться из "гадкого" утёнка в более менее красивого лебедя. ### Кратко о развитии языка php Примерно похожей эволюции придерживается и язык **php**. Вначале своего пути этот язык тоже задумывался и использовался, как мелкий скриптовый язык. Для генерации какой-то мелкой динамики на сайтах. Те же часики и что-то подобное. И название у него вначале было соответствующее – **personal home page tools** (т.е. по сути некий набор утилит для создания и поддержки персональных страничек). Но приобретя популярность язык вырос в полноценный язык на котором стали писать полноценные динамичные сайты. Это всевозможные гостевые книги, форумы, социальные сети и многое другое. И название языка сменило себя на более серьезное. Он стал называться, как **hypertext preprocessor** (дословно препроцессор гипертекста). С появлением четвертой версии (PHP 4) языка появилась возможность строить проекты с применением **парадигмы ООП** (**объектно-ориентированное программирование**). И только в 2004 году с появлением версии пятой языка стало возможным полноценно применять эту парадигму в своих проектах. В 2011 или 2012 году (только тогда) язык обзавелся своим пакетным менеджером – **composer**. И он позволил строить приложения (проекты), включающие в себя десятки (сотни и даже тысячи) зависимостей. И разворачивать такие проекты буквально одной командой. ### Опыт от "старших" братьев, социальный кодинг и пакетные менеджеры и их главное преимущество Резюмируя все вышесказанное можно сказать о том, что **языки javascript** & **php достаточно молодые языки**. Которые получили заслуженное призвание только в последние там 5-7 лет. И **появлению многих современных features** (того же **ООП**) **они обязаны своим старшим собратьям**. Тому же **c++**, **java** и многим другим серьезным и "старым" языкам программирования. Новички это воспринимают достаточно болезненно и считают, что из их привычного скриптового языка пытаются сделать какую-то пародию той же java. Но с этим надо смирится и как-то научиться жить! Социальный кодинг так называемый пришел в эти языки благодаря развитию **git** & **github.com**. И если раньше своим кодом делились мало и только в рамках каких-то архивов немногочисленных. То в последние годы **появилось очень много open source решений** (**библиотек**). И это стало возможным благодаря мощному развитию этих технологий. **Главные плюсы этого подхода стало – удобное выкладывание (sharing) своего кода и совместная поддержка таких библиотек**. Но вручную дергать с того же github’а библиотеки и встраивать их в свои проекты не очень то и удобно. Это все рано или поздно разработчикам надоело. Плюс надо было бороться с возникающими конфликтными ситуациями между совместимостью разных (не связанных) компонентов (библиотек). И именно эта проблема послужила главным "двигателем" к **появлению пакетных менеджеров** (**npm**/**yarn** & **composer**). Эти инструменты позволяли автоматически разруливать зависимости, бороться с конфликтными ситуациями и прочими вещами. Все это также наложило некоторые требования к осмыслению привычного процесса разработки. Особенно у тех разработчиков, которые начинали с 2004 и более раннего времени разрабатывать проекты на том же языке php. Здесь **потребуется немного изменить привычный образ мышления разработчика**. ### Абстрактный и реальный отличный примеры, когда новые подходы сильно помогают при разработке и это в свою очередь сказывается положительно на конечных результатах И таким образом, мы имеем следующую ситуацию. Некий разработчик написал свою библиотеку (или компонент) и выложил ее/его на github (или на **packagist’е – это хранилище библиотек пакетного менеджера composer**). Другой разработчик написал свой компонент и также разместил его в публичный доступ. А третий программист сел и написал свою библиотеку, использовав компоненты (библиотеки) двух предыдущих разработчиков. Т.е. он сделал зависимой свою библиотеку от других вещей. А четвертый разработчик вообще разработал свой фреймворк, применив в нем зависимости от второго и третьего программистов. ```html github.com/разработчик_1/библиотека_1 github.com/разработчик_2/библиотека_2 github.com/разработчик_3/библиотека_3 require: [ github.com/разработчик_1/библиотека_1, github.com/разработчик_2/библиотека_2 ] github.com/разработчик_4/фреймворк_4 require: [ github.com/разработчик_2/библиотека_2, github.com/разработчик_3/библиотека_3 require: [ github.com/разработчик_1/библиотека_1, github.com/разработчик_2/библиотека_2 ] ] ``` В итоге мы получаем такую **древовидную сеть с гибким переплетением компонентов между собой**. В одних проектах (ведь разрабатывая свой компонент или библиотеку мы по сути тоже работаем над созданием своего проекта или конечного продукта) мы используем один стек библиотек, а в других другой. Таким образом, получается, что каждый разработчик занимается построением логики только своего проекта. Не вдаваясь в детализацию алгоритмов "чужого" кода (т.е. кода от которого зависит его данный проект). Когда-то кто-то заморочился и написал **хорошие компоненты/библиотеки**: - логирование приложений на языке php ([**Seldaek/monolog**](https://github.com/Seldaek/monolog.git)) - клиентская библиотека http для языка php ([**guzzle/guzzle**](https://github.com/guzzle/guzzle)) На протяжении этого курса мы **научимся использовать "чужие" библиотеки** (внедрять их в наш проект) и также **научимся писать логику проекта так, чтобы когда мы его захотим выложить в публичный доступ им захотели воспользоваться другие** (и нам за это не было стыдно). ### Создание "точки входа" в приложение, понятия запрос-ответ сервера и отправка своих заголовков ответа клиенту С чего мы обычно начинаем? С создания файла – **index.php** (его еще **называют точкой входа в приложение**). ```php <?php echo 'Hello'; ``` Помимо этого мы можем передать в наш скрипт какие-то параметры. Например, имя пользователя (через **$_GET параметр**). ```php <?php $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` На самом деле под "капотом" этого простого действия скрыты два очень важных действия протокола http. Браузер (клиент) отправляет серверу **http запрос** (**http request**) на что сервер готовит и отдает ему **http ответ** (**http response**). Вот примерно следующее отправляет браузер (клиент) серверу: ```http Request Headers GET / HTTP/1.1 Host: localhost Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3 Accept-Encoding: gzip, deflate, br Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7 Cookie: PHPSESSID=1b7880531e556088778b75cc2e27a27a ``` На что сервер отвечает примерно следующим: ```http Response Headers HTTP/1.1 200 OK Server: nginx/1.15.12 Date: Fri, 09 Aug 2019 20:16:52 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive X-Powered-By: PHP/7.3.6 Content-Encoding: gzip ``` Мы в свою очередь можем отправить с сервера, используя опять же язык php свои **заголовки ответа** (**headers response**). Например, так: ```php <?php $name = $_GET['name'] ?: 'Guest'; header('X-Developer: Denis Kitaev'); echo 'Hello, ' . $name; ``` ```http Response Headers HTTP/1.1 200 OK Server: nginx/1.15.12 Date: Fri, 09 Aug 2019 20:20:28 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive X-Powered-By: PHP/7.3.6 X-Developer: Denis Kitaev Content-Encoding: gzip ``` **Важное замечание**! Главная задача любого веб-сервера – это принять запрос от клиента, как-то его проанализировать (обработать) и вернуть ответ обратно клиенту. ### Суперглобальные структуры данных и написание функции по определению языка клиента В языке php есть **суперглобальные массивы** ```php $_GET, $_POST, $_SERVER, $_SESSION, $_COOKIE, $_FILES, $_ENV ``` Например, мы хотим реализовать **функционал по определению языка у пользователя** в зависимости от разных условий ```php <?php session_start(); chdir(dirname(__DIR__)); ## Initializing the application /** * The function of determining the language of the client * * @param string $default * @return string */ function getLang($default = 'en') : string { if (!empty($_GET['lang'])) return $_GET['lang']; else if (!empty($_COOKIE['lang'])) return $_COOKIE['lang']; else if (!empty($_SESSION['lang'])) return $_SESSION['lang']; else if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) return substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2); else return $default; } ## Running the application $lang = getLang(); $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name . '! Your lang is "' . $lang . '"'; ``` **Важное замечание**! Функция удобная, но работает с суперглобальными массивами внутри себя. Т.е. функция не является "чистой" и лезет наружу. Это не хорошо! ### Рефакторинг функции в "чистую" **Сделаем функцию "чистой"** ```php <?php session_start(); chdir(dirname(__DIR__)); ## Initializing the application /** * The function of determining the language of the client * * @param $get * @param $cookie * @param $session * @param $server * @param string $default * @return string */ function getLang($get, $cookie, $session, $server, string $default = 'en') : string { if (!empty($get['lang'])) return $get['lang']; else if (!empty($cookie['lang'])) return $cookie['lang']; else if (!empty($session['lang'])) return $session['lang']; else if (!empty($server['HTTP_ACCEPT_LANGUAGE'])) return substr($server['HTTP_ACCEPT_LANGUAGE'], 0, 2); else return $default; } ## Running the application $lang = getLang($_GET, $_COOKIE, $_SESSION, $_SERVER); $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name . '! Your lang is "' . $lang . '"'; ``` **Важное замечание**! Преимущество "чистой" функции в том, что сколько ее не вызывай с одними и теми же параметрами, то результат будет всегда одинаковый. Такую функцию удобно тестировать, поскольку можно на вход ей подать любые "фейковые" данные (эмулирующие те же суперглобальные массивы) и все будет работать точно также. ### Рефакторим аргументы функции - many-to-one (по сути записываем в одну структуру - request) Но работать и постоянно "таскать" за собой эти суперглобальные массивы, вставляя их во всевозможные нужные места не очень то и удобно. **Что же делать в этом случае?** Можно создать массив (**$request**), в который "уложить" все суперглобальные массивы. Например, таким образом ```php <?php session_start(); chdir(dirname(__DIR__)); ## Initializing the application /** * The function of determining the language of the client * * @param array $request * @param string $default * @return string */ function getLang(array $request, string $default = 'en') : string { if (!empty($request['get']['lang'])) return $request['get']['lang']; else if (!empty($request['cookie']['lang'])) return $request['cookie']['lang']; else if (!empty($request['session']['lang'])) return $request['session']['lang']; else if (!empty($request['server']['HTTP_ACCEPT_LANGUAGE'])) return substr($request['server']['HTTP_ACCEPT_LANGUAGE'], 0, 2); else return $default; } ## Running the application $request = [ 'get' => $_GET, 'cookie' => $_COOKIE, 'session' => $_SESSION, 'server' => $_SERVER ]; $lang = getLang($request); $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name . '! Your lang is "' . $lang . '"'; ``` **Важное замечание**! Вроде бы все стало гораздо лучше, чем было до того. Но работать с ассоциативными массивами не очень то и безопасно. Поскольку можно допустить где-то опечатку, что-то забыть, не так обозвать и т.д. И логика работы программы нарушится, хотя синтаксически с точки зрения языка все будет правильно. ### Переписываем нашу функцию в объектно-ориентированную парадигму Потому мы можем написать такой класс (**class Request**) ```php <?php /** * * Class Request */ class Request { /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $_GET; } /** * Get array $_COOKIE * * @return array */ public function getCookiesData() : array { return $_COOKIE; } /** * Get array $_SESSION * * @return array */ public function getSessionsData() : array { return $_SESSION; } /** * Get array $_SERVER * @return array */ public function getServersData() : array { return $_SERVER; } } ``` И использовать его в нашей функции по определению языка ```php <?php session_start(); chdir(dirname(__DIR__)); ## Initializing the application /** * * Class Request */ class Request { /* ... */ } /** * The function of determining the language of the client * * @param Request $request * @param string $default * @return string */ function getLang(Request $request, string $default = 'en') : string { if (!empty($request->getQueriesParams()['lang'])) return $request->getQueriesParams()['lang']; else if (!empty($request->getCookiesData()['lang'])) return $request->getCookiesData()['lang']; else if (!empty($request->getSessionsData()['lang'])) return $request->getSessionsData()['lang']; else if (!empty($request->getServersData()['HTTP_ACCEPT_LANGUAGE'])) return substr($request->getServersData()['HTTP_ACCEPT_LANGUAGE'], 0, 2); else return $default; } ## Running the application $lang = getLang(new Request()); $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name . '! Your lang is "' . $lang . '"'; ``` ### Перерабатываем нашу структуру приложения **Поработаем немного со структурой нашего приложения**. Вынесем в отдельную директорию исходники нашего будущего фреймворка ```php <?php session_start(); chdir(dirname(__DIR__)); ## Initializing the application require_once '/sources/Framework/Http/Request'; /** * The function of determining the language of the client * * @param Request $request * @param string $default * @return string */ function getLang(Request $request, string $default = 'en') : string { if (!empty($request->getQueriesParams()['lang'])) return $request->getQueriesParams()['lang']; else if (!empty($request->getCookiesData()['lang'])) return $request->getCookiesData()['lang']; else if (!empty($request->getSessionsData()['lang'])) return $request->getSessionsData()['lang']; else if (!empty($request->getServersData()['HTTP_ACCEPT_LANGUAGE'])) return substr($request->getServersData()['HTTP_ACCEPT_LANGUAGE'], 0, 2); else return $default; } ## Running the application $lang = getLang(new Request()); $name = $_GET['name'] ?: 'Guest'; echo 'Hello, ' . $name . '! Your lang is "' . $lang . '"'; ``` **Важное замечание**! Здесь мы определили корневую директорию нашего приложения, сделав ее рабочей. И вынесли класс запроса в иерархию исходников будущего нашего фреймворка. Пропишем **namespace** для класса **Request** ```php <?php namespace App\Framework\Http; /** * * Class Request */ class Request { /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $_GET; } /** * Get array $_COOKIE * * @return array */ public function getCookiesData() : array { return $_COOKIE; } /** * Get array $_SESSION * * @return array */ public function getSessionsData() : array { return $_SESSION; } /** * Get array $_SERVER * @return array */ public function getServersData() : array { return $_SERVER; } } ``` ### Рефакторим наш класс под основные методы запросов Существуют также **стандартные методы запроса** ```html GET, POST, PUT, PATCH, DELETE ``` **Важное замечание**! **GET** (обычно для получения каких-либо данных с сервера из какого-либо хранилища), **POST** (для создания новых записей в каком-то хранилище данных на сервере), **PUT**/**PATCH** (для обновления записей в каком-то хранилище на сервере) и **DELETE** (для удаления записей в каком-то хранилище на сервере). Причем запросы методами POST/PUT/PATCH/DELETE – могут приходить с различными post-параметрами. Согласно этому перепишем наш **класс Request** ```php <?php namespace App\Framework\Http; /** * * Class Request */ class Request { /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $_GET; } /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody() : array { return $_POST ?: null; } } ``` **Важное замечание**! Теперь наш класс запросов работает только с входными данными из запроса клиента. И метод **getParsedBody** у нас в будущем может заниматься распарсиванием каких-либо "сырых" данных. Например, это могут быть не только данные с форм, но и какие-нибудь json-объекты или xml-объекты. Они попадают в такой **псевдопротокол php** – **php://input**. Код точки входа (**entry point**) ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\Request; ## Initializing the application require_once '/sources/Framework/Http/Request.php'; $request = new Request(); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` В процессе нашей работы классов у нас будет достаточно много и потому необходимо организовать какую-либо удобную их автозагрузку. ### Работаем с файлом "composer.json", ставим полезную библиотеку и включаем сортировку зависимостей проекта Удобнее всего это сделать через файл – **composer.json** ```json { "config": { "sort-packages": true }, "require": { "php": "^7.3.0" } } ``` **Важное замечание**! В секции **require** мы обычно указываем необходимые зависимости (от каких библиотек зависит наше разрабатываемое приложение), версию минимальную языка php и его расширений различных. В секции **config** – **sort-packages** ставим в значение истины, что будет означать выстраивание по алфавиту всех зависимых библиотек в секции require. Подключим библиотеку – **roave/security-advisories** в ```bash $ composer require roave/security-advisories:dev-master ``` файл - composer.json ```json { "config": { "sort-packages": true }, "require": { "php": "^7.3.0", "roave/security-advisories": "dev-master" } } ``` **Важное замечание**! Эта **зависимость включит определенный список всех библиотек (фреймворков) в версиях которых обнаружены серьезные уязвимости безопасности**. И это поможет composer’у отслеживать версии таких пакетов, которые подтягиваются в наш проект и сигнализировать об этом. Эдакая **защита от случайного подтягивания старых версий и/или опасных версий каких-то библиотек**. ### Подключаем автозагрузку через composer наших исходных классов проекта Попробуем загрузить **файл автозагрузки composer** в наш проект ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\Request; ## Initializing the application require_once '/vendor/autoload.php'; $request = new Request(); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` И получим закономерную ошибку, что такой класс не найден ```html Fatal error: Uncaught Error: Class 'App\Framework\Http\Request' not found in /application/public/index.php on line 11 ``` **Важное замечание**! Почему так происходит? Потому, что ничего о нашей папке и внутренних ее директорий и файлов composer не знает мы ему об этом нигде ничего не сказали и не указали, где и как он должен это все искать и находить. Он ищет только то, что лежит внутри каталога "vendor" Используя **стандарт именования классов PSR-4**, пропишем **автозагрузку наших классов в файл composer.json** ```json { "config": { "sort-packages": true }, "require": { "php": "^7.3.0", "roave/security-advisories": "dev-master" }, "autoload": { "psr-4": { "App\\Framework\\": "sources/Framework/" } } } ``` **Важное замечание**! Обязательно необходимо выполнить команду ```bash $ composer dump-autoload ``` для перегенерации файлов автозагрузки composer. Внутри директории vendor в файле **autoload_psr4.php** мы увидим закэшированную нашу директорию ```php <?php // autoload_psr4.php @generated by Composer $vendorDir = dirname(dirname(__FILE__)); $baseDir = dirname($vendorDir); return array( 'App\\Framework\\' => array($baseDir . '/sources/Framework'), ); ``` **Важное замечание**! Это особенно **полезно для Yii-разработчиков**. Там как-то почему то не принято использовать такую конструкцию для автозагрузки файлов в composer-файле. ### Устанавливаем компонент для тестирования проекта - "phpunit" и создаем первый наш тестовый класс Теперь нам необходимо написанный наш первый **класс запросов** (**Request**) как-то **протестировать**. Или еще говорят на сленге программистов – **покрыть тестами**. Для этого установим фреймворк (библиотеку) **phpunit/phpunit** ```bash $ composer require phpunit/phpunit --dev ``` Создадим директорию, где будут храниться наши тесты (**application/tests**). И в корне нашего проекта добавим **конфигурационный файл** самого phpunit – **phpunit.xml.dist** ```xml <?xml version="1.0" encoding="utf-8" ?> <phpunit bootstrap="./vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" stopOnFailure="false"> <testsuites> <testsuite name="Test Suite"> <directory>./tests</directory> </testsuite> </testsuites> <filter> <whitelist processUncoveredFilesFromWhitelist="true"> <directory suffix=".php">./sources/</directory> </whitelist> </filter> </phpunit> ``` **Важное замечание**! Библиотека phpunit вначале будет искать **файл под названием – phpunit.xml** и если такой не найдется, то считает файл – **phpunit.xml.dist**. Обычно файл phpunit.xml является локальным файлом каждого разработчика (по аналогии с тем же файлом .env), работающего над проектом и создавая такой файл он может перебить глобальные настройки в файле phpunit.xml.dist А в **.gitignore** мы можем прописать такое сразу ```html vendor phpunit.xml ``` **Напишем первый тест** для нашего класса запросов ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\Request; use PHPUnit\Framework\TestCase; /** * Class RequestTest * @package Tests\Framework\Http */ class RequestTest extends TestCase { /** * Void testing */ public function testEmpty() : void { $_GET = $_POST = []; $request = new Request(); self::assertEquals([], $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing to get $_GET query parameters */ public function testQueryParams() : void { $_GET = $data = [ 'name' => 'Denis', 'age' => 35 ]; $_POST = []; $request = new Request(); self::assertEquals($data, $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing for $_POST query parameters */ public function testParsedBody() : void { $_GET = []; $_POST = $data = [ 'name' => 'Denis', 'age' => 35 ]; $request = new Request(); self::assertEquals([], $request->getQueriesParams()); self::assertEquals($data, $request->getParsedBody()); } } ``` ### Запускаем наши тесты Вызовем наши **тесты** командой ```bash $ vendor/bin/phpunit --colors=always ``` **Важное замечание**! Видим, что все тесты прошли успешно И можем запустить команду **покрытие тестами** нашего приложения ```bash $ vendor/bin/phpunit --colors=always --coverage-html tests/coverage ``` **Важное замечание**! Для того, чтобы это все сработало необходимо, чтобы в php было установлено **расширение xdebug**. А так мы видим, что у нас 100% покрытие тестами ### Рефакторим наш тестовый класс, вынося в базовый метод - "setUp" инициализацию тестовых данных Мы также можем переопределить **стандартный метод библиотеки phpunit** и не в каждом методе тестовом переопределять суперглобальные переменные, а задать их в одном месте. Например, так, используя **метод setUp** ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\Request; use PHPUnit\Framework\TestCase; /** * Class RequestTest * @package Tests\Framework\Http */ class RequestTest extends TestCase { /** * Standard method for initializing the logic under test */ public function setUp() : void { parent::setUp(); $_GET = $_POST = []; } /** * Void testing */ public function testEmpty() : void { $request = new Request(); self::assertEquals([], $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing to get $_GET query parameters */ public function testQueryParams() : void { $_GET = $data = [ 'name' => 'Denis', 'age' => 35 ]; $request = new Request(); self::assertEquals($data, $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing for $_POST query parameters */ public function testParsedBody() : void { $_POST = $data = [ 'name' => 'Denis', 'age' => 35 ]; $request = new Request(); self::assertEquals([], $request->getQueriesParams()); self::assertEquals($data, $request->getParsedBody()); } } ``` ### Разбираемся с флагом "--dev" нашего пакетного менеджера **Важное замечание**! Мы установили библиотеку phpunit, использовав **флаг "--dev"** и получили этот компонент **в секции "require-dev"**. Что логично, поскольку процесс тестирования обычно нужен только на этапе разработки приложения. Но, если мы запустим команду ```bash $ composer install ``` то у нас установятся все зависимости и из **секции "require"** и из **секции "require-dev"**. Как же быть? На боевом production-сервере мы можем при разворачивании зависимостей использовать **флаг "--no-dev"**, т.е. полная команда будет ```bash $ composer install –no-dev ``` ### Выносим запуск тестов в скрипты composer или в отдельную консольную команду UNIX **Важное замечание**! Чтобы не писать всякий раз длинные команды на запуск тех же тестов **"vendor/bin/phpunit"**. Можно занести их либо в специальный **скриптовый файл "makefile"** ```makefile phpunit: vendor/bin/phpunit --colors=always phpunit-coverage: vendor/bin/phpunit --colors=always --coverage-html tests/coverage ``` либо же **в секцию "scripts" самого файла "composer.json"** ```json { "config": { "sort-packages": true }, "require": { "php": "^7.3.0", "roave/security-advisories": "dev-master" }, "autoload": { "psr-4": { "App\\Framework\\": "sources/Framework/" } }, "require-dev": { "phpunit/phpunit": "^8.3" }, "scripts": { "tests": "vendor/bin/phpunit --colors=always", "tests-coverage": "vendor/bin/phpunit --colors=always --coverage-html tests/coverage", "tests-all": "composer tests && composer tests-coverage" } } ``` **Важное замечание**! Флаг "--colors=always" указали для раскраски вывода результата тестов. И запустив команду ```bash $ composer tests ``` убедимся, что тесты также проходят успешно ### Разбираемся с текущими "слабыми" местами нашего класса запросов. И почему важно разрабатывая приложение использовать парадигму TTD (test driven development) Главная проблема нашего класса, что он работает напрямую с глобальными (суперглобальными) переменными. А это значит, что создав два инстанса такого класса мы не получим два независимых от внешнего мира объекта. Поскольку они оба будут иметь дело с сущностью, находящуюся извне. Такие объекты невозможно нормально тестировать и они легко поддаются ошибкам. Можем поменять что-то в одном и что-то может нарушиться в другом. Это невозможно практически контролировать. **Важное замечание**! Это так получилось отчасти от того, что вначале мы написали класс (реализующий какую-то сущность), а после стали его тестировать. Если бы сделали наоборот, то такой проблемы бы в 99% случаев не получили. **Важное замечание**! Когда мы работаем с какой-то сущностью, как с "черным" ящиком, то такой код проще поддается тестированию и невозможно получить конфликтующий код (когда одна сущность что-то поменяла снаружи и другая сущность перестала функционировать). Т.е. аналогия с "чистой" функцией – мы передали что-то на вход и она только с этим работает внутри себя что-то там делает и возвращает итоговый результат. Такие **инстансы классов называются "чистыми"** ### Инкапсулируем внутри нашего класса так называемые данные с которыми работает наша сущность - Request Давайте, следуя этому принципу перепишем наш класс **Request** и тестирующий его класс **RequestTest** тоже ```php <?php namespace App\Framework\Http; /** * * Class Request */ class Request { private $_queryParams = []; private $_parsedBody = null; /** * Request constructor. * @param array $queryParams * @param array|null $parsedBody */ public function __construct(array $queryParams = [], $parsedBody = null) { $this->_queryParams = (array)$queryParams; $this->_parsedBody = (array)$parsedBody; } /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $this->_queryParams; } /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody() : array { return $this->_parsedBody ?: null; } } ``` ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\Request; use PHPUnit\Framework\TestCase; /** * Class RequestTest * @package Tests\Framework\Http */ class RequestTest extends TestCase { /** * Void testing */ public function testEmpty() : void { $request = new Request(); self::assertEquals([], $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing to get $_GET query parameters */ public function testQueryParams() : void { $request = new Request($data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals($data, $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Testing for $_POST query parameters */ public function testParsedBody() : void { $request = new Request([], $data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals([], $request->getQueriesParams()); self::assertEquals($data, $request->getParsedBody()); } } ``` И еще изменим нашу **"точку входа"** (**entry point**) в приложение ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\Request; ## Initializing the application require_once '/vendor/autoload.php'; $request = new Request($_GET, $_POST); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` **Важное замечание**! И получается, что только из этого файла мы работаем и передаем переменные суперглобальные - $_GET & $_POST. Нигде больше в другом месте мы этого не делаем. И это хороший подход именно так делать! ### Выводим наш класс запросов из состояния "read-only". Добавляем ему "сеттеры" (setters) **Важное замечание**! Таким образом, наш класс запросов стал полностью универсальным и его можно использовать хоть в нашем приложении, хоть в каком-нибудь другом "чужом". Хоть для эмуляции чего-либо. Ему это все равно, поскольку что ему передадим, с тем он и будет работать. Единственное те данные, что мы ему передаем они остаются внутри него только для чтения. Иногда это не очень удобно. Потому принято в таких случаях добавлять некоторые так **называемые "сеттеры"** Потому перепишем наш класс запросов, **создав необходимые "сеттеры"** ```php <?php namespace App\Framework\Http; /** * * Class Request */ class Request { private $_queryParams = []; private $_parsedBody = null; /** * Request constructor. * @param array $queryParams * @param array|null $parsedBody */ public function __construct(array $queryParams = [], $parsedBody = null) { $this->_queryParams = (array)$queryParams; $this->_parsedBody = (array)$parsedBody; } /** * To add a new $_GET query parameters * * @param array $queryParams * @return self */ public function withQueryParams(array $queryParams) : self { $this->_queryParams = (array)$queryParams; return $this; } /** * To add new $_POST query parameters * * @param array $data * @return self */ public function withParsedBody(array $data) : self { $this->_parsedBody = (array)$data; return $this; } /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $this->_queryParams; } /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody() : array { return $this->_parsedBody ?: null; } } ``` **Важное замечание**! По сути можно было бы убрать из этого класса конструктор. В нем особой необходимости уже нет. Разве что для удобства, когда создаем новый инстанс, то сразу можем передать и необходимые ему данные во внутрь. Но сейчас у нас эту **роль выполняют методы-мутаторы**. Так называют методы, которые что-то изменяют внутри объекта. Как-то изменяют объект. И **"точку входа"** (**entry point**) тоже можно слегка изменить ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\Request; ## Initializing the application require_once '/vendor/autoload.php'; $request = (new Request()) ->withQueryParams($_GET) ->withParsedBody($_POST); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` И даже тут есть небольшая погрешность, что при "неправильном" понимании копирования инстансов у классов в языке php можно наскрести кучу проблем. ### Правильное копирование инстансов в языке php Вот, как следует **правильно копировать инстансы в языке php** ```php <?php $request_1 = new Request(); $request_2 = clone $request_1; $request_2->withQueryParams([ 'lang' => 'ru' ]); echo getLang($request_1, 'en'); // en echo getLang($request_2, 'en'); // ru ``` **Важное замечание**! Тут при верном клонировании объектов мы получаем закономерный результат. Стоит помнить, что обычные **скалярные величины в языке php копируются по значению**, нежели сложные (объектные). ### Разбираемся с понятием "иммутабельных" объектов Есть еще такое понятие, как **"иммутабельный объект"**. Которые не меняются внутри себя. Например, так ```php <?php $request_1 = new Request(); $request_2 = clone $request_1; $request_2 = $request_1->withQueryParams([ 'lang' => 'ru' ]); echo getLang($request_1, 'en'); // en echo getLang($request_2, 'en'); // ru ``` **Важное замечание**! Такого рода объекты они с чем инстанцировались, с тем и работают всегда. У них всегда одинаковое состояние. Свое **внутреннее состояние они не меняют**. ### Отрефакторим наш класс запросов, сделав его "иммутабельным" Следуя этой логике сделаем наш класс **Request "иммутабельным"** ```php <?php namespace App\Framework\Http; /** * * Class Request */ class Request { private $_queryParams = []; private $_parsedBody = null; /** * Request constructor. * @param array $queryParams * @param array|null $parsedBody */ public function __construct(array $queryParams = [], $parsedBody = null) { $this->_queryParams = (array)$queryParams; $this->_parsedBody = (array)$parsedBody; } /** * To add a new $_GET query parameters * * @param array $queryParams * @return self */ public function withQueryParams(array $queryParams = []) : self { $newInstance = clone $this; $newInstance->_queryParams = (array)$queryParams; return $newInstance; } /** * To add new $_POST query parameters * * @param array|null $data * @return self */ public function withParsedBody($data = null) : self { $newInstance = clone $this; $newInstance->_parsedBody = (array)$data; return $newInstance; } /** * Get array $_GET * * @return array */ public function getQueriesParams() : array { return $this->_queryParams; } /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody() { return $this->_parsedBody ?: null; } } ``` И теперь у нашей **"точки входа"** вместо одного объекта по цепочке идет **порождение трёх инстансов класса Request**. Например, визуально это можно изобразить так ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\Request; ## Initializing the application require_once '/vendor/autoload.php'; $request_1 = new Request(); $request_2 = $request_1->withQueryParams($_GET); $request_3 = $request_2->withParsedBody($_POST); ## Running the application $name = $request_3->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` ### Каждый вызов "мутабельного" метода порождает новый инстанс **Важное замечание**! На каждом новом вызове мутабельного метода у нас **идет порождение нового инстанса класса**, который наследует своего предка из которого дергается мутабельный метод ```bash $request_1 => Request {#3 ▼ -_queryParams: [] -_parsedBody: null } ``` ```bash $request_2 => Request {#2 ▼ -_queryParams: array:2 [▼ "name" => "Denis" "lang" => "ru" ] -_parsedBody: null } ``` ```bash $request_3 => Request {#4 ▼ -_queryParams: array:2 [▼ "name" => "Denis" "lang" => "ru" ] -_parsedBody: [] } ``` **Важное замечание**! Ненавистниками "иммутабельных объектов" выступают "борцы за быстродействие кода". Но надо понимать, что в памяти хранится только по сути название объекта и значения его полей. ### Рефакторим наши тесты под "иммутабельность" Вследствие этого у нас изменятся тесты этого класса **RequestTest** ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\RequestFactory; use PHPUnit\Framework\TestCase; /** * Class RequestTest * @package Tests\Framework\Http */ class RequestTest extends TestCase { /** * Тестирование на пустоту */ public function testEmpty(): void { $request = RequestFactory::fromGlobals(); self::assertEquals([], $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Тестирование на получение GET-параметров запроса */ public function testQueryParams(): void { $request = RequestFactory::fromGlobals($data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals($data, $request->getQueriesParams()); self::assertNull($request->getParsedBody()); } /** * Тестирование на получение POST-параметров запроса */ public function testParsedBody(): void { $request = RequestFactory::fromGlobals([], $data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals([], $request->getQueriesParams()); self::assertEquals($data, $request->getParsedBody()); } } ``` ### Для удобства инстанцирования класса может создать класс-фабрику - RequestFactory Но передавать в инстанс класса запросов каждый суперглобальный массив (там помимо $_GET, $_POST могут быть еще $_SESSION, $_COOKIE и многие другие). Потому можно создать **класс-фабрику RequestFactory** ```php <?php namespace App\Framework\Http; /** * Class RequestFactory * @package App\Framework\Http */ class RequestFactory { /** * Static method for obtaining superglobal arrays * * @param array $queryParams * @param array|null $parsedBody * @return Request */ public static function fromGlobals(array $queryParams = [], $parsedBody = null) : Request { return (new Request()) ->withQueryParams($queryParams ?: $_GET) ->withParsedBody($parsedBody ?: $_POST); } } ``` Изменим нашу **"точку входа"** (**entry point**) ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\RequestFactory; ## Initializing the application require_once 'vendor/autoload.php'; $request = RequestFactory::fromGlobals(); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; echo 'Hello, ' . $name; ``` **Важное замечание**! Теперь, если мы передадим что-то во внутрь нашей фабрики (RequestFactory::fromGlobals(...)), то у нас с этим будет работать наш класс запросов. Если ничего не передадим, то будут по умолчанию суперглобальные массивы там. ### Работаем над ответом от сервера - **http response**. В случае, когда работаем через массивы данных Как мы уже знаем, сервер принимает запрос от клиента и что-то возвращает ему в ответ. В ответе мы получаем ни что иное, как заголовки и какое-то "тело" (контент). **Если бы мы работали с массивами** теми же, то у нас была бы примерно такая ситуация ```php <?php $request = [ 'queryParams' => $_GET, 'parsedBody' => $_POST ]; $response = $application->handleRequest($request); ``` **Важное замечание**! Сервер вернул бы нам некий ответ примерно такого содержания ```php <?php $response = [ 'headers' => [], 'body' => 'Hello, Guest' ]; ``` **Важное замечание**! И мы бы могли эту информацию (**$response**) как то вывести ```php <?php foreach ($response['headers'] as $name => $value) { header($name . ': ' . $value); } echo $response['body']; ``` **Важное замечание**! Вот с массивами мы работали бы таким образом. У нас бы был массив входных параметров - **$request** и через какой-нибудь класс того же контроллера или еще откуда-нибудь у нас бы возвращался массив выходных данных - **$response** ### Работаем над ответом от сервера - **http response**. В случае, когда работаем через парадигму ООП. Напишем класс ответа - **Response**, тестовый класс - **ResponseTest** и доработаем под новую логику нашу "точку входа" (**entry point**) ```php <?php namespace App\Framework\Http; /** * Class Response * @package App\Framework\Http */ class Response { private $_headers = []; private $_body = ''; private $_statusCode = 200; private $_reasonPhrase; public static $phrases = [ 200 => 'OK', 404 => 'Not Found' ]; /** * Response constructor. * @param string $body * @param int $statusCode */ public function __construct(string $body = '', int $statusCode = 200) { $this->_body = (string)$body ?: ''; $this->_statusCode = (int)$statusCode ?: 200; $this->_reasonPhrase = (string)self::$phrases[$this->_statusCode]; } /** * @param string $body * @return self */ public function withBody(string $body) : self { $newInstance = clone $this; $newInstance->_body = (string)$body; return $newInstance; } /** * @return string */ public function getBody() : string { return $this->_body; } /** * @return int */ public function getStatusCode() : int { return $this->_statusCode; } /** * @param int $statusCode * @return self */ public function withStatusCode(int $statusCode) : self { $newInstance = clone $this; $newInstance->_statusCode = (int)$statusCode; $newInstance->_reasonPhrase = (string)self::$phrases[$newInstance->_statusCode] ?: (string)self::$phrases[200]; return $newInstance; } /** * @return string */ public function getReasonPhrase() : string { return $this->_reasonPhrase ?: (string)self::$phrases[200]; } /** * @return array */ public function getHeaders() : array { return $this->_headers; } /** * @param $header * @return null */ public function getHeader($header) { return $this->_headers[$header] ?: null; } /** * @param $header * @return bool */ public function hasHeader($header) : bool { return isset($this->_headers[$header]); } /** * @param string $name * @param string $value * @return self */ public function withHeader(string $name, string $value) : self { $newInstance = clone $this; if ($newInstance->_headers[(string)$name]) unset($newInstance->_headers[(string)$name]); $newInstance->_headers[(string)$name] = (string)$value; return $newInstance; } } ``` ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\Response; use PHPUnit\Framework\TestCase; class ResponseTest extends TestCase { public function testEmpty() : void { $response = new Response($body = 'body content'); self::assertEquals($body, $response->getBody()); self::assertEquals(200, $response->getStatusCode()); self::assertEquals('OK', $response->getReasonPhrase()); } public function testNotFound() : void { $response = new Response($body = 'empty', $status = 404); self::assertEquals($body, $response->getBody()); self::assertEquals($status, $response->getStatusCode()); self::assertEquals('Not Found', $response->getReasonPhrase()); } public function testHeaders() : void { $response = (new Response()) ->withHeader($nameDeveloper = 'X-Developer', $valueDeveloper = 'Denis Kitaev'); ->withHeader($nameLanguage = 'X-Language', $valueLanguage = 'ru'); self::assertEquals([ $nameDeveloper => $valueDeveloper, $nameLanguage => $valueLanguage ], $response->getHeaders()); } } ``` ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\RequestFactory; use App\Framework\Http\Response; ## Initializing the application require_once 'vendor/autoload.php'; $request = RequestFactory::fromGlobals(); ## Running the application $name = $request->getQueriesParams()['name'] ?: 'Guest'; $response = (new Response('Hello, ' . $name)) ->withHeader('X-Developer', 'Denis Kitaev'); ## Sending header('HTTP/1.1 ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase()); foreach ($response->getHeaders() as $name => $value) { header($name . ': ' . $value); } echo $response->getBody(); ``` **Важное замечание**! При таком подходе мы прошли весь **цикл взаимодействия "клиент-сервер"**. Мы взяли некие входные данные их как-то сформировали и обработали - **результат положили в $request**. И на основе этих входных данных сформировали ответ обратно клиенту - **результат положили и вывели клиенту в $response** **Важное замечание**! Теперь мы можем отдать ответ в любом формате - будь то обычный ответ **headers+html**, или же **headers+json** и любой другой формат который захотим вернуть. ### Рефакторим наш код так, чтобы получить полноценный компонент (библиотеку) Попробуем сделать так, чтобы этим проектом мы могли с кем-нибудь поделиться. Сделаем из него свой компонент или библиотеку. **Что значит свой компонент или библиотека?** Например, у нас есть некий компонент (класс) по определению языка клиента ```php <?php class LanguageDetector { public function getLanguage(App\Framework\Http\Request $request, string $default = 'en') : string { return ...; } } ``` Мы его создали и теперь хотим использовать в своем проекте ```php <?php $request = new App\Framework\Http\Request(); $detector = new LanguageDetector(); echo $detector->getLanguage($request); ``` **Важное замечание**! При таком подходе этот класс ни у кого, кроме нас не заработает. Почему? Потому что у других разработчиков класс запросов может быть другой. А наш компонент понимает только наш класс запросов. Потому создадим некий **интерфейс** для этого дела ```php <?php class LanguageDetector { public function getLanguage(App\Framework\Http\ServerRequestInterface $request, string $default = 'en') : string { return ...; } } ``` **Важное замечание**! Почему именно **ServerRequestInterface** мы обозвали? Потому что запрос запросу рознь. И когда запрос приходит **от клиента** (например, **от браузера**), то помимо стандартных данных запроса в нем еще содержаться будут такие суперглобальные массивы, как **$_SERVER** & **$_SESSION**. А когда мы делаем **запрос посредством того же CURL**, то в таком запросе будут только **$_GET** & **$_POST**. И чтобы не путать две разные концепции мы и можем обозвать наш интерфейс запросов таким образом. И наш интерфейс запросов к серверу от клиента может выглядеть так ```php interface ServerRequestInterface { /** * To add a new $_GET query parameters * * @param array $queryParams * @return static */ public function withQueryParams(array $queryParams = []) : ServerRequestInterface; /** * To add new $_POST query parameters * * @param array|null $data * @return static */ public function withParsedBody($data = null) : ServerRequestInterface; /** * Get array $_GET * * @return array */ public function getQueriesParams() : array; /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody(); } ``` И теперь любой желающий может использовать наш компонент по определению языка клиента. Необходимо лишь **реализовать наш интерфейс**. Например, **написав какой-нибудь адаптер** под это дело ```php <?php $request = new OtherFramework\Request(); $detector = new LanguageDetector(); echo $detector->getLanguage(new RequestAdapter($request)); ``` **Важное замечание**! Таким образом сторонний адаптер будет как бы реализовывать наш интерфейс (**ServerRequestInterface**). А сам класс запросов теперь можно "подсунуть" абсолютно любой с любого фреймворка или еще откуда-то. ### Подключение к нашему проекту стандартных интерфейсов по стандарту PSR-7 - компонент **"psr/http-message"** **Важное замечание**! Таким образом, сообщество разработчиков в мире однажды сели и разработали общие интерфейсы запросов под всевозможные нужды. И заложили их в стандарт **PSR-7** И теперь, чтобы подключить этот компонент с общими едиными интерфейсами запросов. Надо написать команду ```bash $ composer require psr/http-message ``` **Важное замечание**! Прежде чем установить эту библиотеку **попробуем написать свои интерфейсы для наших классов запроса и ответа** ```php <?php namespace App\Framework\Http; /** * Interface ServerRequestInterface * @package App\Framework\Http */ interface ServerRequestInterface { /** * To add a new $_GET query parameters * * @param array $queryParams * @return static */ public function withQueryParams(array $queryParams = []) : ServerRequestInterface; /** * To add new $_POST query parameters * * @param array|null $data * @return static */ public function withParsedBody($data = null) : ServerRequestInterface; /** * Get array $_GET * * @return array */ public function getQueriesParams() : array; /** * Get request of the methods POST, PUT, PATCH & DELETE * * @return array|null */ public function getParsedBody(); } ``` ```php <?php namespace App\Framework\Http; /** * Interface ResponseInterface * @package App\Framework\Http */ interface ResponseInterface { /** * @param string $body * @return static */ public function withBody(string $body) : ResponseInterface; /** * @return string */ public function getBody() : string; /** * @return int */ public function getStatusCode() : int; /** * @param int $statusCode * @return static */ public function withStatusCode(int $statusCode) : ResponseInterface; /** * @return string */ public function getReasonPhrase() : string; /** * @return array */ public function getHeaders() : array; /** * @param $header * @return null */ public function getHeader($header); /** * @param $header * @return bool */ public function hasHeader($header) : bool; /** * @param string $name * @param string $value * @return static */ public function withHeader(string $name, string $value) : ResponseInterface; } ``` **Важное замечание**! И как мы увидим далее, написанные наши интерфейсы точная копия интерфейсов, содержащихся в компоненте - **psr/http-message**. Давайте в этом убедимся Установим их командой ```bash $ composer require psr/http-message ``` И вот, как они выглядят. **ServerRequestInterface** & **ResponseInterface** ```php <?php namespace Psr\Http\Message; /** * Representation of an incoming, server-side HTTP request. */ interface ServerRequestInterface extends RequestInterface { /** * Retrieve server parameters. * * @return array */ public function getServerParams(); /** * Retrieve cookies. * * @return array */ public function getCookieParams(); /** * Return an instance with the specified cookies. * * @param array $cookies Array of key/value pairs representing cookies. * @return static */ public function withCookieParams(array $cookies); /** * Retrieve query string arguments. * * @return array */ public function getQueryParams(); /** * Return an instance with the specified query string arguments. * * @param array $query Array of query string arguments, typically from * $_GET. * @return static */ public function withQueryParams(array $query); /** * Retrieve normalized file upload data. * * @return array An array tree of UploadedFileInterface instances; an empty * array MUST be returned if no data is present. */ public function getUploadedFiles(); /** * Create a new instance with the specified uploaded files. * * @param array $uploadedFiles An array tree of UploadedFileInterface instances. * @return static * @throws \InvalidArgumentException if an invalid structure is provided. */ public function withUploadedFiles(array $uploadedFiles); /** * Retrieve any parameters provided in the request body. * * @return null|array|object The deserialized body parameters, if any. * These will typically be an array or object. */ public function getParsedBody(); /** * Return an instance with the specified body parameters. * * @param null|array|object $data The deserialized body data. This will * typically be in an array or object. * @return static * @throws \InvalidArgumentException if an unsupported argument type is * provided. */ public function withParsedBody($data); /** * Retrieve attributes derived from the request. * * @return array Attributes derived from the request. */ public function getAttributes(); /** * Retrieve a single derived request attribute. * * @see getAttributes() * @param string $name The attribute name. * @param mixed $default Default value to return if the attribute does not exist. * @return mixed */ public function getAttribute($name, $default = null); /** * Return an instance with the specified derived request attribute. * * @see getAttributes() * @param string $name The attribute name. * @param mixed $value The value of the attribute. * @return static */ public function withAttribute($name, $value); /** * Return an instance that removes the specified derived request attribute. * * @see getAttributes() * @param string $name The attribute name. * @return static */ public function withoutAttribute($name); } ``` ```php <?php namespace Psr\Http\Message; /** * Representation of an outgoing, server-side response. */ interface ResponseInterface extends MessageInterface { /** * Gets the response status code. * * @return int Status code. */ public function getStatusCode(); /** * Return an instance with the specified status code and, optionally, reason phrase. * * @link http://tools.ietf.org/html/rfc7231#section-6 * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @param int $code The 3-digit integer result code to set. * @param string $reasonPhrase The reason phrase to use with the * provided status code; if none is provided, implementations MAY * use the defaults as suggested in the HTTP specification. * @return static * @throws \InvalidArgumentException For invalid status code arguments. */ public function withStatus($code, $reasonPhrase = ''); /** * Gets the response reason phrase associated with the status code. * * @link http://tools.ietf.org/html/rfc7231#section-6 * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @return string Reason phrase; must return an empty string if none present. */ public function getReasonPhrase(); } ``` **Важное замечание**! Теперь нам требуется немного переписать наш код наших классов. ```php <?php namespace App\Framework\Http; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; /** * Class Response * @package App\Framework\Http */ class Response implements ResponseInterface { private $_headers = []; private $_body; private $_statusCode = 200; private $_reasonPhrase; public static $phrases = [ 200 => 'OK', 404 => 'Not Found' ]; /** * Response constructor. * @param StreamInterface|string $body * @param int $statusCode */ public function __construct($body = '', int $statusCode = 200) { $this->_body = $body instanceof StreamInterface ?: new Stream((string)$body); $this->_statusCode = (int)$statusCode ?: 200; $this->_reasonPhrase = (string)self::$phrases[$this->_statusCode]; } /** * @param StreamInterface $body * @return self */ public function withBody(StreamInterface $body) : ResponseInterface { $newInstance = clone $this; $newInstance->_body = $body; return $newInstance; } /** * @return StreamInterface */ public function getBody() : StreamInterface { return $this->_body; } /** * @return int */ public function getStatusCode() : int { return $this->_statusCode; } /** * @param int $statusCode * @return self */ public function withStatusCode(int $statusCode) : ResponseInterface { $newInstance = clone $this; $newInstance->_statusCode = (int)$statusCode; $newInstance->_reasonPhrase = (string)self::$phrases[$newInstance->_statusCode] ?: (string)self::$phrases[200]; return $newInstance; } /** * @return string */ public function getReasonPhrase() : string { return $this->_reasonPhrase ?: (string)self::$phrases[200]; } /** * @return array */ public function getHeaders() : array { return $this->_headers; } /** * @param $header * @return null */ public function getHeader($header) { return $this->_headers[$header] ?: null; } /** * @param $header * @return bool */ public function hasHeader($header) : bool { return isset($this->_headers[$header]); } /** * @param string $name * @param string|string[] $value * @return self */ public function withHeader($name, $value) : ResponseInterface { $newInstance = clone $this; if ($newInstance->_headers[(string)$name]) unset($newInstance->_headers[(string)$name]); $newInstance->_headers[(string)$name] = (string)$value; return $newInstance; } /** * Retrieves the HTTP protocol version as a string. * * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0"). * * @return string HTTP protocol version. */ public function getProtocolVersion() { // TODO: Implement getProtocolVersion() method. } /** * Return an instance with the specified HTTP protocol version. * * The version string MUST contain only the HTTP version number (e.g., * "1.1", "1.0"). * * This method MUST be implemented in such a way as to retain the * immutability of the message, and MUST return an instance that has the * new protocol version. * * @param string $version HTTP protocol version * @return static */ public function withProtocolVersion($version) { // TODO: Implement withProtocolVersion() method. } /** * Retrieves a comma-separated string of the values for a single header. * * This method returns all of the header values of the given * case-insensitive header name as a string concatenated together using * a comma. * * NOTE: Not all header values may be appropriately represented using * comma concatenation. For such headers, use getHeader() instead * and supply your own delimiter when concatenating. * * If the header does not appear in the message, this method MUST return * an empty string. * * @param string $name Case-insensitive header field name. * @return string A string of values as provided for the given header * concatenated together using a comma. If the header does not appear in * the message, this method MUST return an empty string. */ public function getHeaderLine($name) { // TODO: Implement getHeaderLine() method. } /** * Return an instance with the specified header appended with the given value. * * @param string $name Case-insensitive header field name to add. * @param string|string[] $value Header value(s). * @return static */ public function withAddedHeader($name, $value) { $newInstance = clone $this; $newInstance->_headers[(string)$name] = array_merge($newInstance->_headers[$name], (array)$value); return $newInstance; } /** * Return an instance without the specified header. * * @param string $name Case-insensitive header field name to remove. * @return static */ public function withoutHeader($name) { $newInstance = clone $this; if ($newInstance->hasHeader((string)$name)) unset($newInstance->_headers[(string)$name]); return $newInstance; } /** * Return an instance with the specified status code and, optionally, reason phrase. * * @link http://tools.ietf.org/html/rfc7231#section-6 * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @param int $code The 3-digit integer result code to set. * @param string $reasonPhrase The reason phrase to use with the * provided status code; if none is provided, implementations MAY * use the defaults as suggested in the HTTP specification. * @return static * @throws \InvalidArgumentException For invalid status code arguments. */ public function withStatus($code, $reasonPhrase = '') { // TODO: Implement withStatus() method. } } ``` **Важное замечание**! Теперь у метода **withBody** класса **Response** передается не просто строка, а объект класса, реализующий интерфейс **StreamInterface**. Который помимо своего основного метода **getContents()** содержит кучу еще других вспомогательных методов. Которые позволяют перелистывать (как аудиокассету) туда-сюда контент инстанса. **Для чего это может быть нужно?** Ну, например, если у нас есть файл в несколько сотен мегабайт и будет странно его сразу считать целиком с сервера и вернуть клиенту. Потому он считывается последовательно порциями. В этом и есть суть интерфейса **StreamInterface** и класса, который его реализует. **Важное замечание**! Потому работа с потоками (streams) намного более универсальнее, чем простая работа со строками. Потоки могут быть любыми. Мы можем реализовать интерфейс **StreamInterface** в разных классах - это могут быть классы по работе с обычными строками, с файлами и чем-нибудь другим. И мы реализуем этот интерфейс для примера, написав такой класс - **Stream** ```php <?php namespace App\Framework\Http; use Psr\Http\Message\StreamInterface; /** * Class Stream * @package App\Framework\Http */ class Stream implements StreamInterface { private $_content = ''; /** * Stream constructor. * * @param string $content */ public function __construct(string $content) { $this->_content = (string)$content; } /** * Reads all data from the stream into a string, from the beginning to end. * * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring * @return string */ public function __toString() : string { return $this->getContents(); } /** * Closes the stream and any underlying resources. * * @return void */ public function close() { // TODO: Implement close() method. } /** * Separates any underlying resources from the stream. * * @return resource|null Underlying PHP stream, if any */ public function detach() { // TODO: Implement detach() method. } /** * Get the size of the stream if known. * * @return int|null Returns the size in bytes if known, or null if unknown. */ public function getSize() { return mb_strlen($this->_content); } /** * Returns the current position of the file read/write pointer * * @return int Position of the file pointer * @throws \RuntimeException on error. */ public function tell() { // TODO: Implement tell() method. } /** * Returns true if the stream is at the end of the stream. * * @return bool */ public function eof() { // TODO: Implement eof() method. } /** * Returns whether or not the stream is seekable. * * @return bool */ public function isSeekable() { // TODO: Implement isSeekable() method. } /** * Seek to a position in the stream. * * @link http://www.php.net/manual/en/function.fseek.php * @param int $offset Stream offset * @param int $whence Specifies how the cursor position will be calculated * based on the seek offset. Valid values are identical to the built-in * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to * offset bytes SEEK_CUR: Set position to current location plus offset * SEEK_END: Set position to end-of-stream plus offset. * @throws \RuntimeException on failure. */ public function seek($offset, $whence = SEEK_SET) { // TODO: Implement seek() method. } /** * Seek to the beginning of the stream. * * @throws \RuntimeException on failure. * @link http://www.php.net/manual/en/function.fseek.php * @see seek() */ public function rewind() { // TODO: Implement rewind() method. } /** * Returns whether or not the stream is writable. * * @return bool */ public function isWritable() { // TODO: Implement isWritable() method. } /** * Write data to the stream. * * @param string $string The string that is to be written. */ public function write($string) : void { $this->_content .= (string)$string; } /** * Returns whether or not the stream is readable. * * @return bool */ public function isReadable() { // TODO: Implement isReadable() method. } /** * Read data from the stream. * * @param int $length Read up to $length bytes from the object and return * them. Fewer than $length bytes may be returned if underlying stream * call returns fewer bytes. * @return string Returns the data read from the stream, or an empty string * if no bytes are available. * @throws \RuntimeException if an error occurs. */ public function read($length) { // TODO: Implement read() method. } /** * Returns the remaining contents in a string * * @return string */ public function getContents() : string { return $this->_content; } /** * Get stream metadata as an associative array or retrieve a specific key. * * @link http://php.net/manual/en/function.stream-get-meta-data.php * @param string $key Specific metadata to retrieve. * @return array|mixed|null Returns an associative array if no key is * provided. Returns a specific key value if a key is provided and the * value is found, or null if the key is not found. */ public function getMetadata($key = null) { // TODO: Implement getMetadata() method. } } ``` **Важное замечание**! Здесь мы в конструкторе будем получать некую строку (наш контент), можем получить длину всей строки - **getSize()**, получить целиком сам контент (все содержимое) - **getContents()** и можем что-то в случае чего дописать в наш поток - **write()** И соответственно в наших тестах класса **ResponseTest** тоже кое-что изменится ```php <?php namespace Tests\Framework\Http; use App\Framework\Http\Response; use App\Framework\Http\Stream; use PHPUnit\Framework\TestCase; class ResponseTest extends TestCase { public function testEmpty() : void { $response = new Response($body = 'body content'); self::assertEquals($body, $response->getBody()->getContents()); self::assertEquals(200, $response->getStatusCode()); self::assertEquals('OK', $response->getReasonPhrase()); } public function testNotFound() : void { $response = (new Response()) ->withBody(new Stream($body = 'empty')) ->withStatusCode($status = 404); self::assertEquals($body, $response->getBody()->getContents()); self::assertEquals($status, $response->getStatusCode()); self::assertEquals(Response::$phrases[$status], $response->getReasonPhrase()); } public function testHeaders() : void { $response = (new Response()) ->withHeader($nameDeveloper = 'X-Developer', $valueDeveloper = 'Denis Kitaev') ->withHeader($nameLanguage = 'X-Language', $valueLanguage = 'ru'); self::assertTrue($response->hasHeader($nameDeveloper)); self::assertEquals([ $nameDeveloper => $response->getHeader($nameDeveloper), $nameLanguage => $valueLanguage ], $response->getHeaders()); } } ``` **Важное замечание**! Мы будем теперь не просто получать через метод **getBody()** наше содержимое, а использовать внутренний метод нашего потокового класса **Stream::getContents()** или можем использовать магический метод языка - **Stream::__toString()**. Т.е. сделав так - **(string)$response->getBody()**, что в свою очередь также вызовет как раз-таки этот магический метод. **Важное замечание**! Вроде все хорошо, но чтобы полноценно реализовать в нашем проекте у классов запроса и ответа стандарт PSR-7 необходимо дописать реализацию еще многих методов. ### Внедрим в наш проект библиотеку **"zendframework/zend-diactoros"**, которая включает в себя полноценную реализацию классов запроса и ответа по стандарту PSR-7 Но мы этого делать не станем. Почему? Потому что уже умные люди все это реализовали за нас и для нас. Мы говорим о пакете **zendframework/zend-diactoros** ```bash $ composer require zendframework/zend-diactoros ``` **Важное замечание**! И эта библиотека добавит в наш проект реализацию интерфейсов **ServerRequestInterface** & **ResponseInterface**. Т.е. у нас будут свои полноценные классы в проекте запроса и ответа по стандарту PSR-7. **Важное замечание**! Мы уже будем вместо своего класса-фабрики запроса использовать класс **ServerRequestFactory**, а вместо своего класса ответа использовать класс **HtmlResponse** ```php <?php session_start(); chdir(dirname(__DIR__)); use Zend\Diactoros\ServerRequestFactory as RequestFactory; use Zend\Diactoros\Response\HtmlResponse as Response; ## Initializing the application require_once 'vendor/autoload.php'; $request = RequestFactory::fromGlobals(); ## Running the application $name = $request->getQueryParams()['name'] ?: 'Guest'; $response = (new Response('Hello, ' . $name)) ->withHeader('X-Developer', 'Denis Kitaev'); ## Sending header('HTTP/1.1 ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase()); foreach ($response->getHeaders() as $name => $values) { header($name . ': ' . implode(', ', $values)); } echo $response->getBody(); ``` **Важное замечание**! Наша точка входа теперь полностью переписана для работы с классами библиотеки **zendframework/zend-diactoros** И также отрефакторим наши тестовые классы ```php <?php namespace Tests\Framework\Http; use Zend\Diactoros\ServerRequestFactory as RequestFactory; use PHPUnit\Framework\TestCase; /** * Class RequestTest * @package Tests\Framework\Http */ class RequestTest extends TestCase { /** * Void testing */ public function testEmpty(): void { $request = RequestFactory::fromGlobals(); self::assertEquals([], $request->getQueryParams()); self::assertEquals([], $request->getParsedBody()); } /** * Testing to get request GET-parameters */ public function testQueryParams(): void { $request = (RequestFactory::fromGlobals()) ->withQueryParams($data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals($data, $request->getQueryParams()); self::assertEquals([], $request->getParsedBody()); } /** * Testing to obtain POST-parameters of the request */ public function testParsedBody(): void { $request = (RequestFactory::fromGlobals()) ->withParsedBody($data = [ 'name' => 'Denis', 'age' => 35 ]); self::assertEquals([], $request->getQueryParams()); self::assertEquals($data, $request->getParsedBody()); } } ``` ```php <?php namespace Tests\Framework\Http; use Zend\Diactoros\Response\HtmlResponse as Response; use PHPUnit\Framework\TestCase; class ResponseTest extends TestCase { public function testEmpty() : void { $response = new Response($body = 'body content'); self::assertEquals($body, $response->getBody()->getContents()); self::assertEquals(200, $response->getStatusCode()); self::assertEquals('OK', $response->getReasonPhrase()); } public function testNotFound() : void { $response = new Response($body = 'empty', $status = 404); self::assertEquals($body, $response->getBody()->getContents()); self::assertEquals($status, $response->getStatusCode()); self::assertEquals('Not Found', $response->getReasonPhrase()); } public function testHeaders() : void { $response = (new Response('')) ->withHeader($nameDeveloper = 'X-Developer', $valueDeveloper = 'Denis Kitaev') ->withHeader($nameLanguage = 'X-Language', $valueLanguage = 'ru'); self::assertTrue($response->hasHeader($nameDeveloper)); } } ``` **Важное замечание**! Мы использовали класс ответа **HtmlResponse**, но также в этом компоненте от компании Zend есть еще много реализаций ответов в различном формате - это и json-ответ (**JsonResponse**) и многие другие. ### Чистка проекта от наших реализаций классов запроса и ответа и тестов под них **Важное замечание**! Поскольку наш проект сейчас полностью взаимодействует с классами **библиотеки ZendDiactoros**, то вообще нет необходимости хранить наши классы запроса и ответа их интерфейсы и тестовые классы под все это дело. ### "Чистим" в нашей "точке входа" низкоуровневую реализацию логики ответа ```php <?php ... ## Sending header('HTTP/1.1 ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase()); foreach ($response->getHeaders() as $name => $values) { header($name . ': ' . implode(', ', $values)); } echo $response->getBody(); ``` ```php <?php ## Sending header(sprintf( 'HTTP/%s %d %s', $response->getProtocolVersion(), $response->getStatusCode(), $response->getReasonPhrase() )); foreach ($response->getHeaders() as $name => $values) { foreach ($values as $value) { header(sprintf('%s: %s', $name, $value), false); } } echo $response->getBody(); ``` **Важное замечание**! Попробуем инкапсулировать весь этот код (логику) в нашем отдельном классе **ResponseSender** ```php <?php namespace App\Framework\Http; use Psr\Http\Message\ResponseInterface; /** * Class ResponseSender * @package App\Framework\Http */ class ResponseSender { /** * @param ResponseInterface $response */ public function send(ResponseInterface $response) : void { header(sprintf( 'HTTP/%s %d %s', $response->getProtocolVersion(), $response->getStatusCode(), $response->getReasonPhrase() )); foreach ($response->getHeaders() as $name => $values) { foreach ($values as $value) { header(sprintf('%s: %s', $name, $value), false); } } echo $response->getBody()->getContents(); } } ``` **Важное замечание**! Теперь наш передатчик (**emitter**) класс **ResponseSender** может принимать на вход любой PSR-интерфейс и в зависимости от переданного инстанса выдавать в том или ином формате ответ клиенту. И теперь наша "точка входа" (**entry point**) выглядит так ```php <?php session_start(); chdir(dirname(__DIR__)); use App\Framework\Http\ResponseSender; use Zend\Diactoros\ServerRequestFactory as RequestFactory; use Zend\Diactoros\Response\HtmlResponse as Response; ## Initializing the application require_once 'vendor/autoload.php'; $request = RequestFactory::fromGlobals(); ## Running the application $name = $request->getQueryParams()['name'] ?: 'Guest'; $response = (new Response('Hello, ' . $name)) ->withHeader('X-Developer', 'Denis Kitaev'); ## Sending $emitter = new ResponseSender(); $emitter->send($response); ``` **Важное замечание**! Но когда мы установили библиотеку **ZendDiactoros**, то в ней уже есть удобный класс-передатчик. Потому мы можем использовать именно его. К сожалению с недавнего времени реализацию "передатчика" вынесли в отдельную библиотеку - **zendframework/zend-httphandlerrunner**. ### Установка библиотеки для использования "передатчика" (**emitter**) ответов - **"zendframework/zend-httphandlerrunner"** Установим ее следующей командой ```bash $ composer require zendframework/zend-httphandlerrunner ``` И используем ее в нашем проекте в **"entry point"** нашей ```php <?php session_start(); chdir(dirname(__DIR__)); use Zend\Diactoros\ServerRequestFactory as RequestFactory; use Zend\Diactoros\Response\HtmlResponse as Response; use Zend\HttpHandlerRunner\Emitter\SapiEmitter; ## Initializing the application require_once 'vendor/autoload.php'; $request = RequestFactory::fromGlobals(); ## Running the application $name = $request->getQueryParams()['name'] ?: 'Guest'; $response = (new Response('Hello, ' . $name)) ->withHeader('X-Developer', 'Denis Kitaev'); ## Sending $emitter = new SapiEmitter(); $emitter->emit($response); ``` **Важное замечание**! Помимо класса **SapiEmitter** есть более так сказать продвинутая его версия - **SapiStreamEmitter**. Она помимо всего прочего может работать с большими объемами контента (крупными файлами и т.д.). **Важное замечание**! Например, если у нас большой файл по умолчанию, используя класс **SapiStreamEmitter**, мы можем постепенно считывать из него и выводить каждые 8 килобайт данных. И мы решим проблему с выводом результата больших объемных данных. **Важное замечание**! Также в классе **SapiStreamEmitter** реализована возможность посредством специального заголовка - **Content-Range** производить докачку файлов больших. После обрыва связи (соединения) или еще из-за чего-либо.