Djangoのstartprojectをテンプレ化して繰り返し使おう
前書き
この記事は、JSL(日本システム技研) Advent Calendar 2020 - Qiita 12/9の記事です!
Djangoを使ったwebアプリケーション開発で、実装したことのない機能や初めて使うライブラリはサンプルアプリを作成して実際に動かしてみることが多いと思います。 しかしDjangoのプロジェクト立ち上げはそれなりに面倒です。startprojectコマンドを打ち、ディレクトリの切り方を変えてsettings.pyを書き換えて、gitignoreを書いて...など、繰り返し作業が多いです。
元々はコピペ実行可能なスクリプトを作って上記の作業をやっていたのですが、Djangoにはテンプレートという仕組みがあることを知りました。(HTMLのテンプレートとは無関係です!) プロジェクトの雛形を一度作っておけば、それをテンプレートとしてstartprojectを実行することができます。
githubのurlで直接テンプレートが入ったレポジトリをstartprojectコマンドに渡すこともできます。(ただし残念ながらmacosではgithub直指定は不可能<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] ...> · Issue #83 · arocks/edge · GitHub)
環境
バージョン | |
---|---|
MacOS Catalina | 10.15.6 |
Python3 | 3.8.6 |
Django | 3.0.7 |
参考リンク
公式ドキュメント
django-admin and manage.py | Django documentation | Django
こちらの記事を参考にさせていただきました!
https://www.valentinog.com/blog/django-project/
今回は触れませんが、雛形を自動作成するツールもあります。 Welcome to Cookiecutter Django’s documentation! — Cookiecutter Django 2020.50.3 documentation
テンプレートの作成の基本手順
テンプレートプロジェクトの立ち上げ
まずは通常の手順でプロジェクトを作成します。これをテンプレート用のプロジェクトとして使います。
注: この記事の手順をそのまま進めるなら、「config」という名前にすることをおすすめします。後でコマンドの引数にこの名前で渡しています。
$ mkdir tmp-template-project $ cd tmp-template-project/ $ python3 -m venv env $ source env/bin/activate $ pip install Django==3.0.7 # プロジェクト作成 $ django-admin startproject config .
プロジェクトの構成変更
適当にプロジェクトを変更しましょう。自分はsettingsディレクトリを作って、productionとdevelop用の設定ファイルを分けてみました。
$ mkdir config/settings $ mv config/settings.py config/settings/develop.py $ cp config/settings/develop.py config/settings/production.py
あとはファイルの中身も変更しておきましょうか。設定ファイルのタイムゾーンを編集しておきました。
TIME_ZONE = 'Asia/Tokyo'
cmsアプリも作っておきます。URLなどの設定をしておいてもいいと思います。
$ python manage.py startapp cms
あとはrequirements.txtによく使うライブラリを書いておくのも良いと思います。gitignoreも置いておくと便利かもしれないです。何度も同じようなものを使うなら、入れておきましょう。
プロジェクト名をプレースホルダーに変換
概要
Djangoのプロジェクトテンプレート機能の使用手順で「は?」と思うのはここだけです。
- startprojectコマンドで命名したプロジェクト名(
config
)を、プレースホルダー{{ project_name}}
に置き換えます。 - これはプロジェクト配下全てのファイルについて行う必要があります。
例えば、./config/wsgi.py
には以下のようなコードがあります。
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
以下のように変更します。
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ project_name }}.settings')
このようにする理由は、公式ドキュメントのstartprojectの項目をみると納得できます。->django-admin and manage.py | Django documentation | Django
project_name – the project name as passed to the command
「project_name」という変数で渡ってきた値を置換していくことができるため、プレースホルダーでこの辺数名を指定することができるというわけです。 詳しくは、後半の「その他のtemplate context を使ってみる」をよければ参照してください。
一撃で変換するスクリプト
文字列config
を全ファイルから探して手動で書き換える...なんてことはありえないので、以下のように置換します。
なお、macos環境を想定しています。sedコマンドをLinux環境で実行する場合には、iオプションに空文字を渡さないでください(iはin-place置換です)。
まず、config
という文字列を含むファイル一覧をプロジェクト直下からみておきます。
(ここで下の一覧に示すファイル以外が出てくるようなら、どこかで名前が被っているということなので気をつけて結果を確認してください。)
Djangoが新しいのでasgi.pyがありますね。
$ grep -rl 'config' --exclude-dir=env . ./config/asgi.py ./config/settings.py ./config/urls.py ./config/wsgi.py ./manage.py
以下のように置換します。(プロジェクトのディレクトリ==manage.pyがあるところ で実行してください)
$ grep -rl 'config' --exclude-dir=env . | xargs sed -i '' -e 's/config/{{ project_name }}/g'
ここでvenvは消しておきます。
# venvは消しておく必要があります。 $ rm -r env
テンプレートをもとに新規プロジェクト作成
方法
まずは新規ディレクトリを作り、仮想環境を作ってDjangoを入れます。 この手順はコピペで一気にできるので手間にはならないというのがポイントです!
# 先程さくせいしたテンプレートプロジェクトの一回層上に移動しました。 $ cd .. $ ls -d tmp-template-project tmp-template-project/ # 新しいプロジェクトのディレクトリを作り、移動します。 $ mkdir new-project-from-template $ cd new-project-from-template/ # 仮想環境を作ります $ python3 -m venv env $ source env/bin/activate $ pip install Django==3.0.7
startprojectをするときに、--template
引数でテンプレートとなるプロジェクトを指定します。
このとき渡すプロジェクト名(下の例ではnew_project_name
)が、プレースホルダー{{ project_name}}
を置換します。
$ django-admin startproject --template ../tmp-template-project config . # 先程のプロジェクトができている! $ ls cms/ config/ env/ manage.py*
config以外の名前でプロジェクトを立ち上げるときにはディレクトリ名に注意
新しいプロジェクトをconfig以外で作る場合はディレクトリ名だけ修正するように気をつけます。
他にも、このプロジェクトの設定を含むディレクトリ(config)の名前を使って記述する部分は漏れなく{{ project_name}}
というテンプレート文字列で書くように気をつけます。
(grep & sedで一気に置換できるので、あまり意識せずテンプレートプロジェクトを作ってOKだと思います)
$ django-admin startproject --template ../tmp-template-project special_product .
# ディレクトリ名を修正する。
$ mv config special_product
その他のtemplate context を使ってみる
project_name意外にもテンプレートに渡されるコンテキストはいくつかあります。
django-admin and manage.py | Django documentation | Django
実際にみてみましょう。
以下のようなテキストをtmp-template-project/context_test.txt
においてみます。
project_name : {{ project_name }} project_directory: {{ project_directory }} secret_key: {{ secret_key }} docsversion: {{ docs_version }} django_version: {{ django_version }} bbbb
新しいプロジェクトを作成するとき、以下のようにオプションを指定します。
$ django-admin startproject\ --template ../tmp-template-project \ --extension py,txt --name context_test.txt \ config .
結果、新しく作られたプロジェクトのcontext_test.txt
は以下のようになります。
各種プレースホルダーが置換されているのがわかります。
project_name : config project_directory: /Users/kyutatsu/Documents/web_development/new-project-from-template secret_key: b!ui=x21xes*9$q^m_6=cvef(#28$0k&wjouy+5w8f+(ab6!3q docsversion: 3.0 django_version: 3.0.7 bbbb
Pythonのelasticsearchライブラリで遊んでElasticsearchと仲良くなる
前書き
最近、業務でElasticsearchに初めて触れる機会をいただきました。Kibanaを使ってクエリを投げることにある程度慣れた後、PythonのコードからElasticsearchのデータ取得&更新などを実施することになりました。
そこでまずライブラリに慣れようと思い、色々とクエリを試してみました。
後述の公式ドキュメントにも書いてありますが、elasticsearch(Pythonライブラリ)はElasticsearchにそのままリクエストを送るだけの低レイヤのラッパーなので、先にKibanaなどを使ってElasticsearchの素の操作になれていた方がわかりやすいと思いました(感想)。
「kibanaでHTTPリクエストのbodyに入れてるjsonは、body引数でdictとして渡す」
「indexの指定は引数で行う」
「kibanaで_search
のように、アンダースコアで指定したものはメソッド名になる(es.search
など)」
と覚えておくと簡単に読み替えられると思います。
参考リンク
Python Elasticsearch Client — Elasticsearch 7.9.1 documentation
Elasticsearchで不要なフィールドの削除 - Qiita
環境
バージョン | |
---|---|
Elasticsearch | 7.1.0 |
MacOS Catalina | 10.15.6 |
Python3 | 3.8.6 |
elasticsearch(Pythonライブラリ) | 7.10.0 |
elasticsearchは、localstack を使っています。docker-composeの該当部分の設定は下のような形です。(今回は使いませんが他のコンテナも使用しているためこのような環境です。)
本題のelasticsearch(Pythonライブラリ)の操作とは直接関係ないのでご容赦を:pray:
version: "2" services: aws.local: container_name: awslocal mem_limit: 5g ports: - 4566-4597:4566-4597 - 8080:8080 image: localstack/localstack:0.10.9 environment: - SERVICES=s3,es,elasticsearch - DEFAULT_REGION=us-east-1 # dummy configure - AWS_DEFAULT_REGION=us-east-1 - AWS_REGION=us-east-1 - AWS_DEFAULT_OUTPUT=json - AWS_ACCESS_KEY_ID=xxx - AWS_SECRET_ACCESS_KEY=xxx
それではみていきましょう。
各種操作
接続とコネクションの確認
>>> from elasticsearch import Elasticsearch >>> es = Elasticsearch(hosts=[{'host': 'localhost', 'port': 4571}]) >>> es.info() # 疎通確認 # コネクションを閉じる。 >>> es.close()
バージョンなどの情報がprintされれば成功です。 これ以降、esと出てきたら上記のようにコネクションを作成したものとなります。
接続情報などを扱うクラスには、以下のようにドット繋ぎでアクセスできます。
>>> es.transport.connection_pool <DummyConnectionPool: (<Urllib3HttpConnection: http://localhost:4571>,)> >>> es.transport <elasticsearch.transport.Transport at 0x7f89aae75340>
ドキュメントの↓部分を参照すると、これらのクラスから取得できる情報などが確認できます。 esオブジェクトを作成する時に、これらを指定することができるので必要に応じて参照&確認してください。
Connection Layer API — Elasticsearch 7.9.1 documentation
Connection Layer API — Elasticsearch 7.9.1 documentation
データの投入 と 取得
indexを作成しつつ、データを投入します。
# 表示用にpprintをインポートしてます。 import pprint >>> doc = { 'first_name': '霊夢', 'last_name': '博麗', 'user_name': 'reimu', 'spell': { 'prefix': '霊符', 'title': '夢想封印' } } >>> response = es.index(index='gensou-index', id=1, body=doc) >>> pprint.pprint(response, indent=2, width=80) { '_id': '1', '_index': 'gensou-index', '_primary_term': 1, '_seq_no': 0, '_shards': {'failed': 0, 'successful': 1, 'total': 2}, '_type': '_doc', '_version': 1, 'result': 'created'}
データの取得(GETで、ID指定)は以下のように行います。 indexメソッドの時の挙動は Create or Update です。
>>> res = es.get(index="gensou-index", id=1) >>> pprint.pprint(res, indent=2, width=80) { '_id': '1', '_index': 'gensou-index', '_primary_term': 1, '_seq_no': 0, '_source': { 'first_name': '霊夢', 'last_name': '博麗', 'spell': {'prefix': '霊符', 'title': '夢想封印'}, 'user_name': 'reimu'}, '_type': '_doc', '_version': 1, 'found': True}
通常のElasticsearchを使った時と同じように、_source
という項目にdocument(一つのデータ)の情報が得られます。
次に、IDを指定しない場合のデータ投入をやってみます。
>>> doc = { 'first_name': 'レミリア', 'last_name': 'スカーレット', 'user_name': 'remilia', 'spell': { 'prefix': '紅符', 'title': 'スカーレットマイスタ' } } >>> res = es.index(index='gensou-index', body=doc) # idを指定しない場合、デフォルト値"None"となる。 >>> pprint.pprint(res, indent=2, width=80) { '_id': 'vKouLXYBabhEaLLCx13L', '_index': 'gensou-index', '_primary_term': 1, '_seq_no': 1, '_shards': {'failed': 0, 'successful': 1, 'total': 2}, '_type': '_doc', '_version': 1, 'result': 'created'}
返り値をprintするとわかりますが、_id
はvKouLXYBabhEaLLCx13L
となります。
もちろん、そのIDでgetもできます。
>>> es.get(index='gensou-index', id='vKouLXYBabhEaLLCx13L') # 結果は省略した。
index操作
indexの作成や情報の閲覧をやってみます。
es.indices.get
でindex全体の情報が取れます。フィールドのtype、shardやreplicasの数がわかります。
>>> es.indices.get('gensou-index') {'gensou-index': {'aliases': {}, 'mappings': {'properties': {'first_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}, 'last_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}, 'spell': {'properties': {'prefix': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}, 'title': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}}}, 'user_name': {'type': 'text', 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}}}}, 'settings': {'index': {'creation_date': '1607075069521', 'number_of_shards': '1', 'number_of_replicas': '1', 'uuid': 'QUz4C2jxQvWEhtlfBLKhwQ', 'version': {'created': '7010099'}, 'provided_name': 'gensou-index'}}}}
mapping情報だけ欲しければ、get_mapping
を使います。
>>> es.indices.get_mapping(index='gensou-index')
es.indices.create
で新しいindexを作成できます。
Mappingなどをこの際指定することもできます。
API Documentation — Elasticsearch 7.9.1 documentation
>>> es.indices.create(index='new-index') {'acknowledged': True, 'shards_acknowledged': True, 'index': 'new-index'}
テーブル一覧の取得にはes.indices.stats()
を使う..と思います。(GET /_cat/indicest
に相当する処理がわからず...)
ただ、フィールドがとても多くてみづらいのでjmespath
などを用いてjsonを整形する必要があるかもしれません。
以下のようにすると、一応index一覧を取得できます。
>>> es.indices.stats()['indices'].keys() dict_keys(['new-index', '.kibana_1', 'gensou-index'])
サーチ(複数件取得)
検索条件に合ったデータの取得をやってみます。(結果は大量なので適宜省略)
まず、引数を何も渡さない状態で検索すると、全てのindexを跨いで全データを取得します。
>>> es.search()
indexを指定すると、そのindexの全データを取得します。
>>> es.search(index='gensou-index') # match_allのクエリでも、同じ結果です。 res = es.search(index="test-index", body={"query": {"match_all": {}}})
最初にも書きましたが、kibanaでの_search
に相当する操作がes.search
メソッドでは実行でき、HTTPリクエストのbodyに指定していたjsonは、Pythonではbody引数にdictとして渡すことでクエリを投げることができます。
first_name
が霊夢
となるデータは以下のように取得します。
>>> es.search(index='gensou-index', body={'query': {'match': {'first_name': '霊夢'}}})
もう一つよく使うパターンです。
「特定のカラムが存在するデータのみ出力」するには、以下のように指定します。
以下は、age
という項目を持ったdocumentのみを抽出します。
>>> es.search(index='gensou-index', body={"query": {"exists": {"field": "age"}}}) #...............(省略)............. '_source': {'first_name': 'フランドール', 'last_name': 'スカーレット', 'user_name': 'flan', 'spell': {'prefix': '禁忌', 'title': 'かごめかごめ'}, 'age': 495}}]}}
カウント(条件にヒットするデータ件数の表示)
elasticsearchで_search
とほぼ同じようにクエリを指定できる_count
は、検索条件にマッチするデータの件数を表示するのに便利です。
先に述べた、exists
のクエリと組み合わせてよく使います。
>>> es.count(index='gensou-index') {'count': 2, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}}
結果の項目の絞り込み:
document(一つのデータ)の項目が多い場合、クエリの時点で欲しいフィールド以外を取らないようにすることができます。
また、別の方法としてjmespath
を使って取得後に必要なfiledのみを抽出する方法があります。おすすめなのですが今回は紹介しません
>>> es.search(index='gensou-index', filter_path=['hits.hits._source.user_name', 'hits.hits._id']) {'hits': {'hits': [{'_id': '1', '_source': {'user_name': 'reimu'}}, {'_id': 'vKouLXYBabhEaLLCx13L', '_source': {'user_name': 'remilia'}}, {'_id': 'flan-chan', '_source': {'user_name': 'flan'}}]}}
自分が入れたデータはJSONのキーをドット繋ぎでアクセスしたものになります。つまりhits.hits._source
配下になります。その他のデータも自在にfilter可能です。アスタリスクをワイルドカードとして使うこともできます。
めっちゃべんりです。
>>> es.search(index='gensou-index', filter_path=['hits.hits._source.sp*', 'hits.hits._id']) {'hits': {'hits': [{'_id': '1', '_source': {'spell': {'prefix': '霊符', 'title': '夢想封印'}}}, {'_id': 'vKouLXYBabhEaLLCx13L', '_source': {'spell': {'prefix': '紅符', 'title': 'スカーレットマイスタ'}}}, {'_id': 'flan-chan', '_source': {'spell': {'prefix': '禁忌', 'title': 'かごめかごめ'}}}]}}
データの更新(一部アップデート)
パーシャルアップデートをやってみます。データの一部だけ更新したり付け加えたりします。
まず現在のid=1
のデータを確認スルノデス。
>>> es.get(index='gensou-index', id=1) {'_index': 'gensou-index', '_type': '_doc', '_id': '1', '_version': 1, '_seq_no': 0, '_primary_term': 1, 'found': True, '_source': {'first_name': '霊夢', 'last_name': '博麗', 'user_name': 'reimu', 'spell': {'prefix': '霊符', 'title': '夢想封印'}}}
age: 13
という情報を加えましょう。
メソッド名からもわかるように、kibanaでいうところの_update
操作になります。なので、bodyの指定方法に注意です。(doc
というキー配下にデータをおく!)
>>> es.update(index='gensou-index', id=1, body={'doc': {'age': 13}})
データを取得するとageが入っています。
>>> es.get(index='gensou-index', id=1) # ..........(省略)................. 'age': 13}}
もう一つ、スクリプトを使った更新もやってみます。
elasticsearchはpainless
という、名前に反して割と面倒な(ドキュメントが少ない)スクリプト言語を使って、クエリ内部で簡単な処理を行うことができます。
今回は年齢(age)を+1してみます。
ポイントは、ctx._source
からデータ本体にアクセスできるということです。
>>> es.update(index='gensou-index', id=1, body={'script': 'ctx._source.age += 1;'})
結果を見ると、13-> 14になっているので成功です!。 painlessは複数行書くこともできるので、割と便利です。 (また、プラグインを入れるとpythonでも記述できるそうですが自分はやってません...)
>>> es.get(index='gensou-index', id=1) # .................(省略)............... 'age': 14}}
もう一つ、scriptを使ったよくやる構文を紹介します。 「あるフィールドを削除する」パターンをやってみます。
今回は、ageの項目を持つデータからageのデータをpainlessを使って削除します。
まず、ageの項目を持つデータ一覧を取得します。
>>> es.search(index='gensou-index', body={"query": {"exists": {"field": "age" }}}, filter_path=['hits.hits._source', 'hits.hits._id']) {'hits': {'hits': [{'_id': 'flan-chan', '_source': {'first_name': 'フランドール', 'last_name': 'スカーレット', 'user_name': 'flan', 'spell': {'prefix': '禁忌', 'title': 'かごめかごめ'}, 'age': 495}}, {'_id': '1', '_source': {'first_name': '霊夢', 'last_name': '博麗', 'user_name': 'reimu', 'spell': {'prefix': '霊符', 'title': '夢想封印'}, 'age': 14}}]}}
二件ヒットしました。どちらもageフィールドが確認できます。
では、ageフィールドの削除していきます。
>>> es.update(index='gensou-index', id=1, body={'script': "ctx._source.remove('age');"}) >>> es.update(index='gensou-index', id='flan-chan', body={'script': "ctx._source.remove('age');"})
成功したか確認します。まずサーチをしてみます。
>>> es.search(index='gensou-index', body={"query": {"exists": {"field": "age" }}}, filter_path=['hits.hits._source', 'hits.hits._id']) {}
ageを持つデータはないようです。
それぞれのデータをゲットしてみると、ageフィールドが消えたことを確認できます。(省略)
>>> es.get(index='gensou-index', id=1') >>> es.get(index='gensou-index', id='flan-chan') # 結果は省略
バルク操作
複数のデータまとめて操作します。_op_type
(多分オペレーションタイプ)を指定することで、update, create, deleteなどの操作を指定できます。
データは配列として渡しますが、大きなデータの場合にはpythonのジェネレータ関数を使って記述する必要があります。通常、bulkメソッドを使うようなシーンではデータサイズが巨大になると考えられるので、listは避けた方がいいかもしれません。公式もジェネレータを使っています。
(下の例では実際にはリストを渡しているのでジェネレータの利点はなくなっていますが、みやすさのためにこうしました。)
Helpers — Elasticsearch 7.9.1 documentation
>>> from elasticsearch import helpers # イテレータの作成 >>> data_iter = ( {'_op_type': 'create', # operation type '_index': 'gensou-index', '_id': data['id'], # これは任意 '_source': data['data']} for data in [{'data': {'user_name': 'パチュリーノーレッジ'}, 'id': 'p'}, {'data': {'user_name': '十六夜咲夜'}, 'id': 's'}]) >>> helpers.bulk(es, data_iter) (2, [])
結果を確認します。(省略)
>>> es.get(index='gensou-index', id='p') >>> es.get(index='gensou-index', id='s')
バルクデリートもやってみましょう。
>>> data_iter = ( {'_op_type': 'delete', # operation type '_index': 'gensou-index', '_id': data} for data in ['p', 's']) >>> helpers.bulk(es, data_iter) (2, [])
先程入れたデータがきえているはずです。
【AWS Glue】開発エンドポイントをAWS CLIで構築してSageMaker Notebookを使う
前書き
AWS Glueの開発エンドポイントを利用すると、コードを一行ずつお試し実行しながらETLスクリプトを作成することが可能です。
Developing Scripts Using Development Endpoints - AWS Glue
実行環境としては、以下の二種類から選ぶことができます。
- Zeppelin Notebook
- SageMaker Notebook
SageMakerはjupyter Notebookをベースにしていて扱いやすかったです(個人的な感想)。
本記事では開発エンドポイント/SageMaker Notebookの環境構築をAWS CLIを使って行う方法をまとめました(※ SageMaker Notebookの立ち上げはGUIで行います)。
注意: 開発エンドポイントの料金
開発エンドポイントはオプションで、ETL コードをインタラクティブに開発することを選択した場合にのみ課金が適用されます。開発エンドポイントは、開発エンドポイントのプロビジョニングに使用されたデータ処理ユニットの時間に基づいて課金されます。AWS Glue の開発エンドポイントには最低で 2 個の DPU が必要です。デフォルトでは、AWS Glue は各開発エンドポイントに 5 個の DPU を割り当てます。DPU 時間あたり 0.44 ドルが 1 秒単位で課金され、最も近い秒単位に切り上げられます。プロビジョニングされた開発エンドポイントごとに 10 分の最小期間が設定されます。
- 開発エンドポイントは存在している間、常に課金され続けます。
- デフォルトではDPUs(Data processing units)が5に設定されます。(2020年11月28日現在)
「DPU時間当たり0.44ドルが1秒単位で課金され」とは、1時間につき、DPU一つに対して0.44ドルが課金されるということです。 何も考えずにデフォルト(DPUs-> 5個)で開発エンドポイントを作成すると、一時間当たり2.2ドル課金されます。
仮に1ヶ月そのままにしていれば、来月の請求には1580ドルほどになります。
(ちなみにマネジメントコンソール(GUI)から開発エンドポイントのタブを開くと「使わない時も課金されるから、削除してね!(意訳)」というメッセージが表示されます。親切ですね。)
月18万円!AWS Glueの開発エンドポイントで破産しないために - Qiita
↑こちらの記事では、落とし忘れた開発エンドポイントをLambdaによって通知する仕組みを提案されています。
環境
ローカルでAWS CLIコマンドを打っても、同じようにできるはずです(未確認)
開発エンドポイント/SageMaker Notebookの環境構築手順
事前準備
- AWS CLIが使える環境を用意すること(お勧めはCloud9)。
- AWS CLIを実行するロール or ユーザ(グループ)に以下のポリシーをアタッチしておくこと。
-
IAMFullAccess
※ これは最強なので、使用後は外しておくことをお勧めします。 -
AmazonS3FullAccess
-
AWSGlueServiceRole
-
AmazonSageMakerFullAccess
-
IAMロールの作成
必要なIAMリソースを作成していきます。AWS Glueの公式ドキュメントにも詳しく書かれていますが少々記述が不親切なところがあるので、その都度読み替えて適応します。 本手順では、以下のリソースが必要になります。
- 開発エンドポイント用のロール
- SageMaker Notebook用のロール
IAMロール作成手順
1. S3バケットと、バケットへのアクセス権限を定義したIAM ポリシーの作成
デフォルトリージョンを指定しておきます。
export AWS_DEFAULT_REGION=ap-northeast-1
自分のアカウントのAWS_IDを取得し、これをリソース名に含めるようにします。(JAWS-UG CLI専門支部 - connpass様のハンズオンを大いに参考にさせていただきました。)
AWS_ID=`aws sts get-caller-identity --query 'Account' --output text` && echo ${AWS_ID} aws s3api create-bucket --bucket "tmp-test-${AWS_ID}" --create-bucket-configuration "LocationConstraint=ap-northeast-1" cat << EOS > tmp-s3-access-policy.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ], "Resource": [ "arn:aws:s3:::tmp-test-${AWS_ID}/*" ] } ] } EOS # jsonが壊れてないかチェック cat tmp-s3-access-policy.json | python3 -m json.tool # ポリシー作成 POLICY_NAME=tmp-s3-access-policy aws iam create-policy --policy-name ${POLICY_NAME} --path "/tmp-test/" --policy-document file://tmp-s3-access-policy.json # 作成したポリシーが存在することを確認する。 aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[*].PolicyName"
2. 開発エンドポイント用のロールの作成
繰り返しになりますが、基本的に「開発エンドポイント」につけるロールは、Glueで実行するジョブに付加するロールと同じ権限のものにします。 すでにジョブ実行に使用しているIAMロールがあれば、そちらを使うようにしてもOKです。
ロールの作成時には、「このロールはGlueにつかえますよ〜」というtrust relationship(Principalと呼ばれているもの)を定義したpolicy documentを作成する必要があります。これは以下に示す形式で、「Service」のところだけglueとなるように変更します。
(余談)厄介なのですが、このサービス指定部分の一覧は、公式ドキュメントにも整備されていないようです。
一度マネコンから作成したロールを確認するか、非公式の一覧(有志がまとめたもの)を参照するしかないようです。 -> List of AWS Service Principals · GitHub
# ロールの作成 $ cat << EOS > role-for-glue.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "glue.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOS # jsonが壊れていないか確認する。 cat role-for-glue.json | python3 -m json.tool GLUE_ROLE_NAME='AWSGlueServiceRole-Tmp-Test' && echo $GLUE_ROLE_NAME aws iam create-role --role-name $GLUE_ROLE_NAME --assume-role-policy-document file://role-for-glue.json --path /tmp-test/ # ロールができているか確認 aws iam list-roles --path /tmp-test/ --query "Roles[?RoleName=='${GLUE_ROLE_NAME}']"
3. 開発エンドポイント用のロールに、ポリシーをアタッチする
AWSGlueServiceRoleについては公式ドキュメントに詳しく記述されています。(AWSのmanaged policy。名前が「Role」なのでわかりづらいですが、これはポリシーです。)
AWSGlueServiceRole
(AWSのmanaged policy)で基本的なGlueの操作の権限を与え、自作のtmp-s3-access-policy
を使ってs3バケットへのアクセス権限を渡します。
# 先ほど作ったs3アクセス用ポリシーのARN POLICY_ARN=`aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${POLICY_NAME}'].Arn" --output text` # AWSGlueの基本的な権限を持つ、AWS managed policy も取得しておく。 GLUE_POLICY_ARN=`aws iam list-policies --scope AWS --query "Policies[?PolicyName=='AWSGlueServiceRole'].Arn" --output text`
aws iam attach-role-policy --role-name $GLUE_ROLE_NAME --policy-arn $GLUE_POLICY_ARN aws iam attach-role-policy --role-name $GLUE_ROLE_NAME --policy-arn $POLICY_ARN # アタッチしたポリシーが存在することを確認しておく。 aws iam list-attached-role-policies --role-name $GLUE_ROLE_NAME
4. SageMaker Notebook用のロールを作成する
SageMaker Notebookは、PrincipalでServiceを"sagemaker.amazonaws.com"に設定します。
cat << EOS > role-for-sagemaker.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "sagemaker.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOS cat role-for-sagemaker.json | python3 -m json.tool # 必ず、AWSGlueServiceSageMakerNotebookRole というprefixにします。 SAGEMAKER_ROLE_NAME='AWSGlueServiceSageMakerNotebookRole-Tmp-Test' && echo $SAGEMAKER_ROLE_NAME aws iam create-role --role-name $SAGEMAKER_ROLE_NAME --assume-role-policy-document file://role-for-sagemaker.json --path /tmp-test/ # ロールができているか確認 aws iam list-roles --path /tmp-test/ --query "Roles[?RoleName=='${SAGEMAKER_ROLE_NAME}']"
5. SageMaker Notebook用のロールに、ポリシーを作成 & アタッチする
ポリシーに、かなりクセがあります。 どうしても失敗するようならマネジメントコンソールでSageMaker Notebookのインスタンスをマネジメントコンソールから作成するときに、ロールを新規作成するオプションがあるので、そちらを選んだ方が簡単かもしれません。
Step 6: Create an IAM Policy for SageMaker Notebooks - AWS Glue
こちらのドキュメントに記載されているように作成しますが、bucket-nameに指定するのはaws-glue-jes-prod-ap-northeast-1-assets
となります。
(AWS glueに関係する設定ファイルなどが置かれている、公共のs3バケットのようです)
あとは自分のAWS IDとリージョン(ap-northeast-1など)を適切な場所に置換します。
(余談ですが、AmazonSageMakerFullAccessを与えてもうまく行きません。他のサービスと異なり、Glue関連は"FullAccess"系のポリシーだけでは動かないものが多いです。)
AWS_ID=`aws sts get-caller-identity --query 'Account' --output text` && echo ${AWS_ID} cat << EOS > tmp-sagemaker-policy.json { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:ListBucket" ], "Effect": "Allow", "Resource": [ "arn:aws:s3:::aws-glue-jes-prod-${AWS_DEFAULT_REGION}-assets" ] }, { "Action": [ "s3:GetObject" ], "Effect": "Allow", "Resource": [ "arn:aws:s3:::aws-glue-jes-prod-${AWS_DEFAULT_REGION}-assets*" ] }, { "Action": [ "logs:CreateLogStream", "logs:DescribeLogStreams", "logs:PutLogEvents", "logs:CreateLogGroup" ], "Effect": "Allow", "Resource": [ "arn:aws:logs:${AWS_DEFAULT_REGION}:${AWS_ID}:log-group:/aws/sagemaker/*", "arn:aws:logs:${AWS_DEFAULT_REGION}:${AWS_ID}:log-group:/aws/sagemaker/*:log-stream:aws-glue-*" ] }, { "Action": [ "glue:UpdateDevEndpoint", "glue:GetDevEndpoint", "glue:GetDevEndpoints" ], "Effect": "Allow", "Resource": [ "arn:aws:glue:${AWS_DEFAULT_REGION}:${AWS_ID}:devEndpoint/*" ] }, { "Action": [ "sagemaker:ListTags" ], "Effect": "Allow", "Resource": [ "arn:aws:sagemaker:${AWS_DEFAULT_REGION}:${AWS_ID}:notebook-instance/*" ] } ] } EOS # jsonが壊れてないかチェック cat tmp-sagemaker-policy.json | python3 -m json.tool # ポリシー作成 SAGE_POLICY_NAME=tmp-sagemaker-policy aws iam create-policy --policy-name ${SAGE_POLICY_NAME} --path "/tmp-test/" --policy-document file://tmp-sagemaker-policy.json # ポリシーのアタッチ SAGE_POLICY_ARN=`aws iam list-policies --scope Local --query "Policies[?PolicyName=='${SAGE_POLICY_NAME}'].Arn" --output text` aws iam attach-role-policy --role-name $SAGEMAKER_ROLE_NAME --policy-arn $SAGE_POLICY_ARN # アタッチされていることを確認 aws iam list-attached-role-policies --role-name $SAGEMAKER_ROLE_NAME
開発エンドポイントの作成
公式ドキュメント(下のリンク)には、マネジメントコンソール(GUI)とAWS CLI、それぞれを用いた開発エンドポイントの作成方法が簡単に記載されています。こちらを元に、手順を作りました。
Adding a Development Endpoint - AWS Glue
考慮すべきパラメータは以下の通りです。
--number-of-nodes
: (e.g.) '2'- 最初に述べたDPUs(Data Processing Units)。時間当たりの課金額を決める要員。
- 最小で2に設定できる。デフォルトは5。 最低の2に設定すれば、一時間に100円以下(正確には0.88ドル)の課金となるので安心。
--glue-version
: (e.g.) '1.0'- 開発エンドポイントでは
0.9
or1.0
しか選択できない。 - 実際には、現在は最新の
2.0
でjobを作ることが望ましい。
- 開発エンドポイントでは
--arguments
: (e.g.) 'GLUE_PYTHON_VERSION=3'
開発エンドポイント作成手順
パラメータが多いので、引数を準備 & 確認してから実行します。
AWS_ID=`aws sts get-caller-identity --query 'Account' --output text` && echo ${AWS_ID} GLUE_ROLE_NAME='AWSGlueServiceRole-Tmp-Test' && echo $GLUE_ROLE_NAME ENDPOINT_NAME="endpoint-tmp-${AWS_ID}" # 先ほど作成した、開発エンドポイント用のロールを選択します。 ROLE_ARN=`aws iam list-roles --query "Roles[?RoleName=='${GLUE_ROLE_NAME}'].Arn" --output text` NUMBER_OF_NODES=2 # Data Processing Units (DPUs) 2以上。 GLUE_VERSION=1.0 # 0.9か1.0 ARGUMENTS="GLUE_PYTHON_VERSION=3" REGION='ap-northeast-1' # きちんと定義できていることを確認しておく。 cat << EOS ENDPOINT_NAME: $ENDPOINT_NAME ROLE_ARN: $ROLE_ARN NUMBER_OF_NODES: $NUMBER_OF_NODES GLUE_VERSION: $GLUE_VERSION ARGUMENTS: $ARGUMENTS REGION: $REGION EOS
開発エンドポイント一覧を確認 & 新規作成。 この瞬間から課金されるので、開発エンドポイントは忘れずに削除する必要があります。
# 現在の開発エンドポイント一覧を確認する。 aws glue list-dev-endpoints # 開発エンドポイントの作成 aws glue create-dev-endpoint --endpoint-name $ENDPOINT_NAME --role-arn $ROLE_ARN --number-of-nodes $NUMBER_OF_NODES --glue-version $GLUE_VERSION --arguments $ARGUMENTS --region $REGION # 完了確認。 aws glue get-dev-endpoint --endpoint-name $ENDPOINT_NAME
エンドポイント作成後、利用可能になるまでに数分〜10分程度かかります。
以下のコマンドの結果が、PROVISIONING
の間は待ちましょう。
READY
になれば、次の手順に進めます(SageMaker Notebook作成)
aws glue get-dev-endpoint --endpoint-name $ENDPOINT_NAME --query "DevEndpoint.Status"
SageMaker Notebookの作成
SageMakerのインスタンスと、開発エンドポイントを紐つける処理を指定するのは少し面倒です。 (SageMakerのLifecycle configurationsから、インスタンス起動時のShellScriptをbase64でエンコードしてから指定する必要があります。)
そのため、この部分はマネジメントコンソール(GUI)から行うことにします(SageMaker自体もマネジメントコンソールから開く必要があります)
インスタンスタイプの指定などはCLIからupdateコマンドで行う必要があるようです(GUIからは指定することができません) (余談)インスタンスの状態がFailになってしまうとCLIからでないとリスタートできなかったり、現状はいろいろと不便なUIです。
SageMaker Notebook作成手順
AWS Glueの画面にアクセスし、作成した開発エンドポイントを選択してSageMaker Notebookのインスタンスを作成します。 この手順で作成すると、自動で開発エンドポイント周りの設定を行うスクリプトを作成してくれます。(インスタンス作成後、SageMakerのマネジメントコンソール-> Lifecycle configurationsからスクリプトを確認可能)
以下の様に設定し、ページ下のCreate Notebook
を押せば完了です。
もし10分ほどしてインスタンスの状態がFailとなってしまう場合は、SageMaker Notebookに付加したIAMロールの権限を間違えている可能性が高いです。その場合、「Create an IAM role」を選択すると自動で正しいロールを作成してくれます。
立ち上がるのには結構時間がかかります。下手すると10分くらいかかっているようです。
なお、IAMなどの設定ミスでインスタンス立ち上げがFailするとGlueのマネジメントコンソールからはstartできなくなります。 Amazon SageMakerのマネコン(もしくはCLI)を使ってstartする必要があります。
ETLスクリプトをSageMaker Notebookで試す
公式ドキュメントに、以下のような手順書が書かれています。こちらにしたがって試してみます。(上記でスクショを記載した、SageMaker Notebookインスタンスの作成手順も下のリンクに書いてあります。) Tutorial: Use an SageMaker Notebook with Your Development Endpoint - AWS Glue
pysparkを選び、ノートを作成します。
spark
と打ち込み、実行すれば準備はOKです。
上記スクショのように、ETLスクリプトと同じようにコードが実行できます。クローラを実行してGlueのデータベースを作っていれば、from_catalogメソッドを使ってS3バケットからの読み込みもできます。 AWSのリソース(S3など)にETLスクリプトと同じようにアクセスできるため、コードのデバッグに集中できます。
なお、sc = SparkContext()
は実行するとエラーになります。最初のspark
コマンドですでにscは作成済みだからです。
作成したリソースの後始末
作成したリソースを削除していきます。
SageMaker Notebook
インスタンスを停止しておきます。(停止すれば課金されません。コードを残しておけるので、stopにしておきます) 開発エンドポイントを削除しても、同じ名前の開発エンドポイントを再度作成すれば、SageMaker Notebookのインスタンスを再度立ち上げることができます。
不要ならデリートしてもOKです。
開発エンドポイント
絶対に消し忘れてはいけません!!課金されてしまいます。
# 一覧確認 aws glue list-dev-endpoints # 開発エンドポイント名を取得する ENDPOINT_NAME=`aws glue list-dev-endpoints --query "DevEndpointNames[0]" --output text` && echo $ENDPOINT_NAME # 削除する aws glue delete-dev-endpoint --endpoint-name $ENDPOINT_NAME # 削除されたことを確認 aws glue list-dev-endpoints
IAMリソース
不要ならば削除しておきます。IAMリソースは残しておくと増えていきがち & 使用目的を忘れると消すことができなくなるので、その場で消すのが良いと思います。
- 今回作成したポリシーとロールの一覧
ポリシーもロールも、/tmp-test/
というパスを指定して作成しました。パスを使えば、作成したIAMリソースの一覧を簡単に取得できます。(そのほかuserやgroupなども含め、IAMリソースはpathをつけておいた方が扱いやすいです。)
削除漏れ防止のために、確認しておきます。
# 作成したロール名一覧の確認方法 aws iam list-roles --path-prefix /tmp-test/ --query "Roles[*].RoleName" # 作成したポリシー名一覧の確認方法 aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[*].PolicyName"
- SageMaker Notebookのロール(AWSGlueServiceSageMakerNotebookRole-Tmp-Test)からポリシーをデタッチする。
SAGEMAKER_ROLE_NAME=AWSGlueServiceSageMakerNotebookRole-Tmp-Test aws iam list-attached-role-policies --role-name $SAGEMAKER_ROLE_NAME SAGE_POLICY_NAME=tmp-sagemaker-policy SAGE_POLICY_ARN=`aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${SAGE_POLICY_NAME}'].Arn" --output text` aws iam detach-role-policy --role-name $SAGEMAKER_ROLE_NAME --policy-arn $SAGE_POLICY_ARN
tmp-sagemaker-policy
は自作のポリシーなのでこれも削除しておきます。
# 存在することを確認。 SAGE_POLICY_NAME=tmp-sagemaker-policy aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${SAGE_POLICY_NAME}'].Arn" --output text # ポリシーの削除 SAGE_POLICY_ARN=`aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${SAGE_POLICY_NAME}'].Arn" --output text` aws iam delete-policy --policy-arn $SAGE_POLICY_ARN
- SageMaker Notebookのロール(AWSGlueServiceSageMakerNotebookRole-Tmp-Test)を削除
# 削除対象のロールを確認 SAGEMAKER_ROLE_NAME=AWSGlueServiceSageMakerNotebookRole-Tmp-Test aws iam list-roles --path-prefix /tmp-test/ --query "Roles[?RoleName=='${SAGEMAKER_ROLE_NAME}'].RoleName" # 削除。上記手順(list-roles)を再度実行し、削除成功したかを確認しておく。 aws iam delete-role --role-name $SAGEMAKER_ROLE_NAME
- 開発エンドポイントのロールからポリシーをデタッチする
GLUE_ROLE_NAME=AWSGlueServiceRole-Tmp-Test aws iam list-attached-role-policies --role-name $GLUE_ROLE_NAME # アタッチされていたポリシーを、デタッチする。(POLICYのARNは、上記list-attached-role-policiesコマンドの結果からARNを探し、コピペで貼ると楽) aws iam detach-role-policy --role-name $GLUE_ROLE_NAME --policy-arn arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole aws iam detach-role-policy --role-name $GLUE_ROLE_NAME --policy-arn arn:aws:iam::${AWS_ID}:policy/tmp-test/tmp-s3-access-policy # ロールを削除する。 aws iam delete-role --role-name $GLUE_ROLE_NAME # 削除できたことを確認。 aws iam list-roles --path-prefix /tmp-test/ --query "Roles[?RoleName=='${GLUE_ROLE_NAME}'].RoleName"
デタッチしたポリシーのうち、tmp-s3-access-policy
は自作のポリシーなのでこれも削除しておく。
# 存在することを確認。 POLICY_NAME=tmp-s3-access-policy aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${POLICY_NAME}'].Arn" --output text # ポリシーの削除 POLICY_ARN=`aws iam list-policies --scope Local --path-prefix /tmp-test/ --query "Policies[?PolicyName=='${POLICY_NAME}'].Arn" --output text` aws iam delete-policy --policy-arn $POLICY_ARN
- s3バケットの削除
# 中身を空にしておきます。 aws s3 rm --recursive "s3://tmp-test-${AWS_ID}" # バケットを削除します。 aws s3 rb "s3://tmp-test-${AWS_ID}"
参考にさせていただいたリソースへのリンク
- JAWS-UG CLI専門支部 - connpass
- CLIコマンドについて、大いに参考にさせていただきました:pray:
- 月18万円!AWS Glueの開発エンドポイントで破産しないために - Qiita
- 開発エンドポイントのコストについて、わかりやすく解説されています。
DynamoDBから1MB以上のデータを取得する(boto3)
- 前書き
- 参考リンク
- 環境
- DynamoDBのセットアップ
- scanメソッドで1Mb以上のデータを取得する場合
- queryメソッドで1MB以上のデータを取得する場合
- 注意: limit句がある場合には気をつける
- まとめ
前書き
DynamoDBに蓄積したデータをグラフ表示するシステムを作成していました。開発の中途では問題なく動作し、テストコードもパスしていたのですが、ある時表示されるはずのグラフの一部が描画されていないことに気がつきました。 その原因はページネーションでした。DynamoDBのscanやqueryメソッドでは、指定したデータの全てを 1回のリクエストで取得できるとは限りません。 1MBまでしか取得できず、残りのデータを取得するにはもう一度DynamoDBにリクエストする必要があります。
本記事では、boto3からscanおよびqueryを実行してDynamoDBから1MBを越えるデータを取得する方法と注意点についてまとめます。
結論
参考リンク
DynamoDBのページネートを説明している公式ドキュメント。
Working with Scans in DynamoDB - Amazon DynamoDB
boto3でscanを行うサンプルコード公式ドキュメント。
Step 4: Query and Scan the Data - Amazon DynamoDB
環境
バージョン | |
---|---|
MacOS Catalina | 10.15.6 |
Python3 | 3.8.2 |
boto3 | 1.14.16 |
DynamoDB
- ローカルでdockerを使って動かせる、公式のツール DynamoDB Local を利用して擬似的なDynamoDBを用意します。
- 導入は、よろしければ以下の記事を参考にしてください。
DynamoDBのセットアップ
- 以下はpythonの対話モードから実行しました
テーブルの作成
ハッシュキーとして"name"、レンジキーとして"size"を持つテーブル、images を作成します。
import boto3 # ローカルのDynamoDBを使います。(上記記事参照) dynamodb = boto3.resource('dynamodb', endpoint_url='http://localhost:8000') table_name = 'images' key_schema = [ { "AttributeName": 'name', "KeyType": 'HASH', }, { "AttributeName": 'size', "KeyType": "RANGE", } ] attribute_definitions = [ { 'AttributeName': 'name', 'AttributeType': 'S' }, { 'AttributeName': 'size', 'AttributeType': 'N' } ] provisioned_throughput = { 'ReadCapacityUnits': 5, 'WriteCapacityUnits': 5 } table = dynamodb.create_table( TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput )
データの投入
今回は画像のバイナリデータを投入することにします。 現実的には画像をDynamoDBに入れるべきではないと思いますが、一レコードの情報量が大きい方と、1Mbの制限に早く到達できてわかりやすいと思ったためです。
以下の作業も全てPythonの対話モードで行います。
補足: base64
Dynamodbにバイナリデータを登録する場合、base64にエンコードする必要があります。
AttributeValue - Amazon DynamoDB
B An attribute of type Binary. For example:
"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"
Type: Base64-encoded binary data object
base64エンコードを行うには、以下の標準ライブラリを利用します。
バイト列を渡してエンコードするという点には注意しておきます。
base64 — Base16, Base32, Base64, Base85 Data Encodings — Python 3.8.6rc1 documentation
from base64 import b64encode b64encode(b'abcdefg1234') from base64 import b64decode b64decode(b'YWJjZGVmZzEyMzQ=')
scan用のデータ
画像データ(PNG)を読み込み、エンコードします。 今回はハッシュキー(name)だけ変えて、画像データは同じものを使い回そうと思います。
注意点ですが、DynamoDBは1レコードあたりの上限サイズが決まっています。大きすぎる画像は登録できないので注意します。
Service, Account, and Table Quotas in Amazon DynamoDB - Amazon DynamoDB
with open('smallreimu.png', 'rb') as b: img = b.read() encoded_img = b64encode(img) # バイナリでエンコードしているので、19644バイト。 len(encoded_img) 19644
試しに一件、登録してからscanで取得してみます。
data = {'name': '1件目画像', 'size': 19644,'image': encoded_img} table.put_item(Item=data) table.scan() # .....省略....... 'Count': 1 # .....省略.......
データ一件について20,000バイト程度は増えると考え、
だから、なので、3ページ分くらいでページネートすることを想定して110件登録しましょう。for i in range(110): data = {'name': f'{i}件目-複数投入', 'size': 19644,'image': encoded_img} table.put_item(Item=data) table.scan() # .....省略....... 'Count': 54, 'LastEvaluatedKey': {'size': Decimal('19644'), 'name': '47件目-複数投入'}, # .....省略.......
大体予想通りです。データは110件入れましたが、おおよそ50件ほどしか取得できない状態になっています。これは1Mbの制限を超えたため、一度のリクエストでデータを取得しきれなくなった状態です。
また、LastEvaluatedKey という項目が取得されています。これは今回のリクエストで取得できたデータの内、最後のデータのキーの値です。(なお「47件目」と表示されていますが、dynamoDBは取得する順番が投入順とは全く関係ない点には注意します。)
ページネーションして、55件目からのデータを取得するにはこのLastEvaluatedKeyの値を、ExclusiveStartKeyというパラメータでscanメソッドに渡してやればOKです。渡したキーの"続き"の値を取得できます。
scanメソッドで1Mb以上のデータを取得する場合
Step 4: Query and Scan the Data - Amazon DynamoDB
この公式リンクのStep4.3: Scanの項目を参考に、合計1Mb以上のデータがあっても全てscanして取り出せるメソッドscan_all
を定義します。
def scan_all(table, scan_kwargs): items = [] done = False start_key = None while not done: if start_key: scan_kwargs['ExclusiveStartKey'] = start_key response = table.scan(**scan_kwargs) items.extend(response.get('Items', [])) start_key = response.get('LastEvaluatedKey', None) done = start_key is None return items
先ほどの説明通り、レスポンスに含まれるLastEvaluatedKey
を、次回のリクエストでExclusiveStartKey
として渡してあげればOKです。
データを全てとりつくしたら、LastEvaludatedKey
はレスポンスに含まれなくなるので、そこでストップします。
なお、scan_kwargsはscanメソッドに渡す引数となりますが(ProjectionExpressionなどを渡せます。)、今回は必要ないので空のdictを渡しておきます。
tableは、今回作成したimagesテーブルです。
data = scan_all(table, {}) len(data) 110 # 連続投入110件
全件のデータが取れました!
queryメソッドで1MB以上のデータを取得する場合
DynamoDBでは、可能な限りscanよりもqueryメソッドを利用すべきです。ある程度条件を指定してデータを絞って取得しないと、余計なリードコストと通信量、取得したデータによるメモリの圧迫が起こります。
ちなみにFilterExpressionを利用したとしても、Fileterはscanの後に行われるためリードにかかるコストは節約できていません。受けとるデータは減るようなので、メモリは節約できると思います。
queryメソッドを使った場合も、scanと全く同じ仕組みでページネートに対処して1Mb以上のデータを取得できます。
queryは、ハッシュキーを固定で指定してレンジキーの条件を指定、という形でしか使えないので、同一のハッシュキー(name)かつ、ことなるレンジキー(size)のデータを新しく作ります。
sizeにfor文の回した回数を入れているのでデータとしてはおかしいですが、そこは目をつぶります...
for i in range(110): data = {'name': f'query用のデータ', 'size': i,'image': encoded_img} table.put_item(Item=data)
queryを、size > 10 のデータを取得するように実行すると以下の様になります。
from boto3.dynamodb.conditions import Key t.query(KeyConditionExpression=Key('name').eq('query用のデータ') & Key('size').gt(10)) # .....省略....... 'Count': 54, 'LastEvaluatedKey': {'size': Decimal('64'), 'name': 'query用のデータ'}, # .....省略.......
scanと全く同じ仕組みの関数を定義して、全件取得します。
def query_all(table, query_kwargs): items = [] done = False start_key = None while not done: if start_key: query_kwargs['ExclusiveStartKey'] = start_key response = table.query(**query_kwargs) items.extend(response.get('Items', [])) start_key = response.get('LastEvaluatedKey', None) done = start_key is None return items
res = query_all(table, {'KeyConditionExpression': Key('name').eq('query用のデータ') & Key('size').gt(10)}) len(res) 99 # 110件のデータのうち、0 ~ 10の11件は弾かれるのであっている。
注意: limit句がある場合には気をつける
上記のようなラッパー関数を定義すればscanやqueryで1Mb以上のデータをページネートして取得可能なのですが、注意が必要なケースがあります。
それは limit句を使用していた場合です。
通常のlimit句は、以下の様な挙動をします。直感通り、limitで指定した件数だけデータを取得します。
table.scan(Limit=2) {'Items': [ {'name': '11件目-複数投入', 'size': Decimal('19644'), 'image': Binary(b'iVBORw0KGgoAAAANS.....')}, # 省略 {'name': '91件目-複数投入', 'size': Decimal('19644'), 'image': Binary(b'iVBORw0KGgoAAAANS.....')}, # 省略 ] 'Count': 2, 'ScannedCount': 2, 'LastEvaluatedKey': {'size': Decimal('19644'), 'name': '91件目-複数投入'}, # ............省略..............
レスポンスにLastEvaluatedKeyが入ってきます。これはqueryメソッドも同様です。
例えばこのLimit
とScanIndexForward
を組み合わせ、DynamoDBからレンジキーを用いて、「最大の値」のItemを取得する様な処理を実行したいとします。
上記のLastEvaluatedKeyを処理してページネートする関数にうっかり通してしまうと1リクエストで1件データ取得という処理を、dynamodbに入っている全てのデータをとり尽くすまで実行してしまうということになりかねません。
DynamoDBを操作するラッパークラスを定義するのは良いですが、あまり密な実装にならない様にした方が良さそうです。
まとめ
- DynamoDBは1回のリクエストで1Mbまでのデータしか取得できない。
- 1Mb以上のデータをscanやqueryで取得するにはページネートする必要がある。
LastEvaluatedKey
に、「最後に取得した」データのキーが入っている。これを次のリクエストのExclusiveStartKey
に指定することでページネートを実現する。- 最終的に
LastEvaluatedKey
がレスポンスに含まれていないということが、データを全てとり尽くしたことの証明になる。 - 上記はwhile文で処理するとわかりやすい。
- Limitを指定した場合にも
LastEvaluatedKey
は返ってくる。Limitを使う場合にはwhileによるページネーションを併用しない様に気をつける。
シェル上でjsonをフォーマットする、各種ツール導入手順と使い方の個人的なまとめ (jq, jsonlint, json.tool)
前書き
シェル上でJSONファイルを読みやすくフォーマットしたり、壊れていないかチェックする上で使えるツールを比較しました。
やりたいこと
シェル上でJSONをフォーマットしたい。また、JSONが壊れていないかチェックしたい。
'{"key": "val"}' # ↑を ↓な感じに。 { "key": "val" }
個人的結論
すぐ使えるのはpython2が入っているだけで使えるjson.tool
JSONを加工したいなら、インストールのしやすさ、JSON加工性能の多彩さではやっぱりjqが圧倒的に良さそう。
しかしPython3系またはnpmがすでに入っているなら、他のツールも候補に上がってきそう。
参考リンク
distributions/README.md at master · nodesource/distributions · GitHub
環境
※ Linuxはvagrantでこちらのboxを取得して、macOS上で動かしてます。vagrant
バージョン | |
---|---|
macOS Catalina | 10.15.6 |
CentOS Linux | 7.6.1810 |
jq | 1.6 |
npm | 6.14.8 |
python | 2.7 |
jsonlint | 1.6.3 |
jq
centOSならインストールが簡単にできます。単にプリントするだけでは無く、加工に関してかなり複雑なことができるので、その様な場合には重宝します。
インストール
1. centOS7へyumを使ってインストールする場合
$ sudo yum install epel-release $ sudo yum install jq
余談: アンインストール
$ sudo yum erase jq # jqインストール時に入る正規表現ライブラリ。jq以外で使っていなければ消しておいた方がいいかも? $ sudo yum erase oniguruma
2. ソースコードを直接落としてくる場合
おすすめはしないです。
Download jq こちらのLinuxの項目から、64-bitと書かれたリンクを取得する。
# すぐ利用可能なバイナリ $ sudo wget https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64 $ cp jq-linux64 /usr/local/bin/jq # vagrant->自分のユーザ $ sudo chown vagrant:vagrant /usr/local/bin/jq # 実行権限つける $ sudo chmod 744 /usr/local/bin/jq $ jq . test.json
使用例
# jsonファイルをフォーマット $ jq . test.json # パイプして標準出力を受け取ることもできる。 $ echo '{"key": "val"}' | jq .
余談
例: jsonオブジェクトが複数行並んでいる場合
$cat test.json {"key1": "val1"} {"key2": "val2"} # 何事もなかったかの様に、それぞれのjsonオブジェクトをフォーマットして出力される。 $jq . test.json { "key1": "val1" } { "key2": "val2" }
jsonlint
npmがすでに入っていたら、jsonlintを使うのもアリかと思います。Python2系のjson.tool
(後述)よりもオプションが豊富ですが、jqと比べると大したことはできません。Python3.8以上のjson.tool
と比べると特にメリットはないです。
エラーメッセージは割と親切です。
インストール
npm導入まで
$ curl -sL https://rpm.nodesource.com/setup_14.x | sudo bash - # 上記コマンドの結果、以下を実行するようメッセージが表示されます。 $ sudo yum install gcc-c++ make $ sudo yum install -y nodejs
jsonlintのインストール
-g
はグローバルにインストール。自分の環境だと、sudo付けないとダメでした。
$ sudo npm install jsonlint -g
使用例
$ jsonlint test.json { "key1": "val1", "key2": "val2" } # 標準出力を受け取る場合 $ cat test.json | jsonlint
余談
- デフォルトで、jsonが不正な場合のエラーをしっかりとだしてくる。
- オプションがシンプルでとてもわかりやすい。jsonlint - npm
jsonlint: keyでソートして表示する
# 通常 $ jsonlint test.json { "bcd": "val", "abc": "val" } # keyをアルファベット順でソートした。 $ jsonlint test.json --sort-keys { "abc": "val", "bcd": "val" }
pythonのjson.toolモジュール
JAWS-UG CLIのオンライン勉強会に参加した際に教わりました。 pythonは(2系であれば特に)大抵の環境に最初から入っているのでとても便利です。
インストール
python(2.7でもOK)があればすでに利用可能->大抵の環境に入っている。
$ python -m json.tool test.json { "abc": "val", "bcd": "val" }
余談
2系だと大したことはできませんが、3.5以上だとjsonlintと同じ--sort-keys
オプションが使える様になります。
また、3.8系以上では--json-lines
オプションによって、jqと同じ様に一つのファイルに複数のjsonが書かれていてもパースできる様になっています。
結論とまとめ
あくまで個人的な感想です。
- 単にJSONをフォーマットしたい、壊れてないか確かめたいだけなら
json.tool
がもっとも簡単(python入ってたらすぐ使える)- centOS7の場合、次点で
jq
です.(epelレポジトリを入れられれば、yumで取れるため) jsonlint
はnpmが使える状態なら使用を検討すると思います。
- centOS7の場合、次点で
- JSONを加工したい(特定のキーだけ抜きたいなど)なら、インストールのしやすさから
jq
が良さそう。- ただしPython3系が利用可能ならjmespathを使うのもアリだと思います。(個人的にはjmespathの方がjqより遥かにわかりやすいと思います)
JMESPathを使ってJSONをパースするときにPython製ツールjmespath-terminalが便利だった - Qiita
Django + Chart.jsなら、django-chartjsライブラリを使おう
前書き
最近、業務でDjango + Chart.jsを使ったシステムを作成しました。 Chart.jsは綺麗なグラフを簡単に作成することができ、またドキュメントも充実しています。しかしグラフ一つ作るためのパラメータが非常に多く、Django側から送ったデータをjavascriptで埋めていくのが大変でした。 かといってDjango側でChart.js用のJSONを自分で組み立てるのは厳しいと感じ、諦めていました。
ところが、Chart.js描画のためのデータを送るバックエンド側として、Django-chartjsというライブラリがあるということを最近知りました。
こちらを利用すると、DjangoからChart.jsに必要な各種データ、ラベル、オプションをViewのメソッド別に整理して定義することができるようでした。
フロントはAjaxを書いて、new Chart(ctx, {type: 'xxx', data:data})
とするだけで良さそうです。
公式ドキュメントは無い様なのですが、実装されているBaseとなるViewは直感的に使うことができ、またデモ用のサンプルコードが公式GitHubに充実していて、簡単に導入できそうでした。
公式GitHub
GitHub - peopledoc/django-chartjs: Django Class Based Views to generate Ajax charts js parameters.
公式GitHubのデモ用のサンプルコード
django-chartjs/demo at master · peopledoc/django-chartjs · GitHub
実際に使ってみました。基本的には、上述の公式GitHubのREADMEに載っている例を変更して使います。
所感
少ないコード量で記述が可能ですが、その反面デフォルト値を利用することで簡略化を実現しているように感じました。 デフォルトの挙動をカスタムするほど、(仕方ないことですが)当然コード量は増えていく感じです。ソースコードは決して難しいものでは無いので、良く読んでメソッドをオーバーライドする必要があります。
色の変更
色の変更は、以下の2種類のメソッドをオーバーライドすることで実現できるようです。
get_colorsメソッド
RGBを指定したタプルのリストをここで定義します。順番と数は定義したラベルと合わせて使うことが多いと思いますが、数は多くても少なくても大丈夫です(next_colorメソッドを利用する場合)。
next_colorメソッドに定義したリストを渡し、returnします。next_colorメソッドは特殊なジェネレーターを返します。nextし続け最後までくると、渡したリストの最初に戻ります。ですので指定した色を使い果たしてまだラベルが残っていれば、また最初の色から順番に適応していきます。
get_dataset_optionsメソッド
get_colorsメソッドが返した色をもとに、チャートの各部品の色を指定するメソッドです。 デフォルトではbackgroundColorのみ透明度を0.5にしたグラフを描画します。この部分を好きな様に変更すれば、自由にグラフを描画できます。
以下の例では、グラフを濃い目の赤/緑/青に変更しています。
from chartjs.colors imoport next_color # .....(略)........ class lineChartJSONView(BaseLineChartView): # .....(略)......... def get_colors(self): # 赤 / 緑 / 青 l = [(200, 0, 0), (0, 200, 0), (0, 0, 200)] return next_color(l) def get_dataset_options(self, index, color): default_opt = { # 棒グラフの色が濃くなるように、透明度を0.5 -> 0.9に変更 "backgroundColor": "rgba(%d, %d, %d, 0.9)" % color, "borderColor": "rgba(%d, %d, %d, 1)" % color, "pointBackgroundColor": "rgba(%d, %d, %d, 1)" % color, "pointBorderColor": "#fff", } return default_opt
options属性の付加
BaseLineChartViewは、Chart.jsにおけるoptionsを設定できません。optionsを設定したい場合は、BaseLineOptionsChartViewを利用し、get_options関数で設定したいoptionsの一覧をdictとしてreturnします。
また、BaseLineOptionsChartViewを使う際にはフロント側も変更する必要があります。
$.get('{% url "cms:line_chart_json" %}', function (data) { const ctx = 'myChart'; // dataとoptionsを以下の様に取り出す必要がある。 new Chart(ctx, {type: 'bar', data: data.data, options: data.options}); });
サブタイトルをグラフの上に表示し、画質を10倍にしてみます。
また、get_labels, get_providers, get_dataメソッドはBaseLineChartViewと全く同じように記述します。 (BaseLineOptionsChartViewは、BaseLineChartViewを継承して作成されています)
class lineChartJSONView(BaseLineOptionsChartView): # ...........(略).................. def get_options(self): options = { "title": {"display": True, "text": "サブタイトル"}, "devicePixelRatio": 10, } return options
補足: 利用可能なview
デモ用サンプルコードをみると
from chartjs.util import date_range, value_or_null from chartjs.views.columns import BaseColumnsHighChartsView from chartjs.views.lines import ( BaseLineChartView, BaseLineOptionsChartView, HighchartPlotLineChartView, ) from chartjs.views.pie import HighChartDonutView, HighChartPieView
どうやらChart.js用のViewとHighChart用のViewがありそうです。
また、Chart.js用のViewとしてはLineChartと名前がついているViewしか見当たりません。
ですが、このViewでLineChart以外も記述できます。Chart.jsでは、フロント側でtypeを指定しますが、そこでline
やbar
を指定すれば形式を変更できます。
Chart.jsで利用するViewは
- BaseLineCharView
- BaseLineOptionsChartView
この二種類の様です。
ソースコードはchartjs/views/lines.py
に存在し、割とシンプルなコードなので読めばすぐわかります。
BaseLineOptionsChartViewは、その名の通りoptions
属性を付加できる、というだけです。
(念の為)ソースコードの完全なパスは、以下の様にして得られます。
$ python -c "import chartjs; print(chartjs.__path__)"
jsPDFをscriptタグで配置する手順&日本語フォントを使う2種類の方法まとめ
前書き
jsPDFを、npmなどを使わずGitHubから直接ダウンロードし、scriptタグを使って読み込む方法をまとめます(プロジェクトの制約上この方法となりました)。
またjsPDFはそのままだと日本語を使うことができません。日本語フォントを読み込む方法を、公式ドキュメントにも書かれている2種類の方法でやってみます。
また、実行環境はMacOS Catalina 10.15.6で、Djangoを使ってtemplateを作成しています。
(htmlにDjangoのテンプレートタグを用いています。{% static 'xxx' %}
←こういうやつです。)
jQueryとjsPDFのソースファイルをダウンロードする
jQuery
Download the compressed, production jQuery 3.5.1
を右クリックして、別名で保存→プロジェクト配下に保存しました。
jsPDF
https://github.com/MrRio/jsPDF
上記GitHubのページの右の方に、Releasesの項目があります。そこを開くと
Releases · MrRio/jsPDF · GitHub
こちらになります。「Source Code (zip)」とあるので、それをダウンロードします。
利用するのはjsPDF-2.0.0/dist/jspdf.umd.jsです。これを先ほどのjqueryファイルと同じく、プロジェクト配下にコピーします。
umdファイルはscriptタグで読み込みをするための形式とのことです。公式ドキュメントの以下の部分に書いてありました。
jspdf.umd.*.js: UMD module format. For AMD or script-tag loading.
htmlテンプレートを作成し、以下のjsを実行してみます。
注意点: 公式ドキュメントではvar doc = new jsPDF();
としていますが、scriptタグで直接読んでいるため、jsPDFは直にインポートされておらず、jspdf.jsPDFというnamespaceにある様です。
jQuery(function($){ const doc = new jspdf.jsPDF(); // 注意!jspdf配下から呼ぶ。 doc.text('Hello,世界!', 10, 10); doc.save('test.pdf'); });
うまくいってますが、日本語だけ文字化けしています。
フォントの確認と指定方法
jspdfを読み込んでいるhtmlファイルをブラウザで開き、ブラウザのdeveloperツールを開きます。
chrome/macos環境なら、option + command + i
で開きます。
Consoleタブを開き、以下の様にしてフォント一覧を確認します。使うのは、以下の公式ドキュメントリンクにあるgetFontListメソッドです。
const jspdfObject = jspdf.jsPDF();
jspdfObject.getFontList();
上記の出力結果を参照し、パラメータをセットします。Keyがフォント名、valueとなっているlistが設定可能なスタイルです。 フォントの指定は、以下の様にsetFontメソッドによって行います。
jQuery(function($){ const doc = new jspdf.jsPDF(); doc.setFont('Helvetica', 'Bold'); // フォントを適応 doc.text('Hello,世界!', 10, 10); doc.save('test.pdf'); });
文字化けはしていますが(Helveticaは日本語を扱えないフォントなので)、フォントが適応されていることがわかります。
日本語フォントmplusの追加
こちらのUse of Unicode Characters / UTF-8:
という項目に従い、2種類の方法を試します。
mplusのダウンロード
自分がやってみたところ、jsPDFは日本語フォントに関して、受け付けないものもある様でした。(Myricaはダメでした) mplusは有効な日本語フォントでしたので、そちらを使って解説します。
上のサイトからダウンロードします。指示に従い、mplus-TESTFLIGHT-063a.tar.xz
をダウンロードしてその辺で解凍しておきます。(ttfファイルがたくさん出てくればOKです).
日本語フォントの設定方法1. 公式のファイル変換ツールを使う(多分推奨)
先述の公式ドキュメントから、フォントのttfファイルをjsファイルに変換するツールのリンクが示されていました。これを使うと、通常のjsファイルと同じ様に読み込んで使うことができるようにフォントのttfファイルを変換してもらえる様です。
先ほどダウンロードしたmplusの、mplus-TESTFLIGHT-63a/mplus-1p-black.ttf
を選択してアップロードします。fontNameはmplus-1p-black
と自動的に入るはずです。fontStyleはnormalを選択してください。
また、moduleFormatはUMDを選択してください。そうすることで、scriptタグで読み込み可能となる様です。
あとは変換後のファイル(mplus-1p-black-normal.js)をダウンロードし、jsPDFを利用している自分のjsファイルと同じディレクトリに置きます。
Djangoの場合、以下の様にしてhtmlファイルに読み込みます。
<body> <h1>test pdf</h1> <script src="{% static 'js/jquery-3.5.1.min.js' %}"></script> <script src="{% static 'js/jspdf.umd.min.js' %}"></script> <script src="{% static 'js/mplus-1p-black-normal.js' %}"></script> <script src="{% static 'js/test.js' %}"></script> </body>
追記 mplusのjsでjspdfが見つからないという旨のエラーが出るときは、 type="module"を付加します。
<script type="module" src="{% static 'js/mplus-1p-black-normal.js' %}"></script>
ここで、先ほど述べた様にフォント一覧をDeveloperツールのコンソールから確認します。
mplusが追加されているのがわかります。
では、以下の様にtest.jsを書き直します。
jQuery(function($){ const doc = new jspdf.jsPDF(); doc.setFont('mplus-1p-black', 'normal'); doc.text('Hello,世界!', 10, 10); doc.save('test.pdf'); });
元気よく、世界にご挨拶できました。
日本語フォントの設定方法2. base64形式にしたフォントのttfを直接設定する。
先述の公式ドキュメントに、第二の方法として述べられていた方法です。
また、以下のサイトを参考にさせていただきました。
JavaScriptでjsPDFを使ってPDFファイルを生成する(日本語対応)
const doc = new jsPDF(); const myFont = // ここにBase64エンコードしたstringをおく. doc.addFileToVFS("mplus.ttf", myFont); doc.addFont("mplus.ttf", "mplus", "normal");
上述のBase64エンコードは、以下の様に行います(base64コマンド)
$ base64 --input=mplus-TESTFLIGHT-063a/mplus-1p-black.ttf --output=mplus.txt
これで出力された結果を上述のmyFont変数で束縛すればいいんですが、自分がエディタで直接ペーストしようとするとPCが固まってしまって無理でした...base64に変換した文字列が長すぎるようです。 なので、一旦別のファイルで変数myFontに束縛し、それをインポートする様にしました。 以下がその手順です。
$ echo "export const myFont = \`" >> cms/static/js/mplus.js # 先ほどの、base64化したファイル。 $ cat mplus.txt >> cms/static/js/mplus.js $ echo "\`;" >> cms/static/js/mplus.js
export const myFont = `base64の文字列~`;
こんなイメージになります。
それでは、フォントを設定していきます。
import {myFont} from './mplus.js'; jQuery(function($){ const doc = new jspdf.jsPDF(); doc.addFileToVFS('mplus.ttf', myFont); doc.addFont('mplus.ttf', 'mplus', 'normal'); doc.setFont('mplus', 'normal'); // ↑で名付けた名前を設定する。 doc.text('Hello,世界!', 10, 10); doc.save('test.pdf'); });
このままでは動きません。mplus.jsをインポートできないからです。
インポートするには、type=module
属性を呼び元のjs(test.js)に付与すればOKです。
<script type='module' src="{% static 'js/test.js' %}"></script>
日本語をPDF出力できれば成功です。