qtatsuの週報

初心者ですわぁ

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するとわかりますが、_idvKouLXYBabhEaLLCx13Lとなります。

もちろん、その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で行います)。

注意: 開発エンドポイントの料金

料金 - AWS Glue | AWS

開発エンドポイントはオプションで、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の実行環境はCloud9です。

ローカルでAWS CLIコマンドを打っても、同じようにできるはずです(未確認)

開発エンドポイント/SageMaker Notebookの環境構築手順

事前準備

  • AWS CLIが使える環境を用意すること(お勧めはCloud9)。
  • AWS CLIを実行するロール or ユーザ(グループ)に以下のポリシーをアタッチしておくこと。
    1. IAMFullAccess ※ これは最強なので、使用後は外しておくことをお勧めします。
    2. AmazonS3FullAccess
    3. AWSGlueServiceRole
    4. AmazonSageMakerFullAccess

IAMロールの作成

必要なIAMリソースを作成していきます。AWS Glueの公式ドキュメントにも詳しく書かれていますが少々記述が不親切なところがあるので、その都度読み替えて適応します。 本手順では、以下のリソースが必要になります。

  1. 開発エンドポイント用のロール
    • ETLスクリプトを実行するジョブに付加するロールと同じ権限を与えます。今回はAWS managed policyを利用してGlueへの基本的な操作権限を与え、加えてS3バケットを新規に作成 & そのバケットへのアクセス権限を追加します。
    • デフォルトでは、AWSGlueServiceRoleから始まる名前のロールにする必要があります。
    • 公式ドキュメント
  2. SageMaker Notebook用のロール
    • SageMakerの実体はEC2インスタンスです。ここから、開発エンドポイントにアクセスして使います。
    • デフォルトではAWSGlueServiceSageMakerNotebookRoleから始まる名前のロールにする必要があります。
    • 公式ドキュメント

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 or 1.0しか選択できない。
    • 実際には、現在は最新の2.0でjobを作ることが望ましい。


  • --arguments: (e.g.) 'GLUE_PYTHON_VERSION=3'
    • --glue-versionを1.0にした場合、Pythonのバージョンを2 or 3で選択できる。
    • json形式で記載することもできる('{"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からスクリプトを確認可能)

f:id:Qtatsu:20201129180234p:plain
SageMaker Notebookインスタンス作成


以下の様に設定し、ページ下のCreate Notebookを押せば完了です。 もし10分ほどしてインスタンスの状態がFailとなってしまう場合は、SageMaker Notebookに付加したIAMロールの権限を間違えている可能性が高いです。その場合、「Create an IAM role」を選択すると自動で正しいロールを作成してくれます。

f:id:Qtatsu:20201129180335p:plain
SageMaker Notebookインスタンス作成2

立ち上がるのには結構時間がかかります。下手すると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

f:id:Qtatsu:20201129180945p:plain
スクリプト実行1

pysparkを選び、ノートを作成します。

f:id:Qtatsu:20201129181025p:plain
スクリプト実行2

sparkと打ち込み、実行すれば準備はOKです。

f:id:Qtatsu:20201129181241p:plain
スクリプト実行3

上記スクショのように、ETLスクリプトと同じようにコードが実行できます。クローラを実行してGlueのデータベースを作っていれば、from_catalogメソッドを使ってS3バケットからの読み込みもできます。 AWSのリソース(S3など)にETLスクリプトと同じようにアクセスできるため、コードのデバッグに集中できます。

なお、sc = SparkContext()は実行するとエラーになります。最初のsparkコマンドですでにscは作成済みだからです。

作成したリソースの後始末

作成したリソースを削除していきます。

SageMaker Notebook

インスタンスを停止しておきます。(停止すれば課金されません。コードを残しておけるので、stopにしておきます) 開発エンドポイントを削除しても、同じ名前の開発エンドポイントを再度作成すれば、SageMaker Notebookのインスタンスを再度立ち上げることができます。

不要ならデリートしてもOKです。

f:id:Qtatsu:20201129181512p:plain
SageMakerインスタンスの停止

開発エンドポイント

絶対に消し忘れてはいけません!!課金されてしまいます。

# 一覧確認
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
# 中身を空にしておきます。
aws s3 rm --recursive "s3://tmp-test-${AWS_ID}"

# バケットを削除します。
aws s3 rb "s3://tmp-test-${AWS_ID}"

参考にさせていただいたリソースへのリンク


DynamoDBから1MB以上のデータを取得する(boto3)

前書き

DynamoDBに蓄積したデータをグラフ表示するシステムを作成していました。開発の中途では問題なく動作し、テストコードもパスしていたのですが、ある時表示されるはずのグラフの一部が描画されていないことに気がつきました。 その原因はページネーションでした。DynamoDBのscanやqueryメソッドでは、指定したデータの全てを 1回のリクエストで取得できるとは限りません。 1MBまでしか取得できず、残りのデータを取得するにはもう一度DynamoDBにリクエストする必要があります。

本記事では、boto3からscanおよびqueryを実行してDynamoDBから1MBを越えるデータを取得する方法と注意点についてまとめます。

結論

  • DynamoDBからのレスポンスに含まれるLastEvaluatedKeyを調べ、whileループ中でデータ取得のリクエストを投げ続けることで取得します。
  • コードだけ知りたい方はこちら

参考リンク

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を動かす - qtatsuの週報

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バイト程度は増えると考え、

 1Mb \fallingdotseq  1,000,000 B
だから、
 1,000,000 \div 20,000 = 50
なので、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メソッドも同様です。

例えばこのLimitScanIndexForwardを組み合わせ、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がすでに入っているなら、他のツールも候補に上がってきそう。

参考リンク

jq

jsonlint - npm

distributions/README.md at master · nodesource/distributions · GitHub

jsonlint - npm

環境

Linuxvagrantでこちらの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ツールと異なり、デフォルトの挙動で、不完全なjsonを解釈してプリントする。

例: 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"
}

pythonjson.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が使える状態なら使用を検討すると思います。
  • 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を指定しますが、そこでlinebarを指定すれば形式を変更できます。

Chart.jsで利用するViewは

  1. BaseLineCharView
  2. 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 jQuery | 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タグで読み込みをするための形式とのことです。公式ドキュメントの以下の部分に書いてありました。

Home - Documentation

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');
});

f:id:Qtatsu:20200815195344p:plain
日本語が文字化け
うまくいってますが、日本語だけ文字化けしています

フォントの確認と指定方法

jspdfを読み込んでいるhtmlファイルをブラウザで開き、ブラウザのdeveloperツールを開きます。 chrome/macos環境なら、option + command + i で開きます。

Consoleタブを開き、以下の様にしてフォント一覧を確認します。使うのは、以下の公式ドキュメントリンクにあるgetFontListメソッドです。

jsPDF - Documentation

const jspdfObject = jspdf.jsPDF();
jspdfObject.getFontList();

f:id:Qtatsu:20200815195329p:plain
フォント一覧

上記の出力結果を参照し、パラメータをセットします。Keyがフォント名、valueとなっているlistが設定可能なスタイルです。 フォントの指定は、以下の様にsetFontメソッドによって行います。

jsPDF - Documentation

jQuery(function($){
    const doc = new jspdf.jsPDF();
    doc.setFont('Helvetica', 'Bold');  // フォントを適応
    doc.text('Hello,世界!', 10, 10);
    doc.save('test.pdf');
});

f:id:Qtatsu:20200815195310p:plain
Helvetica(Bold)適応成功

文字化けはしていますが(Helveticaは日本語を扱えないフォントなので)、フォントが適応されていることがわかります。

日本語フォントmplusの追加

Home - Documentation

こちらのUse of Unicode Characters / UTF-8:という項目に従い、2種類の方法を試します。

mplusのダウンロード

自分がやってみたところ、jsPDFは日本語フォントに関して、受け付けないものもある様でした。(Myricaはダメでした) mplusは有効な日本語フォントでしたので、そちらを使って解説します。

M+ FONTS | JAPANESE

上のサイトからダウンロードします。指示に従い、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ツールのコンソールから確認します。

f:id:Qtatsu:20200815195242p:plain
mplusが追加されていることを確認

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');
});

f:id:Qtatsu:20200815195205p:plain
日本語出力に成功

元気よく、世界にご挨拶できました。

日本語フォントの設定方法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出力できれば成功です。