Перейти к содержанию

Пакетная загрузка авторов

Вложенный author уже связывает курсы с отдельными объектами. Но при большом списке одинаковый автор может запрашиваться много раз. Один HTTP-документ не гарантирует один запрос источника: каждый resolver поля выполняет свою работу. Теперь добавим пакетный загрузчик и запросный кэш. Наш пример использует Map, поэтому показывает алгоритм сопоставления, а не измеренное ускорение SQL. Никакие замеры или учебные обращения при подготовке не выполнялись.

Загрузчик создаётся внутри run и передаётся в context. Он живёт только на протяжении конкретного выполнения операции. Следующий запрос получает новый экземпляр. Такая граница одновременно ограничивает устаревание и уменьшает риск смешивания данных разных субъектов. В Academy имена авторов публичны, однако подход должен оставаться понятным до подключения закрытого источника. Глобальный кэш «на всякий случай» принёс бы новые правила жизни и доступа.

Отложенное получение ключей

Вызов loadAuthor с ID возвращает Promise. Загрузчик складывает ожидающий ключ в pending и планирует microtask flush. Несколько соседних вызовов, сделанных до обработки очереди, попадают в один набор. Во время flush Map авторов читается по собранным ключам, затем каждый Promise получает результат для своего ID. Само откладывание не выполняет SQL и не объединяет данные магически: оно создаёт место для одной пакетной операции источника.

Если один ID запрошен повторно внутри того же контекста, cache возвращает прежний Promise. Это важно не только для уже готового значения: повтор во время ожидания также не должен создавать второе обращение. Мы кэшируем обещание результата, поэтому несколько связанных resolvers разделяют одно состояние получения. Такой договор помогает понять отличие запроса «сведения ещё загружаются» от отсутствия значения в кэше.

function authorLoader(){
 const cache=new Map();let pending=new Map(),scheduled=false;
 function flush(){const batch=pending;pending=new Map();scheduled=false;
  const rows=new Map([...batch.keys()].map(id=>[id,authors.get(id)??null]));
  for(const [id,resolve] of batch)resolve(rows.get(id));
 }
 return id=>{if(cache.has(id))return cache.get(id);const promise=new Promise(resolve=>{pending.set(id,resolve);if(!scheduled){scheduled=true;queueMicrotask(flush)}});cache.set(id,promise);return promise};
}

Это полная функция authorLoader из academy.mjs состояния lesson-09. Она не требует отдельной зависимости DataLoader и предназначена для небольшого учебного источника. В промышленном приложении можно выбрать готовую библиотеку с управлением пакетами и ошибками, но сначала полезно понять обязательные свойства: область запроса, стабильное соответствие ключа и результата, кэш незавершённого Promise и явное отсутствие объекта.

Порядок результата

Пакетный SQL часто возвращает строки не в том порядке, в котором клиент передал ID. Поэтому нельзя просто взять массив результатов и раздать его ожиданиям по позиции. Наш flush строит Map по ключу, затем разрешает каждый запрос через его ID. Если источник вернул Ирину после Павла, курсы всё равно получают правильного автора. Быстрое неверное сопоставление опаснее повторных обращений, потому что выглядит как успешная содержательная выдача.

Для отсутствующего a99 возвращается null, а resolver author превращает это в ожидаемый GraphQLError AUTHOR_UNAVAILABLE. Nullable граница Course.author остаётся прежней. Загрузчик не проглатывает ошибку и не заменяет отсутствующего автора случайным первым объектом. Такой договор сохраняет частичный data/errors из шестого урока. Оптимизация получения данных не должна незаметно менять предметное значение отсутствия метаданных.

В текущей функции Map читается синхронно. Если подключить асинхронную базу, flush потребуется обрабатывать исключение всей пакетной операции и завершать каждый ожидающий Promise. Нельзя оставить некоторые обещания незавершёнными после ошибки. Также понадобятся максимальный размер набора и разделение больших списков по пределам источника. Учебный алгоритм показывает основу, но не объявляется готовым универсальным загрузчиком любого SQL-драйвера.

Область жизни кэша

После завершения run ссылка на context больше не используется операцией, а новый run создаёт новый cache. Поэтому изменение данных между запросами не обязано оставаться незаметным из-за старого результата. Сохраняемый межзапросный кэш был бы отдельной функцией с TTL, инвалидированием и политикой ключей. Нельзя добавлять его как простую замену локальной Map, не определив, когда сведения считаются устаревшими и кто имеет право увидеть сохранённое значение.

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

После mutation в том же выполнении кэш может потребовать очистки, если действие меняет объект, который уже загружен. Наша enroll не меняет авторов, поэтому такой конфликт отсутствует. Это конкретное свойство текущей предметной операции, а не гарантия GraphQL для любых mutation. При будущей updateAuthor понадобится определить, какие ключи очищаются либо заменяются новым значением. Алгоритм получения должен согласовываться с изменением источника, иначе ответ покажет прежние метаданные после успешной записи.

Что действительно объединяется

GraphQL выполняет независимые поля чтения так, что несколько вызовов могут быть запланированы до flush. Однако не все этапы произвольного документа обязаны попасть в одну партию. Вложенная зависимость может открыть новые ID лишь после завершения предыдущего источника. Поэтому пакетирование уменьшает повторную работу в доступном окне, а не гарантирует ровно один вызов на весь запрос любого графа. Такое ограничение важно для честного анализа производительности.

Псевдонимы также могут заставить клиент запросить похожие связи в разных местах. Кэш по ID предотвращает повторную загрузку одного автора в контексте, но число полей ответа остаётся связанным с документом. Даже идеально пакетированный источник не делает бесконечно большую выборку бесплатной: сериализация и размер выдачи тоже занимают ресурсы. Следующий урок ограничит глубину и количество полей до выполнения, дополнив предел тела транспортного запроса.

Для самостоятельного чтения выберите документ авторов всех трёх курсов. Ирина нужна один раз в текущих данных после изменения третьего курса, Павел — один, a99 — отсутствует. Затем верните c03.authorId в a01 и проследите повторное использование Promise. Такие варианты показывают механизм без придуманного числа SQL-запросов. При подготовке приложение не запускалось и источник не измерялся; статья даёт ожидаемое поведение конкретного алгоритма.

В завершённом состоянии resolver остаётся небольшим: он получает await context.loadAuthor, проверяет отсутствие и возвращает Author. Правила формы ответа и ошибок не перемещаются в инфраструктурный кэш. Такое разделение помогает развивать источник отдельно от схемы. В следующем шаге посмотрим на другой вид ресурса: документ может быть корректным по типам, но чрезмерным по объёму выполнения, и сервер должен иметь явный предел допустимой работы.

Исходники урока

Оглавление серии · Скачать исходники. Полное самостоятельное состояние находится в lesson-09. Учебные приложения, зависимости, база и проверки при подготовке не запускались; описанный результат ожидается по исходникам. Материалы пока находятся в локальной редакционной версии.

Первичные источники: документация 1.