Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18693] ファイルの概要

このコミットは、Go言語の crypto/tls パッケージに DialWithDialer 関数を追加するものです。これにより、net.Dialer を用いてTLS接続の確立時にタイムアウトなどの詳細なネットワーク設定を適用できるようになります。既存の Dial 関数は DialWithDialer を内部的に呼び出すように変更され、より柔軟な接続確立が可能になりました。

コミット

commit 1f8b2a69ec871c1e4c33b6df4b2127bbafd67495
Author: Adam Langley <agl@golang.org>
Date:   Fri Feb 28 09:40:12 2014 -0500

    crypto/tls: add DialWithDialer.
    
    While reviewing uses of the lower-level Client API in code, I found
    that in many cases, code was using Client only because it needed a
    timeout on the connection. DialWithDialer allows a timeout (and
     other values) to be specified without resorting to the low-level API.
    
    LGTM=r
    R=golang-codereviews, r, bradfitz
    CC=golang-codereviews
    https://golang.org/cl/68920045

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/1f8b2a69ec871c1e4c33b6df4b2127bbafd67495

元コミット内容

crypto/tls: add DialWithDialer.

このコミットは、crypto/tls パッケージに DialWithDialer 関数を追加します。 コード内で低レベルの Client APIの使用例をレビューしている際に、多くの場合、接続にタイムアウトが必要なためだけに Client を使用していることが判明しました。DialWithDialer は、低レベルAPIに頼ることなく、タイムアウト(およびその他の値)を指定できるようにします。

変更の背景

Go言語の crypto/tls パッケージは、TLS (Transport Layer Security) プロトコルを実装し、セキュアなネットワーク通信を提供します。TLSクライアントがサーバーに接続する際、通常は tls.Dial 関数を使用します。しかし、従来の tls.Dial 関数は、TCP接続の確立やTLSハンドシェイクの過程でタイムアウトを設定する直接的なメカニズムを提供していませんでした。

開発者が接続タイムアウトを制御したい場合、しばしば net.Dial で生のTCP接続を確立し、その接続に対して tls.Client を使用してTLSハンドシェイクを行うという、より低レベルなアプローチを取る必要がありました。この方法は冗長であり、net.Dialer が提供する豊富な接続設定(タイムアウト、デッドライン、ローカルアドレスのバインドなど)を tls.Dial の高レベルな抽象化と統合することが困難でした。

このコミットの背景には、このような「タイムアウトのためだけに低レベルAPIを使う」という一般的なユースケースを解消し、crypto/tls パッケージの使いやすさと柔軟性を向上させる目的があります。net.Dialer の機能を活用することで、TLS接続の確立プロセス全体にわたるタイムアウトやその他のネットワーク設定を、より簡潔かつ統一された方法で指定できるようになります。

前提知識の解説

このコミットを理解するためには、以下のGo言語のネットワークプログラミングに関する基本的な概念と crypto/tls パッケージの構造を理解しておく必要があります。

  1. net パッケージと net.Dialer:

    • Go言語の net パッケージは、ネットワークI/Oのプリミティブを提供します。TCP/UDP接続の確立、リスナーの作成などが含まれます。
    • net.Dial 関数は、指定されたネットワークアドレスへの接続を確立するためのシンプルな関数です。
    • net.Dialer は、より高度な接続設定を可能にする構造体です。これには、Timeout (接続試行全体のタイムアウト)、Deadline (接続が完了しなければならない絶対時刻)、LocalAddr (ローカルアドレスのバインド)、KeepAlive (TCPキープアライブの設定) などのフィールドが含まれます。Dialer.Dial メソッドは、これらの設定を適用してネットワーク接続を確立します。
  2. crypto/tls パッケージ:

    • Go言語でTLS/SSL通信を扱うための標準ライブラリです。
    • tls.Conn は、TLSハンドシェイクが完了した後のセキュアな接続を表します。net.Conn インターフェースを実装しているため、通常のネットワーク接続と同様に読み書きが可能です。
    • tls.Config は、TLS接続の振る舞いを設定するための構造体です。証明書、キー、ルートCA、サーバー名の検証、プロトコルバージョン、暗号スイートなどを指定できます。
    • tls.Dial(network, addr, config *tls.Config): 指定されたネットワークアドレスに接続し、TLSハンドシェイクを開始して、結果として得られるTLS接続を返します。内部的には net.Dial を使用します。
    • tls.Client(conn net.Conn, config *tls.Config): 既に確立された net.Conn (例えば、net.Dial で得られたTCP接続) をラップし、その上でTLSクライアントハンドシェイクを実行するための tls.Conn を作成します。この関数自体はハンドシェイクを開始せず、tls.ConnHandshake() メソッドを呼び出すことでハンドシェイクが実行されます。
  3. タイムアウト処理:

    • ネットワーク通信において、接続の確立やデータの送受信が指定された時間内に完了しない場合にエラーを発生させるメカニズムです。これにより、応答しないサーバーやネットワークの問題によってアプリケーションがハングアップするのを防ぎます。
    • Goでは、time.AfterFuncselect ステートメントとチャネルを組み合わせて非同期処理のタイムアウトを実装するのが一般的です。

このコミットは、net.Dialer の柔軟な接続設定能力と crypto/tls のセキュアな通信機能を統合することで、より堅牢で制御可能なTLSクライアント接続の確立を可能にしています。

技術的詳細

このコミットの主要な技術的変更点は、crypto/tls パッケージに DialWithDialer 関数を導入し、既存の Dial 関数をその上に再構築したことです。

DialWithDialer の導入

DialWithDialer 関数は以下のようなシグネチャを持ちます。

func DialWithDialer(dialer *net.Dialer, network, addr string, config *Config) (*Conn, error)

この関数は、net.Dialer のインスタンスを受け取ります。この dialer オブジェクトが持つ TimeoutDeadline の設定が、TCP接続の確立からTLSハンドシェイクの完了までの「全体」のタイムアウトとして適用されます。

内部的な処理フローは以下の通りです。

  1. タイムアウトの計算:

    • dialer.Timeoutdialer.Deadline の両方を考慮し、TLS接続確立プロセス全体のタイムアウト時間を決定します。
    • dialer.Timeout が設定されている場合、それが初期のタイムアウト候補となります。
    • dialer.Deadline が設定されている場合、現在の時刻からデッドラインまでの残り時間を計算し、それが timeout 候補よりも短い場合は、その残り時間を timeout として採用します。これにより、Deadline が優先されます。
  2. タイムアウト監視チャネルの準備:

    • 計算された timeout が0でない(つまりタイムアウトが設定されている)場合、errChannel というエラーチャネルを作成します。
    • time.AfterFunc(timeout, ...) を使用して、指定されたタイムアウト時間が経過した後に timeoutError{}errChannel に送信するゴルーチンを起動します。これは、接続とハンドシェイクがタイムアウトした場合にエラーを通知するためのメカニズムです。
  3. 基盤となる接続の確立:

    • dialer.Dial(network, addr) を呼び出して、基盤となるTCP接続 (rawConn) を確立します。ここで net.Dialer の設定(タイムアウト、デッドラインなど)が適用されます。
    • もし dialer.Dial がエラーを返した場合、そのエラーを直ちに返します。
  4. TLS接続の初期化:

    • tls.Client(rawConn, config) を呼び出して、確立された rawConn 上にTLSクライアント接続 (conn) を作成します。この時点ではまだTLSハンドシェイクは行われていません。
  5. TLSハンドシェイクの実行とタイムアウト処理:

    • timeout が0の場合(タイムアウトが設定されていない場合)、conn.Handshake() を直接呼び出してハンドシェイクを実行します。
    • timeout が0でない場合(タイムアウトが設定されている場合)、conn.Handshake() を別のゴルーチンで実行し、その結果を errChannel に送信します。
    • メインのゴルーチンは errChannel からエラーを待ち受けます。これにより、ハンドシェイクが完了したエラーか、タイムアウトエラーのいずれかを受け取ることができます。
    • ハンドシェイクがエラーを返した場合、またはタイムアウトエラーを受け取った場合、rawConn をクローズしてエラーを返します。
  6. 成功時の返却:

    • ハンドシェイクが成功した場合、確立された tls.Conn を返します。

Dial 関数の再構築

既存の tls.Dial 関数は、DialWithDialer を内部的に呼び出すように変更されました。

func Dial(network, addr string, config *Config) (*Conn, error) {
	return DialWithDialer(new(net.Dialer), network, addr, config)
}

これにより、tls.Dial を呼び出すユーザーは、明示的に net.Dialer を設定しなくても、デフォルトの net.Dialer を使用して DialWithDialer の恩恵を受けることができます。これは後方互換性を保ちつつ、内部実装を改善する典型的なパターンです。

timeoutError 型の導入

タイムアウトエラーを区別するために、timeoutError という内部的な型が導入されました。これは error インターフェースと、net.Error インターフェースの一部である Timeout() および Temporary() メソッドを実装しています。これにより、呼び出し元は返されたエラーがタイムアウトによるものかどうかを net.Error インターフェースを通じて判別できます。

テストケースの追加

tls_test.goTestDialTimeout という新しいテストケースが追加されました。このテストは、DialWithDialer が正しくタイムアウトを処理することを確認します。具体的には、リスナーが接続を受け入れた後にTLSハンドシェイクを意図的に遅延させ、DialWithDialer が設定されたタイムアウト時間内にエラーを返すことを検証しています。

この変更により、crypto/tls パッケージは、より柔軟で堅牢なTLSクライアント接続の確立機能を提供できるようになり、開発者はタイムアウト処理のために低レベルなネットワークAPIに直接触れる必要がなくなりました。

コアとなるコードの変更箇所

このコミットによる主要なコード変更は、src/pkg/crypto/tls/tls.gosrc/pkg/crypto/tls/tls_test.go の2つのファイルに集中しています。

src/pkg/crypto/tls/tls.go

  • import 文の追加: time パッケージがインポートされました。
  • timeoutError 型の定義:
    type timeoutError struct{}
    
    func (timeoutError) Error() string   { return "tls: DialWithDialer timed out" }
    func (timeoutError) Timeout() bool   { return true }
    func (timeoutError) Temporary() bool { return true }
    
  • DialWithDialer 関数の新規追加:
    • net.Dialer を引数として受け取る新しい関数が追加されました。
    • dialer.Timeoutdialer.Deadline を考慮して、接続全体のタイムアウトを計算するロジックが追加されました。
    • タイムアウトが設定されている場合、time.AfterFunc とチャネル (errChannel) を使用してタイムアウトを監視するメカニズムが導入されました。
    • dialer.Dial を使用して基盤となるネットワーク接続を確立します。
    • tls.Client でTLS接続オブジェクトを作成し、TLSハンドシェイクを(必要に応じてゴルーチン内で)実行します。
    • ハンドシェイクがタイムアウトした場合、またはエラーが発生した場合に適切に接続をクローズし、エラーを返します。
  • Dial 関数の変更:
    • 既存の Dial 関数が、新しく追加された DialWithDialer を内部的に呼び出すように変更されました。これにより、Dial の既存の振る舞いを維持しつつ、DialWithDialer の機能を利用できるようになりました。
    func Dial(network, addr string, config *Config) (*Conn, error) {
    	return DialWithDialer(new(net.Dialer), network, addr, config)
    }
    
    • 元の Dial 関数の実装は削除されました。

src/pkg/crypto/tls/tls_test.go

  • import 文の追加: net, strings, time パッケージがインポートされました。
  • TestDialTimeout 関数の新規追加:
    • DialWithDialer のタイムアウト機能をテストするための新しいテストケースが追加されました。
    • net.Listen でリスナーを作成し、クライアント接続を受け入れた後に意図的にハンドシェイクを遅延させることで、タイムアウトが発生する状況をシミュレートします。
    • net.Dialer に短い Timeout を設定し、DialWithDialer を呼び出します。
    • 返されたエラーがタイムアウトエラーであることを strings.Contains とエラーメッセージで検証します。

これらの変更により、crypto/tls パッケージは、より柔軟な接続タイムアウト制御をサポートするようになりました。

コアとなるコードの解説

src/pkg/crypto/tls/tls.go の変更点

DialWithDialer 関数の詳細

この関数は、net.Dialer を介して提供されるネットワーク設定(特にタイムアウト)をTLS接続の確立プロセス全体に適用することを目的としています。

func DialWithDialer(dialer *net.Dialer, network, addr string, config *Config) (*Conn, error) {
	// 1. タイムアウトの計算: dialerのTimeoutとDeadlineを考慮し、全体のタイムアウト時間を決定
	timeout := dialer.Timeout
	if !dialer.Deadline.IsZero() {
		deadlineTimeout := dialer.Deadline.Sub(time.Now())
		if timeout == 0 || deadlineTimeout < timeout {
			timeout = deadlineTimeout
		}
	}

	var errChannel chan error
	if timeout != 0 {
		// 2. タイムアウト監視チャネルの準備: タイムアウト時にエラーを送信するゴルーチンを起動
		errChannel = make(chan error, 2) // バッファサイズ2は、ハンドシェイクエラーとタイムアウトエラーの両方を受け取るため
		time.AfterFunc(timeout, func() {
			errChannel <- timeoutError{} // タイムアウト時にカスタムエラーを送信
		})
	}

	// 3. 基盤となる接続の確立: net.Dialerを使用してTCP接続を確立
	rawConn, err := dialer.Dial(network, addr)
	if err != nil {
		return nil, err
	}

	// サーバー名の抽出 (既存のDialロジックから移植)
	colonPos := strings.LastIndex(addr, ":")
	if colonPos == -1 {
		colonPos = len(addr)
	}
	hostname := addr[:colonPos]

	// configがnilの場合のデフォルト設定 (既存のDialロジックから移植)
	if config == nil {
		config = defaultConfig()
	}
	c := *config
	if c.ServerName == "" {
		c.ServerName = hostname
		config = &c
	}

	// 4. TLS接続の初期化: 確立されたrawConn上にTLSクライアント接続を作成
	conn := Client(rawConn, config)

	// 5. TLSハンドシェイクの実行とタイムアウト処理
	if timeout == 0 {
		// タイムアウトが設定されていない場合、直接ハンドシェイクを実行
		err = conn.Handshake()
	} else {
		// タイムアウトが設定されている場合、別のゴルーチンでハンドシェイクを実行し、結果をチャネルに送信
		go func() {
			errChannel <- conn.Handshake()
		}()

		// チャネルからエラーを待ち受ける (ハンドシェイク完了エラーかタイムアウトエラー)
		err = <-errChannel
	}

	// 6. エラー処理: ハンドシェイクが失敗した場合、rawConnをクローズ
	if err != nil {
		rawConn.Close()
		return nil, err
	}

	return conn, nil
}

この実装の鍵は、net.Dialer のタイムアウト設定をTCP接続だけでなく、その後のTLSハンドシェイクにも適用するために、time.AfterFunc とチャネルを用いた非同期監視メカニズムを導入している点です。これにより、接続確立からハンドシェイク完了までの一連の処理全体が指定された時間内に完了することが保証されます。

Dial 関数の変更点

元の Dial 関数は、DialWithDialer を呼び出すだけのシンプルなラッパーになりました。

// Dial connects to the given network address using net.Dial
// and then initiates a TLS handshake, returning the resulting
// TLS connection.
// Dial interprets a nil configuration as equivalent to
// the zero configuration; see the documentation of Config
// for the defaults.
func Dial(network, addr string, config *Config) (*Conn, error) {
	// デフォルトのnet.Dialerを使用してDialWithDialerを呼び出す
	return DialWithDialer(new(net.Dialer), network, addr, config)
}

これは、既存のAPIのセマンティクスを維持しつつ、内部的に新しい、より強力な機能を利用するための標準的なGoのパターンです。new(net.Dialer) は、タイムアウトやデッドラインが設定されていない、デフォルトの net.Dialer インスタンスを作成します。

src/pkg/crypto/tls/tls_test.go の変更点

TestDialTimeout 関数の詳細

このテストは、DialWithDialer のタイムアウト機能が期待通りに動作することを確認します。

func TestDialTimeout(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping in short mode")
	}

	// 1. リスナーの準備: 接続を受け入れるためのTCPリスナーを作成
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		listener, err = net.Listen("tcp6", "[::1]:0") // IPv6も試行
	}
	if err != nil {
		t.Fatal(err)
	}
	addr := listener.Addr().String()
	defer listener.Close()

	complete := make(chan bool) // テスト終了を通知するためのチャネル
	defer close(complete)

	// 2. サーバー側のゴルーチン: 接続を受け入れ、ハンドシェイクを意図的に遅延させる
	go func() {
		conn, err := listener.Accept() // クライアントからの接続を受け入れる
		if err != nil {
			t.Error(err)
			return
		}
		<-complete // completeチャネルが閉じられるまで待機 (ハンドシェイクを遅延させる)
		conn.Close()
	}()

	// 3. クライアント側の設定: 短いタイムアウトを持つnet.Dialerを作成
	dialer := &net.Dialer{
		Timeout: 10 * time.Millisecond, // 非常に短いタイムアウトを設定
	}

	// 4. DialWithDialerの呼び出し: タイムアウトが発生することを期待
	if _, err = DialWithDialer(dialer, "tcp", addr, nil); err == nil {
		t.Fatal("DialWithTimeout completed successfully") // タイムアウトするはずなので、成功したらエラー
	}

	// 5. エラーの検証: 返されたエラーがタイムアウトエラーであることを確認
	if !strings.Contains(err.Error(), "timed out") {
		t.Errorf("resulting error not a timeout: %s", err)
	}
}

このテストは、サーバー側で <-complete を使用して意図的にハンドシェイクをブロックすることで、クライアント側の DialWithDialer が設定された短いタイムアウト時間内にエラーを返すことを効果的に検証しています。これにより、DialWithDialer のタイムアウト処理の正確性が保証されます。

関連リンク

参考にした情報源リンク

  • Go CL 68920045: crypto/tls: add DialWithDialer. (このコミットのChange-ID): https://golang.org/cl/68920045
  • Goのソースコード (GitHub): https://github.com/golang/go
  • Go言語の公式ドキュメント: https://go.dev/doc/
  • Go言語におけるネットワークプログラミングの基本概念に関する一般的な情報源 (例: Go by Example, Go言語の書籍など)
  • TLS/SSLプロトコルに関する一般的な情報源 (例: RFC 5246など)