golangの日記

Go言語を中心にプログラミングについてのブログ

Go言語(golang) HTTPリクエスト

golang.png


Go言語のnet/httpパッケージは Transport,Client,Request,Response に分けて考えたほうがいいので、
それらの役割を確認しながら使い方を説明してます。

GitHubを探せば gorequestrestysling などHTTPクライアント系パッケージがあるので、
コードを読んで参考にしながら、一度自分でパッケージを作ってみるとより理解が深まるのでおすすめです。





目次



基本的な使い方

大まかな使い方は httpのOverview に載っています。

それぞれの定義

以下は、http.NewRequest を使ったリクエスト送信です。

package main

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

func main() {
    // 第一引数: http.MethodGet は "GET" を指定するのと同じ
    // 第二引数: リクエストするURL
    // 第三引数: リクエストボディで フォーム、ファイル などのデータを送信する場合に使う。何も送信しない場合は nil を指定する
    req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    // リクエストの送信
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    // ステータスコード
    resp.StatusCode 
    // ヘッダーを取得
    resp.Header.Get("Content-Type")
    // resp.Bodyの大きさ len(body) と同じ
    resp.ContentLength
    // リクエストURL
    resp.Request.URL.String()

    // レスポンスボディをすべて読み出す
    body, _ := ioutil.ReadAll(resp.Body)
    // body は []byte
    fmt.Println(string(body))
}



Transport - トランスポート

http.DefaultTransportは以下のようになってます。
使用するときには、RoundTripper であることに注意

var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}


各ホストに対するKeep-Aliveの数
TransportのMaxIdleConnsPerHostに変更がない場合(値が0の場合)に、このデフォルト値が使われる

const DefaultMaxIdleConnsPerHost = 2


TLSを有効にする

package main

import (
    "crypto/tls"
    "log"
    "net/http"
)

func main() {
    req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    // 証明書を検証せずにリクエストを送る場合これを設定する
    http.DefaultClient.Transport = &http.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: true,
        },
    }

    _, err = http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
}


Proxy - プロキシ

デフォルトは ProxyFromEnvironment になっているので
環境変数に HTTP_PROXY, HTTPS_PROXY, NO_PROXY が設定してあった場合はそれが使われる。

// http.DefaultTransport は http.RoundTripper なので http.Transport に戻す
transport := http.DefaultTransport.(*http.Transport)

// http.Transport の Proxy にセットする
u, _ := url.Parse("proxy address")
transport.Proxy = http.ProxyURL(u)

client := &http.Client{
    Transport: transport,
}


Sock5 Proxy - ソックス 5 プロキシ

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "net/http"

    "golang.org/x/net/proxy"
)

type dialer struct {
    addr   string
    socks5 proxy.Dialer
    auth   *proxy.Auth
}

// 引数にコンテキストを受け取る
func (d *dialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
    return d.Dial(network, addr)
}

func (d *dialer) Dial(network, addr string) (net.Conn, error) {
    var err error
    if d.socks5 == nil {
        d.socks5, err = proxy.SOCKS5("tcp", d.addr, d.auth, proxy.Direct)
        if err != nil {
            return nil, err
        }
    }
    return d.socks5.Dial(network, addr)
}

func main() {
    d := dialer{
        addr: "localhost:9050",
        // 認証が必要ならユーザー名とパスワードとセットする
        // auth: &proxy.Auth{
        //     User:     "username",
        //     Password: "password",
        // },
    }

    // http.DefaultTransport は http.RoundTripper なので http.Transport に戻す
    t := http.DefaultTransport.(*http.Transport)
    // dialerをセットする
    t.DialContext = (&d).DialContext
    http.DefaultClient.Transport = t
    
    req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    _, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
}


RoundTripper - ラウンドトリッパー

RoundTripperは http.Transport に RoundTrip 関数を実装したインターフェイスのことなんですが、
リクエストの送信前とレスポンスを受け取った後に(ロギングやキャッシュなど)何らかの処理を付け加えるときに独自で作成します。

以下のように定義されているので、RoundTrip関数を実装します。

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

以下、簡単なRoundTrip実装。

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
)

type CustomTransport struct {
    Transport http.RoundTripper
}

// 念の為 CustomTransoprt.Transport が nil だったら http.DefaultTransport を返すようにする
func (c *CustomTransport) transport() http.RoundTripper {
    if c.Transport == nil {
        return http.DefaultTransport
    }
    return c.Transport
}

// RoundTrip の実装
func (c *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {

    // リクエストの内容を表示
    b, err := httputil.DumpRequest(req, false)
    if err != nil {
        return nil, err
    }
    log.Println(string(b))

    // ここでリクエストが送信される。本当の http.Transport の RoundTrip を呼び出す
    resp, err := c.transport().RoundTrip(req)
    if err != nil {
        return nil, err
    }

    // レスポンスの内容を表示
    b, err = httputil.DumpResponse(resp, false)
    if err != nil {
        return nil, err
    }
    log.Println(string(b))

    return resp, err
}

func main() {
    // 実装しておけば、そのまま簡素に書ける
    c := &http.Client{
        Transport: &CustomTransport{Transport: http.DefaultTransport.(*http.Transport)},
    }

    req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    _, err := c.Do(req)
    if err != nil {
        log.Fatal(err)
    }
}



Client - クライント

http.Clientは以下のように定義されてます。

type Client struct {
    Transport RoundTripper
    CheckRedirect func(req *Request, via []*Request) error
    Jar CookieJar
    Timeout time.Duration
}


デフォルトクライントは以下のようになっていてるので、DefaultTransportが使われることになる

var DefaultClient = &Client{}


http.Clientは1つを使う回す必要がある。
複数回リクエストを送る場合は、広いスコープに new しておくか、構造体にでも入れておく

package main

import (
    "log"
    "net/http"
)

func main() {
    urls := []string{
        "http://example.com/1",
        "http://example.com/2",
        "http://example.com/3",
    }

    client := new(http.Client)
        
    for _, v := range urls {
        // リクエストごとにクライントを new するのはダメ
        // client := new(http.Client)

        req, err := http.NewRequest(http.MethodGet, v, nil)
        if err != nil {
            log.Fatal(err)
        }

        _, err = client.Do(req)
        if err != nil {
            log.Fatal(err)
        }

    }
}



Timeout - タイムアウト

  • クライアントのTimeoutに秒数を設定する。0の場合は、タイムアウトしない
http.DefaultClient.Timeout = 120 * time.Second

// タイムアウトしたら、以下のようなエラーになる
// net/http: request canceled (Client.Timeout exceeded while awaiting headers)
  • コンテキストを使ったタイムアウト
package main

import (
    "context"
    "log"
    "net/http"
    "time"
)

func main() {
    req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)

    ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
    defer cancel()

    req = req.WithContext(ctx)

    _, err := http.DefaultClient.Do(req)
    if err != nil {
        // タイムアウトしたら、以下のエラーになる
        log.Fatal(err) // context deadline exceeded
    }
}



Redirect - リダイレクト

http.ClientCheckRedirect にリダイレクトポリシー関数をセットする。
回数制限以外にもリクエストの内容に問題があれば、エラーにして止めることができる

package main

import (
    "errors"
    "net/http"
)

func RedirectPolicyFunc(n int) func(*http.Request, []*http.Request) error {
    return func(req *http.Request, via []*http.Request) error {
        // req には、次のリクエストが入っている。
        // via にはリクエストが追加されてくので、その数で制限を設ける
        if len(via) >= n {
            return errors.New("Redirection limit exceeded")
        }
        return nil
    }
}

func main() {
    // FireFoxのリダイレクト(network.http.redirection-limit)は 20
    http.DefaultClient.CheckRedirect = RedirectPolicyFunc(20)
}


  • リダイレクトのURL
    // 最後のリクエストURL。リダイレクトの有無にかかわらずURLはここにある
    resp.Request.URL.String()

    // 最後から2番目のリクエストURL
    resp.Request.Response.Request.URL.String()
    
    // レスポンスのリクエストを辿っていくことで、リダイレクト元のURLをすべて知ることができる
    resp.Request.Response.Request.Response.Request...


  • リダイレクトURLをすべて取り出す
    // 試しはしたけど、多分こんなんで問題ないはず。

    var a []string
    r := resp
    for i := 0; r != nil; i++ {
        rv := reflect.ValueOf(r).Elem()
        vv := rv.FieldByName("Request")
        rp, ok := vv.Interface().(*http.Request)
        if !ok {
            break
        }
        a = append(a, rp.URL.String())
        r = rp.Response
    }



CookieJar - クッキージャー

cookiejar はレスポンスにクッキーがあれば保持して、次回以降のリクエストにクッキーを付加するやつです。

$ go get golang.org/x/net/publicsuffix
package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/cookiejar"
    "net/url"

    "golang.org/x/net/publicsuffix"
)

func main() {
    jar, err := cookiejar.New(&cookiejar.Options{
        PublicSuffixList: publicsuffix.List,
    })
    if err != nil {
        log.Fatal(err)
    }

    // jar をセットする
    http.DefaultClient.Jar = jar

    // 自動的にクッキーを使ってくれる
    req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
    _, err = http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }


    // 追加と取得は以下でできる
    cookies := []*http.Cookie{
        {
            Name:  "name",
            Value: "value",
            Path:  "/",
        },
    }

    // *url.URL でクッキーをセットする
    u, _ := url.Parse("https://example.com")
    http.DefaultClient.Jar.SetCookies(u, cookies)

    // URLのクッキーを取得
    cookie := http.DefaultClient.Jar.Cookies(u)
    fmt.Println(cookie) // [name=value]

    // 当然、違うドメインでは取得できない
    u, _ = url.Parse("https://www.example.com")
    cookie = http.DefaultClient.Jar.Cookies(u)
    fmt.Println(cookie) // []
}



Request - リクエスト

Requestは http.Request に定義されています。


Header - ヘッダー

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
)

func main() {
    req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    // Set か Add でヘッダーをセットする。削除する場合は Del を使う
    // 内部的に CanonicalMIMEHeaderKey が使われるので、accept のように小文字でも問題ない
    req.Header.Set("Accept", "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8")

    b, err := httputil.DumpRequest(req, false)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(string(b))
    // GET / HTTP/1.1
    // Host: example.com
    // Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8
}


Query - クエリ

最初からNewRequestにクエリ付きのURLを渡せばいいけど、追加するなどの場合は、
req.URL.Query()url.Values にしてから追加して再度戻す

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    // url.Values にする。
    values := req.URL.Query()
    values.Set("key", "value")
    // 戻す
    req.URL.RawQuery = values.Encode()

    fmt.Println(req.URL.String()) // http://example.com?key=value
}


Form - フォーム

http.PostForm を使ってもできるけど、NewRequest を使ったフォーム送信。

package main

import (
    "log"
    "net/http"
    "net/url"
    "strings"
)

func main() {
    // url.Values は、内部的には map なので、make する必要がある
    values := make(url.Values)
    values.Set("key", "value")

    // POSTメソッドにする
    req, err := http.NewRequest(
        http.MethodPost, "http://example.com", strings.NewReader(values.Encode()))

    if err != nil {
        log.Fatal(err)
    }

    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    _, err = http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
}


BasicAuth - ベーシック認証

package main

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
)

func main() {
    req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    req.SetBasicAuth("username", "password")

    b, err := httputil.DumpRequest(req, false)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(string(b))
    // GET / HTTP/1.1
    // Host: example.com
    // Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
}



UTF8にエンコード

go-encodingパッケージを使わせてもらって、この記事を参考にさせてもらう

$ go get https://github.com/mattn/go-encoding

テストした限りでは、Shift-JISからUTF-8に変換できました。

package main

import (
    "bufio"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net/http"

    "github.com/mattn/go-encoding"
    "golang.org/x/net/html/charset"
)

func ToUTF8FromReader(reader io.Reader, contentType string) io.Reader {
    br := bufio.NewReader(reader)
    var r io.Reader = br
    if data, err := br.Peek(4096); err == nil {
        enc, name, _ := charset.DetermineEncoding(data, contentType)
        if enc != nil {
            r = enc.NewDecoder().Reader(br)
        } else if name != "" {
            if enc := encoding.GetEncoding(name); enc != nil {
                r = enc.NewDecoder().Reader(br)
            }
        }
    }
    return r
}

func main() {
    req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    body, _ := ioutil.ReadAll(ToUTF8FromReader(resp.Body, resp.Header.Get("Content-Type")))
    fmt.Println(string(body))
}