2018年9月13日
React.jsのアプリにNeo4jの日付と空間のタイプを扱う
本記事は、原著者の許諾のもとに翻訳・掲載しております。
Neo4j、Mapbox、React、Nivo Chartを使ってダッシュボードのアプリを構築します。
このシンプルなダッシュボードReactアプリは、特定の場所を示す円内で特定の期間内にレビューがあったビジネスを探すことができます。 こちらでお試しください。
概要
Neo4j 3.4は新たに空間と時間の機能をサポートしました。位置と日付というタイプを持ち、クエリで使用してインデックスに使えるようになっています。空間と時間の機能を備えたシンプルなアプリを構築するのはきっと楽しいでしょう。
本記事では、React.jsによるダッシュボードタイプのアプリの構築について段階を追って記していきます。アプリでは、特定の期間内の位置情報を使ってレビュー情報を持つビジネスを検索したり、レビューの総括に基づいた幾つかのチャートを表示させたりします。これから、新しい空間と時間のタイプを用いた neo4j-import
の使い方、CypherクエリでNeo4jのドライバを用いた新しいタイプの使い方、さらに全てをReactのアプリケーションへ適合させる方法について見ていきます。
データ
今回の例では、空間と時間のコンポーネントが取り込まれたデータセットを必要とします。そこで、 Yelp Open Dataset を使うことにしました。Yelpがリリースしたこのデータセットには、オンラインのレビューサイトを使ったデータのサブセットと、欧米の11大都市エリアにおける17万4000のビジネスに対する500万のレビューがあります。データのダウンロードは こちら 。
グラフィック・データ・モデル
Yelp Open Dataset を使うグラフのデータモデル。
Neo4jでのYelp Open Datasetの使い方を教えてくれるサンプルはほんの少ししかありません。まずは 超高速のNeo4j-importツール を含めたデータセットの モデル化とインポートの方法 、推奨ビジネスを見つけるために Cypherを使ってデータセットをクエリ する方法、 グラフのアルゴリズムをデータセットに適用する 方法から始めましょう。
インポート
最初にYelpのデータセットをNeo4jへインポートする必要があります。Yelpのデータは一連のJSONフォーマット(1行ごとに1つのJSONのオブジェクト)で出来ています。これをNeo4jにインポートするには幾つかの方法があります。
apoc.load.json
を使ってJSONのファイルをインポートする。- JSONの各行を読み込み、Cypherステートメントへパラメータを渡す。
- CSVへ変換し、LOAD CSVを使う。
- CSVへ変換し、neo4j-importツールを使って高速で一括ローディングしてインポートする。
私が選んだのは最後のアプローチ方法( neo4j-importの利用 です。Yelpのデータセットは大きいのでLOAD CSVその他の方法よりも一括インポート機能を使うほうが速いからです。その上、仲間の Mark Needham が 既に私のやりたいことをほとんど作ってくれていました 。私は位置と日付のタイプをサポートするようにスクリプトを拡張するだけでした。
neo4j-importは、インポート用に属性とタイプを定義したヘッダファイルを利用しています。ですので、 Business
と Review
用のヘッダファイルをアップデートしなければなりません。最初は Business
です。
id:ID(Business),name,address,city,state,location:Point(WGS-84)
次に Review
です。
id:ID(Review),text,stars:int,date:Date
レビュー用にCSVファイルを変える必要はありません。というのも、Neo4jは元から使われている(YYYY-MM-DD)のフォーマットで日付の文字列を読み取れるからです。しかし、ビジネスのCSVにある位置のタイプから追加すべきものがあります。緯度と経度を持ったマップ(またはディクショナリ)としてCSVにデータを書き込みます。
"FYWN1wneV18bWNgQjJ2GNg","Dental by Design","4855 E Warner Rd, Ste B9","Ahwatukee","AZ","{latitude: 33.3306902, longitude: -111.9785992}"
次に neo4j-admin import
コマンド を使い、Neo4jへのデータインポート用にCSV内でデータを渡します。
覚えておくべき大切なことがあります。 neo4j-admin import
はインデックスを作りません。ですから、最初のデータ検索用に必要なインデックスを明瞭に作らなくてはなりません。今回は、 Business
ノードの location
属性と Review
ノードの date
属性にインデックスを作ります。
CREATE INDEX ON :Business(location);
CREATE INDEX ON :Review(date);
インポート用の全コードは こちら 。
Neo4jをクラウドでホストする
Webアプリを構築しようとしている以上、Neo4jデータベースをどこかの環境でホストする必要があります。また、きっと安全なHTTPS接続を利用してWebページを提供したくなるでしょうから、Webブラウザが受け入れるためのNeo4j用の信頼できる証明書を生成する必要があります。Neo4jはデフォルトで自己署名証明書を利用しますが、ほとんどの構成において自己署名証明書は十分安全とはいえません。
幸運なことに、 Let’s Encrypt の certbot
というツールを使うことで、認証局による署名済みのNeo4j用証明書を簡単に生成することができます。
# Use certbot to generate certificates
certbot certonly
# Copy certs to Neo4j directory
cp /path_to_certs/fullchain.pem /var/lib/neo4j/certificates/neo4j.cert
cp /path_to_certs/privkey.pem /var/lib/neo4j/certificates/neo4j.key
私はもともとVPSインスタンス上のNeo4jへの接続をセキュアにするために上記の方法を使っていましたが(無数にある Neo4jのデプロイ方法 はこちらを参照)、最終的には Neo4j Cloud を利用するようになりました。これを使えば、証明書の取得に関する労力を全て肩代わりしてくれます。Neo4j Cloudの早期アクセス版には こちら からサインアップできます。
クエリ
これでデータベースの作成が終わりましたから、Cypherクエリを書き、Neo4j上のデータを操作することができます。特定の地点から一定の距離内にある企業を検索し、一定期間内のレビューを取得することを目指しましょう。
空間クエリ
まずサポートしたい機能は、場所による企業の検索です。つまり、ユーザーが地図上のある地点を選択した時に、ユーザーが定義した距離(例えば1000メートル)の範囲内にある企業を探す機能が必要です。ここでは、先ほど作ったポイントタイプに設定した空間インデックスを活用してこれを実現する方法を示します。
MATCH (b:Business)
WHERE distance(
b.location,
point({latitude:33.329 , longitude:-111.978})
) < 1000
RETURN COUNT(b) AS num_businesses
-------------------------------------------------------
num_businesses
117
distance
関数を使用し、緯度と経度で指定した地点から1000メートル以内にある企業を抽出しています。
クエリの先頭に PROFILE
を付加することで実行計画を表示させ、確かに空間インデックスを利用していることを検証できます。
空間インデックスを使用し、ある地点から1キロメートルの範囲にある企業を検索した時のPROFILEの結果
このクエリを使ってフェニックスのある地点から1キロメートル以内にある企業を見つけ出すのに、私のノートPC上ではおよそ7ミリ秒を要しました。
日付クエリ
次に作成したいのは、例えば2015年3月23日から2015年4月20日の間といった特定期間内のレビューを検索するクエリです。これは以下のように記述します。
MATCH (r:Review)
WHERE date("2015-03-24") < r.date < date("2015-04-20")
RETURN COUNT(r) AS num_reviews
-----------------------------------------------------
num_reviews
63527
Cypherで日付を組み立てる方法は限られています。 date
関数の引数として、日付の構成要素(年月日)を整数値で渡すか、上で行ったようにパースする文字列を渡すかです。ここでも、クエリをPROFILEすることで、時間インデックスが利用されているか確認できます。私のノートPCでは、このクエリの実行時間は12ミリ秒以下でした。500万件の Review
ノードを全て検索しなければならない場合に比べれば、間違いなく高速です。
時間インデックスを使用し、特定期間内のレビューを検索。
ほんの一部ではありますが、Neo4jの時間に関する新機能を紹介しました。他にもNeo4jには、 DateTime
や LocalDateTime
などのタイムゾーンの考慮が可能なデータタイプや、時間の間隔を扱うための期間(Duration)といった機能があります。これらの機能の詳細は、 Neo4jのドキュメント に記載されています。また、私の同僚である Adam Cowley が、 Neo4jの時間タイプ と それらのJaveScriptでの使用法 について、いくつかの素晴らしい記事にまとめてくれています。
ここまでのまとめ
さて、これで空間や時間による検索の方法が分かってきました。ここまでに紹介したクエリを組み合わせることで、特定の期間にレビューを受けた企業を、場所を指定して検索することができます。
MATCH (b:Business)<-[:REVIEWS]-(r:Review)
WHERE distance(
b.location,
point({latitude:33.329 , longitude:-111.978})
) < 1000
AND date("2015-03-24") < r.date < date("2015-04-20")
RETURN COUNT(b) AS num_businesses_with_reviews
---------------------------------------------------------
num_businesses_with_reviews
69
このクエリは、指定した期間におけるレビュー情報を持つ特定範囲の企業の件数を示してくれるに過ぎません。UIを作成するには、実際にはもう少し情報が必要です。クエリの全体は、以下にある「ReactアプリからNeo4jクエリを実行する」セクションで紹介することにします。
このクエリのPROFILEを見てみると、クエリプランナーはReview(date)に設定された時間インデックスの利用を選択していることが分かります。空間インデックスが20万件未満のエントリー(企業)しか含まない一方で、時間インデックスが500万件のエントリー(レビュー)を含んでいることを考えれば、時間インデックスの方が選択性の高いインデックスとなりますから、妥当な選択といえるでしょう。
時にはインデックスヒントを用いて、特定のインデックスの使用を強制したい場合もあります。例えば、時間インデックスではなく空間インデックスを使用したい場合です。現状では インデックスヒント は空間インデックスをサポートしていませんが、Neo4jの次のリリースでサポートされる予定です。
Reactアプリ
データベースとクエリが完成し、Webアプリの構築を開始する準備が整いました。
Reactアプリを作成する
Create React App は、Reactプロジェクトを作成する最も簡単な方法です。これは、BabelやWebpackなどの構築ツールを構成することなく、Reactアプリのスケルトンを作成するためのツールです。以下のようにするだけで、create-react-appを使って簡単にReactアプリを作成できます。
npx create-react-app spacetime-reviews
コンポーネント
ReactベースのUIは、何らかのデータ(props)が渡されたインタフェースをレンダリングするためにロジックをカプセル化したコンポーネントで構成されています。このアプリ用に作成するコンポーネントは主に次のものです。
App
コンポーネント
メインとなるコンポーネントで、Neo4j JavaScriptドライバを使ってデータベースにクエリを送信し、その結果を(他のアプリケーションを維持しつつ)stateに保存します。アプリのその他すべてのコンポーネントは、 App
コンポーネントの子になります。
Map
コンポーネント
Mapコンポーネントの役割は、ユーザーがある位置を選択した時に、ビジネスをマーカーで示した地図を表示することです。
私は以前、 Panama Papersデータセットの住所とジオコードの例 や、 米国議会の地域地図 といった、複数の異なるプロジェクトでLeaflet.jsとMapboxを使ったことがありますが、Reactアプリで使ったことはありません。そこで、最初に私が探したのは、Mapbox GL JS用のReactのコンポーネントラッパーでした。まさに求めていたようなものをUberのデータサイエンスチームが公開していました。 react-mapbox-gl です。
しかし、react-mapbox-glで作業を始めてから、このライブラリが実現するpropsだけで作業を進めるには制約があるように感じました。そんな時に運よく、 Tristen Brown の書いた ブログ記事 を見つけ、Reactと一緒にMapbox GL JSを使う方法を確立できたのです。基本的には、このコンポーネント内でライブラリの使用をカプセル化し、Mapbox GL JSライブラリを Map
コンポーネントの中で使う、という考え方です。これで制約が完全になくなったとは感じられませんでしたが、確信はあります。というのも、現実に Kepler.gl が構築されており、 react-mapbox-gl
を使って動作するアプリは作成可能だからです。
また、Reactの双方向データバインディングを使うことで、ユーザーがマップ上の新しい場所を選択した時、Neo4jから新しいデータをフェッチするための呼び出しをトリガーすることもできます。
次に、Mapコンポーネントのドラッグが可能なマーカーに伴うイベントハンドラを示します。ユーザーはマーカーを地図のどこかにドラッグしてからリリースし、検索したい新しい場所の中心を選びます。
// event handler for draggable marker in Map component
const onDragEnd = e => {
var lngLat = e.target.getLngLat();
const viewport = {
latitude: lngLat.lat,
longitude: lngLat.lng,
zoom: this.map.getZoom()
};
this.props.mapSearchPointChange(viewport);
this.map
.getSource("polygon")
.setData(
this.createGeoJSONCircle(
[lngLat.lng, lngLat.lat],
this.props.mapCenter.radius
).data
);
};
検索領域を示すサークルを更新するだけでなく、地図から緯度と経度(ズームレベルも含む)を取得し、 this.props.mapSearchPointChange(viewport)
を呼び出します。この関数は実際には、 App
コンポーネントで定義され、コールバックの一種としてpropsを通して Map
コンポーネントに渡されます。これが、子コンポーネントにpropsとしてコールバック関数が渡される、Reactの双方向データバインディングパターンの仕組みです。次に、 App
コンポーネント内の mapSearchPointChange
の実装を示します。
// defined in App.js, this function is passed to the
// Map component as a prop
mapSearchPointChange = viewport => {
this.setState({
mapCenter: {
...this.state.mapCenter,
latitude: viewport.latitude,
longitude: viewport.longitude,
zoom: viewport.zoom
}
});
};
mapSearchPointChange
は単に App
コンポーネント内のstateを更新し、再レンダリング、および選択された新しい場所のデータを更新するためのNeo4jの呼び出しをトリガーします。
ReviewSummary
および CategorySummary
コンポーネント
ReviewSummary
と CategorySummary
は純粋なプレゼンテーショナルコンポーネントで、propsとして受信したデータに基づいてグラフを描く役割だけを担います。それには Nivo Chartのコンポーネントライブラリ を使います。
Appコンポーネント内のNeo4jから取り出されたデータは、これらの2つのプレゼンテーショナルコンポーネントに渡され、星の数ごとのレビュー数のヒストグラムとビジネスカテゴリの円グラフが表示されます。
React Virtualizedのライブラリから AutoSizer
コンポーネント を使って、コンテナの全高と全幅を考慮してグラフのコンポーネントのサイズを変更できるようにすると便利なことが分かりました。
ReactアプリからNeo4jクエリを実行する
Neo4jからデータを取得するため、クライアントアプリからデータベースに直接クエリを送信します。これは実際のアプリでは理想的なアーキテクチャではないかもしれませんが、私たちのアプリではうまく動きます。 より現実的なアーキテクチャは、Neo4jデータベースにクエリを送信するGraphQL APIを作成することかもしれません 。
// import the web brower version of neo4j-javascript-driver
import neo4j from "neo4j-driver/lib/browser/neo4j-web";
class App extends Component {
constructor(props) {
super(props);
this.state = ... // set default state
// instantiate Neo4j driver instance
this.driver = neo4j.driver(
process.env.REACT_APP_NEO4J_URI,
neo4j.auth.basic(
process.env.REACT_APP_NEO4J_USER,
process.env.REACT_APP_NEO4J_PASSWORD
),
{ encrypted: true }
);
}
}
Neo4jから実際にデータをフェッチするために、実行したいCypherクエリを含む関数 fetchBusiness
を作成し、その App
コンポーネントのstateをクエリの結果で更新します。この関数は、 componentWillUpdate
や componentDidMount
といった適切なコンポーネントのライフサイクル関数から呼び出すことができます。Neo4jの Date
の日付のタイプをインポートし、パラメータとしてクエリに渡す Date
オブジェクトのインスタンスを作成することに注意してください。
import { Date } from "neo4j-driver/lib/v1/temporal-types";
fetchBusinesses = () => {
const { mapCenter, startDate, endDate } = this.state;
const session = this.driver.session();
session.run(`
MATCH (b:Business)<-[:REVIEWS]-(r:Review)
WHERE $start <= r.date <= $end
AND distance(b.location,
point({latitude: $lat, longitude: $lon})) < ( $radius * 1000) OPTIONAL MATCH (b)-[:IN_CATEGORY]->(c:Category)
WITH r,b, COLLECT(c.name) AS categories
WITH COLLECT(DISTINCT b {.*, categories}) AS businesses,
COLLECT(DISTINCT r) AS reviews
UNWIND reviews AS r
WITH businesses, r.stars AS stars, COUNT(r) AS num
ORDER BY stars
WITH businesses,
COLLECT({stars: toString(stars), count:toFloat(num)})
AS starsData
RETURN businesses, starsData`,
{ lat: mapCenter.latitude, lon: mapCenter.longitude,
radius: mapCenter.radius,
start: new Date(
startDate.year(),
startDate.month() + 1,
startDate.date()),
end: new Date(
endDate.year(),
endDate.month() + 1,
endDate.date())
}
)
.then(result => {
console.log(result);
const record = result.records[0];
const businesses = record.get("businesses");
const starsData = record.get("starsData");
this.setState({
businesses,
starsData
});
session.close();
})
.catch(e => {
console.log(e);
session.close();
});
};
Netlifyでデプロイする
Netlify でデプロイするための設定を作成します。
Reactアプリケーションをデプロイするには、静的コンテンツを提供するだけでよいので、たくさんのオプションがありますが、私はアプリの構築とデプロイが簡単な Netlify を使うことにしました。
NetlifyプロジェクトをGithubと統合してgit commitフックを作成すれば、コミットごとに構築をトリガーできます。NetlifyでMapboxトークンとNeo4j認証情報の環境変数を指定することもできます。
このプロジェクトのコードはGithub上で 見られます。また、 ここで実際に試してみてください 。
SpaceTimeのレビューダッシュボード。特定の期間内にレビューがあった特定の場所の中でビジネスを探します。 お試しはこちら 。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa