PHP-фреймворк Badoo

в 9:00, , рубрики: badoo, blitz, framework, javascript, php, php-framework, protobuf, баду, Блог компании Badoo, Веб-разработка, ооп

PHP фреймворк Badoo Код нашего сайта повидал уже не одну версию PHP. Он неоднократно дополнялся, переписывался, модифицировался, рефакторился — в общем, жил и развивался своей жизнью. В это время в мире появлялись и исчезали новые best practice, подходы, фреймворки и тому подобные явления, облегчающие жизнь разработчику и готовые решить все основные проблемы, возникающие в процессе создания веб-сайтов.
В этой статье мы расскажем о нашем пути: как был организован код изначально, какие возникали проблемы и как появился текущий фреймворк.

Что было

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

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

Были пакеты — наборы классов, к которым обращались страницы, чтобы получить данные или обработать каким-то образом. Были представления, которые отвечали за шаблонизацию и вывод. В обычном случае каждая страница получала какие-то данные, передавала их в класс View, который расставлял их в структуру blitz-шаблона и выводил. Так сложилось, что для каждой страницы был свой шаблон (не было базового), и отличался он набором подключаемых скриптов, стилей и центральной частью.

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

Что же, собственно, нас не устраивало и нужно было улучшить?

1. Использование глобального контекста и статичных переменных.
С одной стороны, это удобно, когда можно из любой точки кода получить глобальный объект. С другой — он становится зависимым, повышается связанность. С началом unit-тестирования мы поняли, что такой код ужасно тяжело тестировать: первый тест легко ломает следующий, за этим необходимо очень жестко следить. К тому же код, который использует глобальные объекты, а не имеет лишь вход и выход, требует много mock-объектов для тестирования.

2. Большая связанность контроллеров с представлениями.
Подготовка данных зачастую происходила под конкретный View, т. е. под конкретный шаблон. Целые иерархии контроллеров, наследуемых друг от друга, по частям собирают данные для blitz-шаблона. Поддержка такого кода крайне затруднительна. Создание версии с абсолютно другим шаблоном (например, мобильной версии) становилась порой почти неосуществимой задачей, поэтому было проще написать все с нуля.

3. Использование публичных свойств как норма.
Изначально PHP не поддерживал приватные свойства объектов. Поскольку наш код имеет достаточно большую историю, то в нем осталось много мест, где свойства объявляются через var, и много кода, который использует это. Вполне нормально встретить объект, который передается в другой объект, а тот что-то устанавливает или производит какие-то манипуляции со свойствами первого. Такой код очень сложен в понимании и отладке. В идеале нужно всегда делать геттеры и сеттеры для свойств классов — это сэкономит уйму времени и нервов вам и вашим коллегам!

4. Ассоциативные массивы как контейнер для передачи параметров.
Большой проблемой для нас стало то, что данные, полученные из одного источника, переносятся в какой-нибудь обработчик или контроллер, а по пути туда может быть дописано что угодно и в неограниченном количестве. В итоге все это постоянно обрастает новыми параметрами и в таком виде отправляется в класс View. Хотя лучше было бы использовать какую-нибудь типизацию или интерфейс, чтобы избежать хаоса.

5. Отсутствие единой точки входа.
Каждая страница — это отдельный php-файл, содержащий класс, наследуемый от базового. Если входные данные в такой схеме контролировать в одном месте возможно, то на выходе сделать что-то массово будет уже крайне сложно. Заведение маршрута, отличного от имени папки, файла или содержащего переменные, требует правки конфигов nginx. А это усложняет тестирование в стандартном рабочем процессе, требует предоставления дополнительного доступа и сложнее поддерживается при большом количестве разработчиков.

Новые задачи для фреймворка

Естественно, мы хотели решить большинство вышеперечисленных проблем. Мы хотели иметь возможность «из коробки» отображать одни и те же данные в разном представлении (JSON, мобильной или веб-версии). До этого задача решалась только набором IF-ов в каждом конкретном случае.

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

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

Какова архитектура фреймворка?

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

$Request = new Request($_GET, $_POST, $_COOKIE, $_SERVER);
$App = new Application();
$Response = $App->handle($Request);
$Response->send();

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

$Dispatcher = new EventDispatcher();
$Dispatcher->setListeners($Request->getProject()->loadListeners());
$RequestEvent = new Event_ApplicationRequest($Request); 
$Dispatcher->dispatch($RequestEvent);

На каждое событие есть набор подписчиков, которые реагируют специальным образом. Например, Listener_Router по клиенту (в основном по HTTP_USER_AGENT) и значению REQUEST_URI находит контроллер (например, Controller_Mobile_Index) и устанавливает его в объект события. После диспетчеризации этого события приложение либо вызывает найденный контроллер, либо кидает исключение Exception_HttpNotFound, которое будет выведено как ответ сервера 404. Пример списка подписчиков:

$listeners = [
   FrameworkEvent_ApplicationRequest::class => [
       [FrameworkListener_Platform::class, 'onApplicationRequest'],
       [FrameworkListener_Client::class, 'onApplicationRequest'],
       [FrameworkListener_Router::class, 'onApplicationRequest'],
   ],
];

Каждый контроллер представляет из себя отдельный класс с набором методов — action-ов. Фреймворк находит по карте маршрутов соответствующий класс и метод, создает объект Action (для удобства, вместо callable-массива). Пример карты маршрутов:

$routes = [
   Routes::PAGE_INDEX => [
       'path' => '/',
       'action' => [Controller_Index::class, 'actionIndex'],
   ],
   Routes::PAGE_PROFILE => [
       'path' => '/profile/{user_login}',
       'action' => [Controller_Profile::class, 'actionProfile'],
   ],
];

В массиве маршрутов указаны базовые классы. Если класс имеет наследника под текущего клиента, то будет использован именно он. Имена маршрутов в константах позволяют удобным образом генерировать URL-ы в любом месте проекта.

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

$ActionEvent = new Event_ApplicationAction($Request, $RequestEvent->getAction()); 
$Dispatcher->dispatch($ActionEvent);

Например, для всех контроллеров, к которым обращается JavaScript, мы автоматом проверяем request-token для защиты от CSRF-уязвимостей. За это отвечает отдельный класс Listener_WebService. Но бывают сервисы, для которых нам это не требуется. В таком случае контроллер наследует интерфейс Listener_WebServiceTokenCheckInterface и реализует метод checkToken:

public function checkToken($method_name)
{
   return true;
}

Здесь $method_name — имя метода контроллера, который будет вызван.

За работу с данными (например, загрузку из БД) у нас отвечают пакеты (packages) — наборы классов, объединенные одной областью применения. Контроллер получает данные из пакетов и, вместо использования массива для передачи данных во View, устанавливает их в объект ViewModel — по сути, в контейнер с набором сеттеров и геттеров. За счет этого внутри View всегда известно, какие данные переданы. Если набор данных меняется, все использования методов класса ViewModel можно запросто найти и поправить нужным образом. Будь это массив, пришлось бы искать по всему репозиторию, а потом и среди всех вхождений, особенно, если ключ массива назван простым и распространенным словом, например, “name”.

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

На основе поступивших данных View готовит итоговый результат — это строка или специальный объект ParseResult. Это сделано, чтобы реализовать отложенный рендеринг: сначала идет подготовка всех данных, и лишь потом финальный рендеринг всего разом. Самый частый случай — создание страницы на основе blitz-шаблона и некоторых данных, подставляемых в него. В таком случае объект ParseResult будет содержать имя шаблона и массив с готовыми для шаблонизации данными, которые достаточно в нужный момент отправить в Blitz и получить итоговый HTML. Данные для шаблонизации могут содержать вложенные объекты ParseResult, поэтому финальный рендеринг производится рекурсивно. Тут хочется предостеречь вас от использования функции array_walk_recursive: она ходит не только по массивам, но и по публичным свойствам объектов. В связи с этим некоторые страницы у нас падали по памяти, пока мы не сделали собственную простую рекурсивную функцию:

function arrayWalkRecursive($data, $function)
{
   if (!is_array($data)) {
       return call_user_func($function, $data);
   }
   foreach ($data as $k => $item) {
       $data[$k] = arrayWalkRecursive($item, $function);
   }
   return $data;
}

Поскольку общение между PHP и JavaScript у нас очень тесное, то для него реализована соответствующая поддержка. Каждый объект View — это конкретный блок на сайте: header, sidebar, footer, центральная часть и т.п. Для каждого блока или компонента может быть свой собственный обработчик на JavaScript, который настраивается при помощи определенного набора данных — js_vars. Например, через js_vars передаются настройки для comet-соединения, по которому приходят разные обновления — счетчики, всплывающие уведомления и т.п. Все такие данные передаются через единую точку, определенную в blitz-шаблоне:

<script type="text/javascript">
   $vars = {{JS_VARS}};
</script>

Помимо этого, у нас есть контроллеры, к которым обращается только JS и получает в качестве результата JSON. Мы называем их веб-сервисами. Относительно недавно мы начали описывать протокол общения PHP и JS с использованием Google Protocol Buffers (protobuf). На основе proto-файлов генерируются PHP-классы с набором сеттеров, автоматически валидирующие устанавливаемые в них данные, что позволяет оптимальным образом формализовать договоренность между front-end и back-end разработчиками. Ниже приведен пример proto-файла для описания оверлея и использование PHP-класса, сгенерированного на его основе:

package base;

message Ovl {
   optional string html = 1;
   optional string url = 2;
   optional string type = 3;
}

$Ovl = GPBJSbaseOvl::createInstance();
$Ovl->setHtml($CloudView);
$Ovl->setType('cloud');

На выходе получаем JSON:

{"$gpb":"base.Ovl","html":"here goes html","type":"cloud"}

Среди всего прочего, в данных для JS может быть и HTML, получаемый из blitz-шаблонов. Каждый блок устанавливает js_vars от корня, и они рекурсивно сливаются в одну структуру при помощи функции array_replace_recursive. Пример структуры, готовой к рендерингу:

ParseResult Object
(
   [js_vars:protected] => Array
   (
       [Sidebar] => Array
       (
           [show_menu] => 1
       )
       [Popup] => Array
       (
           [html] => ParseResult Object
           (
               [js_vars:protected] => Array ()
               [template:protected] => popup.tpl
               [tpl_data:protected] => Array
               (
                   [name] => Alex
               )
           )
       )
   )
   [template:protected] => index.tpl
   [tpl_data:protected] => Array
   (
       [title] => Main page
   )
)

Обычно контроллер готовит один блок на сайте — его центральную часть, а остальные блоки либо скрываются или показываются, либо определенным образом меняют свое поведение в зависимости от текущего контроллера. Для управления всем «каркасом» страницы используется объект Layout (грубо говоря, это базовый объект View), который устанавливает стандартные блоки и центральную часть в объект ParseResult для базового шаблона. Чтобы объявить, какой Layout будет использован, контроллер наследует специальный интерфейс HasLayoutInterface и реализует метод getLayout.

Помимо сборки всей страницы, Layout несет дополнительную функцию: он формирует результат в виде JSON для «бесшовных» переходов между страницами. Уже достаточно давно наш сайт работает как веб-приложение: переходы между страницами осуществляются без перезагрузки всей страницы, меняются только определенные элементы (URL, заголовок, центральная часть, показываются или скрываются определенные блоки).

Интеграция

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

class TestPage extends CommonPage
{
   public function run()
   {
       FrameworkApplication::run(); // Запуск нового фреймворка
   }
}

$TestPage = new TestPage();
$TestPage->init(); // Инициализация старого фреймворка
$TestPage->run();

Чтобы сделать независимый запуск нового фреймворка и оставить единую инициализацию, необходимо было вынести все, что происходит в init(), в отдельные классы и использовать и там, и там. Это было выполнено поэтапно, и в итоге получилось порядка 40 классов.

После этого для пробы мы перевели несколько небольших проектов. Одним из первых было переведено наше расширение для браузера Chrome (Badoo Chrome Extension). А первым большим проектом стал сайт Hot Or Not, целиком написанный на новом фреймворке. В настоящее время мы постепенно переводим на него и наш основной сайт Badoo.

Проекты, полностью реализованные на новом фреймворке, работают через фронт-контроллер, то есть единую точку входа — index.phtml. Для badoo.com мы имеем множество правил в nginx, последнее из которых отправляет на профиль. Т.е. badoo.com/something либо откроет профиль пользователя something, либо вернет 404. Именно поэтому, пока профиль полностью не переведен на новый фреймворк, у нас еще остается множество *.phtml файлов, которые содержат в себе лишь запуск фреймворка.

<?php
/*... includes ...*/
FrameworkApplication::run();

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

Заключение

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

Александр Treg Трегер, разработчик.

Автор: Treg

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js