技術は後からついてくる。

就活開始の半年前にエンジニアに目覚めた人

HTTPメソッド(CRUD)まとめ

曖昧だったHTTPメソッド(CRUD)についてまとめた。

HTTPメソッドの種類は8つ

HTTPメソッドは、クライアントが行いたい処理をサーバに伝えるという役割があるが メソッドはたったの8つだけ。しかもメインで使うのは5つか6つ!

メソッド 意味
GET リソースの取得
POST  子リソースの作成、リソースへのデータ追加、その他処理
PUT リソースの更新、リソースの作成
DELETE リソースの削除
HEAD リソースのヘッダ (メタデータの取得)
OPTIONS リソースがサポートしているメソッドの取得
TRACE プロキシ動作の確認
CONNECT プロキシ動作のトンネル接続への変更

CRUD

CRUDはCreate, Read, Update, Deleteのこと。 HTTPメソッドのうちGET、POST、PUT、DELETEは「CRUD」を満たす

CURD名 意味 メソッド
Create 作成 POST/PUT
Read 読み込み GET
Update 更新 PUT
Delete 削除 DELETE

というわけで、CRUDを満たす4つのメソッドについてまとめてみたよー

1. GET -> リソースの取得

指定したURIの情報を取得するメソッド。 利用頻度はおそらく最も高く、Webページ、画像、映像、フィードの取得などがこれに当たる。

GET /list HTTP/1.1
Host: example.jp
HTTP/1.1 200 OK
Content-Type: application/json
[
  {"uri": "http://example.jp/list/item1"}
]

このとき、リクエストはhttp://example.jp/list に対するGETだと言える。 リクエストに対して、サーバは指定されたURIに対応するデータをレスポンスとして返す。

2. POST -> リソースの作成、追加

GETに次いで利用頻度が高い! 3つの役割があるよー

( i ) 子リソースの作成

POSTの代表的な機能で、あるリソースに対する子リソースを作成することができる。 ブログ記事の投稿などで使われる。

POST /list HTTP/1.1
Content-Type: text/plain; charset=utf-8

Qiitaでの初投稿!
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Location: http://example.jp/list/item5

Qiitaでの初投稿!

このリクエストは http://example.jp/list に対して、新しい子リソースを作るようにPOSTで指示している。 レスポンスでは[201 Created]というステータスコードが返ってきており、新しいリソースが生成されたことが分かる。

( ii ) リソースへのデータの追加

子リソース作成ほどメインで使わないが、既存リソースへのデータの追加もできる。 GETしたリソースに対して...

POST /log HTTP/1.1
Host: example.jp

2017-02-24T10:13:00Z, GET /log, 200
HTTP/1.1 200 OK

このときレスポンスは[200 OK]が返ってくる。 これはリクエストが新規リソースの作成ではなく、データの追加を意味しているため。

( iii ) ほかのメソッドでは対応できない処理

POSTの3つ目の機能は、ほかのメソッドでは対応できない処理の実行。 例として、検索結果を表示するURIが挙げられる。

http://example.jp/search?q={検索キーワード}

通常はこのURIをGETできるが、検索キーワードがとてもながーい場合、URIにキーワードを入れてGETする方式は使えない。これはURIを実装する上で、2000文字の制限があるため。 こういうときにPOSTを使う。

POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded

q=very+long+keyword+foo+bar+...+....

GETがキーワードをURIに含めるのに対して、POSTはリクエストボディに入れることができる。そのため、どんなにながーいキーワードにも対応できる。素晴らしい。

3. PUT -> リソースの更新、作成

2つの機能を持っているよ

( i ) リソースの更新

リソースを更新する前にまずGETするよー

GET /list/item5 HTTP/1.1
Host: example.jp
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Qiita初投稿!

PUTを使って「Qiita初投稿!」から「実は2回目!」に更新していくと...

PUT /list/item5 HTTP/1.1
Host: example.jp

実は2回目!
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

実は2回目!

これで更新完了!

( ii ) リソースの作成

POSTでもできるリソースの作成がPUTでもできる。 例えば http://example.jp/newitem がまだ存在しないとする。 このとき、

PUT /newitem HTTP/1.1
Host: example.jp
Content-Type: text/plain; charset=utf-8

新しいリソース/newitemの内容
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8

新しいリソース/newitemの内容

このとき、PUTは存在しないURIへのリクエストとなるため、サーバは新しくリソースを作成すると解釈する。 そして、リクエストが成功すると[201 Created]を返す。 /newitemが存在していた場合は、ただの更新処理になる。

4. DELETE -> リソースの削除

DELETEは言葉のまんま、リソースを削除するメソッド

DELETE /list/item2 HTTP/1.1
Host: example.jp
HTTP/1.1 200 OK

一般的に、DELETEのレスポンスはボディを持たない。そのためレスポンスのステータスコードにはボディがないという意味で、[204 No Content]が使われる場合もある。

Laravel(5.5) + Scout + Elasticsearchで全文検索 (導入編)

業務で使うことになりそうなので、チャチャっと触ってみました。
こちら様を思いっきり参考にさせていただきました、ありがとうございます!

public-constructor.com

Laravelと必要なライブラリを準備

ターミナルでComposerのcreate-projectコマンドを実行
ここでは仮に物件探しのサイトを作るということで
プロジェクト名をfind_houseとする。

// ver5.5を指定  
$ composer create-project --prefer-dist laravel/laravel [project_name] "5.5.*"

// 動作確認など
$ php artisan serve
$ cd find_house

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

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

Scoutの設定ファイル生成

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

config/scout.php に以下を追加

return [

  // 中略

  /*
  |--------------------------------------------------------------------------
  | Elasticsearch Configuration
  |--------------------------------------------------------------------------
  */

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

プロバイダー作成

php artisan make:provider ElasticsearchServiceProvider

app/Providers/ElasticsearchServiceProvider.php

<?php

namespace App\Providers;

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


class ElasticsearchServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {
        resolve(EngineManager::class)->extend('elasticsearch', function ($app) {
            return new ElasticsearchEngine(
                config('scout.elasticsearch.index'),
                ClientBuilder::create()
                    ->setHosts(config('scout.elasticsearch.hosts'))
                    ->build()
            );
        });
    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

プロバイダー登録

config/app.php

'providers' => [

    // 中略

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

カスタムエンジンを作成

performSearch()でElasticsearchで実行するクエリを設定する。
ここではaddressに対してbool queryを発行する。

app/Scout/ElasticSearchEngine.php

<?php

namespace App\Scout;

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

class ElasticsearchEngine extends Engine
{

    /**
     * @var string
     */
    protected $index;

    /**
     * @var Elastic
     */
    protected $elastic;

    /**
     * ElasticsearchEngine constructor.
     *
     * @param string $index
     * @param \Elasticsearch\Client $elastic
     */
    public function __construct($index, Elastic $elastic)
    {
        $this->index = $index;
        $this->elastic = $elastic;
    }


    /**
     * Update the given model in the index.
     *
     * @param  \Illuminate\Database\Eloquent\Collection $models
     * @return void
     */
    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);
    }

    /**
     * Remove the given model from the index.
     *
     * @param  \Illuminate\Database\Eloquent\Collection $models
     * @return void
     */
    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);
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @return mixed
     */
    public function search(Builder $builder)
    {
        return $this->performSearch($builder, array_filter([
            'filters' => $this->filters($builder),
            'limit' => $builder->limit,
        ]));
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @param  int $perPage
     * @param  int $page
     * @return mixed
     */
    public function paginate(Builder $builder, $perPage, $page)
    {
        $result = $this->performSearch($builder, [
            'filters' => $this->filters($builder),
            'from' => (($page * $perPage) - $perPage),
            'limit' => $perPage,
        ]);

        $result['nbPages'] = $result['hits']['total'] / $perPage;

        return $result;
    }

    /**
     * Pluck and return the primary keys of the given results.
     *
     * @param  mixed $results
     * @return \Illuminate\Support\Collection
     */
    public function mapIds($results)
    {
        return collect($results['hits']['hits'])->pluck('_id')->values();
    }

    /**
     * Map the given results to instances of the given model.
     *
     * @param  \Laravel\Scout\Builder $builder
     * @param  mixed $results
     * @param  \Illuminate\Database\Eloquent\Model $model
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function map(Builder $builder, $results, $model)
    {
        if ($results['hits']['total'] === 0) {
            return collect();
        }

        $keys = collect($results['hits']['hits'])
            ->pluck('_id')->values()->all();

        $models = $model->whereIn(
            $model->getKeyName(), $keys
        )->get()->keyBy($model->getKeyName());

        return collect($results['hits']['hits'])->map(function ($hit) use ($model, $models) {
            return isset($models[$hit['_id']]) ? $models[$hit['_id']] : null;
        })->filter()->values();
    }

    /**
     * Get the total count from a raw result returned by the engine.
     *
     * @param  mixed $results
     * @return int
     */
    public function getTotalCount($results)
    {
        return $results['hits']['total'];
    }

    /**
     * @param \Laravel\Scout\Builder $builder
     * @param array $options
     * @return array|mixed
     */
    protected function performSearch(Builder $builder, $options = [])
    {
        $params = [
            'index' => $this->index,
            'type' => $builder->index ?: $builder->model->searchableAs(),
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => [
                            'term' => [
                                'address' => "{$builder->query}",
                            ]
                        ],
                    ],
                ],
            ]
        ];

        if ($sort = $this->sort($builder)) {
            $params['body']['sort'] = $sort;
        }

        if (isset($options['filters']) && count($options['filters'])) {
            $params['body']['query']['bool']['filter'] = $options['filters'];
        }

        if ($builder->callback) {
            return call_user_func(
                $builder->callback,
                $this->elastic,
                $builder->query,
                $params
            );
        }

        return $this->elastic->search($params);
    }

    public function filters(Builder $builder)
    {
        return collect($builder->wheres)->map(function ($value, $key) {
            return [
                'term' => [
                    $key => $value
                ]
            ];
        })->values()->all();
    }

    protected function sort(Builder $builder)
    {
        if (count($builder->orders) == 0) {
            return null;
        }

        return collect($builder->orders)->map(function ($order) {
            return [$order['column'] => $order['direction']];
        })->toArray();
    }
}

環境変数の設定

ルート配下の.envに以下を追加

.env

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=findhome
DB_USERNAME=root
DB_PASSWORD=
SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=http://localhost:9200

DB作成

.envではfindhomeというデータベースを指定したので作る。
今回はmysqlで作ります。

$ mysql -u root

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

migrationファイル作成

$ php artisan make:migration create_shops_table

migrate実行

$ php artisan migrate

モデル作成

$ php artisan make:model Models/Shop

作成したモデルに use Searchable を追加

namespace App\Models;

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

class Post extends Model
{
    use Searchable;
}

Elasticsearchを起動

Dockerを使います。

ルート配下に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 container ls

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": {} } }'

期待される結果

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

テスト用データの作成

ファクトリーを使ってElasticsearchに登録するテスト用のデータを生成する

$ php artisan make:factory ShopFactory

database/factories/ShopFactory.phpを追加する ここでは、fakerを使用してダミーデータを作成する(faker便利...)

<?php

use App\Models\Shop;
use Faker\Generator as Faker;

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

artisan tinkerを使ってデータを登録

$ php artisan tinker
Psy Shell v0.9.8 (PHP 7.1.22 — cli) by Justin Hileman
>>> factory(App\Models\Shop::class, 100)->create() // 100個のダミーデータを作成

Elasticsearchに登録されたデータを確認

curlを使って、config/scout.phpの'elasticsearch' => 'index'で指定された名称で
インデックスが作成されていることを確認する

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

health status index      uuid                   pri rep docs.count docs.deleted store.size pri.store.size
yellow open   scout      IpDiHQkoRlSUjNU_JSjPow   5   1        103            0    131.4kb        131.4kb

登録済みのデータを取得する

$ curl -X GET http://localhost:9200/scout/_search -d '{"query": { "match_all": {} } }'
{
  "took": 21,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 103,
    "max_score": 1.0,
    "hits": [{
      "_index": "scout",
      "_type": "shops",
      "_id": "14",
      "_score": 1.0,
      "_source": {
        "id": 14,
        "name": "中島町レジデンス",
        "phone_number": "090-4364-2947",
        "address": "1163282  静岡県廣川市東区石田町三宅8-7-8 ハイツ吉本102号",
        "latitude": 29.18,
        "longitude": 8.64,
        "nearest_station": "横浜",
        "open": "09:00:00",
        "close": "22:00:00",
        "wifi": "なし",
        "created_at": "2018-09-30 02:30:44",
        "updated_at": "2018-09-30 02:30:44"
      }
    }, {
      "_index": "scout",
      "_type": "shops",
      "_id": "19",
      "_score": 1.0,
      "_source": {
        "id": 19,
        "name": "青田町ハウス",
        "phone_number": "090-3740-0865",
        "address": "9533165  茨城県山口市東区小林町笹田8-1-10",
        "latitude": -63.52,
        "longitude": -147.08,
        "nearest_station": "横浜",
        "open": "11:00:00",
        "close": "21:00:00",
        "wifi": "なし",
        "created_at": "2018-09-30 02:30:44",
        "updated_at": "2018-09-30 02:30:44"
      }
    }, {
      "_index": "scout",
      "_type": "shops",
      "_id": "22",
      "_score": 1.0,
      "_source": {
        "id": 22,
        "name": "井高町レーベン",
        "phone_number": "01816-2-8868",
        "address": "3132555  高知県渚市東区青山町小泉7-7-1 ハイツ杉山102号",
        "latitude": -41.58,
        "longitude": -164.57,
        "nearest_station": "表参道",
        "open": "12:00:00",
        "close": "21:00:00",
        "wifi": "なし",
        "created_at": "2018-09-30 02:30:44",
        "updated_at": "2018-09-30 02:30:44"
      }
    }, {
      "_index": "scout",
      "_type": "shops",
      "_id": "24",
      "_score": 1.0,
      "_source": {
        "id": 24,
        "name": "中村町マンション",
        "phone_number": "09-6701-8562",
        "address": "1783857  神奈川県笹田市北区山田町中村8-5-2 コーポ松本104号",
        "latitude": 32.39,
        "longitude": -125.44,
        "nearest_station": "東京",
        "open": "09:00:00",
        "close": "20:00:00",
        "wifi": "なし",
        "created_at": "2018-09-30 02:30:44",
        "updated_at": "2018-09-30 02:30:44"
      }
      }]
   }
}

artisanでのデータ操作(今回はやる必要なし)

モデルデータの削除とインポートも簡単にできる。

// モデルデータの削除
$ php artisan scout:flush "App\Models\Shop"\n

// モデルデータのインポート
$ php artisan scout:import "App\Models\Shop"\n

リクエストパラメータを使って検索

ルーティング

routes/web.php

Route::get('/post/search', function () {
    return App\Models\Shop::search(\request('q'))->paginate();
});

ローカルサーバ起動

$ php artisan serve

検索結果を表示

http://localhost:8000/post/search?q=静岡

q= の後に検索したいクエリを書いてURLを叩く

実行結果

http://localhost:8000/post/search?q=静岡 だと静岡県の結果が出ない。。。

f:id:ricken0203:20181001133632p:plain

http://localhost:8000/post/search?q=静 で検索すると出る。

f:id:ricken0203:20181001133644p:plain

これはインデックスが静岡県を"静" "岡" "県"で区切っているためだと思う。
デフォルトはポンコツなのでチューニングが必要になってきますね。
チューニング編は次回にします!