CLOVER🍀

That was when it all began.

GoでEcho Server/Clientを書いてみる

これは、なにをしたくて書いたもの?

Goの勉強にということで、Echo Server、Clientを書いてみようかなと。

お題

Goを使って、Echo ServerとClientをそれぞれ書いていきます。

  • Echo Server
    • 引数でバインドするアドレスとポートを受け取る
      • 指定しない場合は0.0.0.0と8080にバインドする
      • ポートのみの指定は可とする
    • クライアントからの複数接続を受け入れられるようにする
    • メッセージを返す時に、「★」で装飾する
  • Echo Client
    • サーバーに送るメッセージを引数で受け取る
    • 接続先のサーバーは、コマンドラインオプションで指定する
      • 指定しない場合は、localhostと8080をターゲットに送信する
    • メッセージを送信して、サーバーからの応答を受け取ったらプログラムを終了する

あとは、適度にログ出力します。

環境

今回の環境は、こちらです。

$ go version
go version go1.15.6 linux/amd64

Echo Server

まずはサーバー側を作ります。モジュールの作成。

$ go mod init echo-server
go: creating new go.mod: module echo-server

go.mod

module echo-server

go 1.15

作成したプログラムは、こんな感じです。 server.go

package main

import (
    "bufio"
    "context"
    "flag"
    "fmt"
    "log"
    "net"
    "os"
    "strconv"
    "strings"
    "syscall"
)

func main() {
    log.SetOutput(os.Stdout)

    var address string
    var port int

    flag.Parse()

    if len(flag.Args()) > 1 {
        address = flag.Arg(0)
        port, _ = strconv.Atoi(flag.Arg(1))
    } else if len(flag.Args()) > 0 {
        address = "0.0.0.0"
        port, _ = strconv.Atoi(flag.Arg(0))
    } else {
        address = "0.0.0.0"
        port = 8080
    }

    server := &Server{address: address, port: port}
    err := server.Start()
    if err != nil {
        log.Fatalf("TCP Echo Server startup failed, reason: %s", err)
        // os.Exit(1)
    }
}

type Server struct {
    address string
    port    int
}

func (s *Server) Start() error {
    listenConfig := net.ListenConfig{Control: SetUpSocket}
    listener, err := listenConfig.Listen(context.Background(), "tcp", fmt.Sprintf("%s:%d", s.address, s.port))
    if err != nil {
        return err
    }

    log.Printf("TCP Echo Server[%s], startup.", listener.Addr())

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("Accept error, reason: %s", err)
            continue
        }

        go func() {
            defer conn.Close()

            log.Printf("[%s] Accepted, client", conn.RemoteAddr())

            reader := bufio.NewReader(conn)

            message, err := reader.ReadString('\n')

            if err != nil {
                log.Printf("[%s] Read error, reason: %s", conn.RemoteAddr(), err)
                return
            }

            log.Printf("[%s] Received message: %s", conn.RemoteAddr(), message)

            if strings.HasSuffix(message, "\r\n") {
                message = message[:len(message)-2]
            } else if strings.HasSuffix(message, "\n") {
                message = message[:len(message)-1]
            }

            replyMessage := fmt.Sprintf("★%s★", message)

            writer := bufio.NewWriter(conn)

            writer.WriteString(replyMessage)
            writer.Flush()

            log.Printf("[%s] Replied, client", conn.RemoteAddr())
        }()
    }
}

func SetUpSocket(network string, address string, conn syscall.RawConn) error {
    return conn.Control(func(fd uintptr) {
        syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
    })
}

サーバーには、バインドするアドレスとポートに対応する構造体を用意しました。

type Server struct {
    address string
    port    int
}

TCPソケットを使ってのリッスンを行うのは、こちら。構造体のメソッドとして定義しました。

func (s *Server) Start() error {
    listenConfig := net.ListenConfig{Control: SetUpSocket}
    listener, err := listenConfig.Listen(context.Background(), "tcp", fmt.Sprintf("%s:%d", s.address, s.port))
    if err != nil {
        return err
    }

    log.Printf("TCP Echo Server[%s], startup.", listener.Addr())


    // 省略
}

TCPソケットを扱うには、netパッケージを使用するようです。

net - The Go Programming Language

ListenConfigを使っているのは、ソケットのオプションを指定するためですね。

type ListenConfig

syscall.RawConnで生のコネクションが渡ってくるようなので、こちらに対して設定を行えばいいみたいです。

func SetUpSocket(network string, address string, conn syscall.RawConn) error {
    return conn.Control(func(fd uintptr) {
        syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
    })
}

Package syscall / type RawConn

今回はSO_REUSEADDRを指定しました。

参照しているのは、このあたりです。

Package syscall / func SetsockoptInt

Package syscall / Constants

クライアントからの接続は、Listener#Acceptで待ちます。ここは無限ループになりますね。

   for {
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("Accept error, reason: %s", err)
            continue
        }

        // 省略
    }

Listener#Acceptで受け取ったコネクションからは、クライアントからのメッセージを読み込み、★を付けて返します。
この処理は、goroutineを使って並行処理として実行します。

The Go Programming Language Specification / Go statements

       go func() {
            defer conn.Close()

            log.Printf("[%s] Accepted, client", conn.RemoteAddr())

            reader := bufio.NewReader(conn)

            message, err := reader.ReadString('\n')

            if err != nil {
                log.Printf("[%s] Read error, reason: %s", conn.RemoteAddr(), err)
                return
            }

            log.Printf("[%s] Received message: %s", conn.RemoteAddr(), message)

            if strings.HasSuffix(message, "\r\n") {
                message = message[:len(message)-2]
            } else if strings.HasSuffix(message, "\n") {
                message = message[:len(message)-1]
            }

            replyMessage := fmt.Sprintf("★%s★", message)

            writer := bufio.NewWriter(conn)

            writer.WriteString(replyMessage)
            writer.Flush()

            log.Printf("[%s] Replied, client", conn.RemoteAddr())
        }()

コネクションに対する読み書きは、bufioパッケージを使ってラップしました。

bufio - The Go Programming Language

最後は、コマンドライン引数の取り扱いです。こちらはflagパッケージを使います。

func main() {
    log.SetOutput(os.Stdout)

    var address string
    var port int

    flag.Parse()

    if len(flag.Args()) > 1 {
        address = flag.Arg(0)
        port, _ = strconv.Atoi(flag.Arg(1))
    } else if len(flag.Args()) > 0 {
        address = "0.0.0.0"
        port, _ = strconv.Atoi(flag.Arg(0))
    } else {
        address = "0.0.0.0"
        port = 8080
    }

    server := &Server{address: address, port: port}
    err := server.Start()
    if err != nil {
        log.Fatalf("TCP Echo Server startup failed, reason: %s", err)
        // os.Exit(1)
    }
}

flag - The Go Programming Language

サーバーの起動に失敗した場合は、log#Fatalf関数を使用しました。log#Fatalもそうですが、このいずれかの関数を使用すると
ログ出力後にos#Exit(1)を呼び出してプログラムを終了するようです。

func Fatalf

これで完成です。起動してみましょう。

$ go run server.go
2021/01/04 23:13:17 TCP Echo Server[[::]:8080], startup.

確認。

$ telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello
★Hello★Connection closed by foreign host.

OKです。

サーバー側のログ。

2021/01/04 23:13:39 [127.0.0.1:48812] Accepted, client
2021/01/04 23:13:42 [127.0.0.1:48812] Received message: Hello
2021/01/04 23:13:42 [127.0.0.1:48812] Replied, client
2021/01/04 23:13:44 [127.0.0.1:48814] Accepted, client
2021/01/04 23:13:48 [127.0.0.1:48814] Received message: World
2021/01/04 23:13:48 [127.0.0.1:48814] Replied, client

ポートを指定して起動したりするのもいいですが、確認結果は省略します。

$ go run server.go localhost 5000
2021/01/04 23:15:10 TCP Echo Server[127.0.0.1:5000], startup.

Echo Client

続いては、クライアント側を作成します。まずはモジュールの作成。

$ go mod init echo-client
go: creating new go.mod: module echo-client

go.mod

module echo-client

go 1.15

作成したプログラムは、こちら。
client.go

package main

import (
    "bufio"
    "flag"
    "fmt"
    "log"
    "net"
    "os"
)

func main() {
    log.SetOutput(os.Stdout)

    const defaultHost = "localhost"
    const defaultPort = 8080

    var host string
    var port int

    flag.StringVar(&host, "host", defaultHost, "target host")
    flag.StringVar(&host, "H", defaultHost, "target host (short option: -host)")
    flag.IntVar(&port, "port", defaultPort, "target port")
    flag.IntVar(&port, "p", defaultPort, "target port (short option: -port)")

    flag.Parse()

    var message string

    if len(flag.Args()) > 0 {
        message = flag.Arg(0)
    } else {
        log.Printf("required 1 argument, send message")
        os.Exit(1)
    }

    client := &Client{host: host, port: port}
    err := client.SendMessage(message)

    if err != nil {
        log.Fatalf("TCP Echo Client startup failed, reason: %s", err)
    }
}

type Client struct {
    host string
    port int
}

func (c *Client) SendMessage(message string) error {
    conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", c.host, c.port))
    if err != nil {
        return err
    }

    defer conn.Close()

    log.Printf("Connect Server[%s]", conn.RemoteAddr())

    writer := bufio.NewWriter(conn)
    reader := bufio.NewReader(conn)

    fmt.Printf("[%s] Send message => %s\n", conn.RemoteAddr(), message)

    writer.WriteString(message)
    writer.WriteString("\r\n")
    writer.Flush()

    readBytes := make([]byte, 1024)
    readLength, err := reader.Read(readBytes)

    if err != nil {
        log.Printf("[%s] Read error, reason : %s", conn.RemoteAddr(), err)
        return nil
    }

    replyMessage := string(readBytes[:readLength])

    fmt.Printf("[%s] Replied message => %s\n", conn.RemoteAddr(), replyMessage)

    log.Printf("Disconnect")

    return nil
}

サーバー側と同様、接続先の情報は構造体に持たせることにします。

type Client struct {
    host string
    port int
}

サーバーへメッセージを送信している部分。

func (c *Client) SendMessage(message string) error {
    conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", c.host, c.port))
    if err != nil {
        return err
    }

    defer conn.Close()

    log.Printf("Connect Server[%s]", conn.RemoteAddr())

    writer := bufio.NewWriter(conn)
    reader := bufio.NewReader(conn)

    fmt.Printf("[%s] Send message => %s\n", conn.RemoteAddr(), message)

    writer.WriteString(message)
    writer.WriteString("\r\n")
    writer.Flush()

    readBytes := make([]byte, 1024)
    readLength, err := reader.Read(readBytes)

    if err != nil {
        log.Printf("[%s] Read error, reason : %s", conn.RemoteAddr(), err)
        return nil
    }

    replyMessage := string(readBytes[:readLength])

    fmt.Printf("[%s] Replied message => %s\n", conn.RemoteAddr(), replyMessage)

    log.Printf("Disconnect")

    return nil
}

クライアント側は、net#Dialを使用します。

func Dial

コネクションが確立した後の読み書きは、サーバー側とそう変わりません。が、メッセージの読み込みの部分だけ改行をあてにしない
形になりました…。

引数とオプションを扱っているのは、こちら。

func main() {
    log.SetOutput(os.Stdout)

    const defaultHost = "localhost"
    const defaultPort = 8080

    var host string
    var port int

    flag.StringVar(&host, "host", defaultHost, "target host")
    flag.StringVar(&host, "H", defaultHost, "target host (short option: -host)")
    flag.IntVar(&port, "port", defaultPort, "target port")
    flag.IntVar(&port, "p", defaultPort, "target port (short option: -port)")

    flag.Parse()

    var message string

    if len(flag.Args()) > 0 {
        message = flag.Arg(0)
    } else {
        log.Printf("required 1 argument, send message")
        os.Exit(1)
    }

    client := &Client{host: host, port: port}
    err := client.SendMessage(message)

    if err != nil {
        log.Fatalf("TCP Echo Client startup failed, reason: %s", err)
    }
}

flag - The Go Programming Language

サーバーに送信するメッセージは引数として受け取り、接続先はオプションとして指定します。
オプションは、ロングオプションとショートオプションの両方を扱うようにしました。

   flag.StringVar(&host, "host", defaultHost, "target host")
    flag.StringVar(&host, "H", defaultHost, "target host (short option: -host)")
    flag.IntVar(&port, "port", defaultPort, "target port")
    flag.IntVar(&port, "p", defaultPort, "target port (short option: -port)")

-hを使わなかったのは、デフォルトのオプション(-help、-h)と重複するからですね。

func (*FlagSet) Parse

では、確認してみましょう。

まずはヘルプを見てみます。

$ go run client.go -h
Usage of /tmp/go-build922131759/b001/exe/client:
  -H string
        target host (short option: -host) (default "localhost")
  -host string
        target host (default "localhost")
  -p int
        target port (short option: -port) (default 8080)
  -port int
        target port (default 8080)

実行(サーバー側は起動しているものとします)。

$ go run client.go 'Hello World!!'
2021/01/04 23:36:08 Connect Server[127.0.0.1:8080]
[127.0.0.1:8080] Send message => Hello World!!
[127.0.0.1:8080] Replied message => ★Hello World!!★
2021/01/04 23:36:08 Disconnect


$ go run client.go -p 5000 'Hello World!!'
2021/01/04 23:36:27 Connect Server[127.0.0.1:5000]
[127.0.0.1:5000] Send message => Hello World!!
[127.0.0.1:5000] Replied message => ★Hello World!!★
2021/01/04 23:36:27 Disconnect


$ go run client.go -H 192.168.0.2 -p 5000 'Hello World!!'
2021/01/04 23:37:17 Connect Server[192.168.0.2:5000]
[192.168.0.2:5000] Send message => Hello World!!
[192.168.0.2:5000] Replied message => ★Hello World!!★
2021/01/04 23:37:17 Disconnect

OKですね。

まとめ

GoでTCPソケットを扱い、Echo ServerとClientを書いてみました。

Goのパッケージはまだまだ把握していないので、勉強になりますね…。