C#のライブラリ vs-streamjsonrpc とGoの標準添付パッケージ jsonrpc を通信させる方法
前回 Go と C# を名前付きパイプでつなぐ - bamch0h’s diary という記事を書いた。 その記事のまとめにも書いたように、RPCで通信できるのか興味があったので調べてみた 意外と大変だったのでブログに残しておくことにする。
TL;DR
- C#側のライブラリとして
Microsoft/vs-streamjsonrpc
を使ってみた - Go側は標準添付のJSONRPCパッケージだと通信しない。
- 理由は
vs-streamjsonrpc
が送受信にヘッダとしてContent-Length
を要求するから - なので、JSONRPCのコーデックに渡すnet.Connをラップしてあげるとよい
Go側のパッケージ
Go側は標準のパッケージを使用することにした。参考にしたのは以下の記事
Microsoft/go-winio
で Go同士をjsonrpcでつながることを確認して次の工程へ
C#側のライブラリ
C# jsonrpc
とかで検索すると JSON-RPC.NET
がトップに出てくるけど、あまり良い印象がないのと Microsoft 公式のリポジトリということで以下のライブラリを選択した。
サンプル実装は以下のリポジトリが紹介されていた。
ただ、サンプルをそのまま使ってもGo言語とは通信できない。理由はいくつかあり、以下の通り。
- このライブラリがjsonデータを送信する前にヘッダ情報として
Content-Length: <jsonデータ長>\r\n\r\n
を送信するから - Goの標準ライブラリは
params
を配列で来ているものとみなして1番目の要素のみを取得するが、送信されてきたデータはmap
なのでパースエラーとなってしまう - 1番目の内容とも絡む話だが、Go側からjsonデータを送信する前に
Content-Length
を送信してあげないとC#ライブラリ側でちゃんと受信してくれない
諸問題を解決する
の問題に対応するため、Go側でヘッダ情報をパースする。といっても、今回は
vs-streamjsonrpc
と通信する。という割り切りの元、Read待ちしているときの初めのパケットを捨てる。という対応で何とかしのぐことにする。の問題はGo側で対処するよりも、C#側で対処したほうがやりやすい。Go側の引数と同じフィールドを持つクラスを作成し、それを引数として送信してあげることにする。
の問題はGo側でjsonデータを送信する前に割り込んで
Content-Length
を送信する必要がある。だが、jsonデータの受信から送信までの流れをServeCodec()
が一手に引き受けているため外側からはどうにもできない。そこで、案が2つあり、一つはServeCodec()
に渡す引数のrpc.ServerCodec
を新たに作成し、jsonデータを送信する前にContent-Length
を送信するように修正する。というもの。もう一つは、jsonrpc.NewServerCodec()
に渡すio.ReadWriteCloser
をラップしてWrite()
が呼ばれたときに、実データを送る前にContent-Length
を送信する。というもの。前者は別の理由(JSONRPCのバージョン情報である、jsonrpc:
キーをjson内に含めたいという理由)で修正を行っていたが、標準ライブラリのほとんどをコピーしてこなければならず、あまり筋の良い方法だとは思えなかったので、後者に切り替えた。(こっちもWrite()する前に必ずContent-Length
を送信するので、それはそれでどうなんだろう? とも思ったりする)
Go側の実装
というわけで以下のように実装した
package main import ( "fmt" "log" "net" "net/rpc" "net/rpc/jsonrpc" "time" "github.com/Microsoft/go-winio" ) // --- ラッパー部分 ---- // Accept() で ラップした net.Conn を返したいのでwinioのListenerもラップする type localWin32PipeListener struct { l net.Listener } func (l *localWin32PipeListener) Accept() (net.Conn, error) { conn, err := l.l.Accept() if err != nil { return nil, err } // Accept()で取得したconnをラップして返す return &wrappedConn{conn}, nil } func (l *localWin32PipeListener) Close() error { return l.l.Close() } func (l *localWin32PipeListener) Addr() net.Addr { return l.l.Addr() } // net.Conn をラップした構造体 type wrappedConn struct { c net.Conn } func (c *wrappedConn) Read(b []byte) (n int, err error) { var m int tmp := make([]byte, 65535) m, err = c.c.Read(tmp) if err != nil { return 0, err } newline := 0 for i := 0; i < m; i++ { // Content-Lengthを受信している前提で読み飛ばす if tmp[i] == '\n' { newline++ continue } // body を buffer に詰め込む。bufferは512バイトなので // 受信データがそれ以上あるとコケる。 // ここら辺は後で実装を詰める必要あり if newline >= 2 { for j := 0; j < len(tmp[i:m]); j++ { b[j] = tmp[i+j] } return len(tmp[i:m]), nil } } return 0, nil } func (c *wrappedConn) Write(b []byte) (n int, err error) { // 標準パッケージでは jsonrpc 2.0 用のバージョンが付加されないので // 無理やりつける // これがないと、クライアント側で受け取ってくれない prefix := []byte(fmt.Sprintf("{\"jsonrpc\":\"2.0\",")) body := append(prefix, b[1:]...) // 実データを書き込む前に常にContent-Lengthを送信する header := []byte(fmt.Sprintf("Content-Length: %v\r\n\r\n", len(body))) fmt.Print(string(header)) fmt.Print(string(body)) return c.c.Write(append(header, body...)) } func (c *wrappedConn) Close() error { return c.c.Close() } func (c *wrappedConn) LocalAddr() net.Addr { return c.c.LocalAddr() } func (c *wrappedConn) RemoteAddr() net.Addr { return c.c.RemoteAddr() } func (c *wrappedConn) SetDeadline(t time.Time) error { return c.c.SetDeadline(t) } func (c *wrappedConn) SetReadDeadline(t time.Time) error { return c.c.SetReadDeadline(t) } func (c *wrappedConn) SetWriteDeadline(t time.Time) error { return c.c.SetWriteDeadline(t) } // ---- 本体 ---- var pipename = `\\.\\pipe\winiotestpipe` type ( TestRPC struct{} RPCArgs struct { A, B int } ) func (t *TestRPC) Add(args *RPCArgs, reply *int) error { fmt.Println("args.A", args.A) fmt.Println("args.B", args.B) *reply = args.A + args.B fmt.Println("Result", *reply) return nil } func main() { s := rpc.NewServer() t := &TestRPC{} s.Register(t) l, err := winio.ListenPipe(pipename, nil) if err != nil { log.Fatal(err) } defer l.Close() // Listener をラップする ll := &localWin32PipeListener{l} conn, err := ll.Accept() if err != nil { log.Fatal(err) } defer conn.Close() s.ServeCodec(jsonrpc.NewServerCodec(conn)) }
C#側の実装
rpc.InvokeAsync()
を実行するときの引数の指定に注意。それ以外はデフォルトで通信する。(Go側で頑張ってるからね)
using System; using System.Threading.Tasks; using System.IO.Pipes; using StreamJsonRpc; namespace StreamJsonRpcServer { // 引数指定用のクラスを定義する public class Add { public int A; public int B; } class Program { static async Task Main(string[] args) { await MainAsync(); } static async Task MainAsync() { using (var stream = new NamedPipeClientStream(".", "winiotestpipe", PipeDirection.InOut, PipeOptions.Asynchronous)) { await stream.ConnectAsync(); var rpc = JsonRpc.Attach(stream); // メソッド名は TestRPC.Add とすること // 引数をクラスで指定する int sum = await rpc.InvokeAsync<int>("TestRPC.Add", new Add { A = 3, B = 5 }); Console.WriteLine($"3+5={sum}"); } } } }
まとめ
C# の vs-streamjsonrpc
と Go の標準ライブラリ jsonrpc
をラップしてnet.Conn経由して通信するようにした。
vs-streamjsonrpc がなぜ Content-Length を要求するのかがわからないが、何か理由がるのだろう。ただ、その部分で汎用性が低くなっているのでは?という気がする。使用したvs-streamjsonrpc のバージョンが1.4
だったのだが、github上では2.0 betaがあったので、もしかしたらそのバージョンではより簡単に通信できるようになっているかもしれない。また、2.0 beta では 自由にハンドラを変更できるようになっているので、2.0がリリースされたら高速なハンドラを使用して通信できるようにするのも楽しいかもしれない。