フューチャー技術ブログ

Go 1.23リリース連載 keep-alive,Cookieのアップデート

はじめに

TIG所属の大江です。Go 1.23リリース連載の7本目です。

本記事では以下の内容を取り上げます。

  1. netでKeep-Aliveの詳細な設定が可能になりました
  2. net/httpにおけるCookieの扱いのアップデート

1. netでKeep-Aliveの詳細な設定が可能になりました

Keep-Aliveに関してより細かい設定が出来るようになりました。

新しく導入されたtype、KeepAliveConfigによって指定できます。

KeepAliveConfigは以下のように定義されています。

type KeepAliveConfig struct {
// If Enable is true, keep-alive probes are enabled.
Enable bool

// Idle is the time that the connection must be idle before
// the first keep-alive probe is sent.
// If zero, a default value of 15 seconds is used.
Idle time.Duration

// Interval is the time between keep-alive probes.
// If zero, a default value of 15 seconds is used.
Interval time.Duration

// Count is the maximum number of keep-alive probes that
// can go unanswered before dropping a connection.
// If zero, a default value of 9 is used.
Count int
}

次に各パラメーターについてご紹介します。

Enable

  • Keep-Alive Probeを送るかどうかを決定する。trueの場合送信する

検証したところ、Keep-Alive Probeを一切送らないようにするにはDialのフィールド、KeepAliveも負の数にする必要があります。以下のように設定するとKeep-Alive Probeが送信されなくなりました。

kac := net.KeepAliveConfig{
Enable: false,
Idle: 10 * time.Second,
Interval: 10 * time.Second,
Count: 100,
}
transport := &http.Transport{
Dial: (&net.Dialer{
KeepAliveConfig: kac,
KeepAlive: -1 * time.Second,
}).Dial,
}

その他の場合もどの設定が適用されるかを確認しました。以下の表の通りです。

Dial.KeepAliveの正負 KeepAliveConfig.Enabled どちらの設定が適用されるか
+ true KeepAliveConfig
+ false Dial.KeepAlive
- true KeepAliveConfig
- false 無(Keep-Alive Probeは送られない)

Idle

  • 最初のKeep-Alive Probeを送信するまでにコネクションがアイドル状態である時間
  • 0だとデフォルトの15秒に設定される

デフォルトの15秒はGo 1.22までの値と同じです。

Interval

  • Keep-Alive Probeが送信される間隔
  • 0だとデフォルトの15秒に設定される

デフォルトの15秒はGo 1.22までの値と同じです。

Count

  • コネクションを切るまでに送るKeep-Alive Probeの回数
  • この回数応答がなければコネクションを閉じる。0だとデフォルトの9回に設定される

デフォルトの9回はLinuxのデフォルト設定と同じです。

パケットキャプチャしてみる

以下のサーバーとクライアントを立て、WiresharkでKeep-Alive Probeをキャプチャしてみます。

サーバー側のコード

package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Println("keep-alive start")
})

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

if err := s.ListenAndServe(); err != nil {
panic(err) // サンプルコードのためpanicを使用
}
}

クライアント側のコード

package main

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

func main() {
c := &http.Client{
Transport: &http.Transport{
Dial: (&net.Dialer{
KeepAliveConfig: net.KeepAliveConfig{
Enable: true,
Idle: 5 * time.Second,
Interval: 5 * time.Second,
Count: 10,
},
}).Dial,
},
}

resp, err := c.Get("http://localhost:8080")
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
defer resp.Body.Close()
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
panic(err) // サンプルコードのためpanicを使用
}

// 100秒止めてサーバーとの通信を続ける
time.Sleep(100 * time.Second)
}

5秒ごとにKeep-Alive Probeが送られていることが確認できました。

2. net/httpにおけるCookieの扱いのアップデート

今回のアップデートでは、Cookieに関するアップデートがいくつか入りました。

まずはCookie関連のアップデートのポイントを紹介します。

  • 新規追加されたCookie.Quotedフィールドにより、Cookieの値が元々クォートされていたかどうかを判定できるようになりました
  • 新規追加されたRequest.CookiesNamed()により、指定された名前と一致するすべてのCookieを取得できるようになりました
  • 新規追加されたParseSetCookie()、ParseCookie()により、Set-CookieヘッダーからCookieを取得できるようになりました。ParseCookie()では、同じCookie名が複数回現れてもすべてのCookieを取得できます
  • 新規追加されたCookie.Partitionedフィールドにより、Partitioned属性を持つCookieを識別できるようになりました

新規追加されたCookie.Quotedフィールドにより、Cookieの値が元々クォートされていたかどうかを判定できるように

まず一点目のアップデートについてご紹介します。

修正の元となったIssueがこちらです。

Cookieについての仕様は、RFC6265の一部として以下のように策定されています。

cookie-pair   = cookie-name "=" cookie-value
...
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )

またクオーテーションについての扱いについて、RFC6265のドラフトのコメントでは以下のように書かれています。

Per the grammar above, the cookie-value MAY be wrapped in DQUOTE
characters. Note that in this case, the initial and trailing DQUOTE
characters are not stripped. They are part of the Cookie-value, and
will be included in Cookie headers sent to the server.

RFCにはっきり明記されている訳ではないので人によって解釈が分かれるところではありましたが、最終的にはRFCはクオーテーションをCookie-valueの一部としているという解釈に落ち着いたようです。

現状のnet/httpパッケージはCookie-valueをダブルクォーテーションを含まないものとして扱っているので、ダブルクォーテーションを残しておく修正が入りました。

結論として以下のような仕様に落ち着きました。

  • http.Cookieに新たなフィールドQuoted boolを追加する
    • Cookieをparseするときに、値からダブルクォートが削除された場合、Quoted=trueに設定する
    • Cookieを出力する際、もしQuoted=trueの場合は、ダブルクォートを値に再度追加する
    • スペースやカンマを含むCookieについては、Go言語の古いバージョンとの互換性のために、ダブルクォートが暗黙的に追加される

新旧比較

それでは実際に動かしてどのような点が変わったのか試してみます。

以下のコードをGo 1.23と1.22で動かしてみます。

処理内容としては以下です。

  1. Set-Cookieヘッダーを返すテストhttpサーバーを立てる
  2. テストHTTPサーバーに対してGET要求を送る。サーバーが返したSet-Cookieヘッダーに基づきCookieがクライアントのcookiejarに保存される
  3. 2回目のGet要求でCookieをサーバーに送信する

ここでは以下二点を確認します。

  • 2のSet-CookieヘッダーからcookiejarにセットしたCookieインスタンスの中身
  • 3でサーバーに送信されたCookieヘッダーの中身

実行したコードは以下です。

package main

import (
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
)

func main() {

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, resp *http.Request) {
w.Header().Add("Set-Cookie", `blank=`)
w.Header().Add("Set-Cookie", `no_quotation=value`)
w.Header().Add("Set-Cookie", `only_double_quotation=""`)
w.Header().Add("Set-Cookie", `with_double_quotation="value"`)
w.Header().Add("Set-Cookie", `with_space=va lue`)
w.Header().Add("Set-Cookie", `with_comma=va,lue`)

fmt.Printf("Cookie: %v", resp.Header["Cookie"])
}))
defer ts.Close()

jar, err := cookiejar.New(nil)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
client := &http.Client{Jar: jar}

resp1, err := client.Get(ts.URL)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
defer resp1.Body.Close()
if _, err := io.Copy(io.Discard, resp1.Body); err != nil {
panic(err) // サンプルコードのためpanicを使用
}

parsedURL, err := url.Parse(ts.URL)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
for i, cookie := range jar.Cookies(parsedURL) {
fmt.Printf("%v.\nName: %v\nValue: %v\nQuoted: %v\n", i+1, cookie.Name, cookie.Value, cookie.Quoted)
}

resp2, err := client.Get(ts.URL)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
defer resp2.Body.Close()
if _, err := io.Copy(io.Discard, resp2.Body); err != nil {
panic(err) // サンプルコードのためpanicを使用
}
}

まずSet-CookieヘッダーcookiejarにどのようなCookieが入るかですが、以下のようになりました。

Go 1.23での実行結果

1.
Name: blank
Value:
Quoted: false
2.
Name: no_quotation
Value: value
Quoted: false
3.
Name: only_double_quotation
Value:
Quoted: true
4.
Name: with_double_quotation
Value: value
Quoted: true
5.
Name: with_space
Value: va lue
Quoted: false
6.
Name: with_comma
Value: va,lue
Quoted: false
Go 1.22以前の実行結果(Quotedの部分を削除して実行)
1.
Name: blank
Value:
2.
Name: no_quotation
Value: value
3.
Name: only_double_quotation
Value:
4.
Name: with_double_quotation
Value: value
5.
Name: with_space
Value: va lue
6.
Name: with_comma
Value: va,lue

Go 1.22以前と以降でCookie.Valueの値は変わらず、ダブルクォーテーションが元々ついていた場合はCookie.Quoted=trueになっています。

Cookie.Valueの中身は変えないことで、Go 1.22以前の動作に影響を与えずかつクオーテーションの情報を付与するということが実現できているようです。

次に、サーバーに送信されたCookieヘッダーの中身を比較します。

Go 1.23での実行結果

Cookie: [blank=; no_quotation=value; only_double_quotation=; with_double_quotation="value"; with_space="va lue"; with_comma="va,lue"]

Go 1.22以前の実行結果(Quotedの部分を削除して実行)

Cookie: [blank=; no_quotation=value; only_double_quotation=; with_double_quotation=value; with_space="va lue"; with_comma="va,lue"]

cookie.Quoted=trueの場合は、ダブルクォーテーションがCookieヘッダーの前後に追加されて送信されます。
ちなみに、Cookieの値がダブルクォーテーションのみの場合は、ダブルクォーテーションだけになるのではなく空になりました。

新規追加されたRequest.CookiesNamed()により、指定された名前と一致するすべてのCookieを取得できるように

同じNameのCookieがブラウザから送られてきたとき、Request.Cookie()では指定した名前に一致する最初のCookieしか取得できませんでした。

Request.CookiesNamed()によって、同じNameの全てのCookieを取得できるようになりました。

package main

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

func main() {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Cookie("duplicate_name"))
fmt.Println(r.CookiesNamed("duplicate_name"))
}))
defer ts.Close()

req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
cookies := []*http.Cookie{
{Name: "duplicate_name", Value: "value"},
{Name: "duplicate_name", Value: "value2"},
{Name: "different_name", Value: "value3"},
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
defer resp.Body.Close()
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
panic(err) // サンプルコードのためpanicを使用
}
}

実行結果

duplicate_name=value <nil>
[duplicate_name=value duplicate_name=value2]

CookiesNamed()では同じ名前(“duplicate_name”)の2つの値(“value”,”value2”)を両方取り出すことが出来ました。

新規追加されたParseSetCookie()、ParseCookie()により、Set-CookieヘッダーからCookieを取得できるように

両者ともSet-CookieヘッダーからCookie型を取得するメソッドです。

ParseSetCookieは1つ、ParseCookieは複数のCookieを取得します。

package main

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

func main() {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Set-Cookie", `cookie1="value1"`)
w.Header().Add("Set-Cookie", `cookie2=value2`)
}))
defer ts.Close()

resp, err := http.Get(ts.URL)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
defer resp.Body.Close()
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
panic(err) // サンプルコードのためpanicを使用
}

// ParseSetCookie
for _, setCookie := range resp.Header["Set-Cookie"] {
cookie, err := http.ParseSetCookie(setCookie)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
fmt.Println(cookie)
}

// ParseCookie
var cookiesString string
for _, setCookie := range resp.Header["Set-Cookie"] {
cookiesString += setCookie + ";"
}
// 最後の";"を削除する
cookiesString = cookiesString[:len(cookiesString)-1]
cookies, err := http.ParseCookie(cookiesString)
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}
fmt.Println(cookies)
}

実行結果

cookie1="value1"
cookie2=value2
[cookie1="value1" cookie2=value2]

新規追加されたCookie.Partitionedフィールドにより、Partitioned属性を持つCookieを識別できるように

CHIPSに対応するため、Cookie.Partitionedフィールドが追加されました。

CHIPSとは、サードパーティーCookieを安全に扱えるようにするためGoogleが策定している仕様です。CHIPSでは、Partitioned属性が入っている場合、特定のトップレベルドメインのみに有効なサードパーティーCookieとして保存されます。

Chromeは2025年初頭から、現行のサードパーティCookieの廃止に向けた取り組みを進める予定です。この対応が行われると、CHIPSに対応している場合のみにサードパーティーCookieを使えるようになります。
そちらへ対応するため、今回Go 1.23へのアップデート項目に入りました。

以下が策定された仕様です。

  • Cookie parserで、Cookieに”; Partitioned”が入っている場合、boolはtrueに設定されます
  • Cookie.Stringでは、Partitionedがtrueの場合、文字列に”; Partitioned”が加えられます
  • Cookie.Validでは、Partitionedがtrueで、Cookieがセキュアでない場合、Validはエラーを返します

以下実行したコードです。

package main

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

func main() {
cookieParsed, err := http.ParseSetCookie("parsed_cookie=parsed_cookie; SameSite=None; Secure; HttpOnly; Path=/; Partitioned")
if err != nil {
panic(err) // サンプルコードのためpanicを使用
}

cookies := []*http.Cookie{
cookieParsed,
{Name: "secure_partitioned", Value: "secure_partitioned", Path: "/", Secure: true, Partitioned: true},
{Name: "no_secure_partitioned", Value: "no_secure_partitioned", Path: "/", Partitioned: true},
{Name: "no_partitioned", Value: "no_partitioned", Path: "/", Secure: true},
}
for _, cookie := range cookies {
fmt.Printf("{\"String\":\"%v\", \"Valid\":\"%v\", \"Partitioned\":\"%v\"}\n", cookie.String(), cookie.Valid(), cookie.Partitioned)
}
}

実行結果

cookie.Valid() はValidだとnil,Validでないとエラーメッセージが出力されます。

{"String":"parsed_cookie=parsed_cookie; Path=/; HttpOnly; Secure; SameSite=None; Partitioned", "Valid":"<nil>", "Partitioned":"true"}
{"String":"secure_partitioned=secure_partitioned; Path=/; Secure; Partitioned", "Valid":"<nil>", "Partitioned":"true"}
{"String":"no_secure_partitioned=no_secure_partitioned; Path=/; Partitioned", "Valid":"http: partitioned cookies must be set with Secure", "Partitioned":"true"}
{"String":"no_partitioned=no_partitioned; Path=/; Secure", "Valid":"<nil>", "Partitioned":"false"}

おわりに

net,net/httpパッケージの一部アップデート項目についてのご紹介でした。

個人的には、初めて言語仕様変更の議論を追ってみて、当初の提案から議論を経て修正されていく様子を見るのが興味深かったです。

また今回ご紹介したものには数年前に挙がったIssueが発端となり修正されたものが多く、長い綿密な議論を経てアップデートされていることも印象的でした。

次は棚井さんのGo Telemetryです。