フューチャー技術ブログ

Go 1.18集中連載 net/httpのマイナーチェンジ

はじめに

TIG DXユニット真野です。Go 1.18連載の2本目です。

Go Release Notes に記載があったMinor changes to the libraryにあったnet/httpの3点の更新について触れていきます。

  1. WebAssemblyの改善について
  2. Cookie.Valid()の追加
  3. MaxBytesHandlerの追加

なお、2022年2月6日にGo 1.18 beta2で調べていますのでご注意ください。また、登場するコードはここにコミットしています。

(1)WebAssemblyの改善について

Go 1.18からWebAssemblyでDialContext、DialTLS、DialTLSContext が正しく動くようになりました。

リリースノートから引用します。

On WebAssembly targets, the Dial, DialContext, DialTLS and DialTLSContext method fields in Transport will now be correctly used, if specified, for making HTTP requests.
https://tip.golang.org/doc/go1.18#minor_library_changes

net/httpはサーバー・クライアントの両方を含んでいますがWebAssemblyというだけあってクライアントの話です。トランスポートのDial、DialContext、DialTLS、およびDialTLSContextメソッドフィールドが指定されている場合、HTTPリクエストが正しく使用されるようになりました。..ということです。そのままですが詳しく説明していきます。

どういうことか

net/http: Expose the normal transport RoundTripper for WASM/js #27495のIssueで触れられています。

http.Clientを拡張する手段としてトランスポートがあります。例えば必ずエラーになるような拡張を行うと、HTTP GETが必ず失敗するはずです。

トランスポートを使った例
func main() {
c := http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, fmt.Errorf("あえてエラーにします")
},
}}
resp, err := c.Get("https://api.ipify.org/")
if err != nil {
fmt.Println(err) // 必ずここでエラーになるはず
return
}
defer resp.Body.Close()

if _, err = io.Copy(os.Stdout, resp.Body); err != nil {
fmt.Println(err)
}
fmt.Printf("\nDone.\n")
}

実際動かすと、Get "https://api.ipify.org/": あえてエラーにします が出力されるでしょう。

このコードをGo 1.17で wasm 版で動かすと動かないよーということでした。

実際に動かしてみますが、Goのwasm対応は少しお作法が多く、先程のコードもお作法にそって修正する必要があります。

GoのWASMがライブラリではなくアプリケーションであること の記事を参考にしました。

wasm対応させるために修正したmain.go
package main

import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"syscall/js" // wasm利用で必要なパッケージ
)

func main() {
c := make(chan struct{}, 0) // チャネル呼び出しはお作法
js.Global().Set("getIp", js.FuncOf(GetIp)) // JS側で呼び出すための関数登録
<-c
}

func GetIp(_ js.Value, _ []js.Value) interface{} {
go func() { // HTTPリクエストを送信する場合は、goroutine化する必要がある
c := http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, fmt.Errorf("あえてエラーにします")
},
}}

resp, err := c.Get("https://api.ipify.org/")
if err != nil {
appendHTMLBody(fmt.Sprintf("http get: %s", err))
return
}
defer resp.Body.Close()

b := bytes.NewBuffer(nil)
if _, err = io.Copy(b, resp.Body); err != nil {
appendHTMLBody(fmt.Sprintf("read body: %s", err))
return
}
appendHTMLBody(b.String())
}()
return "OK"
}

func appendHTMLBody(s string) {
var document = js.Global().Get("document")
var p = document.Call("createElement", "p")
p.Set("textContent", s)
document.Get("body").Call("appendChild", p)
}

コード中にコメントにも書いていますが、wasmでHTTPリクエストを送る場合にブロッキングさせると wasm: fatal error: all goroutines are asleep - deadlock! #34478 にあるようにdeadlock! と表示されます。回避するためには別goroutineを利用する必要があるので、ひと手間ラップしています。appendHTMLBody は見たままですが、bodyに <p> タグを追加してIP情報(HTTPレスポンス)かエラーメッセージを表示します。

これをビルドします。

$ go version
go version go1.17.6 windows/amd64

# ビルド(Go 1.17.6)
$ set GOOS=js
$ set GOARCH=wasm
$ go build -o main1.17.wasm

# ビルド(Go 1.18beta2)
$ go1.18beta2 build -o main1.18beta2.wasm

続いて以下のようなHTMLを作成し、さきほどのw main.1.17.wasmと、main.1.18beta2.wasm と同じ階層に配備し何かしらのWebサーバでホストさせます。wasm_exec.jsはGoインストールしたフォルダに準備されているのでコピーして持ってきます。

index.html
<html>
<head>
<meta charset="utf-8">
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
// WebAssembly.instantiateStreaming(fetch("main1.17.wasm"), go.importObject).then((result) => {
WebAssembly.instantiateStreaming(fetch("main1.18beta2.wasm"), go.importObject).then((result) => {
go.run(result.instance);
getIp();
});
</script>
</head>
<body><h1>net/http wasm調査</h1></body>
</html>

実行結果を比較すると、当然ながらGo 1.18ではトランスポートが正しく機能している(あえて発生させたエラーが表示される)ことが分かります。

1.17実行結果

カスタムラウンドトリッパーが無視され、普通に通信が行われます。

1.17実行結果

1.18実行結果

カスタムラウンドトリッパーが有効に動き、想定通りエラーメッセージが表示されます。

1.18実行結果

標準パッケージのどのような修正だったかというと概ね以下の修正方針だったそうで、goosがjsだったときはデフォルトのラウンドトリッパーを使わず、jsRoundTripperという構造体を新たに使うようになったようです。

https://github.com/golang/go/commit/e8050da2dd93f4ff00a590c14f94c31da3c3159b

(2)Cookie.Valid()の追加

HTTPリクエストヘッダーからCookieを読み取るロジックでは、Cookieのキーや値のパースに失敗した場合は切り捨てる(標準エラーに出力する)設計だったそうです。切り捨てられたかどうか判定するためにValid()の関数を追加しようよという提案でした。

https://github.com/golang/go/blob/master/src/net/http/cookie.go#L288-L303

もとのIssueはnet/http: add Cookie.Valid method #46370 で、かなり議論が長いです。そもそもValidかどうか知ってもハンドリングできないだろうとか、無効な値を送信することがそもそも~とか、Serialize()関数を追加すべきとか、RFC 6265準拠について誤解が無いようにしようなど、いろいろな意見があり興味深いです。

(3)MaxBytesHandlerの追加

net/http: add MaxBytesHandler(h Handler, n int64) Handler #39567 で提案されています。

みなさん、HTTPリクエストボディをjson.Unmarshal() なんかで読み取るコードは覚えきれないほど書いて来たかと思いますが、何かしら悪意のあるクライアントが大容量のペイロードを送信してきたときはメモリ溢れ(オーバーフローなど)が起こる懸念があります。DDOS攻撃の一種かと思うので、セキュリティ的な改善につながるかと思います。

すでにいくつかのブログで紹介されていました。詳しい..。

こういったストリームの読み取り時は、io.CopyN() を使ってまるごと読み取らないようにしようよといったお決まりがありましたが、それがさらに標準化されたのは良い流れかなと思います。ぜひ使っていきましょう。

さいごに

Go1.16のときはgo installについてまとめ、Go 1.17連載のときはencoding/csv について調べました。マイナーチェンジ系もIssueなどの議論を追っていくと自分にとってちょうど良いサイズで、学びがありオススメです。

この記事では、wasmのお作法をあまり理解せずかなりハマりましたが、こういう機会でないと使わないので楽しかったです。

最後まで読んでいただき、ありがとうございます!