シリアル通信でRPC通信をするためのライブラリ Kuda を Go言語で作った

はじめに

この記事は私が作成したシリアル通信でRPCをするためのライブラリ Kuda についての紹介記事です。

github.com

経緯

シリアル通信でRPC通信できるのかという技術的興味に端を発したライブラリ。

RPCを使うようにした理由は、ファイル転送以外にも色々なデータをやり取りできるように作った方が今後の展開として面白そうだなと思ったのと、シリアル通信部分をユーザーにできるだけ意識させないように作りたかったからです。

使い方

シリアル通信で機器同士を接続してもらったうえで、以下のサーバーサイドのコード、クライアントサイドのコードをそれぞれの機器で実行します。

以下の例では、サーバー側が Linux、クライアント側が Windows となっています。

サーバー側コード

package main

import (
    "context"
    "net/http"

    "github.com/gorilla/rpc/v2"
    "github.com/gorilla/rpc/v2/json2"

    "kuda"
)

type (
    Calculator   struct{}
    AdditionArgs struct {
        Add, Added int
    }
    AdditionResult struct {
        Computation int
    }
)

func (c Calculator) Add(r *http.Request, args *AdditionArgs, result *AdditionResult) error {
    result.Computation = args.Add + args.Added
    return nil
}

func main() {
    s := rpc.NewServer()
    s.RegisterCodec(json2.NewCodec(), "application/json")
    calculator := &Calculator{}
    s.RegisterService(calculator, "")

    kuda.Serve("/dev/ttyGS0", s)
}

クライアント側コード

package main

import (
    "kuda"
    "log"
)

type (
    Calculator   struct{}
    AdditionArgs struct {
        Add, Added int
    }
    AdditionResult struct {
        Computation int
    }
)

func main() {
    client := kuda.Client{
        PortName: "COM9",
    }

    response, err := client.Call("Calculator.Add", &AdditionArgs{Added: 10, Add: 12})
    if err != nil {
        log.Fatalln(err)
    }
    var result AdditionResult
    err = response.GetObject(&result)
    if err != nil {
        log.Fatalln(err)
    }
    log.Printf("10 + 12 = %d", result.Computation)
}

見ていただいた通り、 gorilla/rpc で一度でも RPCしたことがある人であれば、結構すんなり読めるんじゃないでしょうか。

通常であれば、 http のサーバーライブラリを使いますが、その部分を kuda で置き換えしています。

これができるのも、 net/http の抽象化が強力だからですね。 Go言語の素晴らしいところを享受させてもらいました。

内部実装

gorilla/rpc をシリアル通信で接続するために

zenn.dev

こちらの例にある通り、 gorilla/rpc の RPCサーバーを http.Handle() に http.Handler として渡すことで http サーバーを経由して要求が RPCサーバーに渡ってくるようになります。なので、httpサーバーがやっていることを疑似的に再現してあげればシリアル通信でもRPC通信ができるようになるはずです。

http.ListenAndServe() でサーバーが起動します。HTTPで要求がきたら、内部でごにょごにょして、http.Handler() で登録した http.Handler インターフェースの ServeHTTP() を呼び出します。要は、http.Handler の インターフェースの ServeHTTP() を呼び出せれば RPC通信ができるようになります。 ListenAndServe() の詳しい処理の内容は以下のブログが詳しいかと思います。ご興味のある方はこちらを参考にされると良いかと思います。このブログでは本筋からそれるのでこれ以上は扱いません。

zenn.dev

ServerHTTPの定義は以下のようになっています。第一引数に http.ResponseWriter, 第二引数に *http.Request を取ります。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

http.ResponseWriter はインターフェースで 以下のメソッドを実装していなければなりません。今回はこちらを自作する必要がありました。

type ResponseWriter interface {
    Header() http.Header

    Write([]byte) (int, error)

    WriteHeader(statusCode int)
}

Header() http.Header および WriteHeader(statusCode int) は HTTPのヘッダー生成に関連する箇所で今回は使用しませんが、 Header() メソッドで http.Header を返してあげないと gorilla/rpc でエラーとなります。 http.Header は map[string][]string のエイリアスなので、空のmapを返すことで対処します。本題は Write([]byte) (int, error) ですが、渡されるバイト列は json なので それをそのままシリアル回線に流すようにすればOKです。そういう考えで作った自作 ResponseWriter が以下の通り。構造体のメンバーの writer がシリアル通信を担う構造体です。

type response struct {
    writer io.Writer
}

func (r *response) Header() http.Header {
    header := make(map[string][]string, 0)
    return header
}

func (r *response) Write(data []byte) (int, error) {
    n, err := r.writer.Write(data)
    if err != nil {
        r.err = err
    }
    return n, nil
}

func (r *response) WriteHeader(statusCode int) {
}

Henader インターフェースの定義の ServerHTTP の引数に戻って、次は第二引数の http.Request を作成します。こちらは、http.NewRequest() で作成することができます。渡す引数は、第一引数に HTTPメソッド、第二引数に URL、第三引数に body を表す io.Reader インターフェースを実装した構造体となります。 gorilla/rpc の ServeHTTP は HTTPメソッドが POST でないと受け付けてくれません。URLはシリアル通信では不要なので空文字とし、io.Reader には bytes.Buffer で受信したバイト列を包んで渡します。

body := &bytes.Buffer{}
/* この間で シリアル通信からデータを受信して body に詰める */
request, _ := http.NewRequest("POST", "", body)
/* ここで responseWriter を作成する */
handler.ServeHTTP(responseWriter, request)

シリアル通信ライブラリ

github.com

シリアル通信ライブラリは bugst/go-serial を使用しています。

ポートオープンの仕方はこんな感じでいけます。

mode := &serial.Mode{
    BaudRate: 115200,
}
port, err := serial.Open("/dev/ttyUSB0", mode)
if err != nil {
    log.Fatal(err)
}

Read / Write はこんな感じ

n, err := port.Write([]byte("10,20,30\n\r"))
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Sent %v bytes\n", n)

buff := make([]byte, 100)
for {
    n, err := port.Read(buff)
    if err != nil {
        log.Fatal(err)
        break
    }
    if n == 0 {
        fmt.Println("\nEOF")
        break
    }
    fmt.Printf("%v", string(buff[:n]))
}

これを Kuda ライブラリの中で使用しています。

通信プロトコル

単純にシリアルライブラリとサーバー部分をくっつけるだけでも動くんですがデータ量が多い場合、バッファサイズを超えてしまって正しく送受信できません。なので、データ量が多い場合は分割して送信するようにしてます。分割サイズは 1024 バイトにしてます。次のデータを送信するかどうかは ACK が受信できたかどうかで判別します。

送受信の方法

これだと受信側で終わりがわからないので、データフォーマットを以下のようにしています。

データフォーマット

ACK応答も同じフォーマットで送信しています。

ACK応答

もしかすると、エラー応答用に Status フィールドをヘッダー部分に追加することでより汎用性のあるプロトコルになるかもですが、今のところはシンプル設計にしてます。

まとめ

シリアル通信で RPC通信 をするための 自作ライブラリ Kuda についての紹介記事を書きました。

みなさんも、これを機にシリアル通信で RPCしてみてもいいかもしれませんし

トランスポート層を挿げ替えていろんなものと通信させてみても面白いかもしれませんね。