C#のライブラリ vs-streamjsonrpc とGoの標準添付パッケージ jsonrpc を通信させる方法

前回 Go と C# を名前付きパイプでつなぐ - bamch0h’s diary という記事を書いた。 その記事のまとめにも書いたように、RPCで通信できるのか興味があったので調べてみた 意外と大変だったのでブログに残しておくことにする。

TL;DR

  1. C#側のライブラリとして Microsoft/vs-streamjsonrpc を使ってみた
  2. Go側は標準添付のJSONRPCパッケージだと通信しない。
  3. 理由はvs-streamjsonrpcが送受信にヘッダとしてContent-Lengthを要求するから
  4. なので、JSONRPCのコーデックに渡すnet.Connをラップしてあげるとよい

Go側のパッケージ

Go側は標準のパッケージを使用することにした。参考にしたのは以下の記事

medium.com

Microsoft/go-winio で Go同士をjsonrpcでつながることを確認して次の工程へ

C#側のライブラリ

C# jsonrpc とかで検索すると JSON-RPC.NET がトップに出てくるけど、あまり良い印象がないのと Microsoft 公式のリポジトリということで以下のライブラリを選択した。

github.com

サンプル実装は以下のリポジトリが紹介されていた。

github.com

ただ、サンプルをそのまま使ってもGo言語とは通信できない。理由はいくつかあり、以下の通り。

  1. このライブラリがjsonデータを送信する前にヘッダ情報として Content-Length: <jsonデータ長>\r\n\r\n を送信するから
  2. Goの標準ライブラリはparamsを配列で来ているものとみなして1番目の要素のみを取得するが、送信されてきたデータはmapなのでパースエラーとなってしまう
  3. 1番目の内容とも絡む話だが、Go側からjsonデータを送信する前にContent-Lengthを送信してあげないとC#ライブラリ側でちゃんと受信してくれない

諸問題を解決する

  1. の問題に対応するため、Go側でヘッダ情報をパースする。といっても、今回は vs-streamjsonrpc と通信する。という割り切りの元、Read待ちしているときの初めのパケットを捨てる。という対応で何とかしのぐことにする。

  2. の問題はGo側で対処するよりも、C#側で対処したほうがやりやすい。Go側の引数と同じフィールドを持つクラスを作成し、それを引数として送信してあげることにする。

  3. の問題は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がリリースされたら高速なハンドラを使用して通信できるようにするのも楽しいかもしれない。