2018/08/21

[golang]TCPサーバ/クライアントをつくって学ぶTCP/IPの基礎

フロントエンドエンジニアとして働く分にはHTML/CSS/JSの知識だけで良いのかもしれないが、仕事上でTCPについての知識が必要になった。しかし、TCPについては基本情報技術者レベルの知識しかなく、いまだにOSI参照モデルが実際のアプリケーションとどう関連付けられるかわかっていない。

そこで、低レイヤーの学習も兼ねて、Go言語(golang)でTCPサーバーとクライアントをStep by Stepで実装し、TCP/IPについてより深く学ぼうと思う。



知識編: TCP/IPとは


インターネットやネットワークについて学んでいると必ず出てくるのが「TCP/IP」だ。OSI参照モデルの第3層(ネットワーク層)のIP(Internet Protocol)と第4層(トランスポート層)のTCP(Transmission Control Protocol)の2つのプロトコルから構成されており、インターネットで標準的に利用されている通信プロトコルだ。

OSI参照モデルとTCP/IPプロトコル群


基本情報技術者の試験ではOSI参照モデルの概念についてしか触れないが、TPC/IPプロトコル群(別名: インターネット・プロトコル・スイート)も存在する。TCP/IPプロトコル群のほうが効率的で、かつ現実的な仕様となっているので一般的に使われており、OSI参照モデルは「概念」として残っている。

OSI参照モデルとTCP/IPプロトコル群の対比表
OSI参照モデルTCP/IPプロトコル群プロトコル
アプリケーション層アプリケーション層HTTP, SMTP, POP3, Telnet, …
プレゼンテーション層
セッション層
トランスポート層トランスポート層TCP, UDP
ネットワーク層インターネット層IP, ICMP, ARP, RARO
データリンク層ネットワークインターフェース層Ethernet, PPP
物理層



IP(Internet Protocol)とは

IPとはインターネットにおいてIPアドレスに基づいて通信相手にパケットを届けるためのプロトコルで、RFC791で定められている。一度に大きなデータを送るとネットワークを占領してしまうので、パケットと呼ばれる小さな塊に分割し、以下のようなヘッダを付けて送信する。
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|      Fragment Offset    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |         Header Checksum       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Source Address                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Destination Address                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options                    |    Padding    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
RFC791 SPECIFICATIONより

IPはパケットを宛先のコンピュータに送信する役割を担っているのだが、データを送信するだけで相手に確実に届いたかどうかまでは保証されない。

それを回避するために、上位のトランスポート層のプロトコルであるTCPを使う。



TCP(Transmission Control Protocol)とは

TCPとはデータを確実に届けるためのプロトコルで、IPと併用されTCP/IPと表示されることが多い。3ウェイハンドシェイクと呼ばれる方法を用いて送信相手とコネクションを確立させてからデータのやり取りを行うため信頼性が高い。

3ウェイハンドシェイクは以下のような手順で行われる。
  1. 送信者がデータを送ってよいか確認するためにSYNパケットを送信する
  2. 受信者は接続許可のACKパケットに加え、こちらからもデータを送ってよいか確認するためにSYNパケットを送信する
  3. 返ってきたSYNパケットに対して、接続許可のACKパケットを送信する

このような手順で、送信者、受信者、双方の接続準備ができてからコネクションを張り、データを確実に届けられるようにする。


また、コネクション切断時も双方に終了確認を行い、4ウェイハンドシェイクで切断を確実にする。切断時は以下のような手順で行われる。
  1. 送信者が切断してよいか確認するためにFINパケットを送信する
  2. 受信者は切断許可のACKパケットを返す
  3. 受信者は切断してよいか確認するためにFINパケットを送信する
  4. 送信元は切断許可のACKパケットを返す

同じトランスポート層のUDPは、コネクションレス型のプロトコルで、データを送りっぱなし(受信できたか確認しない)ため信頼性に欠ける。しかし、データにつけるヘッダのサイズが、TCPのそれと比べて60%ほど小さく、コネクションを張らないため高速に通信できる。一般的にマルチメディアストリーミング(動画や音楽をダウンロードしながら再生する方式)に使われる。

人のコミュニケーションを例に、TCPとUDPの違いを表すと、

  • TCP: 電話
    • 電話をかけて相手がでたら→コネクション確立
    • あとは話すだけ
  • UDP: メール
    • 一方的に情報を送れる
    • 相手が受け取ったかどうかはわからない




実装編: golangでTCPサーバ/クライアントを実装する


TCP/IPの基本的な知識を学んだところで、Step by StepでTCPサーバ/クライアントを実装する。TCPサーバとクライアント間でコネクションを張り、データのやりとりをするというごく単純なものだ。


最小のTCPサーバ

まずは最小のTCPサーバを実装する。

package main

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

func main() {
  // TCP://127.0.0.1:8888でリッスン(hostを省略する場合は`:8888`のように指定する)
  listen, err := net.Listen("tcp", "127.0.0.1:8888")
  if err != nil {
    log.Fatal("tcp://127.0.0.1:8888のリッスンに失敗しました")
  }
  fmt.Println("127.0.0.1:8888で受付開始しました")

  // コネクションを受け付ける
  conn, err := listen.Accept()
  if err != nil {
    log.Fatal("コネクションを確立できませんでした")
  }
  buf := make([]byte, 1024)

  // リクエスト元のアドレス
  fmt.Printf("[Remote Address]\n%s\n", conn.RemoteAddr())

  // Readerを作成して、送られてきたメッセージを出力する
  n, _ := conn.Read(buf)
  fmt.Printf("[Message]\n%s", string(buf[:n]))

  // コネクションを切断する
  conn.Close()
}

net.Listenでサーバを起動、127.0.0.1:8888で接続を待ち受ける。
net.ListenはListener型を返すので、listen.Acceptでコネクションを受け付ける。
コネクションが確立されたあとはConn型のコネクションを使って、送られてきたメッセージを読み込み、コンソールに出力する。
最後にコネクションを切断して終わり。

またTCPクライアントは作っていないので、ためしにTCPサーバにcurlコマンドでアクセスしてみる。
# TCPサーバ起動
server$ go run server.go

---
# httpでアクセス
client$ curl 127.0.0.1:8888
curl: (52) Empty reply from server

---
# httpのメッセージを受信
server$ 
127.0.0.1:8888で受付開始しました
[Remote Address]
127.0.0.1:57228
[Message]
GET / HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.43.0
Accept: */*

curlでアクセスしているのでHTTPヘッダが出力されている。ただこのままでは1度コネクションの確立・切断を行うとサーバが落ちてしまう。そこで無限ループを使って何度もアクセスできるように修正する。


連続でアクセスを処理できるようにする


func main() {
  // 略
  fmt.Println("127.0.0.1:8888で受付開始しました")

  // listen.Acceptは1回受け付けるとcloseしてしまうため、何度もAcceptを呼ぶ
  for {
    // コネクションを確立する
    conn, err := listen.Accept()
    // 以下略
  }
}

無限ループ(for {...})をすることで、立て続けにアクセスを処理することができる。


ゴルーチン(goroutine)で非同期処理させる

無限ループで何度もアクセスを処理できるようになったが、このままでは複数同時にアクセスが来た場合に処理が遅延してしまう。たとえば以下のように時間がかかる処理があった場合、ほぼ同時にアクセスがあっても前のアクセスの処理が終わるまで次の処理が始まらず、結果2つ目のアクセスの処理が開始されるまでに5秒待つことになってしまう。
// 略
for {
  conn, err := listen.Accept()
  // 略

  // 時間がかかる処理
  time.Sleep(5 * time.Second)

  conn.Close()
}

実行すると以下のようになる。
# クライアント
client1$ curl 127.0.0.1:8888
client2$ curl 127.0.0.1:8888

---
server$
# 5秒
[Remote Address]
127.0.0.1:56531
[Message]
GET / HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.43.0
Accept: */*

# 5秒
[Remote Address]
127.0.0.1:56533
[Message]
GET / HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.43.0
Accept: */*

そこでゴルーチンを使って複数アクセスを同時に処理できるようにする。
// 略
  for {
    // コネクションを確立する
    conn, err := listen.Accept()
    if err != nil {
      log.Fatal("コネクションを確立できませんでした")
    }

    // 非同期処理させる
    go func() {
      // リクエスト元のアドレス
      fmt.Printf("[Remote Address]\n%s\n", conn.RemoteAddr())

      time.Sleep(5 * time.Second)

      // Readerを作成して、送られてきたメッセージを出力する
      n, _ := conn.Read(buf)
      fmt.Printf("[Message]\n%s", string(buf[:n]))

      // コネクションを切断する
      conn.Close()
    }()
  }
// 略

実行すると以下のようになる。
client1$ curl localhost:8888
client2$ curl localhost:8888

server$ 
# 前の処理中に次のアクセスの処理を開始している
[Remote Address]
127.0.0.1:56531
[Remote Address]
127.0.0.1:56533

# client1のメッセージ
[Message]
GET / HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.43.0
Accept: */*

# client2のメッセージ
[Message]
GET / HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.43.0
Accept: */*



レスポンスを返す

データを受け取りコンソールに表示するところまではできたので、次はレスポンスを返したい。返す方法はコネクションに対してwriteするだけで良い。
// 略
for {
  // 略

  // メッセージを返す
  res := fmt.Sprintf("Hello, %s\n", conn.RemoteAddr())
  conn.Write([]byte(res))

  // 略
}


TCPクライアント

いままでcurlを使っていたので、ちゃんとTCPでアクセスできるようにクライアントを作る。
func main() {
  // tcp://127.0.0.1:8888に接続する
  conn, err := net.Dial("tcp", "127.0.0.1:8888")
  if err != nil {
    log.Fatal("tcp://127.0.0.1:8888に接続できませんでした")
  }
  defer conn.Close()

  // メッセージを送信する
  msg := fmt.Sprintf("Hello, %s\n", conn.RemoteAddr())
  conn.Write([]byte(msg))

  // メッセージを受信する
  res := make([]byte, 1024)
  n, _ := conn.Read(res)
  fmt.Println(string(res[:n]))
}

クライアントはnet.Dialを使って接続する。データを送受信する場合は、サーバ側と同じくコネクションに対してread/writeするだけだ。
実際に実行すると以下のようになる。
server$ go run server.go
127.0.0.1:8888で受付開始しました

---
client$ go run client.go

---
server$
[Remote Address]
127.0.0.1:58242
[Message]
Hello, 127.0.0.1:8888

---
client$
Hello, 127.0.0.1:58185


TCPサーバーとTCPクライアントの全体



// TCPサーバ server.go
package main

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

func main() {
  // TCP://127.0.0.1:8000でリッスン(hostを省略する場合は`:8888`のように指定する)
  listen, err := net.Listen("tcp", "127.0.0.1:8888")
  if err != nil {
    log.Fatal("tcp://127.0.0.1:8888のリッスンに失敗しました")
  }
  defer listen.Close()
  fmt.Println("127.0.0.1:8888で受付開始しました")

  // listen.Acceptは1回受け付けるとcloseしてしまうため、何度もAcceptを呼ぶ
  for {
    // コネクションを確立する
    conn, err := listen.Accept()
    if err != nil {
      log.Fatal("コネクションを確立できませんでした")
    }

    buf := make([]byte, 1024)

    go func() {
      // リクエスト元のアドレス
      fmt.Printf("[Remote Address]\n%s\n", conn.RemoteAddr())

      // Readerを作成して、送られてきたメッセージを出力する
      n, _ := conn.Read(buf)
      fmt.Printf("[Message]\n%s", string(buf[:n]))

      time.Sleep(1 * time.Second)

      // メッセージを返す
      res := fmt.Sprintf("Hello, %s\n", conn.RemoteAddr())
      conn.Write([]byte(res))

      // コネクションを切断する
      conn.Close()
    }()
  }
}


// TCPクライアント client.go
package main

import (
  "fmt"
  "log"
  "net"
)

func main() {
  // tcp://127.0.0.1:8888に接続する
  conn, err := net.Dial("tcp", "127.0.0.1:8888")
  if err != nil {
    log.Fatal("tcp://127.0.0.1:8888に接続できませんでした")
  }
  defer conn.Close()

  // メッセージを送信する
  msg := fmt.Sprintf("Hello, %s\n", conn.RemoteAddr())
  conn.Write([]byte(msg))

  // メッセージを受信する
  res := make([]byte, 1024)
  n, _ := conn.Read(res)
  fmt.Println(string(res[:n]))
}



参考サイト





以上

written by @bc_rikko

0 件のコメント :

コメントを投稿