Spring Data Neo4j 6.0を触ってみると、初心者には一筋縄で行かなかった


GraphDBには前から興味があり、一時期はArangoDBで遊んでみたりもしましたが、新年を迎え、Spring Bootは2.4に進化しており、今回はReactive対応しているNeo4jを試してみました。
そしてハマリました。。。

Ne4jの構築

お手軽にdocker-composeで構築します。

docker-compose.yml
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は同じオブジェクトが使われているので、メモリ消費的にも優しそうです。

所感

なんか最初でいきなり躓いた感じですが、まだまだ奥が深そうです。