goでWebサービス No.8(ソケット)

11 min read読了の目安(約10500字

今回はソケット通信についてまとめます。とあるITの用語についてまとめたサイトはいろいろな用語を初心者にもわかりやすく説明してあるにも関わらずソケット通信は 「考えるんじゃない、感じるんだ」な用語 と書いていあるくらいなのでとても概念的で理解が難しい用語だと思いますが、頑張って説明してみようと思います。

今回のデータはgithubにあげています。必要なファイルはクローンしてお使いください。

注意

コマンドラインを使った操作が出て来ることがあります。cd, ls, mkdir, touchといった基本的なコマンドを知っていることを前提に進めさせていただきます。
環境の違いで操作や実行結果に差異が出てくる可能性があります。私の実行環境は以下になります。

MacBook Pro (Early 2015)
macOS Catalina ver.10.15.6
go version go1.14.7 darwin/amd64
エディタはVScode

ソケット通信とは

ネットワーク通信

ソケット通信を理解するためにはネットワーク通信の理解が必要だと思ったのでまずネットワーク通信から説明します。
私たちが、ウェブサイトやウェブアプリケーションをクライアントに届ける時を考えて見ましょう。この時送るデータはHTMLやCSSで書かれたデータですが、ルータを通る時には電気信号にする必要があります。またクライアントでは電気信号を元のHTMLやCSSのデータに戻す必要があります。この抽象的なデータから電気信号という具体的な物理現象に変換するまではちゃんとした規則で行われないといけません。そうでないと異なるデバイス間でのちゃんとした通信は無理でしょう。またこの間には誰宛に送るかといった情報やデータを確実に送るための仕組みが必要です。

これらをどのように実現するかはいろいろな考え方があるでしょう。人類はこれらを一つ一つ切り分けて7つの階層構造に単純化することでこれを実現し現在のスタンダードになっています。それが OSI参照モデル と呼ばれるものです。

OSI参照モデル(引用:wikipedia)

  • 第7層 - アプリケーション層
    具体的な通信サービス(例えばファイル・メールの転送、遠隔データベースアクセスなど)を提供。HTTPやFTP等の通信サービス。
  • 第6層 - プレゼンテーション層
    データの表現方法(例えばEBCDICコードのテキストファイルをASCIIコードのファイルへ変換する)。
  • 第5層 - セッション層
    通信プログラム間の通信の開始から終了までの手順(接続が途切れた場合、接続の回復を試みる)。
  • 第4層 - トランスポート層
    ネットワークの端から端までの通信管理(エラー訂正、再送制御等)。
  • 第3層 - ネットワーク層
    ネットワークにおける通信経路の選択(ルーティング)。データ中継。
  • 第2層 - データリンク層
    直接的(隣接的)に接続されている通信機器間の信号の受け渡し。
  • 第1層 - 物理層
    物理的な接続。コネクタのピンの数、コネクタ形状の規定等。銅線-光ファイバ間の電気信号の変換等。

各階層では、その階層で扱う内容(データの記述の仕方、変換の仕方、追加・削除の必要なデータなど)のルールを定めています。このルールが プロトコル と呼ばれるものです。プロトコルは一つの階層に一つとは限りません。取り扱う内容などによって複数存在します。例えば私たちが直接触れる部分に位置するアプリケーション層ではウェブページなどハイパーテキストを扱う場合はHTTPというプロトコル、メールを扱う場合はSMTPというプロトコルになります。
みなさんがウェブサイトを作る際やウェブAPIを叩くときGET, POSTといったメソッドに則って処理をしているのはHTTPというプロトコル(ルール)に則ってやっているからです。
逆に接続が途切れた場合はどのような処理をしようとかどのような経路で相手までデータを送ろうとか考えなくていいのは、7つの階層に分けて下の階層がやってくれているからです。
これは例えばプログラミングにおいてディスプレイに処理結果をどのように表示するかやCPUやメモリをどう制御するかをもっと下のライブラリやOS、デバイスドライバなどが提供するAPIに任せるのと似ていると思います。

ソケット

上で紹介した7階層のうち、今回扱うソケットは4層目のトランスポート層にあたる仕組みです(プロトコルではありません)。トランスポート層はその名の通りデータをどのように送受信するかを定めています。例えばトランスポート層のプロトコルであるTCPは接続の確立やエラーの検出を行っています。トランスポート層では送られてきたデータがHTMLなのかメールなのかといったことは気にしません。それはもっと上のアプリケーション層やプレゼンテーション層が扱うことです。
逆にいうと送られてきたデータにどのような処理をするかをこちらが実装するならば、アプリケーション層などが処理を行うために付与した情報は必要なくなるのでよりコンパクトな通信が行えるようになります。
ちなみにHTTP通信でもソケットは使用されているようです。

ソケットを扱う前に以下のことを抑えておきましょう。ここで説明することは使用する言語などによって、多少要素が増えたり減ったり違いはあると思いますが、概ねこの様な実装を行うと思います。

  • IPアドレスとポート
    ソケットはIPアドレスとポート番号で通信相手と通信を行うアプリケーションを識別します。

  • ソケットにはサーバ側とクライアント側があります。
    ここでいうサーバ側とクライアント側はHTTP通信の時のイメージと一緒です。
    サーバ側は以下の様な流れで通信を行います。

    • create: ソケットの作成
    • bind: ソケットとIPアドレス:ポートの紐付け
    • listen: 接続の待機
    • accept: 接続の受信
    • close: 接続の切断

    クライアント側は以下の様な流れで通信を行います。

    • create: ソケットの作成
    • bind: ソケットとIPアドレス:ポートの紐付け
    • connect: サーバ側のソケットに接続
    • close: 接続の切断
  • ソケットではデータの読み書きにread, writeを使う。
    サーバ、クライアントに関係なくソケットにデータを読み書きすることでデータを送ることができます。

TCP Socket

TCPで使用されるソケットとはストリームソケットと言われます。TCPは接続指向のプロトコルで「接続の確立→通信→切断」という流れで通信を行います。接続の確立には俗にいう3ウェイハンドシェイクで行われます。この様にデータの送受信以外にも処理が入ってくるので速いデータ通信のプロトコルとは言えません。TCPは確実にデータを送信する時に使われるプロトコルです。具体的にはウェブサイトの表示やメールの送受信などの下の層の処理はTCPつまりストリームソケットで行われます。

UDP Socket

UDPで使用されるソケットはデータグラムソケットと言われます。UDPは無接続のプロトコルです。ここでいう無接続とはTCPの様な接続の確立やデータの正確性を確認するチェックを行わないという意味です。そのためTCPより速いデータ通信ができます。具体的には音声通信や動画ストリーミングなど速さを求められる通信の下の層の処理はUDPつまりデータグラムソケットで行われます。

Goでソケットプログラミング

ここからはGoでサーバソケットとクライアントソケットを使って通信を実際に行ってみます。

TCP Socket

client

クライアント側は以下の様に実装します。

client/main.go
// code:web8-1
package main

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

func main() {
    // ①実行の際に指定したhost:portでbind
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    // ②ソケットの作成とIP:portに紐付け
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err, "tcpAddr")
    // ③サーバ側に接続
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    checkError(err, "conn")
    // ④ソケットにデータの書き込み
    _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
    checkError(err, "conn write")
    res := make([]byte, 1024)
    // ⑤ソケットからデータの読み込み
    len, err := conn.Read(res)
    checkError(err, "conn read")
    fmt.Println("response:", string(res[:len]))
    // ⑥接続の切断
    conn.Close()
}

func checkError(err error, msg string) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error: %s \n", err.Error())
		fmt.Fprintf(os.Stderr, "message: %s \n", msg)
		os.Exit(1)
	}
}

大体上で紹介したクライアント側の通信の流れと同じだと思います。ここでは少し補足しておきます。

  1. Goではgo run main.goの後に引数をつけることができます。引数はos.Args[]変数に配列の形で格納されます。例えば以下のようになります。
    if len(os.Args) > 0 {
        for i, arg := range os.Args {
            fmt.Printf("%v os.Args: %v \n", i, arg)
        }
    }
    
    $ go run main.go localhost:7777 param2 param3
    0 os.Args: /var/folders/f9/7fm_jzt56x143zvl_wdhxsc00000gn/T/go-build322955780/b001/exe/main 
    1 os.Args: localhost:7777 
    2 os.Args: param2 
    3 os.Args: param3 
    
    一番最初には必ず実行ファイルへのパスが入るので2番目の要素からが与えた引数になります。これで実行時にIPアドレスとポートを与えることができます。
  2. GoではIPアドレスとポートを指定してソケットを作成する様なのでソケットの作成と紐付けを同時にやっているとみなせるでしょう。ちなみにnet.ResolveTCPAddr("tcp4", service)の最初の引数は'tcp4', 'tcp6', 'tcp'が設定できます。これはIPv4, IPv6, IPv4orIPv6のどれを使うかを指定しています。
  3. net.DialTCP("tcp", nil, tcpAddr)でサーバ側に接続します。最初の引数はResolveTDPAddr()と一緒です。2つ目の引数はローカルアドレス(クライアント側のアドレス)です。特に指定がなければnilにすると自動でアドレスが割り振られます。3つ目がサーバ側のアドレスです。
  4. ソケットにデータを書き込んでいます。ここではHTTPのヘッドを書いていますがhello server.などの適当な文字列でも構いません。
  5. サーバからの返信を読み取っています。
  6. 接続を解除しています。

server

続いてサーバ側を実装しましょう。ここではクライアントに対して現在時刻を返す様にします。

server/main.go
// code:web8-2
package main

import (
	"fmt"
	"log"
	"net"
	"os"
	"time"
)

func main() {
    // ①ソケットの作成とIP:portのbind
    service := ":7777"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    // ②接続の待機
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    log.Println("normal socket\nlisten on port", service)
    for {
        // ③接続の受信
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        // ④ソケットの読み込み
        req := make([]byte, 1024)
        len, err := conn.Read(req)
        log.Println("riquest:", string(req[:len]))
        // ⑤ソケットの書き込み
        daytime := time.Now().String()
        conn.Write([]byte(daytime))
        // ⑥接続の切断
        conn.Close()
    }
}

func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

これも上で紹介したサーバ側の流れと同じになっていると思います。server, clientを実行すると以下の様になります(先にserverから立ち上げてください)。

/server
$ go run main.go
2020/10/31 11:11:23 normal socket
listen on port :7777
2020/10/31 11:11:38 riquest: HEAD / HTTP/1.0
/client
$ go run main.go localhost:7777
response: 2020-10-31 10:11:00.279481 +0900 JST m=+6.279114433

実際は複数のクライアントからの接続を捌く必要があるので並行処理で書く方がいいでしょう。

// code:web8-3
func goroutinSocket() {
	service := ":7777"
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	checkError(err)
	listener, err := net.ListenTCP("tcp", tcpAddr)
	checkError(err)
	log.Println("concurrency socket\nlisten on port", service)
	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		go handleClient(conn)
	}
}

func handleClient(conn net.Conn) {
	defer conn.Close()
	daytime := time.Now().String()
	conn.Write([]byte(daytime))
}

タイムアウト

クライアント側でサーバ側のソケットに接続が出来ない場合があります。それは物理的な問題かもしれませんしサーバ側のエラーによるものかもしれません。いずれにせよクライアントはサーバ側の状況を知る術はないのでずっと応答を待つことになります。
そのため、クライアント側には一定時間過ぎたらサーバ側の応答の待機を解除するタイムアウトを設定します。Goでの実装のやり方をみてみましょう。

client/main.go
// code:web8-4
func main() {
	if len(os.Args) != 2 {
		fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
		os.Exit(1)
	}

	service := os.Args[1]
    conn, err := net.DialTimeout("tcp", service, 1*time.Second)
    fmt.Println(&conn, err)
    // 略

簡単なのはweb8-4の様にnet.DialTimeout()を使うことです。これでタイムアウト機能付きのコネクションを作成出来ます。

$ go run main.go google.com:80
216.58.197.14:80
Fatal error: dial tcp: i/o timeout 
message: conn 
exit status 1

実行結果は上になります。存在するドメインで接続までにタイムアウトするほど遅いサイトを知りませんので今回はgoogle.comに対してタイムアウト時間を1msで実行しています。ちなみに存在しないドメインをでっち上げても名前解決のエラーが帰ってきます。

接続は出来たけどサーバから応答がない場合は
conn.SetReadDeadline(t time.Time)
を使います。これで指定した時間までに返信が返ってこなかったらタイムアウトします。

$ go run main.go google.com:80
216.58.196.238:80
0xc0000b0030 <nil>
input:hi,google!
Fatal error: read tcp 192.168.11.3:59598->216.58.196.238:80: i/o timeout 
message: conn read 
exit status 1

上はgoogle.com:80に「hi,google!」とメッセージを送ったのですが、返ってこなかった例です(もちろんgoogleのサーバにはこの様なTCP接続に対しての処理を実装していないので当然返ってきません)。

サーバ側はどうでしょう。例えばクライアントが接続を維持し続ける場合、接続するクライアントが増えていき処理が追いつかなくなっていきます。
SYNフラッド攻撃(SYN Flood Attack)といって3ウェイハンドシェイクの仕組みを逆手に取って故意に接続待機状態を作りサーバの処理を圧迫する攻撃も存在します。
サーバ側でも場合によっては上のようなタイムアウトを設けてクライアントとの接続に制限時間を設ける必要があるでしょう。

詳しい実装はnetパッケージのドキュメントを読んでみてください。

UDP Socket

UDPは接続の確立などを行わないだけで処理の流れ自体はTCPと一緒です。UDPを使ったソケットの実装は呼び出す関数が異なるだけでTCPの実装とほぼ一緒です。

server/main.go
// code:web8-5
package main

import (
	"fmt"
	"net"
	"os"
	"time"
)

func main() {
	service := ":1200"
	udpAddr, err := net.ResolveUDPAddr("udp4", service)
	checkError(err)
	conn, err := net.ListenUDP("udp", udpAddr)
	checkError(err)
	for {
		handleClient(conn)
	}
}

func handleClient(conn *net.UDPConn) {
	var buf [512]byte
	n, addr, err := conn.ReadFromUDP(buf[0:])
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(buf[:n]), addr.IP, addr.Port)
	daytime := time.Now().String()
	conn.WriteToUDP([]byte(daytime), addr)
}

func checkError(err error) {
	if err != nil {
		fmt.Fprint(os.Stderr, "Fatal error ", err.Error())
		os.Exit(1)
	}
}
client/main.go
// code:web8-6
package main

import (
	"fmt"
	"net"
	"os"
)

func main() {
	if len(os.Args) != 2 {
		fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
		os.Exit(1)
	}
	services := os.Args[1]
	udpAddr, err := net.ResolveUDPAddr("udp4", services)
	checkError(err)
	conn, err := net.DialUDP("udp", nil, udpAddr)
	checkError(err)
	defer conn.Close()
	_, err = conn.Write([]byte("anything"))
	checkError(err)
	var buf [512]byte
	n, err := conn.Read(buf[0:])
	checkError(err)
	fmt.Println(string(buf[:n]))
	os.Exit(0)
}

func checkError(err error) {
	if err != nil {
		fmt.Fprint(os.Stderr, "Fatal error ", err.Error())
		os.Exit(1)
	}
}
/server
$ go run main.go
anything 127.0.0.1 57187
^Csignal: interrupt
/client
$ go run main.go localhost:1200
2020-11-01 21:06:19.750344 +0900 JST m=+6.013518910

まとめ

今回はソケットについて学んできました。みてきた通りこれは日頃扱っているHTTPのしたの層であるTCPやUDPでの技術です。Goのnetにはさらに下のIPでの接続もサポートしているみたいなので興味があれば試してみてください。
他の言語での実装もウェブ開発が可能なものは似たようなAPI?ライブラリ?が用意してあると思うのでやってみましょう。