Golangでnet/rpc over Unix domain socketのハンズオン
この記事はLOCAL Students Advent Calendar 2021の1日目の記事です。 adventar.org
はじめに
Golangのnet/rpc
パッケージを使って、Unix domain socket上でRPCを行うサンプルを書いてみたので、これについて説明します。
デーモンとして動くプロセスをclientとなるCLIツールから操作したい場合に便利そうですね。
実装したRPCサーバ・クライアントは以下のように動作します。
$ ./go-daemon-sample daemon & # RPCサーバをバックグラウンドで起動 [1] 32660 $ ./go-daemon-sample client add 3 5 # Add(3, 5)を呼び出し Add(3, 5) = 8 $ ./go-daemon-sample client sub 7 2 # Sub(7, 2)を呼び出し Sub(7, 2) = 5 $ kill 32660 # RPCサーバのプロセスをkill [1] + terminated ./go-daemon-sample daemon $ ./go-daemon-sample client add 3 5 # RPCサーバが生きていないのでエラー 2021/11/30 22:01:05 client.go:19: dial unix /xxxxxx/go-daemon-sample.sock: connect: connection refused
コードはこちらです。 github.com
まずは生のUnix domain socketを使ってみる
まずはnet/rpc
は使わずに、生のUnix domain socketを使ってEchoサーバを実装してみます。
コードは以下で用意できます。
$ git clone git@github.com:w-haibara/go-daemon-sample.git
$ git reset --hard 700d5b76a0
実行すると以下のようになります。 クライアントがサーバにメッセージを送り、それと同じものをサーバが返していることがわかります。
$ go build $ ./go-deamon-sample deamon & [1] 38324 $ ./go-deamon-sample client 2021/11/30 23:51:08 client.go:22: SEND: hello 2021/11/30 23:51:08 deamon.go:39: REQ:hello 2021/11/30 23:51:08 deamon.go:45: SEND:hello 2021/11/30 23:51:08 client.go:33: RESP: hello
パッケージの構造は以下のようになっています。
(この段階ではdaemon
をdeamon
とtypoしてますね……ご了承くださいませ……)
$ tree
.
├── LICENSE
├── README.md
├── client
│ └── client.go
├── cmd
│ ├── clientCommand.go
│ ├── daemonCommand.go
│ └── root.go
├── daemon
│ └── deamon.go
├── go-deamon-sample
├── go.mod
├── go.sum
├── main.go
└── util
└── util.go
cobraを使ったCLIとしての実装の説明は省略しますが、
コマンドclient-go-sample deamon
が実行された時にはdeamon
パッケージのDo()
関数が呼ばれ、
コマンドclient-go-sample client
が実行された時にはclient
パッケージのDo()
関数が呼ばれます。これらの関数は以下のようになっています。
deamon.go
(サーバ側)
package deamon import ( "bufio" "go-deamon-sample/util" "io" "log" "net" "os" ) func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } func Do() { os.Remove(util.UnixDomainPath) listener, err := net.Listen("unix", util.UnixDomainPath) if err != nil { panic(err) } defer listener.Close() for { conn, err := listener.Accept() if err != nil { panic(err) } go func() { defer conn.Close() r := bufio.NewReader(conn) line, err := r.ReadBytes(byte('\n')) if err != nil && err != io.EOF { log.Println("[ERROR]", err) return } log.Printf("REQ:%s\n", line) if _, err := conn.Write(line); err != nil { log.Println("[ERROR]", err) return } log.Printf("SEND:%s\n", line) }() } }
client.go
(クライアント側)
package client import ( "bufio" "go-deamon-sample/util" "io" "log" "net" ) func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } func Do() { conn, err := net.Dial("unix", util.UnixDomainPath) if err != nil { panic(err) } msg := []byte("hello\n") log.Printf("SEND: %s\n", msg) if _, err := conn.Write(msg); err != nil { panic(err) } r := bufio.NewReader(conn) line, err := r.ReadBytes(byte('\n')) if err != nil && err != io.EOF { log.Fatalln("[ERROR]", err) } log.Printf("RESP: %s\n", line) }
ここでutil.UnixDomainPath
は以下のように定義しています。
var ( UnixDomainPath = filepath.Join(os.TempDir(), "go-deamon-sample.sock") )
これでUnix domain socketを使ったプロセス間通信の確認ができました。
RPC over Unix domain socket
次はnet/rpc
を使ってRPCをやってみます。
コードは以下で用意できます。
$ git pull
$ git reset --hard ab4c98e705
実行すると以下のようになります。
サーバ側の関数 (Add()
・Sub()
)をクライアントから呼び、結果を受け取っています。
$ go build $ ./go-deamon-sample deamon & [2] 39329 $ ./go-deamon-sample client Func.Add(7, 8) = 15 Func.Sub(7, 8) = -1
ここでは先ほどのdeamon.go
・client.go
を以下のように更新しています。
deamon.go
(サーバ側)
package deamon import ( "go-deamon-sample/util" "log" "net" "net/http" "net/rpc" "os" ) func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } type Func struct { } type Args struct { A, B int } type Reply struct { C int } func (f *Func) Add(args *Args, reply *Reply) error { reply.C = args.A + args.B return nil } func (f *Func) Sub(args *Args, reply *Reply) error { reply.C = args.A - args.B return nil } func Do() { f := new(Func) if err := rpc.Register(f); err != nil { log.Fatalln(err) } os.Remove(util.UnixDomainPath) l, err := net.Listen("unix", util.UnixDomainPath) if err != nil { log.Fatalln(err) } defer l.Close() rpc.HandleHTTP() if err := http.Serve(l, nil); err != nil { log.Fatalln(err) } }
サーバ側の肝はrpc.Register(f)
としている部分です。
ここでFunc
型のインスタンスであるf
をRegister()
関数に渡すことで、Func
型が持つメソッドをRPCで呼び出せるように登録されます。
登録できる関数の制約についてはGoDocに記載があります。
rpc package - net/rpc - pkg.go.dev
ここではFunc.Add()
とFunc.Sub()
が登録されています。
client.go
(クライアント側)
package client import ( "fmt" "go-deamon-sample/deamon" "go-deamon-sample/util" "log" "net/rpc" ) func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } func Do() { client, err := rpc.DialHTTP("unix", util.UnixDomainPath) if err != nil { log.Fatalln("dialing:", err) } args := &deamon.Args{ A: 7, B: 8, } reply := &deamon.Reply{} if err = client.Call("Func.Add", args, &reply); err != nil { log.Fatalln("func error:", err) } fmt.Printf("Func.Add(%d, %d) = %d\n", args.A, args.B, reply.C) if err = client.Call("Func.Sub", args, &reply); err != nil { log.Fatalln("func error:", err) } fmt.Printf("Func.Sub(%d, %d) = %d\n", args.A, args.B, reply.C) }
サーバで登録した関数をクライアントから呼び出すには、Call()
関数を使います。
Call()
関数の第1引数には呼び出す関数名を与えます。
これでRPC over Unix domain socketができました。
Client単体でも動くようにしてみる
おまけです。ここまでの例では当然RPCサーバを起動していないと、クライアントの実行は失敗します。 ここでクライアントだけでもRPCに登録された関数が実行ができるように改良してみます。
クライアントを実行する際に--standalone
オプションを付けると、クライアント単体で動作するようにしました。
$ ./go-deamon-sample client 2021/12/01 02:02:35 client.go:27: dial unix /xxxxx/go-deamon-sample.sock: connect: connection refused $ ./go-deamon-sample client --standalone Add(7, 8) = 15 Sub(7, 8) = -1
コードはこちらです。
GitHub - w-haibara/go-daemon-sample at 6499a7a862d252fadc2ba6c84bd897932516e781
client.go
package client import ( "fmt" "go-deamon-sample/service" "log" ) func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } func Do(standalone bool) { args := &service.Args{ A: 7, B: 8, } reply := &service.Reply{} var f service.Func if standalone { f = service.NewRawFunc() } else { var err error f, err = service.NewRPCFunc() if err != nil { log.Fatalln(err) } } if err := f.Add(args, reply); err != nil { log.Fatalln("func error:", err) } fmt.Printf("Add(%d, %d) = %d\n", args.A, args.B, reply.C) if err := f.Sub(args, reply); err != nil { log.Fatalln("func error:", err) } fmt.Printf("Sub(%d, %d) = %d\n", args.A, args.B, reply.C) }
service.Func
interfaceを定義して、その実装としてservice.RawFunc
型・service.RPCFunc
型を用意しています。
sample.go
package service type Args struct { A, B int } type Reply struct { C int } type Func interface { Add(*Args, *Reply) error Sub(*Args, *Reply) error }
raw.go
package service type RawFunc struct { } func NewRawFunc() Func { rawfunc := RawFunc{} return Func(&rawfunc) } func (f *RawFunc) Add(args *Args, reply *Reply) error { reply.C = args.A + args.B return nil } func (f *RawFunc) Sub(args *Args, reply *Reply) error { reply.C = args.A - args.B return nil }
rpc.go
package service import ( "go-deamon-sample/util" "net/rpc" ) type RPCFunc struct { client *rpc.Client } func NewRPCFunc() (Func, error) { client, err := rpc.DialHTTP("unix", util.UnixDomainPath) if err != nil { return nil, err } rpcfunc := RPCFunc{ client: client, } return Func(&rpcfunc), nil } func (f *RPCFunc) Add(args *Args, reply *Reply) error { return f.client.Call("RawFunc.Add", args, &reply) } func (f *RPCFunc) Sub(args *Args, reply *Reply) error { return f.client.Call("RawFunc.Sub", args, &reply) }
このような構造にすることで、Func
interfacenの実装を「直接関数を実行」・「RPCを介して実行」のどちらか選び、以降は両者を同じように使うことができます。
おわりに
アドカレ後半で、この仕組みを使ったツールの紹介ができたらいいなーと思ってます。やっていくぞ。