はじめに
お返し申す!1 nikkieです。
HerokuでいくつかのDjangoアプリを動かしていましたが、この週末はお片付け。
Herokuのアプリを消す前に、DBに格納されたデータをエクスポートします(アプリ自体はもう使っていないのでデータを保管したらアプリは消します)。
11/26のPython mini Hack-a-thonで取り組みました2。
※筆者はDjangoを実務でバリバリ使っているわけではなく、今回の実装はDjangoアプリの練習(ごく小規模なアプリでうまくいった例)といった趣です。
このブログの情報を実務で利用する場合には、紹介している書籍やドキュメントまで確認されることを強くおすすめします。
目次
- はじめに
- 目次
- 背景:PyCon JPスタッフ活動で作ったDjangoアプリ
- Django REST Frameworkでデータを一覧するエンドポイントを生やす
- ページネーションの仕組み
- Web API以外のやり方:エクスポートコマンド追加
- 終わりに
背景:PyCon JPスタッフ活動で作ったDjangoアプリ
無料でアプリがデプロイできる(さらにDBも使える)Herokuには大変お世話になってきました。
そして、いよいよ無料枠の終了が迫っています(11/28に終了)。
Herokuにデプロイしたアプリには、過去のPyCon JPスタッフ活動で実装したDjangoアプリ3もあります。
PyCon JPはトークを公募していて、集まったプロポーザル(「私はこういうトークをしたい」という文書)をレビュアー(スタッフ & ボランティア)がレビューし、採択します4。
「レビュアーがプロポーザルをレビューするためのアプリ」を2020、2021で作りました5。
扱うデータ
- プロポーザル(2020、2021で使ったサービス sessionize6 から読み込み)
- レビューアプリでは、プロポーザルは読み取りだけです
- レビュー
- レビュアーが書き込みます(レビュアーは何件もレビューを書けるが、1つのレビューは1人のレビュアーだけに対応する)
- プロポーザルには何件レビューがあってもよいが、1つのレビューは1つのプロポーザルだけに対応する
- 1つのプロポーザルをN人以上で目を通すようにレビューを進めます
参考までにReview
モデルの定義7です。
class Review(models.Model): class ReviewScore(models.IntegerChoices): YES = 5, "YES😃" MAYBE = 3, "MAYBE😐" NO = 1, "NO😫" reviewer = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, verbose_name="レビュアー", ) proposal = models.ForeignKey( Proposal, on_delete=models.PROTECT, related_name="reviews", verbose_name="レビュー対象のプロポーザル", ) score = models.PositiveIntegerField("レビュースコア", choices=ReviewScore.choices) comment = models.TextField("レビューコメント(NOの場合は必須)", blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)
数十人のレビュアーで使い、データ量としてはプロポーザルが100件程度、レビューが500〜600件程度です。
Django REST Frameworkでデータを一覧するエンドポイントを生やす
データのエクスポートの方法はWeb API以外にもあると思いますが、今回はWeb APIを選びました。
これは「DRF(Django REST Framework)を触ってみたかった」という理由によります。
アプリ自体を別の環境に移したいわけではなく、人が読める形でデータをファイルに保存しさえすれば、今回のエクスポートは達成されます。
DRFは2021のアプリ開発で初めて触りましたが、akiyokoさんの『現場で使える Django REST Framework の教科書』(以降、この記事では「DRFの教科書」)にめちゃめちゃ助けていただきました。
日本語で読めてぱっと完全に理解できるリソースとして、大変貴重と思います。
レビュー一覧用のシリアライザ
エクスポートしたいデータ(HerokuのDBにだけ入っているデータ)はレビューだけです(プロポーザルはsessionizeに入っています)。
「誰がどのプロポーザルをこれこれの理由で何点とレビューした」という情報が取り出せれば、今回の目的には十分でしょう。
これをSerializer
として実装しました。
class ReviewSerializer(serializers.ModelSerializer): reviewer_name = serializers.ReadOnlyField(source="reviewer.username") proposal_id = serializers.ReadOnlyField(source="proposal.sessionize_id") class Meta: model = Review fields = [ "reviewer_name", "proposal_id", "score", "comment", "created_at", "updated_at", ]
実装する上では、『DRFの教科書』の11.7「関連先のモデルまで含めた情報を取得したい」が役立ちました。
ReadOnlyField
のsource
引数!
レビュー一覧用のビュー(ページネーション付き!)
レビューは一度に全件返すのではなく、ページネーションして返したいです。
『DRFの教科書』では、取得(一覧)APIは4.2、ページネーションは11.5で扱われています。
settings.py
による全エンドポイントへの設定ではなく、個別のエンドポイントに設定する方法が知りたかったので、教科書の案内に従ってDRFのドキュメントを参照しました。
今回はPageNumberPagination
を使います。
- 継承したクラスで、クラス変数を使ってページサイズを指定します
generics.ListAPIView
を継承したビューのクラスで、クラス変数pagination_class
に1のクラスを指定します- ビューのクラスにはあと2つクラス変数
queryset
とserializer_class
も指定します
class StandardResultsSetPagination(PageNumberPagination): page_size = 100 class ReviewListAPIView(generics.ListAPIView): queryset = Review.objects.select_related("reviewer", "proposal").order_by( "pk" ) serializer_class = ReviewSerializer pagination_class = StandardResultsSetPagination
これでページネーションされました!
『DRFの教科書』から離れたので実装しきれるか不安でしたが、DRFに用意されたクラスを使い、クラス変数を設定するだけでページネーションが実現できています!
Adminだけレビューを一覧できる
レビューアプリにはAdminがプロポーザルを登録できるエンドポイントがありました。
認証してトークンを取得し、それを使ってプロポーザルを登録します。
『DRFの教科書』7.4で解説されていて、djoserを使って実現しています。
この実装にならい、レビューの一覧エンドポイントも使えるのはAdminだけとしました。
class ReviewListAPIView(generics.ListAPIView): permission_classes = [IsAdminUser] # 追加 # 変更はないので省略
ページネーションの仕組み
私にとってDRFはブラックボックス(ラップされすぎていて自分のDjango力でデバッグできるか不安)だったので、ページネーションを最初はAPIView
で実装しようとしました。
If you're using a regular
APIView
, you'll need to call into the pagination API yourself to ensure you return a paginated response. See the source code for themixins.ListModelMixin
andgenerics.GenericAPIView
classes for an example. (上で紹介したDRFのドキュメントより)
mixins.ListModelMixin
の実装がこちら8。
class ListModelMixin: def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data)
filter_queryset
はReview
へのクエリセットをベタ書きし、get_serializer
は先に定義したReviewSerializer
を使えばよさそうです。
これを真似し、上記の点だけ変えてビュークラスのget
メソッドを実装すると、paginate_queryset
とget_paginated_response
メソッドがないことでAttributeError
となりました。
そこでgenerics.GenericAPIView
の実装を見ます9。
class GenericAPIView(views.APIView): pagination_class = api_settings.DEFAULT_PAGINATION_CLASS @property def paginator(self): if not hasattr(self, '_paginator'): if self.pagination_class is None: self._paginator = None else: self._paginator = self.pagination_class() return self._paginator def paginate_queryset(self, queryset): if self.paginator is None: return None return self.paginator.paginate_queryset(queryset, self.request, view=self) def get_paginated_response(self, data): assert self.paginator is not None return self.paginator.get_paginated_response(data)
paginator
プロパティでpagination_class
がインスタンス化されます。
今回の例では(上で定義した)StandardResultsSetPagination
をインスタンス化します。
ページネーション処理はStandardResultsSetPagination
インスタンスに任せる(paginate_queryset
やget_paginated_response
)という実装で、これは委譲していますね。
以下が当初の実装(APIView
を継承したビュークラス)です10。
class ReviewListAPIView(views.APIView): permission_classes = [IsAdminUser] pagination_class = StandardResultsSetPagination @property def paginator(self): if not hasattr(self, "_paginator"): self._paginator = self.pagination_class() return self._paginator def get(self, request, *args, **kwargs): reviews = Review.objects.select_related( "reviewer", "proposal" ).order_by("pk") page = self.paginator.paginate_queryset(reviews, request, view=self) if page is not None: serializer = ReviewSerializer(page, many=True) return self.paginator.get_paginated_response(serializer.data) serializer = ReviewSerializer(reviews, many=True) return Response(serializer.data)
この実装で動かしたあと、ListAPIView
の実装を見たら11、ListModelMixin
とGenericAPIView
から多重継承されていることが分かりました。
class ListAPIView(mixins.ListModelMixin, GenericAPIView):
これを見て「ListAPIView
を継承したら簡潔に書けるな」と腑に落ち、上で紹介した実装となりました。
Web API以外のやり方:エクスポートコマンド追加
python manage.py dump_reviews
のようなコマンドを追加してファイルに掃き出してエクスポートする方法も考えられます。
heroku run bash
12でアプリケーションに接続し、コマンドを実行させてファイルは作成できました。
ですが、Heroku側にできたファイルをローカルに取る方法が限られます。
Herokuにはgit push
でデプロイできるので、「Heroku側でコミットを作れるのではないか(それをpullしよう)」と考えました。
ところが、heroku run bash
してからgit
コマンドを叩くと、「カレントディレクトリはGitリポジトリではない」旨のエラーメッセージが表示されます(ファイルは同期していますが、Gitリポジトリではないようです)。
コミットを作ってpullする案はうまく動きませんでした13。
今回はエクスポートしたファイルが数百KBだったので、cat
でターミナルに書き出し、それをコピペして移しました(2020のアプリの方で実施)。
2通りやってみて、Web APIのやり方がしっくり来ています。
終わりに
Heroku無料枠終了に伴い、自作DjangoアプリにWeb APIの口を追加して、HerokuのDBのデータをエクスポートしたことを共有しました。
generics.ListAPIView
の継承により、いくつかのクラス変数を設定するだけでページネーション付きの一覧APIが実装できます!
DRFのソースコードを初めて読んでみましたが、「意外と読めるぞ」という感覚でした。
Djangoアプリと思えばいいんですね、怖がりすぎてました。
今回取り上げたアプリの実装はこちらにアーカイブしています。
なお、最近のプロポーザルシステムはpretalx14が主流と思われます(2022のEuroPythonやPyCon JPで使用)。
こちらもDjangoアプリみたいなので、ソース読んでみたいなと思っています。
最後に、Thank you, Heroku Free Plan❤️15
いままでクソお世話になりました。
ありがとうございました!
風邪引くなよ!!
#pyhack Herokuのアプリ、片付けた!
— nikkie にっきー 🎤10/1 XP祭り 10/14-15 PyCon JP (@ftnext) 2022年11月26日
これまで無料で使わせてくれてありがとうございました。
データをエクスポートする開発、頑張った! pic.twitter.com/sMxKuEmFUa
- ↩
- 参加者のsoogieさんはHerokuの機能を使ってPostgres DBのバックアップを取得されていました(スライド3)。Herokuの機能でもサポートしているんですね! ↩
- 過去に登壇もしています。 https://pycon.jp/2020/timetable/?id=203919↩
- 2022の例です。PyCon JP Blog: PyCon JP 2022 プロポーザルレビュアー募集のお知らせ↩
- 私のアツいスタッフ活動!技術で(も)支えたPyCon JP 2021 #pyconjp - nikkie-ftnextの日記 でも紹介した「プロポーザルレビューアプリ」です↩
- PyCon JP Blog: PyCon JP 2020を支えたツール sessionize について | Thank you, sessionize! Part 1/2↩
- https://github.com/pyconjp/pycon.jp.2021.review/blob/352ec3f2ac5d9473ed723882597287f23f4e79ab/reviewsite/review/models.py#L124-L147↩
- https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/mixins.py#L33-L46↩
- https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/generics.py#L165-L171↩
- https://github.com/pyconjp/pycon.jp.2021.review/commit/ec7999f001b9f6cde7ff3cfb428c637915b10c01↩
- https://github.com/encode/django-rest-framework/blob/3.14.0/rest_framework/generics.py#L193-L199↩
-
heroku run
コマンド https://devcenter.heroku.com/articles/heroku-cli-commands#heroku-run (記事を書いていて気付きましたが-r
指定は試せてはいないですねー)↩ -
また、
heroku run bash
でファイルを作った後、もう一度heroku run
すると、作られたファイルはありませんでした。heroku run
はgit push
した状態で始まるようです↩ - 私は「プレトークス」と呼んでいます。プロポーザル=トークの前(プレ)で、トークになるまで扱えるので洒落た名前だと思ってます。↩
- 新しい低コストプランはこちらにあるみたいです。Introducing Our New Low-Cost Plans | Heroku↩