はじめに
半年ほど前にNeo4jを使うアプリケーションをSpringBoot(Kotlin)でつくりました。
javaでググっても(当時は)あまり事例を見かけなかったので、だれかの参考になればいいなと思って記事にしてみます。
やりたい(やりたかった)こと
- 1つのWebアプリケーションから、MySQLとNeo4j両方にアクセスしたい
- (時間がなかったので)できれば使い慣れた言語とかフレームワークだとうれしい
ということでちらりとググってみたところ Spring Data Neo4j
とかいうプロジェクトがあるじゃないですかー!ということで、今回はこちらを使ってみました。
現時点(2019年11月現在)だと、最新版は 5.2.2 になるようです。ドキュメントはこちら。
https://docs.spring.io/spring-data/neo4j/docs/5.2.2.RELEASE/reference/html/#reference
注意
Neo4jのセットアップ方法や、Spring Bootについての突っ込んだ話はしていません。ある程度Spring Bootを触ったことのある方向けの記事になっています。
実装したときのバージョン
- Spring Boot 2.1.4
- Spring Data JPAとNeo4jは上記に対応するバージョン
プロジェクトの作成
-
https://start.spring.io/ にて
Dependencies
で下記を追加しGenerate
をぽち-
Spring Data JPA
(MySQL用) Spring Data Neo4j
-
-
ダウンロード後、DBのドライバがそれぞれ必要なので、依存関係に
org.neo4j:neo4j-ogm-embedded-driver
とmysql:mysql-connector-java
を追加
DBに接続する
色々試行錯誤した結果、以下のようなやり方で接続することができました。
作り終えてから気が付いたのですが、なんとなく Spring Data JPA
を使って複数のデータソースに接続するときと考え方は似ているんじゃないかと思います。
repositoryとentityのパッケージを決める
それぞれのデータソースごとにrepositoryとentityを格納するパッケージを決めます。
お好みでお好きな感じにどうぞ。今回はこんな感じでわけてみました。
// Neo4j用
- xxx.gateway.neo4j.repository
- xxx.gateway.neo4j.entity
// MySQL用
- xxx.geteway.mysql.repository
- xxx.geteway.mysql.entity
接続設定用のクラスをつくる
まずはソースコードから。
@Configuration
@EnableJpaRepositories(basePackages = [ "xxx.gateway.mysql.repository" ])
@EnableNeo4jRepositories(basePackages = ["xxx.gateway.neo4j.repository"], transactionManagerRef = "neo4jTransactionManager")
@EnableTransactionManagement
class DataSourceConfiguration {
@Value("\${spring.data.neo4j.uri}")
private lateinit var neo4jUri: String
@Value("\${spring.data.neo4j.username}")
private lateinit var neo4jUserName: String
@Value("\${spring.data.neo4j.password}")
private lateinit var neo4jPassword: String
@Value("\${spring.data.neo4j.connection.pool.size:#{null}}")
private lateinit var connectionPoolSize: Optional<Int>
@Bean
fun sessionFactory(): SessionFactory {
return SessionFactory(configuration(), "xxx.gateway.neo4j.entity")
}
@Bean
fun configuration(): org.neo4j.ogm.config.Configuration {
return org.neo4j.ogm.config.Configuration.Builder()
.uri(neo4jUri)
.credentials(neo4jUserName, neo4jPassword).build()
}
@Bean("neo4jTransactionManager")
fun neo4jTransactionManager(): Neo4jTransactionManager {
return Neo4jTransactionManager(sessionFactory())
}
@Bean("mysqlTransactionManager")
fun mysqlTransactionManager(entityManagerFactory: LocalContainerEntityManagerFactoryBean): JpaTransactionManager {
return JpaTransactionManager(entityManagerFactory.`object`!!)
}
@Primary
@Bean("transactionManager")
fun transactionManager(neo4jTransactionManager: Neo4jTransactionManager,
mysqlTransactionManager: JpaTransactionManager): PlatformTransactionManager {
return ChainedTransactionManager(
mysqlTransactionManager,
neo4jTransactionManager
)
}
}
構成としては、JPA(MySQL)用のTransactionManager
とNeo4j用のTransactionManager
を2つを用意し、Springのコンテナから最優先で参照される(@Primary
)ときにChainedTransactionManager
として2つとも返す という作りになっています。
で、repositoryでTransactionManager
をバインドするときにどちらのmanagerが採用されるかを決めるのが、上記で決めたパッケージ、という構成になるようです。
この状態で、それぞれのrepository、entityパッケージの配下にNeo4j用、JPA(MySQL)用のrepositoryクラスとentityクラスを実装します。(JPA用の実装については先達たちがたくさん記事を書いてくださっているので割愛)
Neo4j用のEntityをつくる
とはいえ、Neo4j側もJPAとそんなに違いはありません。
まずはEntityから。最初に決めた xxx.gateway.neo4j.entity
パッケージに作成します。
package xxx.gateway.neo4j.entity
import org.neo4j.ogm.annotation.*
@NodeEntity
data class Person (
@Id
@GeneratedValue
var id: Long? = null,
var name: String? = null,
@Relationship(type = "parent")
var parents: MutableList<Person> = mutableListOf()
)
@Entity
ではなく、 @NodeEntity
となっているところがポイント。
@Id
などもNeo4jで利用できますが、クラス名が同じでもSpring Data JPA
側の実装だったりするので気を付けましょう。
特にIDEで自動補完したりしていると間違えやすいです。Neo4j
の方はorg.neo4j.ogm.annotation
パッケージの方を参照するのだと頭の片隅に置いておくとよいかもです。
Neo4j用のrepository(interface)をつくる
こちらも最初に決めたパッケージの xxx.gateway.neo4j.repository
以下に作成します。
基本的に、Neo4jRepository
をextendsするだけです。(Kotlinだと「:」表記になるのでわかりづらいですが)
package xxx.gateway.neo4j.repository
import xxx.gateway.neo4j.entity.Person
import org.springframework.data.neo4j.repository.Neo4jRepository
interface PersonRepository: Neo4jRepository<Person, Long> {
// name属性をキーにしてNodeを探す
fun findByName(name: String): List<Person>
}
つかってみる
これで、Spring Data Neo4j
でも、Spring Data JPA
と同じようにsave()
などの基本的なEntityの操作や、メソッド名から直接クエリを導出する機能なども使えちゃいます。便利。
@Autowired
private lateinit var personRepository: PersonRepository
fun findPerson(name: String): List<PersonInfo> {
// nameで検索したあと、PersonInfoというクラスのインスタンスのリストに変換して返す
return personRepository.findByName(name).map{ PersonInfo(it) }
}
おまけ
以下は Spring Data Neo4j
を使うときに困ったことと解決方法のおまけです。ご参考まで。
OGMを直接参照する
NodeやRelationshipを直接いじいじしたり、1種類のNodeだけをリストで取ってくるという場合は上記の例で十分ですが、業務的にはCypherをある程度自分で書くことになることが多いんじゃないかと思います(少なくともわたしは書きたかった)。
その場合は org.neo4j.ogm.session.Session
をDIしてあげればOKです。かんたん!
@Repository
class SomeRepositoryImpl: SomeRepository {
@Autowired
private lateinit var session: Session
override fun getSomeCustomData(params: SomeParams) {
val cypher = 'MATCH (n: SomethingNode)...'
val paramsMap = emptyMap<String, Any>()
paramsMap.add('aaa', 'bbb');
val result = session.query(cypher, paramsMap, true)
val rows = result.queryResults()
}
}
ただし、この実行結果である rows
はうまくEntityとして取得できるパターンと、そうでないパターンがあるようです。
queryForObject
メソッドだと指定したクラスのインスタンスにマッピングした状態で返してくれるのかな?と思っていたのですが、試したCypherでは1階層目だけEntityになり、2階層目以降はMapで返るという不思議パターンに…。
最終的に、Mapを地道に自前でマッピングしてオブジェクトに突っ込む処理をごりごり書きました。この辺がもうちょっと便利になっているといいなーと思うのですが…。一応最新版の関連しそうなドキュメントのURLも貼っておきます。
https://docs.spring.io/spring-data/neo4j/docs/5.2.2.RELEASE/reference/html/#reference:session:loading-entities:cypher-queries
日付型(日時)の属性の扱い
日付型の属性についても記述しておきます。
試行錯誤
- 単純にEntityの属性を
java.util.Date
にしてみたら、登録はできたけど取得時に例外が発生した -
LocalDateTime
でやっても似たようなもの - ドキュメントでは対応しているようなことを書いてあったのだが、どうやら(当時は)まだ未対応だった模様
解決方法
- 「なにも変換をしないConverter」を定義し、Entityの対象の属性にコンバータとして指定
- データの取得時は(SpringDataNeo4jで)Jacksonを使っているため、そちらにも同じように「なにも変換をしないConverter」を指定してあげる(SpringのRestサービスでよくやるJacksonの日付のフォーマット設定と同じ)
2は色んなところでサンプルがあるので省略しますが、1の内容だけ書くとこんな感じです。
class NoOpLocalDateConversion : AttributeConverter<LocalDate, LocalDate> {
override fun toGraphProperty(value: LocalDate): LocalDate {
// 特に変換はしない
return value
}
override fun toEntityAttribute(value: LocalDate): LocalDate {
// 特に変換はしない
return value
}
}
@Convert(NoOpLocalDateTimeConversion::class) // ←これ
@get:JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
var someDateTime: LocalDateTime = LocalDateTime.now()
ちなみにkotlinで @JsonFormat(...)
は @get:JsonFormat(...) と指定してあげる必要があります(ハマった)。
これで日時の属性も正常に編集・取得が行えるようになりました。
まとめ
実際に開発していたときは接続まわりがうまく動くようになるまでにだいぶ時間がかかりました。
ただしいちど動いてしまえばそのあとの機能の作りこみがぐっと楽になるので、苦労しているどこかの誰かに届くといいな。
(でも一番苦労したのは同アプリにつっこんだSpring Boot Sequrity
の設定回りだったのはひみつだよ!)