TinkerPop でグラフ操作 - Groovy

前回、Neo4j の Cypher を使って実施したグラフ操作を Apache TinkerPop を使って Groovy (@Grab を使用)で実装してみました。

Apache TinkerPop はグラフ処理のためのフレームワークで、Neo4j 等の様々なグラフ DB ※ に対して共通のインターフェースを提供します。

 ※ グラフ DB だけではなく、
    Cassandra、HBase、DynamoDB 等をサポートする
    ライブラリも提供されています

ソースは http://github.com/fits/try_samples/tree/master/blog/20170718/

a. 設定ファイル

TinkerPop にはインメモリーの TinkerGraph が用意されていますが、前回と同様に Neo4j を使う事にします。

ただ、前回と違って Neo4j をサーバー起動せずに組み込み実行します。

TinkerPop には Graph オブジェクトを汎用的に生成する手段として org.apache.tinkerpop.gremlin.structure.util.GraphFactory が用意されています。

GraphFactory.open(<設定ファイル>) を使えば、依存ライブラリと設定ファイルを差し替えて DB を切り替える事もできそうなので、今回はこの方法を使います。

Neo4j を組み込み利用する場合、gremlin.graphorg.apache.tinkerpop.gremlin.neo4j.structure.Neo4jGraph を設定して gremlin.neo4j.directory に DB ファイルを出力するディレクトリを指定します。 (Neo4j をサーバー起動した場合の data/databases/graph.db)

setting.properties
gremlin.graph=org.apache.tinkerpop.gremlin.neo4j.structure.Neo4jGraph
gremlin.neo4j.directory=neo4jdb

b. グラフデータ作成

前回 と同様のデータを作成する処理を実装してみます。

Groovy で実行する際の注意点として、neo4j-tinkerpop-api-impl 等は依存ライブラリとして Groovy 2.4.11 のライブラリを含んでおり、このバージョン以外の groovy コマンドで実行すると org.codehaus.groovy.control.MultipleCompilationErrorsException が発生してしまいます。

そこで今回は、Groovy 2.5.0 beta1 で実行できるように @GrabExclude を使って groovy-xml 等を除くようにしています。

グラフの操作は GraphFactory.open() で取得した Graph オブジェクトに対して実施します。

ノードの追加は addVertex、エッジの追加は addEdge メソッドで行う事ができ、property メソッドで任意の属性を設定できます。

トランザクションtx メソッドで開始します。※

 ※ TinkerGraph のように tx メソッドをサポートしていないものもありますので
    (その場合に tx() を呼び出すとエラーになる)
    実際は tx のサポート有無をチェックしてから
    呼び出すようにした方が安全だと思います

    (例)
        if (g.features().graph().supportsTransactions()) {
            g.tx().withAutoCloseable { t ->
                ・・・
            }
        }
add-data.groovy
@Grapes([
    @Grab('org.apache.tinkerpop:neo4j-gremlin:3.2.5'),
    @Grab('org.neo4j:neo4j-tinkerpop-api-impl:0.6-3.2.2'),
    @Grab('org.slf4j:slf4j-nop:1.7.25'),
    @GrabExclude('org.codehaus.groovy:groovy-xml'),
    @GrabExclude('org.codehaus.groovy:groovy-swing'),
    @GrabExclude('org.codehaus.groovy:groovy-jsr223')
])
import org.apache.tinkerpop.gremlin.structure.util.GraphFactory

def conf = args[0]

def addNode = { g, type, id, name = id ->
    def res = g.addVertex(type)

    res.property('oid', id)
    res.property('name', name)

    res
}

def createData = { g -> 
    def p = addNode(g, 'Principals', 'principals')

    def u1 = addNode(g, 'User', 'user1')
    def u2 = addNode(g, 'User', 'user2')
    def ad = addNode(g, 'User', 'admin')

    def g1 = addNode(g, 'Group', 'group1')

    [u1, u2, ad, g1].each {
        it.addEdge('PART_OF', p)
    }

    u2.addEdge('PART_OF', g1)

    def r = addNode(g, 'Resources', 'resources')

    def s1 = addNode(g, 'Service', 'service1')

    def s2 = addNode(g, 'Service', 'service2')
    def s2o1 = addNode(g, 'Operation', 'service2.get', 'get')
    def s2o2 = addNode(g, 'Operation', 'service2.post', 'post')

    [s2o1, s2o2].each {
        s2.addEdge('METHOD', it)
    }

    [s1, s2].each {
        r.addEdge('RESOURCE', it)
    }

    u1.addEdge('PERMIT', s1)
    g1.addEdge('PERMIT', s2o2)
    ad.addEdge('PERMIT', r)
}

GraphFactory.open(conf).withAutoCloseable { g ->
    g.tx().withAutoCloseable { tx ->
        createData(g)

        tx.commit()
    }
}

withAutoCloseableAutoCloseable のリソースをクローズするための Groovy の機能です(TinkerPop の API ではありません)

実行結果
> groovy add-data.groovy setting.properties

c. 経路の探索

前回 の経路探索の処理を TinkerPop の API で実装してみます。

traversal().V() でノードを対象とした GraphTraversal<Vertex,Vertex> を取得でき、has(<プロパティ名>, <プロパティ値>) 等を使ってノードの条件を指定できます。

(c-1) 複数エッジ(条件なし)

まず、エッジは気にせずに指定ノードから指定ノードまでのパス(経路)を取得する処理を実装してみます。

終点のノードまで複数のノードで繋がっている場合は repeat(・・・).until(<終点ノードの条件>) で探せます。

エッジの条件を指定しない場合は repeat(__.out()).until(・・・) で取得できます。(__ はクラス名です)

パスを取得するには path() を使います。 Path からは objects() でパスに含まれるノード(やエッジ)を List<Object> で取得できます。

なお、以下の処理でトランザクションは必要ありませんが、一応トランザクションを使っています。 (JanusGraph をマルチスレッドで使うケースではトランザクションが必要になったので)

find-data-simple.groovy
@Grapes([
    @Grab('org.apache.tinkerpop:neo4j-gremlin:3.2.5'),
    @Grab('org.neo4j:neo4j-tinkerpop-api-impl:0.6-3.2.2'),
    @Grab('org.slf4j:slf4j-nop:1.7.25'),
    @GrabExclude('org.codehaus.groovy:groovy-xml'),
    @GrabExclude('org.codehaus.groovy:groovy-swing'),
    @GrabExclude('org.codehaus.groovy:groovy-jsr223')
])
import org.apache.tinkerpop.gremlin.structure.util.GraphFactory
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__

def conf = args[0]
def start = args[1]
def end = args[2]

def toStr = {
    "${it.label()}[${it.id()}]{${it.properties().join(', ')}}"
}

GraphFactory.open(conf).withAutoCloseable { g ->
    g.tx().withAutoCloseable {
        def p = g.traversal().V()
            .has('oid', start) // 始点ノードの条件
            .repeat(__.out())
            .until(__.has('oid', end)) // 終点ノードの条件
            .path()

        p.each {
            println it.objects().collect(toStr).join(' -> ')
        }
    }
}
実行結果

user2 から service2.post へのパスを取得してみます。

> groovy find-data-simple.groovy setting.properties user2 service2.post

User[2]{vp[oid->user2], vp[name->user2]} -> Group[4]{vp[oid->group1], vp[name->group1]} -> Operation[9]{vp[oid->service2.post], vp[name->post]}

__.out() を使った事で、パスにはノードの情報だけが含まれエッジの情報を含んでいません。

(c-2) 複数エッジ(条件あり)

エッジの条件を指定するには repeat でエッジも指定します。

A ノードと B ノードが C エッジで繋がっている (A)-[C]->(B) のような状態で、A の __.outE() が C エッジで、C エッジの inV() が B ノードとなります。

そのため、repeat(__.outE().as('e').inV()) とすれば、複数の外向きエッジで繋がっているノードを検索する事ができます。

ここで as(<ステップラベル名>) を使って、該当するエッジにラベル名を付けておき、where での条件判定(PERMIT エッジを含むかどうか)に使います。

__.select('e') の結果は GraphTraversal<Vertex, ArrayList<Edge>> のようになるので __.select('e').hasLabel(・・・) とはできません。 (ClassCastException になります)

unfold() で GraphTraversal<Vertex, ArrayList> を GraphTraversal<Vertex, Edge> にして hasLabel を使えば、該当のラベルを持つエッジを含んでいるかどうかを条件判定できます。

find-data.groovy
・・・
GraphFactory.open(conf).withAutoCloseable { g ->
    g.tx().withAutoCloseable {
        def p = g.traversal().V()
            .has('oid', start) // 始点ノードの条件
            .repeat(__.outE().as('e').inV())
            .until(__.has('oid', end)) // 終点ノードの条件
            .where(__.select('e').unfold().hasLabel('PERMIT')) // PERMIT エッジを含んでいるかどうかの判定
            .path()

        p.each {
            println it.objects().collect(toStr).join(' -> ')
        }
    }
}
実行結果
> groovy find-data.groovy setting.properties user2 service2.post

User[2]{vp[oid->user2], vp[name->user2]} -> PART_OF[4]{} -> Group[4]{vp[oid->group1], vp[name->group1]} -> PERMIT[10]{} -> Operation[9]{vp[oid->service2.post], vp[name->post]}

__.outE() を使った事でエッジの情報もパスに含まれるようになりました。