The Go gopher was designed by Renee French.
Go 1.27 リリース連載 の 6 本目です。
はじめに
本記事では Go 1.27 で標準ライブラリに追加された uuid パッケージを扱います。
パッケージの概要は、すでに Go 1.27 で標準ライブラリに追加される UUID パッケージ や Go の標準ライブラリに uuid パッケージが入る で解説されています。
本記事は API の紹介に加え、google/uuid と比較した実測ベンチマークや移行などにも言及します。
アップデートの概要
リリースノート の記述はきわめて簡潔です。
The new
uuidpackage generates and parses UUIDs.
パッケージ名は crypto/uuid ではなく uuid に落ち着きました。
生成できるのは UUIDv4 と UUIDv7 だけで、v1/v2/v3/v5/v6 の生成関数は入っていません。
主なディスカッションは次の Proposal と Change List を見ればわかります。
Proposal
https://github.com/golang/go/issues/62026(2023 年起票、2026 年 4 月 8 日に Accept)
https://github.com/golang/go/issues/23789(2018 年に起票され、当時は見送られた最初の提案)
API の全体像はこれだけです。かなり小さくまとまっています。
package uuid |
そもそもの話
本題へ入る前に、UUID そのものと、標準ライブラリ入りに至った経緯を軽くおさらいします。
UUID とは
UUID(Universally Unique Identifier)は 128 ビットの識別子です。
中央集権的な採番機構を用意しなくても、複数のノードがそれぞれ勝手に生成して衝突しない、という点が最大の価値です。
Go 1.27 の uuid パッケージが準拠するのは RFC 9562 で、これは 2024 年に RFC 4122 を置き換えた最新の仕様です。
テキスト表現は 8-4-4-4-12 のハイフン区切り小文字 16 進数で、f81d4fae-7dec-11d0-a765-00a0c91e6bf6 のような形になります。
128 ビットのうち 6 ビットは、固定的な意味を持つフィールドに使われます。
4 ビットの version が生成アルゴリズムの種類を、2 ビットの variant がレイアウトの種類を表します。
テキスト表現で見ると、それぞれ 3 番目と 4 番目のグループの先頭の桁に現れます。
f81d4fae-7dec-11d0-a765-00a0c91e6bf6 |
ここで 16 進数の 1 桁は 4 ビットである点に注意が必要です。
version は 4 ビットをまるごと使いますが、variant は先頭の 2 ビットしか使いません。
上の例の該当桁を 2 進数へ展開すると、次のようになります。
version の桁 "1" |
実際に使われるバージョンを並べておきます。
このほかに、実装が中身を自由に決められる v8 が実験・ベンダー独自用途として定義されています。
| バージョン | 生成方法 | 時系列ソート | Go 1.27 での生成 |
|---|---|---|---|
| v1 | 時刻(100 ナノ秒単位)とノード ID(通常は MAC アドレス) | 不可 | 非対応 |
| v2 | DCE Security。定義は RFC 9562 の範囲外 | 不可 | 非対応 |
| v3 | 名前空間と名前の MD5 ハッシュ | 不可 | 非対応 |
| v4 | 122 ビットの乱数 | 不可 | NewV4 |
| v5 | 名前空間と名前の SHA-1 ハッシュ | 不可 | 非対応 |
| v6 | v1 と同じ要素を時刻順に並べ替えたもの | 可 | 非対応 |
| v7 | UNIX 時刻(ミリ秒)と乱数 | 可 | NewV7 |
「時系列ソート」は、パースせずにバイト列や文字列のまま並べ替えても時刻順になる、という意味です。
v1 も時刻を持っていますが、タイムスタンプが上位・中位・下位に分割して配置されているため、そのまま並べても時刻順にはなりません。v6 はこれを並べ替えて解消したもので、RFC 9562 はこの 2 つだけをソート可能なバージョンとして位置付けています。
なぜいま標準ライブラリに入ったのか
UUID を標準ライブラリへ入れる提案は #23789 として 2018 年にすでに存在していましたが、「標準ライブラリに何が必要なのか情報が足りず、当面はサードパーティで十分」という結論で見送られていました。
その後 google/uuid をはじめとするライブラリが広く使われるようになりました。
標準ライブラリへの導入の決め手は、次の 2 点でした。
1 つは相互運用性です。ライブラリが乱立していると、UUID 型もそれぞれ別物になります。net/url が URL の共通表現を提供しているのと同じように、標準の uuid があれば、どのライブラリとも UUID をそのまま受け渡せます。
もう 1 つは、2018 年の時点で足りないとされた「標準ライブラリに何が必要か」という情報が、実データとして得られるようになった点です。これだけ広く使われた結果、どの API がどれだけ使われているかを調べられるようになりました。
google/uuid の利用箇所を 調査した結果 が次のものです。
function usages percentage cumulative |
上位 4 つ(生成・文字列化・パース)で全体の約 89% を占め、バージョンや時刻を取り出す introspection 系はほとんど使われていません。同時は判断できなかった「何を入れるべきか」が明確になったわけです。
使ってみる
生成
新しい UUID をつくる関数は New、NewV4、NewV7 の 3 つです。
アルゴリズムにこだわりがなければ New、明示したいときは NewV4 か NewV7 を呼びます。
これとは別に、RFC 9562 が定義する固定値を返す Nil と Max があります。
package main |
$ go run ./gen |
どれもエラーを返しません。
乱数の取得に crypto/rand.Read を直接使っており、この関数は Go 1.24 以降エラーを返さない仕様(失敗時はプロセスをクラッシュさせる)になっているためです。
google/uuid の NewRandom がエラーを返していたのは、この仕様変更より前のなごりです。
パース
Parse が受け付ける表記は 4 種類あります。
ハイフン区切りの標準形に加えて、波括弧付き、URN 形式、ハイフンなしの 32 文字が通ります。
16 進数の英字は大文字と小文字のどちらでもかまいません。
func main() { |
$ go run ./parse |
この緩さは意図的なものです。
google/uuid と satori/go.uuid が受け付ける表記をすべて受け入れることで、移行時に挙動が変わらないようにしています。厳密に検証したい場合は Parse へ渡す前に自分で長さをチェックする、というのが Proposal での 結論 でした。
エラーの内容は invalid uuid の 1 種類だけです。google/uuid のように長さ違いと文字違いでエラー型を分けるようなことはしていません。
比較とソート
UUID の実体は [16]byte であり、== で比較でき、map のキーとしてもそのまま使えます。
順序が必要な場面では Compare メソッドを使います。
func main() { |
$ go run ./cmp |
slices.SortFunc(ids, uuid.UUID.Compare) のようにメソッド式を渡せば、比較関数を書く必要もありません。
並び順は RFC 9562 が定めるビッグエンディアンのバイト順で、時刻順に意味を持つのは v6 と v7 だけである点には注意してください。
JSON でのやりとり
UUID は MarshalText / UnmarshalText を実装しているため、encoding/json では自動的に文字列として扱われます。
type User struct { |
$ go run ./jsondemo |
注意したいのは、UnmarshalText が Parse と同じ 4 種類の表記を受け付ける点です。
上の例のように、波括弧付きで大文字の JSON が何のエラーもなく通ります。
外部から受け取る JSON を厳密に検証したい場合は、len(s) == 36 のような長さチェックを自分で足す必要があります。
API の設計思想を Proposal から読む
Go 1.27 の uuid パッケージが驚くほど小さいのは、先ほどの利用実績をそのまま API 設計へ反映したためです。
その判断の過程は Proposal に残っています。#62026 には 300 件を超えるコメントが付き、Accept 時には実装者である neild 氏が 設計判断の根拠をまとめたコメント を残しました。
ここからいくつか拾ってみます。
なぜ crypto/uuid ではなく uuid なのか
当初の提案は crypto/uuid でした。crypto/rand の安全な乱数を使う点を名前で示す意図があったためです。
しかし RFC 9562 に暗号学的な用語がほとんど登場せず、暗号強度は「安全な乱数ソースを推奨する」という文脈でしか扱われません。
gofrs/uuid を保守している dylan-bourque 氏の この主張 が決め手となり、より短い uuid が選ばれました。
なぜ [16]byte なのか
不透明な構造体にする案もありましたが、既存パッケージのほぼすべてが [16]byte を採用しているため、同じ表現にしておけば単なる型変換で相互変換できるという利点が決め手になりました。
不正な UUID を表す [16]byte はどうするのかという問いには、RFC 9562 が有効性という概念を定義していない、つまり不正な UUID というものは存在しない、とプロポーザルでも回答されています。
現行のどのアルゴリズムでも生成されない 16 バイト値は存在しますが、それは不正であることを意味しません。
なぜ New と NewV4 の両方があるのか
現時点で両者の挙動は同一です。
それでも両方あるのは、呼び出し側の意図を表現できるからだと説明されています。NewV4 を呼ぶのは「UUIDv4 が欲しい」という宣言であり、New を呼ぶのは「UUID が欲しいが生成アルゴリズムにはこだわらない」という宣言です。将来より良いデフォルトが現れたとき、New の利用者だけを移行させられます。
なぜデフォルトが v7 ではなく v4 なのか
UUIDv7 は時刻順に並ぶため、B-tree インデックスへの大量挿入ではたしかに有利です。
それでも v4 がデフォルトに選ばれたのには、次の理由があります。
- Cassandra、CockroachDB、Spanner のように水平シャーディングされたデータストアでは、時刻順に並ぶ ID は書き込み先が特定のノードへ偏り、ホットスポットになる
- v7 には生成時刻が埋め込まれるため、ID を外部へ公開していると作成日時が読み取れる
v4 は単なる乱数なので性能やセキュリティ上の懸念がなく「最も安全なデフォルト」だと結論づけられています。
なぜ NewRandom ではなく NewV4 なのか
NewRandom のほうが説明的ではありますが UUIDv7 に対応する命名が困難です。
NewSequential や NewMonotonic という候補は、v6 と v7 の両方へ等しく当てはまってしまいます。v4 だけ説明的な名前にするのは不整合ですし、NewRandom という名前が将来にわたって一意な意味を保つ保証もありません。
結果としてバージョン番号で命名する方式が採用されました。
Nil と Max が変数から関数になった経緯
google/uuid は var Nil という公開変数として定義しており、互換性を重視するなら変数にすべきという意見と、書き換えられる公開変数は避けるべきという意見が対立しました。
Proposal レビュー側は当初「実害の証拠があれば関数にする」というスタンスでしたが、実際に google/uuid の変数が意図せず書き換えられて問題になった事例 が提示されたことで、最終的に関数へ倒れました。
なお uuid.UUID のゼロ値は Nil UUID そのものですので、実用上は id == uuid.UUID{} でも id == uuid.Nil() でも同じです。
採用されなかったもの
Go の互換性保証のもとでは、一度公開した API は取り下げられません。そのため「迷ったら入れない」方針が徹底されており、次のものが見送られました。
1 つめは v1/v2/v3/v5/v6 の生成です。生成関数の利用実績を 集計 すると v4 が 94.17%、v1 が 4.39%、v7 が 1.22% で、残りは合計 0.22% でした。v1 利用の大半は NewUUID という紛らわしい名前に手が伸びただけと推測されています。v3/v5 は MD5/SHA-1 という壊れたハッシュに依存しており、新規に決定的な UUID が必要なら、自由形式の v8 で現代的なハッシュを使うほうがよい、という整理です。
2 つめは Version() や Time() といった introspection です。利用実績がほとんどなく、Google 社内のコードベースを調べても Version の利用箇所の大半は「バージョン 4 以外を理由もなく弾く」という誤用だった、と報告されています。
3 つめは UUIDv7 の生成時刻を指定する API です。時刻を取り出す手段を提供しないと決めた以上、設定だけできるのは不自然という理由です。「時刻漏洩を避けるためにランダムなオフセットを入れるべき」という議論もありましたが、一貫したオフセットは推測可能で、可変にすると v7 の性能上の利点が失われます。
4 つめは Generator 型による乱数ソースの差し替えです。インジェクションしたいなら func() uuid.UUID を渡せば十分、という結論になりました。
ベンチマーク
google/uuid v1.6.0 と比較したベンチマークを取りました。
bench/bench_test.go
package bench |
環境は go1.27rc2 / linux/arm64 / 12 コアです。
$ go test -run=NONE -bench=. -benchmem ./bench |
いくつかポイントを説明します。
まずは、生成側のアロケーションがゼロになっている点です。google/uuid は io.ReadFull(rander, uuid[:]) のようにインターフェース越しに配列を渡すため 16 バイトのヒープアロケーションが発生しますが、標準ライブラリは crypto/rand.Read を直接呼ぶのでエスケープしません。この差もあって、v7 では 1.5 倍の性能差がでています。
意外だったのは NewV7 を並列化したときの性能です。NewV4 は 12 コアで 84ns から 8.5ns へとほぼコア数分だけ速くなるのに、NewV7 は 99ns から 205ns へ悪化しました。NewV7 は単調増加を保証するためにパッケージレベルで採番を直列化しており、これがそのままボトルネックになります。とはいえ 1 秒あたり約 500 万件は生成できる計算ですので、実際にこれが問題になるのは相当に特殊なワークロードだけでしょう。
逆に Parse と String は、わずかながら google/uuid のほうが速いという結果になりました。標準ライブラリ版は Parse を UnmarshalText 経由で実装しているぶん、専用に最適化された実装には劣るのではないかと考えられます。差は数ナノ秒ですので、実用上は誤差の範囲です。
google/uuid からの移行
型が同じ [16]byte ですので、移行そのものは難しくありません。
主な対応関係を表にまとめました。
| google/uuid | 標準ライブラリ uuid |
備考 |
|---|---|---|
uuid.New() |
uuid.New() |
|
uuid.NewString() |
uuid.New().String() |
|
uuid.NewRandom() |
uuid.NewV4() |
標準はエラーを返さない |
uuid.NewV7() |
uuid.NewV7() |
標準はエラーを返さない |
uuid.NewUUID() |
なし | |
uuid.NewMD5 / NewSHA1 |
なし | |
uuid.Parse() |
uuid.Parse() |
|
uuid.ParseBytes() |
u.UnmarshalText(b) |
|
uuid.MustParse() |
uuid.MustParse() |
|
uuid.Validate() |
なし | Parse のエラーで判定する |
uuid.Nil |
uuid.Nil() |
|
uuid.Max |
uuid.Max() |
|
u.Version() |
なし | 必要なら u[6]>>4 を自前で書く |
u.Time() |
なし | |
u.MarshalBinary() |
u[:] |
実体が配列なのでスライス化するだけ |
u.Scan() / u.Value() |
なし | database/sql 側が uuid.UUID を認識する |
おわりに
300 件を超える議論の末に残ったのが 12 個の関数とメソッドだけ、というのは、いかにも Go らしい結論だと思います。
標準で使えるようになったのは非常にうれしいアップデートですね。