Deep Engineering

ЗАМЕР

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,
  ),
);