Зачем неофициальный 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.
Где почитать подробнее
- avadim/manticore-query-builder-laravel — интеграция с Laravel и Lumen
- avadim/manticore-query-builder-php — ядро, синтаксис запросов и DSL схемы
- avadim/manticore-laravel-scout — драйвер для Laravel Scout поверх этих двух, если нужен
Post::search('manticore')->get()по моделям Eloquent - manticoresoftware/manticoresearch-php — официальный клиент
- Документация Manticore Search