この記事は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.jsやVue.jsのUIコンポーネントとして使えるReactivesearchです。
こいつは幾つかの設定を記載するだけで Elasticsearchに対する検索クエリの組み立てや発行も自動でやってくれる 優れものです。Elasticsearchにデータが投入済みであれば、慣れると数分で簡単な検索SPAを実装できます。
今回はSteamのゲーム検索UIを作るチュートリアルを通して、Reactivesearchの使い方を紹介してみます。
完成済みのコードはこちらのリポジトリから確認できます。
Reactivesearchとは
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をクリックします。
画面中央にgames-features.csv
をドラッグアンドドロップします。
すると、データを解析して、CSVであることを解釈し、それぞれのカラムについてのデータ傾向を表示してくれます。
インポート目的ではなくてデータの傾向を把握したい時なんかにも便利です。
軽く眺めて満足したら、左下の"Import"をクリックします。
インポート設定画面に飛んだら、今回はingest pipelineを実行したい部分があるので、"Advanced"をクリックします。
実は、今回のデータセットでは一部ゲームデータが重複しています。
したがって、ゲームのIDであるResponseID
というフィールドをドキュメントidに設定するingest pipelineを実行することで、重複をなくします。
画面右側のingest pipeline
に以下のコードを入力します。
{
"processors" : [
{
"set" : {
"field" : "_id",
"value" : "{{ResponseID}}"
}
}
]
}
その後、"Import"をクリックします。
無事インポートされました。
そのままDiscoverなどでも閲覧できます。
これでデータ側は準備完了です。
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
- Reactアプリを起動する
npm start
- ブラウザで http://localhost:3000 にアクセスしてReactアプリの初期画面が表示されることを確認する
これでアプリ側も準備完了です。
以後、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) の表示は以下のように変化します。
もし画面を開いた瞬間に結果が"No Results found."と表示される場合は以下の可能性が考えられます。環境を見直してください。
- Elasticsearchが起動していない
- Elasticsearchに今回のデータセットが"steam-search"というインデックス名で投入できていない
- CORS回避の設定をElasticsearchに設定せず起動してしまった
2.挙動の確認
"Search"部分に文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされます。
また、"Search"部分で文字を入力してEnterキーを押下するか、オートコンプリートのサジェストを選択すると、該当の文字列が全文検索の条件になり、検索結果が変更されます。
画面上部の"title: fez"と表示されている検索条件や"Clear All"と表示されているバッジをクリックすると、その条件を削除できます。
また、スペース区切りで文字を入力すると、AND条件で検索されます。
これで見た目はさておき、以下を実現できました。
- ゲームタイトルに対する全文検索
- 文字を入力し始めるとゲームタイトルのオートコンプリートがサジェストされる
- スペース区切りで入力するとAND条件で検索される
- 設定されている検索条件の表示
- 検索結果としてゲームタイトルを表示する
2. コードの解説
ReactivesearchはReactiveBaseというコンポーネントを最上位コンポーネントとして持ち、その中に検索条件入力用のコンポーネントや結果表示用のコンポーネントを配置していきます。イメージとしては以下の形に必ずなります。
{/* 必ず最上位コンポーネントとしてReactiveBaseを配置する */}
<ReactiveBase>
{/* 検索条件入力用のコンポーネント */}
<HogeSearch />
{/* 検索結果表示用のコンポーネントはReactiveListの下に配置する */}
<ReactiveList>
<ResultHoge/>
</ReactiveList>
</ReactiveBase>
イメージコードでは適当なコンポーネント名にしていますが、それぞれ使用できるコンポーネントは様々な種類が用意されています。詳しくはドキュメントを参照してください。
今回のコードではReactiveBaseの内部で3種類のコンポーネントを使用しています。それぞれの役割を以下に示します。
-
DataSearch
- オートコンプリート付きの全文検索フォームの提供
-
SelectedFilters
- 設定されている検索条件の表示と解除
-
ResultList
- 検索結果の表示
画面上で言うと以下のイメージです。
この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. 結果表示にゲーム画像と発売時期、価格を表示し、クリックしたらゲームストアへリンクするようにする
今回のデータセットにはゲームに関連した以下の情報が含まれています。
フィールド名 | 説明 | 例 |
---|---|---|
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>
画面を確認してみます。
ゲームタイトルに加えて、先ほどの情報を追加表示できるようになりました。
また、結果をクリックすることで、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"},
+ ]}
>
画面を確認します。
検索ワード入力欄の右下にソート用のセレクトボックスが追加されました。
Best Match
はクエリ発行結果の_score
の降順に並べる設定です。
Lowest Price
は発売価格の昇順なので、検索結果の序盤には無料ゲームが並んでいます。
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}/>
(以下省略)
まず、ReactiveBase
にtheme
を設定することで、配下のコンポーネントの共通設定を変更します。
次に、ページネーションの表示数や検索フォームのプレースホルダを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;
}
body
やinput
に直で何かを当てたり!important
をバリバリ使っていたり、かなりアレな感じですが見なかったことにして…ここまでの手順を経ると画面は以下のようになっているはずです。
実際に動かしてみるとこんな感じです。
中々それらしい検索画面になったのではないでしょうか。
おわりに
Reactivesearch、いかがだったでしょうか。
個人的にはかなり気に入りました。Elasticsearchで検索UIを作るときのBootstrap的存在として流行ってもおかしくないと思います。
まだまだ使えていない機能がたくさんあるので、ちょこちょこ使っていこうかと思います。
最後に読まれた方が気になりそうな仮想質問にカジュアルに答えるコーナーを設けて終わろうと思います。
仮想質問に対するQ&A
- Q. ライセンスを教えろください
- A. Apache License 2.0なので安心して使えまする
- Q. エンドユーザのブラウザから直接Elasticsearchに接続できる必要があったりします?その場合、プロダクションで使うにはセキュリティ的にどうかと思うんですが。
- A. プロキシサーバーを間に挟むことで直接接続を回避できます。公式の実装例もあるので、その辺りを参考にすればよいと思います。また、ElasticsearchのSecurity機能で、最小権限のユーザーを作成し、使用することを推奨します。
- Q. CSS当てないとまともに使えないんですか?
- A. 機能的には問題なく動きます。見た目の好みの問題です。Themes機能を使いこなせれば、開発中は耐えうる見た目にできるかもしれません。
- Q. クエリを自動で発行してくれるみたいだけど、自分でカスタマイズはできないんですか?
- A. カスタムクエリを設定する方法があるっぽいです。クエリを発行するコンポーネントのドキュメントにあるように、
defaultQuery
やcustomQuery
をPropsとして設定する模様。
- A. カスタムクエリを設定する方法があるっぽいです。クエリを発行するコンポーネントのドキュメントにあるように、
- Q. もっと他の実装、デザイン例はないんですか?
本当のおわりに
以上です。
明日の枠はまだ空いてるみたいです。小ネタでも何でも共有してみてはいかがでしょうか!ではでは〜。
-
ただし、検索用データセットのDLと、この記事の解説を読む時間は除きます...ずるい? ↩