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は、インポート用に属性とタイプを定義したヘッダファイルを利用しています。ですので、BusinessReview用のヘッダファイルをアップデートしなければなりません。最初は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 Encryptcertbotというツールを使うことで、認証局による署名済みの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には、DateTimeLocalDateTimeなどのタイムゾーンの考慮が可能なデータタイプや、時間の間隔を扱うための期間(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コンポーネント

ReviewSummaryCategorySummaryは純粋なプレゼンテーショナルコンポーネントで、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をクエリの結果で更新します。この関数は、componentWillUpdatecomponentDidMountといった適切なコンポーネントのライフサイクル関数から呼び出すことができます。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のレビューダッシュボード。特定の期間内にレビューがあった特定の場所の中でビジネスを探します。お試しはこちら