Golangでnet/rpc over Unix domain socketのハンズオン

この記事はLOCAL Students Advent Calendar 2021の1日目の記事です。 adventar.org

はじめに

Golangnet/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

パッケージの構造は以下のようになっています。

(この段階ではdaemondeamontypoしてますね……ご了承くださいませ……)

$ 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.goclient.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型のインスタンスであるfRegister()関数に渡すことで、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)
}

このような構造にすることで、Funcinterfacenの実装を「直接関数を実行」・「RPCを介して実行」のどちらか選び、以降は両者を同じように使うことができます。

おわりに

アドカレ後半で、この仕組みを使ったツールの紹介ができたらいいなーと思ってます。やっていくぞ。