ЗАМЕР
bench/bff/latency.mjs
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/system-design/api-design/bff
- Как запустить
node bench/bff/measure.mjs > bench/bff/measurements.json node bench/bff/storage.mjs > bench/bff/storage.json node bench/bff/latency.mjs > bench/bff/latency.json
Запись прогона
Замеры для статьи «Backend for Frontend»
Не симуляция. Каждое число статьи взято из прогона этих файлов против
next start перед настоящим Go API перед PostgreSQL — кроме одной величины,
которая задана намеренно (см. ниже).
| Файл | Что меряет |
|---|---|
measure.mjs |
что скрипт из собственного источника страницы может и чего не может достать, когда сессия живёт в HttpOnly-cookie за BFF |
storage.mjs |
две другие колонки той же таблицы: что достаёт тот же скрипт, когда токен лежит в localStorage и когда — в куке без HttpOnly |
latency.mjs |
как цена экрана растёт с числом обходов, которые делает браузер |
measurements.json, storage.json, latency.json |
своды, из которых взяты числа статьи |
node bench/bff/measure.mjs > bench/bff/measurements.json
node bench/bff/storage.mjs > bench/bff/storage.json
node bench/bff/latency.mjs > bench/bff/latency.json
Одно число снято не этими скриптами. Поле serverLegLoopback в
latency.json (0,71 мс) получено двадцатью запросами curl к
http://localhost:8091/health с той же машины, а не из браузера, — это
записано в самом файле, в поле tool. Статья обязана называть его так же:
обращение к соседнему сервису с машины, а не «внутри датацентра».
Что здесь измерено, а что задано
Задержка на стороне браузера вводится намеренно
(Network.emulateNetworkConditions через CDP), потому что замер по петле
обратной связи сказал бы, что обход бесплатен, — а это ровно та иллюзия,
которую замер призван сломать. Введённая величина названа в тексте статьи;
измеряется не она, а то, как от неё растёт цена экрана.
Почему этот каталог появился поздно
Скрипты лежали в tmp/, а tmp/ стоит в .gitignore — в репозитории их не
было, и числа статьи нельзя было перезапустить. Правило «числа в статьях
берутся отсюда, а не из головы» для уроков выполнялось, для статей — нет.
Скрипт
96 строк/**
* Measures how the cost of a screen scales with the number of round trips the
* browser makes, and what one of those round trips costs on the server side.
*
* The browser-side latency is injected deliberately (CDP
* Network.emulateNetworkConditions), because a loopback measurement would say
* a round trip is free — which is exactly the illusion this measurement is
* meant to break. The injected figure is stated in the article; the scaling it
* produces is measured, not modelled.
*
* node bench/bff/latency.mjs > bench/bff/latency.json
*/
import { chromium } from "@playwright/test";
import { randomUUID } from "node:crypto";
const BASE = process.env.BASE_URL ?? "http://localhost:3417";
const API = process.env.DE_API_URL ?? "http://localhost:8091";
const PASSWORD = "correct horse battery staple";
const INJECTED_MS = Number(process.env.INJECTED_LATENCY_MS ?? 150);
const REPEATS = 3;
const browser = await chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
});
const context = await browser.newContext();
const page = await context.newPage();
const name = `lat-${randomUUID().slice(0, 8)}`;
await page.goto(`${BASE}/register`);
await page.getByLabel("Имя пользователя").fill(name);
await page.getByLabel("Email").fill(`${name}@example.dev`);
await page.getByLabel("Пароль").fill(PASSWORD);
await page.getByRole("button", { name: "Создать аккаунт" }).click();
await page.getByTestId("account-name").waitFor();
// Server-side leg first, with no injected latency: this is what one hop
// costs when both ends sit in the same datacentre.
const serverLeg = await page.evaluate(async (apiUrl) => {
const samples = [];
for (let i = 0; i < 20; i++) {
const t0 = performance.now();
await fetch(`${apiUrl}/health`, { cache: "no-store" });
samples.push(performance.now() - t0);
}
samples.sort((a, b) => a - b);
return { medianMs: samples[Math.floor(samples.length / 2)], samples: samples.length };
}, API);
// Now injure the network the way a mobile connection is injured.
const cdp = await context.newCDPSession(page);
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: INJECTED_MS,
downloadThroughput: -1,
uploadThroughput: -1,
});
async function timeSequential(count) {
const runs = [];
for (let r = 0; r < REPEATS; r++) {
const ms = await page.evaluate(async (n) => {
const t0 = performance.now();
for (let i = 0; i < n; i++) {
// A distinct URL each time so nothing can be served from a cache.
await fetch(`/api/auth/me?probe=${i}-${Math.random()}`, {
credentials: "same-origin",
cache: "no-store",
});
}
return performance.now() - t0;
}, count);
runs.push(Math.round(ms));
}
runs.sort((a, b) => a - b);
return { roundTrips: count, medianMs: runs[Math.floor(runs.length / 2)], runs };
}
const sequential = [];
for (const n of [1, 2, 3, 6]) sequential.push(await timeSequential(n));
await browser.close();
process.stdout.write(
JSON.stringify(
{
injectedLatencyMs: INJECTED_MS,
repeats: REPEATS,
serverLegNoInjectedLatency: serverLeg,
sequential,
recordedAt: new Date().toISOString(),
},
null,
2,
),
);