📍

Django4でデフォルトのホスト/ポートを環境変数で指定する

2024/05/25に公開

TL;DR

  • HTTPの場合
    • django.core.management.commands.runserver.Commandを継承したクラスをカスタムコマンドとして使用する
  • HTTPS(SSL)の場合
    • django-sslserverを用いてsslserver.management.commands.runsslserver.Commandを継承したクラスをカスタムコマンドとして使用する
  • add_arguments()をオーバーライドし、super().add_arguments(parser)実行後parser._get_positional_actions()からAction.destaddrportActionに対し、Action.defaultに環境変数から取得したホスト:ポートを設定する

背景

  • 前提
    • Django開発:未経験

Djangoで開発サーバーを起動する場合、既定値はdjango.core.management.commands.runserver.Commanddefault_addr(IPv4)、default_addr_ipv6(IPv6)、default_portでホスト/ポートが設定される。

django/core/management/commands/runserver.py
    default_addr = "127.0.0.1"
    default_addr_ipv6 = "::1"
    default_port = "8000"
    protocol = "http"
    server_cls = WSGIServer

一方で起動コマンドをpython manage.py runserver ホスト:ポート、あるいはSSL化するならdjango-sslserverpython manage.py runsslserver ホスト:ポートとすればホスト/ポートを変更することができる。

しかしながら、VSCodeで起動設定にコマンドを記述する等ソースコード管理対象に起動コマンドが記述されている場合、毎回ローカル環境に合わせて記述を変える必要があり、誤って自環境に合わせた変更をコミットしてしまう恐れがある。

これを防ぐために開発環境要因で実コードに変更を入れることは方針として正しくはないが、Flask等の別フレームワークであればサーバー起動メソッドの引数で指定するため環境変数から設定することができるので、これと同様な指定方法を取りたい。

だがDjangoで環境変数からホスト/ポートを設定する方法を見つけることができなかったため、実コードに新規のカスタムコマンドを追加してそこからサーバーを起動するというアプローチをとる。

カスタムコマンドの実装

基本的には以下の通り。

command
mkdir -p customCommand/management/commands
touch customCommand/management/__init__.py
touch customCommand/management/commands/__init__.py

環境変数名DJANGO_DEFAULT_ADDRでデフォルトのアドレス、DJANGO_DEFAULT_PORTでデフォルトのポートを指定する。

customCommand/management/commands/custom_run.py
import os

from django.core.management.base import CommandParser
from django.core.management.commands.runserver import Command as RunServer


class Command(RunServer):
    ARG_ADDRPORT_DEST: str = "addrport"
    ENV_DEFAULT_ADDR: str = "DJANGO_DEFAULT_ADDR"
    ENV_DEFAULT_PORT: str = "DJANGO_DEFAULT_PORT"

    def add_arguments(self, parser: CommandParser) -> None:
        super().add_arguments(parser)
        default_host: str = os.environ.get(self.ENV_DEFAULT_ADDR, "")
        default_port: str = os.environ.get(self.ENV_DEFAULT_PORT, self.default_port)

        for action in parser._get_positional_actions():
            if action.dest == self.ARG_ADDRPORT_DEST:
                action.default = (
                    default_host + f":{default_port}"
                    if default_host != ""
                    else default_port
                )

django-sslserverでSSLにしたい場合、from django.core.management.commands.runserver import Command as RunServerの部分を以下のように変更する。

from sslserver.management.commands.runsslserver import Command as RunServer
config/settings.py
INSTALLED_APPS = [
    "customCommand",
    ・・・

開発サーバー起動

command
export DJANGO_DEFAULT_ADDR=0.0.0.0
export DJANGO_DEFAULT_PORT=8888
python manage.py custom_run
System check identified no issues (0 silenced).
*** **, **** - **:**:**
Django version 4.2, using settings 'config.settings'
Starting development server at http://0.0.0.0:8888/
Quit the server with CTRL-BREAK.

VSCodeで起動する場合

通常のlaunch.jsonで環境変数を読み込ませればよい。直書きしても良いが恩恵は得られないので、外部ファイルから読み込ませる。

.django.env
DJANGO_DEFAULT_ADDR=0.0.0.0
DJANGO_DEFAULT_PORT=8888
launch.json
    "configurations": [
        {
            "name": "Run django dev server",
            "type": "debugpy",
            "request": "launch",
            "args": ["custom_run"],
            "django": true,
            "autoStartBrowser": false,
            "program": "${workspaceFolder}\\manage.py",
            "envFile": "${workspaceFolder}/.django.env"
        },

詳細

引数で指定する開発サーバーのホスト/ポートは、実質argparseモジュールのArgumentParserの位置引数で処理されている。
具体的にはdjango.core.management.commands.runserver.Commandadd_arguments()メソッドで、メンバ名としてはaddrportで処理されている。

django/core/management/commands/runserver.py
    def add_arguments(self, parser):
        parser.add_argument(
            "addrport", nargs="?", help="Optional port number, or ipaddr:port"
        )
        parser.add_argument(
            "--ipv6",
            "-6",
            action="store_true",
            dest="use_ipv6",
            help="Tells Django to use an IPv6 address.",
        )

この引数parserは起動時にはdjango.core.management.base.BaseCommand.create_parser()の呼び出しによりdjango.core.management.base.CommandParserクラスが来るが、このクラスはArgumentParserを継承している。

django/core/management/base.py
    def create_parser(self, prog_name, subcommand, **kwargs):
        """
        Create and return the ``ArgumentParser`` which will be used to
        parse the arguments to this command.
        """
        kwargs.setdefault("formatter_class", DjangoHelpFormatter)
        parser = CommandParser(
            prog="%s %s" % (os.path.basename(prog_name), subcommand),
            description=self.help or None,
            missing_args_message=getattr(self, "missing_args_message", None),
            called_from_command_line=getattr(self, "_called_from_command_line", None),
            **kwargs,
        )
        ・・・
        self.add_arguments(parser)
django/core/management/base.py
class CommandParser(ArgumentParser):
    """
    Customized ArgumentParser class to improve some error messages and prevent
    SystemExit in several occasions, as SystemExit is unacceptable when a
    command is called programmatically.
    """

一方で、ArgumentParserで一度追加した引数は後から削除することを想定しておらず、一応_remove_action()という内部メソッドが用意されているがこれだけでは完全に削除することができず、同一の引数に対して後から自前でadd_argument()を用いて追加することは難しかった。

そこで、方針としては先にadd_arguments()を実行した後、既に追加した引数に対して生成されたargparse.Actionクラスのインスタンスに対して、メンバdefaultを直接書き換えることでデフォルト値を自前で設定するように対応したのがcustom_run.pyでの内容である。

なお、環境変数で指定がされなかった場合はdjango.core.management.commands.runserver.Commandのデフォルト値を使用するようにしている。

ただし、ホストについてはIPv4/IPv6を共存させることができない(どちらか一方をのアドレスを採用すると--ipv6の有無によりCommandError: "0.0.0.0" is not a valid IPv6 address.などと出る)起動できなくなるので、既定値を空文字として環境変数があった場合のみホスト:ポートの書式にし、ない場合はポートのみの書式にしている。

また、custom_run.pyではArgumentParser._get_positional_actions()という内部メソッドを用いているため、このアプローチ自体は今後の標準モジュールの仕様変更次第では使えなくなるのであまり推奨はできない。

参考

  • argparse

Discussion