Як ми за допомогою PHP та Laravel запустили стартап за три місяці
Привіт, мене звати Володимир Гуц. Обіймаю посаду Head of Development у стартапі Brighterly від венчур-білдера SKELAR. Ми створюємо онлайн-школу з вивчення математики для дітей від 6 до 17 років на ринку США. За 2+ роки роботи над платформою вдалось досягнути класних показників — у нас стали навчатись понад 2 тис. дітей.
У статті хочу поділитись дієвими практиками зі швидкого запуску стартапу, яким я навчився, будуючи Brighterly. Ми розглянемо:
- архітектурні підходи та лайфхаки з побудови Back-end;
- інтеграцію Back-end з Front-end;
- те, як швидко та якісно побудувати деплой у продакшн;
- готові рішення, які можуть бути корисними.
Вимоги
Спочатку визначимо, що є важливими саме на першому етапі побудови стартапу.
Міфи у побудові стартапу
Коли інженеру ставлять задачу запустити стартап, то зазвичай на думку спадає наступне:
- треба усе одразу побудувати так, щоб проєкт тримав навантаження;
- він обовʼязково має бути відмовостійким;
- бажано відразу його гарно розділити на мікросервіси;
- було б класно подумати про те, щоб проєкт міг легко масштабуватись, коли прийде навантаження;
- проєкт має бути якісно спроєктованим;
- ну й обовʼязково має бути безпечним, адже не хочеться, щоб одразу після запуску нас зламали хакери.
До подібного списку вимог приходив і я в одному з попередніх проєктів. Але якраз через них розробка, на жаль, була не зовсім вдалою і я зрозумів, що почав думати про це зарано.
Тож згадані вище пункти дуже важливі, але про них варто задумуватись не на старті. Бо тоді вони заважатимуть швидко побудувати стартап і пройти перший етап виживання.
Реальність у побудові стартапу
Реальність полягає у тому, що на старті важливими є інші речі:
Виживання. Якщо ми не встигнемо швидко побудувати MVP, то стартап просто не виживе, не зможе вийти з «долини смерті» (турбулентний період розвитку, коли проєкт запустився, але ще не почав отримувати прибуток) та не доживе до трафіку, навантаження тощо.
На нас очікує багато кардинальних змін. Те, що ми закладаємо у продукт на старті, може відрізнятись на 90% від того, що врешті побудуємо.
Гнучкість. Треба бути готовим до будь-яких викликів, тому краще сфокусуватись на простоті.
Time to market. Виживання залежить від того, наскільки швидко ми зможемо доставляти наш код у Production. Цього можна досягти, якщо все побудовано дуже просто. Ми у Brighterly боролись за секунди деплою, тож навіть 4 хвилини для нас було занадто довго.
На нас очікує багато тестових гіпотез, які треба вміти швидко перевіряти. Що більше гіпотез ми зможемо протестувати за короткий шлях, тим більша ймовірність знайти ті з них, які спрацюють.
Усі речі, які я назвав «міфами», матимуть сенс трохи згодом, коли нам вдасться пройти цю «долину смерті».
Tech Stack
Ми обирали технології, розуміючи, що для нас є важливим під час запуску стартапу. Тож стартували на такому технічному стеці:
- PHP 8.1
- Laravel 9
- MySQL 8
- VueJS 3
Чому PHP? Ця мова програмування не може похвалитись класними показниками за performance, він не має багатопоточності з коробки та не є асинхронною мовою, але на старті все це й не потрібно (памʼятаємо про міфи та реальність).
То що ж корисного є у PHP та заради чого ми його взяли:
- можна швидко прототипувати й швидко запуститись;
- достатньо дорослий для того, щоб на ньому можна було писати будь-що;
- наявність будь-яких бібліотек під різні задачі сильно допомагає;
- у нашої команди був досвід із цією мовою.
Чому взяли Laravel:
- ТОП-1 framework у світі PHP;
- на ньому теж можна швидко стартанути;
- багато функціоналу Laravel має з коробки (черги тощо);
- команда мала відповідний досвід.
Архітектура
У нашому продукті досить непроста архітектура. Ми одразу розуміли, що треба будувати три окремих продукти:
- Customer Area — зона для батьків і дітей.
- Tutor Cabinet — зона, де працюватимуть викладачі.
- Admin Area — адмінська зона, де працюватимуть Support engineers.
Тут перше, що спало б на думку — одразу все ділити на мікросервіси.
Ми ж вирішили, що все буде в одному застосунку. Розподілення зробили всередині застосунку за допомогою роутінгу:
Production |
Development | |
Client Space |
app.brighterly.com |
app.brighterly-dev.xyz |
Admin |
app.brighterly.com/admin |
app.brighterly-dev.xyz/admin |
Tutor |
app.brighterly.com/tutor |
app.brighterly-dev.xyz/tutor |
Таким чином замість мікросервісів ми отримали моноліт, але на старті в нас була можливість швидко рухатись та легко перевикористовувати логіку з однієї зони в іншій.
Production |
Test environment | |
Client Area |
prod-host/ |
test-host/ |
Admin Area |
prod-host/admin |
test-host/admin |
Tutor Area |
prod-host/tutor |
test-host/tutor |
Чому не мікросервіси
- Мікросервіси — це добре, але не на старті проєкту.
- На старті неможливо чітко визначити межі між застосунками. Імовірність помилитись — дуже висока. Тому краще робити розподілення пізніше, коли ми вже пройдемо певний шлях, а за продуктом буде зрозуміло, до чого ми дійшли.
- На жаль, мікросервіси — це overhead як у розробці, так і в коштах на інфраструктуру.
З мого досвіду:
- Більшість проєктів, які стартували на мікросервісах, постраждали саме через них та не вижили.
- Більшість успішних історій про мікросервіси починались з моноліту, який став великим та був розділений.
Back-end tips and tricks
Хочу також поділитись невеликим переліком архітектурних підходів та лайфхаків, які допомогли нам на старті. Сподіваюсь, вони зможуть полегшити життя і вам:
- Arch Approach: Back-end Layers
- Extended Query Builder
- Repositories
- Events & Listeners
Arch Approach: Back-end Layers
На Back-end ми взяли шарову архітектуру. А саме чотири шари:
- Presentation Layer — шар інтерфейсу.
- Business Logic Layer — шар бізнес-логіки.
- Service Layer — сервісний шар.
- Data Layer — шар роботи з даними.
Використали головні концепції з книжки (перші два з наступного списку), а ще додали третю, власну концепцію:
- Шар вищого рівня користується послугами шару нижчого рівня.
- Шар нижчого рівня не знає про шар вищого рівня.
- Для спрощення вищий шар може пропускати середній та звертатись до нижчого.
Це допомогло нам уникнути технічного боргу, а з іншого боку — сильно не заморочуватись із побудовою правильної академічної архітектури.
Extended Query Builder
Цей патерн допомагає елегантно та міцно вибирати дані з бази. Ідея полягає у тому, щоб взяти стандартний QueryBuilder та розширити його власними методами, які потрібні для конкретної сутності.
Покажу на прикладі сутності Booking (у нашому продукті це урок).
Ось так відбувається обʼявлення в моделі:
class Booking extends Model { /** * @return BookingQueryBuilder */ public function newEloquentBuilder($query) { return new BookingQueryBuilder($query); }
Як ви бачите, взагалі нічого складного.
Сам білдер теж виглядає дуже просто:
class BookingQueryBuilder extends Builder implements Searchable { public function type(BookingType $type): self { return $this->where('type', $type); } public function demo(): self { return $this->whereIn('type', BookingType::demo()); } public function withFeedback(): self { return $this->whereNotNull('tutor_feedback'); }
Наявність такого білдера дуже класно спрощує клієнтський код. Ось приклад саме клієнтського коду:
return Booking::query() ->tutorId($tutor->id) ->paid() ->notFinishedForCustomer() ->ongoing(now()->addMinutes($shiftMinutes)) ->oldestFirst() ->first();
Найбільша перевага в тому, що такий код читається дуже легко, а тому підтримувати систему теж досить не важко. Навіть якщо людина, яка не є технічним спеціалістом, спробує прочитати цей код, вона найімовірніше зрозуміє суть.
А читання коду — це майже найважливіша історія, бо рядок коду в середньому пишеться один раз, а читають його десять разів.
Спробуємо прочитати той код, який побачили. «Беремо уроки конкретного викладача, тільки платні, ще не закінчені для клієнта, які наближаються та почнуться через +5 хвилин від зараз, беремо найстаріший перший урок».
Це я прочитав код та переклав з PHP на українську.
Repositories
Репозиторіїв немає з коробки в Laravel, але цей патерн нескладний та може бути дуже корисним. Головна ідея: у клієнтському коді використовувати не моделі напряму, а працювати через окремий прошарок у вигляді репозиторію.
Ми першочергово використовуємо репозиторії для оновлення даних. Тому моделі відіграють у нас роль сутностей (Entities), а зміна та збереження відбуваються через репозиторій. Отже, ми трошки виправляємо проблему того, що модель Eloquent порушує принцип Single Responsibility з SOLID. А додатково отримуємо важливу можливість мати івенти у системі та будувати складну логіку завдяки ним (про це трішки згодом).
Як в нас виглядає метод update в репозиторії:
class SubscriptionsRepository { public function update(Subscription $subscription, array $data): Subscription { $subscription->fill($data); $this->calculateTotal($subscription); $originalData = $subscription->getOriginal(); $changedData = $subscription->getDirty(); $subscription->save(); event(new SubscriptionUpdatedEvent($subscription, $originalData, $changedData, true)); return $subscription; }
Як ми бачимо, репозиторій є обгорткою моделі, але крім самого збереження він робить додаткову роботу (у цьому разі калькулює totals у підписці) та кидає івент (SubscriptionUpdatedEvent), на який потім можна завʼязати будь-яку іншу бізнес-логіку.
Event & Listeners
Якщо ми вже маємо репозиторії та кастомні івенти, логічним продовженням є використання слухачів цих івентів.
Але спочатку невеликий відступ... У будь-якому продукті буде певна непроста бізнес-логіка та інтеграція між різними частинами системи. Зазвичай цю інтеграцію роблять «у лоба», тому код стає схожим на спагеті-код. Я пропоную підхід, де завдяки Events та Listeners можна розвʼязувати складну інтеграцію між частинами системи. Так ми можемо робити складну логіку, але водночас код залишиться простим та зрозумілим.
Головне у цьому підході — щоб івенти були привʼязані до сутностей, з якими подія відбувається:
Тут важливим є те, що івент CustomerUpdatedEvent розташовано у неймспейсі Customer. Заходячи у цей неймспейс, ми можемо побачити всі івенти, які є у цій сутності.
А ось слухачі вже мають бути згруповані у бізнес-фічі. Наприклад:
Бачимо, що в нас є фіча Autoassign (у ній ми автоматично вибираємо демо-користувачу вчителя). Усіх слухачів, потрібних для цієї фічі, ми «поклали» в один спільний неймспейс «Autoassign». Але слухати ці слухачі можуть уже що завгодно. У цьому разі ми слухаємо івенти «BookingActivityUpdated» та «TutorCheckIn».
Якщо відкрити слухача, він теж виглядає дуже просто:
class BookingActivityUpdatedListener { public function handle(BookingUpdatedByCustomerEvent $event): void { if ( $event->booking->activity_status === BookingActivityStatus::WAITING && $event->booking->tutor_id === null ) { dispatch(new AssignTutorToBookingJob($event->booking)); } }
Є перевірка вхідних параметрів та запуск фічі. Саму фічу в нашому випадку ми винесли в окрему Job.
Отже, завдяки правильному використанню Events & Listeners ми можемо звʼязувати будь-які частини системи, будувати складну бізнес-логіку і робити це просто! І наш код буде виглядати адекватно, його буде легко підтримувати й нескладно розібратись, як воно працює.
Хочу навести приклади фічей у нашому продукті, що реалізовані завдяки Event & Listeners:
- Коли замовлення сплачено, треба нарахувати баланс користувачу.
- Після успішного закінчення уроку потрібно списати баланс з рахунку користувача.
- Коли перенесли урок на іншу дату, треба оновити івент у Google-календарі клієнта.
- Після успішного закінчення демо-уроку треба згенерувати сертифікат та відправити його клієнту.
Комунікація між Back-end і Front-end
Ще одна важлива частина швидкої побудови стартапу — правильно зроблена інтеграція між Back-end та Front-end.
Загальноприйнята практика така, що Front-end одразу відділяють від Back-end, кладуть усе в різні репозиторії, на Back-end будується API з Postman, а Front-end використовує це API. Насправді це гарний та правильний підхід.
Але в нього є своя ціна:
- У вигляді оверхеду на інтеграцію Back-end з Front-end.
- Та в можливому сепаруванні в команді на Back-end та Front-end. Усі знають приклади баталій та пінг-понг-ігор між Back-end та Front-end.
Ми у Brighterly пішли шляхом монолітного застосунку та вирішили досить просто зінтегрувати Front-end та Back-end у межах одного застосунку, але так, щоб спілкування відбувалось у json-форматі (як це було б з API).
В монолітному підході є свої проблеми:
- Front-end цвяхами прибитий до бекенду. Та перероблювати UI й не чіпати при цьому Backend-end — боляче.
- При масштабуванні команди стане важко підтримувати монолітний продукт.
На старті ми це розуміли, але все ж вирішили піти таким шляхом. Бо він дає дуже швидкий старт та не блокує можливість відокремити Front-end від Back-end в майбутньому.
Крута новина полягає у тому, що для Laravel вже є класне рішення, і це IntertiaJS.
Переваги Inertia:
- Маємо повноцінний Front-end, написаний на Vue або React.
- Водночас не треба інтегрувати Front-end-частину з Back-end.
- Це дає швидку розробку.
- Також маємо просте локальне оточення: потрібно встановити один застосунок, але не треба інтегрувати Back-end з Front-end тощо.
Хочу навести приклади з сайту Inertia. Ось так виглядає контролер (файл UsersController.php):
class UsersController { public function index() { $users = User::active() ->orderByName() ->get(['id', 'name', 'email']); return Inertia::render('Users', [ 'users' => $users ]); } }
А ось так виглядає в’юшка (файл Users.vue):
<script setup> import Layout from './Layout' import { Link, Head } from '@inertiajs/vue3' defineProps({ users: Array }) </script> <template> <Layout> <Head title="Users" /> <div v-for="user in users" :key="user.id"> <Link :href="`/users/${user.id}`"> {{ user.name }} </Link> <div>{{ user.email }}</div> </div> </Layout> </template>
Як бачимо, з контролера просто повертаємо звичайний Json, але обгорнутий в Inertia::render(). У в’юшці отримуємо пропси.
Водночас Insertia робить усю брудну роботу під капотом: якщо це перше завантаження сторінки, то вона відрендерить HTML, а пропси закодує у data-атрибут.
Якщо ж сторінка провантажена, і ми переходимо з іншої сторінки за посиланням, тоді цей контролер просто поверне json з пропсами.
Що нам дає такий підхід:
- На Front-end маємо повноцінний SPA (Single-Page Application), але будуємо його з окремих сторінок з окремими в’юшками.
- Нам не треба будувати окреме API на Back-end.
- Швидка розробка.
- Зручно для невеликої команди.
- Коли зʼявиться потреба, буде можливість легко відокремити Back-end від Front-end.
Deploy
Найпростіший спосіб побудувати деплой — це зробити локальне оточення таким самим, як production environment. Ми брали Docker та Docker Compose. Локальне оточення побудували на Docker, а для прода взяли звичайний сервер на Ubuntu, і там так само підняли проєкт на Docker Compose. Ось так приблизно виглядає наш docker-compose.yml:
services: php: build: ./infra/docker/php-fpm volumes: - .:/app working_dir: /app environment: PHP_IDE_CONFIG: serverName=localhost PHP_OPCACHE_ENABLE: 0 php-worker: build: ./infra/docker/php-fpm deploy: replicas: 1 volumes: - .:/app working_dir: /app command: php artisan queue:listen --tries=2 nginx: build: ./infra/docker/nginx volumes: - .:/app working_dir: /app ports: - 8080:80
Я розумію, що у такого підходу є багато проблем, але на старті проєкту він працює відмінно:
- Якщо щось трапилось, розробники зможуть дуже швидко все виправити, бо на проді вони не побачать нічого нового. Там буде все те саме, що й локально.
- Вам не треба витрачати багато часу на побудову правильної інфраструктури на Kubernetes — лише ще раз розвернути локалку на віддаленому сервері.
- Якщо сервер «помер», то ми можемо досить швидко підняти новий з докером, спулити туди репозиторій та запустити docker compose up -d. Більші складна інфраструктура на Kubernetes вміє сама автоматично хендлити подібні проблеми, але якщо щось пішло не так з самою інфраструктурою, виправляти таки проблеми вже набагато складніше.
Готові рішення
Іншим важливим моментом під час запуску стартапу є максимальне використання готових рішень. Хочу поділитись невеличким переліком того, що може одразу допомогти та спростити життя, бо у вас не буде необхідності будувати це власноруч:
UptimeRobot — сервіс, який коштує $8 на місяць, та «під ключ» закриває питання перевірки життєздатності вашого продукту. Дуже класно алертить у Slack, якщо щось впало. Просто, дешево, але ключову проблему алертингу вирішує.
Digitalocean — cloud. Недорогий та зручний Cloud, у якому можна швидко розібратись. Та з коробки будуть усі необхідні графіки, що допоможуть дивитись на те, що відбувається з Back-end. Ось приклад графіків:
Також DigitalOcean має алерти з коробки й вміє зручно писати про це у Slack. Приклад:
Sentry — збір логів та error traces в одному місці. Дуже легко інтегрується у Laravel та Vue, ви одразу закриваєте питання логів та помилок. Є можливість використовувати Performance-логіку, щоб дивитись, що відбувається під капотом на Back-end. А також встановити опенсорсну версію власноруч, щоб не витрачати гроші та не мати обмеження у кількості помилок. Sentry також може алертити у Slack, коли насипає багато помилок. Приклад:
Google Tag Manager. Будь-якому продукту потрібен маркетинг, а маркетингу потрібні івенти. Можна інтегрувати Google Tag Manager, дати доступ маркетологам, тож вони самі зможуть сетапити будь-які івенти та підлаштовувати маркетинг під них. А ви зможете не витрачати на це багато часу та зосередитись на розробці продукту.
Наступні кроки. Що далі
Kubernetes
Коли ми успішно пройдемо перший етап «виживання», зможемо сфокусуватись на побудові відмовостійкої інфраструктури. Тут нам допоможе Kubernetes. Щоб це було можливим, обовʼязково треба продумати логіку роботи з локальними файлами. Замість них треба використовувати зовнішнє сховище Redis/Memcached. Якщо ви будували застосунок на стандартних механізмах Laravel, варто замінити драйвери з files на redis.
Separate Front-end from Back-end
Коли команда стане більшою, логічним кроком буде відокремити Front-end в окремий репозиторій. На Back-end-частині для цього достатньо прибрати обгортку Inertia та повернути звичайний json.
Automation tests
Обовʼязковою частиною є написання автотестів для функціоналу, що вже працює.
Unit tests класно підходить те, що нам дає Laravel з коробки (тести на базі PHPUnit). Дуже раджу користуватись. Прогон автотестів налаштовуємо прямо в CI та тішимось цьому.
Для фронтових автотестів дуже класно підходить Playwright. Пишеться досить легко та дає змогу використовувати різні мови програмування: Python, Node. Досить неважко інтегрується в CI, при чому не обовʼязково підіймати окремий інструмент по типу Jenkins. Зараз воно запускається у нас безпосередньо в Gitlab CI.
Висновки
Ми пройшлись набором різних речей, які можуть спростити життя на початку стартапу й допомогти швидко рухатись, а також одразу закласти фундамент, щоб пізніше мати можливість увійти в «доросле життя».
Сподіваюсь, мій досвід допоможе вам побудувати потужний проєкт та запустити його швидко та якісно! Поділіться власним досвідом та думками у коментарях.
129 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів