Django 系列 · 第四篇

如果我在自己的 app 里写一个叫 check 的命令,会发生什么?

manage.py 认识的命令,一部分来自 Django 自己,一部分来自各个 app 里的 management/commands/ 目录。这一篇本机真实验证了这套发现机制的两个反直觉规则:自己 app 里的命令真的能完全顶替掉 Django 自带的同名命令;两个 app 都定义了同名命令时,INSTALLED_APPS 里排得更靠前的那个赢。两条规则都从真实源码的同一段代码里推出来,也都用真实代码验证过。

完全顶替
本机真实实测:自己写的 check 命令,真的替换了 Django 自带的 check
更靠前的赢
本机真实实测:两个 app 都有 greet 命令,INSTALLED_APPS 里排前面的那个生效

真实源码:命令表是怎么拼出来的

核心是 get_commands() 这一个函数,逻辑比想象中简单。

django/core/management/__init__.py · django @ 6.0.7, L52 @functools.cache def get_commands(): commands = {name: "django.core" for name in find_commands(__path__[0])} if not settings.configured: return commands for app_config in reversed(apps.get_app_configs()): path = os.path.join(app_config.path, "management") commands.update({name: app_config.name for name in find_commands(path)}) return commands

三个关键点:①先用 Django 自带的命令把字典填满(全部标记成 "django.core");②倒序遍历 INSTALLED_APPS 里的每个 app;③每个 app 的命令用 dict.update() 覆盖式地写进同一个字典。find_commands() 本身也很直接——就是拿 pkgutil.iter_modules 扫一遍 management/commands/ 目录下有哪些模块文件。

django/core/management/__init__.py · django @ 6.0.7, L29 def find_commands(management_dir): command_dir = os.path.join(management_dir, "commands") return [name for _, name, is_pkg in pkgutil.iter_modules([command_dir]) if not is_pkg and not name.startswith("_")]

真实实测:自己的命令能顶替 Django 内置命令

既然 dict.update() 是覆盖式写入,而 Django 自己的命令是最先填进去的——那任何 app 定义一个同名命令,理论上都能把它覆盖掉。

真实实测 # 在自己的 app 里放一个 management/commands/check.py $ python3 -c " commands = get_commands() print(commands['check']) call_command('check') " myapp THIS IS MY OWN check COMMAND, NOT DJANGO'S BUILT-IN ONE

check 是 Django 内置的一个真实命令(项目健康检查),正常情况下 get_commands()['check'] 应该是 "django.core"——但自己 app 里一旦有同名的 check.py,整个内置命令就被真实顶替掉了,manage.py check 跑的是你自己写的逻辑,没有任何警告或报错。

真实实测:两个 app 都有同名命令,谁赢

reversed(apps.get_app_configs()) 这个"倒序"是关键——它决定了写在 INSTALLED_APPS 更前面的 app,反而是最后update() 覆盖的那个。

真实实测
# INSTALLED_APPS = [..., 'appA', 'appB']  -- appA 排前面
# appA 和 appB 都有 management/commands/greet.py,内容不同
commands = get_commands()
print('greet 归属:', commands['greet'])
call_command('greet')
greet 归属: appA hello from appA

appA 排在 INSTALLED_APPS 前面,却是最终生效的那个——因为倒序遍历时 appB 先被处理(先写入,后面会被覆盖),appA 后被处理(最后写入,不会再被覆盖)。这条规则和上一篇讲的中间件构造顺序是同一个套路:reversed() + "后处理的赢",只是这次赢的标准从"外层"换成了"列表里排得更靠前"。

真实实测:get_commands() 真的只算一次

@functools.cache 不是摆设——用 cache_info() 能直接看到命中情况。

真实实测
print(get_commands.cache_info())
c1 = get_commands()
print(get_commands.cache_info())
c2 = get_commands()
print(get_commands.cache_info())
print('c1 is c2:', c1 is c2)
CacheInfo(hits=0, misses=0, maxsize=None, currsize=0) CacheInfo(hits=0, misses=1, maxsize=None, currsize=1) CacheInfo(hits=1, misses=1, maxsize=None, currsize=1) c1 is c2: True

第一次调用是真实的一次"未命中"(扫描所有 app 的目录),第二次开始全是"命中"——拿到的还是同一个字典对象。这也是为什么在一个真实的 Django 进程运行期间,新增一个 app 或者新增一个命令文件不会被自动发现:这张表只在第一次被用到时建好,之后就固定了。

真实实测:命令本身怎么接参数

BaseCommand 的参数解析,底层就是标准库 argparse,没有另起炉灶。

真实实测
class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument('name', type=str)
        parser.add_argument('--shout', action='store_true')
        parser.add_argument('--times', type=int, default=1)
    def handle(self, *args, **options):
        if options['name'] == 'nobody':
            raise CommandError('cannot greet nobody')
        msg = f'Hello, {options[\"name\"]}!'
        if options['shout']: msg = msg.upper()
        for _ in range(options['times']): self.stdout.write(msg)
$ python3 manage.py greet Alice --shout --times 2 HELLO, ALICE! HELLO, ALICE! $ python3 manage.py greet nobody CommandError: cannot greet nobody

add_arguments(parser) 拿到的就是一个真实的 argparse.ArgumentParser,该怎么加参数就怎么加;CommandError 会被 run_from_argv() 专门捕获,干净地打印到 stderr 并以非零状态码退出,不会甩一大段 Python 堆栈——这也是为什么写管理命令时报错要用 CommandError 而不是普通 raise Exception

演示:命令表一步步被填满

复现"django.core 先垫底,appB 先写入,appA 后写入覆盖"的完整过程。

get_commands() 构建过程演示
命令名当前归属

数据来自独立验证过的 Python 模拟——用真实的"倒序 + 覆盖式写入"逻辑复现,最终每个命令名的归属都用独立回放算法核对过,和 appA 覆盖 appB 的真实实验结果一致。

参考与说明

  • 本文全部真实实验(自定义命令顶替 check 内置命令、appA/appB 同名命令的胜出规则、get_commands() 的缓存行为、argparse 参数解析与 CommandError)均在本机真实运行的 Django 6.0.7 上完成(真实创建临时 app 目录和命令文件,不是 mock),数据未做删改。
  • 源码引用(django/core/management/__init__.pyget_commands/find_commands/load_command_classdjango/core/management/base.pyBaseCommand.run_from_argv)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的命令表构建模型(先垫底、倒序遍历、覆盖式写入)由 Python 独立实现并交叉验证过——用真实实测过的三方(django.core、appA、appB)命令冲突场景做参数,最终每个命令名的归属都用独立回放算法核对过。
  • 没有涉及:异步命令(async def handle())的支持情况、BaseCommandstyle 属性(给终端输出上色)、call_command() 和真实命令行调用在参数校验时机上的细微差异、MIGRATION_MODULES 这类专门给某些命令用的额外配置。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电