Привет! Меня зовут Вадим Клеба, я руковожу командой бэкенд-разработки в Телемосте. Последние девять лет я разрабатываю высоконагруженные распределённые системы. Раньше я разрабатывал search-as-a-service-решение с эффективным полнотекстовым поиском с учётом релевантности.
В статье расскажу, как в Яндекс 360 строили API в течение десяти лет без дропа обратной совместимости, выдерживающий сейчас сотни тысяч RPS. Вы узнаете, какие подходы мы изначально закладывали, чтобы наш API прожил так долго.
О чём поговорим в статье
В 2014 году существовали Яндекс Почта, Яндекс Диск, Яндекс Мессенджер — как отдельные сервисы. В 2021 году мы объединили их в единое цифровое решение для команд — Яндекс 360. Но когда мы начинали проектировать наш API Gateway в 2014 году, он был предназначен только для Яндекс Диска.
API Gateway — это паттерн в микросервисной архитектуре, который даёт единую точку доступа к API. API Gateway умеет принимать, обрабатывать и распределять запросы. На нём удобно строить системы контроля трафика, такие как Rate Limiter, кэши, и так далее.
В статье раскрою, как он развивался в течение десяти лет активного использования и не потерял своей актуальности. Подмечу, что это будет рассказ про наш путь, а не хардкорный туториал.
API Gateway: возможности и ограничения
Наш API Gateway называется CloudAPI. Все входящие запросы наших пользователей и сторонних систем проходят через него.
API Gateway инкапсулирует знания о внутренней системе. Он похож на паттерн проектирования «фасад», меняется только масштаб применения.
UPD: В статье я буду писать слово «ручки» — так в Яндексе мы называем эндпоинты или хэндлеры.
Так как все запросы проходят через него, их удобно мониторить. Ещё, если вам нужно ограничить доступ к ручкам, то это тоже удобно делать на API Gateway. Ручками, кстати, в Яндексе мы называем эндпойнты или хендлеры.
API Gateway — это не серебряная пуля. Как и у любого другого сервиса, у него есть свои минусы:
Рост времени отклика. API Gateway — ещё одна система, которой нужно принять и обработать запрос, а также что-то с ним сделать. Ваша latency на запрос вырастет.
Единая точка отказа. Всё, что находится за API Gateway, будет недоступно, если он ляжет.
Дополнительные издержки. Это ещё одна система, которую нужно релизить, поддерживать и обслуживать.
Хроники развития API Gateway в Яндекс 360
2014 год: создали новый-кленовый API на базе доменной модели
В 2014 году в нашей команде стояла задача: создать новый публичный API Диска. На тот момент из всех утюгов звучал REST. Готовить его никто не умел, но все очень хотели научиться.
Тогда у нас уже был публичный API в виде WebDAV API, но в нём нам становилось тесно. WebDAV API — это протокол для управления удалённой файловой системой, а Яндекс Диск — это облачный сервис, который выходит за грани обычной хранилки. Подробнее о том, почему мы не выбрали WebDAV API, можно почитать в статье «Новый REST API Яндекс.Диска и Полигон. А также зачем Диску ещё один API и как мы его делали».
Richardson maturity model
Мы стали проектировать наш API, вдохновившись статьёй Мартина Фаулера «The Richardson Maturity Mode»l, где он описывает книгу «REST in practice» 2010 года. В ней говорится, что в Web API делится на четыре уровня:
Уровень №0. Есть один метод http, есть один url. И в контенте запроса определяется, что мы хотим от сервера. Также мы никак не обрабатываем http-коды, нам достаточно кода 200. И в самом контенте-ответе приходит описание ошибки. Примерно так работает RPC.
Уровень №1. На этом уровне у нас подключаются ресурсы. То есть из body запроса у нас перетекает либо функция, либо название entity в сам url. И у нас по-прежнему используется один метод.
Уровень №2. На этом уровне у нас подключаются http-методы, которые несут за собой какую-то функцию, либо действия над ресурсами: GET, POST, PUT. А также на этом уровне мы начинаем обрабатывать http-коды.
Уровень №3. HATEOAS. На этом уровне, помимо полезного body, который мы возвращаем в ответе, мы ещё возвращаем ссылки для гипермедиа-переходов — мы остановили свой выбор на этом уровне.
Мы взяли стандарт HAL и реализовали его поддержку в нашем фреймворке. До сих пор можно посмотреть на ручки с поддержкой HAL в REST API Диска. Однако быстро стало понятно, что пользователям API HAL не очень нужен, а его поддержка требовала дополнительных трудозатрат. Поэтому мы довольно быстро отказались от него и откатились к уровню №2.
Обычным проектированием API мы не обошлись. Уже тогда мы понимали, что у нас ещё будут другие сервисы помимо Диска. И построили сервис, который назвали CloudAPI. Тогда мы ещё не знали, что это будущее представление нашего API Gateway. Также мы сразу завезли туда генерации Open API спеки, потому что хотели, чтобы наш код был машиночитаемым, то есть мы могли генерировать что-то из нашей спеки.
Тогда это был Swagger стандарт 1.2. Нам даже удалось немного поработать в рабочей группе по формированию стандарта.
Итак, как стал выглядеть наш API? Так как мы работаем с Диском пользователя, у нас есть файлы и папки. По факту файлы и папки — это наши доменные модели мы их никак не разделяем, для нас это какие-то ресурсы. И это стало для нас первой абстракцией, которую мы используем для проектирования нашего API. У нас есть методы — копирование или перемещение. Эти операции долгие, поэтому мы выполняем их асинхронно. Как только вы выполняете копирование, мы сразу же отдаём идентификатор операции, и вы уже можете отслеживать статус этой операции и прогресс, дёрнув отдельную ручку. Операция — это вторая абстракция, которую мы использовали.
Однако наши ручки стали многословны и тяжеловесны. Нашим клиентам и фронтендерам весь ответ стал не нужен, а понадобилась отдельная часть. /
Как можно решить эту проблему? В первую очередь можно сделать новые ручки под конкретного клиента и задачу, которые будут возвращать нужный ответ. Однако делать ещё один API мы не хотим.
И тогда мы сделали глобальный параметр на нашем шлюзе, который назвали fields. Он позволяет нам управлять выдачей контента. В запросе мы указываем query-параметр, где перечисляем через запятую список полей. Шлюз, как только он начинает формировать ответ, отфильтровывает все ненужные поля, которые мы не запросили, и возвращает его.
Fields — фильтрация полей
Получаем большой Response:
GET
/disk/resources?path=%2F
{
"_embedded": {
"sort": "",
"items": [
{
"name": "СodeFest",
"exif": {},
"created": "2024-01-18T10:54:10+00:00",
"resource_id": "1584921471:29e4d973fa7a12a0deba0c3033ee2302f9b948207cf92abceead57e081cc569b",
"created": "2024-01-18T10:54:10+00:00",
"path": "disk:/CodeFest",
"comment_ids": {
"private_resource": "1584921471:29e4d973fa7a12a0deba0c3033ee2302f9b948207cf92abceead57e081cc569b",
"public_resource": "1584921471:29e4d973fa7a12a0deba0c3033ee2302f9b948207cf92abceead57e081cc569b"
},
"type": "dir",
"revision": 1660820050819410
}
],
"limit": 20,
"offset": 0,
"path": "disk:/",
"total": 33
},
"name": "disk",
"exif": {},
"resource_id": "1584921471:7f8fc90be644c00fed5285798e97e874d295aaa0e8e0fe99ecf7ca412e5f3fe1",
"created": "2012-04-04T20:00:00+00:00",
"modified": "2012-04-04T20:00:00+00:00",
"path": "disk:/",
"comment_ids": {},
"type": "dir",
"revision": 1647409007892806
}
То, что нам нужно:
GET /disk/resources?path=%2F&fields=_embedded.items.name
{
"_embedded": {
"items": [
{
"name": "СodeFest"
}
]
}
}
2015 год: научились с помощью одного запроса выполнять несколько подзапросов. Сделали Batch API
Мы начинали делать новую фичу — нотификации в вебе: когда вы загружаете файл, там появляется отдельное окошечко со статусом загрузки.
Когда вы загружаете фотографию в папку, то мы отрисовываем две миниатюры. Есть большая фотография и большая превьюшка в файле в папке, и есть маленькая обрезанная миниатюра, которая находится в отдельном окошке. Запросить миниатюру мы можем через запрос мета-информации.
Каким образом мы можем это реализовать?
Способ №1. Можно сделать кастомную ручку, но делать этого мы не будем. Нам нужно, чтобы на разных наших платформах логика была одинаковая.
Способ №2. Можно модифицировать уже имеющуюся ручку, чтобы она возвращала несколько превьюшек. Можно передавать настройки превьюшек через запятую, либо передавать несколько значений через амперсанд. Работать это не будет, потому что у нас появляется проблема: мы не сможем соотносить ответ с тем, что мы запросили. Не все веб-сервера обрабатывают несколько назначений через амперсанд.
Способ №3. Сделать один запрос, в котором клиент будет указывать, какие подзапросы ему нужно выполнить. Клиент делает запрос, в базе указывает список подзапросов, которые ему нужно сделать. На шлюзе мы отправляем эти запросы, собираем в единый ответ и отправляем клиенту. Профит!
Так у нас и родился Batch API. Теперь у нас клиенты могут делать разного рода комбинации, чтобы получить что-то в один запрос.
// Запрос
{
"items": [
{
"method": <HTTP-метод>,
"relative_url": <Относительный URL>,
"headers": {
<Идентификатор заголовка>: <Значение>
} // опционально
},
...
]
}
// Ответ
{
"items": [
{
"code": <HTTP-код ответа>,
"body": <Тело ответа в виде строки>,
"headers": <Заголовки ответа>
},
...
]
}
Как работает под капотом? У нас есть batch-процессоры. Когда приходит к нам запрос в Batch API, мы выбираем нужный batch-процессор. Если никакого кастомного batch-процессора нет, то мы используем дефолтный, который параллельно выполняет запросы, собирает ответ и отправляет его.
Такая функциональность понадобилась нам позже.
Кулстори, где понадобился Batch API
У нас есть технология — DataSync. Это механизм синхронизации с возможностью синхронизации с любого места массива данных. Например, вы сохраняете в Яндекс Браузере закладку, открываете телефон, и эта закладка там появляется.
Помимо Браузера эту функциональность использует главная страница Яндекса. Раньше она брала оттуда информацию о виджетах — это было в те времена, когда они отрисовывались на главной странице. И чтобы эти виджеты показывались одинаково на всех устройствах, главная сохраняла информацию о настройках в DataSync. А также она брала информацию о рабочем и домашнем адресе, чтобы показывать вам, сколько добираться до дома или работы.
Чтобы главная страница открывалась быстро, все запросы, которые выполняются, должны укладываться в 100 миллисекунд. Текущий запрос в это время не укладывался.
Что же мы сделали? Мы написали кастомный batch-процессор. Мы сделали оптимальную ручку в DataSync, которая принимает несколько источников, из которых нужно забрать данные. И там выполняются оптимально запросы, вытягиваются данные из базы данных. Когда к нам приходит запрос, мы понимаем, что нам нужно использовать кастомный batch-процессор. Мы делаем запрос в нашу оптимальную ручку и собираем ответ так, будто это два запроса. И так мы стали укладываться в нужное время. Наши пользователи не заметили, что что-то изменилось — они всё ещё думают, что мы выполняем два запроса асинхронно.
2016 год: эпоха умных лент и появление chaining-процессоров
2016 год, приложения стали делать акцент на вовлечённость, многие начинали внедрять в свои приложения умные ленты. Яндекс Диск — не исключение. Мы внедряли фид, который показывает вам информацию о файлах, которые вы недавно загрузили и недавние фотографии.
Но тут мы столкнулись с проблемой, что при плохом интернете эта лента не загружалась, потому что она состояла из блоков. Одним из таких блоков были подборки недавних фотографий. В таких подборках были идентификаторы к файлам, а нам нужно было получить сами картинки, а не идентификаторы. В идеале — за один запрос. Как можно решить эту проблему?
Можно сделать кастомную ручку… Но нам нужна одинаковая логика на всех платформах. Мы воспользовались опытом построения batch API, немножко его модифицировали, и у нас появилась chaining API.
Пример запроса:
{
"method": "GET",
"relative_url": "/v1/disk/resources?path=%2F",
"subrequests": [
{
"method": "GET",
"relative_url": "/v1/disk/resources?path={body.items.0.name}"
},
{
"method": "GET",
"relative_url": "/v1/disk/resources?path={body.items.1.name}?fake={headers.Content-Length}"
}
]
}
За один запрос мы можем выстроить цепочку вызовов и использовать контент предыдущего запроса, чтобы выполнить следующий запрос. Под капотом chaining API то же самое, что и в Batch API — chaining-процессоры. У нас есть дефолтный chaining-процессор, который выполняет последовательно запросы, собирает единый ответ и отправляет клиенту. Пока что кейса, где нам понадобилась эта функциональность за десять лет, так и не появилось, но мы верим, что время chaining-процессоров придёт.
Так у нас появился универсальный механизм, где мы можем выстраивать цепочки вызовов за один запрос. Тем самым мы уменьшаем раундтрип для мобильных клиентов и они начинают работать более оптимально при плохом интернете.
2017–2019 годы: как мы сделали универсальный механизм отдачи большого контента
Паттерн использования приложений меняется с 2014 года. Выходят новые телефоны с крутыми камерами, места на телефоне не хватает и люди активнее используют облачные хранилища и сохраняют туда свои файлы и синхронизируют эти данные с облаком.
Из-за этого полное скачивание снапшота (слепок базы данных на момент синхронизации с облаком), стало занимать значительное время. Это тяжёлая операция для клиента, ему нужно скачать большой объём данных. А если в процессе пропадёт интернет, то нужно скачивать этот объём данных заново. Для бэкенда тоже нелёгкая операция. Помимо того, что у пользователя есть свои файлы, с ним могли поделиться какими-то файлами и папками, и нам тоже нужно сходить за этими данными уже к другим пользователям. Как можно решить эту проблему?
Чтобы клиент бесперебойно получал всю информацию, её нужно отдавать по кусочкам. В голову сразу приходит решение с page/offset/limit. Работать будет, но нам нужно не забывать, что у нас есть мобильные клиенты. У них есть хвост старых версий, который длится примерно два года. Если мы захотим что-то изменить в нашей выгрузке, то мы сломаем старые клиенты. Можно сделать новую ручку… но это не наш вариант. Мы хотим управлять этой синхронизацией с бэкендом.
Мы сделали iteration key. Это ключ, который мы формируем на бэкэнде и возвращаем его при каждом запросе. Клиент передаёт этот ключ, чтобы получить следующую порцию данных. В самом ключе заложена информация о текущем прогрессе выгрузки. Бэкенд, получая ключ, расшифровывает его, понимает, какую следующую пачку данных ему отдавать, формирует эту пачку и новый ключ и отдаёт клиенту. Да, мы его расшифровываем. Если вы будете отдавать в этом ключе что-то осознанное, знайте, фронтендер его распарсит, будет использовать не так, как вы задумали, и ваша синхронизация сломается.
Запрос:
GET
/v1/disk/snapshot
Ответ:
Response:
{
items: …,
iteration_key: "SOME_VALUE"
}
Запрос:
GET
/v1/disk/snapshot?iteration_key=SOME_VALUE
Что нам даёт такой подход? Клиент итеративно получает ключ, отправляет его, чтобы получить следующую порцию данных. А на бэкенде у нас есть полностью управляемая функциональность синхронизации. Если нам потребуется изменить то, как у нас синхронизируются данные, мы начнём отправлять новый ключ и не сломаем наши старые клиенты.
Так мы заимели универсальный механизм отдачи большого контента, который назвали iteration_key.
Что в итоге
Спустя 10 лет API Яндекс Диска разрослось — то, что вы видите в публичном доступе — только вершина айсберга. Количество ручек выросло, и это не обычные CRUD-ручки, а ручки со сложной логикой. Наш CloudAPI оброс функциональностью, которая может использовать не только сервис Яндекс Диск, но и другие продукты Яндекс 360 — Телемост, Биллинг, Календарь. А все наши новые сервисы в Яндекс 360 сразу же начинают использовать наш API Gateway.
Чтобы лучше прочувствовать масштаб, скажу, что начинали мы с десяти тысяч RPS, а сейчас это сотни тысяч RPS ежедневно.
Мы активно продолжаем развивать наш API Gateway — заносим туда нашу новую функциональность, а подходы, которые мы изначально сформировали, помогли прожить нашему API без переписывания много лет.
Итак, что же нам позволило так долго прожить?
API — это доменная модель. Когда вы проектируете API, вы должны проектировать реальный мир без оглядки на платформы, клиенты, фреймворки и другие факторы. Потому что реальность меняется эволюционно, а не революционно. Версии фреймворков, клиентов, языков не должны никак влиять на ваше решение.
API Яндекс Диска пережил три версии дизайна веба, ПО и две версии мобилки. За время пока жил API Яндекс Диска, у нас появились винфоны (Windows Phone), а потом они умерли. Это доказывает, что клиенты изменчивы, а предметная область — нет.
API должен укладываться в универсальный, долгоживущий, общеупотребимый, широко используемый стандарт. И для нас это http, потому что он работает одинаково на мобилке, десктопе, телевизорах и других устройствах. Упаковка API в рамках http даёт нам такую же универсальность, как и сам http.
Вот и все. Стройте свой API так, чтобы он был антихрупким!
Комментарии (14)
olku
28.08.2024 10:55+3И за "ручки" вместо общепринятых в индустрии терминов.
C4ET4uK
28.08.2024 10:55+1Я кстати тоже когда в яндекс пришел обратил на это внимание. Есть предположение, что этот термин появился внутри яндекса до появления "общепринятого термина" в рунете и так с тех пор и используется. Что в принципе не отменяет того факта, что текст написанный на внешнюю аудиторию должен использовать общепринятые термины.
Amareis
28.08.2024 10:55Замечу что handle переводится как раз как "ручка, рукоять", так что ее слишком натянуто звучит. Да и слышал я это не только в яндексе.
Derevtso
28.08.2024 10:55+2Справедливости для, в половине мест, где я работал, в "повседневной речи" они также назывались "ручками", причём, все прекрасно знают, что в коде это handler (или, в частности, метод API в некоем контроллере).
Так что, "общепринятый стандарт", на мой вкус, вполне "плавающий" и гибкий.
olku
28.08.2024 10:55ИТ РФ не ограничивается, а Хабром пользуются и в образовательных целях. Endpoint и Handler это не одно и то же.
Derevtso
28.08.2024 10:55Вне РФ, если хотят не пользоваться русскоязычными просторечиями - не пользуются, всё просто. И в РФ тоже. В айти вообще нет устоявшихся "железобетонных", прибитых гвоздями к отрасли терминов кроме тех, что пришли из "строгой" науки - математики, философии, логики.
Скажем, недавно я, с пеной у рта, пытался доказать, что класс - это не просто структура, на что мне аргументировали, что внутри класс - та же структура, только с методами. Просто разные точки зрения на одну ситуацию.
Айтишники, в большинстве своём, по моим наблюдениям, не любят избыточно строгих правил, и если за слово "ручка" их будут бить батогами, причём, на русскоязычном ресурсе где присутствуют носители разных понятийных аппаратов, ничего хорошего из этого не выйдет.
Это того же рода разночтения как "винчестер"/"жёсткий диск"/"хард".
В качестве компромисса, могу предложить указывать разово в явном виде "ручка (handler)".
gld11
28.08.2024 10:55+5Что за душня по поводу ручек в коментах, хороший термин, ради чего усложнять?
Статья хорошая, спасибо
SuperKozel
Хочется по случаю выразить фу за убийство почты для домена.
jackchickadee
яндекс почта для домена работает, но нужно деньги платить, постоянно вводить подтверждения и слить свой телефон, иначе потеряете доступ несмотря на деньги.
upd: и периодически капчу капчевать. поэтому лучше расчехлять клиентов imap+smtp.
а вот за удушение доступа WebDAV для яндекс-диска - реально фу.
впрочем йуным пользователям наплевать. есть клиенты с закрытым кодом - будут гонять эти клиенты. и натыкивать в браузере конечно.
Derevtso
Где-то на форуме rclone встречалась тема о том, что очень сильно скорость проседает, если закачивать видео (видимо, оно в реалтайме парсится).
И, по моему опыту, архивы. Вероятно, они так же как-то анализируются.
Однако, если закачивать рандомный набор байт, с "не-видео" расширением, скорость, по словам комментаторов той темы, была приемлемая. Сам пока не проверял.
eaa
Следуя их мантре об изменчивости, не надо затачиваться на яндекс, много чего есть поудобнее и с диском, и с почтой