Урок ценой $115 000: чему меня научила разработка продукта с нуля

в 10:30, , рубрики: App Store, iOS, python, с нуля, стартап

Последние пару лет в свободное от Настоящей Работы время я в роли 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 ручками.

так в 2024-м году выглядит маленький проект с одним бэкендером

так в 2024-м году выглядит маленький проект с одним бэкендером

К этому всему — ещё с десяток интеграций со всякими разными внешними сервисами: вышеупомянутый 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. Но в процессе работы над продуктом оказалось, что онбординг — это одна из ключевых компонент, определяющая финансовый перфоманс приложения и являющаяся постоянным подопытным кроликом продактов и маркетологов.

Урок ценой $115 000: чему меня научила разработка продукта с нуля - 6

Это же коснулось промокодов («да это ж просто строчка с флагом, использована она или нет») и системы подписок. В результате код ключевых компонент написан сумбурно и пребывает в постоянном ожидании рефакторинга, а тщательно вылизанный код консервативных компонент практически не подвергается изменениям.

Нейминг

Когда заходит речь про нейминг, многие думают о советах вроде «назовите переменную user_index вместо i». На деле нейминг переменных в конкретных функциях далеко не так важен, как нейминг сущностей и выработка общих соглашений уровня всей кодовой базы.

При старте проекта крайне важно определиться с неймингом. И важно это не только для разработчиков, но и для бизнеса тоже — эффективность коммуникации между членами команды напрямую зависит от того, говорят ли они на одном, ёмком и однозначном языке.

И с этим мы справились плохо. Пример. У нас два приложения и, соответственно, два класса пользователей. Клиенты именуются user, а тренера — trainer. Но при этом также user — это общая сущность уровня аутентификационного фреймворка. То есть тренеру тоже соответствует некий user, в котором сохранены способы аутентификации. Если бы мы задумались об этом заранее, мы могли бы назвать клиентов athlete и получить гораздо более понятный нейминг во многих частях кодовой базы.

Другая проблема коснулась именования состояний, в которых пребывает пользователь. Мы пропустили много моментов, когда надо было придумать термины бизнесового уровня, поэтому в какой-то момент тикеты на разработку стали огромным полотном перечислений типа «если пользователь прошёл первую половину экранов онбординга, не пропустил экран X и нажал кнопку Y, сделать Z». Полотно перечислений условий в тикете превращалось в полотно перечислений условий в коде. Вместо этого нам стоило продумать конечный автомат бизнесовых состояний пользователя, реализовать его в коде и привязывать поведение к нему.

Заключение

Хотя наша история стартапа не закончилась беспрецедентным успехом, миллионами долларов на счетах и корпоративами на Мальдивах, опыт создания продукта с нуля мне как разработчику был очень полезен и я рекомендую каждому при возможности попробовать сделать что-то своё. Причём не как пет-проект в одиночку, а именно как бизнес — с командой из других специалистов, релизами в сторах и хотя бы единицами живых пользователей.

Урок ценой $115 000: чему меня научила разработка продукта с нуля - 7

Сидя в крупных компаниях на готовых процессах и пайплайнах, построенных бог знает кем бог знает когда, мы привыкаем к тому, что инфраструктура строится за нас. Мы можем получить представление о её недостатках, но легко упускаем достоинства, так как не знаем, какие проблемы были ею изначально решены. Мы рвёмся повторить то, к чему привыкли, на новых местах, часто не задумываясь, нужны ли нам аналогичные решения для других масштабов. И только опыт построения чего-либо с нуля может дать нам по-настоящему глубокое, системное представление о проблемах и решениях в нашей области инженерии.

Возвращаясь к названию статьи,  $115 000 — это сумма всех инвестиций, что мы смогли поднять за время работы — от friends & family и венчурных инвестиционных фондов, и по совпадению также примерная зарплата, которую я мог бы получить, вложив затраченное время в работу по найму. Стоило того?

Автор: saluev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js