はじめに
アプリケーションの設計に関する会話の中で通信に対しての理解が浅く、うまくイメージがわかない場面が最近よくありました。また、障害が起きた際に通信経路がわからず原因の切り分けがうまくできないことが以前あり、もどかしいと思ったことがありました。
このような経験から、少なくともアプリ開発上知っておく必要がある通信経路については全て理解している状態になりたいと強く思うようになりました。
今回は通信経路への理解を深める一環として、Go言語を利用して自分でHTTPサーバーを実装し、TCPやHTTPに対する理解を深めてみました。
※ この記事はGoならわかるシステムプログラミングの6章を参考にしています。低レイヤーについて深く知れるとても素晴らしい一冊でした。
ソケットとは
TCPやHTTPの話に入る前にその前提について整理したいと思います。
まずはソケットについてです。
調べていくとソケットという言葉は「ソケット自体」と「ソケットを利用した通信」の2つを指していることが多いとわかりました。
「ソケット自体」は、ネットワーク上での通信の基本的な「端点」として機能します。ここでいう「端点」とは郵便ポストのようなイメージです。一般的に、ソケットはIPアドレスとポート番号の組み合わせで特定されます。IPアドレスはネットワーク上のデバイスを特定し、ポート番号はそのデバイス上の特定のアプリケーションやサービスを指します。
「ソケットを利用した通信」は異なるマシン間の通信を可能にします。「ソケットを利用した通信」には、主にTCP(Transmission Control Protocol)またはUDP(User Datagram Protocol)といったプロトコルを使用した通信があります。ちなみに、Unixドメインソケットというのもありますが、こちらは同一マシン上での通信となっています。
OSI参照モデルとは
OSI参照モデルは、コンピュータネットワークと通信プロトコルの設計と理解を助けるために作成された概念です。このモデルは、7つの異なるレイヤーから構成され、各レイヤーは特定の役割と責任を持っています。以下は、OSI参照モデルの主要なレイヤーとその役割です。
- 物理層(Physical Layer)
- データリンク層(Data Link Layer)
- ネットワーク層(Network Layer)
- トランスポート層(Transport Layer)
- セッション層(Session Layer)
- プレゼンテーション層(Presentation Layer)
- アプリケーション層(Application Layer)
通信は図のような流れで行われます。
TCPとHTTP
TCPはトランスポート層で利用されるプロトコルです。TCPは、コンピュータネットワークでデータを信頼性のある方法で送受信するためのプロトコルです。TCPは、エラーチェック、再送信などの仕組みを使用してデータの確実な配信を保証します。信頼性は高いですが、転送速度が低いという特徴があります。
HTTPはアプリケーション層のプロトコルです。そして、HTTPは通常TCP上で動作するプロトコルです。逆にいえば他のトランスポート層で利用されるUDP上では利用されないです。HTTPはウェブコンテンツとその構造、メタデータ、通信の状態などを扱います。
あるプロセスから送った通信内容をTCPは別のプロセスに届けるところまでが役割で、その中身の通信内容はHTTPで書かれており、それを各プロセスが読み解いて処理している、というようなイメージです。
TCPを使って通信してみる
いよいよ実際にTCP(ソケット)を利用して通信してみます。
ソケット通信の基本はサーバーとクライアントからなります。
サーバーはソケットを開いて待ち受け、クライアントはソケットに接続し、通信を行います。
サーバー
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
// TCPプロトコルを使用してポート8080で待ち受けるサーバーを開始しようと試みる
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
defer ln.Close()
// ここでは新しいクライアントが接続するまでプログラムの実行をブロック(停止)する
conn, err := ln.Accept()
if err != nil {
panic(err)
}
defer conn.Close()
// クライアントからのメッセージを読み取る
message, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
panic(err)
}
fmt.Print("Message received:", message)
// クライアントにレスポンスを送信
fmt.Fprintf(conn, "Hello from Server!\n")
}
クライアント
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
// TCPプロトコルを使用してポート8080に接続する
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
panic(err)
}
defer conn.Close()
// サーバーにメッセージを送信
fmt.Fprintf(conn, "Hello from Client!\n")
// サーバーからのレスポンスを受け取る
response, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
panic(err)
}
fmt.Print("Message from server: " + response)
}
それではサーバーを起動してみます。
server %go run main.go
実際にサーバーを起動するとわかるのですが、下記の部分で一時的に実行が止まります。
conn, err := ln.Accept()
クライアントを起動すると結果は次のようになります。
サーバー
server %go run main.go Message received:Hello from Client!
クライアント
client %go run main.go
Message from server: Hello from Server!
当たり前といえば当たり前なのですが、1リクエストでサーバーが終了しました。
特に何も設定しないとTCP接続は1回で切れてしまうものであることが確認できました。
つづいて、for文による無限ループを追加して何度リクエストを送信してもサーバーが終了しないようにします。
サーバー
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
defer ln.Close()
// ↓追記
fmt.Println("Server is running at localhost:8080")
for {
conn, err := ln.Accept()
if err != nil {
panic(err)
}
defer conn.Close()
message, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
panic(err)
}
fmt.Print("Message received:", message)
fmt.Fprintf(conn, "Hello from Server!\n")
}
}
実際にリクエストを連続で送った結果です。
サーバー
server %go run main.go
Server is running at localhost:8080
Message received:Hello from Client!
Message received:Hello from Client!
Message received:Hello from Client!
クライアント
client %go run main.go
Message from server: Hello from Server!
client %go run main.go
Message from server: Hello from Server!
client %go run main.go
Message from server: Hello from Server!
何度リクエストを送ってもサーバーは終了しないことがわかりました。
これは接続を使いまわしているわけではなく、リクエストがあるたびに新しい接続を作っているということに注意です。
また試しにこの状態でブラウザからリクエストを送ってみると次のようにターミナルに表示されます。
HTTPのリクエストが単なる文字列として扱われていることがわかります。
Message received:GET / HTTP/1.1
HTTPを使って通信してみる
つぎにHTTPを利用して通信してみます。
サーバー
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
panic(err)
}
fmt.Println("Server is running at localhost:8080")
for {
// コネクションを無限に順次取得
conn, err := listener.Accept()
if err != nil {
panic(err)
}
// 処理は並行で実施される
go func() {
fmt.Printf("Accept %v\n", conn.RemoteAddr())
// リクエストを読み込む
request, err := http.ReadRequest(
bufio.NewReader(conn))
if err != nil {
panic(err)
}
dump, err := httputil.DumpRequest(request, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
// レスポンスを書き込む
response := http.Response{
StatusCode: 200,
ProtoMajor: 1,
ProtoMinor: 0,
Body: io.NopCloser(
strings.NewReader("Hello World\n")),
}
response.Write(conn)
conn.Close()
}()
}
}
クライアント
package main
import (
"bufio"
"fmt"
"net"
"net/http"
"net/http/httputil"
)
func main() {
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
panic(err)
}
request, err := http.NewRequest(
"GET", "http://localhost:8080", nil)
if err != nil {
panic(err)
}
request.Write(conn)
response, err := http.ReadResponse(
bufio.NewReader(conn), request)
if err != nil {
panic(err)
}
dump, err := httputil.DumpResponse(response, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
}
実際に実行した結果は下記です。
サーバー
http-server %go run main.go
Server is running at localhost:8080
Accept 127.0.0.1:55613
GET / HTTP/1.1
Host: localhost:8080
User-Agent: Go-http-client/1.1
クライアント
http-client %go run main.go
HTTP/1.0 200 OK
Connection: close
Hello World
並行処理がなかった場合
今は並行処理を当たり前のように使っていましたが、仮にこれがなかった場合はどうなるでしょうか?
まず、サーバー側に次の内容を追記します。
サーバー
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
panic(err)
}
fmt.Println("Server is running at localhost:8080")
for {
conn, err := listener.Accept()
if err != nil {
panic(err)
}
go func() {
fmt.Printf("Accept %v\n", conn.RemoteAddr())
request, err := http.ReadRequest(
bufio.NewReader(conn))
if err != nil {
panic(err)
}
dump, err := httputil.DumpRequest(request, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
response := http.Response{
StatusCode: 200,
ProtoMajor: 1,
ProtoMinor: 0,
Body: io.NopCloser(
strings.NewReader("Hello World\n")),
}
response.Write(conn)
conn.Close()
// 追記
time.Sleep(5 * time.Second)
}()
}
}
この状態でリクエストを送ってもすぐにリクエストが返ってきます。
続いて並列処理部分をコメントアウトします。
サーバー
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
panic(err)
}
fmt.Println("Server is running at localhost:8080")
for {
conn, err := listener.Accept()
if err != nil {
panic(err)
}
// コメントアウト
// go func() {
fmt.Printf("Accept %v\n", conn.RemoteAddr())
request, err := http.ReadRequest(
bufio.NewReader(conn))
if err != nil {
panic(err)
}
dump, err := httputil.DumpRequest(request, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
response := http.Response{
StatusCode: 200,
ProtoMajor: 1,
ProtoMinor: 0,
Body: io.NopCloser(
strings.NewReader("Hello World\n")),
}
response.Write(conn)
conn.Close()
time.Sleep(5 * time.Second)
// }()
}
}
実行すると処理がつまっていることがわかります。
並列処理することで、連続でリクエストがきてもつまることなくレスポンスを得られることがわかりました。
接続を使い回す
続いて接続を使い回していきます。
もしタイムアウトした場合は、再度connを初期化しています。
サーバー
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
panic(err)
}
fmt.Println("Server is running at localhost:8080")
for {
conn, err := listener.Accept()
if err != nil {
panic(err)
}
go func() {
defer conn.Close()
fmt.Printf("Accept %v\n", conn.RemoteAddr())
// Accept 後のソケットで何度も応答を返すためにループ
for {
// タイムアウトを設定
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
request, err := http.ReadRequest(bufio.NewReader(conn))
if err != nil {
// タイムアウトもしくはソケットクローズ時は終了
// それ以外はエラーにする
neterr, ok := err.(net.Error)
if ok && neterr.Timeout() {
fmt.Println("Timeout")
break
} else if err == io.EOF {
break
}
panic(err)
}
dump, err := httputil.DumpRequest(request, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
content := "Hello World\n"
// レスポンスを書き込む
// HTTP/1.1 かつ、ContentLength の設定が必要
response := http.Response{
StatusCode: 200,
ProtoMajor: 1,
ProtoMinor: 1,
ContentLength: int64(len(content)),
Body: io.NopCloser(
strings.NewReader(content)),
}
response.Write(conn)
}
}()
}
}
クライアント
package main
import (
"bufio"
"fmt"
"net"
"net/http"
"net/http/httputil"
"strings"
)
func main() {
sendMessages := []string{
"ASCII",
"PROGRAMMING",
"PLUS",
}
current := 0
var conn net.Conn = nil
// リトライ用にループで全体を囲う
for {
var err error
// まだコネクションを張ってない / エラーでリトライ
if conn == nil {
// Dial から行って conn を初期化
conn, err = net.Dial("tcp", "localhost:8080")
if err != nil {
panic(err)
}
fmt.Printf("Access: %d\n", current)
}
// POST で文字列を送るリクエストを作成
request, err := http.NewRequest(
"POST",
"http://localhost:8080",
strings.NewReader(sendMessages[current]))
if err != nil {
panic(err)
}
err = request.Write(conn)
if err != nil {
panic(err)
}
// サーバーから読み込む。タイムアウトはここでエラーになるのでリトライ
response, err := http.ReadResponse(
bufio.NewReader(conn), request)
if err != nil {
fmt.Println("Retry")
conn = nil
continue
}
// 結果を表示
dump, err := httputil.DumpResponse(response, true)
if err != nil {
panic(err)
}
fmt.Println(string(dump))
// 全部送信完了していれば終了
current++
if current == len(sendMessages) {
break
}
}
conn.Close()
}
実行結果
keep-alive-client %go run main.go
Access: 0
HTTP/1.1 200 OK
Content-Length: 12
Hello World
HTTP/1.1 200 OK
Content-Length: 12
Hello World
HTTP/1.1 200 OK
Content-Length: 12
Hello World
「Access: 0」が一度だけ表示されています。これはつまりコネクションの接続は1度しか実行されていない、ということです。無事に接続を使い回すことができるようになりました。
まとめ
この記事を書くことで各通信の位置づけを改めて整理することができました。例えば、TCPを利用してHTTPが通信している、というのはあまりイメージが湧いていませんでしたが、今回自分で実装、通信してみてコードベースでその関係のイメージがわきました。
今回はTCPとHTTPでしたが、Unixドメインソケットなどはあまりイメージが湧いていないので、このあたりを次は深掘ってみようかなと思っています。
最後までお読みいただきありがとうございました。
AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459