Goのnet/http, go-sql-driver/mysqlからnet.Connを取り出し、HTTPやMySQLのコネクションを直接読み書きする方法を紹介する。通常のライブラリ利用ではほとんど必要ないが、トランスポート層レベルでペイロードを書き換えたい時などに役立つ。

net/http

Client, Serverそれぞれについて説明する。

Client

http.Client構造体はTransportフィールドを持つ。このフィールドは通信に使用するトランスポート層を定めるものである。デフォルトではDefaultTransportが使用され、これはTransport構造体のインスタンスである。

このTransport構造体のDialContext(DialTLSContext)フィールドを利用してnet.Connにアクセスする。

DialContextはTCPコネクションの生成方法を定める関数である。デフォルトではnet.DialerのDialContextを使用する。DialTLSContextも同様だが、こちらはTLSを用いて暗号化された通信を対象とする。

以下のようにDialContext(DialTLSContext)をwrapする関数を作成し、その中でnet.Connにアクセスする。 例えば必要な値をContextに含めておいて、それを利用してnet.Connに書き込むといったことができる。

type valueKeyType int
var valueKey contextKeyType = iota

func main(){
	client := http.Client{
		Transport: wrapTransport(nil)
	}
	// Contextに任意の値を含めておいてリクエストに付与
	ctx := context.WithValue(context.Background(), valueKey, "test")
	req, err := http.NewRequestWithContext(ctx, "GET", "http://~", nil)
	if err != nil {
		log.Fatal(err)
	}
	resp, err := client.Do(req)
	...
}

func wrapTransport(base *http.Transport) *http.Transport {
	if base == nil {
		base = http.DefaultTransport.(*http.Transport)
	}

	t.DialContext = wrapDialContext(base.DialContext)
	t.DialTLSContext = wrapDialContext(base.DialTLSContext)
	return t
}

func wrapDialContext(dc func(ctx context.Context, network, addr string) (net.Conn, error)) func(ctx context.Context, network, addr string) (net.Conn, error) {
	if dc == nil {
		return nil
	}
	return func(ctx context.Context, network, addr string) (net.Conn, error) {
		conn, err := dc(ctx, network, addr)
		if err != nil {
			return nil, err
		}

		// Contextから値を取り出す
		value := ctx.Value(valueKey)
		// net.Connに対する処理
		conn.Write([]byte(value))

		return conn, err
	}
}

なおKeep Aliveには注意する必要がある。HTTPでは可能な限りコネクションを再利用し、一つのコネクションで複数のリクエスト・レスポンスをやり取りする。 つまり取り出したnet.Connは複数のリクエストで使いまわされている可能性がある。 防ぎたい場合は、Transport構造体のDisableKeepAlivesをtrueにする。

func wrapTransport(base *http.Transport) *http.Transport {
	...
	t.DisableKeepAlives = true
	...
}

Server

http.Server構造体のConnContextフィールドを利用してnet.ConnをContextに含めることで、handleする際にContextを通じてnet.Connにアクセスできるようにする。

type connKeyType int
var connKey connKeyType = iota

func main() {
	http.HandleFunc("/", handler)

	server := &http.Server{
		Addr: ":8080",
		ConnContext: ConnContext,
	}

	log.Fatal(server.ListenAndServe())
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	conn := ctx.Value(connKey)
	conn.Write([]byte("test"))
	...
}

func ConnContext(ctx context.Context, conn net.Conn) context.Context {
	return context.WithValue(ctx, connKey, conn)
}

なおhandlerでhttp.RequestからContextを取り出す処理は、echoなどのフレームワークごとにやり方が異なる。

go-sql-driver/mysql

go-sql-driver/mysqlはGoでMySQLサーバーに通信する際のクライアントとなる。SQL関連のインターフェースをまとめるdatabase/sqlのdriverとして実装される。

net/http.Clientと同様に、このライブラリにもDialContextが用意されている。それを利用する。

type valueKeyType int
var valueKey contextKeyType = iota

func main() {
	// "mytcp"をDBオープン時のData Sourse Nameに使用する("tcp"を指定してもよい)
	mysql.RegisterDialContext("mytcp", DialContext("tcp"))

	db, err := sql.Open("mysql", "user:password@mytcp(localhost:3306)/database")
	if err != nil [
		log.Fatal(err)
	]
	defer db.Close()

	// Contextに任意の値を含めておいてリクエストに付与
	ctx := context.WithValue(context.Background(), valueKey, "test")

	_, err := db.ExecContext(ctx, "INSERT INTO ...")
	... 
}

func DialContext(netP string) mysql.DialContextFunc {
	return func(ctx context.Context, addr string) (net.Conn, error) {
		// デフォルトのTCPコネクションの生成がハードコードされているのでこちらにも書く
		// ref: https://github.com/go-sql-driver/mysql/blob/bcc459a906419e2890a50fc2c99ea6dd927a88f2/connector.go#L48-L49
		nd := net.Dialer{}
		conn, err := nd.DialContext(ctx, netP, addr)
		if err != nil {
			return nil, err
		}

		// Contextから値を取り出す
		value := ctx.Value(valueKey)
		// net.Connに対する処理
		conn.Write([]byte(value))

		return conn, err
	}
}

net/http.ClientのKeep Aliveと同様に、MySQLでもデフォルトではコネクションを再利用する。防ぎたい場合は、MaxIdleConnsを0にする。

func main() {
	...
	defer.db.Close()
	db.SetMaxIdleConns(0)
	...
}

なおトランスポート層レベルでの実装はそれぞれのdriver依存なので、MySQLに限らないdatabase/sqlで同様の実装はできない。

まとめ

今回はnet/httpとgo-sql-driver/mysqlについて紹介した。それ以外のライブラリでもDialContext, ConnContextといった関数が用意されていれば同様のことが可能かもしれない。 気が向けば調べて記事にしたい。

研究で必要になったが参考になる記事がなく、ライブラリのコードを読んで調べた。 なかなか需要のない話ではあるが、だからこそ必要になった時に役立てばうれしい。

2023-12-02追記: Go Conference mini 2023 Winter IN KYOTOでこの内容について話したのでスライドもご覧ください。

参照