読者です 読者をやめる 読者になる 読者になる

初老のボケ防止日記

おっさんのひとりごとだから気にしないようにな。

スポンサーリンク

いまからのGo(2)

Go言語


ポケGOよりもボケGoがアツい!

本を読んでも実際に何か作ってみないと全く理解が進まないのでサンプルを組みながら理解していくコーナー其の2。

前回迄のあらすじ

osa030.hatenablog.com


とりあえずはGo言語でビッグECHOサーバとECHOクライアントを作ったのだが、色々と問題点があった。

共通

  • プログラム終了時に後処理しない

ビッグECHOサーバ

  • 複数のクライアントを同時に捌けない

ECHOクライアント

  • サーバ側から切断されたことを即座に検知できない

ということで、これらを改善してみましょう。

環境

前回通り。

OS Windwos 10 Pro
Go 1.6.2
IntelliJ IDEA 2016.2
Goプラグイン 0.12.1724
GB 0.4.x

プログラム終了時に後処理しない

まずはサンプルを見て欲しい。

  • nosig.go
package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("start")
	defer fmt.Println("stop")

	fmt.Println("sleep")
	<-time.After(time.Second * 10)
	fmt.Println("done.")
}

単純に10秒スリープして終了するだけのサンプル。

time.Sleep(10)でもよいのだが、time.After()というのは指定時間後にチャネル経由でイベントを通知してくれるGo言語ぽいメソッドをさり気なく使ってみた。

time - The Go Programming Language

これを実行して終了まで放置するとこんな出力になる。

$ go run nosig.go
start
sleep
done.
stop

defer構文で追加した出力が呼ばれているのがわかる。

今度は途中でCtrl+Cを押す。

$ go run nosig.go
start
sleep
(sleep中にCtrl+C押す)
exit status 2

defer構文で追加した出力が呼ばれていない。つまり、デフォルト動作ではCtrl+Cを受信するとos.Exit()しているようだ。

今回のサンプルプログラムではなんら問題ないのだが、実際のプログラミングをする時には終了処理をしてからプログラム終了したいケースが多々ある。例えば、メモリ上のデータの保存とか外部リソースの解放とか。なので、お金を貰ってプログラムする時はgracefulにプログラムが終了するようにすることが多い。で、そういう場合はシグナルを使うのが基本であり当然Go言語にもその仕組は提供されている。

signal - The Go Programming Language

  • sig.go
package main

import (
	"fmt"
	"os"
	"os/signal"
	"time"
)

func main() {
	fmt.Println("start")
	defer fmt.Println("stop")

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)

	fmt.Println("sleep")
	select {
	case s := <-sig:
		fmt.Println("get signal:", s)
	case <-time.After(time.Second * 10):
		fmt.Println("done.")
	}
}

Go言語では「Ctrl+C」は「os.Interrupt」と定義されているのでそれを通知するように登録している。勿論Linux系ならばSIGTERMとかも登録できる。で、time.After()メソッドと同様にチャネル経由で通知してくれるので、どちらのイベントも受信できるようにselect文で待つ。こうしておくとどちらかのチャネルからメッセージが飛んで来るまで処理がブロックする。

実行して途中でCtrl+Cを押す。

$ go run sig.go
start
sleep
get signal: interrupt
stop

os.Interruptシグナルを補足して正常にmain関数を抜けたのがわかる。

シグナル処理自体は当たり前の処理なのであるが、このようにチャネル経由で通知されてくるのがGo言語の特徴*1。ということで、この処理をビッグECHOサーバとECHOクライアントに加えれば終了処理を行うことができる。

複数のクライアントを同時に捌けない

前回のビッグECHOサーバの処理の抜粋。

func main() {
    // ECHOクライアントの接続を待つ
    conn, err := listener.Accept()
    for {
        // 接続したECHOクライアントからのメッセージを受信して変換後に応答する
    }
}

見れば分かる通り、受信後に同一関数内で接続後のTCPコネクションが切断されるまで無限ループで処理するので他のECHOクライアントからの接続なんてできる訳がないのだ。そもそも切断すると待受に戻らないし。
ということで、ECHOクライアント単位での処理を関数化してGoルーチンとして実行すればよい。

func main() {
    // ECHOクライアントの接続を待つ
    conn, err := listener.Accept()
    // Goルーチンとして並行実行(すぐに戻ってくる)
    go handle(conn)
}

func handle(con net.Conn) {
    for {
        // 接続したECHOクライアントからのメッセージを受信して変換後に応答する
    }
}

これだけで並行処理が可能なのがなんというかGo言語さんパネーっす。

サーバ側から切断されたことを即座に検知できない

前回のECHOクライアントの処理の抜粋。

func main() {
	for {
        // 標準入力から文字列を取得
		msg, err := getInput()

        // ビッグECHOサーバへ送信
		_, err = conn.Write([]byte(msg + "\n"))

        // ビッグECHOサーバから受信
		resp, err := bufio.NewReader(conn).ReadString('\n')
	}
}

こちらの問題点は、標準入力とビッグECHOサーバとのTCPコネクションという2つの入力を逐次処理していること。つまり、標準入力から文字列を取得するまでブロックされるので、TCPコネクションが切断されたかは送受信してみないとわからないのだ。ああ、なんて不憫な子なんでしょうか。ということで、ここもGoルーチンにすればいいのだ。

func main() {

	input    := make(chan string, 1)   // 標準入力からの文字列通知用チャネル
	response := make(chan string, 1)   // ビッグECHOサーバからの応答通知用チャネル
	closed   := make(chan struct{}, 1) // TCPコネクション切断通知用チャネル

	go getInput(input)
	go getResponse(conn, response, closed)

	for {
		select {
		case <-closed:
			break
		case i := <-input:
                // ビッグECHOサーバへ送信
			_, err = conn.Write([]byte(i + "\n"))
		case o := <-response:
                // print result
		}
	}

}

func getInput(input chan string) {
        // 標準入力からの文字列をチャネルに送信する
	input <- strings.Trim(msg, string(EOL))
}

func getResponse(conn net.Conn, output chan string, closed chan struct{}) {

	for {
        // ビッグECHOサーバから受信
		msg, err := bufio.NewReader(conn).ReadString('\n')
		if err != nil {
            // TCPコネクション切断をチャネルへ送信
			closed <- struct{}{}
			break
		}

        // ビッグECHOサーバからの応答をチャネルに送信
		output <- strings.Trim(msg, "\n")
	}
}

設計の良し悪しはともかく、チャネルを使ってGoルーチン間のメッセージのやり取りをすることで、標準入力からの入力待ち状態でもTCPコネクションの切断を検出できるようになっている。
ここで、文字列型のチャネルである「input」と「response」はイメージがつきやすいと思うのだが、「closed」の

"struct{}"ってなんだ?

実はオジサンもはじめてチャネルを作ろうとして色々とサンプルを見て謎だったんだが…Go言語界隈では

bool型のチャネル使うなら空Struct使え

という暗黙なノリがあるらしい。理由はサイズが0だからなのかとかいうのも見かけたが、まあ定型句なので覚えておこう。

The empty struct | Dave Cheney

改善版

ということで、今までの内容を盛り込んだ改善版ソース。

ビッグECHOサーバ

package main

import (
	"bufio"
	"log"
	"net"
	"os"
	"os/signal"
	"strings"
	"time"
)

func handleConnection(conn net.Conn) {
	defer func() {
		log.Println("close connection:", conn.RemoteAddr())
		conn.Close()
	}()

	for {
		msg, err := bufio.NewReader(conn).ReadString('\n')
		if err != nil {
			log.Println("ERROR:", err)
			break
		}
		msg = strings.Trim(msg, "\n")
		log.Printf("RECV[%s]\n", msg)

		resp := strings.ToUpper(msg)

		_, err = conn.Write([]byte(resp + "\n"))
		if err != nil {
			log.Println("ERROR:", err)
			break
		}
		log.Printf("SEND[%s]\n", msg)
	}
}

func main() {

	service := ":5555"

	log.Println("start server", service)
	defer log.Println("stop server")

	listener, err := net.Listen("tcp", service)
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		log.Println("close listener:", listener.Addr())
		listener.Close()
	}()

	tcpListener, ok := listener.(*net.TCPListener)
	if !ok {
		log.Fatal("HOGE!")
	}

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)

	ACCEPT_LOOP:
	for {
		select {
		case s := <-sig:
			log.Println("receive signal:", s)
			break ACCEPT_LOOP
		default:
			tcpListener.SetDeadline(time.Now().Add(time.Millisecond * 100))
			conn, err := listener.Accept()
			if err != nil {
				if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
					continue
				}

				log.Println("ERROR:", err)
				break ACCEPT_LOOP
			}
			log.Printf("accept [%v]", conn.RemoteAddr())
			go handleConnection(conn)
		}
	}
}

ECHOクライアント

package main

import (
	"bufio"
	"log"
	"net"
	"os"
	"os/signal"
	"strings"
)

const EOL = '\r' // for WINDOWS

func getInput(input chan string) {

	for {
		msg, err := bufio.NewReader(os.Stdin).ReadString(EOL)
		if err != nil {
			log.Println("getInput ERROR:", err)
			break
		}

		input <- strings.Trim(msg, string(EOL))
	}
}

func getResponse(conn net.Conn, output chan string, closed chan struct{}) {

	for {
		msg, err := bufio.NewReader(conn).ReadString('\n')
		if err != nil {
			log.Println("getResponse ERROR:", err)
			closed <- struct{}{}
			break
		}
		output <- strings.Trim(msg, "\n")
	}
}

func main() {
	service := "127.0.0.1:5555"

	log.Println("start client", service)
	defer log.Println("stop client")

	conn, err := net.Dial("tcp", service)
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		log.Println("disconnect from:", conn.RemoteAddr())
		conn.Close()
	}()

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt)

	input    := make(chan string, 1)
	response := make(chan string, 1)
	closed   := make(chan struct{}, 1)

	go getInput(input)
	go getResponse(conn, response, closed)

	MESSAGE_LOOP:
	for {
		select {
		case s := <-sig:
			log.Println("receive signal:", s)
			break MESSAGE_LOOP
		case <-closed:
			break MESSAGE_LOOP
		case i := <-input:
			log.Printf("input msg[%s]\n", i)
			_, err = conn.Write([]byte(i + "\n"))
			if err != nil {
				log.Println("ERROR:", err)
				break MESSAGE_LOOP
			}
			log.Printf("SEND[%s]\n", i)
		case o := <-response:
			log.Printf("RECV[%s]\n", o)
		}
	}

}

サーバ側のコードについて、2点補足しておく。

以下は型アサーション(Type Assertion)他の言語でいうところのキャストみたいなもの。引数チェックなどでも使われるので慣れておこう。

tcpListener, ok := listener.(*net.TCPListener)
if !ok {
	log.Fatal("HOGE!")
}

今回は以下の処理をしたいためにListenerからTCPListenerへ型変換をしている。

tcpListener.SetDeadline(time.Now().Add(time.Millisecond * 100))
conn, err := listener.Accept()
if err != nil {
	if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
		continue
	}
}

Accept()メソッドは、デフォルトでは接続があるまでブロックしてしまう。今回は同じ関数内でシグナルも受信したかったので、タイムアウト値を設定して割り込めるようにしている。なお、タイムアウトかどうかの判定もまた型アサーションしているのである。

ということで、今回はGoルーチンとチャネルを使ってみた。前回に比べたら随分とGoっぽさが出てきた気がする。だがしかし、まだ改善できるネタは放置しているのだッ!(多分)。

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

スターティングGo言語

スターティングGo言語

*1:今のところチャネルの裏には必ずGoルーチンが潜んでいるという理解でいいと思っている

スポンサーリンク