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 = [ \Framework\Event_ApplicationRequest: class => [ [\Framework\Listener_Platform: class, 'onApplicationRequest'], [\Framework\Listener_Client: class, 'onApplicationRequest'], [\Framework\Listener_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-шаблоне: Помимо этого, у нас есть контроллеры, к которым обращается только 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 = \GPBJS\base\Ovl: 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 () { \Framework\Application: 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 файлов, которые содержат в себе лишь запуск фреймворка.