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

Надёжность поискового сервиса

Поисковый движок подготовлен, но передавать его административный ключ браузеру нельзя. Также не следует разрешать посетителю выбирать произвольный индекс, задавать любой фильтр или получать неограниченный ответ. Между интерфейсом и движком поставим небольшой адаптер с явным договором.

В этом уроке браузер получит знакомые операции поиска и подсказок через HTTP. Сохраняются форма, старые параметры страницы и постоянные URL материалов. Учебный сервер предназначен для локального разбора; публичное размещение требует полноценного окружения обслуживания.

Граница приложения

Адаптер выбирает один готовый индекс из окружения. Браузер передаёт только запрос, раздел и параметры страницы. Ключ с правом поиска хранится у адаптера, а ключ подготовки индекса остаётся у редакционного инструмента.

Для Meilisearch создайте отдельный ключ с действием search и доступом к выбранному индексу. Права ключей описаны в Keys API. Не кладите значения в публичную папку, исходник JavaScript или пример статьи.

Даже поисковый ключ не заменяет ограничения приложения. Адаптер проверяет длину запроса, известные разделы и диапазон страницы. Он строит фильтр из разрешённого поля и значения, а не принимает готовое выражение пользователя.

Наш корпус публичен. Если появятся закрытые статьи, понадобится проверка личности и прав до обращения к соответствующим данным. Выбор раздела и ограниченный ключ сами по себе не реализуют такую модель.

Серверный адаптер

Создайте server.py в корне учебного проекта. Он обслуживает папку public и два маршрута API. Порт 4260 выбран для отдельного примера, чтобы не заменять уже открытый локальный сайт.

from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
import json
import os
import re
import time
import uuid

ROOT = Path(__file__).resolve().parent
BASE = os.environ.get("MEILI_URL", "http://127.0.0.1:17700").rstrip("/")
INDEX = os.environ["MEILI_INDEX"]
KEY = os.environ["MEILI_SEARCH_KEY"]
if not re.fullmatch(r"articles_[a-f0-9]{16}", INDEX):
    raise ValueError("Неверный индекс")
SECTIONS = {"", "frontend", "backend", "deployment"}


def engine_search(payload):
    request = Request(BASE + "/indexes/" + INDEX + "/search",
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"})
    with urlopen(request, timeout=5) as response:
        raw = response.read(1_000_001)
    if len(raw) > 1_000_000:
        raise RuntimeError("Ответ слишком большой")
    return json.loads(raw)


class Handler(SimpleHTTPRequestHandler):
    extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".php": "text/html"}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(ROOT / "public"), **kwargs)

    def log_message(self, format, *args):
        pass

    def send_json(self, code, data):
        raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(raw)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.end_headers()
        self.wfile.write(raw)

    def do_GET(self):
        route = urlsplit(self.path)
        if route.path not in {"/api/search", "/api/suggest"}:
            if route.path.startswith("/api/"):
                self.send_json(404, {"error": "Неизвестный маршрут"})
            else:
                super().do_GET()
            return
        request_id = uuid.uuid4().hex[:12]
        started = time.monotonic()
        try:
            params = parse_qs(route.query, keep_blank_values=True, max_num_fields=10)
            if set(params) - {"q", "section", "page", "limit"} or any(
                    len(values) != 1 for values in params.values()):
                raise ValueError("Параметры")
            query = params.get("q", [""])[0].strip()
            section = params.get("section", [""])[0]
            page = int(params.get("page", ["1"])[0])
            limit = int(params.get("limit", ["10"])[0])
            if len(query) > 200 or section not in SECTIONS or not 1 <= page <= 100 or not 1 <= limit <= 50:
                raise ValueError("Границы")
        except ValueError:
            self.send_json(400, {"error": "Неверные параметры", "requestId": request_id})
            return
        code = 200
        try:
            if route.path == "/api/suggest":
                data = engine_search({"q": query, "limit": 5,
                    "attributesToSearchOn": ["title"],
                    "attributesToRetrieve": ["id", "title"]}) if query else {"hits": []}
                result = [{"id": doc["id"], "title": doc["title"]} for doc in data["hits"]]
            elif not query:
                result = {"hits": [], "total": 0, "page": 1,
                          "pageSize": limit, "totalPages": 0, "totalRelation": "window"}
            else:
                payload = {"q": query, "page": page, "hitsPerPage": limit}
                if section:
                    payload["filter"] = "section = " + json.dumps(section)
                data = engine_search(payload)
                result = {"hits": data["hits"], "total": data["totalHits"],
                          "page": data["page"], "pageSize": data["hitsPerPage"],
                          "totalPages": data["totalPages"], "totalRelation": "window",
                          "maxTotalHits": 1000, "indexVersion": INDEX}
            self.send_json(200, result)
        except (HTTPError, URLError, TimeoutError, RuntimeError, ValueError, KeyError):
            code = 503
            self.send_json(503, {"error": "Поиск недоступен", "requestId": request_id})
        finally:
            print(json.dumps({"requestId": request_id, "route": route.path,
                "status": code, "queryLength": len(query), "index": INDEX,
                "durationMs": round((time.monotonic() - started) * 1000)}), flush=True)


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 4260), Handler).serve_forever()

Имя индекса допускает только ожидаемый версионный формат. Значение не поступает из URL запроса. Фильтр раздела создаётся через JSON-строку, поэтому содержимое не становится произвольной частью выражения.

Количество параметров и повторяющиеся значения проверяются. Запрос с двумя q не должен зависеть от того, какую строку случайно выбрал парсер. Известный договор проще сопровождать, чем набор молчаливых преобразований.

На обращение к движку установлено ограничение времени и размера ответа. Это локальные учебные пределы. Они помогают показать отказ вместо бесконечного ожидания, но не являются готовым расчётом ресурсов для продакшена.

Браузерный клиент

Интерфейс после урока 12 использовал объект с методом request. Сохраним этот договор. Создайте public/remote-client.js:

export function createRemoteClient() {
  async function request(type, { query, options = {} }) {
    const path = type === "suggest" ? "/api/suggest" : "/api/search";
    const url = new URL(path, location.origin);
    url.searchParams.set("q", query);
    if (type === "search") {
      if (options.section) url.searchParams.set("section", options.section);
      url.searchParams.set("page", options.page || 1);
      url.searchParams.set("limit", options.pageSize || 10);
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 10000);
    try {
      const response = await fetch(url, { signal: controller.signal });
      if (!response.ok) throw new Error("Поиск недоступен");
      return { data: await response.json() };
    } finally { clearTimeout(timer); }
  }
  return { request };
}

В app.js замените импорт клиента Worker на createRemoteClient и создайте client этой функцией. Остальная обработка response.data, номеров запросов и DOM сохраняется.

При серверной выдаче уточните подпись счётчика: «Совпадений в доступной выдаче». Ответ сообщает totalRelation: "window" и предел окна. Не нужно называть это число полным количеством всех совпадений во всём корпусе.

В браузере сохраняются два механизма: AbortController ограничивает ожидание сетевого вызова, а revision предотвращает отображение старого ответа. Отмена запроса не доказывает, что движок немедленно прекратил вычисление на сервере.

Ошибка и наблюдение

Если параметры неверны, адаптер отвечает 400. Если движок недоступен или вернул неподходящий ответ, используется 503 с общей фразой. Текст внутренних ошибок и ключи не передаются посетителю.

Журнал содержит идентификатор запроса, маршрут, код, длину строки и длительность. Полный пользовательский запрос не записывается автоматически. Для диагностики доступности этого достаточно на первом этапе.

Сам HTTP-сервер Python обычно пишет адрес обращения в стандартный журнал. Поэтому этот обработчик отключён, а выбранные сведения выводятся явно. Иначе политика «не сохраняем запросы» была бы нарушена другим слоем.

Идентификатор помогает сопоставить ошибку интерфейса с записью сервиса. Он не является токеном доступа и не даёт права читать данные. Не перегружайте одну случайную строку несколькими назначениями.

Готовность и обновление

Адаптер запускается только с готовым MEILI_INDEX. Новый индекс сначала проходит подготовку и контрольную оценку, затем выбирается для новых запросов. Если выпуск неудачен, можно вернуться к прежнему имени.

При переключении нужно учитывать разные страницы выдачи и уже открытые сессии. В данном учебном процессе индекс закреплён до перезапуска адаптера. Для динамического переключения понадобится явная версия результата и политика сброса пагинации.

Подсказки используют то же активное имя. Они не должны случайно обращаться к одному выпуску, когда основная выдача обслуживается другим. Общий адаптер помогает сохранить эту связь.

Ограничения локального сервера

ThreadingHTTPServer показывает договор API, но не является готовой публичной платформой. У него нет рассчитанного ограничения параллельных посетителей, полноценной системы TLS и промышленного управления процессами.

Для продакшена нужен подходящий сервер приложения или иной способ обслуживания, ограничения частоты и одновременных запросов, наблюдение за ресурсами и схема перезапуска. Их значения выбираются по нагрузке, а не копируются из нашего таймера.

Не добавляйте бесконечный автоматический повтор запроса на клиенте. При сбое он может увеличить нагрузку. В текущем интерфейсе повтор выполняется по действию посетителя; дальнейшая политика требует ограничений и пауз.

Материалы сайта и каталог продолжают открываться независимо от API. Недоступность поиска не должна закрывать чтение статического урока. Это полезное свойство выбранного разделения.

Рассмотрите недоступность движка отдельно от ошибки посетителя. При остановленном локальном Meilisearch корректный запрос должен получить 503; после восстановления повтор выполняется с теми же параметрами. При неизвестном разделе ответ 400 должен возникнуть до обращения к движку. Такое разделение помогает проверить, на каком уровне применяются ограничения, и не маскировать ошибочный договор сообщением о временной загрузке.

Одинаковая публичная фраза не означает одинаковую внутреннюю причину. Для разбирательства используйте requestId, код и длительность, затем сведения о состоянии самого движка. Текущий короткий журнал не содержит полного разбора исключений и не заменяет систему наблюдения. Он показывает выбранный минимум без сохранения введённого текста. Если позже понадобится подробная диагностика, отдельно определите, какие сведения разрешено хранить и как долго, вместо возврата всех внутренних деталей браузеру.

Теперь готова третья контрольная версия: отдельный индекс, ограниченный API и прежний интерфейс. Дополнительный блок начнётся с представления текста вектором, после чего сравним обычный поиск с поиском по смыслу.