【Laravel10】Elasticserchを導入してみた

# はじめに
Elasticserchを導入しているプロジェクトがあって、どんな感じなのかイメージ掴むため、下記の参考記事にそって実装をすすめてみる

[とりあえずLaravel Scout＋Elasticsearchを動かしてみる](https://qiita.com/sola-msr/items/64d57d3970b715c795f5#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3%E3%81%AE%E4%BD%9C%E6%88%90)

# 開発環境
Os:Mac
PHP：version 8.3.2
Laravel：version 10.45.1
Composer：version 2.2.4
Elasticsearch：5.6.16


# 導入

## プロジェクトを作成

```　　
$ composer create-project --prefer-dist laravel/laravel Laravel-elasticserch
```


## ライブラリインストール

```
$ composer require laravel/scout
$ composer require elasticsearch/elasticsearch
```

## Scoutの設定ファイル生成して設定する

```
// config配下にscout.phpを生成
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
```

```config/scout.php
<?php

return [

// ～～～[略]～～～

    /*
    |--------------------------------------------------------------------------
    | Elasticsearch Configuration
    |--------------------------------------------------------------------------
    */
    'elasticsearch' => [
        'index' => env('ELASTICSEARCH_INDEX', 'scout'),
        'hosts' => [
            env('ELASTICSEARCH_HOST', 'http://localhost'),
        ],
    ],
];

```

## プロバイダー作成

```
$ php artisan make:provider ElasticsearchServiceProvider
```

```ElasticsearchServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Scout\EngineManager;
use App\Scout\ElasticsearchEngine;
use Elastic\Elasticsearch\ClientBuilder;

class ElasticsearchServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap services.
     */
    public function boot(): void
    {
        // ScoutのEngineManagerを拡張してElasticsearchをサポートす
        resolve(EngineManager::class)->extend('elasticsearch', function ($app) {
            // 適切なElasticsearchクライアントを使用してElasticsearchEngineの新しいインスタンスを作成
            return new ElasticsearchEngine(
                // Scoutの設定で指定されたインデックス名を使用
                config('scout.elasticsearch.index'),
                // Scoutの設定からElasticsearchのホスト情報を使用してElasticsearchクライアントを構築
                ClientBuilder::create()
                    ->setHosts(config('scout.elasticsearch.hosts'))
                    ->build()
            );
        });
    }
}

```
## プロバイダー登録

```config/app.php
'providers' => [

    // 中略

    /*
     * Package Service Providers...
     */
    App\Providers\ElasticsearchServiceProvider::class,
],

```

## カスタムエンジンを作成

```app\Scout\ElasticSearchEngine.php.php
<?php

namespace App\Scout;

use Elasticsearch\Client as Elastic;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;

class ElasticsearchEngine extends Engine
{
  protected $index;

  protected $elastic;

  public function __construct($index, $elastic)
  {
    $this->index = $index;
    $this->elastic = $elastic;
  }

  public function flush($model)
  {
    //
  }

  public function lazyMap(Builder $builder, $results, $model)
  {
    // Implement lazyMap method
  }

  public function createIndex($index, array $options = [])
  {
    // Implement createIndex method
  }

  public function deleteIndex($index)
  {
    // Implement deleteIndex method
  }

  public function update($models)
  {
    $params['body'] = [];
    $models->each(function ($model) use (&$params) {
      $params['body'][] = [
        'update' => [
          '_id' => $model->getKey(),
          '_index' => $this->index,
          '_type' => $model->searchableAs(),
        ]
      ];
      $params['body'][] = [
        'doc' => $model->toSearchableArray(),
        'doc_as_upsert' => true
      ];
    });
    $this->elastic->bulk($params);
  }

  public function delete($models)
  {
    $params['body'] = [];
    $models->each(function ($model) use (&$params) {
      $params['body'][] = [
        'delete' => [
          '_id' => $model->getKey(),
          '_index' => $this->index,
          '_type' => $model->searchableAs(),
        ]
      ];
    });
    $this->elastic->bulk($params);
  }

  public function search(Builder $builder)
  {
    // Implement search method
  }

  public function paginate(Builder $builder, $perPage, $page)
  {
    // Implement paginate method
  }

  public function mapIds($results)
  {
    // Implement mapIds method
  }

  public function map(Builder $builder, $results, $model)
  {
    // Implement map method
  }

  public function getTotalCount($results)
  {
    // Implement getTotalCount method
  }

  protected function performSearch(Builder $builder, $options = [])
  {
    // Implement performSearch method
  }

  public function filters(Builder $builder)
  {
    // Implement filters method
  }

  protected function sort(Builder $builder)
  {
    // Implement sort method
  }
}

```

## DB作成

```
$ mysql -u root

MariaDB> CREATE DATABASE `findhome`
MariaDB> CHARACTER SET utf8mb4
MariaDB> COLLATE utf8mb4_unicode_ci;

```

```
$ php artisan make:migration create_shops_table
```

```
$ php artisan migrate
```

## モデル作成

```
$ php artisan make:model Models/Shop
```

```Models/Shop.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Shop extends Model
{
    use Searchable;
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'building_type',
        'phone_number',
        'address',
        'latitude',
        'longitude',
        'nearest_station',
        'open',
        'close',
        'wifi',
    ];
}


```

## Elasticsearchの起動

### ルート配下にdocker-compose.ymlを作成

```docker-compose.yml
version: '2'
services:
  elasticsearch:
    image: elasticsearch:5.6
    volumes:
      - ./elasticsearch-data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
```
### DockerでElasticsearchコンテナを起動

```
// コンテナ起動
$ docker-compose up -d

// 起動しているか確認
$ docker ps
```
- 起動できていることを確認

![スクリーンショット 2024-02-26 21.44.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/923146/e6d1d6d5-aad7-4b71-a762-d9e6c15542ea.png)

## Elasticsearchの動作確認

- curlを使用してデータを追加する

```
$ curl -X PUT http://localhost:9200/test_index/httpd_access/1 -d '{"host": "localhost", "response": "200", "request": "/"}'
```

- 追加したデータの取得

```
$ curl -X GET http://localhost:9200/test_index/_search -d '{"query": { "match_all": {} } }'
```

- ステータス200で期待されている結果どおり取得できている

```
{
  "took": 120,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 1,
    "max_score": 1.0,
    "hits": [
      {
        "_index": "test_index",
        "_type": "httpd_access",
        "_id": "1",
        "_score": 1.0,
        "_source": {
          "host": "localhost",
          "response": "200",
          "request": "/"
        }
      }
    ]
  }
}
```

## テスト用データの作成

Elasticsearchに登録するテスト用のデータを生成する

```
$ php artisan make:factory ShopFactory
```

database/factories/ShopFactory.phpを追加

```database/factories/ShopFactory.php
<?php

namespace Database\Factories;

use App\Models\Shop;
use Illuminate\Database\Eloquent\Factories\Factory;

class ShopFactory extends Factory
{
    protected $model = Shop::class;

    public function definition()
    {
        return [
            'name' => $this->faker->streetName . $this->faker->randomElement(['マンション', 'ハウス', 'レーベン', 'レジデンス']),
            'phone_number' => $this->faker->phoneNumber,
            'address' => $this->faker->address,
            'nearest_station' => $this->faker->randomElement(['東京', '表参道', '錦糸町']),
            'open' => $this->faker->randomElement(['9:00', '10:00', '11:00', '12:00']),
            'close' => $this->faker->randomElement(['19:00', '20:00', '21:00', '22:00', '23:00']),
            'wifi' => $this->faker->randomElement(['あり', 'なし']),
        ];
    }
}


```

- tinker使用せず、通常通りseederで作成してしまった。データが取得できているか確認

```
curl -X GET "localhost:9200/_cat/indices?v"
```

```
curl -X GET http://localhost:9200/scout/_search -d '{"query": { "match_all": {} } }'

```
- データみにくいけど、取得できている


![スクリーンショット 2024-03-04 21.48.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/923146/bdce0a5c-fc6d-d73b-fdf7-4d32f6c2ff4b.png)

- 検索してみる

```
curl -X GET http://localhost:9200/scout/_search -d '{
  "query": {
    "match": {
      "nearest_station": "表参道"
    }
  }
}' | jq
```
- 取得できている

![スクリーンショット 2024-03-04 22.11.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/923146/334fdf0d-d770-9f18-17b8-633644cb294f.png)

- 下記のエラーになる場合は、jqコマンドを使用できるようにインストールする

```
zsh: command not found: jq
```

```
# Macの方
$ brew install jq
```


# まとめ
なんとなくElasticsearch触る時は、こなんな感じか〜ってイメージ掴むために実装すすめてみましたが、よく理解できていない。


# 参考
- [Laravel 10.x Laravel Scout](https://arc.net/l/quote/yjxtfmje)
- [【jqコマンド】JSONをLinuxで扱うコマンドの使い方](https://zenn.dev/en2enzo2/articles/e45e6d0aec6c7e)