コンテンツにスキップ

DynamoDB

DynamoDB はリレーショナルデータベースエンジンの追加候補ではありません。その隣に、 あるいは代わりに置く別系統のストアであり、両者は何も共有しません。 middleware.dynamo は独自のセクションと独自のクライアントを持ち、接続グループも、 トランザクションランナーも、.pw.sql もありません。セッションを SQLite に、イベントを DynamoDB に置くプロジェクトは例外ではなく想定された形で、リレーショナルデータベースを まったく持たないプロジェクトも同じです。

そもそもこれが存在できているのは、オブジェクトストレージと 同じ事情によります。aws-sdk-go-v2 は TinyGo でビルドできません。だからここでのクライアントは tinygodriver のもので、その上の型付きレイヤーは tinybinddynamobind です。どちらも両方のターゲットでコンパイルできます。クライアントは context の値ではなくプロセスのハンドルとして持ち回ります。context 経由のクライアント経路は wasip1 ビルドでおよそ 37 KB あり、ハンドルはそのコストを払わずに済む形です。

設定セクションを作るのは import です。DynamoDB を使わないプロジェクトはキーも持たず、 ドライバもリンクしません。

cmd/myapp/main.go
import _ "github.com/shibukawa/popcornweb/database/dynamo"
[middleware.dynamo]
enabled = true
region = "us-east-1"
endpoint = "http://127.0.0.1:8000"
access_key_id = "local"
secret_access_key = "local"
auto_migrate = true

pw add dynamo は、このセクション、スターターのレコード、生成が読む generate.dynamo の エントリ、そして pw dev が起動する dynamodb-local パッケージを書きます。上の値はローカル サーバー向けです。署名を検証しないので資格情報は空でなければ何でもよく、リージョンは受け 付けられる placeholder です。これらは config.dev.toml に置き、デプロイの既定値にはしません。

キー 既定値 意味
enabled false 起動時にクライアントを開き、プロセスのハンドルとして保持する
region (空) 空なら環境変数。どちらからも決まらなければ起動エラー
endpoint (空) 空ならリージョンのホスト。値を入れるとローカルや互換サーバー
access_key_id (空) 空ならドライバが環境変数から取る
secret_access_key (空) どこでもマスクされる
session_token (空) どこでもマスクされる
table_prefix (空) 宣言上のテーブル名の前に付く
table_names [] 宣言名から実際の名前への明示的な対応
timeout "10s" 1 リクエストの上限
max_idle_conns 4 想定並列数に合わせる
verify_schema true 起動時に登録済みテーブルを 1 回ずつ読み、食い違えば起動しない
auto_migrate false 起動時に不足テーブルを作る。開発専用

静的な資格情報の片方だけを書くと拒否されます。中途半端な組は環境変数へフォールバックして、 もっと分かりにくい場所で失敗するからです。3 つの資格情報キーはすべて ${NAME} を展開し、 展開後にマスクされるので、展開された秘密が起動サマリにもエラーにも出ることはありません。

起動時にはクライアントを構築し、リージョンか資格情報が無ければサービス開始前に失敗します。 シャットダウンではクライアントを閉じ、プールした接続を解放します。

dynamo タグの付いた構造体がテーブルです。

package records
type Note struct {
ID string `dynamo:"id,partitionkey"`
CreatedAt time.Time `dynamo:"created_at,sortkey"`
Body string `dynamo:"body"`
}

pw generate はこれを読み、アイテムのコーデック、キービルダー、テーブル定義、そしてその定義を 「望ましいスキーマ」に登録する init を出力します。宣言上のテーブル名は型名の snake_case—— ここでは note——で、デプロイに関する情報はソースに一切現れません。

生成は使われ方に従います。実際に呼ばれている方向のコーデックだけが出るので、読み取りを消せば 生成コードもその分縮みます。ディレクトリは popcornweb.tomlgenerate.dynamo に並べる 必要があり、これは pw add dynamo が書きます。どのディレクトリにも属さない .pw.dynamo は、 黙って飛ばされるのではなく、パスを名指しして 1 度だけ報告されます。

クライアントと設定済みのテーブル名解決は、1 つのハンドルにまとまってこのパッケージが プロセス状態として保持します。リクエストの context には何も入らないので、呼び出し側が context の探索コストを払うことはありません。

func store(ctx context.Context, note Note) error {
h, err := dynamo.Handle(ctx)
if err != nil {
return err
}
return dynamobind.StoreOn(ctx, h, "note", note)
}
func load(ctx context.Context, id string, createdAt time.Time) (Note, error) {
h, err := dynamo.Handle(ctx)
if err != nil {
return Note{}, err
}
return dynamobind.LoadOn[Note](ctx, h, "note", Note{ID: id, CreatedAt: createdAt}.ItemKey())
}

アイテム操作がテーブル名を取るのは、読み取るべき宣言を持たないからです。セクションを 有効にしないまま呼び出すと、panic ではなく「クライアントが無い」という名前付きのエラーを 受け取ります。宣言済みクエリはハンドルを取りません — 生成された本体が同じハンドルを 自分で解決するからで、呼び出し側が context だけで済むのはそのためです。

これらの呼び出しを包むラッパーはありません。database/dynamo が公開するのは設定、テーブル レジストリ、マイグレータだけです。リレーショナル側にラッパーがあるのは、3 つのエンジンを 1 つに見せる必要があるからでした。こちらのクライアントは 1 つなので、ラッパーはすでに名前が あるものに 2 つ目の名前を与えるだけになります。

アクセスパターンはパッケージの隣の .pw.dynamo ファイルに書き、名前付きの関数 1 つになります。

export statement ReadingsSince(sensor: Sensor, from: int64): dynamo.many<Reading> {
table reading
key sensor = {sensor} and at > {from}
}
for reading, err := range records.ReadingsSince(ctx, sensor, from) {
// ...
}

呼び出し側は属性も式もテーブルもクライアントも書きません。table 句はすべてのステートメントで 必須で、それがシグネチャからテーブル引数を消しています。key 句はパーティションキーの等値と、 =<<=>>=betweenbegins_with のうち高々 1 つのソートキー条件です。

結果型 戻り値
dynamo.page<T> リクエスト 1 回。Count, ScannedCount, LastEvaluatedKey を持つ Page[T]
dynamo.many<T> 全ページを回すイテレータ

何回リクエストするかは書き手の判断のままにしてある、というのが両方ある理由です。

生成時には、SQL のジェネレータにはできない検査ができます。ここではタグスキーマであり、 ソースが食い違う相手の DDL が別に存在しないからです。名前が出てくる属性はすべて対象の型に 存在しなければならないので、宣言を直さずにタグを改名すると、最初のリクエストではなく pw generate が失敗します。キー句に非キー属性を書けば、それが属する句を名指ししたエラーに なります。パラメータの型は、そのタグが属性をどう保存するかと突き合わされます。そして属性は 無条件にエイリアスされるので、DynamoDB の 573 個の予約語のどれも式にそのまま現れません。

filter、projection、condition、update の各式は宣言できません。セカンダリインデックスも 同様なので、宣言型クエリはテーブル自身のキーに対して走ります。宣言で表せないものには、 検査のない文字列のキー条件が引き続き使えます。

タグのオプション、属性の型、キー述語、生成時の検査はすべて DynamoDB クエリフォーマットにあります。

ソースが宣言するのは note です。デプロイ先では myapp-note-prod かもしれません。その差を 埋めるのは、一度だけ設置され、すべてのランタイム入口が実行する 1 つの関数です。

[middleware.dynamo]
table_prefix = "myapp-"
[[middleware.dynamo.table_names]]
declared = "note"
deployed = "notes-prod-8f21c"

明示的なエントリが優先され、次にプレフィックス、どちらも無ければ宣言名がそのまま使われます。 プレフィックスだけでは足りませんでした。listing も IAM もライフサイクルルールも読む S3 の キープレフィックスと違い、DynamoDB のテーブル名にはサービスが見る構造がありません。CDK が 生成した物理名や、環境名が末尾に来る orders-prod は、何かを前に付けるだけでは作れないの です。どちらのキーでも表せないデプロイのために、dynamo.WithTableResolver が合成された関数 ごと差し替えます。

1 つの関数がリクエスト経路とマイグレータの両方を担います。関数にしてある理由がそこです。 ハンドラが見つけられないテーブルをマイグレーションが作ってしまえば、どこにも見える形では 失敗しません。設定した名前も解決後の名前も、起動時に DynamoDB 自身の規則で検証されます。 どのコードも宣言していないテーブルを指す table_names のエントリは、黙って何もしない行では なくエラーになります。

このストアにマイグレーションファイルはなく、バージョンテーブルもありません。望ましい状態は 登録されたテーブル定義の集合、観測される状態は DescribeTable、適用とはその 2 つの比較です。

plan, err := dynamo.Plan(ctx) // []TableChange
result, err := dynamo.Migrate(ctx)

無いテーブルは生成されたキーで作られ、アクティブになるまでポーリングされます。あるテーブルは キー属性の名前が位置で比較され、それ以外は比較されません。DescribeTable が属性の型を返さない ので、型だけ変わったキーは一致して見えます。改名・欠落・余分なキーは捕まります。食い違いは、 テーブル名と望ましい形と観測された形を挙げたエラーになります。「違う」とだけ言われても、 どちらが想定外なのかは分からないからです。

決してやらないことが 2 つあります。キーの変更は報告され、実行されません。ドライバに UpdateTable が無く、そもそもパーティションキーは変更できません。そして、アカウントにあって ソースに無いテーブルは、報告されるだけで削除されません

バージョン管理が無いのは、必要が無いからです。DescribeTable が現物の形を返すので、再適用は 記録によってではなく構造上 no-op になり、手で加えられた変更は隠れるのではなく見えます。down も無く、ここで変更を巻き戻すとは DeleteTable のことで、SQL のロールバックが壊さないデータを 壊します。

ただし作成は、開発とテストの手順です。auto_migrate = true は起動時にプランを適用しますが、 それ以外の環境では設定エラーになります。デプロイされたテーブルは、キューやバケットを作るのと 同じ道具が用意するものだからです。本番でフレームワークが受け持つのはもう半分、既定で有効な verify_schema のほうです。デプロイの道具は自分が作ったものは知っていても、アプリケーションが 前提にしているものは知りません。その 2 つを比べられるのはアプリケーションだけです。無効化は 受け付けられ、そして警告されます。

TTL、保持期間、オートスケーリング、タグ、レプリケーションを比較の外に置いているのも、同じ線 です。それらはテーブルの持ち主のもので、差分を報告する仕組みは正しいデプロイのたびに鳴り、 やがて読まれなくなります。

無いもの 帰結
トランザクション、PartiQL、Streams、DAX ドライバが実装していない
UpdateTable 既存のテーブルは作り変えられない
セカンダリインデックスのタグ 宣言型クエリはテーブル自身のキーを使う
filter と update の式 宣言できるのはキー条件、件数、方向、整合性まで
シングルテーブル設計 1 つの構造体が 1 つのテーブルを持つ。tinybind がそう決めている

どれも Popcorn Web の選択ではありません。下のレイヤーが今できることの縁であり、そちらが 動けばこちらも動きます。

sessionstore/dynamo はログインセッションをこのストアに置きます。リレーショナルデータベースを まったく持たないデプロイのためのものです。このパッケージが開いたクライアントを借り、自分の テーブルを登録するので、セッションテーブルも他と一緒に作られ、検証されます。そして期限切れの レコードを消すのは、有効にした TTL か、さもなければ何もありません。 セッションストレージを参照してください。