ClientとResource、DynamoDBの書き方を分ける境界線
今日はAWS公式の開発者ガイド「Python と Boto3 による Amazon DynamoDB のプログラミング」を読み込んで、内容を整理してみた。
まず前提から。Boto3は、AWSのサービスをPythonから操作するためのSDK(ソフトウェア開発キット。要は「API呼び出しを楽にするための道具箱」)だ。2015年に登場した3番目のメジャーバージョンで、内部は2つのパッケージに分かれている。
- Botocore: 低レベル層。クライアントやセッション、認証情報、設定、例外クラスなどを扱う
- Boto3: Botocoreの上に構築された、ハイレベルなインターフェース
インストール手順については、このページ自体には記載がなかった。Boto3公式の「クイックスタート」ページへの参照があるだけだ。ただし「Boto3は多くの場合、AWS Lambdaなどのサービス内で自動的に利用可能」という一文はあった。
読み進めていくと、このドキュメントを貫く軸がはっきり見えてきた。DynamoDBの操作には「Client」と「Resource」という2つの入口があり、機能によって「両方使えるけど書き方が違う」パターンと「片方でしか使えない」パターンがある、という構造だ。今日はこの軸に沿ってメモをまとめる。
入口は2つ、ClientとResource
まずコードで見ると違いがわかりやすい。
# クライアント層
import boto3
dynamodb = boto3.client('dynamodb')
# リソース層
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('YourTableName')
どちらもDynamoDBを操作するための入口だが、性格が違う。この後何度も出てくる比較の起点になる。
パターン1:両方で書けるが、書き方が違う
PutItem(項目の書き込み)
同じ「1件書き込む」操作でも、ClientとResourceでは項目の書き方が変わる。
クライアント層は、DynamoDB独自のJSON形式で、値の型を明示する必要がある(Sは文字列、Nは数値、という具合)。
dynamodb.put_item(
TableName='YourTableName',
Item={
'pk': {'S': 'id#1'},
'sk': {'S': 'cart#123'},
'name': {'S': 'SomeName'},
'inventory': {'N': '500'},
}
)
一方リソース層は、素のPythonの値をそのまま渡せる。型は裏側で暗黙的に処理される。
table.put_item(
Item={
'pk': 'id#1',
'sk': 'cart#123',
'name': 'SomeName',
'inventory': 500,
}
)
見比べると、「型を明示的に書くか書かないか」がClientとResourceの一番わかりやすい違いだとわかる。テーブル情報を取得するときも同じ構図が出てくる。クライアントはdescribe_tableのレスポンス辞書からresponse['Table']['TableSizeBytes']のように取り出すのに対し、リソースはtable.table_size_bytesと、属性のようにアクセスできる。
なお、DynamoDB独自のJSON形式と普通のPython辞書を相互変換するユーティリティも例として載っていた。
def dynamo_to_python(dynamo_object: dict) -> dict:
deserializer = TypeDeserializer()
return {k: deserializer.deserialize(v) for k, v in dynamo_object.items()}
def python_to_dynamo(python_object: dict) -> dict:
serializer = TypeSerializer()
return {k: serializer.serialize(v) for k, v in python_object.items()}
Query(検索)
クエリも同じく、両方で書けるが条件式の書き方が違う。
クライアント層は文字列ベースの条件式を組み立てる。
response = client.query(
TableName='YourTableName',
KeyConditionExpression='pk = :pk_val AND begins_with(sk, :sk_val)',
FilterExpression='#name = :name_val',
ExpressionAttributeValues={
':pk_val': {'S': 'id#1'},
':sk_val': {'S': 'cart#'},
':name_val': {'S': 'SomeName'},
},
ExpressionAttributeNames={'#name': 'name'}
)
リソース層はKeyとAttrというオブジェクトを組み合わせて条件を書く。
from boto3.dynamodb.conditions import Key, Attr
response = table.query(
KeyConditionExpression=Key('pk').eq('id#1') & Key('sk').begins_with('cart#'),
FilterExpression=Attr('name').eq('SomeName')
)
文字列を組み立てる方式と、オブジェクトを&で連結する方式。どちらも同じことをやっているのに、見た目の印象がかなり違う。
パターン2:片方でしか使えない機能
ここからが今日一番「なるほど」と思った部分。ClientとResourceは単に書き方が違うだけでなく、そもそも片方にしかない機能がある。
バッチ書き込み(Resource限定)
複数件をまとめて書き込むbatch_writerは、リソース層にしかない。
with table.batch_writer() as writer:
for movie in movies:
writer.put_item(Item=movie)
batch_writerはコンテキストマネージャー(with文で使う仕組み)になっていて、内部でバッファリング・バッチ送信・未処理項目の再試行まで面倒を見てくれる。1回のネットワークリクエストで送れる書き込み・削除操作は最大25件、という上限も明記されていた。この便利な仕組みは、ドキュメントを見る限りクライアント層に相当機能の言及がなかった。
ページネーション(Paginatorはクライアント限定)
大量の結果を少しずつ取得する「ページネーション」も、ClientとResourceで書き味が分かれる。
リソース層は、LastEvaluatedKey(「ここまで読んだ」という位置情報)を自分でチェックしながらループを回す、手動のページネーションになる。
query_params = {'KeyConditionExpression': Key('pk').eq('123') & Key('sk').gt(1000), 'Limit': 100}
while True:
response = table.query(**query_params)
for item in response['Items']:
print(item)
if 'LastEvaluatedKey' not in response:
break
query_params['ExclusiveStartKey'] = response['LastEvaluatedKey']
一方クライアント層にはPaginatorというオブジェクトがあり、ループの面倒を巻き取ってくれる。これはクライアントでしか使えない機能だ。
paginator = dynamodb.get_paginator('query')
page_iterator = paginator.paginate(**query_params)
for page in page_iterator:
for item in page['Items']:
print(item)
なおPaginatorにはMaxItemsのような独自の設定項目もあるが、DynamoDBについては無視すべきだという記載があった。便利な仕組みほど、対象サービスによって「使えるオプションと使えないオプション」があるらしい。
スレッドセーフティにも非対称性がある
書き方の違いだけでなく、「安全に共有できるかどうか」にも非対称性があった。
- Resourceオブジェクトはスレッドセーフではない → スレッド間で共有しない
- Sessionオブジェクトもスレッドセーフではない
- Clientオブジェクトは一部の高度な機能を除き、概ねスレッドセーフ
マルチスレッドで使う場合は、スレッドごとに新しいSessionを作り、そこからClientやResourceを作るのが推奨されていた。
session = boto3.Session()
dynamodb = session.resource('dynamodb')
「ResourceはClientより書きやすいけど、スレッドをまたいで使い回すのは危ない」というのは覚えておきたいポイントだった。
Configまわりの数字
通信の挙動はConfigオブジェクトでカスタマイズできる。
from botocore.config import Config
my_config = Config(connect_timeout=1.0, read_timeout=1.0)
dynamodb = boto3.resource('dynamodb', config=my_config)
タイムアウトのデフォルトは、接続・読み取りともに60秒。
再試行モードは3種類あり、DynamoDBにおける最大試行回数がモードによってかなり違う。
- legacy(デフォルト): 最初の再試行は50ms待機、指数バックオフの係数は2。DynamoDBの場合、最大合計10回試行
- standard: 他のSDKとの整合性を重視したモード。最初は0〜1000msのランダム待機で上限20秒。DynamoDBの場合、最大合計3回試行
- adaptive: standardの機能に加えて、クライアント側で自動的にスロットリングする(実験的機能)
デフォルトのlegacyとstandardで最大試行回数が10回と3回、3倍以上違うというのは意外だった。
もう一つ、最大プール接続数のデフォルトは10。同じSessionから作ったクライアント・リソースを10を超えるスレッドで使う場合は、この数を増やすことを検討するとよい、との記載があった。
エラー処理
DynamoDBの操作はbotocore.exceptions.ClientErrorを捕まえて処理するのが基本形。
try:
response = dynamodb.put_item(...)
except botocore.exceptions.ClientError as err:
print('Error Code: {}'.format(err.response['Error']['Code']))
print('Error Message: {}'.format(err.response['Error']['Message']))
print('Http Code: {}'.format(err.response['ResponseMetadata']['HTTPStatusCode']))
print('Request ID: {}'.format(err.response['ResponseMetadata']['RequestId']))
if err.response['Error']['Code'] in ('ProvisionedThroughputExceededException', 'ThrottlingException'):
print("Received a throttle")
elif err.response['Error']['Code'] == 'InternalServerError':
print("Received a server error")
else:
raise err
エラーコード・メッセージ・HTTPステータス・リクエストIDまでresponse辞書から取り出せるので、原因の切り分けがしやすい形になっている。
条件付き書き込みが失敗したケース専用の例外もある。
try:
response = table.put_item(
Item=item,
ConditionExpression='attribute_not_exists(pk)',
ReturnValuesOnConditionCheckFailure='ALL_OLD'
)
except table.meta.client.exceptions.ConditionalCheckFailedException as e:
print('Item already exists:', e.response['Item'])
「この項目がすでに存在していたら書き込まない」というよくあるパターンを、専用の例外クラスとして扱えるのは親切だと感じた。
ログとイベントフック
ロギングはPython標準のloggingモジュールで設定する。
import logging
logging.basicConfig(level=logging.INFO)
boto3 / botocore / botocore.endpoint / urllib3.connectionpoolのように、階層ごとに個別のログレベルを設定することもできる。
さらに面白かったのが、イベントフック(特定のタイミングに割り込んで処理を挟む仕組み)を使って、リクエストのパラメータをログに残す例だ。
def log_put_params(params, **kwargs):
if 'TableName' in params and 'Item' in params:
logging.info(f"PutItem on table {params['TableName']}: {params['Item']}")
session = boto3.Session()
session.events.register('provide-client-params.dynamodb.PutItem', log_put_params)
PutItemが呼ばれる直前に、テーブル名と書き込み内容をログに出すフックを登録している。デバッグ時に「何が実際に送られたか」を追いたいときに使えそうだ。
ウェイター
テーブル作成のように非同期で完了する処理を待つための「ウェイター」という仕組みもある。
response = client.create_table(...)
waiter = client.get_waiter('table_exists')
waiter.wait(TableName='YourTableName')
バックグラウンドで20秒ごと・最大25回チェックし続けて、テーブルが使える状態になるのを待ってくれる。ポーリング処理を自分で書かなくていいのはありがたい。
読んでいて気づいた「書かれていないこと」
正直に書いておくと、このページには載っていない情報が2つあった。
create_table(テーブル作成)の具体的なコード例は、このページ自体には存在しない。ウェイターの例でclient.create_table(...)と省略形で触れられているだけだった- Boto3の具体的なインストール手順(
pip installなど)もこのページには書かれていない
DynamoDBの操作方法を解説するページなので当然と言えば当然だが、「テーブルをどう作るか」を探してこのページにたどり着いた場合は迷うかもしれない、と思ったので記録しておく。
今日の学び
- ClientとResourceは「どちらが優れているか」ではなく、「機能ごとに使える側が違う」という設計になっている。
batch_writerはResource限定、PaginatorはClient限定 - 両方で書ける機能(PutItem、Query)でも、「型を明示するか(Client)、素のPython値で書けるか(Resource)」が一番わかりやすい違いになる
- スレッドセーフティにも非対称性があり、ResourceとSessionは共有NG、Clientは一部を除きOK
- 再試行のデフォルトモードは"legacy"で、DynamoDBでの最大試行回数はlegacyが10回・standardが3回。デフォルトのまま使うと、他のAWSサービスの感覚より再試行が多く行われる可能性がある
一言で言うと、「ClientとResourceのどちらを使うか」は好みの問題ではなく、やりたいことによって選択肢が絞られる、というのが今日の一番の収穫だった。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。