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 = Request new($_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-шаблоні:

<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 = \GPBJS\base\Ovl::createInstance();
$Ovl->setHtml($CloudView);
$Ovl->setType('cloud');

На виході отримуємо JSON:

{"$gpb":"base.Ovl","html":"goes here 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 файлів, які містять в собі лише запуск фреймворка.

<?php
/*... includes ...*/
\Framework\Application::run();

Раніше в цих файлах був код на старому фреймворку. Після рефакторінгу код був перенесений в контролери нового фреймворку, але самі файли не можуть бути видалені. Вони повинні існувати, щоб nginx запускав їх, а не відправляв запит на профіль.

Висновок

У підсумку ми вирішили досить великий пласт проблем: створили єдину точку входу, прибрали багато рівнів спадкування, максимально зменшили використання глобального контексту, код став більш об'єктним і типізованих. В основному зміни торкнулися маршрутизації і всього процесу роботи веб-скриптів, від запиту до відповіді сервера. Також з'явилася кардинально нова система роботи з поданням даних. Ми пробували створити єдину схему з доступу до даних, але поки не знайшли рішення, яке б нас цілком влаштовувала. Але вийшло можна сміливо вважати впевненим кроком в бік зручної розробки і підтримки коду.

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.