Последние пару лет в свободное от Настоящей Работы время я в роли CTO/соло-бэкендера участвовал в создании Stry — фитнес-стартапа с подписной моделью. Теперь, когда наша команда официально объявила о прекращении дальнейшего развития проекта, пришло время порефлексировать и поделиться полученным опытом. В этой статье я в двух словах представлю продукт, детально опишу архитектуру проекта и расскажу о наших (моих?) основных технических успехах и неудачах. Поехали!
Дисклеймер: в этой статье не будет практически ничего про бизнес-составляющую проекта — только разработка, только хардкор. Возможно, когда-нибудь мы напишем про это отдельно, кто знает!
Коротко про продукт
Цель проекта — соединить людей, занимающихся фитнесом, с профессиональными тренерами со всего мира, прошедшими тщательный отбор. Клиент скачивает приложение, заполняет анкету, выбирает тренера, оформляет подписку — и получает доступ к чату с тренером, еженедельные программы тренировок, составляемые тренером вручную (!) под свои индивидуальные запросы, и интерфейс для прохождения тренировки с видео-демонстрацией упражнений, трекингом времени и так далее.
По итогу в сторах мы разместили два приложения: одно — приложение для клиентов, где можно выполнять тренировки, и второе — для тренеров, где эти тренировки можно составлять. Позже к ним присоединилось несколько вариантов веб-лендинга.

Стек и архитектура
Клиентское приложение (iOS, Swift) обращается напрямую в клиентский API-сервис (Python, FastAPI), тренерское (iOS/Android, React Native) — в тренерский API-сервис (тоже Python, FastAPI).
API-сервисы развёрнуты в managed Kubernetes-кластере (DigitalOcean), в качестве базы данных используется MongoDB (об этом решении — ниже), развёрнутая в этом же кластере вручную через StatefulSet (дёшево и сердито!). Там же развёрнут простенький сервис-воркер для выполнения асинхронных/отложенных задач, написанный вручную (чтобы не затягивать Celery и к нему какое-то дополнительное хранилище помимо MongoDB). Сервисы автоматически перевыкатываются на каждый зелёный коммит в мастер посредством Github Actions.
По мере развития проекта к этим четырём мастодонтам прибавилось несколько вспомогательных сервисов. Например, tusd для заливки больших файлов (фото и видео с тренировок), Jaeger для профилирования, ClickHouse для хранения событий, присылаемых платёжной системой Stripe — на всякий случай (сам Stripe хранит полные JSON-ы событий лишь некоторое непродолжительное время), Elasticsearch + Kibana для логов. Плюс ещё пара самописных сервисов для всяких разных нужд и Telegram-бот, который мы используем в качестве админки, алертера и б-г знает чего ещё.
Модных инструментов вроде Helm/Terraform/you name it для управления этим зоопарком завезено не было, потому что бесплатного девопса фиг найдёшь уровень сложности проекта и интенсивность разработки не такие, чтобы в этом была острая необходимость. Я спокойно написал все YAML-ы для Kubernetes ручками.

К этому всему — ещё с десяток интеграций со всякими разными внешними сервисами: вышеупомянутый Stripe для платежей, Agora для видеозвонков внутри приложения, PubNub для чата, Amplitude для аналитики, Appsflyer для атрибуции, DigitalOcean Spaces в качестве CDN и ещё по мелочи.
Всего в проекте вышло порядка 35 000 строк Python-кода бэкенда, примерно столько же Swift-кода клиентского приложения, ещё примерно столько же TypeScript-кода тренерского приложения, ну и ~3 000 строк YAML-ов для кубернетиса.
Что пошло по плану
Теперь, когда я сформулировал достаточно контекста, время перейти к, собственно, вынесенным из этого опыта урокам! Сначала я пройдусь по идеям, которые оказались удачными, а потом перейду к ошибкам и вовсе несделанным вещам, которые стоило бы сделать.
CI/CD
Когда стартуешь проект, соблазн закинуть свежую версию на сервер руками через scp или git pull и рестартануть по ssh очень велик. Но это, конечно, неудобно, и тем более неудобно, чем выше темп разработки.
Поэтому в первую очередь я сделал автотесты и автовыкатку в продакшн-окружение. Это было очень удобно, и за 500+ автовыкаток у меня не было ни одной проблемы с тем, что выкатилось что-то не то или выкатилось преждевременно.

Настройка CI/CD с использованием современных инструментов (Gitlab CI / Github Actions, Kubernetes) для нового проекта занимает час, если у вас набита рука, и рабочий день, если вы этого никогда не делали. Нет никаких причин не катать продакшн на каждый коммит, пока вы в фазе начальной разработки. После релиза на пользователей можно дополнительно потребовать зелёные тесты перед выкаткой. Более сложные релизные процессы — удел зрелых проектов с большим RPS или какой-то ещё спецификой, накладывающей требования на надёжность.
Очередь задач
Как я упомянул выше, поскольку не хотелось затягивать дополнительные инфраструктурные компоненты вроде RabbitMQ, которые к тому же никто в команде не умеет эксплуатировать, то с учётом небольшой планируемой нагрузки (сервис дорогой, следовательно, аудитория вряд ли будет хайлодовой) было принято решение самому написать простенький воркер, который будет поллить коллекцию в MongoDB и выполнять задачи по мере их поступления. Была предусмотрено, конечно, и горизонтальное масштабирование (оно ни разу не потребовалось).
В этом решении у меня было много сомнений и я постоянно чувствовал, что изобретаю велосипед. Но я решил, что я сделаю предельно простой велосипед и буду писать код так, чтобы его легко было мигрировать на любой другой движок, как только это потребуется (это тоже не потребовалось).

И что же вы думаете? В итоге очередь задач имени меня прекрасно проработала два года, никак не давая о себе знать! А благодаря самописности я смог залезть в неё и сделать очень крутую фичу для тестов — перематывание времени с выполнением асинхронных задач. Но об этом в следующем разделе.
Код воркера был так прост, что я могу привести его практически целииком:
class MongoTaskQueue(CoreDAO, Generic[TaskType]): ... async def main_loop(self, worker_id: str): """ Основной цикл воркера (упрощённо). """ while True: op_id = f"{worker_id}{bson.ObjectId()}" now = self._clock.now_utc() now_timestamp = int(now.timestamp() * 1e9) doc = await self._lock_and_fetch_doc(op_id, now_timestamp) if doc is None: await asyncio.sleep(0.05) continue task = self._deserialize_task(doc) task_type = type(task).__name__ # тут выполнение таски и возвращение в очередь в случае ретрая ... await self._delete_completed_task(op_id) return async def _lock_and_fetch_doc(self, op_id: str, now_timestamp: int) -> Any: """ Находим документ, который ещё не взят в работу, и помечаем, что взят. MongoDB делает это для нас атомарно. """ query = self._prepare_fetch_query(now_timestamp) update = self._prepare_lock_update(op_id, now_timestamp) doc = await self._async_collection.find_one_and_update( query, update, sort=[("execution_ts_utc", ASCENDING)] ) return doc def _prepare_fetch_query(self, now_timestamp: int) -> Any: timeout_ts = int(self.task_timeout.total_seconds() * 1e9) return { "$and": [ {"execution_ts_utc": {"$lte": now_timestamp}}, { "$or": [ # Документ ещё не взят в работу: {"locked_by": {"$exists": False}}, # или взят в работу слишком давно - считаем, что # произошла ошибка и нужно ретраить: {"locked_ts_utc": {"$lte": now_timestamp - timeout_ts}}, ] }, ], } def _prepare_lock_update(self, op_id: str, now_timestamp: int) -> Any: return { "$set": { "locked_by": op_id, "locked_ts_utc": now_timestamp, } }
Тестовый фреймворк
Пока проект прототипируется, тесты не звучат как хорошая идея. Архитектура меняется на ходу, код пишется и переписывается, и поддержка качественной базы тестов рискует слишком сильно всё замедлить. Но полный отказ от тестов — это тоже крайность: вы вынуждены будете постоянно тестировать одни и те же сценарии руками. А с учётом автовыкатки (см. выше) «постоянно» — это буквально постоянно, каждый рабочий день. Поэтому необходимо в самом начале выработать подход к тестам, с которым вы пройдёте через фазу разработки прототипа и первые несколько итераций доработок.
В первую очередь я отказался от юнит-тестов. Если архитектура рискует поменяться, а компоненты регулярно переписываются, от юнит-тестов совсем мало пользы (за редким исключением).
То ли дело интеграционные тесты. Если мы делаем API-сервис для приложения, на уровне запросов и ответов стабильность получается гораздо выше (особенно если мы умеем хорошо проектировать API с первого раза). Поэтому я написал в своём коде на Python эмулятор клиента: методы этого класса делали запросы в FastAPI аналогично тем, какие бы делало настоящее приложение, и я мог описывать сценарии на высоком уровне абстракции. Лишь пара вспомогательных методов делала что-то, что не смогло бы сделать приложение (например, создавала тестового пользователя в базе данных).
class UserClient: def __init__(self, users_app: App): ... def authenticate_with_google(self, auth_code="auth_code", **kwargs) -> None: # создаём пользователя с нужными кредами в базе данных forge_google_user(auth_code=auth_code, **kwargs) ... def get_profile(self) -> ResultWrapper[UserProfile]: response = self.http_client.get("/user/profile") return ResultWrapper(response, UserProfile) def update_profile(self, update: UserProfileDiff) -> ResultWrapper[UserProfile]: response = self.http_client.put( "/user/profile", json=update.dict(exclude_unset=True) ) return ResultWrapper(response, UserProfile) def get_subscription_status(self) -> ResultWrapper[ClientSubscription]: response = self.http_client.get("/subscription/status") return ResultWrapper(response, ClientSubscription) ...
Для результатов этих методов я завёл враппер, позволяющий и получить pydantic-объект с проверкой кода ответа (для большинства тестов), и напрямую залезть в Response (для тестов ошибочных сценариев). Он позволил коду всех тестов оставаться в равной мере опрятным и читабельным.
class ResultWrapper(Generic[T]): def __init__(self, response: httpx.Response, model: Type[T]): self.response = response self._model = model @property def json(self) -> Any: return self.response.json() @property def object(self) -> T: self.assert_ok() return self._model.parse_raw(self.response.content) def assert_ok(self): assert ( self.response.status_code == status.HTTP_200_OK ), f"{self.response.status_code} {self.response.text}"
Ещё один нюанс — работа со временем и отложенными задачами. Одной из особенностей нашего проекта была значительная привязка к календарю. Тут и созвоны клиентов с тренерами, назначаемые на определённое время, после которого появляются всякие опции вроде оценить качество связи, и еженедельный цикл составления тренировок, и многое другое. Поэтому вместо традиционных для Python моков datetime я написал отдельный объект Clock, через который шла вся работа со временем и который в тестах обретал дополнительную функциональность «перематывания» времени — не просто замены системного времени на нужное мне для теста, но и выполнения всех отложенных задач, которые были запланированы на перематываемый временной интервал. Это оказалось ОЧЕНЬ удобно.
def test_...(self, users_app, trainers_app): clock = get_mock_clock() clock.init_now(dateutil.parser.isoparse("2015-10-21T07:28:00Z")) # симулируем прохождение онбординга ... # перемещаемся на неделю вперёд clock.advance(timedelta(days=7)) # проверяем, что отправились напоминания о звонке с тренером ...

Работает ли такая перемотка времени быстро? Конечно, нет! Но в маленьком проекте лишние секунды на прогон тестов стоят гораздо меньше, чем лишние дни разработчика на поиск багов или сочинение многословных тестов без высокоуровневого инструментария.
Единый компонент для описания зависимостей
В кодовой базе я много использовал паттерн Observer, чтобы избежать токсичных зависимостей и циклических импортов. Одни менеджеры объявляли у себя события и триггерили их при необходимости, другие — подписывались на них. Всё как у людей!
@inject # inject - это метод DI-фреймворка class SubscriptionsManager: def __init__(self) -> None: self.on_subscription_activation = Observable[SubscriptionEvent]() self.on_subscription_deactivation = Observable[SubscriptionEvent]() @inject class CancelInactiveSubscriptionTaskQueue(TaskQueue): def __init__(self, subscription_manager: SubscriptionManager, ) -> None: subscription_manager.on_subscription_activation.add_handler( self.schedule_cancelling_inactive_subscription ) def schedule_cancelling_inactive_subscription(event: SubscriptionEvent) -> None: self.submit_task(...)
Вскоре я заметил, что происходящее в кодовой базе — в значительной мере загадка для меня. Так, при этом событии должно произойти такое последствие, но где объявлен обработчик?.. Сколько их вообще, согласованы ли они друг с другом? И отладка, и рефакторинги стали болью из-за этих размазанных по коду зависимостей.
Я пошёл на эксперимент: завёл единый компонент Flow, который связывал друг с другом все остальные компоненты.
@inject class Flow: def __init__(self, cancel_inactive_sub_tq: CancelInactiveSubscriptionTaskQueue, subscription_manager: SubscriptionManager, ) -> None: @subscription_manager.on_subscription_activation.add_handler def schedule_cancelling_inactive_subscription(event: SubscriptionEvent) -> None: cancel_inactive_sub_tq.submit_task(...)
Для максимального удобства я отсортировал обработчики в том порядке, в каком они должны срабатывать впервые для среднестатистического пользователя, дал им очень подробные имена и добавил логирование на каждый вызов (это оказалось особенно удобно в тестах).
Я переживал, что это будет компонент-помойка, создающий больше проблем, чем пользы. И на проекте в миллион строк так бы и было, но на моих 35 000 получилось очень даже неплохо: весь компонент занимает ~500 строк (что, на мой взгляд, вполне контролируемо), легко читается и даёт очень хорошее представление и о пользовательском флоу, и о связях между компонентами.
Что могло быть лучше
Выбор базы данных
Мы выбрали MongoDB, потому что у нас с фаундером был обширный и свежий опыт работы с этой базой данных на нашем предыдущем месте работы и не было такового с реляционными базами. Но мы не учли, что на наших масштабах преимущества MongoDB (масштабируемость, простота миграций) не перевешивают её недостатки (в частности, отсутствие реляций и джоинов).
Возможность нормально задавать внешние ключи, гарантировать целостность, делать JOIN и GROUP BY очень сильно упростили бы мне разработку.
Фокус на ключевой функциональности
Знаете, бывает такое, когда реализуешь какую-то незначительную функциональность и особо не стараешься — экономишь время, силы и абстракции для более важных вещей. Это отличная тактика, пока незначительная функциональность действительно незначительная.
Но как определить, что важно? Когда работаешь в специализированной команде в крупной компании, представление о важности тех или иных фич лучше всего формируется в скоупе команды. Сложность работы других команд, а то и вовсе чем занимаются другие команды, может оставаться загадкой («зачем для X нужно держать в штате семь человек?!»). Поэтому при переключении на создание целого продукта с нуля ошибиться очень легко.
Когда я садился писать фитнес-приложение, я в первую очередь задумался о тренировках. Я сделал красивые абстракции для упражнений, тренировок, недельных расписаний и прочего, тщательно продумал расширяемое API. А вот что я счёл неважным, так это онбординг. Что там, анкета из несколько вопросов? Чего здесь заморачиваться, положу ответы прямо в сущность User. Но в процессе работы над продуктом оказалось, что онбординг — это одна из ключевых компонент, определяющая финансовый перфоманс приложения и являющаяся постоянным подопытным кроликом продактов и маркетологов.

Это же коснулось промокодов («да это ж просто строчка с флагом, использована она или нет») и системы подписок. В результате код ключевых компонент написан сумбурно и пребывает в постоянном ожидании рефакторинга, а тщательно вылизанный код консервативных компонент практически не подвергается изменениям.
Нейминг
Когда заходит речь про нейминг, многие думают о советах вроде «назовите переменную user_index вместо i». На деле нейминг переменных в конкретных функциях далеко не так важен, как нейминг сущностей и выработка общих соглашений уровня всей кодовой базы.
При старте проекта крайне важно определиться с неймингом. И важно это не только для разработчиков, но и для бизнеса тоже — эффективность коммуникации между членами команды напрямую зависит от того, говорят ли они на одном, ёмком и однозначном языке.
И с этим мы справились плохо. Пример. У нас два приложения и, соответственно, два класса пользователей. Клиенты именуются user, а тренера — trainer. Но при этом также user — это общая сущность уровня аутентификационного фреймворка. То есть тренеру тоже соответствует некий user, в котором сохранены способы аутентификации. Если бы мы задумались об этом заранее, мы могли бы назвать клиентов athlete и получить гораздо более понятный нейминг во многих частях кодовой базы.
Другая проблема коснулась именования состояний, в которых пребывает пользователь. Мы пропустили много моментов, когда надо было придумать термины бизнесового уровня, поэтому в какой-то момент тикеты на разработку стали огромным полотном перечислений типа «если пользователь прошёл первую половину экранов онбординга, не пропустил экран X и нажал кнопку Y, сделать Z». Полотно перечислений условий в тикете превращалось в полотно перечислений условий в коде. Вместо этого нам стоило продумать конечный автомат бизнесовых состояний пользователя, реализовать его в коде и привязывать поведение к нему.
Заключение
Хотя наша история стартапа не закончилась беспрецедентным успехом, миллионами долларов на счетах и корпоративами на Мальдивах, опыт создания продукта с нуля мне как разработчику был очень полезен и я рекомендую каждому при возможности попробовать сделать что-то своё. Причём не как пет-проект в одиночку, а именно как бизнес — с командой из других специалистов, релизами в сторах и хотя бы единицами живых пользователей.

Сидя в крупных компаниях на готовых процессах и пайплайнах, построенных бог знает кем бог знает когда, мы привыкаем к тому, что инфраструктура строится за нас. Мы можем получить представление о её недостатках, но легко упускаем достоинства, так как не знаем, какие проблемы были ею изначально решены. Мы рвёмся повторить то, к чему привыкли, на новых местах, часто не задумываясь, нужны ли нам аналогичные решения для других масштабов. И только опыт построения чего-либо с нуля может дать нам по-настоящему глубокое, системное представление о проблемах и решениях в нашей области инженерии.
Возвращаясь к названию статьи, $115 000 — это сумма всех инвестиций, что мы смогли поднять за время работы — от friends & family и венчурных инвестиционных фондов, и по совпадению также примерная зарплата, которую я мог бы получить, вложив затраченное время в работу по найму. Стоило того?
ссылка на оригинал статьи https://habr.com/ru/articles/889758/
Добавить комментарий