ほぼPythonだけでサーバーレスアプリをつくろう をやってみた(4章)

IT

アプリとデータベースを連携する部分、初心者がハマるのはきっとこのあたりからだと思います。

mosoメモ
mosoメモ

データベースへの接続はお作法的な部分が多いです。
「なぜこう書くのか」と疑問が多くでますが、それは追々。
と割り切って進めるのが良いと思います。

ほぼPythonだけでサーバーレスアプリをつくろう

Amazon

第4章 DynamoDBでデータの永続化をしよう

データを保存(永続化)するためのデータベースを実装するフェーズです。

第4章の内容

今回作るアプリで使うデータベースは、Amazon DynamoDBです。まずはその説明があり、その後、ローカル環境でDBを設計・テストAWSへ実装するまでがこの章の内容です。

  • DynamoDBとは
  • テーブルを設計する
  • DynamoDBをシュミレートする
  • テーブルを作成する
  • 初期データを投入する
  • DynamoDBに接続する
  • データを登録する
  • データを更新する
  • データを削除する
  • AWS環境にデプロイする

第4章で実際に行った内容詳細

DynamoDBの理解

なぜ、Amazon RDSではなく DynamoDBを使うのか簡単に説明されています。

【DynamoDBを使う理由】
Lambda(*1)と相性が良い。
Lamdaはリクエストごとに別々のコンテナで処理が実行されるため、もし同時に10のリクエストがあれば10個のコンテナが立ち上がり、それぞれのコンテナからRDBに対してコネクションが張られる。よってAmazon RDSでは限界がある。
DynamoDBは分散型のキーバリューデータストアで、アクセスが増えてもスケーリングすることで対応が可能とのこと。
(*1)サーバーレスでプログラムを実行できる環境を提供するAWSのサービス

「ふむふむ、なるほど。」と思ったがすぐ忘れるので、忘れたら読み返そう。

テーブルを設計する

今回作るアプリはToDoアプリです。
そのため、タイトルとか重要度、完了・未完了などのフィールドを作ります。

フィールド概要備考
idStringIDPartition Key
titleStringタイトル
memoStringメモ
priorityInteger重要度
completedBoolean完了

ここは特筆することなし。単純なデータベース設計ですね。

DynamoDBをシュミレートする

ローカル環境で疑似的なDynamoDBを動かすための DynamoDB Localをダウンロードして動かせるようにします。

展開場所は分かりやすい所が良いと思います。
私は今回作るアプリをまとめているフォルダ「hobopy」の下に「DynamoDBLocal」というフォルダをつくってその中に展開しました。

そしてLocalでDynamoDBを動作させます。
以下のJavaコマンドで動作します。

> cd C:\Users\Moso\hobopy\DynamoDBLocal

> java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb -port 8001

Initializing DynamoDB Local with the following configuration:
Port:   8001
InMemory:       false
DbPath: null
SharedDb:       true
shouldDelayTransientStatuses:   false
CorsParams:     *

ローカルのポート8001でDynamoDBが動いたようです。
これだけでは、テーブルもデータもない状態なので作っていきましょう。

テーブルを作成する

テーブル定義用のJSONファイルを作成して awsコマンドでテーブルを作成します。

まずは、テーブル定義用ファイル(schema.json)を作ります。
Todosという名前のテーブルですね。

{
    "TableName":    "Todos",
    "KeySchema":    [
        {
            "KeyType":  "HASH",
            "AttributeName":    "id"
        }
    ],
    "AttributeDefinitions": [
        {
            "AttributeName":    "id",
            "AttributeType":    "S"
        }
    ],
    "ProvisionedThroughput":    {
        "WriteCapacityUnits":   1,
        "ReadCapacityUnits":    1
    }
}

次に aws dynamodb crate-table コマンドを実行します。

> aws dynamodb create-table --cli-input-json file://schema.json --endpoint-url http://localhost:8001

{
    "TableDescription": {
        "AttributeDefinitions": [
            {
                "AttributeName": "id",
                "AttributeType": "S"
            }
        ],
        "TableName": "Todos",
        "KeySchema": [
            {
                "AttributeName": "id",
                "KeyType": "HASH"
            }
        ],
        "TableStatus": "ACTIVE",
        "CreationDateTime": 1589038766.739,
        "ProvisionedThroughput": {
            "LastIncreaseDateTime": 0.0,
            "LastDecreaseDateTime": 0.0,
            "NumberOfDecreasesToday": 0,
            "ReadCapacityUnits": 1,
            "WriteCapacityUnits": 1
        },
        "TableSizeBytes": 0,
        "ItemCount": 0,
        "TableArn": "arn:aws:dynamodb:ddblocal:000000000000:table/Todos"
    }
}

Todosというテーブルが作成されました。
・「–cli-input-json」でテーブル定義ファイルを指定しています。
・「–endpoint-url」で先ほど Localで起動したデータベースを指定しています。

初期データを投入する

テーブルに投入する初期データを jsonファイルで作ります。
そろそろ json形式にも慣れてきたかな?

ToDoっぽいデータを入れておきます。

{
    "Todos":    [
        {
            "PutRequest":   {
                "Item": {
                    "id":   {
                        "S":    "L5"
                    },
                    "title":    {
                        "S":    "プロフィールページ作成"
                    },
                    "memo": {
                        "S":    "分かりやすく端的に、且つ、魅力的に"
                    },
                    "priority": {
                        "N":    "3"
                    },
                    "completed":    {
                        "BOOL": false
                    }
                }
            }
        },
        {
            "PutRequest":   {
                "Item": {
                    "id":   {
                        "S":    "L6"
                    },
                    "title":    {
                        "S":    "サイドメニューのカスタマイズ"
                    },
                    "memo": {
                        "S":    "プロフィール、検索メニューが必要"
                    },
                    "priority": {
                        "N":    "2"
                    },
                    "completed":    {
                        "BOOL": false
                    }
                }
            }
        },
        {
            "PutRequest":   {
                "Item": {
                    "id":   {
                        "S":    "L7"
                    },
                    "title":    {
                        "S":    "リスト(Li)カスタマイズ"
                    },
                    "memo": {
                        "S":    "できるかな?"
                    },
                    "priority": {
                        "N":    "3"
                    },
                    "completed":    {
                        "BOOL": false
                    }
                }
            }
        }
    ]
}

次に aws dynamodb batch-write-itemコマンドでテーブルにデータを投入です。

★つまづきポイントです。
 以下のコードをまず実行していないと、Windowsのローカル環境では日本語のデータをテーブルに投入できずエラーが出ました。

> set PYTHONUTF8=1

参考ページ
 Unable to load file:// with non-English char in UTF-8 encoding on Windows

その後、本書にかかれているとおりコードを実行します。

> aws dynamodb batch-write-item --request-items file://initial-data.json --endpoint-url http://localhost:8001

{
    "UnprocessedItems": {}
}

これでデータが投入されているはずです。

データ投入が正しくできているか aws dynamodb scanコマンドで確認します。

> aws dynamodb scan --table-name Todos --endpoint-url http://localhost:8001

{
    "Items": [
        {
            "memo": {
                "S": "プロフィール、検索メニューが必要"
            },
            "id": {
                "S": "L6"
            },
            "completed": {
                "BOOL": false
            },
            "title": {
                "S": "サイドメニューのカスタマイズ"
            },
            "priority": {
                "N": "2"
            }
        },
        {
            "memo": {
                "S": "できるかな?"
            },
            "id": {
                "S": "L7"
            },
            "completed": {
                "BOOL": false
            },
            "title": {
                "S": "リスト(Li)カスタマイズ"
            },
            "priority": {
                "N": "3"
            }
        },
        {
            "memo": {
                "S": "分かりやすく端的に、且つ、魅力的に"
            },
            "id": {
                "S": "L5"
            },
            "completed": {
                "BOOL": false
            },
            "title": {
                "S": "プロフィールページ作成"
            },
            "priority": {
                "N": "3"
            }
        }
    ],
    "Count": 3,
    "ScannedCount": 3,
    "ConsumedCapacity": null
}

これで、Localのデータベースに作成したテーブルに初期データが投入できました。

DynamoDBに接続する

第3章で作ったアプリからDynamoDBに接続できるようにしましょう。

まずは、アプリ設定ファイル(config.json)にDynamoDB関連の設定を追加します。

Local環境 “dev” は、ローカルのDB(http://127.0.0.1:8001)を見にいくという内容を記載しています。

{
  "version": "2.0",
  "app_name": "hobopy-backend",
  "api_gateway_stage": "api",
  "stages": {
    "dev": {
      "environment_variables":  {
        "DB_ENDPOINT":  "http://127.0.0.1:8001",
        "DB_TABLE_NAME":  "Todos"
      }
    },
    "prod": {
      "autogen_policy": false,
      "environment_variables":  {
        "DB_TABLE_NAME":  "Todos"
      }
    }
  }
}

次に、データベースに接続するためのモジュール(database.py)を修正します。

Boto3によるDynamoDBの操作をコードに組み込んでいるようです。詳細解説はなかったので必要に応じてBoto3ユーザーガイドを見る必要があります。

import os
import boto3
from boto3.dynamodb.conditions import Key

# ①DynamoDBへの接続を取得する
def _get_database():
	endpoint = os.environ.get('DB_ENDPOINT')
	if endpoint:
		return boto3.resource('dynamodb', endpoint_url=endpoint)
	else:
		return boto3.resource('dynamodb')

# ②すべてのレコードを取得する
def get_all_todos():
	table = _get_database().Table(os.environ['DB_TABLE_NAME'])
	response = table.scan()
	return response['Items']

# ③指定されたIDのレコードを取得する
def get_todo(todo_id):
	table = _get_database().Table(os.environ['DB_TABLE_NAME'])
	response = table.query(
		KeyConditionExpression=Key('id').eq(todo_id)
	)
	items = response['Items']
	return items[0] if items else None

アプリの設定・モジュールを修正したら、ローカルでアプリを起動しましょう。
※第3章の内容になるため、第4章では詳しく記載されていませんが、以下を行います。
・バックエンド環境を有効にする。
・chalice localコマンドでアプリを起動する。

この内容は第2章の復習ですが、まだ完全に覚えられていません…

C:\Users\Moso\hobopy> .\.venv\hobopy-backend\Scripts\activate.bat

(hobopy-backend) C:\Users\Moso\hobopy> cd hobopy-backend
(hobopy-backend) C:\Users\Moso\hobopy\hobopy-backend> chalice local --stage dev

Serving on http://127.0.0.1:8000
Restarting local dev server.

httpコマンドでアプリ経由でDBに接続してデータが取得できるか確認します。

全件取得、1件取得ともに正しく結果が返ってきました。

> http http://127.0.0.1:8000/todos

HTTP/1.1 200 OK
Content-Length: 651
Content-Type: application/json
Date: Sat, 09 May 2020 17:09:42 GMT
Server: BaseHTTP/0.6 Python/3.7.4

[
    {
        "completed": false,
        "id": "L6",
        "memo": "プロフィール、検索メニューが必要",
        "priority": 2.0,
        "title": "サイドメニューのカスタマイズ"
    },
    {
        "completed": false,
        "id": "L7",
        "memo": "できるかな?",
        "priority": 3.0,
        "title": "リスト(Li)カスタマイズ"
    },
    {
        "completed": false,
        "id": "L5",
        "memo": "分かりやすく端的に、且つ、魅力的に",
        "priority": 3.0,
        "title": "プロフィールページ作成"
    }
]
> http http://127.0.0.1:8000/todos/L5

HTTP/1.1 200 OK
Content-Length: 233
Content-Type: application/json
Date: Sat, 09 May 2020 17:14:20 GMT
Server: BaseHTTP/0.6 Python/3.7.4

{
    "completed": false,
    "id": "L5",
    "memo": "分かりやすく端的に、且つ、魅力的に",
    "priority": 3.0,
    "title": "プロフィールページ作成"
}

データを登録する

データを登録するために、データベース接続モジュール(database.py)とアプリ本体モジュール(app.py)を修正します。

引数を元に登録内容の辞書を作り、DynamoDBにデータを登録する処理を追加しています。

import uuid

≪略≫

# ④Todoを登録する
def create_todo(todo):
	# 登録内容を作成する
	item = {
		'id':	uuid.uuid4().hex,
		'title':	todo['title'],
		'memo':	todo['memo'],
		'priority':	todo['priority'],
		'completed':	False,
	}

	# DynamoDBにデータを登録する
	table = _get_database().Table(os.environ['DB_TABLE_NAME'])
	table.put_item(Item=item)
	return item

/todos へのPOSTリクエスト時に呼び出されるcreate_todo()を追加しています。必須入力チェックも組み込んでいます。

# BadRequestErrorを追加
from chalice import Chalice, NotFoundError, BadRequestError

≪略≫

# ③指定されたデータを登録する
@app.route('/todos', methods=['POST'])
def create_todo():
    # リクエストメッセージボディを取得する
    todo = app.current_request.json_body

    # 必須項目をチェックする
    for key in ['title', 'memo', 'priority']:
        if key not in todo:
            raise BadRequestError(f"{key} is required.")
    
    # データを登録する
    return database.create_todo(todo)

データの登録ができるか、httpコマンドで確認します。
今回はPOSTでJSONを送信しますので、以下コマンドになります。

> http POST http://127.0.0.1:8000/todos title=定期バックアップ設定 memo=設定したが動いているか確認する priority:=4

HTTP/1.1 200 OK
Content-Length: 243
Content-Type: application/json
Date: Sat, 09 May 2020 17:35:57 GMT
Server: BaseHTTP/0.6 Python/3.7.4

{
    "completed": false,
    "id": "76a38090d12a418d857f575849415297",
    "memo": "設定したが動いているか確認する",
    "priority": 4,
    "title": "定期バックアップ設定"
}

また、必須入力項目が抜けたJSONをPOSTした場合にエラーが返ってくるか確認します。

必須入力項目を抜いた場合、ちゃんとエラーが返ってきました。

> http POST http://127.0.0.1:8000/todos title=あいうえおっさん memo=かきくけこども

HTTP/1.1 400 Bad Request
Content-Length: 77
Content-Type: application/json
Date: Sat, 09 May 2020 17:49:51 GMT
Server: BaseHTTP/0.6 Python/3.7.4

{
    "Code": "BadRequestError",
    "Message": "BadRequestError: priority is required."
}

これで、データ登録の処理が実装できました。

データを更新する

データベース接続モジュール(database.py)とアプリ本体モジュール(app.py)を修正します。

≪略≫

# ⑤Todoを更新する
def update_todo(todo_id, changes):
	table = _get_database().Table(os.environ['DB_TABLE_NAME'])
	# 更新内容のクエリを構築する
	update_expression = []
	expression_attribute_values = {}
	for key in ['title', 'memo', 'priority', 'completed']:
		if key in changes:
			update_expression.append(f"{key} = :{key[0:1]}")
			expression_attribute_values[f":{key[0:1]}"] = changes[key]
	
	# DynamoDBのデータを更新する
	result = table.update_item(
		Key = {
			'id':	todo_id,
		},
		UpdateExpression = 'set ' + ','.join(update_expression),
		ExpressionAttributeValues = expression_attribute_values,
		ReturnValues = 'ALL_NEW'
	)
	return result['Attributes']
≪略≫

# ④指定されたデータを更新する
@app.route('/todos/{todo_id}', methods=['PUT'])
def update_todo(todo_id):
    changes = app.current_request.json_body

    # データを更新する
    return database.update_todo(todo_id, changes)

httpコマンド、今回はPUT でデータの更新ができるか確認します。

idが ‘L5’のToDoの priorityを 1 に更新するコマンドを打ってみました。

> http PUT http://127.0.0.1:8000/todos/L5 priority:=1

HTTP/1.1 200 OK
Content-Length: 233
Content-Type: application/json
Date: Sat, 09 May 2020 18:13:58 GMT
Server: BaseHTTP/0.6 Python/3.7.4

{
    "completed": false,
    "id": "L5",
    "memo": "分かりやすく端的に、且つ、魅力的に",
    "priority": 1.0,
    "title": "プロフィールページ作成"
}

データ更新の処理も実装できましたね。

データを削除する

例によって、データベース接続モジュール(database.py)とアプリ本体モジュール(app.py)を修正します。

≪略≫

# ⑥Todoを削除する
def delete_todo(todo_id):
	table = _get_database().Table(os.environ['DB_TABLE_NAME'])

	# DynamoDBのデータを削除する
	result = table.delete_item(
		Key = {
			'id':	todo_id,
		},
		ReturnValues = 'ALL_OLD'
	)
	return result['Attributes']
≪略≫

# ⑤指定されたデータを削除する
@app.route('/todos/{todo_id}', methods=['DELETE'])
def delete_todo(todo_id):
    # データを削除する
    return database.delete_todo(todo_id)

httpコマンド、今回はDELETE でデータの削除ができるか確認します。

> http DELETE http://127.0.0.1:8000/todos/76a38090d12a418d857f575849415297

HTTP/1.1 200 OK
Content-Length: 245
Content-Type: application/json
Date: Sat, 09 May 2020 18:30:55 GMT
Server: BaseHTTP/0.6 Python/3.7.4

{
    "completed": false,
    "id": "76a38090d12a418d857f575849415297",
    "memo": "設定したが動いているか確認する",
    "priority": 4.0,
    "title": "定期バックアップ設定"
}

削除処理も実装できました。

これで、参照・作成・更新・削除とすべて実装できたことになります。

AWS環境にデプロイする

AWS環境にローカルで作ったアプリをデプロイして動作を確認しましょう。

AWSのDynamoDBをセットアップする

まずは、AWS側にDynamoDBをセットアップする必要があります。
ローカルでDynamoDB Localのために作ったJSONファイルがそのまま使えるので、awsコマンドを実行するだけでOKです。

aws dynamodb crate-tableコマンドでテーブルを作成します。

> aws dynamodb create-table --cli-input-json file://schema.json
{
    "TableDescription": {
        "AttributeDefinitions": [
            {
                "AttributeName": "id",
                "AttributeType": "S"
            }
        ],
        "TableName": "Todos",
        "KeySchema": [
            {
                "AttributeName": "id",
                "KeyType": "HASH"
            }
        ],
        "TableStatus": "CREATING",
        "CreationDateTime": 1589049502.449,
        "ProvisionedThroughput": {
            "NumberOfDecreasesToday": 0,
            "ReadCapacityUnits": 1,
            "WriteCapacityUnits": 1
        },
        "TableSizeBytes": 0,
        "ItemCount": 0,
        "TableArn": "arn:aws:dynamodb:ap-northeast-1:xxxxx:table/Todos",
        "TableId": "a3a7a93a-b1ff-4b86-adb4-8ce10a920366"
    }
}

aws dynamodb batch-write-itemコマンドで初期データを投入します。

> aws dynamodb batch-write-item --request-items file://initial-data.json
{
    "UnprocessedItems": {}
}

AWSのDynamoDBにデータがちゃんと入ってますね。

これでAWS側のDynamoDBセットアップは完了です。

requirementes.txt を更新する

アプリが依存している外部パッケージを指定し、AWSにアプリをデプロイするときに合わせて必要なモジュールを組み込むための作業です。

pip freezeコマンドでインストールされているモジュールを確認できるので実行し、返ってきた結果のうち Boto3の行をrequirements.txtに追加します。

IAMロールの定義ファイルを作成する

アプリプログラムからDynamoDBにアクセスするためには、Lambda関数に対してDynamoDBへのアクセス権を持ったIAMロールを割り当てます。
※ChaliceのIAMロール自動作成機能は使えないらしいです。

IAMロール定義ファイルを .chaliceフォルダに作ります。名前は policy-prod.json とします。 ※配置場所、ファイル名は重要です。

Chaliceフレームワークでのログ出力権限、DynamoDBへのアクセス権限を定義しています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:DeleteItem",
                "dynamodb:UpdateItem",
                "dynamodb:GetItem",
                "dynamodb:Scan",
                "dynamodb:Query"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

AWS環境で実行する

chalice deploy コマンドで AWSへデプロイします。

(hobopy-backend) > chalice deploy --stage prod

Creating deployment package.
Updating policy for IAM role: hobopy-backend-prod-api_handler
Updating lambda function: hobopy-backend-prod
Updating rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:ap-northeast-1:xxxxx:function:hobopy-backend-prod
  - Rest API URL: https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/api/

これでAWSにアプリをデプロイできました。

★今回最大のつまづきポイントでした

以下のエラーが出てAWSへデプロイできず数時間さまよいました。
内容は 先ほど作った policy-prod.json の書式ミスと読み取れます。しかし何度チェックしても書式に間違いはない…

(hobopy-backend) > chalice deploy --stage prod

An error occurred (MalformedPolicyDocument) when calling the PutRolePolicy operation: Syntax errors in policy.

結論を言うと、ファイル内の “Version”: “2012-10-17” の部分を修正していたためであった。ここはポリシーを作った日(本日)を入れるのではなく、この値でないといけないお作法的なものであった。下手に素人がカスタマイズしようとしてハマった典型である。

AWSでの動作を確認

Todoの取得、追加、更新、削除を確認して終わりです。
ローカルで行ったのと同じ処理が行えることを確認できました。

以上で第4章を一通り終えることができました。

第4章の所要時間

約6.5時間でした。

盛大にはまりました。
このくらいの時間で完了したと喜ぶべきでしょうか?

第4章のつまづきポイントまとめ

1.DynamoDBへのデータ投入の際に日本語エラーが出る。

 エラー内容「text contents could not be decoded.」

以下のコマンドを事前に打っておけばエラーが出なくなります。

> set PYTHONUTF8=1

参考ページ
 Unable to load file:// with non-English char in UTF-8 encoding on Windows

2.AWSへのアプリをデプロイする時にエラーが出る。

 エラー内容「An error occurred (MalformedPolicyDocument) when calling the PutRolePolicy operation: Syntax errors in policy.」

お手本通りにJSONを書けてなかっただけです。
どうしても解決しない場合は、公開されているこの本用のGitHubの情報をみて、コピー&ペーストしましょう。

GitHub - 7pairs/hobopy: ほぼPythonだけでサーバーレスアプリをつくろう
ほぼPythonだけでサーバーレスアプリをつくろう. Contribute to 7pairs/hobopy development by creating an account on GitHub.

第4章の感想

エラーが出てなかなか進まず、心が折れそうになりました。

プログラム学習で一番挫折するのが「エラーが出て進まないこと」らしいですね。そのため、プログラミング教室などで教師に教わるのが非常に有効と良く広告されているのを思い出しました。

しかし、このくらい自分のチカラで解決できなければ続かないと思うので、トライ&エラーで頑張りたいと思います。

這い上がろう つまづいたことがあるというのが いつか大きな財産になる。

コメント

  1. Jeff De La Mare より:

    Yo dude thanks for helping me with that error:

    “In conclusion, it was because the “Version”: “2012-10-17″ part in the file was modified .”

    Little salty it’s so obvious I’m an amateur :*)

タイトルとURLをコピーしました