Help us shape the future of Go! Take the 2026 Go Developer Survey

utils

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package utils は IIJ DPF Go SDK(github.com/iij/dpf-go)の上に構築された ユーティリティ関数群を提供する。

生成された SDK の低レベル API を直接呼ぶ代わりに、よくある操作を 1 関数で行えるようにラップしている。提供する機能は大きく次の 5 つ。

  • API クライアントラッパー: レート制限・同時実行数制限・リトライ・ トークン管理を備えた Client(NewClient)。
  • ゾーン取得: ドメイン名・ゾーン名・サービスコードから対応するゾーン/ゾーンID を取得する(GetZoneFromName, GetZoneIDFromZonename, GetZoneFromZonename, GetZoneFromServiceCode, GetZoneIdFromServiceCode)。
  • レコード取得: レコード名と RRTYPE からゾーンとレコードを取得する (GetRecordFromRecordName, GetRecordFromZonename, GetRecordFromZoneID)。
  • ゾーン単位ロック: ゾーンのラベルと一時的な専用レコードを用いた 排他制御(Mutex)。etcd などの仕組みへ差し替えられる(Locker)。
  • ジョブID取得: 非同期 API のレスポンスから request_id を取り出す(GetJobID)。

API クライアントの受け渡し

多くの関数は具象型ではなく dpf.ZonesApi / dpf.RecordsApi インターフェースを 受け取る(テスト時のモック差し替えを容易にするため)。 実運用では *dpf.APIClient の ZonesAPI / RecordsAPI フィールドをそのまま渡せる。

cfg := dpf.NewConfiguration()
client := dpf.NewAPIClient(cfg)

// www.example.jp を含むゾーンを longest match で取得する。
zone, err := utils.GetZoneFromName(ctx, client.ZonesAPI, "www.example.jp.", false)

API クライアントラッパー

エンドポイントは WithEndpoint で指定できるが、既定で環境変数 DPF_API_ENDPOINT、それも空なら本番エンドポイントが使われるため通常は不要。

Client は dpf API client にレート制限(既定 5 req/s, burst 10)、 最大同時実行数(既定 5)、リトライ(既定 3 回)、タイムアウト(既定 30 秒)を 付与する。制限は RoundTripper 層で適用されるため、GetAPIClient() で取り出した クライアント経由の直接リクエストにも効く。

c, err := utils.NewClient() // 環境変数 DPF_API_ENDPOINT / DPF_API_TOKEN を使う
if err != nil {
	return err
}
err = c.Operation(ctx, func() error {
	zones, _, err := c.GetAPIClient().ZonesAPI.GetZoneList(ctx).ExecuteAll()
	...
	return err
})

アクセストークン

アクセストークンは TokenProvider から取得する。TokenProvider は API リクエストの たびに評価されるため、外部でローテーションされたトークンを Client を作り直さずに 反映できる。指定がない場合は環境変数 DPF_API_TOKEN を実行時に参照する。

utils.NewClient(utils.WithToken("..."))                  // 文字列を直接指定
utils.NewClient(utils.WithTokenFile("/etc/dpf/token"))   // ファイルから取得
utils.NewClient(utils.WithTokenProvider(myProvider))     // 任意の取得処理

毎リクエストの評価コストを抑えたい場合は WithTokenTTL でキャッシュ期間を指定する (既定はキャッシュなし)。トークンの取得に失敗した場合は *TokenError が返り、 Operation はこのエラーをリトライしない。

シークレット管理サービスから取得する TokenProvider は、本体に依存を持ち込まない よう独立モジュールとして github.com/iij/dpf-go/misc 以下に用意している。

  • misc/vault : HashiCorp Vault (KV シークレットエンジン)
  • misc/aws : AWS Secrets Manager
  • misc/azure : Azure Key Vault
  • misc/gcp : Google Secret Manager
  • misc/k8s : Kubernetes Secret

いずれも NewTokenProvider が返す関数をそのまま WithTokenProvider に渡せる。

名前の正規化

ゾーン名・レコード名の比較は文字列操作ではなく miekg/dns の関数で行う。 dns.CanonicalName で小文字化・FQDN 化したうえで比較するため、 "Example.JP." と "example.jp" は同一として扱われる。 longest match では、dns.IsSubDomain で包含関係を判定し、dns.CountLabel が 最も大きい(ラベル数が多い = 最も具体的な)ゾーンが選択される。

ゾーン単位ロック

Mutex はゾーンのラベルにロック情報を書き込むことで、複数プログラム間の排他制御を 実現する。ゾーンのラベルは**未反映の編集の概念を持たない**ため、取得・延長・解放の いずれもゾーンに未反映の編集を作らず、ゾーン反映を必要としない。

**2 者が同時に取得することはない。** ラベルの読み取りから書き込みまでの区間を専用 レコードの追加によって排他しており、レコードの新規追加に対する同名かつ同 RRTYPE の 重複の拒否は編集者が誰かに依らないためである。この拒否は**ユーザをまたいで機械的に 効く。** 専用レコードは排他が取得できた時点で取り消され、権威サーバへは公開されない。

**一方、保持中に他ユーザや管理画面からレコードを編集することは止められない。** 保持中の 尊重は、奪ってよい時刻を読んで譲るという協調によって成り立つ。

排他はゾーンのラベルを 1 つ消費する。**解放してもラベルは残る**(奪ってよい時刻を現在 時刻へ更新するだけである)。利用者がそのゾーンへ付けられるラベルは 9 個までになる。

ロックは再入できない。保持期間を延ばす場合は Renew を使う。

取得と解放の対を自分で書く代わりに、Mutex.Do へ処理を渡せる。実行中は保持期間が 自動で延長され、終了時に解放される。排他を他者に奪われた場合は、処理へ渡した context が打ち切られる。

mu := utils.NewMutex(client.RecordsAPI, zoneID)
err := mu.Do(ctx, func(ctx context.Context) error {
	// ここでレコードを編集し、ゾーンへ反映する。
	return nil
})

ゾーン全体を読んで編集し、一括で置き換える場合は ZoneApplier を使う。排他の取得と 解放に加えて、取り込みの可否を決めるフラグの固定と、反映によって排他が解かれることの 扱いを引き受ける。利用者が書くのは編集の内容だけである。

ap := utils.NewZoneApplier(client.RecordsAPI, client.ZonesAPI, client.JobsAPI, zoneID)
err := ap.Apply(ctx, func(ctx context.Context, records []dpf.OverwriteRecordsInner) ([]dpf.OverwriteRecordsInner, error) {
	// records を編集して返す。編集しない要素はそのまま返せばよい。
	return records, nil
})

読んだ結果、反映が不要だと分かった場合は ErrSkipApply を返す。一括置き換えは行われず、 Apply は nil を返す。

return nil, utils.ErrSkipApply // 変える必要が無かった

**空の一覧を返すことと混同しないこと。** 空は ErrNoRecords であり失敗として扱う。 ゾーンの全削除と区別できないため、黙って何もしないことはしない。

省略できることで、台帳と定期的に突き合わせて必要なときだけ反映する使い方が現実的になる。 ただし短い間隔で回すと排他の競合が増えるため、取得できるまで待つ場合は WithHoldOptions へ WithLockWait を渡すこと。

レコードの一括更新とゾーン反映を行う PatchZoneAtomicChanges は、**排他を保持したまま 使える。** 置き換えの対象はレコードであり、排他の状態を持つゾーンのラベルは別のリソース であるためである。反映の後も排他は保持され、通常どおり解放できる。詳細は Mutex を参照。

排他の仕組みの差し替え

排他は Locker インターフェースで抽象化されている。既に etcd や Consul で排他を 運用している環境では、そちらへ寄せられる。RunLocked に自作の実装を渡すか、 ZoneApplier に WithLocker で渡す。**どちらも呼び出し方と結果は変わらない。**

err := utils.RunLocked(ctx, myEtcdLocker, func(ctx context.Context) error {
	// ここでレコードを編集し、ゾーンへ反映する。
	return nil
})

自作の実装が契約(Locker の godoc)を満たすかは utils/lockertest で機械的に 確かめられる。

2 つの方式の比較

差し替えは等価な交換ではない。**排他の効く範囲が変わる。**

観点                       ゾーンのラベルを用いる排他        外部の仕組みを使う排他
                           (既定)
-------------------------  ------------------------------  --------------------------
2 者の同時取得             DPF-API の重複の拒否によって      その仕組み次第
                           機械的に防がれる
                           (ユーザをまたいで効く)
別ユーザ・管理画面からの   止められない                      止められない
編集
外部に必要な運用           無し(DPF-API だけで完結)        その仕組みの運用が要る
取得・延長・解放の費用     DPF-API の呼び出し                その仕組み次第
                           (レート制限を消費する)          (通常は安い)
ゾーン反映との関係         影響しない                        影響しない
保持中のゾーンの状態       未反映の編集は残らない            何も残らない
ゾーンのラベルの消費       1 個(解放後も残る)              無し
保持期間                   ラベルの期限(既定 15 分)        実装次第

**どちらの方式も、保持中に他ユーザや管理画面からレコードを編集することは止められない。** 保持中の尊重は協調的である。違いは「2 者の同時取得を何が防ぐか」であり、既定の排他は DPF-API の重複の拒否によってユーザをまたいで防ぐ。外部の仕組みでは、その仕組みを使う プログラム同士でしか防げない。管理画面や他部署のツールが同じゾーンを触る環境では、 **どちらの方式でも編集の衝突は起こりうる**ことを前提に運用すること。

旧版との混在

**v0.5.0 以前は排他の状態を SOA レコードのラベルへ書いていた。** 新旧が同じゾーンを 触ると互いの排他を認識できず、両方が取得に成功しうる。ライブラリはこの混在を検出 できないため、**同じゾーンを触るプログラムはまとめて更新すること。** 旧版が SOA レコードへ残したラベルと未反映の編集は掃除されないため、必要であれば利用者の側で 取り除く。

排他とゾーンの対応づけ

**1 つの Locker の値は、1 つのゾーンに対する 1 つの保持者を表す。** どのゾーンに 対応するかを決める責任は実装の側にある。本パッケージは対応づけを知らないため、 別のゾーンの排他を渡されても検出できず、保護されていないまま処理が走る。

既定の Mutex は NewMutex に渡した zoneID がそのまま対応づけになる。外部の仕組みを 使う場合は、排他の鍵にゾーンを識別できる値(ゾーン ID など)を含めること。複数の ゾーンを 1 つの鍵で守ると、無関係なゾーンの操作まで直列になる。

Index

Examples

Constants

View Source
const (
	// EnvAPIEndpoint は endpoint が空のときに参照する環境変数名。
	EnvAPIEndpoint = "DPF_API_ENDPOINT"
	// EnvAPIToken はトークンの指定がない場合に、API リクエストのたびに参照する環境変数名。
	EnvAPIToken = "DPF_API_TOKEN"
)

環境変数名。

View Source
const (
	// DefaultRateLimit は 1 秒あたりのリクエスト数のデフォルト。
	DefaultRateLimit = rate.Limit(5)
	// DefaultBurst はレート制限のバーストのデフォルト。
	DefaultBurst = 10
	// DefaultMaxConcurrency は最大同時実行数のデフォルト。
	DefaultMaxConcurrency = 5
	// DefaultMaxRetry はリトライ回数のデフォルト。
	DefaultMaxRetry = 3
	// DefaultTimeout は HTTP クライアントのタイムアウトのデフォルト。
	DefaultTimeout = 30 * time.Second
	// DefaultTokenTTL はトークンのキャッシュ期間のデフォルト。
	// 0 はキャッシュしない(リクエストのたびに TokenProvider を実行する)ことを意味する。
	DefaultTokenTTL = time.Duration(0)
)

Client wrapper のデフォルト値。いずれも ClientOption で変更できる。

View Source
const (
	// LockOwnerLabelKey はロック作成者(owner)を格納するラベルキー。
	LockOwnerLabelKey = "owner.lock.dpf-go"
	// LockDeadlineLabelKey はロックを奪っても良い時刻(UTC Unixtime)を格納するラベルキー。
	LockDeadlineLabelKey = "deadline.lock.dpf-go"
)

一時レコード追加ロックの専用レコードに付けるラベルのキー。 レコードのラベルには JSON を保存できないため、owner と deadline を別々のキーへ分ける。 **ゾーンの排他はこれらを使わない**(ZoneLockLabelKey に 1 つへ詰める)。

View Source
const (
	// DefaultLockOwner はホスト名を取得できなかった場合に使うフォールバックの owner。
	DefaultLockOwner = "dpf-go"
	// DefaultLockTTL はデフォルトのロック保持期間。
	DefaultLockTTL = 15 * time.Minute
	// DefaultLockRecordTTL は一時レコード追加ロックが失効するまでのデフォルトの時間。
	// 排他の取得に要する時間だけ保持すれば足りるため、保持期間より大幅に短い。
	DefaultLockRecordTTL = time.Minute
	// DefaultVerifyTimeout は書き込んだ内容が読み出せるまで待つデフォルトの上限。
	// レコードの更新は非同期に処理されるため、書き込みの直後は反映されていない。
	DefaultVerifyTimeout = 10 * time.Second
	// DefaultLockRecordLabel は一時レコード追加ロックに使う専用レコードの最左ラベル。
	// アンダースコアで始まる名前は通常のホスト名と衝突しない。
	DefaultLockRecordLabel = "_dpf-go-lock"
)
View Source
const DefaultRenewInterval = 5 * time.Minute

DefaultRenewInterval は、排他が延長の間隔を申告しない場合に使う既定の間隔。

View Source
const ZoneLockLabelKey = "lock.dpf-go"

ZoneLockLabelKey はゾーンの排他を格納する、ゾーンのラベルのキー。

値は「保持者」と「奪ってよい時刻」を 1 つに詰めた形である(形式は lockValue を参照)。 **ゾーンのラベルは未反映の編集の概念を持たない。** レコードのラベルと異なり、書き込みに ゾーン反映を必要としない。排他が消費するゾーンのラベルはこの 1 つだけである。

Variables

View Source
var (
	// ErrStillLock は、まだ奪えない排他に対して取得を試みた場合に返される。
	// 他者が保持している場合、自分自身が保持している場合(取得は再入できない)、
	// 一時レコード追加ロックを取得できなかった場合、書き込んだ内容を確認できなかった
	// 場合のいずれもこのエラーになる。呼び出し側の対処は「待って再試行する」の
	// 一択であるため、原因による区別はしない。
	ErrStillLock = errors.New("dpf: zone is still locked")
	// ErrNotLockHolder は、自分が保持していない排他に対して Renew または Unlock を
	// 行った場合に返される。保持中に他者へ奪われた場合も含む。
	ErrNotLockHolder = errors.New("dpf: not the lock holder")
	// ErrLabelLimit は、ゾーンのラベル数が上限に達しており、排他のためのラベルを
	// 追加できない場合に返される。排他はゾーンのラベルを 1 つ使うため、利用者が
	// ゾーンへ自由に付けられるラベルは 9 個までである。
	//
	// 待っても解消しない状態であるため、取得を待つ指定があっても待たない。
	ErrLabelLimit = errors.New("dpf: zone label limit reached")
	// ErrOwnerTooLong は、保持者の文字数が上限を超えている場合に返される。
	// 保持者は 1 文字以上 32 文字以下でなければならない。
	//
	// **黙って切り詰めることはしない。** 切り詰めると別のインスタンスと同じ保持者に
	// なり、延長と解放の宛先の判別が壊れるためである。待っても解消しない状態であるため、
	// 取得を待つ指定があっても待たない。
	ErrOwnerTooLong = errors.New("dpf: lock owner too long")
)
View Source
var ErrNoRecords = errors.New("dpf: no records to apply")

ErrNoRecords は、編集の結果が空だった場合に返される。 ゾーンの一括置き換えはレコードを 1 件以上必要とする。

空は「何も変更しない」ことを意味しない。**ゾーンの全削除と区別できないため、失敗として 扱う。** 反映が不要な場合は ErrSkipApply を返すこと。

View Source
var ErrRecordNotFound = errors.New("dpf: record not found")

ErrRecordNotFound は指定された名前・RRTYPE に対応するレコードが 見つからなかった場合に返される。

View Source
var ErrSkipApply = errors.New("dpf: skip zone apply")

ErrSkipApply は、編集の結果を反映しないことを表す合図である。**失敗ではない。**

ZoneApplier.Apply は編集関数がこれを返すと、一括置き換えを行わずに排他を解放し、nil を 返す。公開されているレコードを読んだ結果、変える必要が無かった場合に使う。

意味を持つのは ZoneApplier.Apply へ渡す編集関数の戻り値だけである。RunLocked や Mutex.Do へ渡した処理が返した場合は、他のエラーと同じくそのまま呼び出し側へ返る。

View Source
var ErrTokenRequired = errors.New("dpf: api token is required (use WithToken/WithTokenFile/WithTokenProvider or set " + EnvAPIToken + ")")

ErrTokenRequired はトークンが取得できなかった場合に返される。 TokenProvider が空文字列を返した場合、および WithToken / WithTokenFile / WithTokenProvider のいずれも指定せず環境変数 DPF_API_TOKEN も空の場合が該当する。

View Source
var ErrZoneNotFound = errors.New("dpf: zone not found")

ErrZoneNotFound はドメイン名・ゾーン名・サービスコードに対応するゾーンが 見つからなかった場合に返される。

Functions

func GetJobID

func GetJobID(res *http.Response) string

GetJobID は Execute() が返す *http.Response のレスポンスボディから request_id を取得する。取得できない場合は空文字列を返す。

生成された Execute() はボディを読み込んだ後に再読込可能な形へ復元するため、 この関数はボディを読み出した後も元に戻し、呼び出し側の再読込を妨げない。

func GetRecordFromRecordName

func GetRecordFromRecordName(ctx context.Context, cz dpf.ZonesApi, cr dpf.RecordsApi, recordName string, rrtype dpf.RecordsRrtype) (*dpf.Zone, *dpf.Record, error)

GetRecordFromRecordName はレコード名(FQDN)から、それを含むゾーンとレコードを取得する。 レコード名に longest match で対応するゾーンを特定し、その中からレコードを取得する。 ゾーンが存在しない場合は ErrZoneNotFound、レコードが存在しない場合は ErrRecordNotFound を返す。

Example

レコード名と RRTYPE から、それを含むゾーンとレコードを取得する。

package main

import (
	"context"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)

	zone, record, err := utils.GetRecordFromRecordName(
		context.Background(),
		client.ZonesAPI,
		client.RecordsAPI,
		"www.example.jp.",
		dpf.RecordsRrtype("A"),
	)
	if err != nil {
		return
	}
	fmt.Println(zone.Name, record.Name)
}

func GetRecordFromZoneID

func GetRecordFromZoneID(ctx context.Context, cr dpf.RecordsApi, zoneID string, recordName string, rrtype dpf.RecordsRrtype) (*dpf.Record, error)

GetRecordFromZoneID はゾーンIDの中から、名前と RRTYPE が一致するレコードを取得する。 存在しない場合は ErrRecordNotFound を返す。

func GetRecordFromZonename

func GetRecordFromZonename(ctx context.Context, cz dpf.ZonesApi, cr dpf.RecordsApi, zonename string, recordName string, rrtype dpf.RecordsRrtype) (*dpf.Zone, *dpf.Record, error)

GetRecordFromZonename はゾーン名とレコード名から、対応するゾーンとレコードを取得する。 ゾーンは zonename に longest match で対応するものを使用する。 ゾーンが存在しない場合は ErrZoneNotFound、レコードが存在しない場合は ErrRecordNotFound を返す。

func GetZoneFromName

func GetZoneFromName(ctx context.Context, c dpf.ZonesApi, name string, parent bool) (*dpf.Zone, error)

GetZoneFromName はドメイン名から対応するゾーンを取得する。 ドメイン名に longest match で対応するゾーンを返す。存在しない場合は ErrZoneNotFound を返す。

ドメイン名の比較は miekg/dns の関数で行う。dns.CanonicalName で小文字化・FQDN 化 したうえで、dns.IsSubDomain で包含関係を、dns.CountLabel でラベル数(マッチの長さ)を判定する。

parent が false の場合は name と完全一致するゾーンも対象とする。 parent が true の場合は name の真の上位(祖先)ゾーンのみを対象とし、 name と完全一致するゾーン自身は除外する。

Example

ドメイン名から longest match でゾーンを取得する。

package main

import (
	"context"
	"errors"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)

	zone, err := utils.GetZoneFromName(context.Background(), client.ZonesAPI, "www.example.jp.", false)
	if errors.Is(err, utils.ErrZoneNotFound) {
		fmt.Println("zone not found")
		return
	}
	if err != nil {
		// ネットワークエラーなど。
		return
	}
	fmt.Println(zone.Name)
}

func GetZoneFromServiceCode

func GetZoneFromServiceCode(ctx context.Context, c dpf.ZonesApi, serviceCode string) (*dpf.Zone, error)

GetZoneFromServiceCode はサービスコードに対応するゾーンを取得する。 存在しない場合は ErrZoneNotFound を返す。

func GetZoneFromZonename

func GetZoneFromZonename(ctx context.Context, c dpf.ZonesApi, zonename string) (*dpf.Zone, error)

GetZoneFromZonename はドメイン名から対応するゾーンを取得する。 ドメイン名に longest match で対応するゾーンを返す。存在しない場合は ErrZoneNotFound を返す。 返却される Zone の Name は FQDN 形式(末尾ドット付き)。

func GetZoneIDFromZonename

func GetZoneIDFromZonename(ctx context.Context, c dpf.ZonesApi, zonename string) (string, error)

GetZoneIDFromZonename はドメイン名から対応するゾーンIDを取得する。 ドメイン名に longest match で対応するゾーンIDを返す。存在しない場合は ErrZoneNotFound を返す。

func GetZoneIdFromServiceCode

func GetZoneIdFromServiceCode(ctx context.Context, c dpf.ZonesApi, serviceCode string) (string, error)

GetZoneIdFromServiceCode はサービスコードに対応するゾーンIDを取得する。 存在しない場合は ErrZoneNotFound を返す。

func RunLocked added in v0.5.0

func RunLocked(ctx context.Context, l Locker, fn func(ctx context.Context) error, opts ...HoldOption) error

RunLocked は排他 l を取得し、fn を実行し、終了時に解放する。

fn へ渡される context は ctx から派生したものであり、**排他を他者に奪われた時点で 打ち切られる。** 保護が切れたことを知らないまま処理が走り続ける状態を作らないためで ある。ただし打ち切りは通知であって強制的な中断ではない。fn が context を無視すれば 走り続け、RunLocked はその終了を待つ。

実行中、保持期間は自動で延長される。間隔は、WithRenewEvery の指定があればその値、 無ければ排他が RenewIntervaler で申告する値、それも無ければ DefaultRenewInterval で ある。利用者が延長を書く必要はない。 延長が「保持者でない」以外の理由で失敗した場合は、次の周期で再試行する。**失敗が続く 間は、排他が実際には失効しているのに fn が走りうる。** この窓の長さは延長の間隔に依存する。

排他を取得できない場合、fn は呼ばれず ErrStillLock を返す。取得できるまで待つ場合は WithLockWait を使う。

終了時は、自分が保持者である場合に限り解放する。fn が返したエラーは種類を判別できる 形のまま返す。排他を失った場合は ErrNotLockHolder を返し、fn もエラーを返していた 場合は両方を判別できる形で返す。

RunLocked が復帰した時点で、延長のための goroutine は終了している。

fn の異常終了(panic)は捕捉しない。この場合、解放は行われず、排他は実装の保持期間の 経過によって解ける(保持期間を持たない実装では解けない)。

Example

排他の仕組みを差し替えて、排他の下で処理を実行する。

既に etcd や Consul で排他を運用している環境では、そちらへ寄せられる。呼び出し方は 既定の排他と変わらない。**ただし、外部の仕組みを使う排他には、別ユーザや管理画面 からの編集を止める効果は無い。** 詳しくはパッケージ文書の比較を参照。

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/iij/dpf-go/utils"
)

func main() {
	ctx := context.Background()

	// 自作の排他(下の myLocker を参照)。ゾーンとの対応づけは実装の責任である。
	locker := newMyLocker("zone-id-123456")

	err := utils.RunLocked(ctx, locker, func(ctx context.Context) error {
		// ここでレコードを編集し、ゾーンへ反映する。
		// 排他を他者に奪われた場合、この ctx は打ち切られる。
		return nil
	}, utils.WithLockWait(5*time.Second))
	if err != nil {
		// ErrStillLock: 取得できなかった。ErrNotLockHolder: 実行中に失った。
		fmt.Println(err)
	}
}

// myLocker は自作の排他の骨格。実際には etcd などの分散ロックを呼ぶ。
//
// 契約(utils.Locker の godoc)を満たすこと。特に、取得は待たないこと、再入を
// 許さないこと、Renew で保持の喪失を報告することの 3 つを取り違えやすい。
// 実装できたら utils/lockertest の Run で確かめること。
type myLocker struct {
	key string
}

func newMyLocker(zoneID string) *myLocker {

	return &myLocker{key: "dpf-go/zone/" + zoneID}
}

// Lock は取得を 1 回だけ試みる。保持されている場合は待たずに返す。
func (l *myLocker) Lock(ctx context.Context) error {

	return nil
}

// Renew は保持を確かめ、必要なら期限を延ばす。失っていれば報告する。
func (l *myLocker) Renew(ctx context.Context) error {

	return nil
}

// Unlock は自分が保持者である場合に限り解放する。
func (l *myLocker) Unlock(ctx context.Context) error {
	return nil
}

// RenewInterval は延長の間隔を申告する(任意)。保持期間から導ける場合は実装する。
func (l *myLocker) RenewInterval() time.Duration { return 10 * time.Second }

Types

type ApplierOption added in v0.5.0

type ApplierOption func(*applierConfig)

ApplierOption は ZoneApplier の任意設定を変更する。

func WithHoldOptions added in v0.5.0

func WithHoldOptions(opts ...HoldOption) ApplierOption

WithHoldOptions は排他を保持したままの実行への設定を渡す(WithLockWait など)。

func WithLockOptions added in v0.5.0

func WithLockOptions(opts ...Option) ApplierOption

WithLockOptions は既定の排他(レコードを用いる Mutex)への設定を渡す。 WithLocker を指定した場合は意味を持たない。

utils.NewZoneApplier(cr, cz, cj, zoneID,
	utils.WithLockOptions(utils.WithTTL(30*time.Minute)))

func WithLocker added in v0.5.0

func WithLocker(l Locker) ApplierOption

WithLocker は排他の仕組みを差し替える(デフォルト: レコードを用いる Mutex)。

etcd などの分散ロックを使う場合に指定する。指定した場合、WithLockOptions は意味を 持たない(既定の排他を作らないため)。

**外部の仕組みを使う排他に替えると、2 者の同時取得を防ぐ範囲が狭まる。** 既定の ゾーンのラベルを用いる排他は、DPF-API の重複の拒否によってユーザをまたいで防ぐ。外部の 仕組みでは、その仕組みを使うプログラム同士でしか防げない。詳しくはパッケージ文書の 比較を参照。

Example

ゾーン全体の一括置き換えで、排他の仕組みを差し替える。

WithLocker を指定した場合、既定の排他への設定(WithLockOptions)は意味を持たない。

package main

import (
	"context"
	"fmt"
	"time"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	ap := utils.NewZoneApplier(
		client.RecordsAPI, client.ZonesAPI, client.JobsAPI,
		"zone-id-123456",
		utils.WithLocker(newMyLocker("zone-id-123456")),
	)

	err := ap.Apply(ctx, func(ctx context.Context, records []dpf.OverwriteRecordsInner) ([]dpf.OverwriteRecordsInner, error) {
		return records, nil
	})
	if err != nil {
		fmt.Println(err)
	}
}

// myLocker は自作の排他の骨格。実際には etcd などの分散ロックを呼ぶ。
//
// 契約(utils.Locker の godoc)を満たすこと。特に、取得は待たないこと、再入を
// 許さないこと、Renew で保持の喪失を報告することの 3 つを取り違えやすい。
// 実装できたら utils/lockertest の Run で確かめること。
type myLocker struct {
	key string
}

func newMyLocker(zoneID string) *myLocker {

	return &myLocker{key: "dpf-go/zone/" + zoneID}
}

// Lock は取得を 1 回だけ試みる。保持されている場合は待たずに返す。
func (l *myLocker) Lock(ctx context.Context) error {

	return nil
}

// Renew は保持を確かめ、必要なら期限を延ばす。失っていれば報告する。
func (l *myLocker) Renew(ctx context.Context) error {

	return nil
}

// Unlock は自分が保持者である場合に限り解放する。
func (l *myLocker) Unlock(ctx context.Context) error {
	return nil
}

// RenewInterval は延長の間隔を申告する(任意)。保持期間から導ける場合は実装する。
func (l *myLocker) RenewInterval() time.Duration { return 10 * time.Second }

type ApplyOption added in v0.4.0

type ApplyOption func(*applyConfig)

ApplyOption は Apply の任意設定を変更する。

func WithApplyDescription added in v0.4.0

func WithApplyDescription(description string) ApplyOption

WithApplyDescription は反映に付けるコメントを指定する(デフォルト: 空)。

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client は dpf API client を内包するラッパー。

レート制限と最大同時実行数制限は HTTP の RoundTripper(Transport)層で適用されるため、 GetAPIClient() で取得したクライアント経由の通常 API リクエストも含め、 この Client を通じて行われるすべての HTTP リクエストにクライアント全体として効く。 Operation はそれらに加えてリトライを付与する。

アクセストークンも同じく RoundTripper 層で、リクエストのたびに TokenProvider を評価して付与される。詳細は NewClient を参照。

func NewClient

func NewClient(opts ...ClientOption) (*Client, error)

NewClient は dpf API client を内包する Client を生成する。

エンドポイントは WithEndpoint で指定する。指定しない場合は環境変数 DPF_API_ENDPOINT、それも空の場合は本番エンドポイントを使うため、 通常は指定不要である。

アクセストークンは TokenProvider から取得する。WithToken / WithTokenFile / WithTokenProvider のいずれも指定しない場合は環境変数 DPF_API_TOKEN を使う。 TokenProvider は API リクエストのたびに評価されるため、外部でローテーション されたトークンを Client を作り直さずに反映できる。評価頻度を抑えたい場合は WithTokenTTL でキャッシュ期間を指定する。

設定ミスを早期に検出するため、NewClient は TokenProvider を 1 度実行して検証する。 トークンが空、または取得に失敗した場合は *TokenError を返す (前者は errors.Is(err, ErrTokenRequired) で判定できる)。

レート制限・最大同時実行数・リトライ回数・タイムアウト・User-Agent は 既定値を使い、変更したい場合のみ opts(With... 関数)を渡す。

Example

既定設定(エンドポイントは環境変数 DPF_API_ENDPOINT または本番、 トークンは環境変数 DPF_API_TOKEN)で Client を作る。

package main

import (
	"context"
	"fmt"

	"github.com/iij/dpf-go/utils"
)

func main() {
	c, err := utils.NewClient()
	if err != nil {
		fmt.Println(err)
		return
	}

	ctx := context.Background()
	err = c.Operation(ctx, func() error {
		zones, _, err := c.GetAPIClient().ZonesAPI.GetZoneList(ctx).ExecuteAll()
		if err != nil {
			return err
		}
		fmt.Println(len(zones.GetResults()))
		return nil
	})
	if err != nil {
		fmt.Println(err)
	}
}
Example (TokenFile)

トークンをファイルから取得する。ファイルは API リクエストのたびに読み込まれるため、 外部プロセスがトークンをローテーションしても Client を作り直す必要はない。

package main

import (
	"fmt"
	"time"

	"github.com/iij/dpf-go/utils"
)

func main() {
	c, err := utils.NewClient(
		utils.WithEndpoint("https://api.dns-platform.jp/dpf/v1"),
		utils.WithTokenFile("/etc/dpf/token"),
		// 毎リクエストのファイル読み込みを避けたい場合は TTL を指定する。
		utils.WithTokenTTL(5*time.Minute),
	)
	if err != nil {
		fmt.Println(err)
		return
	}
	_ = c
}

func (*Client) GetAPIClient

func (c *Client) GetAPIClient() *dpf.APIClient

GetAPIClient は内包する dpf API client を返す。

func (*Client) Operation

func (c *Client) Operation(ctx context.Context, operation func() error) error

Operation は operation を実行し、レスポンスを得られずに失敗した場合 (トランスポートエラー等)は maxRetry 回までリトライする。 HTTP レスポンスが得られたエラー(*dpf.GenericOpenAPIError)はリトライせず即座に返す。

レート制限・最大同時実行数制限は Transport 層で全リクエストに適用されるため、 operation 内の各 API リクエスト(リトライ時の再実行も含む)にも自動的に効く。

type ClientOption

type ClientOption func(*clientConfig)

ClientOption は Client の任意設定を変更する。

func WithEndpoint

func WithEndpoint(endpoint string) ClientOption

WithEndpoint は dpf API のエンドポイント URL を指定する。

指定しない場合は環境変数 DPF_API_ENDPOINT、それも空の場合は 本番エンドポイント(defaultAPIEndpoint)を使う。 空文字を渡した場合は無視される。

func WithHTTPClient

func WithHTTPClient(hc *http.Client) ClientOption

WithHTTPClient は dpf API client が使用する *http.Client を指定する。 プロキシ・TLS 設定・カスタム Transport 等を差し込みたい場合に使う。 指定した場合、そのクライアントがそのまま使われ、WithTimeout は無視される。

func WithMaxConcurrency

func WithMaxConcurrency(n int) ClientOption

WithMaxConcurrency は最大同時実行数を指定する(デフォルト DefaultMaxConcurrency)。

func WithMaxRetry

func WithMaxRetry(n int) ClientOption

WithMaxRetry はリトライ回数を指定する(デフォルト DefaultMaxRetry)。 0 を指定するとリトライしない。

func WithRateLimit

func WithRateLimit(r rate.Limit, burst int) ClientOption

WithRateLimit はレート制限(1 秒あたりのリクエスト数 r とバースト burst)を指定する。 デフォルトは DefaultRateLimit / DefaultBurst。

func WithTimeout

func WithTimeout(d time.Duration) ClientOption

WithTimeout は HTTP クライアントのタイムアウトを指定する(デフォルト DefaultTimeout)。 WithHTTPClient を併用した場合は、そのクライアントのタイムアウトが優先され、本指定は無視される。

func WithToken

func WithToken(token string) ClientOption

WithToken は固定のアクセストークンを指定する。 WithTokenProvider(TokenFromString(token)) と等価。 空文字を渡した場合は無視される。

トークン系オプション(WithToken / WithTokenFile / WithTokenProvider)を 複数指定した場合は、最後に指定したものが有効になる。

func WithTokenFile

func WithTokenFile(path string) ClientOption

WithTokenFile はアクセストークンを読み込むファイルを指定する。 WithTokenProvider(TokenFromFile(path)) と等価。 空文字を渡した場合は無視される。

ファイルは API リクエストのたびに読み込まれるため、外部でトークンが 更新されれば Client を作り直さずに反映される。読み込み頻度を抑えたい場合は WithTokenTTL を併用する。

func WithTokenProvider

func WithTokenProvider(p TokenProvider) ClientOption

WithTokenProvider はアクセストークンを取得する TokenProvider を指定する (デフォルト: TokenFromEnv(EnvAPIToken))。 nil を渡した場合は無視される。

Example

任意の取得処理で TokenProvider を組み立てる。

package main

import (
	"context"
	"fmt"

	"github.com/iij/dpf-go/utils"
)

func main() {
	// 例えば外部のシークレットストアから取得する。
	provider := func(ctx context.Context) (string, error) {
		return fetchTokenFromVault(ctx)
	}

	c, err := utils.NewClient(utils.WithTokenProvider(provider))
	if err != nil {
		fmt.Println(err)
		return
	}
	_ = c
}

// fetchTokenFromVault は ExampleWithTokenProvider 用のダミー。
func fetchTokenFromVault(context.Context) (string, error) {
	return "token-from-vault", nil
}

func WithTokenTTL

func WithTokenTTL(d time.Duration) ClientOption

WithTokenTTL はトークンのキャッシュ期間を指定する(デフォルト DefaultTokenTTL)。

0 以下を渡した場合はデフォルトのまま、つまりキャッシュせずリクエストのたびに TokenProvider を実行する。正の値を指定すると、その期間は前回取得した トークンを使い回す。

func WithUserAgent

func WithUserAgent(ua string) ClientOption

WithUserAgent は User-Agent を指定する(デフォルト "dpf-go/<version>")。

type HoldOption added in v0.5.0

type HoldOption func(*holdConfig)

HoldOption は RunLocked の任意設定を変更する。

func WithLockWait added in v0.4.0

func WithLockWait(d time.Duration) HoldOption

WithLockWait は排他を取得できるまで待つ間隔を指定する(デフォルト: 待たない)。 0 以下を渡した場合は待たない。

指定しない場合、RunLocked は排他を取得できなければただちに ErrStillLock を返す。 指定した場合は、取得できるまでこの間隔で繰り返す。待機は呼び出し側の打ち切りに従う。

func WithRenewEvery added in v0.5.0

func WithRenewEvery(d time.Duration) HoldOption

WithRenewEvery は保持期間を延ばす間隔を指定し、**排他の申告を上書きする** (デフォルト: 排他の申告 → 無ければ DefaultRenewInterval)。0 以下を渡した場合は 上書きしない。

通常は指定しなくてよい。排他が RenewIntervaler を実装していれば、保持期間に見合った 間隔がその実装から得られる。既定の排他は保持期間の 1/3 を申告する (その値は NewMutex の Option WithRenewInterval で変えられる)。

保持期間より十分短くすること。延長が一時的に失敗しても次の周期で間に合うように するためである。

type Locker added in v0.5.0

type Locker interface {
	Lock(ctx context.Context) error
	Renew(ctx context.Context) error
	Unlock(ctx context.Context) error
}

Locker はゾーン単位の排他を表す。

本パッケージの操作(RunLocked、ZoneApplier)はこの抽象を通して排他を扱う。既定では DPF-API のレコードを用いる Mutex が使われるが、etcd などの分散ロックを実装して 差し替えられる。

**1 つの値は 1 つのゾーンに対する 1 つの保持者を表す。** どのゾーンに対応するかを決める 責任は実装にある。本パッケージは対応づけを知らないため、取り違えても検出できない。

契約

実装は次の約束を守ること。

  • Lock は取得できるまで待ってはならない。他者が保持している場合も、**自分が既に 保持している場合(再入)** も ErrStillLock に errors.Is で一致するエラーを返す。 保持期間を延ばす場合は Renew を使う。
  • Renew は「保持を確かめ、必要なら延ばす」操作である。保持していないと分かった場合は ErrNotLockHolder に一致するエラーを返す。一時的に確かめられなかった場合はその エラーを返す(本パッケージは次の周期で再試行する)。**保持の維持を背景で行う仕組み でも、確かめる処理は必要である。** 何もせず nil を返すと、排他を失った状態のまま 処理が走り続ける。確かめる手段を持たない実装は常に nil を返してよいが、その限界を 実装の文書に明記すること。
  • Unlock は自分が保持者である場合に限り解放する。保持者でない場合は ErrNotLockHolder を返し、他者の排他を変更してはならない。排他がそもそも存在しない場合は何もせず nil を返す。
  • ErrStillLock と ErrNotLockHolder を取り違えないこと。前者は待てば取れるかもしれない 状態、後者は既に失った状態である。本パッケージは前者を再試行の対象とし、後者を 処理の打ち切りの合図として扱う。
  • 1 つの値に対する Lock / Renew / Unlock は複数の goroutine から同時に呼ばれうる (延長は別の goroutine で行われる)。実装は自身で直列化すること。

実装が契約を満たすかは utils/lockertest で機械的に確かめられる。

既定の実装との違い

既定のゾーンのラベルを用いる排他は、**2 者が同時に取得しないことを DPF-API の重複の 拒否によって機械的に保証する。この拒否は編集者が誰かに依らないため、ユーザをまたいで 効く。** 外部の仕組みを使う排他では、その仕組みを使うプログラム同士でしか防げない。

**どちらの方式も、保持中に他ユーザや管理画面からレコードを編集することは止められない。** 詳しくはパッケージ文書の比較を参照。

type Mutex

type Mutex struct {
	// contains filtered or unexported fields
}

Mutex はゾーン単位のロックを表す。

対象ゾーンのラベル(ZoneLockLabelKey)に保持者と奪ってよい時刻を書き込むことで ロックする。ラベルの読み取りから書き込みまでの区間は、専用レコードの追加(一時レコード 追加ロック)で排他する。DPF-API はレコードの新規追加に対して同名かつ同 RRTYPE の重複を 拒否し、この拒否は編集者が誰かに依らないためである。

排他の効く相手

**2 者が同時に取得することはない。** 一時レコード追加ロックの重複の拒否は DPF-API が 行うため、**ユーザをまたいで機械的に効く。** 同一ユーザの別プロセス、同一プロセス内の 別インスタンスも同じ仕組みで排他される。

**一方、保持中に他ユーザや管理画面からレコードを編集することは止められない。** 保持中の 尊重は、奪ってよい時刻を読んで譲るという協調によって成り立つ。本ライブラリ(または同じ ラベルを読む実装)を使わない相手には効かない。2 つの方式の比較はパッケージ文書を参照。

ゾーンに残るもの

**排他はゾーンのラベルを 1 つ消費する。** ゾーンのラベル数の上限は 10 であるため、 利用者がゾーンへ自由に付けられるラベルは 9 個までである。上限を超える場合、Lock は 書き込みを試みずに ErrLabelLimit を返す。

**解放してもラベルは残る。** Unlock は奪ってよい時刻を現在時刻へ更新するだけであり、 ラベルを削除しない(削除すると「一度も取得されていない」状態と区別が付かなくなる)。 したがって一度でも排他を取得したゾーンは、以降そのラベルを保持し続ける。

**レコードのラベルは消費しない。** 利用者は SOA レコードのラベルを 10 個すべて使える。

ゾーン反映との関係

Lock/Renew/Unlock はいずれもゾーン反映を行わず、**ゾーンに未反映の編集を作らない。** ゾーンのラベルは未反映の編集の概念を持たないためである。取得・延長・解放は権威サーバへ 公開されるデータにも影響しない。

**レコードの一括更新とゾーン反映(PatchZoneAtomicChanges)は排他に影響しない。** 置き換え の対象はレコードであり、ゾーンのラベルは別のリソースであるためである。反映の後も排他は 保持され、通常どおり解放できる。ゾーン全体を一括で置き換える場合は、取り込みの可否を 決めるフラグの固定と反映の完了待ちを引き受ける ZoneApplier を使うとよい。

保持者

**保持者は 1 文字以上 32 文字以下でなければならない。** 超える場合、Lock は書き込みを 試みずに ErrOwnerTooLong を返す。**黙って切り詰めることはしない**(切り詰めると別の インスタンスと同じ保持者になり、Renew と Unlock の宛先の判別が壊れる)。文字の種類に 制約は無く、区切りと同じ文字を含んでいてもよい。

WithOwner に一意でない値を渡しても排他は壊れない(排他は奪ってよい時刻によって 成立する)。壊れるのは Renew と Unlock の宛先の判別だけである。

旧版との混在

**v0.5.0 以前は排他の状態を SOA レコードのラベルへ書いていた。** 新旧が同じゾーンを 触ると、互いの排他を認識できず、両方が取得に成功しうる。ライブラリはこの混在を検出 できないため、**同じゾーンを触るプログラムはまとめて更新すること。**

旧版が SOA レコードへ残したラベルと未反映の編集は、本実装では掃除しない。必要であれば 利用者の側で取り除く。

その他

ロックは再入できない。保持中に Lock を呼ぶと ErrStillLock になるため、保持期間を 延ばす場合は Renew を使う。1 つの Mutex は 1 つの保持者を表し、Renew と Unlock は 自分が保持者であることを確認してから行う。

Mutex は Locker を満たす。排他の仕組みを差し替えたい場合は Locker を実装し、 ZoneApplier の WithLocker や RunLocked へ渡す。

保持中に他者がゾーン反映を行うと、一時レコード追加ロックのための専用レコードが 権威サーバへ公開されることがある。その場合、次の Lock がそのレコードを削除予定に してから入れ直すことで自力で回復する。削除予定は取り消さないため、利用者が次に ゾーン反映した時点で公開されたレコードは消える。ライブラリからゾーン反映は行わない。

func NewMutex

func NewMutex(cr dpf.RecordsApi, cz dpf.ZonesApi, zoneID string, opts ...Option) *Mutex

NewMutex はゾーン zoneID に対するロックを生成する。

  • cr : レコード API(*dpf.RecordsAPIService が利用できる)
  • cz : ゾーン API(*dpf.ZonesAPIService が利用できる)
  • zoneID : 対象ゾーンの ID

返された Mutex は 1 つの保持者を表す。同じインスタンスから並行して排他を取ることは できない(2 つ目は ErrStillLock になる)。並行して別々に排他を取る場合は インスタンスを分ける。

owner と ttl は基本的にデフォルト値(owner: インスタンスごとに一意な値 / ttl: 15分) を使う。変更したい場合のみ Option を opts に渡す。

Example

ゾーン単位ロックをデフォルト設定(owner: インスタンスごとに一意 / ttl: 15分)で 取得し、レコード編集後にロックを解放する。

package main

import (
	"context"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	mu := utils.NewMutex(client.RecordsAPI, client.ZonesAPI, "zone-id-123456")
	if err := mu.Lock(ctx); err != nil {
		// ErrStillLock の場合は他者がロック中。
		return
	}
	defer func() {
		if err := mu.Unlock(ctx); err != nil {
			fmt.Println(err)
		}
	}()

	// ここでロック対象ゾーンのレコードを編集し、ゾーン反映する。
}
Example (Options)

owner と TTL を変更してロックを生成する。

package main

import (
	"time"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)

	mu := utils.NewMutex(
		client.RecordsAPI,
		client.ZonesAPI,
		"zone-id-123456",
		utils.WithOwner("deployer"),
		utils.WithTTL(30*time.Minute),
	)
	_ = mu
}

func (*Mutex) Do added in v0.4.0

func (t *Mutex) Do(ctx context.Context, fn func(ctx context.Context) error, opts ...HoldOption) error

Do は排他を取得し、fn を実行し、終了時に解放する。RunLocked の薄い包みである。

振る舞いの詳細は RunLocked を参照。この Mutex は延長の間隔として保持期間の 1/3 を 申告するため、利用者が間隔を指定する必要はない。

Example

排他を保持したまま処理を実行する。

実行中は保持期間が自動で延長されるため、処理が長引いても排他は保たれる。 排他を他者に奪われた場合は、処理へ渡された context が打ち切られる。 終了時には自動で解放される。

package main

import (
	"context"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	mu := utils.NewMutex(client.RecordsAPI, client.ZonesAPI, "zone-id-123456")

	err := mu.Do(ctx, func(ctx context.Context) error {
		// ここでレコードを 1 つずつ変更し、ゾーンへ反映する。
		// この流れでは反映によって排他が解かれないため、復帰後に解放される。
		return nil
	})
	if err != nil {
		// ErrStillLock: 他者が保持中。ErrNotLockHolder: 保持中に奪われた。
		fmt.Println(err)
	}
}

func (*Mutex) Lock

func (t *Mutex) Lock(ctx context.Context) error

Lock はゾーンのロックを取得する。

手順は次の 4 段である。

  1. ゾーンのラベルによる事前判定。取得できないと分かった場合は、専用レコードを 作らずに ErrStillLock(またはラベル数の上限による ErrLabelLimit)を返す。
  2. 一時レコード追加ロックの取得。
  3. ゾーンのラベルの再読み取りと判定、書き込み、書き込んだ内容の確認。 事前判定の結果は用いない。判定と書き込みの間に他者が取得しうるためである。
  4. 一時レコード追加ロックの解放。第 3 段の成否によらず行う。

保持者が 32 文字を超える場合は、いずれの段にも入らず ErrOwnerTooLong を返す。

取得できるのは、奪ってよい時刻を過ぎている場合、ラベルが無い場合、ラベルの値が 形式を満たさない場合のみである。owner は判定に用いないため、自分自身が 保持している場合も ErrStillLock となる。保持期間を延ばす場合は Renew を使う。

復帰した時点で、書き込んだ内容が読み出せることを確認済みである。確認できない場合は 取得に失敗したものとして扱う。

func (*Mutex) LockWait

func (t *Mutex) LockWait(ctx context.Context, interval time.Duration) error

LockWait はロックを取得できるまで Lock をリトライし続ける。 interval で指定した間隔でリトライする。 ErrStillLock 以外のエラーが発生した場合は、そのエラーを返して終了する。 ctx がキャンセルされた場合は ctx.Err() を返す。

取得できない場合の Lock は事前判定だけで終わるため、1 回のリトライあたりの 問い合わせは 1 回である。

Example

ロックを取得できるまで待機する。

package main

import (
	"context"
	"fmt"
	"time"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)

	mu := utils.NewMutex(client.RecordsAPI, client.ZonesAPI, "zone-id-123456")
	ctx := context.Background()
	if err := mu.LockWait(ctx, 5*time.Second); err != nil {
		return
	}
	defer func() {
		if err := mu.Unlock(ctx); err != nil {
			fmt.Println(err)
		}
	}()
}

func (*Mutex) Owner added in v0.3.0

func (t *Mutex) Owner() string

Owner は排他の保持者を表す値を返す。診断のために公開している。

func (*Mutex) Renew added in v0.3.0

func (t *Mutex) Renew(ctx context.Context) error

Renew は保持している排他の奪ってよい時刻を、現在時刻 + TTL へ進める。

自分が保持者でない場合(延長の最中に他者へ奪われた場合を含む)は ErrNotLockHolder を返す。一時レコード追加ロックは取得しない。判定の対象が 「自分が保持者か」であり、競合しうる相手は奪ってよい時刻を過ぎたと判断して 奪った者だけで、それは延長が失敗すべき場合そのものであるためである。

Example

編集が長引く場合に保持期間を延長する。

ロックは再入できないため、Lock を呼び直して延長することはできない。 延長は Renew で行う。

package main

import (
	"context"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	mu := utils.NewMutex(client.RecordsAPI, client.ZonesAPI, "zone-id-123456")
	if err := mu.Lock(ctx); err != nil {
		return
	}
	defer func() {
		if err := mu.Unlock(ctx); err != nil {
			// ErrNotLockHolder の場合は保持中に奪われている。
			fmt.Println(err)
		}
	}()

	// 長い編集の途中で保持期間を延ばす。
	if err := mu.Renew(ctx); err != nil {
		// ErrNotLockHolder の場合は既に他者へ渡っているため、編集を中止する。
		return
	}
}

func (*Mutex) RenewInterval added in v0.5.0

func (t *Mutex) RenewInterval() time.Duration

RenewInterval は延長の間隔として保持期間の 1/3 を返す(RenewIntervaler)。 WithRenewInterval で変更できる。

func (*Mutex) Unlock

func (t *Mutex) Unlock(ctx context.Context) error

Unlock はロックを解放する。 ロックを奪っても良い時刻(deadline)を現在時刻に更新することで、他者が即座に 奪取できるようにする。排他のラベルが存在しない場合は何もしない。

**ラベルは削除しない。** 削除すると「一度も取得されていない」状態と区別が付かなく なるためである。したがって一度でも排他を取得したゾーンは、解放の後もゾーンのラベルを 1 つ保持し続ける。

**解放の確定は非同期である。** ゾーンのラベルの更新は非同期に処理され、Unlock は 書き込みの確定を確認しない(Lock と違い、待つ必要が無いためである)。したがって **復帰した直後は、他者からまだ保持されているように見えることがある。** 解放を見届けて から次の操作へ進みたい場合は、ラベルを読んで奪ってよい時刻が過去になったことを確かめる こと。2026-09-29 に実 API で観測した。

自分が保持者でない場合は、他者の排他を変更せずに ErrNotLockHolder を返す。 defer から呼ぶ利用者が、保持中に奪われた事実を検知できるようにするためである。

type Option

type Option func(*Mutex)

Option は Mutex の任意設定を変更する。

func WithLockRecordContent added in v0.3.0

func WithLockRecordContent(rrtype dpf.RecordsRrtypeWithoutSoa, rdata []string) Option

WithLockRecordContent は専用レコードの RRTYPE と rdata を指定する (デフォルト: TXT と `"dpf-go lock"`)。rdata が空の場合はデフォルトのままとする。

RRTYPE と rdata を対で受け取るのは、RRTYPE ごとに妥当な rdata が異なり、 独立に変更できないためである。rdata は排他の判定には用いない。

func WithLockRecordLabel added in v0.3.0

func WithLockRecordLabel(label string) Option

WithLockRecordLabel は一時レコード追加ロックに使う専用レコードの最左ラベルを 指定する(デフォルト: DefaultLockRecordLabel `_dpf-go-lock`)。 空文字を渡した場合はデフォルトのままとする。

受け取るのは最左ラベルのみであり、レコード名はゾーン名と結合して組み立てる。 ゾーンの範囲外の名前を指定できないようにするためである。

func WithLockRecordTTL added in v0.3.0

func WithLockRecordTTL(ttl time.Duration) Option

WithLockRecordTTL は一時レコード追加ロックが失効するまでの時間を指定する (デフォルト: DefaultLockRecordTTL 1分)。0 以下を渡した場合はデフォルトのままとする。

この時間は、取得の途中でプログラムが異常終了した場合に、他者が専用レコードを 取り消して先へ進めるようになるまでの待ち時間である。

func WithOwner

func WithOwner(owner string) Option

WithOwner はロック作成者を指定する(デフォルト: ホスト名・PID・ランダム値から 生成した、インスタンスごとに一意な値)。空文字を渡した場合はデフォルトのままとする。

一意でない値を指定しても排他は壊れないが、Renew と Unlock が別のインスタンスの 排他を対象にしうる。複数のプログラムやインスタンスで同じ値を使わないこと。

func WithRenewInterval added in v0.4.0

func WithRenewInterval(d time.Duration) Option

WithRenewInterval は、この排他が申告する延長の間隔を指定する (デフォルト: 保持期間の 1/3。既定の保持期間 15 分では 5 分)。 0 以下を渡した場合はデフォルトのままとする。

RunLocked(および Do)は、この値を延長の間隔として使う(RenewIntervaler)。

保持期間より十分短くすること。延長が一時的に失敗しても次の周期で間に合う ようにするためである。

func WithTTL

func WithTTL(ttl time.Duration) Option

WithTTL はロック保持期間を指定する(デフォルト: DefaultLockTTL 15分)。 0 以下を渡した場合はデフォルトのままとする。

func WithVerifyTimeout added in v0.3.0

func WithVerifyTimeout(d time.Duration) Option

WithVerifyTimeout は書き込んだ内容が読み出せるまで待つ上限を指定する (デフォルト: DefaultVerifyTimeout 10秒)。0 以下を渡した場合はデフォルトのままとする。

レコードの更新は非同期に処理されるため、Lock は書き込んだラベルが読み出せるまで待つ。 この待機は、同時に書き込んだ別のプログラムに負けていないかの確認も兼ねる。

type RenewIntervaler added in v0.5.0

type RenewIntervaler interface {
	RenewInterval() time.Duration
}

RenewIntervaler は、保持期間を延ばす間隔を自分で決められる排他が実装する任意の インターフェースである。

実装しなくてもよい。実装した場合、RunLocked はこの値を延長の間隔として使う。保持期間から 導ける実装は実装すること。利用者が保持期間を変えるたびに間隔を指定し直さずに済む。 実装しない場合は DefaultRenewInterval が使われる。利用者が WithRenewEvery を指定した 場合は、申告よりそちらが優先される。

type TokenError

type TokenError struct {
	// Err はトークン取得に失敗した原因。
	Err error
}

TokenError はアクセストークンの取得に失敗したことを表す。

トークンを取得できない状態は同じリクエストを繰り返しても解消しないため、 Operation はこのエラーをリトライしない。 Unwrap により errors.Is(err, ErrTokenRequired) や、TokenProvider が返した 元のエラー(os.ErrNotExist など)の判定がそのまま行える。

func (*TokenError) Error

func (e *TokenError) Error() string

Error はエラーメッセージを返す。

func (*TokenError) Unwrap

func (e *TokenError) Unwrap() error

Unwrap は原因となったエラーを返す。

type TokenProvider

type TokenProvider func(ctx context.Context) (string, error)

TokenProvider は API リクエストのたびに呼ばれ、アクセストークンを返す関数。

返す値はトークンそのものであり、"Bearer " 接頭辞は付けない (Authorization ヘッダの組み立てはライブラリ側で行う)。 空文字列を返した場合は ErrTokenRequired として扱われる。

リクエストごとに評価されるため、外部でローテーションされたトークンを Client を作り直さずに反映できる。呼び出し頻度を抑えたい場合は WithTokenTTL でキャッシュ期間を指定する。

func TokenFromEnv

func TokenFromEnv(name string) TokenProvider

TokenFromEnv は環境変数 name の値をトークンとして返す TokenProvider を返す。

環境変数は呼び出しのたびに参照される。未設定の場合は空文字列を返すため、 呼び出し側では ErrTokenRequired として扱われる。

func TokenFromFile

func TokenFromFile(path string) TokenProvider

TokenFromFile は path のファイルを読み、前後の空白を取り除いた内容を トークンとして返す TokenProvider を返す。

ファイルは呼び出しのたびに読み込まれるため、外部プロセスがファイルを 書き換えれば次のリクエストから新しいトークンが使われる。 読み込みに失敗した場合はそのエラーを返す(ファイルの内容はエラーに含めない)。

func TokenFromString

func TokenFromString(token string) TokenProvider

TokenFromString は固定のトークン文字列を返す TokenProvider を返す。

type ZoneApplier added in v0.4.0

type ZoneApplier struct {
	// contains filtered or unexported fields
}

ZoneApplier はゾーン全体の一括置き換えを、ゾーン単位の排他の下で行う。

生成された API を直接使う場合、次の 2 つを自分で扱う必要がある。いずれも間違えても その場では成功したように見えるため、この型がまとめて引き受ける。

  1. SOA レコードと Zone Apex の NS レコードを取り込むか否かのフラグ。**既定は 「取り込む」である。** 公開されているレコードから組み立てたリクエストをそのまま 送ると、これらが意図せず置き換わる。本型は常に「取り込まない」を指定する。
  2. 公開されているレコードの一覧は、編集中の SOA を「更新前の状態」として返す。 レコードの一覧を取得する API が「更新予定」として返すのとは見え方が異なる。

**一括置き換えは排他に影響しない。** 置き換えの対象はレコードであり、排他の状態は ゾーンのラベルにあるためである。反映の後も排他は保持され、終了時に通常どおり解放される。 排他の仕組みを WithLocker で差し替えても同じである。

排他は内部に持ち、公開しない。レコードを 1 つずつ変更してゾーンへ反映する流れでは、 この型ではなく RunLocked(または Mutex.Do)を使うこと。

func NewZoneApplier added in v0.4.0

func NewZoneApplier(cr dpf.RecordsApi, cz dpf.ZonesApi, cj dpf.JobsApi, zoneID string, opts ...ApplierOption) *ZoneApplier

NewZoneApplier はゾーン zoneID に対する一括置き換えを生成する。

  • cr : レコード API(*dpf.RecordsAPIService が利用できる)
  • cz : ゾーン API(*dpf.ZonesAPIService が利用できる)
  • cj : JOB API(*dpf.JobsAPIService が利用できる)
  • zoneID : 対象ゾーンの ID

opts で排他の仕組みを差し替えたり(WithLocker)、既定の排他へ設定を渡したり (WithLockOptions)できる。指定しない場合は、ゾーンのラベルを用いる既定の排他が使われる。

func (*ZoneApplier) Apply added in v0.4.0

func (a *ZoneApplier) Apply(ctx context.Context, edit ZoneRecordsEditor, opts ...ApplyOption) error

Apply は排他を取得し、公開されているレコードを edit へ渡し、その結果でゾーン全体を 置き換えて反映する。反映の完了を待ってから復帰する。

手順は次のとおりである。

  1. 排他を取得する(取得できない場合は ErrStillLock。WithLockWait を指定すれば待つ)
  2. 公開されているレコードを取得し、リクエストの形へ変換する
  3. edit を呼ぶ。ErrSkipApply が返った場合は 4・5 を行わず nil を返す。返された一覧が 空の場合は ErrNoRecords
  4. 一括置き換えと反映を行う(SOA と Zone Apex の NS は取り込まない)
  5. 反映の完了を待つ

反映しても省略しても、終了時に排他を解放する。**一括置き換えは排他に影響しないため、 反映の後も保持されている。** 解放に失敗した場合はそのエラーが返る。

省略しても、排他の取得・公開されているレコードの取得・解放は行われる(レート制限を 消費する)。減るのは一括置き換えと反映の待ちであり、**公開されているゾーンのシリアルと 履歴が動かない**ことが省略の効果である。

edit が返した一覧に SOA レコードまたは Zone Apex の NS レコードが含まれていない 場合は、変換前の一覧のものを補う。いずれも取り込まれないため内容に影響しないが、 リクエストの必須項目を満たすためである。利用者はこれらを意識する必要がない。

edit の実行中も保持期間は自動で延長される。排他を他者に奪われた場合は edit へ渡した context が打ち切られ、ErrNotLockHolder が返る。詳細は Mutex.Do を参照。

Example

ゾーン全体を読んで編集し、一括で置き換える。

取り込みの可否を決めるフラグ(既定はいずれも「取り込む」)の固定、反映によって 排他が解かれることの扱い、非同期処理の完了待ちは ZoneApplier が引き受ける。 利用者が書くのは編集の内容だけである。

package main

import (
	"context"
	"fmt"
	"time"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	ap := utils.NewZoneApplier(
		client.RecordsAPI, client.ZonesAPI, client.JobsAPI,
		"zone-id-123456",
		utils.WithLockOptions(utils.WithTTL(30*time.Minute)),
	)

	err := ap.Apply(ctx, func(ctx context.Context, records []dpf.OverwriteRecordsInner) ([]dpf.OverwriteRecordsInner, error) {
		// 編集しない要素はそのまま返せばよい。ラベル・TTL・コメントは保たれる。
		ttl := int32(300)
		return append(records, dpf.OverwriteRecordsInner{
			Name:   "www.example.jp.",
			Ttl:    *dpf.NewNullableInt32(&ttl),
			Rrtype: dpf.RECORDSRRTYPE_A,
			Rdata:  []dpf.RecordsRdataInner{{Value: dpf.PtrString("192.0.2.1")}},
			Labels: map[string]string{},
		}), nil
	}, utils.WithApplyDescription("台帳と同期"))
	if err != nil {
		// ErrNoRecords: 編集の結果が空だった。反映が不要な場合は ErrSkipApply を使う。
		fmt.Println(err)
	}
}
Example (SkipApply)

反映が不要だった場合は、編集関数から ErrSkipApply を返す。

一括置き換えは行われず、Apply は nil を返す。空の一覧を返すと ErrNoRecords(失敗)に なるため、「変更なし」を空で表してはならない。

package main

import (
	"context"
	"fmt"

	dpf "github.com/iij/dpf-go"
	"github.com/iij/dpf-go/utils"
	"github.com/miekg/dns"
)

func main() {
	cfg := dpf.NewConfiguration()
	client := dpf.NewAPIClient(cfg)
	ctx := context.Background()

	ap := utils.NewZoneApplier(
		client.RecordsAPI, client.ZonesAPI, client.JobsAPI,
		"zone-id-123456",
	)

	// 反映したか省略したかは Apply の戻り値に現れない。必要なら控えておく。
	var skipped bool
	err := ap.Apply(ctx, func(ctx context.Context, records []dpf.OverwriteRecordsInner) ([]dpf.OverwriteRecordsInner, error) {
		// ドメイン名の比較は miekg/dns で正規化してから行う。
		name := dns.CanonicalName("www.example.jp")
		for _, r := range records {
			if r.Rrtype == dpf.RECORDSRRTYPE_A && dns.CanonicalName(r.Name) == name {
				// 既に目的の状態である。反映しない。
				skipped = true
				return nil, utils.ErrSkipApply
			}
		}

		ttl := int32(300)
		return append(records, dpf.OverwriteRecordsInner{
			Name:   name,
			Ttl:    *dpf.NewNullableInt32(&ttl),
			Rrtype: dpf.RECORDSRRTYPE_A,
			Rdata:  []dpf.RecordsRdataInner{{Value: dpf.PtrString("192.0.2.1")}},
			Labels: map[string]string{},
		}), nil
	})
	if err != nil {
		fmt.Println(err)
		return
	}
	if skipped {
		fmt.Println("変える必要が無かった")
	}
}

type ZoneRecordsEditor added in v0.4.0

type ZoneRecordsEditor func(ctx context.Context, records []dpf.OverwriteRecordsInner) ([]dpf.OverwriteRecordsInner, error)

ZoneRecordsEditor はゾーンへ反映したいレコードの一覧を決める。

受け取る一覧は、公開されているレコードを変換したものである。**入力と出力が同じ型 であるため、編集しない要素はそのまま返せばよい。** ラベル・TTL・コメントを写し替える 必要はなく、写し忘れて失う心配もない。

ctx は ZoneApplier.Apply へ渡されたものから派生しており、排他を他者に奪われた時点で 打ち切られる。外部から情報を読んで編集内容を決める場合は、この打ち切りに従うこと。 打ち切られた場合は ErrSkipApply ではなく ctx.Err() を返すこと。

反映が不要だと分かった場合は ErrSkipApply を返す。一括置き換えは行われず、Apply は nil を 返す。エラーを返す場合、返した一覧は使われない。

省略の判定は errors.Is で行う。このため**包んだ場合、そのメッセージは捨てられる。** Apply は反映したか省略したかを呼び出し側へ返さないため、理由を残したい場合はこの関数の 中で記録するか、クロージャの変数へ控えること。errors.Join(ErrSkipApply, 本物のエラー) を 返してはならない。省略が優先され、もう一方が失われる。

Directories

Path Synopsis
Package lockertest は、utils.Locker の実装が契約を満たすかを機械的に確かめる手段と、 契約を満たす参照実装を提供する。
Package lockertest は、utils.Locker の実装が契約を満たすかを機械的に確かめる手段と、 契約を満たす参照実装を提供する。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL