cakephp5 连ElasticSearch

Title cakephp5 连ElasticSearch
Framework CakePHP5
User wy8817399@vip.qq.com
Id 20
Created 1/13/26, 8:34 AM
Modified 1/22/26, 10:42 AM
Published Yes
Content

1、在es里面先添加好规则

{
    "mappings":{
        "properties":{
            "id":{
                "type":"keyword"
            },
            "title":{
                "type":"text",
                "analyzer":"ik_max_word",
                "copy_to":"all"
            },
            "content":{
                "type":"text",
                "analyzer":"ik_max_word",
                "copy_to":"all"
            },
            "framework_id":{
                "type":"keyword"
            },
            "framework_name":{
                "type":"text",
                "analyzer":"ik_max_word",
                "copy_to":"all"
            },
             "published": {
                "type": "boolean"
            },
            "user_id": {
                "type": "keyword"
            },
            "created": {
                "type": "date"
            },
            "modified": {
                "type": "date"
            },
            "all":{
                "type":"text",
                "analyzer":"ik_max_word"
            }
        }
    }
}

 

2、安装ES插件,在项目根目录下执行

composer require cakephp/elastic-search

 

3、加载插件,在src/Application.php文件的bootstrap()方法的parent::bootstrap();下面添加

$this->addPlugin('Cake/ElasticSearch');

 

4、配置ES帐号密码config/app_local.php的Datasources下,添加下面这段话,帐号密码端口自行替换

    'elastic' => [
            'className' => 'Cake\ElasticSearch\Datasource\Connection',
            'driver' => 'Cake\ElasticSearch\Datasource\Connection',
            'hosts' => ['http://elastic:A3MZa=H5sjtyIyE+7aSr@localhost:9201'],
        ],

 

5、在对应的模型层里面,添加afterSave和afterDelete代码,对应字段自行替换

    public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options){
        try {
            // 连接ElasticSearch
            $notesIndex = $this->getElasticsearchIndex();

            // 定义数据
            $data = [
                'id' => (string)$entity ->get('id'),
                'title' => $entity ->get('title'),
                'content' => $entity ->get('content'),
                'framework_id' => $entity ->get('framework_id'),
                'published' => $entity ->get('published'),
                'user_id' => $entity ->get('user_id'),
                'created' => $entity ->get('created'),
                'modified' => $entity ->get('modified'),
            ];

            if (!$entity->has('framework') && $entity->framework_id) {
                // 通过 loadInto 方法加载关联
                $entity = $this->loadInto($entity, ['Frameworks']);
            }

            if ($entity->has('framework') && !empty($entity->framework)) {
                $data['framework_name'] = $entity->framework->name;
            } else if ($entity->framework_id) {
                // 如果关联加载失败,可以直接查询数据库获取框架名
                $framework = $this->Frameworks->find()
                    ->select(['name'])
                    ->where(['id' => $entity->framework_id])
                    ->first();

                if ($framework) {
                    $data['framework_name'] = $framework->name;
                }
            }



            // 保存数据
            $document = $notesIndex->newEntity($data);
            if ($notesIndex->save($document)) {
                Log::info("Note ID {$entity->id} 同步到 Elasticsearch 成功");
            } else {
                Log::error("Note ID {$entity->id} 同步到 Elasticsearch 失败");
            }

        } catch (Exception $e) {
            Log::error("Elasticsearch 同步异常,Note ID: {$entity->id}, 错误: " . $e->getMessage());
        }
    }

    /**
     * 在删除笔记后触发,用于从 Elasticsearch 删除文档
     *
     * @param \Cake\Event\EventInterface $event
     * @param \Cake\Datasource\EntityInterface $entity
     * @param \ArrayObject $options
     * @return void
     */
    public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)
    {
        try {
            // 连接ElasticSearch
            $notesIndex = $this->getElasticsearchIndex();

            try {
                $document = $notesIndex->get((string)$entity->id);

                if ($notesIndex->delete($document)) {
                    Log::info("ID {$entity->id} 删除成功");
                } else {
                    Log::warning("⚠️ 删除操作返回 false,Note ID: {$entity->id}");
                }
            } catch (\Cake\Datasource\Exception\RecordNotFoundException $e) {
                // 文档不存在,无需删除
                Log::debug("Note ID {$entity->id} 在 Elasticsearch 索引中不存在,无需删除");
            }
        } catch (MissingDocumentException $e) {
            Log::debug("Note ID {$entity->id} 在 Elasticsearch 索引中不存在,无需删除");
        } catch (Exception $e) {
            // 捕获其他所有可能的异常(如连接失败、语法错误等)
            Log::error("🔥 从 Elasticsearch 删除文档时发生异常,Note ID: {$entity->id}");
            Log::error("错误信息: " . $e->getMessage());
        }
    }

    private function getElasticsearchIndex()
    {
        // 单例模式,避免重复创建连接
        static $notesIndex = null;

        if ($notesIndex === null) {
            $connection = ConnectionManager::get('elastic');
            $notesIndex = new Index([
                'connection' => $connection,
                'name' => 'notes'
            ]);
        }

        return $notesIndex;
    }

 

6、关于搜索,目前cake官方插件where条件只存在and不能用or。所以自己写了一下。还没优化,先凑活用。

 public function search()
    {
        $this->request->allowMethod(['get']);
        $keyword = $this->request->getQuery('q');
        $framework_id = $this->request->getQuery('framework_id');
        $page = $this->request->getQuery('page', 1);
        $limit = $this->request->getQuery('limit', 10);

        // 检查Datasources配置
        $requestUrl = ConnectionManager::get('elastic')->config()['hosts'][0] . '/notes/_search';

        $from = ($page - 1) * $limit;

        $query = [
            'query' => [
                'bool' => [
                    'must' => [
                        ['term' => ['published' => true]]
                    ]
                ]
            ],
            'from' => $from,
            'size' => $limit,
            'sort' => [
                [
                    'created' => [
                        'order' => 'desc'
                    ]
                ]
            ]
        ];

        // 添加搜索条件
        if (!empty($framework_id)) {
            $query['query']['bool']['must'][] = ['term' => ['framework_id' => $framework_id]];
        }

        if (!empty($keyword)) {
            $query['query']['bool']['must'][] = ['match' => ['all' => $keyword]];
        }

//        echo "请求体: " . json_encode($query, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "<br>";

        // 解析URL获取认证信息
        $parsedUrl = parse_url($requestUrl);
        // 重新构建不带认证信息的URL
        $cleanUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . ':' . $parsedUrl['port'] . $parsedUrl['path'];

        // 发送POST请求(CakePHP HttpClient的GET请求不支持请求体)
        $http = new Client();

        $options = [
            'type' => 'json',
            'headers' => ['Content-Type' => 'application/json']
        ];

        // 添加认证头部
        if (isset($parsedUrl['user']) && isset($parsedUrl['pass'])) {
            $auth = base64_encode($parsedUrl['user'] . ':' . $parsedUrl['pass']);
            $options['headers']['Authorization'] = 'Basic ' . $auth;
        }

        // 发送POST请求
        $response = $http->post($cleanUrl, json_encode($query), $options);

        if ($response->isOk()) {
            $data = json_decode($response->getStringBody(),true);
//            pr($data);
            // 处理响应数据
            $notes = [];
            $total = $data['hits']['total']['value'] ?? 0;
            if (isset($data['hits']['hits'])) {
                foreach ($data['hits']['hits'] as $hit) {
                    $note = $hit['_source'];
                    $note['id'] = $hit['_id'];
                    $notes[] = $note;
                }
            }
            // 创建分页数据
            $paging = [
                'count' => $total,
                'current' => count($notes),
                'perPage' => $limit,
                'page' => $page,
                'pages' => $total > 0 ? ceil($total / $limit) : 1,
                'prevPage' => $page > 1,
                'nextPage' => ($page * $limit) < $total,
                'start' => $from + 1,
                'end' => min($from + $limit, $total)
            ];

            // 7. 设置视图变量
            $this->set(compact('notes', 'paging','page','limit','total'));
            $this->viewBuilder()->setOption('serialize', ['notes', 'paging']);

        }

    }