🐍

Boto3(AWS SDK for Python)をコードリーディングして仕組みを理解する

commits38 min read

はじめに

Boto3を利用する機会があったので、どのように動いているか気になったのでコードを読んでみた。その中で自分のための情報の整理や、実装が面白いな・なるほどなと感じた部分をまとめる。
Boto3を使ってAWS上での開発や、AWSのREST APIの理解などの役に立てば嬉しい。

筆者が解釈した内容なので間違っている場合や、最新バージョンで実装が変わっている場合があるという点はご了承ください。

Boto3とは

Boto3とは AWS SDK for Python のことで、内部的にはAWS CLIでも利用されているBotocoreを利用している。

The SDK is composed of two key Python packages: Botocore (the library providing the low-level functionality shared between the Python SDK and the AWS CLI) and Boto3 (the package implementing the Python SDK itself).

そのため今回はBoto3とBotocoreのコードを調査対象とする。

調査対象

今回利用するバージョンは調査を開始した2021/04/25時点での最新版のこちら。この後出てくるコードの行数の指定などは全てこのバージョンのものが適用される。

Package Version
boto3 1.17.57
botocore 1.20.57

Boto3の2つのAPI、クライアントAPIとリソースAPIについて

Boto3を利用してAWSの操作をする場合、リソースAPIとクライアント(「低レベル」)APIが存在するため2通りの使い方がある。
それぞれでS3のListBucketsを呼び出して結果を表示するコードを比較すると以下のような特徴がある。

client API
s3_client = boto3.client('s3')
response = s3_client.list_buckets()
for b in response['Buckets']:
  print('bucket name:', b['Name'], ', created at:', b['CreationDate'])
resource API
s3_resource = boto3.resource('s3')
buckets = s3_resource.buckets
for b in buckets.all():
  print('bucket name:', b.name, ', created at:', b.creation_date)
- クライアントAPI リソースAPI
特徴 boto3.client('<AWS_SERVICE_NAME>')を利用。全てのAWSのREST APIと1:1で対応し、API名とスネークケースにしたPythonのメソッド名に対応している(詳細) boto3.resource('<AWS_SERVICE_NAME>')を利用。AWSリソースをオブジェクト指向で表現している(詳細)
戻り値の型 dict型 対象のAWSリソースを抽象化したオブジェクト型

今回はそれぞれのAPIを利用する時に生成するオブジェクトを以下のように呼ぶこととして進める。

  • boto3.client('<AWS_SERVICE_NAME>')で取得できる値をClientオブジェクト
  • boto3.resource('<AWS_SERVICE_NAME>')で取得できる値をResourceオブジェクト

ちなみにドキュメントを読むとresourceclientを内包していると書かれており、以下のようにResourceオブジェクトからClientオブジェクトを取り出せる。

It is also possible to access the low-level client from an existing resource:

# Create the resource
sqs_resource = boto3.resource('sqs')

# Get the client from the resource
sqs = sqs_resource.meta.client

なのでまずはクライアントAPIの中身を読んでみて、その後にリソースAPI読むことでクライアントAPIとどう関係しているのか、どんな実装の違いがあるのかを調べていく╭(・ㅂ・)و

この記事を読んでわかること

  • クライアントAPIとリソースAPIの実装の違い
  • boto3の役割とbotocoreの役割の違い
  • jsonファイルから動的にPythonのクラスやメソッドを生成している方法
  • botocoreでAWSのREST APIに対応するメソッド以外に拡張されているメソッドの定義場所
  • boto3が拡張しているメソッドの定義場所
  • SDK内でAWSのREST APIのリトライ設定
  • クレデンシャル情報を取得し、リクエスト署名を作成してAuthorizationヘッダに設定している実装
  • AWSのREST APIのレスポンスをPythonのオブジェクトに変換する実装

など

クライアントAPIのコードリーディング

今回はS3で利用できるAWSのREST APIのListBucketsを実行する場合で、大まかな流れと要点をまとめた。

boto3.client('s3')によるClientオブジェクトの生成

クライアントAPIを実行するため、対象サービス(今回はS3)のClientオブジェクトを生成する部分。この内部で行われている処理の中で重要な役割を果たすオブジェクトを紹介しながら解説する。

boto3.session.Sessionオブジェクト

Clientオブジェクトを生成するために必要なclientメソッドを持っているため、まず最初に生成されるオブジェクト。

boto3.__init__.py#L87-L93の一部を抜粋してコメントを追加
def client(*args, **kwargs):
    # Clientオブジェクトを生成する前に_get_default_session()でSessionオブジェクトを生成している
    return _get_default_session().client(*args, **kwargs)
  • clientメソッドは、内包しているbotocore.session.Sessionオブジェクトのcreate_clientメソッドを呼び出すだけであり、create_clientが最終的にClientオブジェクト生成の処理をしている
  • boto3側のSessionでやっていることは少ないが_register_default_handlersでboto3の拡張メソッドの定義情報を読み込んでいて、Client(やResource)オブジェクトの生成時に拡張メソッドを追加されるようにしている
  • メソッドを追加している部分はClientオブジェクトの説明パートに記載

botocore.session.Sessionオブジェクト

Client(やResource)オブジェクトを生成するメソッドを持ったオブジェクト。boto3を利用する場合は自分で直接生成はしないで良い。

boto3.session.py#L28-L55の一部を抜粋してコメントを追加
# **boto3**のSessionのクラス定義
class Session(object):
    def __init__(self, aws_access_key_id=None, aws_secret_access_key=None,
                 aws_session_token=None, region_name=None,
                 botocore_session=None, profile_name=None):
        if botocore_session is not None:
            self._session = botocore_session
        else:
            # boto3の通常利用だとここでbotocoreのSessionが生成され、boto3.Sessionオブジェクトに保持される
            self._session = botocore.session.get_session()

botocore.config.Configオブジェクト

リージョン名などのAWSリソースに対する設定や、リトライやプロキシなどSDKに対する設定が可能なオブジェクト。詳細は公式ドキュメントを

Configの使用例
import boto3
from botocore.config import Config

my_config = Config(
    region_name = 'us-west-2',
    signature_version = 'v4',
    retries = {
        'max_attempts': 5,
        'mode': 'standard'
    }
)

client = boto3.client('kinesis', config=my_config)
  • Configオブジェクトはbotocoreパッケージなので、boto3のみ利用している場合は明示的にimportを増やす必要がある
  • リージョンなどはConfigオブジェクトに設定があればその値が最優先される
  • Boto3はAWSのRES APIのリトライの挙動はデフォルトでは'legacy'モード(v1)が利用され、他の言語のAWS SDKのリトライロジック (v2)と異なる
    • 他のAWS SDKと同じv2にしたい場合はConfigのコンストラクタで'mode': 'standard'を設定する必要がある
    • 実験的ではあるが、'standard'の機能に追加してクライアント側でレートリミットもできる'adaptive'モードもある
    • v1とv2の違いの詳細はドキュメントを見て欲しいが、デフォルトのリトライ回数(v1:5回 <=> V2:3回)やリトライ対象のHTTPステータスコードや例外の種類が異なる

'legacy'(v1)'standard'(v2)の違いの記載について公式ドキュメントを一部引用

Legacy mode is the default mode used by any Boto3 client you create. As its name implies, legacy mode uses an older (v1) retry handler that has limited functionality.

Standard mode is a retry mode that was introduced with the updated retry handler (v2). This mode is a standardization of retry logic and behavior that is consistent with other AWS SDKs.

botocore.credentials.Credentialsオブジェクト

環境変数や~/.aws/credentials、IAMロールなどから得られたクレデンシャル情報を保持しているオブジェクト。

botocore.credentials.py#L1967-L1977の一部を抜粋してコメントを追加
def load_credentials(self):
    ### 省略 ###
    # 複数のProviderが優先順位を持ちリストになっている
    for provider in self.providers:
        # 提供されているProviderからCredentialsが取得できるかを上から順に確認
        creds = provider.load()
        if creds is not None:
            # 一番最初に取得できたCredentialsを利用する
            return creds

このXXXProviderのリストの順序はこちら↓↓↓

#index botocore.credentials.XXXProvider 取得場所
0 EnvProvider 環境変数
1 AssumeRoleProvider 指定したprofileが~/.aws/の中でrole_arnが設定されていればそのロール
2 AssumeRoleWithWebIdentityProvider
3 SSOProvider
4 SharedCredentialProvider 指定した~/.aws/のprofile情報
5 ProcessProvider
6 ConfigProvider
7 OriginalEC2Provider
8 BotoProvider
9 ContainerProvider
10 InstanceMetadataProvider

SDKドキュメントの順序は以下の通りで、1., 2.はオブジェクト生成時に明示的に指定した場合に優先されるので3.以降が↑のリストの順番通りになる。
それぞれのProviderの処理を全部見れてはいないのでもう少し調べたい🔎

  1. Passing credentials as parameters in the boto.client() method
  2. Passing credentials as parameters when creating a Session object
  3. Environment variables
  4. Shared credential file (~/.aws/credentials)
  5. AWS config file (~/.aws/config)
  6. Assume Role provider
  7. Boto2 config file (/etc/boto.cfg and ~/.boto)
  8. Instance metadata service on an Amazon EC2 instance that has an IAM role configured.

botocore.model.ServiceModelオブジェクト

クライアントAPI作成対象サービス(今回の例であればS3)のAWSのREST APIの一覧情報を保持するオブジェクトで、後述するがClientオブジェクトのメソッド定義はこの情報から作られる。

botocore.client.py#L119-L122の一部を抜粋してコメントを追加
def _load_service_model(self, service_name, api_version=None):
    # 対象サービスのAPIが定義されたjsonファイルをdictに変換
    json_model = self._loader.load_service_model(service_name, 'service-2', api_version=api_version)
    # サービス名やAPI定義のdictなどを保持した`ServiceModel`クラスのオブジェクトにする
    service_model = ServiceModel(json_model, service_name=service_name)
  • 一覧情報はClientCreator#_load_service_modelで対象サービスのAWSのREST APIが定義されたjsonファイルを読み込み、dictに変換することで実現している
    • 今回ローカルのMacで動かしていた場合のS3のクライアントAPIの場合、botocore/data/s3/2006-03-01/service-2.jsonに定義されているAWSのREST APIの情報が読み込まれている

読み込んでdictにしているjsonファイルの大まかな構成要素はこちら。(要素名のリンクや値の例はS3のservice-2.json

要素名 役割 値の例
metadata サービスのAPIエンドポイント、プロトコルなどの子要素を持つ "metadata":{ "globalEndpoint":"s3.amazonaws.com", "protocol":"rest-xml", ...}
operations AWSのREST API名一覧と、それぞれのHTTPメソッド・パス・リクエスト/レスポンスボディの構成定義を示すキー(shape)などの子要素を持つ "operations":{ "AbortMultipartUpload":{ "name":"AbortMultipartUpload", "http":{ "method":"DELETE", "requestUri":"/{Bucket}/{Key+}", "responseCode":204 }, "input":{"shape":"AbortMultipartUploadRequest"}, "output":{"shape":"AbortMultipartUploadOutput"} }, "CompleteMultipartUpload":{...}, ...}
shape リクエスト/レスポンスボディの構成要素の定義(XXXRequest, XXXOutput)と、その要素(member)の型定義が羅列されていて、例えばXXXOutput->members[]->${MEMBER_NAME}->shape->${MEMBER_DEFINITION}のような階層になっている "shapes":{ "AbortMultipartUploadRequest":{ "type":"structure", "required":[ "Bucket", "Key", "UploadId" ], "members":{ "Bucket":{ "shape":"BucketName", "documentation":"<p>The bucket nam....</p>", "location":"uri", "locationName":"Bucket" }, "Key":{ "shape":"ObjectKey", ...}, ...}, ...}, "AbortRuleId":{"type":"string"}, ...}

Clientオブジェクト

前述したがあらためて、boto3.client('s3')で生成される、AWSのREST APIを呼び出せるメソッドなどを持っているオブジェクト。

引数 説明
str(class_name) 動的に定義されるクラス名、S3の場合は'S3'が入る
tuple(bases) 動的に定義されるクラスの親クラスで、ここではbotocore.client.BaseClientが設定される
class_attributes 動的に定義されるクラスのメソッド名やインスタンス変数名をkey, 実体をvalueとしたdictで、ServiceModelの情報を元にスネークケースのメソッド名をAWSのREST API呼び出しができるメソッドに対応させた形式になっている

より細かい話をすると、class_attributesは、_create_methodsでAWSのREST APIに対応するメソッド群、hooks.EventAliaser#emitはbotocore, boto3で拡張するメソッド群がkey, valueで設定される。

_create_methods

対象サービスのREST API名をスネークケースにした文字列をkey_create_api_method内で動的に作成する_api_callメソッドオブジェクトをvalueにすることで、ClientオブジェクトでAWSのREST APIに対応するメソッド呼び出しするとAPIが実行できるようにしている。

hooks.EventAliaser#emit

大きく分けると拡張メソッドの追加は2パターン

  • botocoreのSessionオブジェクトで定義した拡張メソッドを追加する処理の呼び出し
  • boto3のSessionオブジェクトで定義されたパスを指定してimport_moduleすることで拡張メソッドを追加

メタプログラミングのテクニックをうまく使っていて、なるほどなと感じた👏


ここで今までの情報を整理し、Clientオブジェクトが生成されているbotocore.client.pyの流れを説明すると↓↓↓

botocore.client.py#L80-L88の一部を抜粋してコメントを追加
# ServiceModelオブジェクトを生成(今回はS3のAPI一覧などの情報を持っている)
service_model = self._load_service_model(service_name, api_version)
# この中でtype()を利用して動的にクラス定義(clsにClientクラスのメタ情報が入る)
cls = self._create_client_class(service_name, service_model)
### 3行省略 ###
# コンストラクタに渡すためのCredentialやConfigなどをまとめる
client_args = self._get_client_args(
    service_model, region_name, is_secure, endpoint_url,
    verify, credentials, scoped_config, client_config, endpoint_bridge)
# 動的クラスからオブジェクト生成 == 今回であればS3のClientオブジェクト
service_client = cls(**client_args)

ここまでで、Clientオブジェクトの生成処理が完了!!

s3_client.list_buckets()でHTTPリクエストを送るまで

先ほど生成したClientオブジェクトのメソッドを実行すると、どのように対応するAWSのREST API呼び出しにバインドしているかについての解説。

botocore.client.py#L349-L357の一部を抜粋してコメントを追加
# operation_name='ListBuckets', py_operation_name='list_buckets'のような値が入る
# _create_api_method自体はclass_attributesを作るときにすでに呼ばれた部分
def _create_api_method(self, py_operation_name, operation_name,
                        service_model):
    # ★呼び出されるのはここで、kwargsはクライアントAPI呼び出し時の引数のdict
    def _api_call(self, *args, **kwargs):
        if args:
            raise TypeError(
                "%s() only accepts keyword arguments." % py_operation_name)
        # 処理の実装はさらにこの中
        return self._make_api_call(operation_name, kwargs)
  • list_bucketすると、動的なクラス定義で利用したclass_attributesのdictの情報を利用することでoperation_name='ListBuckets'の状態で_api_callが呼ばれる
  • kwargsは引数をdictにしたものが入る
    • list_buckets()の場合は引数が空なので、空のdict
    • 引数があるAPI、例えばS3のGetObjectであれば、呼び出し方はs3_client.get_object(Bucket='examplebucket', Key='HappyFace.jpg')であるためkwargs{'Bucket': 'sugikei-sandbox', 'Key': 'HappyFace.jpg'}が入ってくる
  • _make_api_callでは変数のoperation_nameに設定されているAPI名で、API定義のjsonであるoperationsからHTTPリクエストに必要なパスやリクエストメソッドなどの情報(例えばListBucketsであればこの部分)を取り出してリクエストに必要なオブジェクトを生成し、_make_requestでAWSのREST APIを呼ぶ

AWSのREST API呼び出しの実行ライブラリ・認証設定・レスポンスのパース

SDKがAWSの低レベルAPIと呼ばれるHTTPリクエストを、どのように肩代わりしてくれているのかを説明していく。

リクエスト送信処理と利用しているライブラリなどについて

  • リクエスト処理まわりを担うオブジェクトはbotocore.endpoint.Endpointで、Clientオブジェクト生成の時のcreate_endpointで設定される
    • ここで指定されているbotocore.httpsession.URLLib3Sessionが実際にHTTP通信をするオブジェクトや、エンドポイントのURL、レスポンスをパースするオブジェクトなどを保持している
    • コネクションプールの数やタイムアウトの設定もこの時点で設定される
  • _make_api_callから呼び出す_make_requestEndpointオブジェクトを利用して、リクエストヘッダやボディの生成・HTTPリクエストの送信・HTTPレスポンスボディのXMLをパースしてdict形式にする処理を行なっている

urllib3を利用する直前の処理がここ↓↓↓

botocore.httpsession.py##L306-L331
def send(self, request):
    try:
        # proxyやコネクションの設定
        proxy_url = self._proxy_config.proxy_url_for(request.url)
        manager = self._get_connection_manager(request.url, proxy_url)
        conn = manager.connection_from_url(request.url)
        self._setup_ssl_cert(conn, request.url, self._verify)

        request_target = self._get_request_target(request.url, proxy_url)
	# ここからurllib3でHTTPリクエスト
        urllib_response = conn.urlopen(method=request.method, url=request_target, body=request.body, headers=request.headers, retries=Retry(False), assert_same_host=False, preload_content=False, decode_content=False, chunked=self._chunked(request.headers))

Authorizationヘッダの生成

まずは署名バージョンを確認し、署名情報を計算するためのXXXAuthオブジェクトを取得する。

botocore.signer.py#L92-L162の一部を抜粋してコメントを追加
def sign(self, operation_name, request, region_name=None,
            signing_type='standard', expires_in=None, signing_name=None):
    # 署名バージョンの取得 ex) 'v4', 's3v4', ...
    signature_version = self._choose_signer(
        operation_name, signing_type, request.context)

    if signature_version != botocore.UNSIGNED:
        kwargs = {
            'signing_name': signing_name,
            'region_name': region_name,
            'signature_version': signature_version
        }
        try:
            # SigV4Auth, S3SigV4AuthなどのAuthオブジェクト取得
            auth = self.get_auth_instance(**kwargs)
        except UnknownSignatureVersionError as e:
            ### 省略 ###

        # 署名情報を計算して'Authorization'ヘッダへ設定
        auth.add_auth(request)

AuthオブジェクトとCredentialオブジェクトを利用して署名情報を計算し、Authorizationヘッダに設定する。

botocore.auth.py#L371-L387の一部を抜粋してコメントを追加
def add_auth(self, request):
    datetime_now = datetime.datetime.utcnow()
    request.context['timestamp'] = datetime_now.strftime(SIGV4_TIMESTAMP)
    # STSのトークンがあればここで'X-Amz-Security-Token'ヘッダ設定
    self._modify_request_before_signing(request)
    # リクエスト情報を文字列化
    canonical_request = self.canonical_request(request)
    # タイムスタンプとリクエスト情報のハッシュ値計算
    string_to_sign = self.string_to_sign(request, canonical_request)
    # ここまでの情報を合わせて署名情報の計算
    signature = self.signature(string_to_sign, request)
    # 'Authorization'ヘッダを設定
    self._inject_signature_to_request(request, signature)

レスポンスボディのXMLのパース

ここでAWSのREST APIの結果を取得するが、レスポンスボディはXMLである。SDKとしてPythonで利用しやすいようにdict形式にパース処理をしている部分を解説する。

botocore.endpoint.py#L185-L230の一部を抜粋してコメントを追加
def _do_get_response(self, request, operation_model):
### 省略 ###
    http_response = self._send(request)
    # header, bodyの親要素をdictにしただけで、まだここではレスポンスボディはXMLのまま
    response_dict = convert_to_response_dict(http_response, operation_model)
    # protocol='rest-xml'
    protocol = operation_model.metadata['protocol']
    # XML用のParserオブジェクト取得
    parser = self._response_parser_factory.create_parser(protocol)
    # ここでレスポンスボディのXMLをdictに変換
    parsed_response = parser.parse(
        response_dict, operation_model.output_shape)
  • 今回XMLのパースなのでParserはbotocore.parser.BaseXMLResponseParserが利用される
  • XML以外でもパースできるようになっており、プロトコルによりどのParserが利用されるかはbotocore.parser.pyのpydocでわかりやすいAAが書かれている
  • 変数のoutput_shapeはjsonで定義されているshapeのことで、対象のAWSのREST APIのレスポンスの要素が定義情報が入っている
  • さらに追っていくと_handle_structureの中でXMLをdictにし、shapeに入っている要素と照らし合わせて最終的なレスポンスのdictに必要な要素だけが入るようにしている

クライアントAPIまとめ

クライアントAPIはboto3を使っているがほぼbotocore内で完結していた。(厳密にはboto3.Sessionbotocore.Sessionuser-agentなどの設定を少しだけいじってるようではある)

なので極論、AWSのREST API(S3であればここに定義されているAPI)を利用するだけならboto3を使わずにbotocoreだけでも↓のようなコードを書けば可能。しかしマルチパートアップロードを自動で実施してくれるupload_fileのような複数のAWSのREST APIを組み合わせて処理してくれる便利メソッドはboto3に用意されている。

botocore_client_sandbox.py
import botocore.session

# boto3#client('s3')した時にClientオブジェクト取得に必要な部分だけを抜粋
s3_botocore_client = botocore.session.get_session().create_client('s3')
botocore_response = s3_botocore_client.list_buckets()
for b in botocore_response['Buckets']:
  print('bucket name:', b['Name'], ', created at:', b['CreationDate'])

クライアントAPIの動きはだいたい把握できたので、リソースAPIは何をしているのかを見ていく─=≡Σ((( つ•̀ω•́)つ

リソースAPIのコードリーディング

こちらもS3で利用できるAWSのREST APIのListBucketsを実行する場合で、大まかな流れと要点をまとめた。

boto3.resource('s3')によるResourceオブジェクトの生成

リソースAPIを実行するために(今回はS3)のResourceオブジェクトを生成する部分。ポイントとなるオブジェクトを紹介しながら解説する。

boto3.sessin.Sessionオブジェクト

ResourceオブジェクトはSessionresourceメソッドにより生成する。Clientオブジェクト生成もSessionを利用していたが、今回はboto3側でリソースAPI特有の処理を行なっている。

boto3.__init__.py#L96-L102の一部を抜粋してコメントを追加
def resource(*args, **kwargs):
# Resourceオブジェクトを生成する前に_get_default_session()でSessionオブジェクトを生成している
    return _get_default_session().resource(*args, **kwargs)

resources-1.jsonファイル

リソースAPIを定義するためのjsonファイルで、botocore.loader.Loader#load_service_modelでjsonファイルを探して読み込みしている

boto3.session.py#L339-L353の一部を抜粋してコメントを追加
try:
    # ここで'resources-1'を指定
    resource_model = self._loader.load_service_model(
        service_name, 'resources-1', api_version)
except UnknownServiceError:
    # サービスが存在しない場合はエラー
    raise ResourceNotExistsError(...
except DataNotFoundError:
    # 利用可能なAPIバージョンがない場合はエラー
    raise UnknownAPIVersionError(...
  • クライアントAPIと異なり、resources-1.jsonを指定して読み込んでいる
  • 指定したサービスのresources-1.jsonがロードできない場合、リソースAPIが使用できないと判定されてエラーになる
  • このjsonファイルが動的なクラス定義で利用される

読み込んでdictにしているjsonファイルの大まかな構成要素はこちら。(要素名のリンクや値の例はS3のresources-1.json)

要素名 役割 値の例
service Resourceオブジェクトが利用できるメソッドやプロパティの親要素 -
action Resourceオブジェクトが利用できるメソッド CreateBucketを定義したjson
has このjsonのresources要素と合わせて利用される(詳しい役割は読み解けなかった) Bucketを定義したjson
hasMany Resourceオブジェクトが利用できるプロパティ Bucketsを定義したjson
resources Resourceオブジェクトで生成できるオブジェクト Bucket, BucketPolicy, Object, ObjectVersionなどを定義したjson

Clientオブジェクト

クライアントAPIの時に利用したClientオブジェクトを生成して、リソースAPI内部で利用できるようにしている。生成方法はすでに説明したので省略。

boto3.resources.factory.ResourceFactoryオブジェクト

load_from_definitionで動的にResourceオブジェクトのクラス定義をする役割を持つ。

boto3.resources.factory.py#L42-L139の一部を抜粋してコメントを追加
def load_from_definition(self, resource_name,
                            single_resource_json_definition, service_context):
    # 'action'をクラス定義に組み込むための処理
    self._load_actions(attrs=attrs, ...
    # 'hasMany'をクラス定義に組み込むための処理
    self._load_collections(attrs=attrs, ...
    # 'resources'をクラス定義に組み込むための処理
    self._load_has_relations(attrs=attrs, ...
    # 親クラス
    base_classes = [ServiceResource]
    # 動的にクラスを定義
    return type(str(cls_name), tuple(base_classes), attrs)
  • _load_actionsresources-1.jsonaction要素をそれぞれ呼び出せるよう、do_actionメソッドにバインド
    • S3の場合create_bucketメソッドがここで作られる
    • 対応するクライアントAPIのメソッドに紐づけられている
  • _load_collectionsresources-1.jsonhasManyの要素をそれぞれPythonのpropertyとして呼び出せるようにしたget_collectionメソッドをバインド
    • get_collectionboto3.resources.CollectionManagerクラスを親にして動的に定義したクラスのオブジェクトを返すメソッド
    • S3の場合bucketsプロパティがここで作られる
  • _load_has_relationsresources-1.jsonresources要素をそれぞれcreate_resourceメソッドにバインド
    • S3の場合Bucket, BucketPolicy, Object, ObjectVersionなどなど
  • 最後にBuilt-in Functionsのtype()を利用して、対象サービスのResourceオブジェクトのクラスを定義

Resourceオブジェクト

ResourceFactoryによって定義されたクラスから生成される

引数 説明
str(class_name) 動的に定義されるクラス名、S3の場合は's3.ServiceResource'が入る
tuple(bases) 動的に定義されるクラスの親クラスで、ここではboto3.resources.base.ServiceResourceが設定される
class_attributes 動的に定義されるクラスのメソッド名やインスタンス変数名をkey, 実体をvalueとしたdictで、ResourceFactoryで作成した情報を元にリソースAPIのメソッドやプロパティ呼び出しができるようになっている

ここで今までの情報を整理し、Resourceオブジェクトが生成されているboto3.session.Session#resourceの流れを説明すると↓↓↓

boto3.session.py#L265-L409の一部を抜粋してコメントを追加
# jsonからリソースAPIの定義を読み込み
resource_model = self._loader.load_service_model(service_name, 'resources-1', api_version)
# Clientオブジェクト生成
client = self.client(...
# 動的なクラス定義
cls = self.resource_factory.load_from_definition(...
# Clientオブジェクトを引数にResourceオブジェクト生成
return cls(client=client)

API実行時のバインド

ResourceFactoryオブジェクトの部分で解説したが、action, collection(hasManyから定義されたproperty), resourceそれぞれでバインドされたメソッドが呼ばれるが、少しだけ補足する。

actionの呼び出し(do_action)

ServiceAction#__call__の中でgetattrを利用することで、Clientオブジェクトの対応するクライアントAPIを実行し、dictのレスポンスをリソースオブジェクトに変換する。

collectionの呼び出し(get_collection)

親クラスをCollectionManagerにしたクラスを動的に定義し、そこからXXXCollectionManagerオブジェクトを生成する。(S3のbucketsであればs3.bucketsCollectionManagerというクラス名)
XXXCollectionManagerallを実行した場合はResourceCollection#__iter__が呼ばれる。そのpageの中でgetattrを利用することで、Clientオブジェクトの対応するクライアントAPI(bucketsであればlist_buckets)を実行し、dictのレスポンスをリソースオブジェクトに変換する。

resourceの呼び出し(create_resource)

load_from_definitionを利用してBucketなどのサブリソースのクラスを動的に定義してオブジェクト生成している。これはResourceオブジェクト自体のクラス定義もしているメソッドである。

リソースAPIまとめ

resources-1.jsonからリソースAPIの定義を読み取っていて、オブジェクトの定義やAWSのREST APIの呼び出し方が記載されている。そしてAWSのREST APIを呼んでいるのはクライアントAPIで利用されていたClientオブジェクトである。
なので僅かではあるが、レスポンスの成型時にオブジェクト生成などやっていない分リソースAPIの方が少しだけパフォーマンスが良いのかな?と想像(単純に比較しようとしてもAWSのREST APIの結果取得した後の部分のみを抽出してテストしないといけないので今回は断念)。
ただしパフォーマンスにこだわるならそもそもPython以外のSDK使う方が良いと思うので、Boto3を使う場合は可読性や使いやすさを考慮してリソースAPI使えるサービスなら使う方針が良いと思う。

感想

  • クラスなどのオブジェクト生成はjsonファイルなどからメタプログラミングを利用している
    • botocoreがCLIでも使われているので、できるだけサービスに対する処理実行時に読み込むファイル量を減らせるようにしてるのかも(想像)
    • なのでIDEで補完が効かないのが個人的にはちょっとつらい…
  • クライアントAPIにはWaitersという、特定のリソースがある状態(S3のバケットやオブジェクトが存在するorしないなど)になるまでポーリンングして状態を待ち続ける機能があることを知った
    • 並列処理での待ち合わせとかに使うのかな?🤔
  • ずっとコードを読んでると('s3')が顔文字に見えてくる
  • コードリーディングして理解した内容を文章で伝えるのめちゃむずい

おわりに

過去にはQiitaにも記事を書いてましたので、ぜひこちらも見てください。

https://qiita.com/sugikeitter
GitHubで編集を提案

Discussion

ログインするとコメントできます