17
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactivesearch v3でいい感じの検索SPAを30分ぐらいで作る

Posted at

この記事はElastic Stack (Elasticsearch)その2 Advent Calendar 2019の11日目の記事です。

記事の内容を始める前に

去年のAdvent Calendar記事でReactivesearchの使い方を紹介したのですが、2019/8/28にReactivesearch 3.0.0がリリースされ、前回の記事の内容そのままでは動作しなくなってしまいました。

では、新バージョンを無視してそのまま古いバージョンを使い続けられるのかというと、Reactivesearch 2.x系は現在のメジャーバージョンであるElasticsearch 7.x系に対応していないため、真面目にアップグレードしている人たちやこれからElasticsearchを使う人であればReactivesearch 3.x系をオススメしたい状況です。

そこで、去年のAdvent Calendar記事をReactivesearch 3.xに対応する形で書き換えてみました。コード以外の内容は同じなので、既に読んだことがある方はスルーしちゃっても良いと思います。
まだ前回の記事を読んだことがない方や、これから実際にReactivesearch 3.x系を試してみようという方は是非こちらの記事を読んでいただけますと幸いです。

tl;dr

こんな感じのSPAを30分ぐらいで作ります。1

はじめに

Elasticsearchを使った検索WEBアプリを作りたいんだよねぇ」
『はぁ』
「データ入れたらすぐに高速な検索できちゃうんでしょ?」
『いや、すぐにという訳には...』
「あとさ、ユーザ体験を良くするためにSPAでお願いしたいんだよねぇ」
『(また突拍子もないことを言い始めたぞ...)』
「というわけで検討よろしく!検索対象にしたいデータはCSVで送っとくから」
『とりあえず検討してみます😭』

こんな時、皆さんならどうしますか?
ガッツリ画面を作って検索クエリも自前でガリガリ書いて頑張りますか?

それも一つの方法ではありますが、PoCや小さい案件であれば、もっと楽に早く実現できる方法があればそっちを使いたい感じです。
そして、この場合はそれがあります。

React.jsVue.jsのUIコンポーネントとして使えるReactivesearchです。
こいつは幾つかの設定を記載するだけで Elasticsearchに対する検索クエリの組み立てや発行も自動でやってくれる 優れものです。Elasticsearchにデータが投入済みであれば、慣れると数分で簡単な検索SPAを実装できます。

今回はSteamのゲーム検索UIを作るチュートリアルを通して、Reactivesearchの使い方を紹介してみます。

完成済みのコードはこちらのリポジトリから確認できます。

Reactivesearchとは

reactivesearch.png

ReactivesearchはAppbase.ioが提供するReact.js/Vue.jsで使える検索UIコンポーネントです。
名前がReact.js専用っぽい響きですが、Vue.jsでも使えます

Reactivesearchの特徴はなんと言ってもElasticsearch専用に作り込まれていることです。
Elasticsearchに投入されているデータに対して、 検索クエリの組み立てや発行なども代わりにやってくれます
つまり、これを使えばElasticsearch初学者が陥りがちな 「Elasticsearchの検索クエリを覚えるのが大変」「検索クエリをJSONで書くのがつらい」…という問題を回避 できます。

ちなみに、設定として最低限必要なのは以下の3項目ぐらいなものです。

  • Elasticsearchのホスト
  • 検索対象のインデックス名
  • 検索対象のフィールド名

たったこれだけの項目を設定するだけで面倒なことを考えずに検索SPAが作れちゃいます。掛け値なしに最高です。

今回使う検索用データを投入する(10分)

では、ここからチュートリアルを進めていきます。
まずはデータセットを用意して、Elasticsearchに投入します。

1. 今回使うデータセットをダウンロードする

この中のgames-features.csvが今回使用するデータセットです。
別に怪しいデータではなくて、どのように取得されたデータであるかは以下のリポジトリで確認可能です。
何なら自分で取得し直してみても良いと思います。

GitHub - CraigKelly/steam-data: A simple data project for Steam data

2. ElasticsearchとKibanaを起動する

ElasticsearchとKibanaのインストール、起動の手順に関しては他の記事に譲ります。

ただし、今回ローカルで起動する場合はCORSに引っかからないようelasticsearch.ymlに次の設定を追加してから起動してください。

http.cors.enabled: true
http.cors.allow-credentials: true
http.cors.allow-origin: "*"
http.cors.allow-headers: X-Requested-With, X-Auth-Token, Content-Type, Content-Length, Authorization, Access-Control-Allow-Headers, Accept

3. Data Visualizer経由でCSVをimportする

Kibana 6.5からData Visualizer経由で100MBまでのCSVであればElasticsearchにデータをインポートできるようになりました。
中々便利なので、今回はこれでインポートしてみます。6.4以前を使っている場合はLogstashなど他のツールでCSVをインポートしてください。

メニューのMachine Learning -> Data Visualizerをクリックします。

data-visualizer-for-file-input.png

画面中央にgames-features.csvをドラッグアンドドロップします。

data-visualizer-for-file-analysis.png

すると、データを解析して、CSVであることを解釈し、それぞれのカラムについてのデータ傾向を表示してくれます。
インポート目的ではなくてデータの傾向を把握したい時なんかにも便利です。

軽く眺めて満足したら、左下の"Import"をクリックします。
インポート設定画面に飛んだら、今回はingest pipelineを実行したい部分があるので、"Advanced"をクリックします。

data-visualizer-for-file-ingest-pipeline.png

実は、今回のデータセットでは一部ゲームデータが重複しています。
したがって、ゲームのIDであるResponseIDというフィールドをドキュメントidに設定するingest pipelineを実行することで、重複をなくします。

画面右側のingest pipelineに以下のコードを入力します。

{
  "processors" : [
    {
      "set" : {
        "field" : "_id",
        "value" : "{{ResponseID}}"
      }
    }
  ]
}

その後、"Import"をクリックします。

data-visualizer-for-file-result.png

無事インポートされました。
そのままDiscoverなどでも閲覧できます。

kibana-discover.png

これでデータ側は準備完了です。

React.jsアプリの雛形を作って起動する(5分)

次に、React.jsアプリを作る前準備を実行します。
ほぼreact-create-appの実行とreactivesearchをインストールする時間です。

前提条件

以下のツールがインストール済みの環境であること
※参考までに動作確認時のバージョンを記載してますが、違うバージョンでも普通に動くと思います。

  • Elasticsearch 7.4.0
  • Kibana 7.4.0
  • Node.js v12.11.1
  • npm 6.11.3

1. create-react-appしてReactアプリの雛形を生成する

実行が終わるまで気長に待ちます。

npx create-react-app steam-search

2. Reactivesearchをインストールする

こっちも依存関係が多くて時間がかかりますが、気長に待ちます。
なお、本記事執筆時点での@appbaseio/reactivesearch@2.14.1ではwsのバージョンが古いため、npm auditで警告が出ます。このバージョンをプロダクションで使うのは控えた方が良いかもしれません。

cd steam-search
npm install @appbaseio/reactivesearch
  1. Reactアプリを起動する
npm start
  1. ブラウザで http://localhost:3000 にアクセスしてReactアプリの初期画面が表示されることを確認する
react.png

これでアプリ側も準備完了です。
以後、React.jsアプリは起動したまま、修正を加えていきます。

Reactivesearchでシンプルな検索GUIを作ってみる(5分)

ではまず、ゲームのタイトルを全文検索して、結果にゲームタイトルを表示する簡易的な検索UIを作ってみます。

1. 以下のコードをsrc/App.jsにコピペする

元々のsrc/App.jsのコードは全部削除して上書き保存します。

import React, {Component} from 'react';
import {DataSearch, ReactiveBase, ReactiveList, ResultList, SelectedFilters} from '@appbaseio/reactivesearch';
import './App.css';

const { ResultListWrapper } = ReactiveList;

class App extends Component {
    render() {
        return (
            <div className="main-container">
                <ReactiveBase
                    app="steam-search"
                    url="http://localhost:9200"
                    credentials="elastic:changeme"
                >
                    <DataSearch
                        componentId="title"
                        dataField={["ResponseName"]}
                        queryFormat="and"
                    />
                    <SelectedFilters/>
                    <ReactiveList
                        componentId="resultLists"
                        dataField="ResponseName"
                        size={10}
                        pagination={true}
                        react={{
                            "and": ["title"]
                        }}
                    >
                        {({data}) => (
                            <ResultListWrapper>
                                {
                                    data.map(item => (
                                        <ResultList key={item._id}>
                                            <ResultList.Content>
                                                <ResultList.Title
                                                    dangerouslySetInnerHTML={{
                                                        __html: item.ResponseName
                                                    }}
                                                />
                                            </ResultList.Content>
                                        </ResultList>
                                    ))
                                }
                            </ResultListWrapper>
                        )}
                    </ReactiveList>
                </ReactiveBase>
            </div>
        );
    }
}

export default App;

すると、画面上 (http://localhost:3000) の表示は以下のように変化します。

Screenshot_2018-12-15 React App(4).png

もし画面を開いた瞬間に結果が"No Results found."と表示される場合は以下の可能性が考えられます。環境を見直してください。

  • Elasticsearchが起動していない
  • Elasticsearchに今回のデータセットが"steam-search"というインデックス名で投入できていない
  • CORS回避の設定をElasticsearchに設定せず起動してしまった

2.挙動の確認

"Search"部分に文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされます。

Screenshot_2018-12-15 React App(3).png

また、"Search"部分で文字を入力してEnterキーを押下するか、オートコンプリートのサジェストを選択すると、該当の文字列が全文検索の条件になり、検索結果が変更されます。

Screenshot_2018-12-15 React App(2).png

画面上部の"title: fez"と表示されている検索条件や"Clear All"と表示されているバッジをクリックすると、その条件を削除できます。

また、スペース区切りで文字を入力すると、AND条件で検索されます。

Screenshot_2018-12-15 React App(6).png

これで見た目はさておき、以下を実現できました。

  • ゲームタイトルに対する全文検索
    • 文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされる
    • スペース区切りで入力するとAND条件で検索される
  • 設定されている検索条件の表示
  • 検索結果としてゲームタイトルを表示する

2. コードの解説

ReactivesearchはReactiveBaseというコンポーネントを最上位コンポーネントとして持ち、その中に検索条件入力用のコンポーネントや結果表示用のコンポーネントを配置していきます。イメージとしては以下の形に必ずなります。

{/* 必ず最上位コンポーネントとしてReactiveBaseを配置する */}
<ReactiveBase>

  {/* 検索条件入力用のコンポーネント */}
  <HogeSearch />
  
  {/* 検索結果表示用のコンポーネントはReactiveListの下に配置する */}
  <ReactiveList>
    <ResultHoge/>
  </ReactiveList>
  
</ReactiveBase>

イメージコードでは適当なコンポーネント名にしていますが、それぞれ使用できるコンポーネントは様々な種類が用意されています。詳しくはドキュメントを参照してください。

今回のコードではReactiveBaseの内部で3種類のコンポーネントを使用しています。それぞれの役割を以下に示します。

  • DataSearch
    • オートコンプリート付きの全文検索フォームの提供
  • SelectedFilters
    • 設定されている検索条件の表示と解除
  • ResultList
    • 検索結果の表示

画面上で言うと以下のイメージです。

Screenshot_2018-12-15 React App_mod.png

この3種類のコンポーネントに幾つかのプロパティ(Props)を設定するだけで、良い感じに検索できるようになります。
それぞれのコンポーネントで設定しているPropsについて説明します。

ReactiveBase

Props 今回の値 説明 備考
url "http://localhost:9200" ElasticsearchのHTTPホスト。 -
app "steam-search" 検索対象のインデックス名。 -

DataSearch

Props 今回の値 説明 備考
componentId "title" コンポーネントに付与する一意なID。 -
dataField {["ResponseName"]} 検索対象のフィールド名。 複数指定可。
queryFormat "and" スペース区切りで入力された時にOR条件にするかAND条件にするか。 設定しない場合の初期値は"or"。

ResultList(ReactiveList)

Props 今回の値 説明 備考
componentId "resultLists" コンポーネントに付与する一意なID。 -
pagination {true} 結果をページネーションで表示するかどうか。 設定しない場合の初期値は{false}で、画面最下部までスクロールすると随時下に結果が追加される表示になる。
size {25} ページネーションで1ページに表示する件数。 -
react {{ "and": ["title"] }} どの検索条件に反応して結果を表示するか。 該当の検索条件入力用コンポーネントで設定した"componentId"を指定する。

なんとなく、Reactivesearchで最低限の実装を実現する方法が理解できたでしょうか?

機能を追加していく(5分)

次に、基本的な機能に加えて以下を追加していきます。

  1. 結果表示にゲーム画像と発売時期、価格を表示し、クリックしたらゲームストアへリンクするようにする
  2. 発売価格によるソート機能

1. 結果表示にゲーム画像と発売時期、価格を表示し、クリックしたらゲームストアへリンクするようにする

今回のデータセットにはゲームに関連した以下の情報が含まれています。

フィールド名 説明
HeaderImage ヘッダー画像のURL。 http://cdn.akamai.steamstatic.com/steam/apps/224760/header.jpg?t=1472521163
ReleaseDate 発売日。 May 1 2013
PriceInitial 発売価格。単位はUSドル。 9.99
ResponseID Steamにおける該当ゲームのID。ストアでのゲームのURLに含まれる。 224760

これらのフィールドを使って、検索結果の表示を少しだけリッチにしてみます。

ResultListを以下のように変更します。

      <ResultList
          key={item._id}
+         href={`https://store.steampowered.com/app/${item.ResponseID}`}
      >
+         <ResultList.Image src={item.HeaderImage}/>
          <ResultList.Content>
              <ResultList.Title
                  dangerouslySetInnerHTML={{
                      __html: item.ResponseName
                  }}
              />
+             <ResultList.Description>
+                 <p className="releaseDate">${item.ReleaseDate}</p>
+                 <p className="price">$${item.PriceInitial}</p>
+             </ResultList.Description>
          </ResultList.Content>
      </ResultList>

画面を確認してみます。

Screenshot_2018-12-15 React App(8).png

ゲームタイトルに加えて、先ほどの情報を追加表示できるようになりました。
また、結果をクリックすることで、Steamの該当ゲームのストアページへ飛ぶことが可能になりました。

2. 発売価格によるソート機能

ReactiveListのPropsとしてsortOptionsを追加します。

     <ReactiveList
         componentId="resultLists"
         dataField="ResponseName"
         size={10}
         pagination={true}
         react={{
             "and": ["title"]
         }}
+        sortOptions={[
+            {label: "Best Match", dataField: "_score", sortBy: "desc"},
+            {label: "Lowest Price", dataField: "PriceInitial", sortBy: "asc"},
+            {label: "Highest Price", dataField: "PriceInitial", sortBy: "desc"},
+        ]}
     >

画面を確認します。

Screenshot_2018-12-15 React App(9).png

検索ワード入力欄の右下にソート用のセレクトボックスが追加されました。
Best Matchはクエリ発行結果の_scoreの降順に並べる設定です。

Screenshot_2018-12-15 React App(10).png

Lowest Priceは発売価格の昇順なので、検索結果の序盤には無料ゲームが並んでいます。

Screenshot_2018-12-15 React App(11).png

Highest Priceは発売価格の降順なので、検索結果の序盤には高価な制作者向けツールやバンドルなどが並んでいます。

CSSで画面を調整する(5分)

ここまできたら後はCSSで見た目を調整してフィニッシュです。

1. App.jsの修正

まずApp.jsを以下のように変更します。

  import React, {Component} from 'react';
  import {DataSearch, ReactiveBase, ReactiveList, ResultList, SelectedFilters} from '@appbaseio/reactivesearch';
  import './App.css';
+ import './SteamSearch.css'

  const {ResultListWrapper} = ReactiveList;

  class App extends Component {
      render() {
          return (
              <div className="main-container">
                  <ReactiveBase
                      app="steam-search"
                      url="http://localhost:9200"
                      credentials="elastic:changeme"
+                     theme={
+                         {
+                             typography: {
+                                 fontFamily: 'Arial, Helvetica, sans-serif',
+                                 fontSize: '16px',
+                             },
+                             colors: {
+                                 titleColor: '#c7d5e0',
+                                 textColor: '#c7d5e0',
+                                 backgroundColor: '#212121',
+                                 primaryColor: '#2B475E',
+                             }
+                         }
+                     }
                  >
                      <DataSearch
                          componentId="title"
                          dataField={["ResponseName"]}
                          queryFormat="and"
+                         placeholder="enter search term"
+                         showIcon={false}
+                         title="Steam Search"
+                         className="data-search"
+                         innerClass={{
+                             input: 'input',
+                             list: 'list',
+                         }}
                      />
                      <SelectedFilters/>
                      <ReactiveList
                          componentId="resultLists"
                          dataField="ResponseName"
-                         size={10}
+                         size={25}
                          pagination={true}
                          react={{
                              "and": ["title"]
                          }}
                          sortOptions={[
                              {label: "Best Match", dataField: "_score", sortBy: "desc"},
                              {label: "Lowest Price", dataField: "PriceInitial", sortBy: "asc"},
                              {label: "Highest Price", dataField: "PriceInitial", sortBy: "desc"},
                          ]}
+                         className="result-list"
+                         innerClass={{
+                             resultsInfo: "resultsInfo",
+                             resultStats: "resultStats",
+                         }}
                      >
                          {({data}) => (
                              <ResultListWrapper>
                                  {
                                      data.map(item => (
                                          <ResultList
                                              key={item._id}
                                              href={`https://store.steampowered.com/app/${item.ResponseID}`}
+                                             className="listItem"
                                          >
-                                             <ResultList.Image src={item.HeaderImage}/>
+                                             <ResultList.Image className="image" src={item.HeaderImage}/>
                      (以下省略)

まず、ReactiveBasethemeを設定することで、配下のコンポーネントの共通設定を変更します

次に、ページネーションの表示数や検索フォームのプレースホルダをSteamの検索ページに合わせたりします。

あとは、各コンポーネントにinnerClassを渡すことで、各コンポーネント内部のパーツにclass名を付けます。これで、CSSセレクタで指定しやすくなります。どのパーツにclass名をつけることができるかは各コンポーネントのドキュメントのStylesに記載されています。

2. CSSをあてる

次にsrc/SteamSearch.cssを新たに作成します。

@font-face {
    font-family: "IonIcons";
    src: url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.eot?v=2.0.1");
    src: url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.eot?v=2.0.1#iefix") format("embedded-opentype"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.ttf?v=2.0.1") format("truetype"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.woff?v=2.0.1") format("woff"), url("//code.ionicframework.com/ionicons/2.0.1/fonts/ionicons.svg?v=2.0.1#Ionicons") format("svg");
    font-weight: normal;
    font-style: normal
}

html {
    min-height: 100%;
}

body {
    background-color: #1B3C53;
    background-image: linear-gradient(315deg, #000000 0%, #1B3C53 74%);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

.main-container {
    width: 616px;
    margin-top: 20px;
}

.data-search .input {
    color: white;
}

.data-search .list li {
    background-color: #aaa !important;
    color: #000;
    overflow: hidden;
}

.data-search .list li:target {
    background-color: #666 !important;
}

.resultsInfo {
    position: relative;
}

.resultsInfo:before {
    z-index: 1;
    position: absolute;
    right: 15px;
    top: 0;
    content: "\f123";
    font-family: "IonIcons";
    line-height: 43px;
    color: #7F878C;
    pointer-events: none;
}

.resultsInfo select {
    background-color: #18394D;
    color: #67c1f5;
    outline: 1px solid #000;
    background: transparent;
    background-image: none;
    padding-right: 50px;
}

.resultsInfo option {
    background-color: #417A9B;
    color: #fff;
}

.result-list .resultStats {
    color: #3b6e8c;
}

.result-list .listItem {
    background-color: #16202D;
    padding: 0;
    margin: 2px 0;
    border: 0;
    background: rgba(0, 0, 0, 0.4);
}

.result-list .listItem:hover {
    background: rgba(0, 0, 0, 0.8);
}

.result-list .image {
    width: 120px;
    height: 45px;
}

.result-list article, .result-list article div {
    display: flex;
    align-items: center;
}

.result-list article h2 {
    padding: 0;
}

.releaseDate {
    color: #4c6c8c;
    width: 150px;
}

.price {
    width: 50px;
}

input {
    background-color: #1C3345 !important;
    border: 1px solid #000 !important;
}

bodyinputに直で何かを当てたり!importantをバリバリ使っていたり、かなりアレな感じですが見なかったことにして…ここまでの手順を経ると画面は以下のようになっているはずです。

Screenshot_2018-12-15 React App(12).png

実際に動かしてみるとこんな感じです。

ezgif-1-1a81359e991b.gif

中々それらしい検索画面になったのではないでしょうか。

おわりに

Reactivesearch、いかがだったでしょうか。
個人的にはかなり気に入りました。Elasticsearchで検索UIを作るときのBootstrap的存在として流行ってもおかしくないと思います。
まだまだ使えていない機能がたくさんあるので、ちょこちょこ使っていこうかと思います。

最後に読まれた方が気になりそうな仮想質問にカジュアルに答えるコーナーを設けて終わろうと思います。

仮想質問に対するQ&A

  • Q. ライセンスを教えろください
  • Q. エンドユーザのブラウザから直接Elasticsearchに接続できる必要があったりします?その場合、プロダクションで使うにはセキュリティ的にどうかと思うんですが。
    • A. プロキシサーバーを間に挟むことで直接接続を回避できます。公式の実装例もあるので、その辺りを参考にすればよいと思います。また、ElasticsearchのSecurity機能で、最小権限のユーザーを作成し、使用することを推奨します。
  • Q. CSS当てないとまともに使えないんですか?
    • A. 機能的には問題なく動きます。見た目の好みの問題です。Themes機能を使いこなせれば、開発中は耐えうる見た目にできるかもしれません。
  • Q. クエリを自動で発行してくれるみたいだけど、自分でカスタマイズはできないんですか?
  • Q. もっと他の実装、デザイン例はないんですか?
    • A. 公式でデモサイトが幾つかあるので、それを覗いたりすると良いと思います。リポジトリでコードも公開されているので参考になります。GitXploreとかすごい。

本当のおわりに

以上です。

明日の枠はまだ空いてるみたいです。小ネタでも何でも共有してみてはいかがでしょうか!ではでは〜。

  1. ただし、検索用データセットのDLと、この記事の解説を読む時間は除きます...ずるい?

17
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?