Neo4j Bolt Driver for Go: Code Reading - Bolt Protocol

本記事では、Neo4j Driver として公式サポートされている、neo4j-go-driver における Bolt Protocol の実装について見ていきます。

本記事では、v4.3 branch の最新である 5a14c7024ca3203d89d54ae34bbfbc2886249401 commit hash のソースコードを前提にしています。

Public API

Bolt に関連する公開 API としては、neo4j.ConsoleBoltLogger() があります。

セッションを開始する時に、SessionConfig に引数として渡すことで、Neo4j データベースと接続する際に発生した Bolt Protocol の通信をログ出力します。

session := driver.NewSession(neo4j.SessionConfig{
    BoltLogger: neo4j.ConsoleBoltLogger(),
})
defer session.Close()

ConsoleBoltLogger

例えば、以下は neo4j.ConsleBoltLogger() を用いて接続に成功したときの通信ログ例です。仕様で定められた通り、まずは magic バイト列 6060 B017 を送信した後、Handshake を行い、利用するプロトコルのバージョンとして 0X00000304 をサーバーが選択したことが確認できます。

2021-10-09 19:10:15.542   BOLT  C: <MAGIC> 0X6060B017
2021-10-09 19:10:15.542   BOLT  C: <HANDSHAKE> 0X00010304 0X00000104 0X00000004 0X00000003
2021-10-09 19:10:15.750   BOLT  S: <HANDSHAKE> 0X00000304

この時、クライアントが送信するプロトコルバージョンの候補は、internal/bolt/connect.go で定義されています。

// neo4j/internal/bolt/connect.go

// Supported versions in priority order
var versions = [4]protocolVersion{
    {major: 4, minor: 3, back: 1},
    {major: 4, minor: 1},
    {major: 4, minor: 0},
    {major: 3, minor: 0},
}

サーバーがプロトコルバージョンを選択した後、クライアントが直ちに HELLO リクエストメッセージを送信しています。username/password を利用した Basic 認証を用いて接続を申請した結果、サーバーから SUCCESS レスポンスメッセージが返却されていることがわかります。

2021-10-09 19:10:15.750   BOLT  C: HELLO {"credentials":"<redacted>","principal":"<hidden>","routing":{"address":"<hidden>.databases.neo4j.io:7687"},"scheme":"basic","user_agent":"Go Driver/4.3"}
2021-10-09 19:10:15.967   BOLT  S: SUCCESS {"server":"Neo4j/4.3-aura","connection_id":"bolt-999999","has_more":false}

Custom Bolt Logger

なお、SessionConfig.BoltLogger には、log.BoltLogger インターフェースを実装したオリジナルのロガーを渡すこともできます。

Bolt v4

執筆現在、neo4j-go-driver はプロトコルバージョンとして、v3/v4 の両方をサポートしています。最新のプロトコルバージョンである v4 の実装は、neo4j/internalbolt/bolt4.go に実装されています。

bolt.NewBolt4() が Bolt クライアントのインスタンスを生成しますが、*bolt4 struct は現在獲得しているトランザクションの ID や接続中のコネクションである net.Conn といった状態を保持しています。

type bolt4 struct {
    // ...
    conn                  net.Conn
    connId                string
    txId                  db.TxHandle
    // ...
    serverName            string
    serverVersion         string
    databaseName          string
    // ...
    out                   outgoing
    in                    incoming
}

func NewBolt4(serverName string, conn net.Conn, log log.Logger, boltLog log.BoltLogger) *bolt4 {
    // ...
}

クライアントがサーバーに何らかのリクエストを送信する時、outgoing 型である bolt4.out を通じて実行されます。

outgoing 型は neo4j/internal/bolt/outgoing.go で実装されています。Bolt Protocol が仕様を定義するバイト列に変換するドメインロジックとしての立ち位置になります。対応するリクエストメッセージごとに appendX()) なる関数が実装されています。

type outgoing struct {
    chunker    chunker
    packer     packstream.Packer
    // ...
}

func (o *outgoing) appendHello(hello map[string]interface{}) { /* .... */ }
func (o *outgoing) appendBegin(meta map[string]interface{}) { /* .... */ }
func (o *outgoing) appendCommit() { /* .... */ }
func (o *outgoing) appendRollback() { /* .... */ }
func (o *outgoing) appendRun(cypher string, params, meta map[string]interface{}) { /* .... */ }
// ...

実際のバイト列の Encode/Decode の処理は、後述する packstream.Packer 型である outoing.packer に移譲されています。

バイト列の Encode/Decode

Bolt Protocol は独自の仕様を定義した TCP 上のネットワーク・プロトコルになるので、仕様に定められたとおりにバイト列を Encode/Decode してあげる必要が有ります。関連処理は、neo4j/internal/packstream/{packer,unpacker}.go で実装されています。

仕様は、メッセージ用の Bolt Protocol Message Specification とは別の、PackStream Specification として分離して策定されており、執筆現在最新のメジャーバージョンは v1 です。

エンコーディングは packer.go で、デコーディングは unpacker.go で実装されています。実際は、Go の標準ライブラリである encoding/binary を利用しています。

例)HELLO Request Message

試しに、HELLO リクエストメッセージを送る一連の処理過程を眺めてみましょう。

まず、先述したとおり、outgoing 型の appendHello() 関数が呼ばれます。

// neo4j/internal/bolt/outgoing.go

func (o *outgoing) appendHello(hello map[string]interface{}) {
    //...
	o.begin()
	o.packer.StructHeader(byte(msgHello), 1)
	o.packMap(hello)
	o.end()
}

まずは、o.packer.StructHeader(byte(msgHello), 1) の説明です。

ここでは、リクエストメッセージが HELLO であることを伝えるため、バイト列にメッセージの種類をヘッダーとして伝えます。v4.3 の仕様 HELLO リクエストメッセージの Signature は 01 です。msgHello は、別ファイルで定義されていますが、0x01 となっていることが確認できます。

// neo4j/internal/bolt/messages.go

// Message struct tags
// Shared between bolt versions
const (
    //...
	msgHello      byte = 0x01
    //...
)

次に、o.packMap(hello) の説明です。

HELLO リクエストメッセージは、任意の key/value をフィールドとして送ることが仕様で許されています。具体的には、user_agent/schema/credentials などです。そういった任意のフィールドが map[string]interface{} で渡されているので、それを Map 型としてエンコードしてあげるだけです。

なお、Packstream Specification 上は、Dictionay として仕様が策定されています。

// neo4j/internal/bolt/outgoing.go

func (o *outgoing) packMap(m map[string]interface{}) {
	o.packer.MapHeader(len(m))
	for k, v := range m {
		o.packer.String(k)
		o.packX(v)
	}
}

ここで、packX() は長いので割愛しますが、一言でいうと reflection を活用しながら value の型に応じて適切にエンコードをしてあげるためのメソッドです。

最後に

以上、neo4j-go-driver の Bolt Protocol の実装についての紹介を行いました。

2021-10-11