Адзьедыненне запыткаў ORM пад час выканання Next.js з аблікованнем у React cache()
Дазвольце даклэ расказаць, чаму компаненты сервера, якія знаходзяцца ў аднай інфраструктуре, можу выконваць запыты да таго ж записа колькасць разоў за адну запчатку, як гэта параболіць, і як функцыя React cache() рашае гэтыя проблемы без неабходнасці прабірання прама через пропы.
Маршрут Next.js можа здавацца швайным у прыгарніку, хоць яго база дадзеных тыхамо адпавядае на тое ж пытанне тры-чатыры разы за кожны прыгляд стороніцы. Прычыной рэдкая медленная запытка; це звычны пошук, які павторяецца за межамі рэндараў, якія ў вашам коде выглядаюць незалежнымі: generateMetadata(), сама стороніца, паводкі для навігацыі, вярстаны серверны компонент. У гэтым артыкуле показана, як выступае гэтая дубляванне, як пераканацца, што гэта дзейсна відбываецца, і як падрыхтаваць гэта за дапамою React cache(), заўсёды трэбуючы, каб доступ да дадзеных быў ближэй да тых компонентаў, якімы ён патрэбен. Таксама чытальніку паказана ясная разліка межу мемоізацыяй за кожны запыт і стойкай кэшаваннем, якае адпавядае на зусім іншае пытанне.
Як адны прыгляд стороніцы ператвараецца на чатыры пошуку
Возьмім дынамічны маршрут для продукта:
/products/[slug]
Калькі часткі таго маршруту патрабуюць адно і тое ж продакт. Функцыя generateMetadata() патрабуе яго назву і апісанне для часткі дакумента. Старонка патрабуе цэлы запис. Для паўзовых паведамленняў патрабуецца кантэкст категорыі. Вярнутая ўнутраняй Серверная Компанента можа паказаць цэну або статус залягоўкі. Кожны з гэтых корыстнікаў можа розумна заваносіць тое, што яму патрабуецца, самостоятельна. Функцыя метадаў рабіць гэта так:
export async function generateMetadata({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return {
title: product.name,
}
}
Компанента старонкі рабіць тое ж самае:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return <ProductDetails product={product} />
}
А дзе-небудзь глыбей у структурэ іншая Серверная Компанента незалежна вызывае:
const product = await getProduct(slug)
З точкі зору дизайну компанентаў гэта ўсё правільна. Кожны элемент інтерфейсу запрашае своі даны там, дзе яны ўжываюцца, і адпаведальнасці застаюць чыстымі. Проблема пазірае толькі калі адзірнуцца на іншую сторону: логі запытанняў базы дадзенаў або показнікі API. Адна вхідная запытка можа стварыць кальколькі ідэнтычных запытанняў пра продукты. Адна адпаведзь надходзіць да браузера, пры тым як ўтворэнне яе могла коштаць базе дадзенаў чатыры раунд-трыпы.
Што Next.js вже дэдуплікуе, а што — няў
Перш чым чаго-небудзь зменіць, неабяжна правільна дыякрэтызацыя. Next.js аўтаматычна мемуеідзе ідэнтычныя натыўныя запыты fetch, якія выкананы пад час атрыбутавання дрэва компонентаў React, і ў яго дасяграментацыі зазначаецца, што гэта стосуецца як generateMetadata, так і лэйаўтов, сторанак і серверных компонентаў. Якщо getProduct() пабудаваны на fetch, гэтыя дуплікатныя запыты можаць ўжо быць з’едыненыя ў адны.
Калі даныя надходзяць з іншага джерага, чым fetch (ORM, драйвер базы дадзенаў, SDK трэціх сторон), аўтаматычная мемуеізацыя не выканана. У такім случае Next.js рэкамендуе викорыстоўваць функцыю React cache() для спільнага выкарыстоўвання роботы, якая павтараецца ў межах аднаго запыту.
Асалідныя моманты варта выказаць чыста: кост адработкі частаў не ёсць адной дорогай запыткі, а дзеяннем, якое выглядае недорага, але павтараецца за межамі, якія фрэймворк дазволяе лёгка адсакаваць незалежна. Рашэнне не ў тым, каб перакласты кожную запытку ў адну вялічезную родную складовую. Рашэнне — даўаць павтаральным запыткам да доступу да дадзэнняў адной спяльнай ідэнтычнасці.
Размешчэнне разам робіць павтаральную работу невыразнаю
За дапамогою Server Components стандартны падход — трываліць да доступу да дадзэнняў поблізу таго, хто іх выкарыстоўвае. Складовая, якая адрасавае деталі продукту, можа сама завантажыць продукт, і breadcrumbs не патрабуе прымкнуць вялікі об’ект продукту, які праходзіць чераз некаляжныя складовыя, толькі таму, што якой-небудзь праўейшы элемент яго спачатку завантажыў.
Без такой свабоды звычны спосаб прыткацца да ухіліцца ад дублювання роботы — гэта перакладзець усё завантажэнне на верхняю частку маршруту і перадаваць рэзультаты назад через кожны проміжны слой. Гэта работае, але спаявае компаненты, якія не маюць рэальных звязкаў. Альтернатыва — калі кожны серверскі компанент завісіць ад однай функцыі для перадзеўжвання дадзеных:
const product = await getProduct(slug)
Так кожная патрэба застаецца ля свога корыстніка. Адкрытым пытаннем є тое, чы рэальна гэтыя вызовы выкорыстоўваюць адну і тую ж основную операцыю.
Якщо ў канечнай лініі яны выдаюць ідэнтычныя натыўныя запиты fetch, React мемізуе іх у дрэве компанентаў. У дакументах Next.js самэ гэта наводзіцца як падстава для завантажэння дадзеных у тым компаненте, які іх выкарыстоўвае, а не на верхній частцы маршруту з перадачай пропсаў назад. Прямы вызов ORM, напрыклад:
db.product.findUnique({
where: { slug },
})
Компанента не прагне такой працы толькі таму, што два элементы перадаюць ёй аднаковы ідэнтыфікатор. Для React гэта проста довільная асінхронная функцыя, якая будзе выканана ў кожны раз, калі яе вызываюць, пакуль вы не зададзіце ёй ідэнтыфікатор у вигляде мема.
Граніцы компанеттакоўваюць, хто ёсць власнікам адзельнай часткі інтерфейсу. Яны нічога не кажуць пра тое, хто ёсць власнікам адзельнай часткі дадзеных, якія викорыстоўваюцца парадульна. Размешчэнне ў адным месцы не являецца памылкай; аднак припуск, што размешчэнне ў адным месцы значыць усунення дуплікацый, яўляецца памылкай.
Зьмеряйце раней, чым застосаваць мемаўванне
Мемаўванне — это адпаведнасць на зафіксаваную дуплікацыю, а не на проста падазрэнне. Напрыклад, можна знайсці такі вызов у чатырох разных файлах:
await getProduct(slug)
Гэта не ўсунець доказа таго, што база дадзеных была запытана чатыры разы. У Next.js гэта разлічэнне ўсё болей важлавае, адколькі сам фрэймворк можа вже падчыніцца аддалюкацыі ідэнтычных вызоваў fetch. Нявялікія версіі Next.js таксама апускаюць логаванне падчас розрабоцы для сервернай дзеяльнасі fetch, што дапамагае бачыць, што на самай працэ ўсё-такі запытаецца (перагляньце актуальную дакументацыю, каб з’ясаваць, як ўвядзіць гэта функцыянал у вашай версіі).
Якщо getProduct() знаходзіцца ў ORM, драйверы базы дадзеных або SDK, тады неабходна інструментавація самэй гэтай складовай. Для шырокага локальнага аналізу нават простыя даныя па часе выканання дастаць можлівасць выявіць патэрн:
export async function getProduct(slug: string) {
console.time(`product:${slug}`)
const product = await db.product.findUnique({
where: { slug },
})
console.timeEnd(`product:${slug}`)
return product
}
Якщо вы бачыце, што гэты атрыбут выдруковываецца чатыры разы за кожныя завантажэння сторонкі, значыць у вас є доказ. У рэальных умовах вы хочаце болей ясныя сігналы: логі запытоў да базы дадзеных, распрастраненыя трэйсінгі, даныя APM, лічыльнікі запытоў з вышэйшага роўна і ID запытоў, якія дазволяюць супарабатаваць кожны запыт з сторонкай, якая яго спрычыніла.
Ключова задача — адзеліць два твэрджэння, якія звучаць падобна:
Function called four times
протыва:
Underlying data source hit four times
Яны не ўзаемна эквівалентны. Якщо операцыя вже выканана толькі раз, ёе загорткаў у іншы шар не являецца оптымізацыяй; гэта толькі ускладнюе розумеенне структуры дадзеных. Оптымізавайце тую роботу, якая па-рэальнаму павтарыцца, а не павтарныя вызывы функцый, якія проста з’яўіліся ў кодзе.
Чаму функцыя, падтрымваная ORM, адхоўляецца ад процэса выкарыстоўвання механізмаў унікалізацыі
Падазроўваючы, што механізм завантажэння продукта ўсё проста:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
Тепер уявіце, што ў рамках адной заявкі на неё запускаюцца функцыя метаданых, сторнка, элементы навігацыі та компонент з вычысленням цэн. Функцыя тая ж, аргументы тые ж і запит таксама той самы. Без механізма мемуазавання кожны запуск все равно выконвае свой сабэй запит да базы дадзеных.
Для ORM або прымітнага доступу да базы дадзеных функцыя cache() у React забезпечвае мемуазавання на кожную заявку, тады як функцыя fetch у стандартнай структуре React робіць гэта без дадатковых зусиль. У дасягліх документах Next.js точна описваецца гэты патерн для прымітных запитаў да базы дадзеных, укладаючы ў гэта і ситуацію, калі як generateMetadata, так і сторнка патрабуюць той самы запис.
Такое разуменне таксама заходзіць проты популярнага міфу. Якбы getProduct() быў створаны абоўсумова на аднаковых вызовах fetch, то ўпакоўванне яго ў cache() выключна для адчынення тых дуплікатав не прынесла бы больш заўсёды, таму што Next.js вже мемаізуе іх. Таму правілам не ёсць ставіць cache() абоўсумова навакол кожнага сервернага лоадера. Неабходна пераканацца, чы рошт, якім вы завантажваеце данні, вже мемаізаваны, і дадаць спяльную ідэнтычнасць толькі там, дзе яя не ёсць. Такую версію значна сложней застаўляць без разваг.
Рашэнне: адна мемаізаваная функцыя дадзенняў
Сама змена ў кодзе ёсць невялікая. Ёсць лоадер да перашаго стану:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
А ёсць яго пасля таго, как яго упакавалі ў cache():
import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({
where: { slug },
})
})
Є два моменты, якія вартае звернуць увагу. Апыявленне server-only выклікае паўзу падчас складання, якщо гэты модуль будзе включаны ў код кліента, што яўляецца разумным захадам для всіх элементаў, якіе вярбуюцься з вашай базы дадзенаў. А функцыя cache() абгортае яе раз, на рэвэлі модуля, таму кожны корыстнік атрымлеў тую ж самую запам’ятаваную функцыю. Кожны корыстнік продовжае вызываць яе так сама, як і раней:
const product = await getProduct(slug)
React зберагае рэзультат для кожнага аргумента ў свайшым кэшы на серверной стороне. Кожны пазнейшы вызыв працэсу, через той самы абгортальнік і з тым жа аргументам, атрымлеў вярнуты зберажаны рэзультат, які на самай працэ ёсць тым жа праграмаментным обявленнем, таму канкурэнтныя вызывы чакаюць на адной запытке. React выкасывае гэтыя запам’ятаваныя рэзультаты між запыткамі сервера.
Што не патрэбна было меняць
Ценным аспектам гэтага рашэння ёсць тое, што засталося без змян. Заголовак продукту як і раней паведамляе пра сваія вимагання да дадзейнаў. Элементы наводніцы паведамленняя не атрымліваюць новых налашчэнняў. Генераванне метададзейнаў і адрасаванне сторнічка продовжвае запрашваць дадзеныя пра продукт незалежна, і жаных компонентаў UI не прымушваюць выступаць у ролі власніка, якой у іх няма. Оптымізацыя выконваная абоўсумова на рэгліях дадзейнаў. Тое, што было цэнтрызаванае, — гэта не месца спраўлівання дадзейнаў, а ідэнтычнасць операцыі, якая іх генеруе.
Проблемы з cache()
Некалькі деталей рэалізацыі вялікай меры вплываюць на тое, чы рэальна будзе працаваць мемоізацыя:
- Раздзеліцеўваюце адну функцыю з мемаізацыяй. Абгортаванне аднаго ладчыка за дапамой
cache()у двух разных месцах стварае две незалежныя функцыі з мемаізацыяй, кожная з якіх мае свой сховыш — така поведзенне чытальна описана ў React. Задайце абгортавач аднойчы у вашам модулі доступу да дадзэнняў і імпортуйце яго ў всіх месцах. - Вядомае прывалюйце прымітывныя аргументы. React параболюе аргументы па ідэнтычнасці, таму строка у формате slug надзейна паставляецца з кэшу, тады як аб’ект, створаны занова на кожны вызыв, напрыклад
{ slug }, ўсё часы не будзе знайдзены ў кэшу. - Памяроцьце сферу дзейснення.
cache()прызначаны для рэндараўвання на сервере; па захадзе ад запыту, напрыклад у кліентскай складовай, ён не дае такой можлівасці адсортавання дадзэнняў.
Чаму проста не запрашаць усё на сторонцы?
Відразу зрозумелая альтэравацыя — загрузіць продукт аднойчы ў верхней часты і перадаць яго далей:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<>
<Breadcrumbs product={product} />
<ProductHeader product={product} />
<ProductDetails product={product} />
</>
)
}
Калі сторанка практычна прыналежыць усему об’екту продукту, гэта ўсё ж такі абсалютна правільны дизайн. Проблемы пачынаюцца тады, калі вы пераносіце всі залежнасці ад дадзэнняў выключна для таго, каб ухиліцца ад дублірацыі роботы на бакэнде. З часам колькасць пропаў зростае, проміжныя компоненты пачынаюць перадаваць дадзеныя, якіх ніколі не выкарыстоўваюць, а кожны новы дзеціні компонент, якому патрэбна якая-небудзь характэрыстыка, змушвае вносіць змяны ў усю іерархію. Межы компонентаў ў канцэ наступнае да аптымізацыйных механізмаў, а не да рэальнай прыналежнасці дадзэнняў.
Неабходныя змяны ў мемаізацыі запитоў памагаюць знайсці баланс. Чыныя рэкамендаціі Next.js кажуць, што ідэнтычныя вызовы fetch можна залишыць у тых компонентах, якім яны патрэбны, замест таго, каб выконваць завантажэння на верхнім роўні і праходзіць чераз шэрэджуванне пропаў, а для безпосередньага доступу да базы дадзэнняя cache() задае функцыю на серверы, якая даўае адпаведную семантыку дэдуплікацыі. Таким чынам вы можаце атрымаць обеяе якосці адразу:
data close to consumer
+
deduplicated underlying work
Адзінаковую работу не трэба адмахваты шляхом прымусовага некалякучых компанентаў дзеліць права на аднаго об’екта дадзэння. Выкарыстоўванне патэнтнасці залічваецца гарным выбрам, калі родны компанент па свайбе ўладае дадзэнням; проста гэта не павінна быць обавязковым, таму што слой дадзэння не мае ідэнтыфікатора для адзінаковай работы.
Мемуазаванне запытак — гэта не стойкі кэшаванне
Терміналагія Next.js лёгка прычыняе плутанне, таму важна быць точным. Функцыя React cache() у гэтым патэрне не ператварае пошук продукту аднае адвізыты на зберажаную адпаведнасць для будучых адвізытов. React чыстае своія мемуазаваныя рэзультаты сервера для кожнага запытку. У межах адного запытку адзінаковыя вызывы воспользваюцца тым жа рэзультатам:
getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result
Наступны запыткі пачынаюцца з порожньага кэшу і зноў выкананы пошук:
New request
getProduct("keyboard")
//perform the lookup again
Это ўсьмо толькі мемаізацыя запытоў, і нічага больш. Павторны выкарыстоўкі рэзультатаў у разных запытах — гэта адна з архітектурных рашэнняў. У ныякшым Next.js компанента Cache Components апранацоўвае дырэктыву use cache для кэшавання роботы па захадзе адзінаго запыту, пры чым cacheLife() кантролюе час трываласці зберагачвання элемента, а cacheTag() дазволяе бязперыясна анулювацыю кэша. нарадчык па use cache і анулюванні кэша на адной з пазначак детальна раскрывае гэты аспект.
Корыстным ментальным модэлем є тое, што гэтыя два механізмы адпаведзяюць на разныя запытанні:
- Мемаізацыя запытоў: чы хаця быць, каб ідэнтычная операцыя выканалася чатыры разы падчас стварэння адной адпаведзі?
- Пастаяча кэшавання: чы можа пазнейшы запыт павторна выкарыстоўваць адпаведзь, якая была вырахована раней?
Толькі другы элемент прыносіць свежасць і можлівасць анулювання. Спрыяйце ўваходзіць іх як два аднальныя рашэння, і праблемы з кэшаваннем у Next.js стануць значна менш заплутанымі.
Звядзе где на самай працоўваюць заходы
Сама запитанне да продукту можа быць абсалютна нормальным. Падазроўваючы, што тэлеметрыя фіксуе чатыры ідэнтычныя операцыі над базай дадзеных за кожны запит, а пасля змяны застаецца толькі адна, вы эканаеце тры запиты на кожны прывіт на сторанцу, што здаецца дробным праблемам. Але як толькі да гэтага дадзеце трафік: маршрут, які обслужвае тыячы прывітаў, ухіляецца ад трох разоў большых запытоў, а актыўны маршрут за дзень ухіляецца ад ўжо большай колькасці. Незначная дуплікацыя на адзінцова актыўным маршруце можа прынясіць тыячы непатрэбных запытоў да базай дадзеных або вызоваў да іншых сэрвісаў, пры чым жадна з адзінаковых операцый сама па сабе не выглядае абавяжліваюча.
Гэта таксама прычына, чаму конкретныя цифры заэканаеў можна прапісваць толькі тады, калі яны базуюцца на вашых саўсэбе здзійсненых вимераваннях. Якщо показнікі вашай працы паказваюць конкрэтную колькасць ухіленаўых аперацый, наводзіце гэту цифру; без телеметрыя фраза «можна заэканаць тыячы» чыста апісвае эфект падвоўлення, не ствараючы вымышлёнага кейсу.
Гэта таксама змінюе падход да роботы над выдатнасцю. Звычны вопыт звучыць так:
Which query takes 800 ms?
Часта болей цінны вопыт звучыць так:
Why are we paying for this normal query
four times within one request?
Пошук можа быць дешавым адночасна, але вялікай тратаю ў сукупнасці. Робота над выдатнасцю — гэта не толькі прышвартаванне кожной аперацыі, як можно шырэй; інодзе гэта таксама выдаленне аперацый, якія ніколі не патрабаваліся, і гэта мае вялікое значэнне, калі незначная неэфекывнасць знаходзіцца на ключовым маршруце.
Выбіранне правага межы павторнага выкарыстоўвання
Запит на мемаізацыю ўсё ж простейшы для разумення, адколі React ніколі не пераносіць мемаізаваны рэзультат у наступны запит. Для стойкага кэшавання патрэбны болей шырокія дыскусіі пра правильнасць. За дапамою компонентаў кэшу, кэшаваныя данні можаць мець чыста вказаны тэрмін жыцця і атрыбуты для паўтарной перапрацоўкі, адносна таго, што ўжыццё паўтарна між запитамі выклікае пытанні пра тое, як дазго значэнне застаецца чынным і якія запускі павинны зробіць яго застарэлым.
Таму кращы вопыт не ёсць:
Can we cache this?
а:
Across which boundary is reuse correct?
Просты спосаб разумець можлівыя межы:
- У межах аднаго запиту: безпечна для практычна будзь-якага чытання, уключаючы данні для кожнаго корыстувальця, адколі нічога не застаецца пасля адпаведнай адпаведзі. Сюды і працюе
cache(). - Між запитамі, для публічных дадзеных: падходяча для контэнту, які є адным і тым жа для кожнаго візітара, за ўмовы, што вы визначылі тэрмін жыцця і спосаб анулювання.
Гэтыя два пунктамі не ўзначальваюць толькі Next.js; яны прыменяюцца да будзь-якай кашы. Калі выходны рэзультат зменяецца залежна ад таго, хто запытаеся чы і што ён можа бачыць, ключ, які вяршыць рашэнне пра павторнае выкарыстоўвання, павінен кодаваць гэты самы разлік. Высокі паказнік успехоў кашы нічога не значыць, якщо адпаведны адказ можа прасачыцца да запытка, які ніколі не меў права на ёго. Найлепшая каша — гэтая, чыя правіла павторнага выкарыстоўвання адпавядаюць правілам правядзамасці дадзейна.
Запыткі стварылі проблему з межамі
Паглядзіце на вызов, які ўсё гэта пачаў:
await getProduct(slug)
Нічога пасуючага не было ня правільна ў функцыі generateMetadata(), ні на старонцы, ні ў вкладанай серверскай складовай. Кожны корыстнік дасправды патрабаваў продукт. Неэфектывае выкарыстоўвання ресурса з’яўілася толькі таму, што кожны з эых разумных вызоў самастаільна перакрываў тую ж адпаведную межу сервера. Для выправлення не было патрэбы прышвычаць запиты да шырэйшага выкарыстоўвання; патраэбна была толькі увага да таго, што аднаваецца адпаведны адказ колькісць разоў за кожную перрадзіцю. У коде весь спосаб выправлення можа быць настолькі просты, як адзін обгорак, заданы ўсёрод модуля дадзеных і выкарыстоўваны кожным корыстнікам:
cache(async (...) => ...)
Галоўныя выводы
- Ідэнтычныя вызоўы функцыі
fetchўжо мемаізуюцца ў дрэве React за дапамогою Next.js. Вызоўы ORM, драйвераў і SDK не мемаізуюцца, тамуcache()дае ім унікальную ідэнтычнасць для кожнага запиту. - Спачатку трэба змерыць. Функцыя, якая вызываецца чатыры разы, не є там самым, чым чатыры разы запускаецца запит да джерела дадзеных.
cache() не дзелюцься рэзультатамі.Шырэйшы урок: багато значных павышэнняя каркаснасці у Next.js даходзяць не столькі ад таго, дзе завантажуюцца даны, сколькі ад рашэння пра тое, як дзяловаць час доступу да даных як адну операцыю.