技術メモ

技術メモ

ラフなメモ

GoのHTTPクライアントの実装

GoのおけるHTTPクライアントの実装をまとめていきます。

How to issue HTTP request

クライアントの実装はいくつかありますが、いずれもプリミティブなメソッドは同じです。高レベルの関数はプリミティブへのラッパーです。

1. http.Get を用いる

http.Get を用いる方法です。最も簡易的な HTTP のリクエストです。内部的にはリクエストに必要な構造体はデフォルトの値を用います。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("http://example.com/")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

2. http.Client を用いる

次にクライアントを作る方法です。HTTPクライアントヘッダーやリダイレクトポリシー、その他の設定を制御するには http.Client を作成します。例えばリダイレクトしないようなHTTPクライアントにする場合は以下のようになります。

2-1. client.Get を用いる

http.Client でクライアントを作成し、client.Get で GET リクエストを発行します。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    client := &http.Client{
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
    resp, err := client.Get("http://google.com")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(resp.Status)
    fmt.Println(string(body))
}
301 Moved Permanently
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="http://www.google.com/">here</A>.
</BODY></HTML>

2-2. http.NewRequest を用いる

http.NewRequest を用いてリクエストを発行することもできます。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    client := &http.Client{
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
    req, err := http.NewRequest("GET", "http://example.com", nil)
    if err != nil {
        panic(err)
    }
    req.Header.Add("If-None-Match", `W/"wyzzy"`)
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(resp.Status)
    fmt.Println(string(body))
}

2-3. http.Transport を用いる

プロキシ、TLS構成、キープアライブ、圧縮、およびその他の設定を制御するには、トランスポートを作成します。最もプリミティブな実装です。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

func main() {
    tr := &http.Transport{
        MaxIdleConns:       10,
        IdleConnTimeout:    30 * time.Second,
        DisableCompression: true,
    }
    client := &http.Client{
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
        Transport: tr,
    }
    req, err := http.NewRequest("GET", "https://example.com", nil)
    if err != nil {
        panic(err)
    }
    req.Header.Add("If-None-Match", `W/"wyzzy"`)
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    fmt.Println(resp.Status)
    fmt.Println(string(body))
}

クライアント(http.Client)とリクエスト(http.Request)とリクエストを実際に発行する(client.Do)という構造が見えると見通しが良くなると思います。http.Get ではこれらをデフォルトのクライアント(DefaultClient) を用いてよしなにリクエストを発行しているのです。


Implementation

まずは公式ドキュメントに記載されている内容を見てみます。

type Client

  • クライアントのトランスポートはTCPのキャッシュがあるため、クライアントは再利用すべき
  • クライアントはゴルーチンセーフ
  • RoundTripperよりもClientは高レイヤー
    • CookieやリダイレクトなどのHTTPの詳細を処理
  • リダイレクトは、最初のリクエストで設定されたすべてのヘッダーを転送するが以下の場合は除く
    • 「Authorization」、「WWW-Authenticate」、「Cookie」などのセキュアなヘッダーを信頼できないターゲットに転送する場合
    • 信頼できないターゲットというのは、ドメインが異なる場合
      • foo.com から foo.com や sub.foo.com へのリダイレクトの場合は転送される
      • foo.com から bar.com へのリダイレクトの場合は転送されない
    • Cookie ヘッダーを non nil-cookie jar として転送する場合
      • CookieJar とはHTTPリクエストにおけるクッキーの保存と使用を管理する Go のインターフェース
type CookieJar interface {
    // SetCookies handles the receipt of the cookies in a reply for the
    // given URL.  It may or may not choose to save the cookies, depending
    // on the jar's policy and implementation.
    SetCookies(u *url.URL, cookies []*Cookie)

    // Cookies returns the cookies to send in a request for the given URL.
    // It is up to the implementation to honor the standard cookie use
    // restrictions such as in RFC 6265.
    Cookies(u *url.URL) []*Cookie
}

1-1. func http.Get

一番最初に見た Get 関数を見てみます。

func Get(url string) (resp *Response, err error) {
    return DefaultClient.Get(url)
}

デフォルトクライアントは以下のように宣言されている http.Client 構造体です。Client の設定値にはゼロ値です。

var DefaultClient = &Client{}

1-2. Clinet.Get

Client 型の Get メソッドは以下のようになっています。

func (c *Client) Get(url string) (resp *Response, err error) {
    req, err := NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    return c.Do(req)
}

1-2-1. http.NewRequest

NewRequest は Request 型を返す関数で、以下のように NewRequestWithContext へのラッパーになっています。

// NewRequest wraps NewRequestWithContext using the background context.
func NewRequest(method, url string, body io.Reader) (*Request, error) {
    return NewRequestWithContext(context.Background(), method, url, body)
}

1-2-2. http.NewRequestWithContext

NewRequestWithContext はメソッド、URL、オプションのbodyを与えられた新しいRequestを返します。リクエストを (http.Request) を生成します。

func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
    // ...
    req := &Request{
        ctx:        ctx,
        Method:     method,
        URL:        u,
        Proto:      "HTTP/1.1",
        ProtoMajor: 1,
        ProtoMinor: 1,
        Header:     make(Header),
        Body:       rc,
        Host:       u.Host,
    }
    if body != nil {
        // ...
    }

    return req, nil
}

2-1. Clinet.Do

Do は、クライアントで設定されたポリシー (リダイレクト、クッキー、認証など) に従った HTTP リクエストを送信し、HTTP レスポンスを返します。

NewRequest でリクエストを生成した後 c.Do(req) としてリクエストを実行しています。透過的に非公開の do メソッドを呼び出しています。実際のリクエストは RoundTripper というインターフェースが処理します。 RoundTripper は、単一の HTTP トランザクションを実行し、指定されたリクエストのレスポンスを取得する機能を表すインターフェイスです。

func (c *Client) Do(req *Request) (*Response, error) {
    return c.do(req)
}

2-2. Clinet.do

func (c *Client) do(req *Request) (retres *Response, reterr error) {
    // ...
    for {

        // ...

        reqs = append(reqs, req)
        var err error
        var didTimeout func() bool
        if resp, didTimeout, err = c.send(req, deadline); err != nil {
            // c.send() always closes req.Body
            reqBodyClosed = true
            if !deadline.IsZero() && didTimeout() {
                err = &httpError{
                    // TODO: early in cycle: s/Client.Timeout exceeded/timeout or context cancellation/
                    err:     err.Error() + " (Client.Timeout exceeded while awaiting headers)",
                    timeout: true,
                }
            }
            return nil, uerr(err)
        }

        var shouldRedirect bool
        redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
        if !shouldRedirect {
            return resp, nil
        }

        req.closeBody()
    }
}

2-2. Clinet.send

func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
    // ...
    resp, didTimeout, err = send(req, c.transport(), deadline)
    if err != nil {
        return nil, didTimeout, err
    }
    if c.Jar != nil {
        if rc := resp.Cookies(); len(rc) > 0 {
            c.Jar.SetCookies(req.URL, rc)
        }
    }
    return resp, nil, nil
}
// send issues an HTTP request.
// Caller should close resp.Body when done reading from it.
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
    req := ireq // req is either the original request, or a modified fork

    // ...

    resp, err = rt.RoundTrip(req)
    if err != nil {
        stopTimer()
        if resp != nil {
            log.Printf("RoundTripper returned a response & error; ignoring response")
        }
        if tlsErr, ok := err.(tls.RecordHeaderError); ok {
            // If we get a bad TLS record header, check to see if the
            // response looks like HTTP and give a more helpful error.
            // See golang.org/issue/11111.
            if string(tlsErr.RecordHeader[:]) == "HTTP/" {
                err = errors.New("http: server gave HTTP response to HTTPS client")
            }
        }
        return nil, didTimeout, err
    }
    if !deadline.IsZero() {
        resp.Body = &cancelTimerBody{
            stop:          stopTimer,
            rc:            resp.Body,
            reqDidTimeout: didTimeout,
        }
    }
    return resp, nil, nil
}

最終的に RoundTripper インターフェースの RoundTrip メソッドを呼び出していることが分かりました。このようにしてリクエストのクライアントとして定義した RoundTripper インターフェースを実装する構造体(通常は Transport)によって振る舞いを変えられるのが Go のインターフェースのお手本みたいな実装です。(Go だけに限らず一般的なインターフェースの実装ですが)

Transport 構造体の RoundTrip の実装

Transport 構造体の概要をドキュメントから確認してみます。

  • HTTP と HTTPS を扱うプリミティブ
  • HTTP, HTTPS, HTTP Proxy をサポートする RoundTripper の実装
  • 接続をキャッシュする
    • CloseIdleConnections メソッドで管理する
    • MaxIdleConnsPerHost, DisableKeepAlives で設定可能
  • Transport は再利用するべき
  • ゴルーチンセーフ
  • スキーマが HTTP の場合は HTTP/1.1 を使う、HTTPS の場合は HTTP/1.1 or HTTP/2 のいずれかを使う
    • DefaultTransport は HTTP/2 をサポートしている
  • ネットワークエラーが発生した場合のみリトライ

以下のように実装されています。

3-1. Transport.RoundTrip

func (t *Transport) RoundTrip(req *Request) (*Response, error) {
    return t.roundTrip(req)
}

3-2. Transport.roundTrip

// roundTrip implements a RoundTripper over HTTP.
func (t *Transport) roundTrip(req *Request) (*Response, error) {
    // ... 

    for {
        select {
        case <-ctx.Done():
            req.closeBody()
            return nil, ctx.Err()
        default:
        }

        // treq gets modified by roundTrip, so we need to recreate for each retry.
        treq := &transportRequest{Request: req, trace: trace}
        cm, err := t.connectMethodForRequest(treq)
        if err != nil {
            req.closeBody()
            return nil, err
        }

        // Get the cached or newly-created connection to either the
        // host (for http or https), the http proxy, or the http proxy
        // pre-CONNECTed to https server. In any case, we'll be ready
        // to send it requests.
        pconn, err := t.getConn(treq, cm)
        if err != nil {
            t.setReqCanceler(req, nil)
            req.closeBody()
            return nil, err
        }

        var resp *Response
        if pconn.alt != nil {
            // HTTP/2 path.
            t.setReqCanceler(req, nil) // not cancelable with CancelRequest
            resp, err = pconn.alt.RoundTrip(req)
        } else {
            resp, err = pconn.roundTrip(treq)
        }
        if err == nil {
            return resp, nil
        }

        // Failed. Clean up and determine whether to retry.

        // ...
    }
}

3-3. persistConn.roundTrip

pconn.alt の有無によって HTTP/2 でリクエストするのか HTTP/1.1 でリクエストするのか分かれています。

persistConn 構造体はコネクションをラップしています。

func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
    // ...

    // Write the request concurrently with waiting for a response,
    // in case the server decides to reply before reading our full
    // request body.
    startBytesWritten := pc.nwrite
    writeErrCh := make(chan error, 1)
    pc.writech <- writeRequest{req, writeErrCh, continueCh}

    resc := make(chan responseAndError)
    pc.reqch <- requestAndChan{
        req:        req.Request,
        ch:         resc,
        addedGzip:  requestedGzip,
        continueCh: continueCh,
        callerGone: gone,
    }

    var respHeaderTimer <-chan time.Time
    cancelChan := req.Request.Cancel
    ctxDoneChan := req.Context().Done()
    for {
        testHookWaitResLoop()
        select {
        // ...
        case re := <-resc:
            if (re.res == nil) == (re.err == nil) {
                panic(fmt.Sprintf("internal error: exactly one of res or err should be set; nil=%v", re.res == nil))
            }
            if debugRoundTrip {
                req.logf("resc recv: %p, %T/%#v", re.res, re.err, re.err)
            }
            if re.err != nil {
                return nil, pc.mapRoundTripError(req, startBytesWritten, re.err)
            }
            return re.res, nil
        case <-cancelChan:
            pc.t.CancelRequest(req.Request)
            cancelChan = nil
        case <-ctxDoneChan:
            pc.t.cancelRequest(req.Request, req.Context().Err())
            cancelChan = nil
            ctxDoneChan = nil
        }
    }
}

resc チャネルからの値を受信することでレスポンスを取得します。resc はどこから値が送信されるか確認します。roundTrip メソッドの getConn を追っていくと分かります。

4-1. Transport.getConn

Transport.getConn によって persistConn を生成します。queueForIdleConn でアイドルの接続の有無を確認している(?)ようです。

func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
    // ...
    // Queue for idle connection.
    if delivered := t.queueForIdleConn(w); delivered {
        pc := w.pc
        // Trace only for HTTP/1.
        // HTTP/2 calls trace.GotConn itself.
        if pc.alt == nil && trace != nil && trace.GotConn != nil {
            trace.GotConn(pc.gotIdleConnTrace(pc.idleAt))
        }
        // set request canceler to some non-nil function so we
        // can detect whether it was cleared between now and when
        // we enter roundTrip
        t.setReqCanceler(req, func(error) {})
        return pc, nil
    }
    // ...
    t.queueForDial(w)
    // ...
}

4-2. Transport.queueForDial

queueForDial で wantConn という構造体を取得します。このあたりで Transport の MaxConnsPerHost の設定が効いています。MaxConnsPerHost は、ダイヤル状態、アクティブ状態、およびアイドル状態の接続を含む、ホストごとの総接続数を制限します。制限に違反した場合、ダイヤルはブロックされます。 ゼロは制限なしを意味します。

func (t *Transport) queueForDial(w *wantConn) {
    if t.MaxConnsPerHost <= 0 {
        go t.dialConnFor(w)
        return
    }
    // ...
}

4-3. Transport.dialConnFor

func (t *Transport) dialConnFor(w *wantConn) {
    defer w.afterDial()

    pc, err := t.dialConn(w.ctx, w.cm)
    // ...
}

4-4. Transport.dialConn

ここで persistConn を生成し、以下のようにゴルーチンを起動して、readLoop することで書き込んでいます。

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    pconn = &persistConn{
        t:             t,
        cacheKey:      cm.key(),
        reqch:         make(chan requestAndChan, 1),
        writech:       make(chan writeRequest, 1),
        closech:       make(chan struct{}),
        writeErrCh:    make(chan error, 1),
        writeLoopDone: make(chan struct{}),
    }
    trace := httptrace.ContextClientTrace(ctx)
    // ...
    pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
    pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())

    go pconn.readLoop()
    go pconn.writeLoop()
    return pconn, nil
}

4-5. persistConn.readLoop

readLoop では readResponse で生成したレスポンスを chan requestAndChan 型である rc 変数のチャネル ch にレスポンスを書き戻しています。

func (pc *persistConn) readLoop() {
    // ...
    alive := true
    for alive {
        // ...
        var resp *Response
        if err == nil {
            resp, err = pc.readResponse(rc, trace)
        } else {
            err = transportReadFromServerError{err}
            closeErr = err
        }
        // ...

        if !hasBody || bodyWritable {
            // ...
            if bodyWritable {
                closeErr = errCallerOwnsConn
            }

            select {
            case rc.ch <- responseAndError{res: resp}:
            case <-rc.callerGone:
                return
            }

            // Now that they've read from the unbuffered channel, they're safely
            // out of the select that also waits on this goroutine to die, so
            // we're allowed to exit now if needed (if alive is false)
            testHookReadLoopBeforeNextRead()
            continue
        }

        // ...

        resp.Body = body
        if rc.addedGzip && strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
            resp.Body = &gzipReader{body: body}
            resp.Header.Del("Content-Encoding")
            resp.Header.Del("Content-Length")
            resp.ContentLength = -1
            resp.Uncompressed = true
        }

        select {
        case rc.ch <- responseAndError{res: resp}:
        case <-rc.callerGone:
            return
        }
    }
}

4-6. persistConn.readResponse

readResponse はサーバから HTTP レスポンスを読み込みます。最終的に100以外のものを返します。

func (pc *persistConn) readResponse(rc requestAndChan, trace *httptrace.ClientTrace) (resp *Response, err error) {
    if trace != nil && trace.GotFirstResponseByte != nil {
        if peek, err := pc.br.Peek(1); err == nil && len(peek) == 1 {
            trace.GotFirstResponseByte()
        }
    }
    num1xx := 0               // number of informational 1xx headers received
    const max1xxResponses = 5 // arbitrary bound on number of informational responses

    continueCh := rc.continueCh
    for {
        resp, err = ReadResponse(pc.br, rc.req)
        if err != nil {
            return
        }
        // ...
        break
    }
    // ...

    resp.TLS = pc.tlsState
    return
}

4-7. ReadResponse

ReadResponseは、rからHTTPレスポンスを読み込んで返します。 reqパラメータは、オプションでこのレスポンスに対応するRequestを指定します。nilの場合は、GETリクエストを想定しています。クライアントは resp.Body の読み込みが終了したら resp.Body.Close を呼び出す必要があります。その呼び出しの後、クライアントは resp.Trailer を検査して、レスポンス・トレーラに含まれるキー/値のペアを見つけることができます。

最終的にこの ReadResponse 関数に行きつきます。textproto.NewReader を使ってレスポンスを読み込みながら、レスポンスを生成しています。読み込んだ文字列を RFC7230 の形式によってパースしていることが分かります。

func ReadResponse(r *bufio.Reader, req *Request) (*Response, error) {
    tp := textproto.NewReader(r)
    resp := &Response{
        Request: req,
    }

    // Parse the first line of the response.
    line, err := tp.ReadLine()
    if err != nil {
        if err == io.EOF {
            err = io.ErrUnexpectedEOF
        }
        return nil, err
    }
    if i := strings.IndexByte(line, ' '); i == -1 {
        return nil, &badStringError{"malformed HTTP response", line}
    } else {
        resp.Proto = line[:i]
        resp.Status = strings.TrimLeft(line[i+1:], " ")
    }
    statusCode := resp.Status
    if i := strings.IndexByte(resp.Status, ' '); i != -1 {
        statusCode = resp.Status[:i]
    }
    if len(statusCode) != 3 {
        return nil, &badStringError{"malformed HTTP status code", statusCode}
    }
    resp.StatusCode, err = strconv.Atoi(statusCode)
    if err != nil || resp.StatusCode < 0 {
        return nil, &badStringError{"malformed HTTP status code", statusCode}
    }
    var ok bool
    if resp.ProtoMajor, resp.ProtoMinor, ok = ParseHTTPVersion(resp.Proto); !ok {
        return nil, &badStringError{"malformed HTTP version", resp.Proto}
    }

    // Parse the response headers.
    mimeHeader, err := tp.ReadMIMEHeader()
    if err != nil {
        if err == io.EOF {
            err = io.ErrUnexpectedEOF
        }
        return nil, err
    }
    resp.Header = Header(mimeHeader)

    fixPragmaCacheControl(resp.Header)

    err = readTransfer(resp, r)
    if err != nil {
        return nil, err
    }

    return resp, nil
}

参考