Django Mezzanine で、複数サイトを共存させ同時に運用する


はじめに

Mezzanine とは、Django (Python のWeb フレームワーク )上で動く、CMS フレームワークです。使い勝手としては Wordpress に似ています。

(英語) http://mezzanine.jupo.org/docs/index.html

最初から入っている機能としては

  • WYSIWYG エディタ ( Tiny MCE ) が入っているのでリッチテキストコンテンツが簡単に投稿できる
  • インラインページ編集 … 管理者ログインしていれば、公開済みページの各セクションに編集ボタンが出て編集可能
  • Twitter Bootstrap
  • Disqus連携
  • Gravatar連携
  • Twitter連携

などがあり、そもそも Django なのでテーマの切り替えが簡単だったり、プラグインの入れて機能を拡張したりなどが容易にできます。プラグイン( Django モジュール) の開発者は世界中に多く存在します。

その Mezzanine の機能の中で、少し難解ですが便利な機能に「Multi Tenancy」というものがあります。1つの Mezzanine の中で、複数サイトを運営できる機能です。

このブログも Mezzanine の マルチテナンシーで運営しています。例えば、株式会社TORICO のサイト , TORICOの技術部ブログまんが展公式サイトホーリンラブブックスブログ などは、一つの Gunicorn プロセスから出力しています。

公式サイトの説明は ここ(英語) にあるのですが、テンプレートの説明しかなくてわかりにくいですね。

このマルチテナンシーは Django 標準の Site フレームワークを使いやすく拡張した機能です。Site フレームワークについては Djangoドキュメントに説明があります。(英語)

一昔前はあまり詳しい説明が書いてなかった記憶があるのですが、執筆時現行バージョンの Django 1.9 のドキュメンテーションでは丁寧に書かれていますね。

とはいえ、Django は使っていても Site は使ったことがない方が多いと思います。私もそうで、Django を業務で使っていた今まで5年間、まったく活用したことがありませんでした。というか、使い方を知りませんでした。Mezzanine は、実用的な応用例として参考になると思います。

Mezzanineのセットアップ (省略)

Mezzanine の セットアップについてはこの記事では言及しません。

公式サイト部右ブロックに、「QUICK START」という欄があるので、これに沿ってコマンドを実行すればデモページはすぐ動きますし、Qiita に良記事があります。
Mezzanineをはじめよう - Qiita

nginx の設定

公開環境で動かすには、gunicorn (や uwsgi ) 上で django を起動し、nginx で80ポートを受けて gunicorn がリッスンしている 8000 ポートあたりに転送する感じでしょう。マルチテナンシー運用では、複数ドメインを宛先としたリクエストをすべて nginx から同じ gunicorn に転送します。

リクエストのドメイン名からサイトを判定する都合上、Web サーバから Django アプリケーションへのリクエストの際に、ユーザーエージェントがリクエストする HOST ヘッダを失うことなく Django アプリケーションに伝える必要があります。

Django でリクエストホストを取得する処理は django.http.request.get_host にあるのですが、

1. X_FORWARDED_HOST ヘッダがあればそれを使う
2. 無ければ、HOST ヘッダを使う

という処理になっていますので、nginx の設定は

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

このように書くと良いでしょう。

全体的な nginx の設定はこのようになります。

server {
    listen 80 default;
    listen 443 ssl;

    ssl_certificate ...........;
    ssl_certificate_key ...........;

    access_log  ...........;
    error_log  ...........;

    # ローカルの gunicorn にプロキシ
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
    # Django の static_url 転送
    location ~ ^/static(/(.+))?$ {
        alias ...........$1;
        break;
    }
    # Django の media_url 転送
    location ~ ^/media(/(.+))?$ {
        alias ...........$1;
        break;
    }
}

# ネイキッドドメインリダイレクト
server {
    server_name torico-corp.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.torico-corp.com$request_uri;
}
server {
    server_name manga10.com;
    listen 80;
    listen 443 ssl;
    ssl_certificate ...........;
    ssl_certificate_key ...........;
    return 301 http://www.manga10.com$request_uri;
}

サイト切り替えの仕組みの基礎

Mezzanine 内でサイトIDを判定するために、1リクエスト内で何度も評価されるメソッドがあります。

mezzanine.utils.sites の current_site_id がそれです。

読んでみると、

1. スレッドローカルに保存されている request をキャッシュとして使い、その中に site_id があるか調べる。あれば使う。
2. 無ければ、セッション変数に site_id があるか調べる。あれば使う。
3. Django のキャッシュフレームワークにあるか調べる。あれば使う。
4. 無ければ、リクエストのドメインから Site モデルを検索し、一致すればその id を使う。
※ Site モデルは、DBから取得した後はローカルメモリにキャッシュする。
5. 無ければ、環境変数 MEZZANINE_SITE_ID
6. 無ければ、Django settings の SITE_ID
site_id を見つけたら、適宜キャッシュに保存する。

という順番で評価していくのがわかると思います。このコードの評価順は重要なので、何度も読み返すことになるでしょう。

複数の Site の登録

まず、Mezzanine が動きましたら、管理サイト (デフォルトでは /admin/ ) にログインします。

管理サイトの左ナビ部に、「サイト」がありますのでそれをクリックし、サイトを追加します。


すると、管理サイト上部にサイト切り替えプルダウンが表示されます。

これを切り替えると、編集対象とするサイトを変えることができます。
これは、選択した site_id をセッション内に記録します。

サイトごとに内容を切り替えるモデルの仕組み

BlogPost モデル (ブログ投稿) を見るとわかりますが、Mezzanine に含まれるモデルはほぼすべて、SiteRelated モデルを継承しています。

SiteRelated ← Slugged ← Displayable ← BlogPost

この SiteRelated モデルは、独自のモデルマネージャ CurrentSiteManager を持っており、これを継承したモデル(例: BlogPost ) でクエリセットを操作すると、自動的に WHERE site_id = ? の SQL が付与されます。また、新規保存する際にも、自動的に site_id = ? の SQL を加えて保存します。

site_id は、上記の「mezzanine.utils.sites.current_site_id」から取得します。ここで、セッション変数やリクエストのドメイン名から mezzanine が自動的に site_id を判定してくれるので、意識せずとも複数サイトを切り替えることができるわけです。

そのため、自分で Django モデルを追加した場合は、この SiteRelated を継承して作る必要があります。
単純に SiteRelated を継承するだけで、管理サイトのサイト切り替えなどに対応したモデルを簡単に作ることができます。

サイト別に、固有設定をもっと定義したい

Site モデルには、ドメインと名前のフィールドしかありません。実際にはもっと多くのサイト別情報を管理する必要があるでしょう。

Site に紐付いた付加情報を扱うモデルも、Mezzanine に用意されています。

mezzanine.conf モジュールがそれで、Site 別に、キーバリューストアがあるイメージです。

ドキュメンテーションは ここ にあり、使用例は
mezzanine/accounts/defaults.py を見ると良いでしょう。

from mezzanine.conf import register_setting

register_setting(
    name="ACCOUNTS_MIN_PASSWORD_LENGTH",
    description=_("Minimum length for passwords"),
    editable=False,
    default=6,
)

このように、各 Django アプリの defaults.py で、register_setting を呼ぶことで キー (name)を設定します。

ここで定義したものは、Admin サイトの「設定」にフォームフィールドが表示されます。

オートグルーピング

Mezzanine の管理サイトの、「設定」を見てみると、デフォルトでは「コメント」「SSL」「Twitter」などがグループ化されています。

自分で register_setting した場合、そのままだと設定ページの「その他」(Miscellaneous) グループに含まれてしまいます。自分でグルーピングする手段がわかりにくいですが、グルーピングは name の値によって自動的に行われます。1つめの _ (アンダーバー) の前がグループ名となり、複数あるようなら自動的にグループ化されます。

例えば

from mezzanine.conf import register_setting

register_setting(
    name="TEMPLATE_SITES_HOGE",
    label="テンプレートの…",
    editable=True,
    default="",
)
register_setting(
    name="TEMPLATE_SITES_FUGA",
    label="テンプレートの…",
    editable=True,
    default="",
)

このように register_setting した場合、Admin サイト内では

この画像のように、Template という名前でグループされます。便利ですね。

mezzanine.conf.settings ( conf_settingsテーブル) の読み出し方

上記管理サイトで設定した値は、

from mezzanine.conf import settings
settings.TEMPLATE_SITES_HOGE

でアクセスできます。

django.conf.settings と名前が似ていて混乱しそうですが、mezzanine プロジェクトでは django.conf.settings は使う必要がありません。mezzanine.conf.settings は、django.conf.settings の完全な代わりとして使えます。

このコードの場合、まず TEMPLATE_SITES_HOGE キーが conf_settings テーブルにあるか(現在の site_id で絞り込んで)調べ、無ければ django.conf.settings の値を使う、という動作になります。

なお、DB の検索結果はメモリにキャッシュされますので、頻繁にアクセスする場合も安心です。

テンプレートから使う

mezzanine.conf.settings をテンプレートから使う場合は、コンテキストプロセッサ ( mezzanine.conf.context_processors ) が用意されていますので、特に設定不要で

{{ settings.XXXXXX }}

で設定値にアクセスできます。

ただし、このコンテキストプロセッサを読むと、「ホワイトリストで許可された設定値のみ読み出せる」という処理になっているのがわかると思います。

そのホワイトリストは、mezzanine/core/defaults.py を TEMPLATE_ACCESSIBLE_SETTINGS で検索すると出てきますが、

register_setting(
    name="TEMPLATE_ACCESSIBLE_SETTINGS",
    description=_("Sequence of setting names available within templates."),
    editable=False,
    default=(
        "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
        "ADMIN_MENU_COLLAPSED",
        "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
        "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
        "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
        "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
        "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
        "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
        "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    ),
)

こうなっています。

そのため、独自に register_settings した値は、このホワイトリストに追加しないとコンテクストプロセッサ経由でアクセスできません。

このホワイトリスト TEMPLATE_ACCESSIBLE_SETTINGS に値を追加するには、自分の App 内の defaults.py に、上記の register_setting をコピーして貼り付け、default= に値を追加しても良いですし、Django の settings.py に

TEMPLATE_ACCESSIBLE_SETTINGS = (
    "ACCOUNTS_APPROVAL_REQUIRED", "ACCOUNTS_VERIFICATION_REQUIRED",
    "ADMIN_MENU_COLLAPSED",
    "BITLY_ACCESS_TOKEN", "BLOG_USE_FEATURED_IMAGE",
    "COMMENTS_DISQUS_SHORTNAME", "COMMENTS_NUM_LATEST",
    "COMMENTS_DISQUS_API_PUBLIC_KEY", "COMMENTS_DISQUS_API_SECRET_KEY",
    "COMMENTS_USE_RATINGS", "DEV_SERVER", "FORMS_USE_HTML5",
    "GRAPPELLI_INSTALLED", "GOOGLE_ANALYTICS_ID", "JQUERY_FILENAME",
    "JQUERY_UI_FILENAME", "LOGIN_URL", "LOGOUT_URL", "SITE_TITLE",
    "SITE_TAGLINE", "USE_L10N", "USE_MODELTRANSLATION",
    # =================================
    # 追加分
    "TEMPLATE_SITES_HOGE", "TEMPLATE_SITES_FUGA
)

このように書いておいても読み出されます。

(デフォルト値をベタッと書いているのがメンテナンス性低そうで嫌ですが、良い方法が思いつきませんでした)

これで、テンプレートで

{{ settings.TEMPLATE_SITES_HOGE }}

ができるようになります。

全サイトを回すバッチの作り方

Sites に複数のサイトを登録してある環境で、Django のマネジメントコマンドでバッチを動作させたいとします。

普通にマネジメントコマンドを書いて実行すると、現在の site_id は settings.SITE_ID に書いた値のみ使われることになります。この場合、全サイトを対象にバッチを動作させることができません。

そこで、環境変数 MEZZANINE_SITE_ID を使います。

私は、このようなメソッドを用意してやっています。

def set_site_id(site_id):
    os.environ["MEZZANINE_SITE_ID"] = str(site_id)

def clear_current_request():
    """
    Mezzanine のスレッドローカルリクエストを消去
    """
    from mezzanine.core.request import _thread_local
    if hasattr(_thread_local, "request"):
        setattr(_thread_local, "request", None)

def all_sites():
    """
    全site を、有効なサイトを設定しながらループする
    :return:
    """
    for site in Site.object.all().order_by("id"):
        clear_current_request()
        set_site_id(site.id)
        yield site

この場合、使い方としては

for site in all_sites():
    1つのサイトに対するバッチ処理...

このようになり、ループ毎に環境変数の MEZZANINE_SITE_ID をセットしながら回すことができます。

スレッドローカルの "request" を消去するのは、ユニットテスト用です。

同じスレッドで過去にビュー関数を呼ぶテストが行われていた場合、スレッドローカルに request が残り誤動作をしてしまうので、消してます。

Admin 管理サイトから、特定のサイトだけ左メニューからリンクを消す

モデルを作り、admin.py を作るなどして管理サイトから編集できるようにすると、マルチテナンシーの場合はすべてのサイトで編集リンクが出ますが、特定のサイトのみ管理サイト項目を有効化(もしくは無効化) させたいケースはあると思います。

管理サイトに項目を表示するかどうかは、Admin モデルの in_menu メソッドで評価するようになってますので、例えば Contact というモデルを作り、ContactAdmin という Admin モデルをひも付けた場合は

from django.contrib import admin
from mezzanine.utils.sites import current_site_id

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return current_site_id() in [2, ]


admin.site.register(models.Contact, ContactAdmin)

(ちょっと上の例は泥臭い例ですが)このように、in_menu メソッドを作り、その中で current_site_id() を判断するとか、

register_setting(
    name="CONTACT_ENABLED",
    editable=True,
    default=True,
)

defaults.py なんかで、上記のように設定フラグを定義して、

from django.contrib import admin
from mezzanine.conf import settings

from . import models


class ContactAdmin(admin.ModelAdmin):

    def in_menu(self):
        return settings.CONTACT_ENABLED


admin.site.register(models.Contact, ContactAdmin)

その設定値を使って項目を出し分けることもできます。

mezzanine のビルトインモデルを Admin で出し分けるには

モンキーパッチでできます。他に良い方法ありそうですが知りません。

def patch_blog_post_admin():
    """
    BlogPost site_id = 7 では消す
    """
    from mezzanine.blog.admin import BlogPostAdmin

    def _in_menu(self):
        return _current_site_id() not in [7, ]

    BlogPostAdmin.in_menu = _in_menu

patch_blog_post_admin()

Admin 表示前に、上記関数が評価されていれば、site_id == 7 の場合、BlogPost が表示されなくなります。

現在の評価: 4

コメント

コメントを投稿
コメントするには TORICO-ID にログインしてください。
ログイン コメント利用規約