前提条件
環境
・ローカル
検証端末:MacOS Monterey (12.1)
docker:Docker version 20.10.11, build dea9396
docker-compose:v2.2.1
・Docker環境
PHP:8.0.13
MySQL:8.0.27
Elasticsearch:7.12.0
読むべき人
Elasticsearch導入したことない人。
家で検証してみたい人。
Elasticという響きがかっこいいなーと思ってる人。
コンテナの構成
・nginx
リクエストを最初に受けるサーバー
・php
Laravelが動作するコンテナ。
・mysql
メインのデータストレージとして使用するDB。
・Elasticsearch
全文検索エンジン。Scout経由で使用する想定。
・node
画面実装にはないと困るので。
Laravelのモジュールをクローンする
git clone https://github.com/laravel/laravel.git
今回作成する環境のディレクトリ構成
elasticsearch_test/ ←ディレクトリ名は任意のものに変更
├── CHANGELOG.md
├── README.md
├── app
├── artisan
├── bootstrap
├── composer.json
├── composer.lock
├── config
├── database
├── docker ←[追加] 設定ファイルを追加
├── docker-compose.yml ←[追加] 設定ファイルを追加
├── package.json
├── phpunit.xml
├── public
├── resources
├── routes
├── server.php
├── src
├── storage
├── tests
├── vendor
└── webpack.mix.js
任意のプロジェクト名に変更
今回は「elasticsearch_test」とする。
mv laravel elasticsearch_test
Dockerの設定ファイルを追加していく
nginxの設定ファイル
nginxコンテナ。
Laravelのrootディレクトリはpublic配下なので設定しておく。
listenするポートはコンテナの内側なのでwebサーバーのデフォルトの80でおk。
docker/nginx/default.conf
server {
listen 80;
index index.php index.html;
root /var/www/elasticsearch_test/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}
phpコンテナのDockerfile
phpコンテナはimageではなくDockerfileで設定を指定する。
理由はコンテナ起動時にRUNで叩きたいコマンドがあるからだと思うけど、今回はその辺から拝借してきた記述をそのままにしてある。
docker/php/Dockerfile
FROM php:8.0-fpm
COPY php.ini /usr/local/etc/php/
RUN apt-get update \
&& apt-get install -y zlib1g-dev mariadb-client vim libzip-dev \
&& docker-php-ext-install zip pdo_mysql
#Composer install
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
RUN php composer-setup.php
RUN php -r "unlink('composer-setup.php');"
RUN mv composer.phar /usr/local/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER 1
ENV COMPOSER_HOME /composer
ENV PATH $PATH:/composer/vendor/bin
WORKDIR /var/www
COPY . /var/www/
RUN composer global require "laravel/installer"
php.ini
docker/php/php.ini
[Date]
date.timezone = "Asia/Tokyo"
[mbstring]
mbstring.internal_encoding = "UTF-8"
mbstring.language = "Japanese"
docker-compose.yml
コンテナ達を1セットに扱うためのdocker-compose。
それの設定ファイルを定義しておきます。
version: '3'
services:
php:
container_name: php_container
build: ./docker/php
volumes:
- ./:/var/www/elasticsearch_test
nginx:
container_name: nginx_container
image: nginx
ports:
- 81:80
volumes:
- ./:/var/www/elasticsearch_test
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
db:
container_name: mysql_container
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: database
MYSQL_PASSWORD: root
TZ: 'Asia/Tokyo'
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- ./docker/db/data:/var/lib/mysql
- ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
- ./docker/db/sql:/docker-entrypoint-initdb.d
ports:
- 3306:3306
es:
container_name: elasticsearch_container
image: elasticsearch:7.12.0
ports:
- "9200:9200"
environment:
- discovery.type=single-node
- cluster.name=docker-cluster
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
node:
image: node:12.13-alpine
tty: true
volumes:
- ./src:/var/www
working_dir: /var/www
コンテナ起動
起動!
docker-compose up -d
コンテナで実行
vendor周りを作成。
composerの意味がわからずvendor配下を他プロジェクトからコピーしてきていた日が僕にもありました。
依存ライブラリはちゃんとcomposerでインストールしよう!
composer install
envファイルを作成
cp .env.example .env
keyを生成
これあんまり意味分かってない。謎の儀式。
php artisan key:generate
疎通確認
phpコンテナ
ブラウザでアクセス
localhost:81
mysqlコンテナ
コンテナにログイン
docker exec -it mysql_container /bin/bash
Mysqlにログイン
mysql -uroot -proot
Databaseを作成する
create database elasticsearch_test_db;
Mysql8系はデフォルトの接続方法が異なるため変更する
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'root';
.envファイルのDBホスト情報をdocker-composeで定義したサービス名に変更
DB_CONNECTION=mysql
DB_HOST=db // ←ここ
DB_PORT=3306
DB_DATABASE=elasticsearch_test_db
DB_USERNAME=root
DB_PASSWORD=root
Elasticsearchコンテナ
とりあえずコンテナに入る
docker exec -it elasticsearch_container /bin/bash
Elasticserachコンテナの中からなのでhostは「localhost」
[root@8970f3395096 elasticsearch]# curl -X GET localhost:9200
{
"name" : "8970f3395096",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "JKMTJ9JYRDuTfq0o4phKIQ",
"version" : {
"number" : "7.12.0",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "78722783c38caa25a70982b5b042074cde5d3b3a",
"build_date" : "2021-03-18T06:17:15.410153305Z",
"build_snapshot" : false,
"lucene_version" : "8.8.0",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
.envにElasticsearchの設定を追加。
識別はdocker-compose.ymlのserviceに書いたservice名
SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=es:9200
サンプルとなるDBを用意する
migration
migrationファイルを作成
php artisan make:migration create_scout_test_records_table --create=scout_test_records
database/migrations/2021_12_17_xxxxxx_create_scout_test_records_table.php のサンプル
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateScoutTestRecordsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('scout_test_records', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 255)->comment('名前');
$table->string('hash_code', 255)->comment('ハッシュコード');
$table->text('text')->nullable()->comment('テキスト');
$table->double('latitude')->nullable()->comment('緯度');
$table->double('longitude')->nullable()->comment('経度');
$table->integer('type')->comment('タイプ');
$table->tinyInteger('status')->comment('ステータス');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('scout_test_records');
}
}
Model
modelを作成
php artisan make:model ScoutTestRecord
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ScoutTestRecord extends Model
{
use HasFactory;
use SoftDeletes;
public $table = 'scout_test_records';
public $dates = ['deleted_at'];
public $fillable = [
'id',
'name',
'hash_code',
'text',
'latitude',
'longitude',
'type',
'status',
];
protected $casts = [
'id' => 'integer',
'name' => 'string',
'hash_code' => 'string',
'text' => 'string',
'latitude' => 'double',
'longitude' => 'double',
'type' => 'integer',
'status' => 'integer',
];
public static $rules = [
'name' => 'required|string',
'hash_code' => 'required|string',
'text' => 'string',
'type' => 'integer',
'status' => 'integer',
];
}
Seeder
seederも作成しておく
php artisan make:seeder ScoutTestRecordsTableSeeder
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\ScoutTestRecord;
use Faker\Factory as Faker;
class ScoutTestRecordsTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
ScoutTestRecord::factory()->count(20)->create();
}
}
DatabaseSeederにSeederファイルを登録する。
こうすると php artisan migrate --seed
でseederが実行される。
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Database\Seeders\ScoutTestRecordsTableSeeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call([
ScoutTestRecordsTableSeeder::class,
]);
}
}
Factory
config/app.php factoryの設定をするため一部変更する
'fallback_locale' => 'en',
factoryを作成
php artisan make:factory ScoutTestRecordFactory --model=ScoutTestRecord
<?php
namespace Database\Factories;
use App\Models\ScoutTestRecord;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class ScoutTestRecordFactory extends Factory
{
protected $model = ScoutTestRecord::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => $this->faker->name,
'hash_code' => $this->faker->password(),
'text' => $this->faker->realText($maxNbChars = 50, $indexSize = 2),
'latitude' => $this->faker->latitude(-90, 90),
'longitude' => $this->faker->longitude(0, 180),
'type' => $this->faker->randomNumber($nbDigits = 1),
'status' => $this->faker->randomNumber($nbDigits = 1),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'deleted_at' => null,
];
}
}
DBにデータを登録する
php artisan migrate --seed
Elasticsearchにデータを連携する
各モジュールの役割は下記の記事で解説してます。
Scoutの設定
まずはScoutをインストールする。
このあとインストールするエンジンがscoutの8.x系に依存しているため、バージョンを指定する。
バージョン指定のセマンティックバージョンの概念はこれがわかりやすかったです。
composer require laravel/scout:^8.0
設定ファイルをバージョン管理配下に配置する
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
連携したいデータソースのModelにモデルオブザーバを登録する
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable; // 追加
class ScoutTestRecord extends Model
{
use HasFactory;
use SoftDeletes;
use Searchable; // 追加
public $table = 'scout_test_records';
・
・
・
エンジンを設定
Elasticsearchのクライアントモジュールをインストール
composer require elasticsearch/elasticsearch
エンジンは先人達がけっこういっぱい用意してくれてるけど、今回はtamayoさんのやつをチョイス。
composer require tamayo/laravel-scout-elastic
エンジンの処理の挙動はカスタマイズしたい場合があるので、app配下にエンジンを継承したファイルを用意する。
mkdir app/Scout/
touch app/Scout/ElasticsearchEngine.php
ElasticsearchEngine.php。
若干挙動がおかしいところがあったので継承して修正。
<?php
namespace App\Scout;
use Laravel\Scout\Builder;
use Elasticsearch\Client;
use Tamayo\LaravelScoutElastic\Engines\ElasticsearchEngine as ScoutElasticsearchEngine;
class ElasticsearchEngine extends ScoutElasticsearchEngine
{
protected $elastic;
protected $index;
private $query;
public function __construct(Client $elastic, $index)
{
$this->elastic = $elastic;
$this->index = $index;
}
protected function performSearch(Builder $builder, array $options = [])
{
$params = [
'index' => $builder->model->searchableAs(),
'type' => get_class($builder->model),
'body' => [
'query' => [
'bool' => [
'must' => [['query_string' => ['query' => "*{$builder->query}*"]]]
]
]
]
];
if ($sort = $this->sort($builder)) {
$params['body']['sort'] = $sort;
}
if (isset($options['from'])) {
$params['body']['from'] = $options['from'];
}
if (isset($options['size'])) {
$params['body']['size'] = $options['size'];
}
if (isset($options['z']) && count($options['numericFilters'])) {
$params['body']['query']['bool']['must'] = array_merge(
$params['body']['query']['bool']['must'],
$options['numericFilters']
);
}
if ($builder->callback) {
return call_user_func(
$builder->callback,
$this->elastic,
$builder->query,
$params
);
}
return $this->elastic->search($params);
}
/**
* Perform the given search on the engine.
* @inheritDoc
* @see ScoutElasticsearchEngine::paginate
* @param Builder $builder
* @param int $perPage (limit)
* @param int $page (offset)
* @return mixed
*/
public function paginate(Builder $builder, $perPage, $page)
{
$result = $this->performSearch($builder, [
'numericFilters' => $this->filters($builder),
'from' => (($page * $perPage) - $perPage),
'size' => $perPage,
]);
$result['nbPages'] = $result['hits']['total']['value'] / $perPage;
return $result;
}
/**
* Map the given results to instances of the given model.
*
* @inheritDoc
* @see ScoutElasticsearchEngine::map
* @param \Laravel\Scout\Builder $builder
* @param mixed $results
* @param \Illuminate\Database\Eloquent\Model $model
* @return Collection
*/
public function map(Builder $builder, $results, $model)
{
if ($results['hits']['total']['value'] === 0) {
return $model->newCollection();
}
$keys = collect($results['hits']['hits'])->pluck('_id')->values()->all();
$modelIdPositions = array_flip($keys);
return $model->getScoutModelsByIds(
$builder,
$keys
)->filter(function ($model) use ($keys) {
return in_array($model->getScoutKey(), $keys);
})->sortBy(function ($model) use ($modelIdPositions) {
return $modelIdPositions[$model->getScoutKey()];
})->values();
}
/**
* Get the total count from a raw result returned by the engine.
*
* @inheritDoc
* @see ScoutElasticsearchEngine::getTotalCount
* @param mixed $results
* @return int
*/
public function getTotalCount($results)
{
return $results['hits']['total']['value'];
}
}
Providerを読み込む
configを設定。
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
・
・
・
// Elasticsearch
Tamayo\LaravelScoutElastic\LaravelScoutElasticProvider::class, // ←これを追加
],
軽く動作確認
ここまで書いたらphpのコンテナにログインしてScoutが提供しているデータの取り込みスクリプトを実行する。
php artisan scout:import "App\Models\ScoutTestRecord"
Imported [App\Models\ScoutTestRecord] models up to ID: 20
All [App\Models\ScoutTestRecord] records have been imported.
// こうなれば成功
次はElasticsearchに検索リクエストを投げてみる。
コンテナ間の通信はdocker-composeのおかげでservice名で識別できる。
curl -X GET es:9200/scout_test_records/_search
{"took":9,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":20,"relation":"eq"},"max_score":1.0,"hits":[{"_index":"scout_test_records","_type":"App\\Models\\ScoutTestRecord","_id":"1","_score":1.0,"_source":{"id":1,"name":"山田 涼平","has...
// 結果が返却されればOK!
まとめ
MySQL8.0とかPHP8を使ったせいで実プロジェクトで完コピして作業するには難しいかも。
でもおおまかな流れは全て一緒で、変わるとしたらcomposerを使って依存ライブラリを導入した時のバージョンが変わるくらいかなー。
あと使ったライブラリがあきらか個人名みたいな名前だけどMITライセンス、みたいな感じ、
これってどれくらい信用度あるんだろ。
フレームワーク本体とかよりは信頼度低い気がする。
そのまま使ったら普通にエラーになったし。
クエリの使い方とかちょいちょい上げていきます。
記載漏れとかあったら是非ご連絡ください!