駄文型

プログラミングとか英語とかの話題を中心にした至極ちゃらんぽらんな日記です。

システムコールだけ使って素朴すぎるHTTPクライアント/サーバーをGoで実装した

実装してみた。素朴すぎてタイムアウトの設定もできないし自動でヘッダーを付与してくれる機能もない。IPv6にも対応してない。

github.com

[追記]クライアントだけでなくサーバーも実装できるようにした。公式のnet/httpのようにHandleFuncListenAndServeでサーバーが立ち上がる。開発関連のまとめツイートは↓

net/httpと同じようなインターフェースで使える

tr;dr

  • ネットワークに苦手意識があったので勉強しはじめた。
  • 勉強のためにシステムコールを直接使ってTCP通信を行うHTTPクライアントとTCPクライアントを作ってみた。
  • ついでにUDPクライアントも作った。
    • 名前解決はDNSパケットを作ってUDPDNSサーバーに投げてる。
  • HTTPサーバーも作った。
  • カーネルすごい。インターネットすごい。

ひとはなぜHTTPクライアントを自作するのか

HTTPクライアントは自作しなくてもすでに存在する。ほとんどの場合は各プログラミング言語が標準ライブラリとして提供している。Goの場合も同様で、 net/http を使えば基本的な操作は十分にできる。それでもHTTPクライアントを自作する理由はなんだろうか。いくつか考えられるが、一番多いのは標準ライブラリのクライアントでは物足りない。より多機能なものを作りたいという動機ではないだろうか。リクエストが失敗したときに、いい感じにリトライしてくれるクライアントがあるとうれしいかもしれない。

僕の場合はそれとは全く反対の理由になった。シンプルなクライアントを素朴に作りたかった。素朴すぎて実用に耐えないレベルで十分だった。なぜそんなものの実装をはじめたのか。きっかけは、とある本だった。

インターネットの仕組みをかわいいイラストとわかりやすい表現で説明してくれる、とてもいい本だった。個人的な話になるけど、ネットワーク関連は学生時代に理論を学びつつルーターを触ったりする最高の授業をとっていた。だけどあまり覚えていなかった。残念。だからネットワーク関連には苦手意識があった。学び直すためにこの本を選んだ。僕と同じようにネットワーク関連に苦手意識があるエンジニアもいると思う。まずはこの本を読むことをおすすめする。分厚いTCP/IPの本に挑戦する前にざっくりと全体を理解できる*1。この本があればインターネットを完全に理解できる。インターネット完全に理解した。

この本のある章にシステムコールを使ったTCPUDPのプログラム例がある。数十行のプログラムで、TCPUDPのサーバーとクライアントを実装できる。C言語で書かれていて、 sys/socket.hsocket()bind()connect() などをいくつか使う。これらはOSのシステムコールと一対一になっているAPIだ。そのコードを読んで、こんな簡単にTCP通信を行うプログラムを組めるのかと驚いた。ソケットを作って connect() するだけでTCPのコネクションをカーネルがいい感じに作ってくれるようだ。

驚くと同時に、あるアイデアが浮かんできた。このTCPのプログラムを使ってHTTPの仕様に沿ったデータを投げれば、きちんとしたHTTPリクエストになるのでは?シンプルなHTTPクライアントくらいなら簡単に作れるのでは?自分で実装してみればTCPの仕組みへの理解が深まるのではないか?と思って早速実装してみた。言語はGoを使った。Cと同じくらいかそれ以上にシステムコールを簡単に扱えるからだ。書きなれているからでもあった。

美しきネットワークレイヤーたち

ネットワークモデルは美しい。実装の詳細に入る前にネットワークモデル、あるいはプロトコルスタックについて述べなければならない。すでに詳しい方は読み飛ばすか、説明が間違っている箇所を指摘していただけると嬉しい。

ネットワークはレイヤー化されている。上位の層の複雑な処理を下位の層は気にせずにシンプルに処理し続けられる。それぞれの層のプロトコルが入れ替わっても、概ね全体としてはきちんと動作する。このような説明はネットワークが苦手なエンジニアでもなんとなく聞いたことがあると思う。

ネットワークレイヤーモデルはOSI参照モデルTCP/IPモデルが定番だが、ここでは面倒なのでポートとソケットがわかればインターネットがわかる――TCP/IP・ネットワーク技術を学びたいあなたのために (Software Design plus)と同じように5層で考えることにする。ネットワークは以下のような階層とプロトコルから成り立っている。

下から順に説明する。ここでの説明はかなりざっくりとした内容なので正確性に欠けるものもある。

物理層データリンク層

物理層は文字通り物理的な接続だ。光ケーブルとかLANケーブルとかを想像してもらえれば良い。物理層は直接接続された機器間における役割になる。データリンク層はそういった物理的な違いを隠蔽する役割を担う。同一ネットワークにおける役割だ。このふたつはTCP/IPモデルではネットワークインターフェース層としてひとつにまとめられる。

ネットワーク層

では同一ネットワークとはなんだろうか。何がネットワークの境界になるのか。それはルーターである。

ルーターの役割はシンプルだ。ざっくりいうとルーティングテーブルに従ってパケットをどっちに流すか決めるだけ。しかもルーターはインターネット全体を把握しているわけではない。個々のルーターはこのIPアドレスならこっち方面だろうというざっくりとしたルールだけをテーブルに持っている。シンプルなだけに比較的安価な仕組みで動く上に高速に処理できる。

トランスポート層

ではそんなシンプルすぎるインターネットで、どうしてWebのような比較的信頼性の高い仕組みが動いているのだろうか*2。それを支えているのがTCPになる。TCPは3wayハンドシェイクで仮想的な経路をいい感じに作るプロトコルだ。接続後は経路が閉じられるまで、その仮想経路を使ってデータのやりとりが行われる。TCPの実装はOSのカーネルにある。つまり、通信の信頼性は通信経路ではなく末端と末端の機器ががんばるのだ。

データを送信する前に接続確認を行うTCPに対して、UDPはがんばらない。確認する前にデータをいきなり送りつける。ちゃんと相手に届くかどうかはわからない。そのかわり速い。

アプリケーション層

アプリケーション層に属するHTTPはTCPを使う。決まったフォーマットでメソッド、パス、ヘッダー、ボディを送る。 curl コマンドでは -v オプションを使うとヘッダーも表示される。ボディは空白行を挟んで最後に付与される。下の例の場合はGETしているだけなのでボディは空になっているはずだ。実装はプログラミング言語の標準ライブラリであることが多い

$ curl -v http://example.com/index.html
> GET /index.html HTTP/1.1
> Host: example.com
> User-Agent: curl/7.64.1
> Accept: */*
> 

そして実装してみた

それぞれの層はプロトコルで仕様が定義されている。実装はもちろんいくつかあるし、それぞれが活動している「場」も異なる。HTTP通信の場合、それぞれの層の担い手は↓のようになる。例えばHTTPに関する実装は標準ライブラリを使うことが多い。HTTP関連の標準ライブラリを使わずに直接システムコールを使って実装すれば、この層を置き換えられるのでは?と思って作ったのが今回の自作HTTPクライアントになる。

システムコールを直接使ってまずTCPクライアントを作って、それを利用するHTTPクライアントを作った*3TCPのクライアント側で必要なシステムコールは5つだけしかない。

  • socket()
  • connect()
  • write()
  • read()
  • close()

https://cacoo.com/diagrams/Jx4Yvi4CUgPoQtkl-A8395.png
TCP接続でサーバー側とクライアント側で使われるシステムコール

たったこれだけのシステムコールを順番に呼ぶだけで、TCPによる通信ができた。驚いた。ほんとうに小さいプログラムになった。TCPクライアントの実装は30行ほとしかない。TCPすごい。カーネルもすごい。ソケットを作って connect() すれば、あとは write()read() を使ってファイルや標準入出力と同じように読み書きできる。ソケットに読み書きするだけでサーバーとデータのやりとりができるようになった。TCPが作る仮想的な経路を肌で感じることができた。やっぱり理論を学ぶだけでなく自分で実装してみると腹落ちするというか、頭ではなく心で理解できる気がする。

HTTPクライアントの実装も小さい。メソッドとパス、HTTPのバージョンと HOST ヘッダーだけを自動で付与する。HTTPのフォーマットに沿ったテキストデータをTCPクライアントに渡すだけ。本当は名前解決も自作したUDPクライアントを使ってやりたかった。しかしそれにはDNSパケットを組み上げてレスポンスを自分でパースする必要があった。面倒すぎたのでやめた。なのでこの記事のタイトルは正確ではない。名前解決には net.ResolveIPAddr() を使った。 net パッケージを使ってはいるが、 net/http は使ってない。それにTCPは本当に syscall しか使ってないので大目に見てほしい。

[追記]名前解決も自作したUDPクライアントを使うようにした。現在はnet への依存もない。

使い方

まずは go get してほしい。

go get -v -u github.com/cohhei/http

使い方はシンプルで、 http.NewClient() で作ったクライアントの Do() メソッドにリクエストを投げるだけでいい。レスポンスはio.ReadCloserで帰ってくるのであとはよしなに使って最後にCloseしてもらえればいい。レスポンスヘッダーとボディのパースはしてくれない。下記の例だとヘッダーとボディのHTMLファイルがまるごと標準出力に表示される。下のコードを main.go にコピペして go run main.go すれば動くはず。ちなみに macOS で開発したので、他のOSでの動作は保証しない。けどDockerコンテナ内で試してみたら動いたので、Linuxでも動くと思う。Windowsはわからない。

package main

import (
    "fmt"
    "io"
    "log"
    "os"

    "github.com/cohhei/http"
)

func main() {
    // Create a client.
    c := http.NewClient()

    // Create a request.
    req := &http.Request{
        Method: "GET",
        Host:   "example.com",
        Path:   "/index.html",
        Header: Header{},
    }

    // Send the request.
    resp, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(resp)
    io.Copy(os.Stdout, resp.Body)
}

[追記]サーバー側はかなり公式パッケージに近い。

package main

import (
    "io"
    "log"
    "os"

    "github.com/cohhei/http"
)

func main() {
    http.HandleFunc("/", func(w io.Writer, r io.Reader) {
        io.Copy(os.Stderr, r)
        w.Write([]byte("HTTP/1.1 200 OK\naaaaa: bbbbbb\n\nHello World!"))
    })
    log.Print("http://127.0.0.1:8080/")
    if err := http.ListenAndServe(8080); err != nil {
        log.Fatal(err)
    }
}

もうすこし詳しく

もうすこし実装の詳細に踏み込むことにする。ここでは自作したHTTPクライアント、TCPクライアント、UDPクライアントについて説明する。

HTTPクライアント

https://github.com/cohhei/http/blob/master/http.go

HTTPクライアントに必要な機能はなんだろうか。いろいろあるが、削ぎ落として削ぎ落として必要最低限なコア機能だけ残すと下のふたつに絞られる。と思う。逆に言えばこのふたつさえやってくれれば、とりあえずHTTPクライアントが出来上がる。このクライアントにURLとメソッドを指定すると、ちゃんとサーバーからレスポンスが返ってくる。

  • 名前解決
  • TCP接続

TCPは自作のTCPクライアントでやっているので、HTTPクライアントは100行とすこしくらいで実装できた。

名前解決

名前解決とはホスト名から対応するIPアドレスを取得することを指す。IPアドレスがないとTCP/IP通信ができない。理論上はDNSサーバーにUDPDNSパケットを送れば取得できるはず*4。今回は net.ResolveIPAddr() を使った*5

[追記]名前解決も自作したUDPクライアントを使うようにした。現在はnet への依存もない。

TCPクライアント

http/tcp.go at master · cohhei/http · GitHub

自作したTCPクライアントのインターフェースはこんな感じ。

type TCPClient interface {
    Connect(addr *Addr) error
    Send(data []byte) (int, error)
    GetReader() (io.Reader, error)
    Close() error
}

Connect() で指定したIPアドレスに繋いで Send() でデータを送る。レスポンスは GetReader() でio.Readerのインターフェースで返ってくる。この時点ではソケットの読み込みはやっていない。 Read() メソッドを呼び出すことで syscall.Read() が呼ばれる。通常は io.Copy() などを使うと思う。外部パッケージから使う場合は http.NewTCPClient() というよくわからない変な名前の関数を使うはめになる。各メソッドの実装も非常にシンプルで、 syscall.Read() を使うReaderも含めて50行くらいで実装できた。本当にシステムコールを呼んでいるだけ。

UDPクライアント

http/udp.go at master · cohhei/http · GitHub

UDPクライアントも自作してみた。ただしHTTPクライアントからは使っていない。こちらもTCPクライアントと設計はほぼ同じで、使っているシステムコールが少し違うだけ。こちらには Connect() にあたるメソッドはない。いきなり送りつけるのがUDP流だ。

type UDPClient interface {
    SendTo(data []byte, addr *Addr) error
    Recvfrom() (io.Reader, error)
    Close() error
}

[追記]HTTPサーバー

実装したのはListenAndServeHandleFuncのみ。TCPソケットはリクエストごとに閉じているので、Keep-Aliveはサポートできていない。

さいごに

さいごまで読んでいただきありがとうございます。勉強会などどこかでこの内容を発表する機会があれば嬉しく思います。発表者のアサインに困ってる方は遠慮なく @cohhei までお気軽にご相談ください。

*1:著者の小川晃通さんが言うには理解した気になれる。

*2:ここでいう比較的とはネットワーク層、つまりIPレイヤと比較して言っている。

*3:正確にはGoの標準パッケージのsyscallを使う。このパッケージの関数はシステムコールと一対一になっている。

*4:通常であればキャッシュDNSサーバーを使う。

*5:現在は使用していない