И сократил счета за серверы приложения
22 сент. 2026 г. · 7 мин на чтение
Если вы следили за запутанной историей приключений команды Flutter с временными кофейнями, то знаете, что мы объединили Flutter, Firebase и Gemini, чтобы подавать необычный кофе. Вы также знаете, что, ничего не беря за это, мы не смогли получить прибыль.
Кроме того, если вы заглянули в код, то могли заметить, что наш фронтенд на Flutter дополнялся функциями Firebase, написанными на Node.js. Это несколько удивительный исторический артефакт, учитывая, что поддержка функций Dart со стороны Firebase появилась в публичном превью практически одновременно с первым появлением GenLatte на Google Cloud Next.
Но в том-то и дело — поскольку поддержка Dart в Firebase появилась в самый последний момент, мы просто не могли использовать ее для продакшн-приложения в условиях цейтнота, когда шоу должно продолжаться. Если бы поддержка Dart в Firebase задержалась по любой причине, мы могли бы не успеть развернуть проект к Cloud Next. И поэтому, когда GenLatte появилась в апреле, мае или июне, она работала с бэкендом на Node.js.
И я принял это на свой счет.
Полный переход на fullstack Dart
Dart и язык JavaScript — это разные языки с разными сильными сторонами. В каком-то смысле это очевидное утверждение, но оно также имеет более глубокие последствия для миграции на стороне сервера, поэтому простой перенос «один в к одному», вероятно, не имеет смысла. В конце концов, Dart на сервере может обеспечить сквозную типизацию (end-to-end type safety) совместно с Dart на клиенте, и было бы настоящим позором оставаться на нетипизированных Map.
Учитывая это и замахнувшись на задачу, которая, возможно, превосходила мои силы, я решил кардинально переписать GenLatte. Среди моих многочисленных целей были следующие:
- Сократить инфраструктуру Firebase Functions до одной развертываемой функции
- Удалить все операции записи на стороне клиента, сосредоточившись вместо этого на вызове серверных функций
- Удалить все триггеры на стороне сервера и вместо этого явно выполнять любые изменения данных в серверных функциях, вызываемых с клиента
- Сохранить все проверки контроля доступа (ACL) на основе ролей
- Наконец-то получить сквозные (E2E) тесты!
Эти цели были грандиозными и не совсем вписывались в мой план на 2026 год, поэтому я, естественно, сохранил свои планы в секрете и принялся за кодинг!
Выполнение миграции
В отличие от работы над оригинальной GenLatte, которая в основном создавалась без использования ИИ, я знал, что мой сжатый график требует привлечения реактивного ассистента для написания кода. Моей моделью выбора стала Gemini 3.6 Flash, и ее сочетание низкой задержки и общих знаний очень меня выручило.
Если говорить в целом, я все равно читал каждую строку, написанную Gemini, чтобы сохранять когнитивный контроль над проектом. Это стало намного проще благодаря глубокому пониманию предмета, и без этого обязательства миграция, я думаю, не увенчалась бы успехом за те несколько дней, что были в моем распоряжении. ИИ-помощники, при всех их достоинствах, все равно очень сильно зависят от человеческого руководства.
Общие модели
Сквозная типизация fullstack означает общий код между клиентом и сервером. Как упоминалось ранее, это означает выделение любого кода, который должен выполняться на сервере, в отдельный пакет, который, что критически важно, не должен зависеть от Flutter SDK. В целом это просто, но потребовало доработок в моем собственном пакете управления данными, pkg:data_layer. Один из его сопутствующих пакетов, pkg:data_layer_firestore, зависит от клиентского SDK Firebase (который зависит от Flutter), поэтому мне пришлось добавить pkg:data_layer_firestore_admin, который написан на чистом Dart и потому дружелюбен к серверу.
Использование одной функции Firebase
По умолчанию каждая развертываемая функция Firebase превращается в собственный сервис Cloud Run. (Если вы пользуетесь Firebase Functions и слышите об этом впервые, перейдите в раздел Cloud Run в консоли Google Cloud и посмотрите, как делается «колбаса» Firebase!)
Однако, опираясь более чем на 15 отдельных сервисов Cloud Run в эпоху Node.js для GenLatte, я знал, что хочу более компактную настройку по нескольким причинам. Во-первых, развертывание стало бы значительно быстрее, но, что еще важнее, это радикально сократило бы счета за серверы GenLatte.
«Но Крейг!», — скажете вы, — «Cloud Run масштабируется до нуля, когда не используется, так имеет ли это вообще значение?».
Отличный вопрос. Я рад, что вы внимательны.
И да, это очень важно! Пока GenLatte использовалась, различные операции записи данных и асинхронные задачи запускали все эти 15 сервисов, и хотя каждый из них отключался в режиме простоя, это все равно оказывало предсказуемое влияние на наши счета за серверы. Но, что еще хуже, пока GenLatte использовалась, мы установили минимальное количество узлов для каждого сервиса равным 1, чтобы избежать холодных стартов, что, конечно же, отключило эту функцию масштабирования до нуля. В конечном итоге запуск GenLatte обходился на удивление дорого.
Как запихнуть все в один сервис Firebase
Чтобы сэкономить деньги, я решил вдохновиться Serverpod и внедрить BackendMessage DTO, чтобы сообщить моей единственной функции Firebase, какую именно внутреннюю функцию мне нужно вызвать. С помощью хитрого трюка с pkg:freezed, который помог мне написать Gemini, я даже смог получать типизированные ответы.
Итоговая структура классов выглядит немного сложновато, но ее стоит понять, если вы любите типизацию и экономию денег.
BackendMessage
Это родительский класс DTO, который я использую в сигнатурах своих реальных функций.
dart
MessageParameters
Он реализует BackendMessage и использует pkg:freezed для привязки отдельных типов сообщений к ожидаемым классам ответов.
Трюк @Implements.fromString генерирует классы, удовлетворяющие требованию родителя привязать тип MessageResponse. И хотя использование исходным классом чистых строк кажется небезопасным с точки зрения типов, любые опечатки приводят к ошибкам в сгенерированных классах, что означает функциональную безопасность типов.
dart
MessageResponse
Это замыкает цикл, объявляя классы ответов, которые ожидает каждый вызов метода. Он предлагает комбинацию индивидуальных типов ответов для методов, которым требуется немедленная обратная связь, и пустых ответов, представляющих собой некий HTTP-ответ вроде 202 Accepted или 204 No Content.
dart
Приведя в порядок свои DTO, мне понадобилась единственная функция на стороне сервера для приема и соответствующей маршрутизации каждого входящего BackendMessage.
dart
Наконец, мне понадобились методы saveOrder и completeOrder, которые удовлетворяли бы своему контракту.
dart
Имея эти системы, я смог запихнуть произвольное количество серверных операций в одну функцию Dart. Привет, сэкономленные деньги!
Рефакторинг всех операций записи
Описанная выше система BackendMessage создала почву для немедленного достижения еще трех моих подцелей:
- Я удалил все операции записи на стороне клиента, заблокировав их в файле firestore.rules. Я также переписал свои слои управления данными так, чтобы они вызывали единую функцию бэкенда вместо прямых вызовов функций Firestore вроде docRef.set().
- Аналогичным образом я удалил все триггеры Firestore, но воссоздал утраченную функциональность в виде функций, которые я мог бы явно вызывать с клиента.
- Я сохранил строгую модель разрешений GenLatte, внедрив проверки ACL в коде на Dart, что как тестируемая система помогало мне спокойно спать по ночам.
Добавление сквозных тестов
В качестве моего последнего фокуса я смог отдавать команды Gemini, чтобы написать как тесты на стороне сервера, так и, что очень увлекательно, замокировать поведение бэкенда в клиентских тестах, которые внезапно стали сквозными (end-to-end). Учитывая мои безопасные относительно типов возвращаемые значения, это привело к стабильности и надежности приложения, которыми я остался доволен!
Радость от использования полностекового языка
Независимо от того, любите ли вы реляционные данные и поэтому используете что-то вроде , или нереляционное решение вроде Cloud Firestore, писать на одном и том же языке во всем вашем стеке — это просто мечта. И преимущества продолжают сыпаться как из рога изобилия, если этот язык — Dart, а вы наслаждаетесь сиянием полностью строгой типобезопасности с защитой от нулевых значений (null safety). Это поистине создает иллюзию, что компьютеры действительно созданы для того, чтобы быть вашими друзьями!
Мне понравилось переводить GenLatte на повсеместное использование Dart, и в процессе этого счета за сервер сократились до крошечной доли от того, что было с Node, а надежность и производительность возросли. Деньги, потраченные на токены Gemini при выполнении этого изменения, окупились всего через несколько минут работы. Теперь GenLatte сможет продолжать подавать персонализированный кофе участникам мероприятий еще долгое время, пока нам не надоест его поддерживать.









