ElasticKit:PHP Elasticsearch 查询构建器

PHP 操作 Elasticsearch,查询一复杂就是五层嵌套的关联数组。改一个 filtermust,要重写整段结构;加一个动态条件,要拼 $params['body']['query']['bool']['filter'][] 这种路径。

对比

手写数组:

php 复制代码
$params = [
    'body' => [
        'query' => [
            'bool' => [
                'must' => [
                    ['match' => ['title' => 'elasticsearch']]
                ],
                'filter' => [
                    ['range' => ['price' => ['gte' => 10, 'lte' => 100]]],
                    ['term' => ['status' => 'published']]
                ]
            ]
        ],
        'highlight' => [
            'fields' => ['title' => new \stdClass()]
        ],
        'sort' => [['price' => 'asc']],
        'size' => 20
    ]
];

if ($categoryId) {
    $params['body']['query']['bool']['filter'][] = ['term' => ['category_id' => $categoryId]];
}

ElasticKit:

php 复制代码
ProductIndex::query()
    ->bool([
        'must'   => fn ($q) => $q->match('title', 'elasticsearch'),
        'filter' => fn ($q) => $q
            ->range('price', [10, 100])
            ->term('status', 'published')
            ->when($categoryId, fn ($q) => $q->term('category_id', $categoryId)),
    ])
    ->highlight('title')
    ->sort('price', 'asc')
    ->size(20)
    ->get();

为什么要做 ElasticKit

用 PHP 管理 Elasticsearch,DSL 手写数组、Index 管理、scroll 遍历、零停机重建、批量写入等都需要自行组织。现有方案要么过重,要么过于轻量,要么自建概念,需要额外学习成本。ElasticKit 希望在中间做一些平衡。

ElasticKit 的目标:

  • 全链路------Index 管理、查询、CRUD、批量写入、零停机重建一站搞定
  • API 与 ES 保持一致------方法名即 JSON key,无额外概念
  • 不封闭------覆盖不到的场景传原生数组,不强制走 DSL
  • 不绑框架------无框架依赖,DSL 层可独立使用

DSL 层

查询构建

方法名即 ES JSON key,链式调用:

php 复制代码
// 简单查询
$query->match('title', 'elasticsearch');
$query->term('status', 'published');
$query->range('price', ['gte' => 10, 'lte' => 100]);

// bool 组合
$query->bool([
    'must'   => fn ($q) => $q->match('title', 'elasticsearch'),
    'filter' => fn ($q) => $q->range('price', [10, 100])->term('status', 'published'),
]);

// 条件查询
$query->match('title', 'elasticsearch')
    ->when($categoryId, fn ($q) => $q->term('category_id', $categoryId));

toArray() 返回数组,toJson() 返回 JSON 字符串------调试时随时查看生成的 DSL:

php 复制代码
$query = new Query();
$query->match('title', 'elasticsearch');

$query->toArray();
// ['query' => ['match' => ['title' => 'elasticsearch']]]

$query->toJson();
// {"query":{"match":{"title":"elasticsearch"}}}

DSL 对象在 toArray() 时才序列化,构建过程无额外性能开销。

聚合

链式调用添加平级聚合,闭包处理嵌套:

php 复制代码
// 按分类聚合,每个分类取平均价格和 Top 3 商品
$query->aggs('by_category', function ($a) {
    $a->terms(['field' => 'category_id', 'size' => 20]);
    $a->aggs('avg_price', ['avg' => ['field' => 'price']]);
    $a->aggs('top_products', ['top_hits' => ['size' => 3, 'sort' => ['price' => 'desc']]]);
})->aggs('price_stats', ['stats' => ['field' => 'price']]);

// Index 层快捷方法------直接返回标量
ProductIndex::query()->avg('price');   // 29.99
ProductIndex::query()->max('price');   // 199.99

多态参数

每个 DSL 方法接受字符串、数组、闭包、对象四种输入:

php 复制代码
// 字符串
$query->match('title', 'elasticsearch');

// 数组
$query->range('price', ['gte' => 10, 'lte' => 100]);

// 闭包------精细控制参数
$query->match('title', function (Match_ $m) {
    $m->query('elasticsearch')->fuzziness('AUTO');
});

// 对象
$query->term((new Term())->field('status')->value('published'));

所有输入最终都包装成内部对象,toArray() 时统一序列化。DSL 覆盖不到的场景,直接传原生数组:

php 复制代码
Query::make([
    'query' => fn ($q) => $q->match('title', 'test'),
    'post_filter' => fn ($q) => $q->term('status', 'published'),
    'size' => 20,
])->sort('price', 'asc');

Index 层

一切从一个 Index 子类开始:

php 复制代码
use ElasticKit\Index\Index;

class ProductIndex extends Index
{
    protected $name = 'products';
    protected $mappings = [
        'properties' => [
            'title'  => ['type' => 'text'],
            'price'  => ['type' => 'float'],
            'status' => ['type' => 'keyword'],
        ],
    ];
}

// 注册 Client,全局一次
Index::setClient(
    \Elastic\Elasticsearch\ClientBuilder::create()->setHosts(['http://localhost:9200'])->build()
);

name、mappings、settings 在子类中声明,查询、CRUD、重建通过 ProductIndex:: 静态调用。

搜索

php 复制代码
$results = ProductIndex::query()
    ->match('title', 'elasticsearch')
    ->sort('price', 'asc')
    ->size(20)
    ->get();

$results->total();        // 命中数
$results->docs();         // _source 数组
$results->aggregations(); // 聚合结果

ProductIndex::query()->first();
ProductIndex::query()->term('status', 'published')->count();

分页

php 复制代码
// 手动分页
$results = ProductIndex::query()->paginate($page, $perPage);

// 自动解析请求
Index::setPageResolver(fn () => [$_GET['page'] ?? 1, $_GET['per_page'] ?? 20]);
$results = ProductIndex::query()->paginate();

// 可扩展对接框架分页器(以 Laravel 为例)
Index::setPaginatorResolver(fn ($results, $page, $perPage) =>
    new LengthAwarePaginator($results->docs(), $results->total(), $perPage, $page)
);
$results->toPaginator();

大数据遍历

原生 scroll 需手动管理 scrollId、循环、清理:

php 复制代码
$results = $client->search(['scroll' => '5m', ...]);
while (count($results['hits']['hits']) > 0) {
    // 处理...
    $results = $client->scroll(['scroll_id' => $results['_scroll_id'], 'scroll' => '5m']);
}
$client->clearScroll(['scroll_id' => $results['_scroll_id']]);

Cursor 封装为生成器,用完自动清理:

php 复制代码
foreach (ProductIndex::query()->cursor() as $results) {
    foreach ($results->docs() as $doc) {
        // 处理
    }
}

文档 CRUD

php 复制代码
$doc = ProductIndex::doc(1);
$doc->create(['title' => 'New Product', 'price' => 29.99]);
$doc->source();
$doc->update(['price' => 39.99]);
$doc->retryOnConflict(3)->update(['price' => 39.99]);
$doc->delete();

批量操作

php 复制代码
$bulk = new Bulk(new ProductIndex());
$bulk->batchSize(500);
$bulk->index(1, ['title' => 'Product A']);
$bulk->index(2, ['title' => 'Product B']);
$bulk->delete(3);
$bulk->execute();

零停机重建

索引结构变更(加字段、改分词器)需要重建索引。数据源在 Index 子类中用生成器定义:

php 复制代码
class ProductIndex extends Index
{
    public function source(array $context = []): iterable
    {
        foreach (Product::all() as $product) {
            yield $product->id => $product->toArray();
        }
    }
}

一条命令完成重建:

php 复制代码
$rebuild = new Rebuild(new ProductIndex());
$result = $rebuild->batchSize(500)->run();
// 创建新索引(带时间戳后缀) → 批量导入 → 切别名指向新索引 → 确认后清理旧索引

切别名后发现问题:

php 复制代码
$rebuild->rollback($result['oldIndex']);  // 别名切回旧索引
$rebuild->clean($result['oldIndex']);     // 确认无误后清理旧索引

数据源是生成器,百万级数据不会撑爆内存。run() 支持传 context 过滤数据:$rebuild->run(['after' => '2025-01-01'])

source() 也适用于增量同步场景------通过 context 传入指定 ID,支持单文档或多文档的增量更新。

事件系统

11 个事件钩子,三层命名(search.query.beforebulk.execute.afterrebuild.run.failed),支持通配符:

php 复制代码
Index::listen('search.query.after', function (Event $e) {
    Log::info("{$e->name} on {$e->index}", ['duration' => $e->duration]);
});

Index::listen('rebuild.run.after', function (Event $e) {
    Mail::to($admin)->send("索引 {$e->index} 重建完成");
});

Index::listen('rebuild.import.failed', function (Event $e) {
    Sms::to($admin)->send("索引重建导入失败:{$e->index}");
});

覆盖范围

类别 数量 示例
查询类型 10 大类 50+ 方法 match、term、range、bool、nested、geo、function_score...
聚合 47 种 terms、date_histogram、composite、significant_terms...
搜索参数 26 个 sort、highlight、rescore、collapse、suggest...

PHPStan Level 8 静态分析 + 360+ 单元测试。完整列表见 DSL 文档,Index 层功能见 Index 文档

安装

bash 复制代码
# ES 8.x(PHP 8.1+)
composer require ykan/elastickit:^8@beta

# ES 7.x(PHP 7.4+)
composer require ykan/elastickit:^7@beta

核心 API 已稳定。Beta 阶段主要在完善文档和收集反馈。


MIT 协议。GitHub : github.com/ykan821/Ela...

相关推荐
两个人的幸福3 天前
Windows 桌面应用自研 PHP 队列(下):完整代码与六大工程化优化
php
BingoGo6 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
JaguarJack6 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
用户3074596982076 天前
PHP 扩展——从入门到理解
php
鹏仔先生7 天前
拷贝漫画APP下载页PHP程序,后台带免费AI写作
php
云水一下7 天前
从零开始学 PHP 系列(一):PHP 的前世今生与开发环境搭建
开发语言·php
xingpanvip7 天前
星盘接口开发文档:本命盘接口指南
android·开发语言·css·php·lua
酉鬼女又兒7 天前
零基础入门计算机网络运输层:端到端通信核心作用、端口号分类规则、复用分用工作机制及UDP与TCP协议全方位对比详解
网络·网络协议·tcp/ip·计算机网络·考研·udp·php
dog2507 天前
不要再继续优化 TCP
网络协议·tcp/ip·php
Channing Lewis7 天前
PHP 解析 Excel 的那些坑:一次“行号错位”引发的数据丢失
开发语言·php·excel