Карта Кипра с "рабочими местами" для цифровых кочевников
Карта Кипра с "рабочими местами" для цифровых кочевников

Ещё один небольшой pet-проект: про кафе и коворкинги на солнечном Кипре. "Рабочие места" для цифровых кочевников ヽ(。_°)ノ

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

Цели проекта

Кафе, кофеен, кафенио, таверн, ресторанов и баров на острове очень много, но далеко не в каждом можно спокойно поработать хотя бы пару часов. Есть, конечно, широкоизвестные Starbucks, Costa Coffee, Gloria Jeans Coffee и т.д., но ещё есть очень уютные и совершенно недооценённые локальные заведения. Поэтому было решено:

  1. Категоризовать места по актуальным для удалённой работы параметрам: кафе/коворкинг, розетки, шум, размер, занятость, вид из окна и т.д.

  2. Фильтровать места по выбранным параметрам.

  3. Показать карту с подходящими местами.

  4. Реализовать десктопную и мобильную версию веб-приложения.

Всё удалось, код проекта открыт, велкам в пул-реквесты. Адрес сайта - в конце статьи, чтобы меньше походило на рекламу.

Для достижения целей было решено реализовать 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.

Комментарии (4)


  1. eugenie_r_v
    19.10.2022 17:41
    +4

    Очень полезная штука, особенно когда нужно найти тихое место с розеткой!


  1. NlCKNAMe
    20.10.2022 00:10
    +3

    Интересно, и описан процесс создания заодно, круто!


  1. Henryh
    20.10.2022 08:44
    +2

    Кажется я сегодня поработаю в новом месте! Полезный сервис спасибо.

    Отдельный плюс за детализацию подхода и открытый код, было интересно посмотреть на реализацию.


  1. savostin
    20.10.2022 23:29
    +1

    То чувство, когда у кого-то хобби-проект круче правильнее твоего продакшена.