Spring Data Neo4j 6.0を触ってみると、初心者には一筋縄で行かなかった
GraphDBには前から興味があり、一時期はArangoDBで遊んでみたりもしましたが、新年を迎え、Spring Bootは2.4に進化しており、今回はReactive対応しているNeo4jを試してみました。
そしてハマリました。。。
Ne4jの構築
お手軽にdocker-composeで構築します。
version: '3'
services:
neo4j:
image: neo4j
environment:
NEO4J_AUTH: neo4j/password
ports:
- 7474:7474
- 7687:7687
docker-compose up -d
で起動して、ブラウザで http://localhost:7474
にアクセスすると無事ログイン画面が表示されます。
Password欄に、今回の設定であればpassword
を入力して、Connectボタンをクリックするとログインが完了します。
お試しアプリの作成
下準備
Spring Initializrで依存にSpring Data Neo4j(以下、SDN)を選択し、言語はKotlinてプロジェクトを生成します。
- サイトのロゴがInitializerじゃないのに今気づいた。
- この記事を書いている時点でSpring Bootの最新は2.4.1で、SDNは6.0.2でした。
application.propertiesは好みで拡張子をymlに変えて、Neo4jへの接続情報を記載します。
spring:
data:
neo4j:
uri: bolt://localhost:7687
username: neo4j
password: password
これで下準備が終わったので、いよいよドメインモデルを書いてみます。
ドメインモデル
社員(User)とプロジェクト(Project)が多対多になる関係です。
@Node
data class User(
@field:Id
val userName: String,
@Relationship(type = "WORK")
val projects: Set<Project>? = null
)
@Node
data class Project(
@field:Id
val projectName: String
)
SDNでは、グラフの頂点を表すクラスには@Node
、辺で繋がった先を保持するプロパティに@Relationship
を付与します。
この例では辺が型(type)以外の情報を持っていないので、@Relationship
が付与されたプロパティは接続先の頂点クラス(Project)のコレクションになっています。
- もし辺にもプロパティが存在する場合は、辺を表すクラスを作成して
@RelationshipProperties
を付与し、@Relationship
が付与されたプロパティは辺クラスを保持する形になります。
テスト
これを本来ならSpring DataらしくRepositoryインタフェースを作成すべきでしょうが、特に目新しいことはないので、Neo4jClientとNeo4jTemplateを使ってテストを書きます。
@DataNeo4jTest
class Neo4jApplicationTests {
@BeforeEach
fun prepareDatabase(@Autowired client: Neo4jClient) {
// データベースの初期化
client.query("MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n, r").run()
}
@Test
fun test(@Autowired client: Neo4jTemplate) {
// 登録
val user = User(
"user0001",
setOf(Project("project0001"), Project("project0002"), Project("project0003"))
)
client.save(user)
// 社員の取得
val users = client.findAll(User::class.java).onEach { println(it) }
Assertions.assertThat(users.size).isEqualTo(1)
Assertions.assertThat(users[0].projects.size).isEqualTo(3)
// 社員の取得
val records = client.findAll(Project::class.java).onEach { println(it) }
Assertions.assertThat(records.size).isEqualTo(3)
}
}
実行すると以下のように出力されます。
User(userName=user0001, projects=[Project(projectName=project0001), Project(projectName=project0002), Project(projectName=project0003)])
Project(projectName=project0001)
Project(projectName=project0002)
Project(projectName=project0003)
双方向モデルでハマる
ドメインモデルや出力結果で疑問に思った方もいらっしゃるかもしれませんが、Userを検索するとProjectも入手できますが、Projectを検索しても所属しているUserが判りません。
またSDN 5.xでは、Neo4j-OGMというライブラリを使う仕様で、Neo4j-OGMのチュートリアルを見ると、双方向に頂点クラスを保持する形になっていました。
SDN 6.xはReactive対応等でOGMを使用しないように実装されたとのことですが、OGMも後継であれば今回のドメインモデルは以下でも良いはずです。
@Node
data class User(
@field:Id
val userName: String,
@Relationship(type = "WORK")
val projects: Set<Project>? = null
)
@Node
data class Project(
@field:Id
val projectName: String
// ↓追加
@Relationship(type = "WORK", direction = Relationship.Direction.INCOMING)
val user: Set<User> = setOf()
)
しかし、これだと実行結果が以下のようになります。
User(userName=user0001, projects=[Project(projectName=project0001, user=[]), Project(projectName=project0002, user=[]), Project(projectName=project0003, user=[])])
Project(projectName=project0001, user=[User(userName=user0001, projects=[Project(projectName=project0003, user=[]), Project(projectName=project0002, user=[])])])
Project(projectName=project0002, user=[])
Project(projectName=project0003, user=[])
Userの検索結果は問題ないのですが、Projectの結果がおかしなことになっています。
- project0001にuserが1件存在するのは正しいが、projectsが2件しかない。というか、この場合はuserのprojectsは空で良いかと。
- project0002と0003はuserがいない。
ちょっと、というより大分困りました。
ログにCypherのステートメントを出力させてみると
MATCH (n:`Project`) WITH n, id(n) AS __internalNeo4jId__
RETURN n{.projectName, .version, __nodeLabels__: labels(n), __internalNeo4jId__: id(n),
__paths__: [p = (n)<-[:`WORK`]-()-[:`WORK`*0..1]->()-[:`WORK`*0..]-() | p]}
ちょっと今の自分には難解です。この結果をオブジェクト化するSDNのソースも追って見たのですが、 DefaultNeo4jEntityConverter#createInstanceOfRelationshipsで、検索結果に__paths__
が存在した場合の条件分岐中で何か行われてそうだという感触を掴んだところで、追うのを辞めました。
で、SDNのドキュメントに何か答えがないかと探したところ、ようやくそれらしき文章を見つけました。要約すると
- 双方向でモデル化するのは、オブジェクトでGraphDBを再構築するようなもので、それはこのフレームワークは意図していない。
と言っているのだと思います。探せば双方向モデルについてもっと直接的な説明があるかもしれません。
それでも双方向モデルしたい
SDNのソースを追ったおかげで、問題の処理ではなく、同等のことを実現する別のロジックがありそうです。
Neo4jTemplate#findAll(Class<T> domainType)
を呼び出すと、domainTypeからCypherステートメントが自動生成され、その検索結果に__paths__
があることで問題の処理が動きますが、Neo4jTemplate#findAll(String cypherQuery, Class<T> domainType)
を使ってCypherステートメントを指定することで別のロジックが動くことになります。
で、結論として以下で、一応期待する出力が得られました。
client.findAll(
"""
MATCH (n:Project)
OPTIONAL MATCH (n:Project)-[r:WORK]-(m:User)
RETURN n, [r], [m]
""".trimIndent(), Project::class.java
).onEach { println(it) }
.flatMap { it.user }.onEach { println(System.identityHashCode(it))}
理屈は理解していないのですが、Cypherステートメントで起点となるProjectを表すnに対し、それ以外は[r],[m]のように配列にしないと、この別ロジックは処理を抜けてしまいます。
Project(projectName=project0001, user=[User(userName=user0001, projects=[])])
Project(projectName=project0002, user=[User(userName=user0001, projects=[])])
Project(projectName=project0003, user=[User(userName=user0001, projects=[])])
1060251152
1060251152
1060251152
同じUserは同じオブジェクトが使われているので、メモリ消費的にも優しそうです。
所感
なんか最初でいきなり躓いた感じですが、まだまだ奥が深そうです。
Author And Source
この問題について(Spring Data Neo4j 6.0を触ってみると、初心者には一筋縄で行かなかった), 我々は、より多くの情報をここで見つけました https://qiita.com/megasys1968/items/a1526fb0c3aea4da96ab著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Content is automatically searched and collected through network algorithms . If there is a violation . Please contact us . We will adjust (correct author information ,or delete content ) as soon as possible .