Каноническая архитектура, Дизайн URL, Логика резолвера, Спецификация API и масштабируемости

Illustration
Geo Discovery: каноническая архитектура, дизайн URL, логика резолвера, API и спецификация масштабируемости
Этот документ описывает поверхность географического поиска (страна/город/радиус) для нескольких порталов (multi-tenant), без немедленного рефакторинга базы данных и без жёсткой связи с маршрутизацией CMS (страница/пост). Он сохраняет стабильность SEO, остаётся дружественным к кэшу и оставляет пространство для бронирований, отзывов и карт, не превращая приложение в монолит.
Ограничения и не-цели
- Multi-tenant: одна и та же кодовая база обслуживает несколько порталов. Tenant влияет на область контента, брендинг и иногда на источник данных.
- Географический поиск должен поддерживать: страну, город и поиск по радиусу (вокруг точки).
- Без немедленного рефакторинга базы данных: мы не можем прямо сейчас перестроить существующие таблицы в идеальную geo-схему.
- Независимость от маршрутизации CMS: geo-страницы не являются «постами» или «страницами». Они не должны блокироваться конфликтами slug CMS.
- Стабильность SEO: канонические URL не должны меняться при изменении фильтров или сортировки.
- Дружественность к кэшу: CDN и серверный кэш должны иметь предсказуемые ключи. Избегать вариаций на пользователя.
- Строгое разделение ответственности: discovery, CMS и резолвинг tenant — это отдельные модули с чёткими границами.
- Не-цель (пока): идеальное геокодирование. Мы принимаем один геокодер, одну стратегию нормализации и сохраняем нормализованный результат.
Каноническая архитектура
Мы реализуем географический discovery как отдельный bounded context с минимальной публичной поверхностью: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Response → SEO и метаданные кэша. Маршруты CMS никогда не вызывают discovery. Discovery никогда не вызывает маршрутизацию CMS. Они разделяют только низкоуровневые утилиты (HTTP, кэш, tenant-контекст).
Ключевые компоненты
- TenantContext: определяет tenant по заголовку Host (или по явному идентификатору портала во внутренних вызовах).
- GeoResolver: парсит и валидирует geo-сегменты URL; формирует нормализованный GeoQuery.
- GeoIndex (Read Model): отдельная таблица/коллекция, которая сопоставляет entityId → lat/lng + tenant scope + минимальные поля для поиска. Это позволяет избежать рефакторинга исходных таблиц БД.
- Data Providers: подключаемые источники (например, существующие SQL-таблицы, WordPress API, сервис другого портала), скрытые за интерфейсом.
- SEO Router: формирует канонический URL и метаданные (canonical, hreflang при необходимости, robots-флаги).
- Слой кэширования: ключи CDN-кэша и серверного кэша с tenant-скоупом и версионированием.
Структура каталогов
apps/
web/
routes/
discovery/ # discovery entry points (not CMS)
pages/
[...cms].vue # CMS catch-all (kept away from /discover)
server/
src/
tenant/
TenantContext.ts
tenantConfig.ts
discovery/
geo/
GeoResolver.ts
GeoQuery.ts
GeoCanonical.ts
GeoController.ts
providers/
GeoProvider.ts
SqlGeoProvider.ts
RemoteGeoProvider.ts
index/
GeoIndexRepository.ts
migrations/
cache/
Cache.ts
cacheKeys.ts
http/
errors.ts
requestContext.ts
packages/
shared/
src/
geo/
normalize.ts
haversine.ts
validation/
zod.ts
Дизайн URL (SEO Canonical)
Мы сохраняем канонический URL строго иерархическим и человекочитаемым. Фильтры и сортировка остаются в querystring, но НЕ влияют на canonical. Поиск по радиусу использует стабильную страницу «near» с canonical, основанным на округлённой координатной ячейке, а не на сырых lat/lng. Это предотвращает бесконечное количество вариантов URL и взрыв кэша. Это осознанный компромисс.
- Страница страны: /discover/{countryCode} (пример: /discover/de)
- Страница города: /discover/{countryCode}/{citySlug} (пример: /discover/de/munich)
- Near (радиус): /discover/{countryCode}/near/{cellId} (пример: /discover/de/near/u281z), где cellId — короткий идентификатор в стиле geohash
- Необязательные параметры запроса (не канонические): ?q=shih-tzu&category=pet-care&sort=rating&page=2
- Tenant НЕ включён в путь. Tenant определяется хостом (portal-a.tld, portal-b.tld). Внутренние вызовы передают x-tenant-id.
Правила каноникализации
- Страницы страны/города канонизируются на собственный чистый путь (без querystring).
- Страницы near канонизируются на /discover/{country}/near/{cellId}. Canonical формируется на основе округлённой ячейки, а не входных координат.
- page=1 исключается из canonical и из генерации внутренних ссылок.
- Неподдерживаемые комбинации (например, несоответствие страны) возвращают 404, а не редирект. Цепочки редиректов ухудшали crawl budget при тестировании.
Логика резолвера
Вход резолвера — (tenant, pathname, query). Выход — нормализованный GeoQuery с query plan. Доступа к базе данных внутри резолвера нет. Это разделение оказалось критически важным позже и спасло нас от неприятной ошибки кэширования.
export type TenantId = string;
export type GeoScope =
| { kind: 'country'; countryCode: string }
| { kind: 'city'; countryCode: string; citySlug: string }
| { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };
export type GeoFilters = {
q?: string;
category?: string;
sort?: 'relevance' | 'rating' | 'distance';
page: number;
pageSize: number;
};
export type GeoQuery = {
tenantId: TenantId;
scope: GeoScope;
filters: GeoFilters;
canonicalPath: string;
cacheKey: string;
};
import { z } from 'zod';
import type { GeoQuery, TenantId } from './GeoQuery';
const QuerySchema = z.object({
q: z.string().trim().min(1).max(120).optional(),
category: z.string().trim().min(1).max(60).optional(),
sort: z.enum(['relevance', 'rating', 'distance']).optional(),
page: z.coerce.number().int().min(1).max(200).default(1),
pageSize: z.coerce.number().int().min(5).max(50).default(20)
});
const CountryCodeSchema = z.string().regex(/^[a-z]{2}$/i);
const CitySlugSchema = z.string().regex(/^[a-z0-9-]{2,80}$/i);
const CellIdSchema = z.string().regex(/^[a-z0-9]{4,12}$/i);
export function resolveGeo(
tenantId: TenantId,
pathname: string,
query: Record<string, unknown>
): GeoQuery {
const filters = QuerySchema.parse(query);
const parts = pathname.split('/').filter(Boolean);
if (parts[0] !== 'discover') {
throw new Error('Not a discovery route');
}
const countryCode = CountryCodeSchema.parse(parts[1] ?? '');
let scope: GeoQuery['scope'];
if (parts.length === 2) {
scope = { kind: 'country', countryCode: countryCode.toLowerCase() };
} else if (parts[2] === 'near') {
const cellId = CellIdSchema.parse(parts[3] ?? '');
const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));
scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };
} else {
const citySlug = CitySlugSchema.parse(parts[2] ?? '');
scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };
}
const canonicalPath = canonicalizePath(scope);
const cacheKey = buildCacheKey(tenantId, scope, filters);
return {
tenantId,
scope,
filters: { ...filters, sort: filters.sort ?? 'relevance' },
canonicalPath,
cacheKey
};
}
function canonicalizePath(scope: GeoQuery['scope']): string {
switch (scope.kind) {
case 'country': return `/discover/${scope.countryCode}`;
case 'city': return `/discover/${scope.countryCode}/${scope.citySlug}`;
case 'near': return `/discover/${scope.countryCode}/near/${scope.cellId}`;
}
}
function buildCacheKey(
tenantId: string,
scope: GeoQuery['scope'],
filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }
): string {
const q = filters.q ? `q=${filters.q}` : '';
const c = filters.category ? `cat=${filters.category}` : '';
const s = `sort=${filters.sort ?? 'relevance'}`;
const p = `p=${filters.page}`;
const ps = `ps=${filters.pageSize}`;
const scopeKey =
scope.kind === 'country'
? `country:${scope.countryCode}`
: scope.kind === 'city'
? `city:${scope.countryCode}:${scope.citySlug}`
: `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;
return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;
}
Пошаговый поток запроса
- Edge/CDN принимает запрос. Ключ кэша включает host + путь + ограниченные параметры запроса (q, category, sort, page, pageSize, r).
- Сервер приложения создаёт TenantContext из заголовка Host. Доступа к БД на этом этапе нет.
- GeoResolver парсит URL и формирует GeoQuery с canonicalPath и server cacheKey.
- GeoController строит QueryPlan и решает, какие provider’ы вызывать, исходя из конфигурации tenant и типа scope.
- Provider выполняет запрос read-model к GeoIndex (быстро), затем гидратирует результаты из существующего источника (SQL БД, CMS API) по entity ID.
- Сборщик ответа добавляет SEO-метаданные: canonical, robots, ссылки пагинации.
- Сервер устанавливает заголовки кэша (s-maxage + stale-while-revalidate). Тело ответа привязано к tenant и никогда не шарится между tenant’ами.
Стратегия без рефакторинга: Read Model GeoIndex
Мы не можем сразу перестроить существующие таблицы контента. Поэтому добавляем отдельный GeoIndex, который можно независимо пересобирать. Он хранит минимум, необходимый для эффективного discovery: tenantId, entityId, entityType, countryCode, citySlug, lat, lng и несколько полей фильтрации. Гидратация получает полный объект из текущего источника.
Компромисс: eventual consistency. Задержка пересборки индекса приемлема для discovery-страниц. SLA: обновления появляются в течение 15 минут. Если позже потребуется real-time (доступность бронирований), это будет другая поверхность и она не должна использовать discovery-кэш.
API-поверхность
Две поверхности: (A) HTML-страницы для SEO и пользователей, (B) JSON API для клиентского рендеринга и будущих расширений. Тот же resolver и тот же query plan. Разные presenters.
- HTML: GET /discover/{country}, /discover/{country}/{city}, /discover/{country}/near/{cellId}
- API: GET /api/discovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..
- Admin (внутренний): POST /internal/geoindex/rebuild (защищён), POST /internal/geoindex/upsert (опционально, позже)
import type { IncomingMessage, ServerResponse } from 'http';
import { resolveGeo } from './GeoResolver';
import { runQueryPlan } from './GeoController';
export async function discoveryApi(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? '', 'http://localhost');
const tenantId = String(req.headers['x-tenant-id'] ?? 'default');
const scope = url.searchParams.get('scope') ?? 'country';
const country = url.searchParams.get('country') ?? '';
const city = url.searchParams.get('city');
const cell = url.searchParams.get('cell');
const pseudoPath =
scope === 'city' && city
? `/discover/${country}/${city}`
: scope === 'near' && cell
? `/discover/${country}/near/${cell}`
: `/discover/${country}`;
const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));
const result = await runQueryPlan(geoQuery);
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');
res.end(JSON.stringify(result));
}
Масштабируемость и производительность
- Основная цель по производительности: отдавать HTML discovery из CDN для популярных geo-лендингов (страна/город).
- Страницы near кэшируемы, но имеют больше вариантов (cellId + r + q + category + sort + page). Мы ограничиваем r и pageSize и строго валидируем фильтры.
- Запросы к GeoIndex должны быть быстрыми: индексы по tenantId + countryCode + citySlug; для near — использовать cell buckets (совпадение по префиксу), затем уточнять расстояние в приложении.
- Избегать дорогих SQL-haversine вычислений на больших наборах данных. Это выглядит просто. Это не так.
- Гидратация батчится по списку entityId: один запрос на entityType, а не N+1.
Стратегия поиска по радиусу (страницы near)
Мы не выполняем полный радиусный скан по всем строкам. Вместо этого: (1) cellId сопоставляется с ограничивающим bucket’ом (префикс в стиле geohash), (2) получаем кандидатов из GeoIndex по префиксу bucket’а, (3) уточняем расстояние в приложении с помощью haversine, (4) сортируем и пагинируем. Это делает нагрузку на БД предсказуемой.
export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const sinDLat = Math.sin(dLat / 2);
const sinDLng = Math.sin(dLng / 2);
const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;
const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
return R * c;
}
Модель кэширования (CDN + сервер)
Мы кэшируем на двух уровнях. CDN кэширует полный HTML/JSON для анонимного трафика. Серверный кэш хранит результаты providers, индексированные по GeoQuery.cacheKey. Ключ включает tenant, scope и ограниченные фильтры. Кэшировать нужно не всё. Доступность бронирований будет исключена позже.
- Ключ CDN-кэша варьируется по заголовку Host — это граница tenant.
- Ключ серверного кэша явно включает tenantId. Никогда не полагаться на неявный host в памяти процесса.
- TTL кэша: страна/город — 30–60 минут на CDN (stale-while-revalidate включён). Страницы near — 5 минут.
- Мы сохраняем ручной рычаг сброса кэша по tenant (суффикс версии в ключе кэша), используемый при миграциях и неудачных деплоях.
Детали стабильности SEO
- Canonical-теги всегда указывают на чистый иерархический путь (без querystring).
- Robots: при наличии неразрешённых параметров запроса (q/category/sort/page/pageSize/r) устанавливается noindex для блокировки мусорных параметров из внешних ссылок.
- Пагинация: rel=next/prev генерируется только для страниц > 1 и если resultCount > pageSize.
- Стабильная внутренняя перелинковка: UI всегда генерирует канонические пути; querystring используется только для пользовательских фильтров.
Будущие расширения без раздувания
Мы расширяем систему, добавляя providers и presenters, а не перегружая resolver. Бронирование, отзывы и карты живут на страницах сущностей или в отдельных API. Discovery остаётся поверхностью списка и фильтрации. Эта граница строго соблюдается при code review.
- Бронирование: отдельные endpoints /api/booking. Discovery показывает бейджи доступности только если они закэшированы и не персонализированы.
- Отзывы: отдельный сервис/provider отзывов. Discovery читает агрегированные рейтинги из GeoIndex (предварительно вычисленные).
- Карты: тайлы и маркеры загружаются через /api/discovery/markers с агрессивным кэшированием; не встраивать в HTML-ответ, если это ухудшает TTFB.
Одна ошибка (и что мы изменили)
Мы выпустили первую версию с серверным ключом кэша, в который НЕ входил tenantId. Локально это «работало». На staging всё выглядело нормально. Затем — продакшн: портал A начал показывать листинги портала B на городских страницах. Один и тот же путь, разные tenant’ы. Коллизия кэша. Некрасиво.
Исправление было скучным, но строгим: tenantId стал обязательным в GeoQuery, а построение ключа кэша было перенесено в resolver, чтобы его нельзя было пропустить. Мы также добавили runtime-assertion: если гидратированные сущности содержат другой tenantId, мы выбрасываем ошибку и пропускаем запись в кэш. Намеренно шумно.
Query Plan и контракт providers
import type { GeoQuery } from '../GeoQuery';
export type GeoHit = {
entityId: string;
entityType: 'place' | 'service' | 'listing';
lat: number;
lng: number;
citySlug?: string;
countryCode: string;
score?: number;
distanceMeters?: number;
};
export type GeoResult = {
hits: GeoHit[];
total: number;
page: number;
pageSize: number;
};
export interface GeoProvider {
search(query: GeoQuery): Promise<GeoResult>;
}
QueryPlan — это простой свитч на основе конфигурации tenant и типа scope. Например: одни tenant’ы используют только SQL; другие — удалённый сервис портала. Одинаковый вход GeoQuery. Одинаковый выход GeoResult. Это делает выкатывание предсказуемым.
SQL GeoIndex: минимальная схема
CREATE TABLE geo_index (
tenant_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
country_code TEXT NOT NULL,
city_slug TEXT,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
cell_prefix TEXT NOT NULL,
category TEXT,
rating_avg DOUBLE PRECISION,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, entity_id)
);
CREATE INDEX geo_index_country_city
ON geo_index (tenant_id, country_code, city_slug);
CREATE INDEX geo_index_cell_prefix
ON geo_index (tenant_id, country_code, cell_prefix);
Компромиссы (явные)
- Мы жертвуем идеальной точностью ради стабильных URL: страницы near канонизируются по cellId. Два пользователя на расстоянии 300 м могут попасть на одну каноническую страницу. Допустимо.
- Мы жертвуем свежестью ради кэшируемости: discovery-страницы не real-time. Обновления индекса могут задерживаться.
- Мы избегаем рефакторинга БД сейчас, но платим дополнительным путём записи для поддержки GeoIndex.
- Мы избегаем жёсткой связи с маршрутизацией CMS, но теперь владеем отдельным namespace маршрутов (/discover). Это осознанно и это обещание, которое нужно соблюдать.
- Мы уточняем расстояние в коде приложения для near-поиска, чтобы избежать дорогой математики в БД, перенося нагрузку CPU на слой приложения, который проще масштабировать горизонтально.
План внедрения (практический)
- Задеплоить resolver и пустые страницы, возвращающие 404, под feature flag. Убедиться, что маршрутизация не конфликтует с CMS catch-all.
- Построить job для backfill GeoIndex для одного tenant. Сверить количества с исходными данными. Ожидать расхождения и логировать их.
- Включить только страницы стран. Наблюдать за cache hit ratio и crawl-статистикой.
- Затем включить страницы городов. Страницы near — последними (они генерируют варианты).
- После стабилизации ключей кэша добавить потребителей JSON API (маркеры карт, клиентская фильтрация).
Чек-лист приёмки
- Мульти-tenant изоляция: один и тот же путь на двух хостах никогда не разделяет серверный кэш.
- Канонические URL не содержат query-параметров и остаются стабильными при изменении фильтров.
- Конфликты CMS slug не влияют на маршруты /discover.
- Страницы near не генерируют неограниченных перестановок URL (ограничены r и pageSize).
- Контракт provider’а возвращает детерминированную пагинацию и согласованные total.
- Пересборка GeoIndex может выполняться без downtime и без длительной блокировки исходных таблиц.