Ещё один небольшой pet-проект: про кафе и коворкинги на солнечном Кипре. "Рабочие места" для цифровых кочевников ヽ(。_°)ノ
Делюсь процессом разработки, фичами и граблями. Общий подход к разработке прагматичен и аналогичен предыдущему проекту.
Цели проекта
Кафе, кофеен, кафенио, таверн, ресторанов и баров на острове очень много, но далеко не в каждом можно спокойно поработать хотя бы пару часов. Есть, конечно, широкоизвестные Starbucks, Costa Coffee, Gloria Jeans Coffee и т.д., но ещё есть очень уютные и совершенно недооценённые локальные заведения. Поэтому было решено:
Категоризовать места по актуальным для удалённой работы параметрам: кафе/коворкинг, розетки, шум, размер, занятость, вид из окна и т.д.
Фильтровать места по выбранным параметрам.
Показать карту с подходящими местами.
Реализовать десктопную и мобильную версию веб-приложения.
Всё удалось, код проекта открыт, велкам в пул-реквесты. Адрес сайта - в конце статьи, чтобы меньше походило на рекламу.
Для достижения целей было решено реализовать REST API микросервис на Laravel с админкой на Twill и фронтэнд веб-приложение на Vue. Деплой, как и прежде, на Fly.io.
REST API микросервис
В качестве платформы выбран знакомый и лёгкий Laravel и PHP 8.1 с promoted- и readonly- properties и строгой типизацией.
composer.json и конфигурация проекта максимально облегчены: удалены неиспользуемые пакеты и классы, отключен platform-check, включен classmap-authoritative.
Благодаря этому количество загружаемых классов уменьшилось в 4,5 раза с 28247 до 6230 штук, каталог vendor "похудел" почти в 1,5 раза, тесты стали проходить чуть быстрее.
Архитектура
Основная модель Place - типичная Laravel-модель с прослойкой из модели Twill (A17\Twill\Models\Model
).
Свойства для фильтрации - нативные PHP enum'ы с несколькими общими методами из трейта EnumValues для получения значений для админки. Кастятся в свойства модели.
Кроме того, у каждого свойства есть коэффициент и вес для расчёта рейтинга заведения. Например, наличие розеток более важно, чем вид из окна.
enum Sockets: string implements PropertyEnum
{
use EnumValues;
case None = 'None';
case Few = 'Few';
case Many = 'Many';
public const WEIGHT = 3;
public static function default(): self
{
return self::Few;
}
/** @inheritDoc */
public function coefficient(): int
{
return match ($this) {
self::None => 1,
self::Few => 3,
self::Many => 5,
};
}
}
Запросы к API обрабатываются single-action контроллерами, валидируются Request'ами в т.ч. по совпадению с enum'ами. Например, IndexRequest.
#[OA\Parameter(name: 'busyness', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'city', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'size', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'sockets', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'noise', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'type', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'view', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'cuisine', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'vRate', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string', format: 'float', maximum: 0, minimum: 5))]
final class IndexRequest extends FormRequest
{
/** @return array{busyness: string, city: string, size: string, sockets: string, noise: string, type: string, view: string} */
public function rules(): array
{
return [
'busyness' => ['sometimes', 'required', new Enum(Busyness::class)],
'city' => ['sometimes', 'required', new Enum(City::class)],
'size' => ['sometimes', 'required', new Enum(Size::class)],
'sockets' => ['sometimes', 'required', new Enum(Sockets::class)],
'noise' => ['sometimes', 'required', new Enum(Noise::class)],
'type' => ['sometimes', 'required', new Enum(Type::class)],
'view' => ['sometimes', 'required', new Enum(View::class)],
'cuisine' => ['sometimes', 'required', new Enum(Cuisine::class)],
'vRate' => ['sometimes', 'required', 'float', 'numeric', 'between:0,5'],
];
}
}
Нативные PHP-аттрибуты позволили разместить OpenAPI-разметку гораздо компактнее, чем в DocBlock'ах. Итоговый openapi.yaml создаётся с помощью swagger-php и используется для тестирования API.
Кроме валидаторов, запросы проходят через фильтры на основе EloquentFilter - очень выразительное решение вместо кучи if'ов и when'ов.
У некоторых заведений есть фотографии, которые прозрачно загружаются в AWS S3 из админки и обрабатываются сервисом Imgix. На стороне API нет ничего для работы с картинками.
Для получения подробных geo-данных для заведения из Google Maps используется GooglePlacesService и пакет alexpechkarev/google-maps. В API сервис все заведения добавляются только с названием, городом и свойствами для рейтинга. Остальное - координаты, идентификаторы компании, адрес и ссылка получаются в 2 шага из Google Places API.
Для расчёта рейтинга заведения используется VRateService.
Оба сервиса завёрнуты в соответствующие экшены и доступны через консольные команды и события после записи заведения.
Готовые данные оборачиваются в PlaceResource и PlaceCollection. Там же из них удаляются лишние поля. Для принудительного ответа в JSON-формате используется middleware JsonResponse.php
final class JsonResponse
{
/** @param Closure(Request): (BaseJsonResponse) $next */
public function handle(Request $request, Closure $next): BaseJsonResponse
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}
Административная панель управления
Ранее я уже работал с Twill, поэтому решил использовать его для своего проекта: открытая бесплатная система с богатыми возможностями и хорошей поддержкой. Why not? :-)
Ставится через composer require area17/twill
, добавляет несколько миграций и прозрачно связывается с существующими моделями. В некоторых случаях необходимо добавить к ним служебные поля типа published
и дат начала/окончания аквтивности. Впрочем, в документации всё подробно описано.
Сейчас рекомендую попробовать версию 3-beta: в ней гораздо больше возможностей программного управления данными на страницах вместо отдельных виджетов в blade-шаблонах.
Пример контроллера раздела, репозитария и шаблона.
БД
Простая и быстрая SQLite ¯\_(ツ)_/¯
На хостинге размещена на persistent volume. Никаких настроек не потребовалось.
Тесты
Для тестов используется Pest с поддержкой Laravel, параллельным выполнением тестов и отключенным тротлингом ($this->withoutMiddleware(ThrottleRequests::class)
).
По эндпойтам проверяется адекватость ответов по dataset'ам и их соответствие с OpenAPI-спецификацией.
Для ручной проверки есть Rector с некоторыми исключениями.
Нашёлся один минус: laravel/dusk и php-webdriver/webdriver прибиты к Twill и требуют обязательной установки, хотя в моих тестах не используются :-(
Деплой
Для размещения сервера используется платформа Fly.io с управляемыми microVM Firecracker. Она никогда не спит, имеет хороший free tier и позволяет разместить как статику, так и любой сервер приложений. Кроме того, сама терминирует https-трафик, управляет сертификатами, предоставляет различные стратегии деплоя и отката изменений, health check'и и имеет широкую географию дата-центров.
Настроить среду выполнения можно автоматически командой flyctl launch
из каталога приложения или написать свои конфиг и Dockerfile.
Я использовал свой Dockerfile и запуск микросервиса API самым простым способом через php artisan serve
.
Раздачу статики (ассеты админки и robots.txt & Co) можно делегировать платформе Fly посредством настройки fly.toml
[[statics]]
guest_path = "/var/www/html/public/assets"
url_prefix = "/assets"
CI/CD
Всё просто: Github Action из одного workflow и тот же самый flyctl.
Мониторинг
Для отслеживания ошибок используется Sentry, а для аптайма и доступности - Honeybadger.
На этом этапе микросервис API работает, размещён в production-окружении и доступен всем пользователям. План-минимум выполнен :-)
Репозиторий API, сайт https://workplaces.cy/
Во второй части расскажу про создание фронтэнда на Vue 3 Composition API.