Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Titan Security Keyの新しいバージョンがPasskeysに対応していた

Titan Security Keyがパスキーに対応していたようですが、Google Storeの製品ページを見ても、それがパスキー対応したバージョンのTitan Security Keyなのか分からなくて混乱しました*1。ストア上では全部同じ名前でバージョン表記も無いし、国内販売が遅れた事例も過去にあるので「国内版はまだ古いバージョンだったりしないか」が気になります。

そこで、過去の記事などから形状と対応規格を調べてみました。過去バージョンは公式のGoogle Titan セキュリティ キーの安全および保証ガイドを参照しています。

先に結論を述べると、2024年1月時点で販売されているTitan Security Keyはパスキー対応と謳われているバージョンです。

2018年

  • Model: K9T (USB-A/NFC)
  • Model: K13T (Micro-USB/NFC/BLE)

Titan Security Keys: Now available on the Google Storeで登場したバージョンです。購入したパッケージに2つ同梱されていました。もともと2018年に登場していましたが、国内販売が遅れて2019年販売開始でした。過去記事で買ったのはこれ。

blog.lufia.org

具体的な形状はGoogleの安全な2段階認証を構築し不正アクセスを防ぐ物理キー「Titan セキュリティ キー」が日本で登場 - GIGAZINEの写真を見てもらえれば分かりますが、K13Tは卵形をしていて中央に楕円のボタンがあります。

2019年

  • Model: YT1 (USB-C)

USB-C Titan Security Keys - available tomorrow in the US でUSB-C版が追加になりました。こちらも形状はGoogleの2段階認証を構築する「Titan セキュリティ キー」にUSB Type-Cタイプが登場 - GIGAZINEの写真を見てもらえれば分かりますが、円形の鍵穴とTITANの刻印があります。

ところで、Titan Security Key - Wikipediaには

USB-C/NFC

と書かれているけど、YT1の安全および保証ガイドを見るとNFCは含まれていないので間違いかなと思っています。

2021年

  • Model: K40T (USB-C/NFC)

この辺りはよく知らないのですが、おそらくSimplifying Titan Security Key options for our usersの辺りでアップデートされたバージョンです。接続インターフェイスとしてのBLEが廃止となる代わりにNFCが追加になったようです。

K40Tの画像はGoogle「Titan セキュリティキー」ってどう使うの?NFCによるスマホ利用にも対応を見てください。円形の鍵穴と丸いボタンがあります。

これも、Titan Security Key - Wikipediaによると

supporting U2F and FIDO2

とありますが、Google USB-C/NFC Titan Security Key ReviewではFIDO U2F対応(FIDO2ではない)とあるので間違いかなと思っています。

2023年

  • Model: K51T (USB-A/NFC)
  • Model: K52T (USB-C/NFC)

The latest Titan Security Key is in the Google Storeで更新されたバージョンで、現在販売されているモデルです。公式情報にFIDO2対応とあるので本物でしょう。

形状は、ストアの画像はおそらく次のバージョンが出たら変わってしまうので、その場合は最大250種類のパスキーの保存が可能なGoogle Titan セキュリティ キーを使ってパスキー認証してみた - GIGAZINEの写真をみてください。楕円形の鍵穴と四角のボタンがあります。

*1:本当に困るので更新日くらいは書いてほしい

Goで非推奨(Deprecated)や撤回(Retracted)を明示する方法

最近のGoには、関数やパッケージを非推奨と扱う方法があります。まとまっていると便利かなと思うので、種類ごとにまとめてみました。GoDocコメントを多用するので、GoDocを書き慣れていない場合は以下も参考にしてください。

blog.lufia.org

関数と型を非推奨にする

関数コメントに、// Deprecated: ではじまる段落を追加します。

// Parse parses a string of the form <status>=<status>.
//
// Deprecated: Use ParseStatusMap instead.
func Parse(src string) (map[Status]Status, error) {
    ...
}

型の場合も同様に。

// Error is the interface that wraps Error method. 
//
/// Deprecated: Use error instead.
type Error interface {
    ...
}

これを含んだバージョンをリリースすると、GoDocでは以下のように表示されます。

関数名と並んでDEPRECATEDと描画されている様子

ただし、関数や型の非推奨化を検出するLinterは存在します*1が、標準のツールでは警告等を出力しません。これはおそらく、非推奨だとしても互換性を維持するために関数は残り続ける習慣があるので、使い続けても支障はないためじゃないかなと思っています。

互換性を維持しつつ変更を加える方法は、以下の記事が参考になります。

変数と定数を非推奨にする

これも // Deprecated: コメントを追加します。

// ZP is the zero Point.
//
// Deprecated: Use a literal image.Point{} instead.
var ZP Point

定数も同様。

const (
    ...
    // Deprecated: Use TypeReg instead.
    TypeRegA = '\x00'
    ...
)

定数をまとめて非推奨にしたい場合はconstの前に書きます。

// Seek whence values.
//
// Deprecated: Use io.SeekStart, io.SeekCurrent, and io.SeekEnd.
const (
    SEEK_SET int = 0 // seek relative to the origin of the file
    SEEK_CUR int = 1 // seek relative to the current offset
    SEEK_END int = 2 // seek relative to the end
)

2023年現在、変数または定数の場合は、GoDocの上では特別な表示をしていません。

構造体のフィールドを非推奨にする

型そのものではなく、一部のフィールドだけ非推奨としたい場合は、該当フィールドのコメントに // Deprecated: ではじまる段落を追加します。

type FileHeader struct {
    ...
    // ModifiedTime is an MS-DOS-encoded time.
    //
    // Deprecated: Use Modified instead.
    ModifiedTime uint16
    ...
}

行コメントの場合はこのように。

// PipeNode holds a pipeline with optional declaration
type PipeNode struct {
    ...
    Line     int             // The line number in the input. Deprecated: Kept for compatibility.
    ...
}

2023年現在、GoDocの上では特別な表示をしていません。

モジュールの特定バージョンを撤回する

壊れた状態でリリースしたしまったとか、関数名をtypoしていた等でバージョンを撤回したい場合があると思います。この場合は、go.modretractディレクティブを追加して、新しいバージョンを公開します。

retract v0.1.0 // Contains a misleading function name.

retractディレクティブでバージョンを示すと、モジュール上では撤回されたバージョンとして扱われます。GoDocでの表示はこのようになります。

撤回されたフラグが描画されている様子

また、撤回されたバージョンを参照したとき、goコマンドによって以下のような警告が出力されます。

go: warning: github.com/mackerelio/checkers@v0.1.0: retracted by module author: Contains a misleading function name.

retract ディレクティブは複数のバージョンをまとめて撤回したりもできます。詳細はドキュメントを参照してください。

モジュールそのものを非推奨にする

新しいメジャーバージョンを公開したので古いほうを非推奨としたい、またはメンテナンスを縮小するので非推奨としたい場合にはモジュールそのものを非推奨とすることもできます。

この場合は、go.modmoduleディレクティブに // Deprecated: を書きます。

// Deprecated: Use github.com/gomodule/redigo instead.
module github.com/garyburd/redigo

GoDocでの表示はこのようになります。

モジュールは非推奨と警告を出している様子

公式のドキュメントは以下です。

*1:staticcheck

GitHub ProjectsのTracksとTracked byを設定する

GitHub ProjectsにはTracksフィールドとTracked byフィールドがあります。

フィールド選択のところで確認できます

これらのフィールドは、新しいタスクリストでissueやプルリクエストを追加すると追跡の対象となります。

```[tasklist]
## Tasks
- [ ] #1234
- [ ] #1233
```

こうすると、タスクリストを記述した側のissueは#1234#1233を追跡している(Tracks)ことになり、反対に#1234#1233は親のissueに追跡された状態(Tracked by)となります。複数の親issueから1つの子issueを追跡することも可能です。この場合、2つのissueから参照された子issueは、2つのTracksを持ちます。

従来のタスクリストを使ってもUI上では追跡されているようにみえますが、どうやら従来のタスクリストはTracksTracked byの対象となっていません。

追跡されているようにみえるけど対象にならない

GitHub Projectsのタスクリストと追跡については公式ドキュメントがあります。

GitHub上のタスクリスト

2023年8月現在、GitHubのissueやプルリクエストでタスクリストを作成するには2種類の方法があります。

1つ目は従来からあるタスクリストです。以下のように記述します。

- [ ] task
- [ ] task

この方法は、日本語版のドキュメントではタスクリスト、英語版のドキュメントではtask listsと表現されています。

もうひとつ、昨年あたりに追加された方法があります。新しいタスクリストの書き方はこのようになります。

```[tasklist]
## section
- [ ] task
- [ ] task
```

こちらの方法は、日本語版だと従来と同じタスクリストと呼ばれていますが、英語版ではtasklistsと使い分けがされているようです。

新しいタスクリストがただのコードブロックになる場合

新しいタスクリストは、ドキュメントによるとまだプライベートベータです。Organization単位で有効化されるようなので、タスクリストがコードブロックになる場合は、該当のOrganizationはまだ無効なのかもしれません。この場合は、有効化したいOrganizationのAdminを持ったアカウントでWaitlistに登録しておくと、そのうち使えるようになります。

ただし、Waitlistに登録するフォームはOrganizationの指定が必須となっていたので、個人のアカウントはWaitlistに登録できません*1

*1:Feature Previewにも無かったので個人のリポジトリでは使えない気がする

systemd-homed環境でユーザーにログインできなくなる原因と対処

ここ数年はLinuxデスクトップで生活しているが、たまにユーザー環境へログインできなくなることがあった。

症状

これまで何の問題もなく動いていたが、再起動した後に突然ログインできなくなる。gdm を使っているけれども、パスワード入力のあと何のエラーもなくパスワード再入力画面に戻る。このとき、パスワードを間違えると「認証に失敗しました」のようなエラーになるのでパスワードは合っていると思われる。

root ではログインできるので、ログインした後に systemd-journald のログをみると以下のような行が記録される。

$ journalctl --since=today
systemd-homed[532]: lufia: changing state inactive → activating-for-acquire
systemd-homework[2917]: Provided password unlocks user record.
systemd-homework[2917]: Home directory /home/lufia exists, is not mounted but populated, refusing.
systemd-homed[532]: Activation failed: Device or resource busy

systemd-homed では一般的に /home/$USER は空のディレクトリで、ログインしたとき各種ストレージ方式ごとに必要なファイルをホームディレクトリとしてマウントする。例えば /home/$USER.home (LUKSストレージの場合)や /home/$USER.homedir (ディレクトリ方式の場合)を /home/$USER にマウントすることになる。

なのでログインする時点では /home/$USER は空であるはずが、エラーメッセージには /home/lufia が空ではないのでマウントに失敗したとあり、実際に /home/lufia 以下には空のディレクトリが作られていた。この謎のファイルは消しても一定時間で復活する状態だった。

fsck -f でディスクの検査をしても何もエラーは検出されない。

原因

以下の条件が揃えば同様の症状になる。

  • systemd-homedでユーザーを管理している
  • Dockerが動作している
  • /home/$USER 以下のファイルをボリュームマウントしたdockerコンテナが再起動時に残っている

Dockerはボリュームマウントしたとき、ディレクトリがなければ作成する。Dockerコンテナ実行時は /home/$USER にホームディレクトリがマウントされているので問題なく参照できるが、Linuxを再起動したときはまだユーザーがログインしていないので空のディレクトリになっている。そこでDockerは残っているコンテナを復活させようとして、/home/$USER 以下にファイルを作ってしまう。そうすると、実際にログインしたときは /home/$USER 以下に(Dockerが作成した)ファイルが存在するため、マウントできずエラーになってしまう。

対処方法

この状況になった場合は、root などでログインしたあと以下の手順を実行する。

  • Dockerに残っているボリュームマウントしているコンテナを止める

または

  • docker.service を止める
  • /home/$USER に残っているファイルを消す
  • $USER でログインして docker.service を開始する

感想

Dockerも systemd-homed も、それぞれは意図した動作をしているだけなので、困りますね。

Goの型パラメータを使って型付きバリデータを作っている

型パラメータ(generics)とerrors.Joinを使ってバリデータを作っています。

github.com

経緯

Goで値のバリデーションを行う場合、有名なライブラリには以下の2つがあります。

go-playground/validator はよくある構造体のフィールドに validate:"required" のようにタグを付けるスタイルのバリデータです。awesome-goで紹介されているvalidatorのほとんどはこのスタイルで人気もありますが、個人的には validate:"required" 程度ならともかく validate:"oneof='red green' 'blue'" あたりからは、手軽さよりも複雑さの方が強くて少々厳しいなと感じます。

一方、 go-ozzo/ozz-validation の方はコードで記述するスタイルのバリデータです。公式のサンプルを借用すると、

a := Address{
    Street: "123",
    City:   "Unknown",
    State:  "Virginia",
    Zip:    "12345",
}
err := validation.ValidateStruct(&a,
    validation.Field(&a.Street, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.City, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.State, validation.Required, validation.Match(regexp.MustCompile("^[A-Z]{2}$"))),
    validation.Field(&a.Zip, validation.Required, validation.Match(regexp.MustCompile("^[0-9]{5}$"))),
)
fmt.Println(err)

とても印象は良かったのですが、残念ながら2020年ごろからメンテされなくなっており、IssueやPull requestの状況をみる限りは枯れて安定している様子もないので、今このライブラリに依存するのも怖いなと思います。

go-ozzo/ozzo-validation から派生したリポジトリjellydator/validationというリポジトリがあり、これは今もメンテされているようですが、Go 1.18で入った型パラメータ(generics)とか、1.20で入った errors.Join を使ってみたい気持ちがありました。

そういう理由で、上で挙げた go-validator を自作しています。

go-validatorの使い方

例えば以下のコードでは、createUserRequestValidatorValidate(context.Context, *CreateUserRequest) メソッドを持つバリデータです。

import (
    "context"

    "github.com/lufia/go-validator"
)

var createUserRequestValidator = validator.Struct(func(s validator.StructRule, r *CreateUserRequest) {
    validator.AddField(s, &r.Name, "name", validator.Length[string](5, 20))
    validator.AddField(s, &r.Provider, "provider", validator.In(Google, Apple, GitHub))
    validator.AddField(s, &r.Theme, "theme", validator.In("light", "dark"))
})
err := createUserRequestValidator.Validate(context.Background(), &CreateUserRequest{})
fmt.Println(err)

実装するときに気をつけたことをいくつか挙げます。

型のあるバリデータ

バリデータはanyではなく個別の型を持たせています。これは必ず達成したいと思っていました。Goの型パラメータは関数引数などから推論されるので、上記では validator.Struct に渡している関数や validator.In に渡している値の型などからバリデータの型を決定しています。

同様に、構造体フィールドも型推論によって決定したかったので、validator.AddField にはフィールドのポインタを渡すようにしています。Validateの内部では r の先頭ポインタとのオフセットを使って reflect でフィールドを探しています。

複数のエラーを扱う

複数の項目で違反があった場合は errors.Join で複数のエラーを返すようになっています。個別のエラーをコード側から扱いたい場合は errors.Asinterface { Unwrap() []error } などで掘る必要があって少し面倒ですが、Go 1.20時点で複数のエラーを扱う場合の標準的な方法なのでそれに合わせています。

国際化対応

go-validator では golang.org/x/text/message でエラーメッセージの飜訳をしています。

本当は、ライブラリ側では素朴にエラーを返して、使う側で飜訳してもらう方が好ましいとは思うのですが、バリデータでは

  • 複数のエラーを返す場合がある
  • 構造体やスライスをバリデーションする場合は階層構造になることがある

などがあり、使う側でエラーを飜訳してもらうのも難しそうだったのでライブラリ側で対応しました。言語を切り替えたい場合は context.Context に言語タグを設定しておくと切り替わりますし、ライブラリ側の飜訳では不十分なら独自のカタログを用意する方法もあります。

難しかったところ

メソッドで型パラメータを宣言したい

Go 1.20時点では、メソッドで新しく型パラメータを宣言できません。

// これはできない
func (p *userType) Method[T any](v T)

何度か欲しくなったけれどできないので、別のgenericな型を用意して対応しました。

type Type[T any] struct{...}

// これはできる
func (p *Type[T]) Method(v T)

具体的な型引数を省略できない場合がある

複数の値のうちどれかを選択する validator.In バリデータや、値の範囲が一定以上になっていることを検査する validator.Min バリデータなどは、引数から型推論できるため型引数を省略できますが、値が必須であることの検査をする validator.Required バリデータなどは推論するための型がないので型引数を省略できません。

今の時点では仕方がないので、これは諦めました。今後リリースされるGoのバージョンでは、代入先の型から推論したり、デフォルトの型引数が定義できたりなど拡張されていくようなので期待しています。

lenの引数にできる型の制約を書けない

簡単にできそうだけど意外と困難です*1

len() できる型は

  • string
  • []T
  • map[K]V
  • chan T

などありますが、string には型パラメータが不要だけどスライスには1つ必要、マップはキーと値で2つ必要です。型パラメータを3つ持たせて必要なところだけ使うようにすればできなくはないですが、その実現方法はどうなんだ?とは思います。

基底型がstringまたはStringメソッドを実装している型の制約が書けない

String() string を実装していれば文字列のように扱えると便利じゃないかなと思って、

type stringable interface {
    ~string | String() string
}

としてみたところエラーになりました。

type stringable interface {
    ~string
    String() string
}

これはエラーにはならないけれど、ANDとして扱われるようでうまく実現できませんでした。

参考

Goの型パラメータについてとても詳しくまとまっている情報なのでぜひ読んでください。

*1:できなくはないけど歪なものになる、が正しい

GitHubの複数リポジトリで共通したワークフローを(ある程度)まとめて管理する

TL;DR

  • Reusable workflowにタグを付けて、参照する側のリポジトリはDependabotなどで更新するといいと思う
  • リポジトリごとにDependabotのプルリクエストをマージする手間は必要になる
  • GitHub Actionsのscheduleトリガーでcron式を書けるが、60日以上更新がないリポジトリでは無効になるので使いづらい

背景

個人で複数のリポジトリを管理しているとき、それぞれだいたい同じようなワークフローを管理しているのではないかと思います。例えば私はGoを使って開発することが多いのですが、その場合、リポジトリには以下のようなワークフローを作ります。

jobs:
  test:
    strategy:
      matrix:
        os: ['ubuntu-22.04', 'windows-2022', 'macos-12']
        go: ['1.20.x', '1.19.x']
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        parallel-finished: true

このワークフローは、以下のイベントごとに更新していくことになります。

  • OSに更新があったとき(1〜2年に1回)
  • Goのバージョンに更新があったとき(半年ごと)
  • 各アクションのアップデート(随時)

1つ2つならいいけれど、いくつもあるリポジトリ全てを更新するのは大変ですね。

Reusable workflowにする

GitHubでは、Reusable workflowとしてアクションの一部を別のリポジトリで管理できます。上記のワークフローをReusable workflowに書き換えると、以下のようになります。

name: Test

on:
  workflow_call:
    inputs:
      os-versions:
        description: 'Stringfied JSON object listing GitHub-hosted runnners'
        default: '["ubuntu-22.04", "windows-2022", "macos-12"]'
        required: false
        type: string
      go-versions:
        description: 'Stringfied JSON object listing target Go versions'
        default: '["1.20.x", "1.19.x"]'
        required: false
        type: string

jobs:
  test:
    strategy:
      matrix:
        os: ${{ fromJson(inputs.os-versions) }}
        go: ${{ fromJson(inputs.go-versions) }}
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out ./...
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        parallel-finished: true

inputsでOSやGoのバージョンをJSON文字列で受け取れるように実装していますが、なぜかというと現在の仕様ではリストをワークフローに渡す手段がないからですね。この方法はArray input type support in reusable workflowsのコメントで紹介されていました。

参照する側はこのように。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@main
    with:
      os-versions: '["ubuntu-22.04"]'  # ワークフローごとに環境を変更したい場合は文字列を渡す

これでOSやGoのバージョンを上げたいときなどはReusable workflowだけを更新すればよくなります。

Reusable workflowを更新したときワークフローを実行したい

ところで、Reusable workflowでOSやGoのバージョンを更新したとき、参照している側のリポジトリでも更新後の環境でテストなどを実行したくなります。ですが今のままではReusable workflowに更新があったことを伝える手段がありません。

GitHub Actionsではscheduleトリガーでcron式を書けるので、これで定期的に実行するよう設定できますが、GitHub側の制約によりscheduleは「60日以上更新のないリポジトリ」では自動的に無効化されます。手動で有効に戻すことはできますがちょっと面倒ですし、必要のない時でもワークフローが動作することになるので、あまりよくないですね。

on:
  schedule:
  - cron: '0 14 10 * *'

本当はトリガーで更新を受け取れるといいのですが、そのようなトリガーイベントはありません。代わりに、Reusable workflowにタグを付けておいて、参照する側のリポジトリはDependabot*1でReusable workflowのバージョンを上げていく方法がいいんじゃないかなと思います。

上記でmainとしていた部分をタグに変更しておくと、Dependabotはプルリクエストを作成します。あとはCIなどを確認したうえでマージすればいいでしょう。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@v0.1.0

ただし、このタグはReusable workflowを管理するリポジトリ(上記で言えばlufia/workflows)全体のタグです。1つのリポジトリで複数のReusable workflowを扱っている場合は、それぞれのワークフローごとにタグを打ちたくなりますが、個別にタグを打つ方法は分かりませんでした。何か分かったら追記します。

おまけ: go.modのgoディレクティブはどうするか

Go Modulesでは、モジュールが必要とするGoのバージョンをgo.modファイルのgoディレクティブで管理しています。Dependabotはgoディレクティブの更新をしないので、Reusable workflowを更新するプルリクエストをマージしても、このバージョンはずっと変化せず古いバージョンのままです。

// go.mod
module github.com/lufia/backoff

go 1.16

数年前のバージョンが残っていると、外から見たときに「今のバージョンで正しく動くのかな」とは思いますが、とはいえバージョンの不一致で壊れた場合はどこかのCIで気付くでしょうし、Go公式のProposalで検討されているいくつかの仕様*2ではmainモジュールのバージョンではなくモジュールごとに挙動が変わるような仕様で考えられているので、goディレクティブに古いバージョンが残っていても大きな問題にはならないんじゃないかなと思っています。

*1:Renovateでも同様にできるかもしれませんが調べてません

*2:例えばredefining for loop variable semanticsなど

Goでホスト名とポート番号の操作をするときはnetパッケージの関数が使える

標準のnetパッケージには、ホスト名とポート番号を操作する関数がいくつか用意されていますが意外と知られていないようなので、便利だけどあまり知られていない関数を3つ紹介します。

TL;DR

ホスト名とポート番号を:で区切られたアドレスに変換する

Goでコードを書くとき、localhost:8080のようにホスト名とポート番号を:で区切ったひとつの文字列として表現することがあります。この表記はAddressと呼ばれており、例えば以下のような関数で使われています。

package http

func ListenAndServe(addr string, handler Handler) error
package net

func Dial(network, address string) (Conn, error)

type Addr interface {
    String() string  // string form of address (for example, "192.0.2.1:25", "[2001:db8::1]:80")
}

ホスト名とポート番号をアドレスへ変換するとき、fmt.Sprintfを使ったコードをよく見かけますが、ホストとしてIPv6アドレスが与えられた場合に意図しない動作を引き起すことがあります。IPv6アドレスは:で区切って表記するので、単純に結合してしまうとIPv6アドレスとポート番号の区別がつかなくなり不正なアドレスとなります。

var (
    host = "::1"
    port = 80
)
addr := fmt.Sprintf("%s:%d", host, port)
c, err := net.Dial("tcp", addr) // err = "dial tcp: address ::1:80: too many colons in address"

正しくは、IPv6アドレスにポート番号を連結する場合は[::1]:80のようにIPv6アドレスの部分を[]で囲む必要があります。この書式はRFC 4038

Therefore, the IP address parsers that take the port number separated with a colon should distinguish IPv6 addresses somehow. One way is to enclose the address in brackets, as is done with Uniform Resource Locators (URLs) [RFC2732]; for example, http://[2001:db8::1]:80.

と定められています。Goのnetパッケージには、この操作を行うnet.JoinHostPort関数が用意されているので、なるべくこちらを使いましょう。ただし、このときportstringなので型の変換が必要です。

addr := net.JoinHostPort(host, strconv.Itoa(port))
c, err := net.Dial("tcp", addr)

ホスト名とポート番号を分割する

上記とは逆に、:で接続された文字列をホスト名とポート番号に分割したい場合は、こちらもnet.SplitHostPortが用意されているのでそのまま使えます。

host, port, err := net.SplitHostPort("[::1]:80")
if err != nil {
    log.Fatalln(err)
}
fmt.Println(host, port) // ::1 80

ポート番号を文字列から数値にする

これはstrconv.Atoistrconv.ParseUintでもそこまで困らないかなと思いますが、net.LookupPortが便利に使えます。strconv.Atoiとの違いは、

  • 負数の場合、または65535より大きい値の場合はエラーになる
  • redisなどサービス名も対応している

といった特徴があります。

_, err := net.LookupPort("tcp", "-1") // err = "address -1: invalid port"
_, err := net.LookupPort("tcp", "65536") // err = "address 65536: invalid port"

port, err := net.LookupPort("tcp", "redis")
if err != nil {
    log.Fatalln(err)
}
fmt.Println(port) // 6379