Работа с Apache Solr через API на платформе C#

Практический курс для разработчиков C#, желающих освоить интеграцию с поисковым движком Solr. Вы научитесь индексировать данные, строить сложные поисковые запросы и эффективно использовать фильтры через API.

Настройка окружения и подключение к Solr из C# приложения

Настройка окружения и подключение к Solr из C# приложения

Добро пожаловать в курс «Работа с Apache Solr через API на платформе C#». Это первая статья, в которой мы заложим фундамент для всей дальнейшей работы. Прежде чем мы начнем загружать данные, строить сложные фасетные фильтры или настраивать полнотекстовый поиск, нам необходимо выполнить базовую, но критически важную задачу: развернуть сервер Solr и научить наше C# приложение «общаться» с ним.

Apache Solr — это поисковая платформа корпоративного уровня с открытым исходным кодом, построенная на базе библиотеки Apache Lucene. Она невероятно быстрая, масштабируемая и надежная. Но для разработчика самое главное то, что Solr предоставляет удобный REST-like API. Это означает, что мы можем управлять поиском, просто отправляя HTTP-запросы и получая ответы в формате JSON.

Подготовка окружения: Запуск Solr

Существует несколько способов установить Solr: скачать архив с официального сайта, установить через пакетный менеджер (например, Homebrew на macOS) или использовать Docker. В рамках этого курса мы будем использовать Docker, так как это современный стандарт, позволяющий получить гарантированно работающее окружение за одну команду, не засоряя вашу операционную систему зависимостями Java.

Шаг 1: Установка и запуск контейнера

Предполагается, что Docker Desktop уже установлен на вашем компьютере. Откройте терминал (PowerShell, CMD или Terminal) и выполните следующую команду:

docker run -d -p 8983:8983 --name solr_course_instance solr:latest

Давайте разберем, что делает эта команда:

  • docker run — команда запуска контейнера.
  • -d (detach) — запускает контейнер в фоновом режиме.
  • -p 8983:8983 — пробрасывает порт. Solr по умолчанию работает на порту 8983. Мы связываем порт 8983 вашего компьютера с портом 8983 внутри контейнера.
  • --name solr_course_instance — дает нашему контейнеру понятное имя, чтобы мы могли легко к нему обращаться.
  • solr:latest — указывает образ, который нужно использовать (последнюю официальную версию Solr).

После выполнения команды подождите несколько секунд. Вы можете проверить, что Solr запустился, открыв браузер и перейдя по адресу http://localhost:8983. Вы должны увидеть административную панель Solr (Solr Admin UI).

Шаг 2: Создание ядра (Core)

Solr не может просто «хранить данные» в пустоте. Ему нужна структура, называемая Core (Ядро) или Collection (Коллекция, если мы говорим о кластерном режиме SolrCloud). Грубо говоря, это аналог базы данных в мире SQL.

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

docker exec -it solr_course_instance solr create_core -c techproducts

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

Создание проекта на C#

Теперь, когда сервер запущен, перейдем к клиентской части. Мы будем использовать .NET 6 или новее (подойдет и .NET 8). Мы создадим простое консольное приложение, которое будет служить полигоном для наших экспериментов.

Откройте терминал в папке, где вы храните проекты, и выполните:

dotnet new console -n SolrApiClient
cd SolrApiClient

Выбор инструментов

Для работы с Solr в .NET существует популярная библиотека SolrNet. Она отличная, мощная и скрывает много сложности. Однако, цель нашего курса — глубокое понимание работы с API. Использование высокоуровневой библиотеки сразу может скрыть от вас то, как именно формируются запросы и как устроены данные.

Поэтому мы будем использовать стандартный HttpClient из пространства имен System.Net.Http. Это позволит нам вручную конструировать запросы и полностью контролировать процесс. Это знание будет универсальным: поняв принципы HTTP API Solr, вы сможете работать с ним на любом языке программирования.

Нам также понадобится библиотека для работы с JSON, так как Solr обменивается данными именно в этом формате. В современных версиях .NET библиотека System.Text.Json уже встроена и отлично справляется с задачей.

Реализация подключения

Откройте файл Program.cs в вашей любимой IDE (Visual Studio, VS Code или Rider). Наша первая задача — просто «постучаться» в Solr и убедиться, что он нам отвечает.

Базовая структура URL

API Solr имеет строгую структуру URL. Для обращения к нашему ядру techproducts базовый адрес будет выглядеть так:

http://localhost:8983/solr/techproducts

Все операции (поиск, добавление, удаление) будут добавляться к этому базовому пути. Например, для поиска мы будем обращаться к /select, а для обновления данных — к /update.

Код проверки соединения (Ping)

У каждого ядра в Solr есть специальный эндпоинт (точка входа) для проверки работоспособности: /admin/ping. Если мы отправим туда GET-запрос, Solr должен вернуть статус OK.

Давайте напишем код для этого. Полностью замените содержимое Program.cs на следующее:

using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

namespace SolrApiClient
{
    class Program
    {
        // Базовый адрес нашего ядра Solr
        private const string SolrBaseUrl = "http://localhost:8983/solr/techproducts";

        // HttpClient рекомендуется создавать один раз на все приложение
        private static readonly HttpClient client = new HttpClient();

        static async Task Main(string[] args)
        {
            Console.WriteLine("Запуск проверки соединения с Solr...");

            try
            {
                // Вызываем метод проверки
                bool isConnected = await PingSolrAsync();

                if (isConnected)
                {
                    Console.WriteLine("Успех! Соединение с Solr установлено.");
                }
                else
                {
                    Console.WriteLine("Ошибка: Solr вернул некорректный статус.");
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Критическая ошибка подключения: {ex.Message}");
            }
        }

        private static async Task<bool> PingSolrAsync()
        {
            // Формируем полный URL для пинга
            // Обратите внимание: /admin/ping - это стандартный путь для проверки ядра
            string pingUrl = $"{SolrBaseUrl}/admin/ping?wt=json";

            Console.WriteLine($"Отправка запроса на: {pingUrl}");

            // Отправляем GET запрос
            HttpResponseMessage response = await client.GetAsync(pingUrl);

            // Проверяем, что HTTP статус код успешный (200 OK)
            response.EnsureSuccessStatusCode();

            // Читаем ответ как строку
            string jsonResponse = await response.Content.ReadAsStringAsync();

            // Выводим сырой ответ для наглядности (в учебных целях)
            Console.WriteLine($"Ответ от сервера: {jsonResponse}");

            // Простая проверка наличия статуса OK в JSON
            // В реальном приложении мы бы десериализовали это в объект
            using (JsonDocument doc = JsonDocument.Parse(jsonResponse))
            {
                JsonElement root = doc.RootElement;
                if (root.TryGetProperty("status", out JsonElement statusElement))
                {
                    return statusElement.GetString() == "OK";
                }
            }

            return false;
        }
    }
}

Разбор ключевых моментов кода

  1. wt=json: Обратите внимание на параметр в строке pingUrl. Параметр wt означает Writer Type (тип писателя). По умолчанию Solr старых версий мог возвращать XML. Мы явно указываем wt=json, чтобы гарантированно получить JSON. Это «золотое правило» при работе с Solr API — всегда явно указывать формат.
  2. HttpClient: Мы используем static readonly экземпляр. В .NET неправильное использование HttpClient (создание нового экземпляра на каждый запрос) может привести к исчерпанию сокетов (Socket Exhaustion). Для нашего простого приложения это не критично, но мы сразу учимся писать правильно.
  3. JsonDocument: Мы используем легковесный парсер из System.Text.Json для анализа ответа. Нам нужно найти поле status и убедиться, что оно равно OK.

Запуск и проверка

Запустите приложение командой:

dotnet run

Если вы все сделали правильно, вывод будет примерно таким:

Запуск проверки соединения с Solr...
Отправка запроса на: http://localhost:8983/solr/techproducts/admin/ping?wt=json
Ответ от сервера: {
  "responseHeader":{
    "zkConnected":null,
    "status":0,
    "QTime":2,
    "params":{
      "q":"{!lucene}*:*",
      "distrib":"false",
      "df":"_text_",
      "rows":"10",
      "echoParams":"all",
      "wt":"json"}},
  "status":"OK"}
Успех! Соединение с Solr установлено.

Обратите внимание на структуру JSON-ответа. Она состоит из двух основных частей:

  • responseHeader: метаданные запроса (сколько времени занял запрос QTime, статус выполнения status: 0 — это внутренний код успеха Solr, не путать с HTTP 200).
  • status: "OK" — это специфичное поле именно для хендлера /admin/ping.

Возможные проблемы

Если вы получили ошибку, проверьте следующее:

  1. Connection Refused: Убедитесь, что контейнер Docker запущен (docker ps).
  2. 404 Not Found: Скорее всего, вы ошиблись в названии ядра в URL. Проверьте, что вы создали ядро techproducts и указали его в SolrBaseUrl.
  3. JSON Parsing Error: Убедитесь, что вы добавили ?wt=json к запросу.

Заключение

В этой статье мы выполнили важнейшую подготовительную работу. Мы развернули поисковый движок Solr в изолированном контейнере, создали индекс (ядро) для хранения данных и написали C# код, который успешно соединяется с API. Мы отказались от использования готовых библиотек в пользу чистого HttpClient, чтобы понимать каждый байт, передаваемый по сети.

В следующей статье мы перейдем к самому интересному — наполнению нашего индекса данными. Мы научимся формировать документы в C# и отправлять их в Solr через API обновления.

Управление данными: индексация, обновление и удаление документов

Управление данными: индексация, обновление и удаление документов

В предыдущей статье мы успешно настроили окружение Docker и научили наше C# приложение проверять соединение с Solr. Теперь пришло время наполнить наш поисковый индекс реальными данными. В терминологии поисковых движков процесс добавления данных называется индексацией.

Solr — это не просто база данных, это документоориентированное хранилище. Это означает, что единицей информации здесь является документ (Document), который состоит из полей (Fields). Если проводить аналогию с реляционными базами данных (SQL), то:

  • Индекс (Core/Collection) — это Таблица.
  • Документ — это Строка таблицы.
  • Поле — это Колонка.

В этой статье мы научимся выполнять операции CRUD (Create, Read, Update, Delete) используя HttpClient и JSON-сериализацию.

Подготовка модели данных

Solr обменивается данными в формате JSON. Чтобы нам было удобно работать в C#, создадим класс, представляющий наш документ. Допустим, мы создаем каталог электроники.

Добавьте в ваш проект файл Product.cs:

using System.Text.Json.Serialization;

namespace SolrApiClient
{
    public class Product
    {
        // Уникальный идентификатор документа. Обязательное поле в Solr.
        [JsonPropertyName("id")]
        public string Id { get; set; }

        [JsonPropertyName("name")]
        public string Name { get; set; }

        [JsonPropertyName("price")]
        public double Price { get; set; }

        [JsonPropertyName("category")]
        public string[] Categories { get; set; }

        [JsonPropertyName("in_stock")]
        public bool InStock { get; set; }
    }
}

Обратите внимание на атрибут [JsonPropertyName]. В C# принято называть свойства с большой буквы (PascalCase), а в JSON API часто используется camelCase или snake_case. Этот атрибут позволяет нам соблюдать стандарты языка C#, отправляя в Solr правильные имена полей.

Добавление документов (Индексация)

Для добавления документов используется эндпоинт /update. Мы можем отправлять как один документ, так и массив документов. С точки зрения производительности, всегда лучше отправлять документы пачками (batch), а не по одному.

Реализация метода добавления

Вернемся в Program.cs и добавим метод для отправки данных. Нам понадобится изменить базовый URL, так как /admin/ping нам больше не нужен для этой задачи.

// Обновленный базовый URL (без конкретного хендлера)
private const string SolrCoreUrl = "http://localhost:8983/solr/techproducts";

private static async Task IndexDocumentsAsync(List<Product> products)
{
    // 1. Сериализуем список объектов в JSON строку
    string jsonContent = JsonSerializer.Serialize(products);

    // 2. Создаем HTTP контент
    var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");

    // 3. Формируем URL.
    // commitWithin=1000 говорит Solr: "Сохрани данные на диск не позднее чем через 1000 мс (1 сек)"
    string updateUrl = $"{SolrCoreUrl}/update?commitWithin=1000";

    Console.WriteLine($"Отправка {products.Count} документов...");

    // 4. Отправляем POST запрос
    HttpResponseMessage response = await client.PostAsync(updateUrl, content);

    response.EnsureSuccessStatusCode();

    Console.WriteLine("Документы успешно отправлены.");
}

Важное отступление: Commit (Фиксация)

В Solr данные не появляются в поиске мгновенно после отправки. Они попадают в буфер памяти. Чтобы они стали доступны для поиска, должна произойти операция Commit.

Существует два способа сделать это:

  1. Hard Commit (commit=true в URL): Принудительно сбрасывает данные на диск и открывает новый поисковый сегмент. Это тяжелая операция. Если делать её после каждого документа, производительность упадет катастрофически.
  2. Soft Commit или commitWithin: Мы просим Solr зафиксировать данные в течение определенного времени (например, 1000 мс). Это позволяет Solr самому оптимизировать процесс записи. Рекомендуется использовать именно этот способ.

Вызов метода

Обновим метод Main, чтобы протестировать индексацию:

static async Task Main(string[] args)
{
    // ... (код инициализации HttpClient)

    var newProducts = new List<Product>
    {
        new Product
        {
            Id = "101",
            Name = "Игровой ноутбук",
            Price = 1500.00,
            Categories = new[] { "electronics", "computers" },
            InStock = true
        },
        new Product
        {
            Id = "102",
            Name = "Беспроводная мышь",
            Price = 25.50,
            Categories = new[] { "electronics", "accessories" },
            InStock = true
        }
    };

    await IndexDocumentsAsync(newProducts);
}

Обновление данных

В Solr обновление документа работает по принципу: «Последний побеждает». Если вы отправите документ с id, который уже существует в индексе, Solr полностью удалит старый документ и запишет новый.

Однако, часто нам нужно обновить только одно поле (например, изменить цену), не переписывая весь документ. Это называется Атомарное обновление (Atomic Update).

Для атомарного обновления структура JSON должна быть особенной. Вместо значения мы передаем объект с командой.

Пример JSON для изменения цены:

[
  {
    "id": "101",
    "price": { "set": 1450.00 }
  }
]

Давайте реализуем это в C#. Поскольку структура атомарного обновления отличается от нашего класса Product, мы можем использовать Dictionary<string, object> или анонимные объекты.

private static async Task UpdatePriceAsync(string docId, double newPrice)
{
    // Формируем структуру для атомарного обновления
    var updatePayload = new[]
    {
        new
        {
            id = docId,
            price = new { set = newPrice } // Команда set обновляет значение
        }
    };

    string jsonContent = JsonSerializer.Serialize(updatePayload);
    var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");

    // Используем тот же эндпоинт /update
    string updateUrl = $"{SolrCoreUrl}/update?commitWithin=1000";

    await client.PostAsync(updateUrl, content);
    Console.WriteLine($"Цена для товара {docId} обновлена.");
}

Основные команды атомарного обновления:

  • set: Установить новое значение (или перезаписать).
  • add: Добавить значение в массив (для многозначных полей).
  • remove: Удалить значение из массива.
  • inc: Увеличить числовое значение (инкремент).

Удаление документов

Удаление также происходит через отправку POST запроса на /update, но формат JSON отличается. Мы можем удалять по конкретному ID или по запросу (Query).

Удаление по ID

{ "delete": { "id": "101" } }

Удаление по запросу

Можно удалить, например, все товары категории "accessories":

{ "delete": { "query": "category:accessories" } }

Реализуем метод удаления по ID в C#:

private static async Task DeleteDocumentAsync(string docId)
{
    // Формируем объект удаления
    var deletePayload = new
    {
        delete = new { id = docId }
    };

    string jsonContent = JsonSerializer.Serialize(deletePayload);
    var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");

    string updateUrl = $"{SolrCoreUrl}/update?commitWithin=1000";

    await client.PostAsync(updateUrl, content);
    Console.WriteLine($"Документ {docId} удален.");
}

Полная очистка индекса

Иногда в процессе разработки нужно удалить все данные. Для этого используется удаление по специальному запросу *:* (все поля : все значения).

Будьте осторожны с этой командой в продакшене!

var clearAllPayload = new { delete = new { query = "*:*" } };

Резюме

Сегодня мы научились управлять жизненным циклом данных в Solr через C#:

  1. Индексация: Мы используем HTTP POST на адрес /update и передаем массив JSON-объектов.
  2. Commit: Данные не видны сразу. Используйте параметр commitWithin=1000 для автоматической фиксации изменений.
  3. Атомарное обновление: Чтобы изменить одно поле, используйте конструкцию "field": { "set": value }.
  4. Удаление: Отправляйте JSON вида { "delete": { "id": "..." } }.

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

Построение поисковых запросов и обработка ответов API

Построение поисковых запросов и обработка ответов API

В предыдущих статьях мы настроили Solr и научились индексировать данные. Теперь наш индекс techproducts наполнен товарами, и пришло время научиться извлекать эту информацию. Поиск — это основная функция Solr, и именно здесь раскрывается вся мощь этой платформы.

В этой статье мы разберем, как формировать поисковые запросы через HTTP API, чем отличаются параметры фильтрации от параметров запроса, как работает пагинация и сортировка, и, самое главное, как правильно обрабатывать JSON-ответы в C#.

Базовая структура поискового запроса

Основной эндпоинт для поиска в Solr — это /select. В отличие от операций обновления, поиск обычно выполняется через GET-запросы, где параметры передаются в строке запроса (Query String).

Минимальный валидный запрос выглядит так:

http://localhost:8983/solr/techproducts/select?q=*:*

Здесь параметр q (query) — это основной поисковый запрос. Синтаксис *:* означает «найти все документы», где первая звездочка — это любое поле, а вторая — любое значение.

Основные параметры поиска

Solr предлагает множество параметров, но для 90% задач вам понадобятся следующие:

  • q (Query): Основной запрос. Определяет, какие документы будут найдены и, что важно, как они будут отсортированы по релевантности (Scoring).
  • fq (Filter Query): Фильтрующий запрос. Ограничивает выборку документов, но не влияет на релевантность. Результаты фильтров кэшируются, поэтому они работают очень быстро.
  • rows: Количество возвращаемых документов (по умолчанию 10).
  • start: Смещение (offset) для пагинации (по умолчанию 0).
  • sort: Порядок сортировки (например, price desc).
  • fl (Field List): Список полей, которые нужно вернуть в ответе (например, id,name,price).

Различие между q и fq

Понимание разницы между q и fq критически важно для производительности и качества поиска.

Представьте, что пользователь ищет «ноутбук» в категории «электроника» в диапазоне цен от 1000 до 2000.

  1. Слово «ноутбук» должно влиять на релевантность: документ, где это слово встречается в заголовке, важнее, чем тот, где оно в описании. Это параметр q.
  2. Категория и цена — это жесткие критерии. Товар либо попадает в цену, либо нет. Это параметр fq.

Математически релевантность (Score) для документа рассчитывается только на основе параметра q. Упрощенная формула релевантности выглядит так:

Score(q,d)=∑(tf(t,d)×idf(t)2)Score(q, d) = \sum (tf(t, d) \times idf(t)^2)

Где Score(q,d)Score(q, d) — итоговая оценка релевантности документа dd запросу qq, tf(t,d)tf(t, d) — частота термина tt в документе (term frequency), а idf(t)idf(t) — обратная частота документа (inverse document frequency), показывающая редкость термина во всем индексе.

Использование fq исключает документы из выборки до расчета этой формулы, что экономит ресурсы процессора.

Реализация поиска на C#

Давайте напишем метод, который выполняет поиск товаров по названию с фильтрацией по цене.

Модель ответа Solr

Ответ Solr имеет вложенную структуру. Нам нужно создать классы-обертки, чтобы десериализовать JSON. Вспомним наш класс Product из прошлой статьи и добавим обертки.

using System.Text.Json.Serialization;

namespace SolrApiClient
{
    // Корневой объект ответа
    public class SolrResponse<T>
    {
        [JsonPropertyName("responseHeader")]
        public ResponseHeader Header { get; set; }

        [JsonPropertyName("response")]
        public ResponseBody<T> Body { get; set; }
    }

    public class ResponseHeader
    {
        [JsonPropertyName("status")]
        public int Status { get; set; }

        [JsonPropertyName("QTime")]
        public int QTime { get; set; }
    }

    public class ResponseBody<T>
    {
        [JsonPropertyName("numFound")]
        public int NumFound { get; set; }

        [JsonPropertyName("start")]
        public int Start { get; set; }

        [JsonPropertyName("docs")]
        public List<T> Docs { get; set; }
    }
}

Метод поиска

Теперь реализуем метод в нашем классе Program. Мы будем использовать UriBuilder или интерполяцию строк для формирования URL. Важно помнить про URL-кодирование значений, чтобы пробелы и спецсимволы не сломали запрос.

private static async Task SearchProductsAsync(string searchTerm, double? minPrice, double? maxPrice)
{
    // Базовый URL для поиска
    string searchUrl = "http://localhost:8983/solr/techproducts/select";

    // Формируем параметры запроса
    var queryParams = new List<string>();

    // 1. Параметр q (основной поиск)
    // Если поисковая фраза пустая, ищем всё (*:*)
    string q = string.IsNullOrWhiteSpace(searchTerm) ? "*:*" : $"name:{searchTerm}";
    queryParams.Add($"q={Uri.EscapeDataString(q)}");

    // 2. Параметр fq (фильтр по цене)
    if (minPrice.HasValue || maxPrice.HasValue)
    {
        string min = minPrice.HasValue ? minPrice.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "*";
        string max = maxPrice.HasValue ? maxPrice.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "*";

        // Синтаксис диапазона: field:[min TO max]
        string fq = $"price:[{min} TO {max}]";
        queryParams.Add($"fq={Uri.EscapeDataString(fq)}");
    }

    // 3. Дополнительные параметры
    queryParams.Add("rows=10"); // Вернуть 10 документов
    queryParams.Add("wt=json"); // Формат JSON
    queryParams.Add("indent=true"); // Красивый вывод (для отладки)

    // Собираем итоговый URL
    string fullUrl = $"{searchUrl}?{string.Join("&", queryParams)}";

    Console.WriteLine($"Выполняем запрос: {fullUrl}");

    try
    {
        HttpResponseMessage response = await client.GetAsync(fullUrl);
        response.EnsureSuccessStatusCode();

        string jsonString = await response.Content.ReadAsStringAsync();

        // Десериализация
        var solrResponse = System.Text.Json.JsonSerializer.Deserialize<SolrResponse<Product>>(jsonString);

        Console.WriteLine($"Найдено всего: {solrResponse.Body.NumFound}");
        Console.WriteLine($"Получено документов: {solrResponse.Body.Docs.Count}");

        foreach (var product in solrResponse.Body.Docs)
        {
            Console.WriteLine($" - {product.Name} (ID: {product.Id}) - ${product.Price}");
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Ошибка поиска: {ex.Message}");
    }
}

Разбор синтаксиса запросов

В коде выше мы использовали несколько важных конструкций:

  1. Поиск по полю: name:searchTerm. Если не указать поле (просто написать searchTerm), Solr будет искать в поле по умолчанию (обычно _text_, куда копируются данные из всех полей).
  2. Диапазоны: price:[100 TO 200]. Квадратные скобки [] означают включение границ (>= и <=). Фигурные скобки {} означают исключение границ (> и <). Звездочка * означает бесконечность.

Пагинация и Сортировка

Для реализации постраничного вывода (пейджинга) используются параметры start и rows.

  • Страница 1: start=0&rows=10
  • Страница 2: start=10&rows=10
  • Страница 3: start=20&rows=10

Формула для расчета start:

start=(pageNumber−1)×pageSizestart = (pageNumber - 1) \times pageSize

Где startstart — смещение (сколько пропустить), pageNumberpageNumber — номер текущей страницы (начиная с 1), pageSizepageSize — количество элементов на странице.

Для сортировки добавьте параметр sort. Формат: <поле> <порядок>. Порядок может быть asc (по возрастанию) или desc (по убыванию).

Пример добавления сортировки в наш код:

queryParams.Add("sort=price desc"); // Сначала дорогие

Оптимизация с помощью fl

Если ваши документы содержат большие текстовые поля (например, полное описание товара), а вам для вывода списка нужны только название и цена, обязательно используйте параметр fl (Field List).

Передача лишних данных по сети и их десериализация — одна из частых причин медленной работы приложений.

queryParams.Add("fl=id,name,price");

Заключение

Мы научились отправлять поисковые запросы в Solr, используя HttpClient, и обрабатывать ответы, десериализуя их в типизированные объекты C#. Мы разобрали разницу между q и fq, научились фильтровать данные по диапазонам и использовать пагинацию.

В следующей статье мы углубимся в одну из самых мощных возможностей Solr — Фасетный поиск (Faceting), который позволяет строить динамические фильтры и агрегировать данные, как это делают крупные интернет-магазины.

Продвинутый поиск: фильтрация, сортировка и пагинация результатов

Продвинутый поиск: фильтрация, сортировка и пагинация результатов

В предыдущих статьях мы научились подключаться к Solr, индексировать данные и выполнять базовые поисковые запросы. Однако реальные приложения редко ограничиваются простым поиском по ключевым словам. Пользователи ожидают увидеть инструменты, к которым они привыкли в интернет-магазинах: фильтры по цене и наличию, сортировку от дешевых к дорогим и удобную навигацию по страницам.

В этой статье мы углубимся в параметры API Solr, отвечающие за уточнение выдачи. Мы разберем сложную логику фильтрации, научимся правильно сортировать данные и реализуем эффективную пагинацию, включая работу с «глубокими» страницами.

Логика фильтрации (Filter Query)

Как мы упоминали ранее, параметр fq (Filter Query) используется для ограничения выборки документов без влияния на их релевантность (Score). Это критически важно для производительности, так как Solr кэширует результаты фильтров.

Булева логика

Solr поддерживает стандартные булевы операторы: AND, OR, NOT. Важно писать их заглавными буквами.

Пример запроса: «Найти товары категории Electronics ИЛИ Computers, но НЕ бренда Apple».

fq=(category:Electronics OR category:Computers) AND -brand:Apple

Обратите внимание на минус - перед полем brand. Это сокращенная запись для NOT.

Диапазоны значений

Мы уже касались диапазонов цен, но синтаксис Solr позволяет делать гораздо больше. Диапазоны работают для чисел, дат и даже строк.

Синтаксис: field:[min TO max]

  • price:[100 TO *] — цена от 100 и выше (до бесконечности).
  • price:[* TO 500] — цена до 500 включительно.
  • price:{100 TO 200} — фигурные скобки исключают границы (строго больше 100 и строго меньше 200).

Работа с датами

Solr имеет мощный встроенный механизм математики дат. Для этого используется ключевое слово NOW.

Пример: «Найти товары, добавленные за последние 7 дней».

fq=created_at:[NOW-7DAYS TO NOW]

Это позволяет создавать динамические фильтры, которые не нужно переписывать каждый день в коде приложения.

Сортировка (Sorting)

По умолчанию Solr сортирует результаты по релевантности (score desc). Однако пользователю часто нужны другие критерии.

Параметр sort принимает список полей и направление сортировки (asc — по возрастанию, desc — по убыванию), разделенных запятой.

Множественная сортировка

Часто одного поля недостаточно. Рассмотрим пример интернет-магазина. Мы хотим показать сначала товары, которые есть в наличии (in_stock), а среди них — сначала самые дешевые (price).

Строка запроса будет выглядеть так:

sort=in_stock desc, price asc

Логика здесь следующая: in_stock — это булево поле (true/false). При сортировке desc (по убыванию) true идет раньше false. Таким образом, все товары в наличии окажутся в начале списка, а внутри этой группы они будут отсортированы по цене.

Сортировка при отсутствии значения

Что произойдет, если у товара не заполнено поле price? По умолчанию он может оказаться как в начале, так и в конце списка. Мы можем управлять этим поведением с помощью sortMissingLast или sortMissingFirst в схеме, но в запросе это не переопределить. Поэтому хорошей практикой является контроль данных на этапе индексации.

Пагинация и проблема Deep Paging

Для реализации постраничного вывода мы используем параметры start (смещение) и rows (количество).

Формула расчета смещения:

start=(N−1)×Rstart = (N - 1) \times R

Где startstart — значение параметра start для запроса, NN — номер текущей страницы (начиная с 1), а RR — количество записей на странице (параметр rows).

Проблема производительности

Классическая пагинация отлично работает для первых страниц. Но если пользователь (или робот-скрапер) запросит 1000-ю страницу (start=10000, rows=10), Solr будет вынужден:

  1. Найти все подходящие документы.
  2. Отсортировать их все.
  3. Отсчитать 10000 записей, выбросить их и вернуть следующие 10.

Это потребляет огромное количество памяти и CPU. Для глубокой пагинации (Deep Paging) в Solr существует механизм CursorMark.

Использование CursorMark

CursorMark работает как закладка в книге. Вместо того чтобы говорить «пропусти 1000 строк», мы говорим «дай мне 10 строк, следующих за вот этой меткой».

Алгоритм работы:

  1. В первом запросе передаем параметр cursorMark=* и обязательно сортировку по уникальному полю (обычно id) в дополнение к основной сортировке. Например: sort=price asc, id asc.
  2. Solr вернет результаты и специальное поле nextCursorMark в теле ответа.
  3. В следующем запросе мы передаем значение nextCursorMark в параметр cursorMark.
  4. Повторяем, пока nextCursorMark не станет равен cursorMark (это означает конец данных).

Оптимизация ответа (Field List)

Параметр fl (Field List) позволяет не только перечислить нужные поля, но и переименовать их «на лету», а также запросить вычисляемые поля.

Пример:

fl=id,product_name:name,total_price:mul(price, 1.2),score

В этом примере:

  • Поле name вернется в JSON как product_name.
  • Мы создали виртуальное поле total_price, которое равно цене, умноженной на 1.2 (например, налог).
  • Мы запросили score, чтобы видеть, почему документ попал в выдачу.

Реализация на C#

Давайте соберем все знания и напишем универсальный метод поиска в нашем классе Program.cs. Мы будем использовать Dictionary для параметров, чтобы гибко управлять запросом.

using System.Text;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;

// ... (внутри класса Program)

private static async Task AdvancedSearchAsync(
    string query,
    List<string> filters,
    string sort,
    int page,
    int pageSize)
{
    string baseUrl = "http://localhost:8983/solr/techproducts/select";

    // Расчет start для пагинации
    int start = (page - 1) * pageSize;

    // Собираем параметры запроса
    var parameters = new Dictionary<string, string>
    {
        ["q"] = string.IsNullOrWhiteSpace(query) ? "*:*" : query,
        ["rows"] = pageSize.ToString(),
        ["start"] = start.ToString(),
        ["wt"] = "json",
        ["fl"] = "id,name,price,in_stock,score" // Оптимизация полей
    };

    // Добавляем сортировку, если она есть
    if (!string.IsNullOrEmpty(sort))
    {
        parameters["sort"] = sort;
    }

    // Формируем строку запроса
    // Используем StringBuilder для эффективности
    var queryString = new StringBuilder();
    queryString.Append("?");

    // Добавляем основные параметры
    foreach (var param in parameters)
    {
        queryString.Append($"{param.Key}={Uri.EscapeDataString(param.Value)}&");
    }

    // Добавляем фильтры (fq может повторяться несколько раз)
    if (filters != null)
    {
        foreach (var filter in filters)
        {
            queryString.Append($"fq={Uri.EscapeDataString(filter)}&");
        }
    }

    // Убираем последний амперсанд
    string fullUrl = baseUrl + queryString.ToString().TrimEnd('&');

    Console.WriteLine($"Запрос: {fullUrl}");

    try
    {
        HttpResponseMessage response = await client.GetAsync(fullUrl);
        response.EnsureSuccessStatusCode();

        string jsonResponse = await response.Content.ReadAsStringAsync();

        // Десериализация (используем классы из предыдущего урока)
        var solrResponse = JsonSerializer.Deserialize<SolrResponse<Product>>(jsonResponse);

        Console.WriteLine($"Страница {page}. Найдено всего: {solrResponse.Body.NumFound}");
        foreach (var doc in solrResponse.Body.Docs)
        {
            Console.WriteLine($"[{doc.Id}] {doc.Name} - {doc.Price}$");
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Ошибка: {ex.Message}");
    }
}

Пример вызова метода

Теперь мы можем вызывать этот метод с различными комбинациями параметров:

static async Task Main(string[] args)
{
    // ... инициализация client ...

    // Пример: Ищем "memory", цена от 0 до 200, в наличии, сортировка по цене
    var filters = new List<string>
    {
        "price:[0 TO 200]",
        "in_stock:true"
    };

    await AdvancedSearchAsync(
        query: "memory",
        filters: filters,
        sort: "price asc",
        page: 1,
        pageSize: 5
    );
}

Заключение

В этой статье мы значительно расширили возможности нашего поискового клиента. Теперь мы умеем:

  1. Использовать сложные фильтры с булевой логикой и диапазонами дат.
  2. Настраивать многоуровневую сортировку.
  3. Правильно рассчитывать параметры пагинации.
  4. Оптимизировать объем передаваемых данных с помощью ограничения полей.

Эти навыки превращают простое приложение в мощный инструмент поиска данных. Однако, современный поиск — это не только список результатов. Пользователи хотят видеть категории, распределение цен и подсказки. Для этого используется Фасетный поиск (Faceting), который станет темой нашей следующей статьи.

Оптимизация запросов и лучшие практики интеграции Solr с .NET

Оптимизация запросов и лучшие практики интеграции Solr с .NET

Мы прошли долгий путь: от запуска контейнера Docker до реализации сложного фасетного поиска. Ваше приложение на C# уже умеет искать, фильтровать и обновлять данные. Однако, между «работающим кодом» и «продакшн-решением» лежит пропасть, имя которой — оптимизация.

В этой статье мы разберем, как превратить ваш клиент Solr в высокопроизводительный инструмент. Мы обсудим управление соединениями в .NET, стратегии кэширования на стороне Solr, оптимизацию размера пакетов данных и правильную настройку схемы.

Управление HTTP-соединениями в .NET

Самая распространенная ошибка новичков при работе с любым REST API в C# — создание нового экземпляра HttpClient для каждого запроса.

Проблема исчерпания сокетов

Если вы пишете using (var client = new HttpClient()) { ... } внутри цикла или часто вызываемого метода, вы рискуете столкнуться с ошибкой Socket Exhaustion. Даже после вызова Dispose(), сокет остается в состоянии TIME_WAIT на некоторое время (обычно 240 секунд). При высокой нагрузке у операционной системы просто закончатся свободные порты.

Решение: IHttpClientFactory

В современных приложениях .NET (Core, 5, 6+) стандартом является использование IHttpClientFactory. Это позволяет системе самой управлять пулом соединений.

Если вы пишете консольное приложение или сервис, настройте внедрение зависимостей (Dependency Injection):

// В Program.cs или Startup.cs
var services = new ServiceCollection();

services.AddHttpClient("SolrClient", client =>
{
    client.BaseAddress = new Uri("http://localhost:8983/solr/techproducts/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

И затем используйте его в своих сервисах:

public class SolrService
{
    private readonly IHttpClientFactory _clientFactory;

    public SolrService(IHttpClientFactory clientFactory)
    {
        _clientFactory = clientFactory;
    }

    public async Task<string> SearchAsync(string query)
    {
        var client = _clientFactory.CreateClient("SolrClient");
        return await client.GetStringAsync($"select?q={query}");
    }
}

Оптимизация чтения данных

Скорость ответа Solr складывается из нескольких компонентов. Мы можем выразить общее время выполнения запроса (TtotalT_{total}) следующей формулой:

Ttotal=Tnet+Tqueue+Texec+TdeserT_{total} = T_{net} + T_{queue} + T_{exec} + T_{deser}

Где TtotalT_{total} — общее время, TnetT_{net} — время передачи данных по сети, TqueueT_{queue} — время ожидания в очереди запросов Solr, TexecT_{exec} — время выполнения поиска движком Lucene, а TdeserT_{deser} — время десериализации JSON в объекты C#.

Наша задача — минимизировать каждое слагаемое.

1. Используйте Filter Query (fq) для кэширования

Как мы обсуждали ранее, параметр q влияет на релевантность (Score), а fq — только фильтрует. Но есть еще одно критическое отличие: Solr кэширует результаты фильтров.

Если вы выполняете запрос q=category:Electronics, Solr каждый раз вычисляет релевантность. Если же вы используете q=*:*&fq=category:Electronics, Solr один раз вычислит набор документов (bitset) для этого фильтра и положит его в Filter Cache. Следующий пользователь, который будет искать «ноутбуки» в категории «Electronics», получит результат мгновенно, так как фильтр уже в памяти.

Правило: Все, что не влияет на сортировку по релевантности, выносите в fq.

2. Ограничение полей (fl)

Никогда не запрашивайте все поля (*), если они вам не нужны. Передача больших объемов текста по сети увеличивает TnetT_{net} и TdeserT_{deser}.

Представьте, что у вас есть поле description размером 5 КБ. Вы запрашиваете 100 товаров. Это 500 КБ лишних данных. Используйте параметр fl, чтобы вернуть только необходимое для отображения в списке (например, id,name,price,thumbnail).

3. JSON Facet API вместо старых фасетов

В старых версиях Solr использовались параметры facet=true&facet.field=.... В современном Solr рекомендуется использовать JSON Facet API. Он работает быстрее, потребляет меньше памяти и позволяет строить вложенные агрегации любой глубины.

Пример тела запроса для JSON Facet API:

{
  "query": "*:*",
  "facet": {
    "categories": {
      "type": "terms",
      "field": "category",
      "limit": 5
    }
  }
}

Оптимизация записи (Индексация)

Индексация — ресурсоемкий процесс. Частые коммиты и мелкие запросы могут «убить» производительность кластера.

Пакетная отправка (Batching)

Отправка документов по одному — худшее, что можно сделать. На каждый HTTP-запрос накладываются накладные расходы (TCP handshake, заголовки).

Эффективность пакетной отправки можно оценить формулой:

E=N×SdocToverhead+N×TprocE = \frac{N \times S_{doc}}{T_{overhead} + N \times T_{proc}}

Где EE — эффективность (пропускная способность), NN — количество документов в пакете, SdocS_{doc} — средний размер документа, ToverheadT_{overhead} — накладные расходы на установку соединения, TprocT_{proc} — время обработки одного документа.

При увеличении NN влияние ToverheadT_{overhead} размывается. Рекомендуемый размер пакета — от 100 до 1000 документов или около 1-5 МБ данных за запрос.

Стратегия коммитов (Commit Strategy)

Операция commit заставляет Solr сбросить данные из памяти на диск и открыть новый поисковый сегмент (Searcher). Это тяжелая операция, которая инвалидирует кэши.

  1. Никогда не делайте commit=true после каждого документа.
  2. Используйте commitWithin. Это говорит Solr: «Добавь этот документ и убедись, что он станет видимым в течение X миллисекунд».
// Хорошая практика: документ появится в поиске в течение 10 секунд
string url = "update?commitWithin=10000";
  1. AutoCommit в solrconfig.xml. Лучше всего настроить автоматический коммит на стороне сервера (например, каждые 15 секунд или при достижении буфера в 100 МБ). Тогда клиентскому приложению вообще не нужно думать о коммитах.

Проектирование схемы (Schema Design)

Оптимизация начинается не с кода, а со структуры данных.

Stored vs Indexed vs DocValues

В файле managed-schema у каждого поля есть атрибуты:

  • `indexed=