Djangoのテンプレートローダーを自作する

Djangoフレームワークのテンプレートエンジンは、『テンプレートをロードする処理』を自分で実装した処理に差し替えるための設定が用意されています。

django.template.loaders.base.Loader を継承したクラスを実装し、 settings.pyTEMPLATES を設定すると、処理を差し替えることができます。

※この記事の内容は、DjangoCongressJP 2023で発表した内容の一部をまとめなおしたものになります。

Djangoテンプレートエンジンを使いこなそう! - DjangoCongressJP 2023 @tokibito

アプリの用意

まずは、変更の比較のためにテンプレートを単純にレンダリングするビューを含むアプリを用意します。

python manage.py startapp myapp

myappのアプリをstartappで追加したら、 settings.pyINSTALLED_APPS に追加して、有効にしておきます。

myapp/views.py

from django.shortcuts import render

def my_view(request):
    return render(request, 'spam.html', {'name': 'egg'})

myapp/templates/spam.html

Hello {{ name }}

myproject/urls.py

from django.contrib import admin
from django.urls import path
from myapp import views

urlpatterns = [
    path('', views.my_view),
    path('admin/', admin.site.urls),
]

デフォルトのテンプレートローダーの動作を確認

この状態でrunserverを起動し、 http://127.0.0.1:8000/ にアクセスすると、myapp/templates/spam.html` が使われる表示となります。

テンプレートローダーを自作する

今回は、データベースからテンプレートデータをロードするようなローダーを作成してみます。

models.py

テンプレートデータを保存しておくモデルです。 name にテンプレート名(テンプレートパス)を入れておき、 source にテンプレートの内容を入れておく想定です。

from django.db import models

class Template(models.Model):
    name = models.CharField(max_length=255)
    source = models.TextField()

    def __str__(self):
        return f"{self.name}"

makemigrationsとmigrateでテーブルを作っておきます。

python manage.py makemigrations myapp
python manage.py migrate myapp

myapp/admin.py

Django adminからTemplateのモデルを編集できるように、admin.pyを書き換えます。

from django.contrib import admin
from .models import Template

admin.site.register(Template)

myapp/db_loader.py

テンプレートローダーのクラスです。

from django.template.loaders.base import Loader as BaseLoader
from django.template import Origin
from .models import Template

class DatabaseOrigin(Origin):
    def __init__(self, name, template_name=None, loader=None, object=None):
        super().__init__(name, template_name, loader)
        self.object = object


class Loader(BaseLoader):
    def get_contents(self, origin):
        return origin.object.source

    def get_template_sources(self, template_name):
        # データベースからテンプレートデータを取得
        template = Template.objects.filter(name=template_name).first()
        if not template:
            return []
        # テンプレートのデータをカスタムのOriginクラスでラップして返す。
        origin = DatabaseOrigin(
            name=template_name,
            template_name=template_name,
            loader=self,
            object=template)
        return [origin]

これでテンプレートローダーは完成です。

自作したテンプレートローダーを有効にする

作成したテンプレートローダーを有効にしてみます。 settings.pyTEMPLATES 設定を書き換えます。

APP_DIRS を削除(またはコメントアウト)して、 loaders キーを追加し、作成したテンプレートローダーと app_directories.Loader を設定します。

settings.py(抜粋)

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        # 'APP_DIRS': True,  # APP_DIRSは、loaders設定を入れる場合には、設定できない。
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            'loaders': [
                # 複数のテンプレートローダーが設定されている場合、前にあるほうから、順番にテンプレートを探す
                (
                    # 自作したテンプレートローダー
                    'myapp.db_loader.Loader',
                ),
                (
                    # Django adminなどが動作するように、app_directories.Loaderを有効にしておく
                    'django.template.loaders.app_directories.Loader',
                ),
            ]
        }
    },
]

動かしてみる

この状態で http://127.0.0.1:8000 にアクセスすると、データベースにはテンプレートデータがまだ無いので、ファイルから読み込まれたテンプレートが使用され、挙動に変化はありません。

Django adminからnameを spam.html としたテンプレートデータを追加します。

そうすると、今度は自作したテンプレートローダー側が読み取った、データベース上のデータがテンプレートとして使用されます。

データベース上のテンプレートデータを利用するテンプレートローダーを作成することができました。

サンプルコード一式

github.com

参考

DjangoCongressJP 2023に参加しました

2023/10/7にDjangoCongressJP 2023が開催され、参加してきました。

django.connpass.com

私は、登壇者として、また、スタッフとしての参加となりました。

登壇者として

Djangoテンプレートエンジンの使い方や、カスタマイズについて発表しました。

初心者~中級者向けの内容といった感じでした。

自分では、テンプレートは何度も検証したことがあり、コードも読んだことがあったので、再検証しつつ、整理してまとめて発表する、といった形になりました。

スタッフとしての作業があったり、仕事も忙しかったりして、スライドの完成がぎりぎりになってしまったので、発表練習はできなかったのですが、なんとかこなせました。

資料の最後のほうの、テンプレートローダーをカスタマイズする例は、知っておくと面白いかもしれません。 テンプレートを動的に変更可能な機能を作り込めます。

たとえば、CMSで静的ファイルを生成するタイプのものだとか、利用者が画面レイアウトをカスタマイズできる機能だとかに応用できます。

ユーザーに自由に使わせる場合は、セキュリティにも気をつける必要があるので、単純にそのまま使えるかはわからいですけども。

スタッフとして

スタッフとしては、例年通りCfPの選考、プログラムの検討などをやりつつ、今回は、会場を提供いただいたサイボウズさんのスタッフとのやりとりも担当していました。

また、先日から実施している Django利用状況アンケート2023 も、私が主に進めていたものなので、会場に出す案内の紙(アンケートのQRコードが書かれたやつ)を用意したりもしてました。

当日、会場ではタイムキーパーとしてRoom2のほうに主ににいました。

今年は、CfPの応募が少なかったのですが、来年はもっと増えてほしいです!(自分も毎年提出してます、来年も出す)

参加者として

Room2のほうにスタッフとしてほとんどの時間待機する必要があったので、Room2のほうの発表を聞いていた感じです。

みなさんしっかり資料を作り込んでいて、発表もうまかったので、聞きやすかったと思います。

今回はコロナ禍の制限もなく、会場で飲食を伴う懇親会(パーティ)がありました。

100人規模のパーティで、わいわいできてよかったです。

オフラインの会は、普段話さない人とも話せたりするので、やはり楽しい。このオフライン会の良さを復活させていきたいと思います。

また来年も参加できると良いなーと思います。

Django利用状況アンケート2023

Djangoの利用状況に関するアンケートを実施しています。 Djangoを使っている方は、ぜひご協力ください。 回答受付期間は、2023/10/2~2023/10/16。 forms.gle 集計結果はインターネット上で公開し、また今月のPyCon APAC 2023のポスターセッションでも発表予定です。

2023/10/31 追記

集計結果はこちら。

https://djangoproject.jp/whouses/pyconapac2023-django-ja.pdf

django-extensionsの runscript コマンドでスクリプトを実行する

django-extensionsには、スクリプトファイルを実行するための runscript というコマンドが用意されている。

このコマンドを使うと、Djangoのコンテキスト(つまり、Djangoのsettings.pyが適用された、 django.setup() を実行済みの状態)で、Pythonスクリプトを実行できる。

RunScript — django-extensions 3.2.3 documentation

試したバージョンは、Python 3.10、Django 4.2、django-extensions 3.2.3。

scriptsフォルダにスクリプトファイルを用意する

django-extensionsをセットアップ済みのプロジェクトで作業する。

Djangoのプロジェクトディレクトリに、 scripts という名前のフォルダを作成し、スクリプトファイルはその中に作成する。

scriptsフォルダ内のスクリプトPythonモジュールとして再利用しないのであれば、scriptsフォルダには __init__.py を作成しなくてもよい。

今回作成したのは、 scripts/foo.py というファイル。

ディレクトリ構造とファイルの配置は次の通り。

.
├── db.sqlite3
├── manage.py
├── myproject
│     ├── __init__.py
│     ├── asgi.py
│     ├── settings.py
│     ├── urls.py
│     └── wsgi.py
└── scripts
    └── foo.py

scripts/foo.py:

from django.contrib.auth.models import Permission

def run(*args):
    """fooという名前のスクリプト
    """
    print(f"args: {args}")
    # ORMも使える
    print(Permission.objects.count())

今回作成したファイルでは、Permissionモデルのレコード件数を取得して画面に出力している。

このコードは、DjangoのORMが使われる。

関数名は run としておく。runscriptコマンドから渡される引数は、可変長引数で受け取れる。

runscriptコマンドでスクリプトを実行する

django-extensionsをセットアップ済みのプロジェクトでは、manage.pyコマンドのサブコマンドとして runscript コマンドが使えるようになっている。

python manage.py runscript <script_name> のように、スクリプトファイル名の .py を除いた名前で実行できる。

$ python manage.py runscript foo --script-args hoge fuga
args: ('hoge', 'fuga')
24

スクリプトには、 --script-args オプションでコマンドから追加パラメータの文字列を渡すことができる。スペース区切りで複数の値を指定できる。

実行結果では、ORMが動作して、正常にcount()の結果が表示されていることがわかる。

まとめ

  • django-extensionsには、 runscript コマンドがある
  • runscript コマンドでは、Djangoのコンテキストでスクリプトファイルを実行できる
  • Djangoのプロジェクトフォルダに scripts/<script name>.py のようなファイルでスクリプトを用意する
  • python manage.py runscript <script name> --script-args param1 param2 .. のように実行する

DjangoCongress JP 2023のチケット販売が開始されました

DjangoCongress JP 2023 のチケット販売が開始されました。私はスタッフ兼登壇者として参加します。

django.connpass.com

パーティーでは色々な人と話せるのでおすすめです。久しぶりにパーティー有りの会なので楽しみです。

Djangoのcacheフレームワークで複数の値をまとめて取得、更新する

試したバージョンはPython3.10, Django 4.2.4, pymemcache 4.0.0

Djangoのキャッシュフレームワークを使うと、memcachedやRedisなどをキャッシュサーバーとして利用できる。 複数の値をキャッシュサーバーから読み書きする際、 cache.get() cache.set() を何度も呼ぶと、キャッシュサーバーとの通信のレイテンシーの影響を受けて遅くなりやすい。

同一ホスト内にキャッシュサーバーがいる場合はさほど問題にならず、クラウド上にデプロイしてサーバーを分けたときに問題に気づきやすい。

キャッシュ読み書きを複数回行う例

まず、 cache.set() cache.get() をキャッシュの読み書きする都度呼び出すコードを示す。

>>> from django.core.cache import cache
>>> cache.set("k1", "spam")
>>> cache.set("k2", "egg")
>>> cache.set("k3", "ham")
>>> cache.get("k1")
'spam'
>>> cache.get("k2")
'egg'
>>> cache.get("k3")
'ham'

このコードだと cache.set() を3回、 cache.get() を3回呼び出していて、たとえばキャッシュバックエンドがmemcachedの場合、サーバーとの通信が6回発生する。

仮に1回の通信に50ミリ秒かかるとすると、50ms * 6回=300ms程度かかってしまう。回数が増えると線形にパフォーマンスが劣化していく。

複数の値をまとめて読み書きする例

複数の値をまとめて読み書きする場合は、 cache.set_many()cache.get_many() を使う。

>>> from django.core.cache import cache
>>> cache.set_many({"k1": "spam", "k2": "egg", "k3": "ham"})
[]
>>> cache.get_many(["k1", "k2", "k3"])
{'k1': 'spam', 'k2': 'egg', 'k3': 'ham'}

このコードだと、memcachedの場合はサーバーとの通信は2回で済む。読み書きする値の数が増えても、レイテンシの影響は大きくならない。

複数の値の読み書きは、バックエンドクラスの実装にも依存するので、どの場合でもこのAPIを使うと速くなるとは言い切れないことに注意する。

キャッシュサーバーを使うときは、実装によっては「ローカルの開発時は速いのに本番環境ではレイテンシの影響で遅くなる」という可能性があることを考慮しておくとよい。

参考

ワイルドカードのサブドメイン名をnginxのルーティングに利用する

DNSのサービスで *.example.com のようにワイルドカードサブドメインを設定し、nginxでワイルドカード部分の文字列を見てルーティング先を切り換える、といった構成を行ったので、メモを残します。

ドメイン名の設定

ムームードメインムームDNSを利用していたので、CNAMEの設定でワイルドカード* の文字を使用できた。

*.test としたので、 *.test.example.com のようなワイルドカードの設定となり、 hoge.test.example.com などにマッチする。

nginxのVirtualHost設定

nginxでも server_name ディレクティブで、ワイルドカードの部分は * の文字を使用できる。

Module ngx_http_core_module

まずはすべてのホスト名にマッチする設定をする。

server {
  listen 80;
  root /home/vagrant/www;
  index index.html;
  server_name *.test.example.com;

  location / {
    try_files $uri $uri/ =404;
  }
}

example.com の部分は自分の利用したドメイン名に置き換える。 /home/vagrant/www には適当な動作確認用の index.html ファイルを作成しておく。

この設定で http://hoge.test.example.com/ にブラウザでアクセスすると、 index.html の内容が表示される。

正規表現サブドメインのホスト名部分を抜き出す

ドキュメントの通り、 server_name ディレクティブでは正規表現を使用できるので、 (?<server_host>.+) のようなパターンで一致した文字列を変数名で読めるようしてみる。

Module ngx_http_core_module

また、 if ディレクティブで抜き出したホスト名部分の文字列を使って分岐処理をしてみます。

server {
  listen 80;
  root /home/vagrant/www;
  index index.html;
  server_name ~^(?<server_host>.+).test.example.com$;

  location / {
    if ($server_host != "www") {
      add_header Content-Type text/plain;
      return 200 $server_host;
    }
    try_files $uri $uri/ =404;
  }
}

この設定では、 www.test.example.com 以外の場合は、ホスト名をレスポンスとして返しています。 www.test.example.com の場合は、 try_files ディレクティブにより、 index.html の内容が返されるようになります。

たとえば、 http://hoge.test.example.com/ にブラウザでアクセスした場合は、 hoge と表示されます。

ここまでできると、location以下でルーティングは自由にできるので、たとえばホスト名部分によって proxy_pass の先を変更したりもできます。

サブドメインごとに server ディレクティブを記述してルーティングすることもできますが、運用上この形のほうが嬉しい場合があったので、この形で今回はやってみました。

Let's Encryptでワイルドカード証明書を取得して設定する

ここまでの設定だとHTTPですが、Let's Encrypt(certbot)を使ってワイルドカード証明書を設定すると、HTTPSにすることもできます。

certbotコマンドで以下のようにワイルドカード証明書を取得します。

certbot certonly --server https://acme-v02.api.letsencrypt.org/directory --manual -d *.test.example.com --preferred-challenges dns

この場合、ドメインの所有確認のためにTXTレコードの追加が必要になります。画面に表示された指示にしたがって、TXTレコードをDNSに設定しておきます。

証明書を取得できたらnginxに設定します。HTTPの場合は強制でHTTPSにリダイレクトさせるような場合は以下のようになります。

server {
  listen 80;
  server_name *.test.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443;
  root /home/vagrant/www;
  index index.html;
  server_name ~^(?<server_host>.+).test.example.com$;

  ssl_certificate /etc/letsencrypt/live/test.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/test.example.com/privkey.pem;
  include /etc/letsencrypt/options-ssl-nginx.conf;
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

  location / {
    if ($server_host != "www") {
      add_header Content-Type text/plain;
      return 200 $server_host;
    }
    try_files $uri $uri/ =404;
  }
}

2023/12/26追記

ワイルドカード証明書を手動でコマンド実行して取得している場合、自動更新にならない。自動更新されるように追加で設定が必要

参考