DynamoDB上のデータをファイルにエクスポートする際のJSONシリアライズについて考えてみる(スクリプト付き)

2020.05.14

こんにちは、CX事業本部の若槻です。

Pythonによる実装で、辞書(dict型オブジェクト)データをファイルに書き込んだり、AWS LambdaからLambdaを呼び出す際のペイロードに指定したりする場合は、その辞書データは事前にjson.dump()json.dumps()などのJSONエンコーダによりJSONシリアライズされている必要があります。

今回は、DynamoDBから取得したデータをローカルファイルに書き込み(エクスポート)する際のJSONシリアライズの方法について考えてみた上で、一連の処理を行うPythonスクリプトを作ってみました。

DynamoDBから取得したデータはどんな型を取りうる?

まず、そもそもDynamoDBから取得したデータはどんな型を取りうるのかを確認してみます。

AWSドキュメント[命名ルールおよびデータ型 - Amazon DynamoDB]によると、DynamoDBでは以下の型のデータが格納可能とのことです。

  • スカラー型
    • 数値(Number)
    • 文字列(String)
    • バイナリ(Binary)
    • ブール(Boolean)
    • null(Null)
  • ドキュメント型
    • リスト(List)
    • マップ(Map)
  • セット型
    • 文字セット(StringSet)
    • 数値セット(NumberSet)
    • バイナリセット(BinarySet)

実際にテーブルを作成(sample-table)し、上記10種の型の値を含むデータを以下のように登録します。 image.png

下記Pythonスクリプトを使用してテーブル上のデータを取得し、各キーの値と型を確認してみます。

scan.py

import json
import boto3

dynamodb = boto3.resource('dynamodb')
table_name = 'sample-table'
table = dynamodb.Table(table_name)

resp = table.scan()
items = resp['Items']

for item in items:
    for kv in item.items():
        _, v = kv
        print(kv, type(v))

取得結果は以下のようになりました。

$ python scan.py
('k01', Decimal('1234567890')) <class 'decimal.Decimal'>
('k02', 'テスト') <class 'str'>
('k03', Binary(b'Binary Data')) <class 'boto3.dynamodb.types.Binary'>
('k04', True) <class 'bool'>
('k05', None) <class 'NoneType'>
('k06', ['val1', Decimal('9876543210')]) <class 'list'>
('k07', {'key': 'hoge'}) <class 'dict'>
('k08', {'bar', 'foo'}) <class 'set'>
('k09', {Decimal('222'), Decimal('111')}) <class 'set'>
('k10', {Binary(b'Binary Set 2'), Binary(b'Binary Set 1')}) <class 'set'>

DynamoDB登録時の型と、取得したデータの型の対応をまとめると以下のようになります。

DynamoDB登録時の型 キー名 取得したデータの型
数値(Number) k01 decimal.Decimal
文字列(String) k02 str
バイナリ(Binary) k03 boto3.dynamodb.types.Binary(bytes)
ブール(Boolean) k04 bool
null(Null) k05 NoneType
リスト(List) k06 list
マップ(Map) k07 dict
文字セット(StringSet) k08 set{str}
数値セット(NumberSet) k09 set{decimal.Decimal}
バイナリセット(BinarySet) k10 set(boto3.dynamodb.types.Binary(bytes))

よって、DynamoDBから取得したデータは以下の型を取りうることが分かりました

  • decimal.Decimal
  • str
  • boto3.dynamodb.types.Binary
  • bytes
  • bool
  • NoneType
  • list
  • dict
  • set

DynamoDBから取得したデータのうち既定でJSONシリアライズができないデータ型はどれ?

次に、DynamoDBから取得したデータのうち既定でJSONシリアライズができないデータ型はどれであるか確認してみます。

以下のPythonドキュメントによると、json.dump()json.dumps()で使われるJSONエンコーダーでデフォルトでJSONシリアライズ可能なデータ型は下記とのことです。

class json.JSONEncoder(*, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, indent=None, separators=None, default=None)

Extensible JSON encoder for Python data structures.

Supports the following objects and types by default:

Python JSON
dict object
list, tuple array
str string
int, float, int- & float-derived Enums number
True true
False false
None null

よって、DynamoDBから取得したデータが取りうる型のうち、既定でJSONシリアライズできない型は以下となることが分かりました。

  • decimal.Decimal
  • boto3.dynamodb.types.Binary
  • bytes
  • set

json.dumpなどによりJSONシリアライズする際はこれらの型のデータをシリアライズ可能な型に変換する必要があります。

デフォルトでJSONシリアライズできない型のデータをシリアライズする

次に、デフォルトでJSONシリアライズできない型のデータをシリアライズする方法について考えてみます。

辞書データをJSONシリアライズしてファイルに書き込むことができるメソッドであるjson.dump()は、defaultオプションにて型変換を実施するメソッドを指定することにより、デフォルトでJSONシリアライズできない型のデータをシリアライズすることができるようになります。

json.dump(obj, fp, *, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, indent=None, separators=None, default=None, sort_keys=False, **kw)

Serialize obj as a JSON formatted stream to fp (a .write()-supporting file-like object) using this conversion table.

If specified, default should be a function that gets called for objects that can’t otherwise be serialized. It should return a JSON encodable version of the object or raise a TypeError. If not specified, TypeError is raised.

そこで、DynamoDBから取得したデータのうち、デフォルトでJSONシリアライズできない型も含めてシリアライズ可能とする以下のようなdefault()メソッドを作ってみました。

import json
from decimal import Decimal
from boto3.dynamodb.types import Binary

def default(obj) -> object:
    if isinstance(obj, Decimal):
        if int(obj) == obj:
            return int(obj)
        else:
            return float(obj)
    elif isinstance(obj, Binary):
        return obj.value
    elif isinstance(obj, bytes):
        return obj.decode()
    elif isinstance(obj, set):
        return list(obj)
    try:
        return str(obj)
    except Exception:
        return None

json.dump(<対象データ>, <書き込み先のファイル>, default=default)

上記のdefault()メソッドでは、各データ型の変換は下記のように行っています。

変換前の型 変換を行う箇所(ブロック) 変換後の型
decimal.Decimal if isinstance(obj, Decimal): intまたはfloat
boto3.dynamodb.types.Binary elif isinstance(obj, Binary): str
bytes elif isinstance(obj, bytes): str
set elif isinstance(obj, set): list

使用する際の注意点としては、

  • もし上記の4つ以外の型のオブジェクトが含まれていた場合は、tryブロックでstr型かNoneに変換するようにしています。
  • bytes型のオブジェクトは.decode()によりデフォルトのエンコードタイプutf-8でデコードするようにしています。その他エンコードタイプを使用している場合は、.decode(encoding='<encode type>)'のように明示的に指定する必要があります。

などです。

一連の処理をスクリプトにしてみた

ここまでの内容を踏まえて、DynamoDB上のデータをファイルにエクスポートする際のスクリプトを作ってみました。

script.py

import json
import sys
from typing import List
from decimal import Decimal

import boto3
from boto3.resources.base import ServiceResource
from boto3.dynamodb.types import Binary


def main(table_name: str, dynamodb_resource: ServiceResource) -> None:
    table_items = scan_table(table_name, dynamodb_resource)
    put_data(table_items)


def scan_table(table_name: str, dynamodb_resource: ServiceResource) -> List[dict]:
    table = dynamodb_resource.Table(table_name)
    resp = table.scan()
    table_items = resp['Items']
    while 'LastEvaluatedKey' in resp:
        resp = table.scan(
            ExclusiveStartKey=resp['LastEvaluatedKey']
        )
        table_items.extend(resp['Items'])
    return table_items


def default(obj) -> object:
    if isinstance(obj, Decimal):
        if int(obj) == obj:
            return int(obj)
        else:
            return float(obj)
    elif isinstance(obj, Binary):
        return obj.value
    elif isinstance(obj, bytes):
        return obj.decode(encoding='default')
    elif isinstance(obj, set):
        return list(obj)
    try:
        return str(obj)
    except Exception:
        return None


def put_data(table_items: List[dict]) -> None:
    with open('./export.json', 'w') as f:
        json.dump(table_items, f, default=default, ensure_ascii=False, sort_keys=True, indent=4)


if __name__ == '__main__':
    table_name = 'sample-table'
    dynamodb_resource = boto3.resource('dynamodb')

    main(table_name, dynamodb_resource)

スクリプトを実行すると、指定したDynamoDBテーブルからローカルのexport.jsonファイルにデータがエクスポートされます。(下記は今回作成したsample-tableテーブルの場合)

export.json

[
    {
        "k01": 1234567890,
        "k02": "テスト",
        "k03": "Binary Data",
        "k04": true,
        "k05": null,
        "k06": [
            "val1",
            9876543210
        ],
        "k07": {
            "key": "hoge"
        },
        "k08": [
            "foo",
            "bar"
        ],
        "k09": [
            222,
            111
        ],
        "k10": [
            "Binary Set 1",
            "Binary Set 2"
        ]
    }
]

補足

defaultオプションは再帰的に適用される

json.dumpでのdefaultオプションで指定したメソッドは、既定でJSONシリアライズできないオブジェクトに対して再帰的に適用されます。試しにdefault()メソッドでオブジェクトを何も変換せずに返すようにしてみると、

def default(obj) -> object:
    return obj

以下のように循環エラーValueError: Circular reference detectedとなります。

$ python script.py
Traceback (most recent call last):
  File "script.py", line 41, in <module>
    main(table_name, dynamodb_resource)
  File "script.py", line 13, in main
    put_data(table_items)
  File "fetcher.py", line 34, in put_data
    json.dump(table_items, f, default=default, ensure_ascii=False, sort_keys=True, indent=4)
  File "/usr/lib64/python3.6/json/__init__.py", line 179, in dump
    for chunk in iterable:
  File "/usr/lib64/python3.6/json/encoder.py", line 428, in _iterencode
    yield from _iterencode_list(o, _current_indent_level)
  File "/usr/lib64/python3.6/json/encoder.py", line 325, in _iterencode_list
    yield from chunks
  File "/usr/lib64/python3.6/json/encoder.py", line 404, in _iterencode_dict
    yield from chunks
  File "/usr/lib64/python3.6/json/encoder.py", line 438, in _iterencode
    yield from _iterencode(o, _current_indent_level)
  File "/usr/lib64/python3.6/json/encoder.py", line 435, in _iterencode
    raise ValueError("Circular reference detected")
ValueError: Circular reference detected

おわりに

データを扱う時に型を意識することはとても大切ですね。

今回は飽くまで"DynamoDBから取得したデータをファイルにエクスポートする場合"に必要な変換処理についてでした。しかしデータの出どころや使用方法が異なる場合でも今回と同様の考え方で変換処理の検討はできると思うのでご参考ください。

以上