GraphDBのひとつである、Neo4jを触ってみたので、簡単に解説していきます。
GraphDBとは
グラフと聞くと、Excelなどで作成できる棒グラフや折れ線グラフをイメージしますが、GraphDBが表すGraphとは、データ構造の概念のひとつで「ノードの関係の集合」を示す語とされています。
身近なところで言えばSNSのフォロー関係や、駅と線路の関係などもこれに当てはまります。
MySQLなどの昔からあるRDBMSでは、例えば「友達の友達」や、「駅間の最短経路探索」は苦手としており、いくつも連なるJOIN句を書いたり、複雑なテーブル構造を持ったりして無理矢理実装するしかありませんでしたが、GraphDBの登場によって、高速かつ簡潔な探索を実現することが可能となりました。
neo4jとは
GraphDBの中でも比較的ポピュラーなデータベースエンジンで、Javaで記述されています。SQLライクなCypher Query Languageといった独自のクエリ言語を搭載しています。
インストール
neo4jのダウンロードページから自分のOSに合ったファイルを落とし、インストールします。インストール方法はダウンロードページにそのまま書いてあるので、特に説明の必要もないでしょう。
Java8のインストールが必須となっているので、インストールがまだの方は併せてインストールしましょう。
設定
localhostのみで動かす場合は問題ありませんが、外部からneo4jに繋ごうとしたとき、デフォルト設定ではアクセスできません。外部から接続する場合のみ以下を実施してください。
conf/neo4j.conf
以下のコメントアウトを外します。
dbms.connectors.default_listen_address=0.0.0.0
ファイアウォール
iptablesなどのファイアウォールを利用している場合は、以下のポートを開けてください。
- 7687
- 7474
7687番はneo4jのWebSocketポート、7474番は管理コンソールへの接続用ポートです。
試してみる
インストールを完了し、デーモンが立ち上がったら、http://localhost:7474、またはお使いのサーバーのアドレスの7474番ポートにブラウザから接続します。
管理コンソールが起動したら、ユーザー名「neo4j」パスワード「neo4j」でログインし、パスワードの変更を済ませます。これで準備は完了です。
ノードを作成する
neo4jは、ドキュメントが非常に充実しており、たとえば上部のコンソールに「:play movie-graph」と入力すれば、映画とキャスト(アクター)、ディレクターなどの関連が設定されたテストデータをテーマにして、いろいろと試せるようになります。
しかし、今回はCypher Query Languageの基礎を学ぶため、あえて生のクエリで解説したいと思います。
CREATE (:User{name:"TestUser"});
ノードの作成は、上記のような構文で可能です。順に解説します。
:User
CREATE文の説明は必要ありませんね。:User
という部分が何を示しているか、ですが、neo4jにはノードの意味づけをするための「ラベル」があります。「カテゴリ」「ジャンル」などと言い換えても構いません。ここに意味のあるラベルを付けることによって、このレコードが「User」という意味を持つことを示しています。
{name:"TestUser"}
これは、Webエンジニアであれば馴染みの深いJSONに似た構文です。ノードに属性(Attributes)を持たせることができます。SQL文に比べても、非常に簡潔ですね。
ラベルを複数持たせることもできます。以下の構文は、「SuperUserはUserでもあるし、Adminでもある」という意味づけをしています。
CREATE (:User:Admin{name:"SuperUser"});
ノードを複数作成する
複数のノードを一度に作成することも簡単です。
CREATE (:User{name:"Alice"}), (:User{name:"Bob"});
リレーションを追加する
それでは、先ほど作成した「Alice」と「Bob」のユーザーに「:FRIEND」という関係(relationship)を作成してみましょう。
MATCH (a:User{name:"Alice"}),(b:User{name:"Bob"}) CREATE (a)-[:FRIEND]->(b);
※MATCH句に関する詳しい解説は後述します。
これで、AliceはBobに対して「FRIEND」であるという片方向のリレーションができました。
ところで、注意しなければいけないことがあります。今回作成するリレーションは、ユニークなリレーションではありません。つまり、先ほどのレコードを複数回実行すると、実行した数だけリレーションが作成されてしまいます。
CREATE句にUNIQUEを付与することで、それを防ぐことができるようです。
MATCH (a:User{name:"Alice"}),(b:User{name:"Bob"}) CREATE UNIQUE (a)-[:FRIEND]->(b);
ノードとリレーションを同時に追加する
もちろん、ノードとリレーションを一気に追加することもできます。以下の構文は、「EveというUserがIssacというISPに対して攻撃を仕掛けた」という関係を表しています。
CREATE (:User{name:"Eve"})-[:ATTACKED]->(:ISP{name:"Issac"});
正常に作成されたか確かめてみます。
MATCH (a:User{name:"Eve"})-[]-(b) RETURN a,b;
リレーションにプロパティを持たせる
ところで、リレーションにプロパティを持たせたい場合があります。たとえば、駅と駅をつなぐ路線を表現しようとしたときに、「A駅からB駅まで何分かかるか」といった情報はリレーションに持たせたい情報ですよね。
「東京から有楽町まで、山手線で2分かかる」というデータを作成してみましょう。
CREATE (:Station{name:"Tokyo"})-[:YAMANOTE{minute:2}]->(:Station{name:"Yurakucho"});
複数のリレーションを追加する
先ほどは、AliceがBobに対して一方的に友情を感じていたようですが、一般的に「友達」といえば双方向の関係を持っているはずです。
「BobとEveは互いに友達である」というリレーションを追加してみましょう。
MATCH (a:User{name:"Bob"}),(b:User{name:"Eve"}) CREATE UNIQUE (a)<-[:FRIEND]->(b);
ノードを検索する
先ほども少し出てきましたが、MATCH構文により、非常に簡潔な検索ができます。
全件検索
MATCH (n) RETURN n;
MATCH句で引っ掛けたリソースをRETURN句で返します。少し独特な構文ですが、プログラムを組んだことがあればむしろ分かりやすいのではないでしょうか?
この中で、「n」とは、ただの変数名にすぎません。neo4jの中で、MATCH句によって検索を行った後、RETURN句で結果を返すわけです。SQLでいえば、丁度WHERE句とSELECT句の関係に当てはまります。
完全一致検索
MATCH句は、その名のとおり、「一致」するものを返しますから、以下のようにすると、「:User」というラベルが付いたもののみ検索することができます。
MATCH (users:User) RETURN users;
プロパティとラベルを両方指定することもできます。
MATCH (alice:User{name:"Alice"}) RETURN alice;
あるいは、プロパティのみで検索することもできますが……。
MATCH (n{name:"Alice"}) RETURN n;
どうもこれはインデックスが効かなくなるようです。お勧めしません。
WHERE句による検索
おなじみWHERE句による検索も可能です。
MATCH (user:User) WHERE user.name = "Alice" RETURN user;
WHERE句ではRegex(正規表現)が使えます。
MATCH (user:User) WHERE user.name =~ ".*ice.*" RETURN user;
範囲検索(Between)も、自由自在です。
CREATE (:User{name:"Carol", age:25}), (:User{name:"Charlie", age:40);
テストデータを用意して……、
MATCH (user:User) WHERE user.age > 20 AND user.age < 30 RETURN user;
これで、よくある「あなたと同じ年代の人はこんな商品も買っています!」などの機能が実装可能ですね。
削除する
いろいろ試すうちにテストデータが増えてきました。基本的には、MATCH句で検索したデータに対して、DELETE句で削除するだけです。
ノードを削除する
Carolを削除してみましょう。
MATCH (carol:User{name:"Carol"}) DELETE carol;
リレーションを削除する
Aliceは、Bobと友達であることをやめたいようです。以下のようにします。
MATCH (alice:User{name: "Alice"})-[r:FRIEND]->(bob:User{name: "Bob"}) DELETE r;
リレーション指定の[r:FRIEND]
の部分のラベル指定を外す([r]
)と、全てのリレーションが削除されます。
ところで、Bobはとあることで炎上してしまい、みんなから友達をやめられました。
MATCH ()-[r:FRIEND]->(bob:User{name: "Bob"}) DELETE r;
このように、片方だけプロパティを指定することで、「そのノードに関連する全てのリレーション」が取得できます。
Charlieには、人間関係リセット癖があるようです。
MATCH (charlie:User{name: "Charlie"})-[r]-() DELETE r;
これは、方向を指定しないことで、双方向のリレーションを削除してしまうクエリです。
更新する
MATCHクエリの使い方はもう大丈夫ですね。ノードとリレーションのプロパティを更新してみましょう。
ノードのプロパティを更新する
Aliceに年齢を追加してあげましょう。
MATCH (alice:User{name:"Alice"}) SET alice.age = 22;
非常に簡潔な構文ですね。
リレーションのプロパティを更新する
先ほど作成した「東京と有楽町を山手線で結んだ関係」に、「内回り」か「外回り」の情報を足してみます。
MATCH (tokyo:Station{name:"Tokyo"})-[line:YAMANOTE{minute:2}]->(yurakucho:Station{name:"Yurakucho"}) SET line.type = "Outer";
しかし、こういったカテゴリ分け出来そうなものは、ラベルで分けたほうがいいかもしれません。
MATCH (tokyo:Station{name:"Tokyo"})-[line:YAMANOTE{minute:2}]->(yurakucho:Station{name:"Yurakucho"}) SET line:OUTER;
ラベルを付与する方法は、もちろんノードにも適用できます。
MATCH (alice:User{name:"Alice"}) SET alice:TEACHER;
基点からnノードまでの階層を探索する
たとえば、SNSの情報公開設定で、「友達の友達まで」を公開範囲とする設定を見たことがないでしょうか。これについては、以下のクエリで簡単に実現できます。
MATCH (user:User{name: "Alice"})-[:FRIEND*1..2]->(to) RETURN to;
ここで初めて出てきた、*1..2
という箇所は、「1~2層のリレーションを探索する」つまり、「友達」で1層、「友達の友達」で2層というわけです。
「友達」と「友達の友達」ではなく、「友達の友達」だけを検索したい場合には、以下のようにすれば実現できますね。
MATCH (user:User{name: "Alice"})-[:FRIEND*2]->(to) RETURN to;
あるユーザー間は「友達の友達」なのか調べる
とはいえ、投稿を閲覧しようとしたときにすべてのノードを毎回調べるようでは非効率すぎますから、「ユーザーAとユーザーBは友達もしくは友達の友達であるか」を調べてみましょう。
MATCH (user:User{name: "Alice"})-[:FRIEND*1..2]->(to:User{name:"Bob"}) RETURN to;
これも単純な話で、toのユーザーの検索条件を指定することで、「userと:FRIENDで1層~2層の関係を持っており、かつtoがBobである」という条件になっています。すなわち、このクエリを投げて結果レコードが返ってくれば「投稿を見る権限がある」とみなすわけです。
しかし、このとき返ってくる情報は、実はほとんどいらないものです。なぜなら上記のクエリで知りたいことは、「その人が友達~友達の友達であるか」つまり、trueかfalse(boolean値)しか必要ないのです。
少しクエリを工夫してみましょう。
MATCH (user:User{name: "Alice"})-[:FRIEND*1..2]->(to:User{name:"Bob"}) RETURN count(to) > 0 AS authorized;
RETURNする値を条件文にすることで、booleanの値が返ってくるようになりました。さらに、そのままでは扱いづらいので、「authorized」という名前を与えます。
これで、ページにアクセスする権限があるかどうかを調べる仕組みができました。
最短経路探索
neo4jの目玉機能として、最短経路探索用のメソッドが用意されています。「コネ.com」という、友達の友達を辿ってコネクションを作るという、どっかにありそうな架空サービスを考案してみましょう。
さて、「AliceはCharlieとどうしても友達になりたいが、知人は誰もCharlieのことを知らない。どうにかコネを作ることはできないだろうか?」と考えたとします。このとき、先ほどのように、「友達の友達に紹介してもらう」という方法が出来れば楽ですよね。
それでは、その人に繋がるための最短経路を検索してみましょう。
MATCH (from:User{name:"Alice"}), (to:User{name:"Charlie"}) RETURN shortestPath((a)-[:FRIEND*]->(b));
これで、AliceがCharlieと友達になるにはどの人を辿ればいいかがわかる、というわけです。
路線案内の例
ちょっとしたテストデータを用意して、路線案内サービスを想定してみましょう。
CREATE (from:Station:BusStop{name:"START"}), (to:Station:BusStop{name:"GOAL"})
CREATE (from)-[:TRAIN{cost:5}]->(:Station{name:"StationA"})-[:TRAIN{cost:5}]->(:Station{name:"StationB"})-[:TRAIN{cost:5}]->(:Station{name:"StationC"})-[:TRAIN{cost:5}]->(to)
CREATE (from)-[:BUS{cost:11}]->(:BusStop{name:"BusStopA"})-[:BUS{cost:11}]->(to);
この経路は、電車での経路だと20分で目的地までつきますが、間に3駅あります。一方バスは間に1つのバス停しかありませんが、各11分かかるため、22分のコストが掛かります。
では、先ほどのshortestPath関数を利用して検索してみましょう。
MATCH (from:Station:BusStop{name:"START"}), (to:Station:BusStop{name:"GOAL"}) RETURN shortestPath((from)-[*]->(to));
より時間のかかるバスを利用した経路が探索されました。これは当然の話で、shortestPathは単純にリレーションが最も短くなる経路を探索するわけですから、プロパティをいちいち考慮してくれるわけではありません。
この問題について調べていると、StackOverFlowに参考になるコードがありました。これを少し弄って、今回のテストデータに適用させてみましょう。
MATCH p = (from:Station:BusStop{name:"START"})-[*]->(to:Station:BusStop{name:"GOAL"})
WITH p,reduce(s = 0, r IN rels(p) | s + r.cost) AS cost
RETURN p, cost ORDER BY cost ASC LIMIT 1;
全ての経路を取得した上で、各径路にreduce関数を通すことでコストを計算しています。あとは、それを昇順に並べてLIMIT 1してやれば、「最もコストのかからない経路」が取得できますね。
まとめ
このように、主に経路探索などで絶大なパフォーマンスを発揮するneo4jですが、うまく使えば爆発的な性能向上が期待できますので、新規開発のみならず、DBのマイグレーションを検討している場合は、ぜひ候補に入れてみてはいかがでしょうか。
コメント
[…] https://www.deep-rain.com/programming/database/880#i-2 […]
[…] SQLに似てるCypherというグラフクエリ言語を使います。Cypherでは、グラフに入っているデータを検索できますが、Cypherの入門はここやここを参考してください。 […]