Поддержите проект, сделав пожертвование

Зачем неофициальный php-клиент к Manticore, если есть официальный

Manticore Search — быстрый поисковый движок с полнотекстовым поиском, фасетами и векторным KNN. У него есть официальный PHP-клиент, manticoresoftware/manticoresearch-php, который поддерживает вендор и который идёт в ногу с релизами сервера. Тем не менее я написал свой. Ниже — три конкретные вещи, из-за которых это произошло, и то, чем за них пришлось заплатить.

С чего началось

Задача была обычная: полнотекстовый поиск по каталогу в приложении на Laravel. Manticore подошёл, официальный клиент поставился, первый запрос заработал за десять минут:

use Manticoresearch\Client;

$client = new Client(['host' => '127.0.0.1', 'port' => 9308]);

$result = $client->table('products')
    ->search('galaxy')
    ->filter('price', 'lte', 1000)
    ->sort('price', 'desc')
    ->limit(10)
    ->get();

foreach ($result as $hit) {
    echo $hit->title;
}

Казалось бы, все хорошо, у клиента есть “текучий” интерфейс, ResultSet реализует Iterator и Countable, а ResultHit через __get() отдаёт поля как свойства. Придраться тут не к чему.

Но чего-то все равно не хватало…

Причина намбер уан: PDO и SQL-интерфейс

Manticore говорит на двух языках. По порту 9308 — JSON поверх HTTP, по порту 9306 — SQL по протоколу MySQL. Официальный клиент умеет только первое.

В Laravel-проекте второе оказалось удобнее по четырём причинам.

PDO-расширение уже есть. Расширение ext-pdo входит в стандартную сборку PHP и заведомо включён в любом проекте, который ходит в базу через DB::. Отдельный HTTP-стек ради поиска добавлять не нужно.

Порт уже проброшен. Порт 9306 говорит на том же протоколе, что MySQL, и вписывается в готовую сетевую обвязку: те же правила фаервола, те же туннели, тот же способ пустить приложение к базе в докере. Отдельный HTTP-порт — это отдельный разговор с теми, кто эту обвязку настраивал.

Запрос можно выполнить руками. Логирование отдаёт готовый SQL, который копируется в консольный mysql -P9306 и выполняется как есть:

// глобально, на конкретное соединение или на один запрос
\ManticoreDb::setLogger(\Log::getLogger());

Это обычный цикл отладки, к которому разработчик уже привык по работе с базой. С JSON-телом запроса так не выйдет: его сначала нужно превратить во что-то, что можно скормить серверу вручную.

SQL-интерфейс шире. JSON API покрывает поиск и модификацию данных. Всё остальное — SHOW, ALTER, FLUSH, служебные CALL-процедуры — живёт только в SQL. А значит, рано или поздно понадобится “просто выполнить SQL-запрос”:

\ManticoreDb::connection()->statement('FLUSH RAMCHUNK products');

Была еще мысль, что PDO будет работать быстрее, чем HTTP API, но по факту оказалось, что это не так, во всяком случае, у меня в бенчмарках расхождение оказалось на уровне погрешности, так что этот аргумент точно мимо.

Причина вторая: плейсхолдер префикса

В Manticore нет привычных баз данных и схем. Одно плоское пространство имён на весь демон — все таблицы лежат рядом.

И пока демон один на проект, это не играет никакой роли. Как только он становится общий на несколько проектов (а у меня было так) — начинается ручная работа. Таблицы прода и дева, таблицы соседнего проекта, таблицы, которые создал в тестах и забыл, все они живут в одном списке и норовят столкнуться именами.

Ровно от этого Laravel избавляет в работе с базой уже лет десять, но официальный клиент, к сожалению, ничего такого не умеет, значит, префикс придётся приклеивать самому — в каждом вызове, из конфига, руками.

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

MANTICORE_PREFIX=myapp_
// читает таблицу myapp_products
\ManticoreDb::table('?products')->match('galaxy')->get();

Знак ? в начале имени — единственное, что нужно написать. Если приложение работает только со своими таблицами, знак ? можно не писать вовсе:

MANTICORE_FORCE_PREFIX=true

Тогда префикс добавится к любому имени таблицы. Зачем тогда плейсхолдер, а не всегда безусловный префикс по умолчанию? А это отработка частного случая, когда в коде и к своим, и к чужим таблицам, и их имя трогать нельзя. Явный знак ? разделяет “свои” таблицы и “чужие” (которые читаются, как есть) прямо в коде запроса, а не в голове.

Причина третья: синтаксис Laravel

Вот пример запроса из php через официального клиента:

$result = $client->table('products')
    ->search('galaxy')
    ->filter('price', 'lte', 1000)
    ->sort('price', 'desc')
    ->limit(10)
    ->get();

Этот же запрос с помощью монй библиотеки:

$rows = \ManticoreDb::table('products')
    ->match('galaxy')
    ->where('price', '<', 1000)
    ->orderBy('price', 'desc')
    ->limit(10)
    ->get();

Строчка в строчку то же самое, но во втором случае разработчику не нужно помнить, что вместо where здесь надо писать filter, оператор сравнения пишется словом lte, а сортировка — sort. Используя библиотеку, разработчик пишет where и orderBy, потому что весь остальной код в проекте написан так. Экономия — не в символах, а в отсутствии переключения.

А дальше начинается то, ради чего нужна именно интеграция с фреймворком, а не просто похожие имена методов.

Ответ — коллекция. Метод get() возвращает Illuminate\Support\Collection, а строка — объект Row:

$rows = \ManticoreDb::table('products')->match('galaxy')->get();

$titles = $rows->map(fn ($row) => $row->title)->all();

Row при этом читается и как объект, и как массив:

$row->title;     // привычно для Laravel
$row['title'];   // так же, как отвечает ядро пакета

$row->toArray();
json_encode($rows);   // массив объектов, как и ждёт JSON API

Постраничная навигация — штатная. С теми же аргументами, что у DB::, и с тем же links() в шаблоне:

$products = \ManticoreDb::table('products')->match('galaxy')->paginate(15);
@foreach ($products as $product)
    {{ $product->title }}
@endforeach

{{ $products->links() }}

Есть и simplePaginate() — без COUNT(*), когда шаблону нужны только “вперёд” и “назад”.

Транзакции пишутся как обычно. Manticore выполняет BEGIN / COMMIT / ROLLBACK на real-time таблицах:

\ManticoreDb::transaction(function ($connection) {
    $connection->table('products')->insert($data);
    $connection->table('log')->insert($record);
});

Миграция — обычный класс миграции:

use avadim\Manticore\QueryBuilder\Schema\SchemaTable;

public function up()
{
    \ManticoreDb::create('products', function (SchemaTable $table) {
        $table->timestamp('created_at');
        $table->string('name');
        $table->text('description');
        $table->float('price');
    });
}

public function down()
{
    \ManticoreDb::drop('products', true);
}

Ошибка запроса — исключение, а не пустая выборка. Отклонённое чтение бросает QueryErrorException: неверный запрос — это дефект, и молча вернуть ноль строк здесь хуже, чем упасть.

use avadim\Manticore\QueryBuilder\QueryErrorException;

try {
    $rows = \ManticoreDb::table('products')->match($text)->get();
}
catch (QueryErrorException $e) {
    report($e);

    $rows = collect();
}

Чаще всего запрос отклоняет полнотекстовая строка, пришедшая от посетителя, — её стоит пропустить через \ManticoreDb::escapeMatch($text) до попадания в match().

Почему php-пакетов два

Я сделал два пакета: avadim/manticore-query-builder-php — ядро без единой зависимости от фреймворка, и avadim/manticore-query-builder-laravel — обвязка поверх него.

Разделение не ради красоты. Ядро отвечает обычными массивами именно потому, что Collection в нём означала бы illuminate/support в библиотеке, которая должна работать и в проекте на Symfony, и в скрипте без фреймворка вообще. А обвязка добавляет ровно то, что имеет смысл только внутри Laravel: сервис-провайдер, файл конфигурации, именованные соединения и ответы в том виде, в каком их ждёт фреймворк.

Для приложения это значит, что до одного и того же соединения ведут три равнозначные дороги:

use avadim\Manticore\Laravel\Manager;

// глобальный алиас, который регистрирует сам пакет
\ManticoreDb::table('products')->find($id);

// фасад, если глобальный алиас не по душе
ManticoreDb::table('products')->find($id);

// внедрение зависимости, если класс должен тестироваться без фреймворка вокруг
public function __construct(Manager $manticore) { … }

Грабли, о которых важно помнить

Транзакционного DDL в Manticore нет. Миграция, упавшая на середине, оставит всё, что успела создать. Писать down() нужно так, чтобы он отработал и на недостроенной таблице.

Одна операция на выражение. ADD COLUMN a, ADD COLUMN b сервер не примет — это два запроса, и падение второго оставит первый применённым.

Новая колонка не переиндексирует старые строки. Полнотекстовое поле, добавленное к работающей таблице, найдёт только то, что записано после него. Чтобы колонка что-то значила, данные нужно записать заново.

Кеш схемы живёт столько же, сколько соединение. Соединение запоминает DESCRIBE каждой таблицы, чтобы не спрашивать сервер перед каждым запросом и приводить значения к типам PHP.

Если вы меняете схему с помощью методов самого пакета – create(), alter(), addColumn(), dropColumn() и прочие – библиотека знает об этих изменениях, сбрасывает кеш схемы и и никаких проблем не возникает. Но бывают ситуации, когда кеш схемы надо сбрасывать явно:

\ManticoreDb::forgetSchema();

Условие для сборса кеша всегда одно и то же — схему изменили мимо вашего соединения, а объект Connection пережил это изменение. На практике это четыре случая.

1. ALTER через statement(). Сырой SQL проходит мимо билдера, и сбросить кеш ему нечем:

\ManticoreDb::connection()->statement('ALTER TABLE products ADD COLUMN rating int');
\ManticoreDb::forgetSchema();

2. Миграция в другом процессе. php artisan migrate отработал, а воркер Horizon или Octane, поднятый до неё, продолжает жить со старым кешом. Самый частый случай, потому что деплой обычно так и выглядит.

3. Таблицу изменил кто-то ещё — indexer, соседний сервис, другое приложение на том же демоне.

4. Другое именованное соединение того же приложения. Изменили через connection('admin'), читаете через connection('default') — это два разных пула.

Важно помнить: под php-fpm соединение живёт ровно один HTTP-запрос, поэтому в контроллере вызывать forgetSchema() бессмысленно почти всегда: кеш и так не переживёт ответ. Долгоживущие процессы — Octane, очереди, планировщик, длинные CLI-команды — единственное место, где это реально нужно.

Отдельно стоит держать в голове тесты: если тест-кейс создаёт и удаляет таблицы через statement() и переиспользует соединение между тестами, кеш надо сбрасывать в setUp(), иначе следующий тест увидит схему предыдущего.

Когда брать официальный клиент

Если вы работаете именно по HTTP JSON API — например, тот же код ходит в Manticore из нескольких языков и формат запросов общий. Если инфраструктура не пускает вас на 9306. И если важно, чтобы клиент поддерживался вендором и выходил вместе с релизами сервера.

Мой пакет — SQL-only, HTTP JSON API он не использует вовсе. Это осознанное ограничение, а не недоделка: первая из трёх причин, по которым он написан, ровно в том и состоит, что он говорит с сервером на SQL.

Где почитать подробнее

0

Партнёры и друзья

Помощь в разработке вашего проекта на Laravel

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

Присоединиться

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

Перейти

Подкасты c зажигательными эпизодами, которые заставят задуматься и приведут к новым перспективам.

Перейти

Делятся опытом, находят друзей и обсуждают разработку и сопровождение любых бэкендов на PHP.

Перейти