はじめに
Pharo Smalltalkでデータを永続化する場合、さまざまな選択肢がありますが、グラフDBも有力な候補のうちの一つです。
Smalltalkはオンメモリにオブジェクトを浮かべていく環境なので、アプリの開発が進むと、わりと複雑な構造のオブジェクトのグラフがイメージ内にできていきます。これをRDBなどに保存しようとすると、特に問題になるのは表とオブジェクトとのインピーダンスミスマッチです。GlorpなどのOR Mapperもあるのですが、マッピングの管理や維持がなかなか大変です。
一方グラフDBなら、オブジェクトが織りなすグラフ構造を、変換せずに素直に永続化していくことができます。ややこしいマッピングに悩まされることはありません。
以後は、PharoとグラフDBの連携の例として、SCypherGraphという自作のライブラリを紹介していきたいと思います。SCypherGraphを使えば、代表的なグラフDBであるNeo4jに、Pharoから気軽にアクセスできます。
SCypherGraphのPharoへの導入
Pharoそのもののインストールについては割愛します。
Advent Calendarに良い記事がありましたので、適宜参照してください。
SCypherGraphを入れるにはPharoのPlaygroundで以下を"do it"します。(なお、PlaygroundはControl+o+wで開きます。MacではCommandキーです。以後は読み替えるようにしてください)
Metacello new
baseline: 'SCypherGraph';
repository: 'github://mumez/SCypherGraph:main/src';
load.
これでSCypherGraph本体と依存するライブラリ群がGitHubから取得され、Pharoにインストールされます。
Neo4jのインストールと起動
Neo4j Download Centerから、Community Serverを選択し、アーカイブファイルを取得して展開します。
bin以下に、パスを通し、neo4j
(windowsの場合はneo4j.bat
)をconsole
指定で実行します。なお、実行にはJDK 11以降が必要です。
neo4j console
ブラウザで http://localhost:7474/ と指定すると、Webベースの管理用UIが開きます。初回のみ、管理者用パスワードを設定する必要があります。(以後はバージョン4.2.1を想定して説明していきます。バージョンが進むとUIが多少違うことがあるかもしれません)
デフォルトでは
Username: neo4j
Password: neo4j
となっていますが、Connectボタンを押して上記でログインすると、すぐに変更を求められます。
今回は
Password: neoneo
とでもしておきます。
Neo4jへのデータ投入
パスワードを変更すると、管理コマンド等を評価できるペインが出てきます。
これを使い、"Movie Graph"というサンプルデータを手始めに投入しておきましょう。
一番上にある$
のプロンプトが出ているペインに
:use neo4j
と打ち実行します。(再生ボタンかControl + enter)
これはデフォルトのデータベースをsystem
からneo4j
に切り替えるためのものです。(system
のデータベースにはサンプルデータを投入できないので、切り替えています)
続いてチュートリアルを開きます。プロンプトが出ているペインに
:play movie-graph
と打ち実行します。
複数ページに渡るチュートリアルがすぐ下に表示されます。2ページ目に移り左上のCREATEの近くの再生ボタンを押すと、上部のペインにCypherというグラフ操作言語のコードが貼り付けられます。
貼り付けられたCypherを実行しましょう。これでデータが投入されます。
結果がグラフィカルに表示されるようになりました。
SCypherGraphによるグラフデータへのアクセス
では、投入された"Movie Graph"のデータを、SCypherGraphでNeo4jにつないで取り出してみましょう。PharoのPlaygroundで以下のコードを書き"print it"します*1。(コード全体を選んでControl + p)
db := SgGraphDb new.
db settings username: 'neo4j'; password: 'neoneo'.
db allLabels. "print it"
SgGraphDb >> allLabels
で、DB内部にあるノードのラベル一覧を得ることができます。結果を見ると'Movie'と 'Person'のラベルが存在することがわかりました。
ノードの取得
'Movie'というラベルのノードにはどういったものがあるのか、Transcriptに表示してみたいと思います。Control+o+tでTranscriptを開いてから、Playgroundにさらに追記して"do it"してみましょう。(コードを選んでControl + d)
(db nodesLabeled: 'Movie')
do: [ :each | self traceCr: each properties ].
SgGraphDb >> nodesLabeled:
で、特定のラベルが付けられたノード群を取り出すことができます。各Movieノードは、リリース年やキャッチコピー(tagline)など、さまざまな情報をプロパティとして持っていることが確認できます。
(結果をdo:
でイテレートしているので、traceCr:
によりTranscriptにノードごとに改行されて表示されます。)
Movieの量が多すぎるので、今度はwhere:
もつけて条件を指定してみます。以下を追記して、"inspect it"してみましょう。(Control+i)
matrix := (db nodesLabeled: 'Movie' where: [:each | each @ 'title' = 'The Matrix']) first.
matrix properties.
where:
のブロック内で、'title'プロパティの値が'The Matrix'であるものに限定しています。結果はOrderedCollection
で返ってきますが、一つだけなのでfirst
で取り出しています。
properties
でプロパティ一覧が取り出され、インスペクタが開きます。
「マトリックス」はもう20年以上前の映画なのですね。
リレーションの取得
リレーションとは、ノード間に貼られている関連のことです。Neo4jではリレーションにタイプや方向の概念があります。また、リレーションにもプロパティを設定できます。
'The Matrix'に向かって張られている関連を一覧してみることにします。
matrix inRelationships. "print it"
ACTED_IN
, DIRECTED
, PRODUCED
といったタイプの関連が張られていることがわかります。俳優、ディレクター、プロデューサーなどの人物が関わっているようですね。
一方で、'The Matrix'から伸びている関連はありませんでした。
matrix outRelationships. "print it"
では、もう少し条件を指定して、演じた(ACTED_IN
)人達の名前を一覧してみましょう。
(matrix inRelationshipsTyped: 'ACTED_IN')
collect: [:each | each endNode @ 'name']. "print it"
関連の終端ノード側はPersonノードになっており、プロパティ'name'を持っています。
collect:
で集められ、文字列の配列として結果が得られることを確認できました。
where:
を入れて更に絞り込んでみます。ブロック引数には、検索用の条件指定に使えるよう、開始ノード、ノード間の関連、終端ノードが渡されるようになっています。
ACTED_IN
の関連にはプロパティ'roles'があります。ある映画で一人二役以上ということもあるので、配列になっているのでしょう。'Neo'を演じた人を探してみます。
(matrix inRelationshipsTyped: 'ACTED_IN' where: [ :start :rel :end | (rel @ 'roles') = #('Neo') ])
collect: [ :each | each endNode properties ]. "inspect it"
インスペクタを見ると、
'Neo'を演じたのは'Keanu Reeves'、1964年生まれということがわかりました。
SCypherGraphによるグラフデータの更新
ノードとリレーションをいい感じに取り出せたので、次は更新系の操作をしてみたいと思います。
ノードの作成
まず、ジャンル(Genre)というラベルのノードを新設することにします。
'SF'と'Action'の二つをとりあえず作成します。プロパティとして、名前('name')の他に、短い説明('description')も付けておきます。
sf := db mergeNodeLabeled: 'Genre' properties: {'name'->'SF'. 'description'->'Science Fiction'}. "inspect it"
action := db mergeNodeLabeled: 'Genre' properties: {'name'->'Action'. 'description'->'Exciting Actions'}. "inspect it"
上記のようにSgGraphDb >> mergeNodeLabeled:properties:
でノードの作成が可能です。作成された結果のノードが即座に返るようになっています。
createNodeLabeled:properties:
でもノードを作成できますが、こちらを使うと毎回新しいノードが作られ、実行の度にGenreが重複して増えていってしまいます。mergeNodeLabeled:properties:
では、すでにDBに存在するノードがあると単にそれを返すようになっており、同じノードが作られることはありません。そのため通常はmerge系を使うことが多いでしょう。
作成した各ノードについてのインスペクタが開いていると思います。インスペクタ下部のペインではSmalltalkの式を評価できるので、self properties
と打って"print it"してみましょう。ノードに付与したプロパティの値を確認できます。
リレーションの作成
次はジャンルと映画とを、リレーションで結びつけてみたいと思います。
マトリックスは、SFでもあり、アクション映画でもある感じなので、2つのGenreノードに対して関連を張ることになるでしょう。
関連タイプはHAS_GENRE
とします。以下のようにSgNode >> relateOneTo:typed:properties:
を使います。
matrixToSf := matrix relateOneTo: sf typed: 'HAS_GENRE' properties: {'score'-> 6}.
matrixToAction := matrix relateTo: action typed: 'HAS_GENRE' properties: {'score'-> 7}. "do it"
分類されるジャンルの傾向がどのくらい強いかの度合いとして'score'というプロパティも入れてみました。
リレーションはSgNode >> relateTo:typed:properties:
で作成することもできますが、こちらは実行の度に新たなリレーションが作成されてしまいます。マトリクスから同じSFのノードに重複した関連があっても意味がありませんので、relateOneTo
が今回は適しています。ノードのmerge系操作と同じく、すでに同種のリレーションがある場合はそれを返してくれます。
確認のため、生成されたリレーションの開始ノードと終了ノードから、プロパティを取り出してみます。
{matrixToSf startNode @ 'title'. matrixToSf endNode @ 'name'}. "print it"
想定通り、'The Matrix'から'SF'に関連が張られたことがわかりますね。
先ほどは空だった、matrixから伸びていく関連を一覧してみましょう。
matrix outRelationships. "print it"
今度は2つのリレーションを確認できます。
生のCypherを実行する
SCypherGraphの裏では、グラフ操作言語のCypherが動的に生成され、Neo4jに送り込まれています。Cypherを知らずとも、オブジェクトにシンプルにメッセージを送るだけで、グラフデータの読み取りや更新が一通りできるというのが、SCypherGraphの良いところです。
しかし実際には、パフォーマンスチューニングなどの観点から、Cypherを意識して実行したい場面もあります。
多少長くて複雑なCypherを書くことになったとしても、欲しい要素のみをまとめて取り出せれば、クエリの実行回数が減り、パフォーマンス的に有利になるからです。
そのためSCypherGraphでは、生のCypherをNeo4jに渡して実行させることも可能になっています。
まずは単純なCypherで試してみましょう。
db runCypher: 'UNWIND range(1, 10) AS n RETURN n*n'. "inspect it"
UNWIND
でrange
から数値のリストを生成し、それぞれを2乗した結果をRETURN
で返すというものです。
実行するとSbCypherResult
のインスペクタが開きます。下部のペインにself fieldValues
と書いて"print it"で、結果を確認できます。
パラメータを渡す形でのCypherの実行もサポートしています。下記の$from
と$to
という部分がパラメータです。arguments:
により値を与えています。
db runCypher: 'UNWIND range($from, $to) AS n RETURN n*n'
arguments: {'from'->2. 'to'->5}. "inspect it"
SCypherでCypherを生成して実行する
より複雑なCypherについては、どういったアプローチがあるでしょうか。
SCypherGraphは内部でSCypherというライブラリを用いており、メッセージ送信によってCypherを動的かつ柔軟に生成することができます。
少し込み入ったクエリとして、「'Tom'で始まる俳優が、2000年封切りの映画で共演した俳優の一覧を得る」という例を考えてみましょう。
生のCypherを書くとこういった感じです。
MATCH (p:Person)-[act1:ACTED_IN]->(m:Movie {released:2000})<-[act2:ACTED_IN]-(o:Person)
WHERE (p.name STARTS WITH 'Tom')
RETURN p.name, o.name, m.title ORDER BY p.name
MATCH
で、ノードとリレーションとの連なりのパターンを指定しています。WHERE
ではプロパティ値が'Tom'で始まるという細かな条件をさらに加えています。RETURN
で、俳優さんの名前、映画のタイトルといった、必要な情報のみを取り出しています。
検索パターンが固定であれば、このCypherをソースコード上にハードコードで埋めておく方式もあるかもしれません。しかしいくつか検索のバリエーションが増えてくると、対応していくのはつらいことになります。
では、SCypherで上記のCypherを動的に作成してみましょう。
m := 'm' asCypherObject. "映画"
p := 'p' asCypherObject. "俳優"
o := 'o' asCypherObject. "共演者"
"2000年封切りの映画で2人の俳優がつながっているパターン"
pathPattern := (p node: 'Person') - ('act1' asCypherObject rel: 'ACTED_IN' ) -> (m node: 'Movie' props: {'released'->2000}) <- ('act2' asCypherObject rel: 'ACTED_IN' ) - (o node: 'Person').
"俳優が、パラメータで指定した名前で始まるかの条件指定"
actorNameParam := 'actorName' asCypherParameter.
where := (p @ 'name') starts: actorNameParam.
"結果を俳優の名前、共演者の名前、映画のタイトルの順で返す指定"
return := (p @ 'name'), (o @ 'name'), (m @ 'title').
"Cypherクエリの組み立て"
query := CyQuery match: pathPattern where: where return: return orderBy: (p @ 'name') skip: 0 limit: 100. "print it"
直接書いたCypherよりも長くなりましたが、マッチさせるパターンや、where
、return
などが変数となり、組み替えることが容易になっているのが見て取れると思います。
また、Smalltalkのメッセージ送信ではありますが、パターンやwhereの書き方などがCypherとほぼ一対一に対応しているため、処理を連想しやすく、相互の変換がしやすくなっています。
一連のコードを選んで"print it"すると、以下のようなCypherが作られることを確認できます。
MATCH (p:Person)-[act1:ACTED_IN]->(m:Movie {released:2000})<-[act2:ACTED_IN]-(o:Person)
WHERE (p.name STARTS WITH $actorName)
RETURN p.name, o.name, m.title ORDER BY p.name SKIP 0 LIMIT 100
パラメータを使うようになっていますが、先ほど示したCypherとほぼ同じですね。
ではSbGraphDb >> runCypher:arguments:
で実行してみます。
result := db runCypher: query arguments: { actorNameParam -> 'Tom' }.
(result fieldValues groupedBy: [ :each | each at: 1 ]). "inspect it"
インスペクタで見やすいように、groupedBy:
を使って俳優の名前でグルーピングしています。
'Tom Cruise'が'Jerry Maguire'で8名、'Tom Hanks'が'Cast Away'で1名と共演しているという結果になりました。
せっかくなので、where
の条件を少し変えてみましょう。
where := ((p @ 'born') > 1970) and: ((o @ 'born') > 1970). "do it"
where
を設定しなおしたので、クエリを再生成する必要があります。query := ...
と result := ...
の両方を選んで"inspect it"してください。
これで、2000年当時に互いに30歳未満で共演した人達が取得できました。
アプリでの適用事例は?
実戦投入はまだなのですが、中古建機売買サービスのAllstocker.comでは、私が過去に作成したNeo4reStというNeo4jクライアントが、高度な検索を行うために使われています。
Neo4reStはNeo4jのレガシーなREST APIを使うものでした。一方SCypherGraphは、Neo4jネィティブのBoltというバイナリプロトコルを採用しており、だいたい3倍ほど速度も向上しています。そのため、折を見てSCypherGraphに移行していこうと思っているところです。
おわりに
SCypherGraphを使えば、Neo4jにアクセスし、グラフデータを自在に操れることがわかっていただけたのではないでしょうか。
いざという時には、動的に生成したCypherを直接送り込むこともできるので、柔軟な利用が可能になっています。
ドキュメント類がまだ未整備ですが、この記事を皮切りとして充実させていこうと思っています。次のSmalltalk勉強会で話すことになるかもしれません。
それでは良いクリスマス+新年をお過ごしください!
-
なんらかの理由でIPアドレスやポート番号を変えたい場合は
db settings targetUri: 'bolt://127.0.0.1:7687'.
で設定できます ↩