用 django-extensions dumpscript 将数据库数据导出为可执行的 Python 脚本

原创2026-10-05 23:59:521,695 阅读
文章标签:后端开发工具

用 django-extensions dumpscript 将数据库数据导出为可执行的 Python 脚本

dumpscript 是 django-extensions 提供的一个 Django 管理命令,它把数据库中的已有数据逆向生成一段独立的、可读的 Python 脚本,运行该脚本即可在目标数据库上重建同样的对象。相比 dumpdata 导出的 JSON/XML fixture,脚本方式天然避开了主键与外键 ID 的绑定问题,也让批量造数、迁移数据、回放测试数据变得更加灵活可控。读完本文,你将掌握 dumpscript 的三种典型用法、生成脚本的内部结构、关键源码实现原理,以及如何配合 runscript 命令完成"导出 → 清库 → 重放"的完整数据迁移闭环。

本文以仓库 docs/dumpscript.rst 为骨架,结合命令源码 dumpscript.py 与测试用例 test_dumpscript.py 展开。

dumpscript 是什么:把数据导出变成"可读、可改、可重放"的脚本

dumpscript 的核心定位(见 docs/dumpscript.rst 的 synopsis)是:

Generates a standalone Python script that will repopulate the database using objects.

即:生成一段独立的 Python 脚本,用对象的方式重新填充数据库。与直接往数据库里灌数据、或使用 XML fixture 相比,它的最大优势是易于理解、更灵活。源码文件头的描述进一步补充了这一点(见 dumpscript.py 第 9–25 行):

  • 允许新的模型默认值生效,只迁移真正需要的数据;
  • 如果目标库 schema 新增了属性,该属性不会被填充(配合默认值即可平滑过渡);
  • 如果目标库 schema 删除了某个属性,它会被直接忽略,数据照常迁移;
  • 唯一可能出问题的情况是:出现了新的模型,且它是已有模型上必填的 ForeignKey——不过通过编辑生成的脚本很容易修复,因为所有外键查找都经由生成脚本里的 locate_object() 函数完成。

源码注释里还给出了一句朴素的总结:"If a new database schema has a NEW ATTRIBUTE, it is simply not populated (using a default value will make the transition smooth :)" —— 这正是 dumpscript 对"模型演进"最宽容的地方。

为什么不用 fixture:模型演进的"低摩擦"方案

传统 dumpdata 导出的 fixture 会把主键、外键 ID 原样保存,一旦目标库的模型结构变了(删列、加列、改主键),重放时往往报错或产生脏数据。而 dumpscript 生成的脚本:

  • 外键用 Python 变量天然衔接,不依赖数字 ID:脚本按依赖顺序实例化对象,把前一个对象的实例变量名直接赋给后一个对象的外键字段;
  • 新增/删除的列被自动忽略:只导出当前模型上仍然存在的字段值。

这样一来,"模型演进"带来的麻烦被降到最低——这对应原文档 Why? 一节总结的两大好处:

  • less drama with model evolution:外键无需 ID 即可自然处理,新列、删列都被忽略;
  • 可以编辑脚本生成成千上万条记录:用 for 循环、程序化生成的名字、Python 模块等技巧批量造数。

原文档给出了一个经典的批量造数示例(注意这是 Python 2 时代的写法,Python 3 下请改用 range() 和 f-string):

for i in xrange(2000):
    poll = Poll()
    poll.question = "Question #%d" % i
    poll.pub_date = date(2001, 01, 01) + timedelta(days=i)
    poll.save()

原文档同时提醒:真实的数据库通常更大、更复杂,所以一个实用工作流是——先用 admin 界面录入一些有代表性的数据,导出脚本后,再人工编辑脚本补充其余数据。脚本的每个对象都对应一段清晰、独立的赋值语句,编辑体验远好于在 JSON/XML 里手工增改记录。

功能特性一览(Features)

原文档列出了 dumpscript 支持的特性清单,以下逐条给出对应的源码实现位置,方便读者对照验证:

特性 说明 源码依据
ForeignKey 与 ManyToManyField 用 Python 变量关联 不使用对象 ID,直接引用前序实例的变量名 InstanceCode.instantiate() 与 get_many_to_many_lines()(dumpscript.py)
自引用 ForeignKey / M2M 字段 通过"两轮处理"解决:第一轮生成不了的引用留到第二轮再生成 ModelCode.get_lines() 中的二次遍历(dumpscript.py)
子类化模型(Sub-classed models) 父实例在"只有一个子类实例引用它"时被跳过,由子类实例代为创建 InstanceCode.skip() 基于 Django Collector 的判定(dumpscript.py)
ContentType 字段与通用关系 ContentType 不导出,用 ContentType.objects.get(app_label=..., model=...) 现场查回 get_attribute_value() 的特判分支(dumpscript.py)
递归引用 无法在导出集合内解析的对象,推迟到 importer.locate_object() 在目标库按主键查找 orm_item_locator()(dumpscript.py)
AutoField 默认被排除 主键交给数据库自增,脚本不写死 ID get_attribute_value() 中 skip_autofield 判断(dumpscript.py)
父模型仅在无子模型链接时导出 避免父/子实例重复创建 同 skip() 逻辑
可只导出单个模型 支持 appname.ModelName 点分写法 get_models()(dumpscript.py)

另外值得注意:get_models() 中有一个内置的排除名单 EXCLUDED_MODELS = (ContentType,)(dumpscript.py),ContentType 记录不会被导出,因为它们在目标库会自动重新生成。

快速上手:三种典型用法

原文档 How? 一节给出了完整的命令流程。

1. 导出整个 app 的所有模型

$ ./manage.py dumpscript appname > scripts/testdata.py

2. 只导出单个模型

$ ./manage.py dumpscript appname.ModelName > scripts/testdata.py

从源码看,appname 参数支持 nargs="+"(dumpscript.py),即可一次传入多个 app 标签或 app.模型 点分标签;get_models() 遇到带 . 的标签会拆成 app_label 和 model_name,调用 apps.get_model() 精确定位到单个模型。

3. 重置指定 app 并用保存的数据重放

原文档给出的完整闭环是:

$ ./manage.py reset appname
$ ./manage.py runscript testdata

并特别注明:runscript 要求 scripts 是一个 Python 包,因此需要创建该目录并在其中放一个 __init__.py 文件:

$ mkdir scripts
$ touch scripts/__init__.py

说明:reset 是 Django 早期版本的内置命令,用于清空指定 app 的数据表;新版 Django 已移除该命令,可改用 manage.py flush(清空整个库)或项目自定义的重置方案替代。

可选参数:--autofield

源码的 add_arguments() 定义了一个与 AutoField 相关的选项(dumpscript.py):

$ ./manage.py dumpscript appname --autofield

其实现细节值得留意:--autofield 使用 action="store_false" 写入 dest="skip_autofield",默认 skip_autofield=True(即默认排除 AutoField)。也就是说:

  • 默认行为:主键(pk)等 AutoField 不写入脚本,由目标库自动生成;
  • 加 --autofield 后:AutoField 被显式导出,脚本会带上主键值。

生成的脚本长什么样:文件头与 BasicImportHelper

命令执行后,脚本直接输出到 stdout(进度信息输出到 stderr),重定向 > 即可落盘。生成的脚本以一段内置文件头开头(Script.FILE_HEADER,见 dumpscript.py),大致结构如下:

#!/usr/bin/env python

# This file has been automatically generated.
# Instead of changing it, create a file called import_helper.py
# and put there a class called ImportHelper(object) in it.
#
# This class will be specially cast so that instead of extending object,
# it will actually extend the class BasicImportHelper()
#
# That means you just have to overload the methods you want to
# change, leaving the other ones intact.
#
# This file was generated with the following command:
# manage.py dumpscript appname
#
# to restore it, run
# manage.py runscript module_name.this_script_name

import os, sys
from django.db import transaction

class BasicImportHelper:

    def pre_import(self):
        pass

    @transaction.atomic
    def run_import(self, import_data):
        import_data()

    def post_import(self):
        pass

    def locate_similar(self, current_object, search_data):
        the_obj = current_object.__class__.objects.get(**search_data)
        return the_obj

    def locate_object(self, original_class, original_pk_name, the_class, pk_name, pk_value, obj_content):
        search_data = {pk_name: pk_value}
        the_obj = the_class.objects.get(**search_data)
        return the_obj

    def save_or_locate(self, the_obj):
        the_obj.save()
        return the_obj

def run():
    importer.pre_import()
    importer.run_import(import_data)
    importer.post_import()

文件头揭示了几个关键设计:

  • 运行入口是 run():依次调用 pre_import() → run_import(import_data) → post_import(),其中 run_import 被 @transaction.atomic 包裹,整个导入在单个事务内执行;
  • importer 的机制:脚本末尾先尝试 import import_helper,若存在自定义的 ImportHelper 类,则用 type("DynamicImportHelper", (import_helper.ImportHelper, BasicImportHelper), {})() 动态构造一个同时继承自定义类与 BasicImportHelper 的实例(dumpscript.py)。也就是说——你不用修改生成的脚本,只需在脚本同目录放一个 import_helper.py 定义 ImportHelper,即可覆写任意导入行为(比如改事务策略、定制 locate_object 的查找逻辑);
  • 依赖 python-dateutil:文件头中 import dateutil.parser,若未安装会打印 "Please install python-dateutil" 并以退出码 os.EX_USAGE 退出(dumpscript.py)。日期时间字段统一用 dateutil.parser.parse("ISO格式") 还原。

数据主体长什么样

文件头之后是每个模型的代码块。以仓库测试模型 Name(tests/testapp/models.py,app_label = "django_extensions")为例,导出一行 Name(name="Gabriel") 大致会生成:

    # django_extensions.testapp.models.Name
    from django_extensions.testapp.models import Name

    django_extensions_name_1 = Name()
    django_extensions_name_1.name = "Gabriel"
    django_extensions_name_1 = importer.save_or_locate(django_extensions_name_1)

注意变量名由 db_table + 序号 组成(InstanceCode.__init__ 中的 "%s_%s" % (self.instance._meta.db_table, id),见 dumpscript.py),而外键引用用的则是 模型名_主键值 这样的 context key。如果 Note 引用 Club(测试模型见 tests/testapp/models.py),脚本会生成类似:

    django_extensions_note_1 = Note()
    django_extensions_note_1.note = "Django Tips"
    django_extensions_note_1.club = django_extensions_club_1
    django_extensions_note_1 = importer.save_or_locate(django_extensions_note_1)

ManyToMany 关系则以 .add(...) 形式追加,例如 Person 的 notes 字段:

    django_extensions_person_1.notes.add(django_extensions_note_1, django_extensions_note_2)

源码剖析:dumpscript 如何把 ORM 数据变成 Python 代码

命令的 handle() 流程非常清晰(dumpscript.py):

  1. 解析 appname 标签,调用 get_models() 得到待导出模型列表;
  2. 初始化一个 context 字典——用于登记"模型实例 → Python 变量名"的映射,后续所有外键引用都查这张表;
  3. 构造 Script 对象并输出其字符串形式。

此外,handle() 用 @signalcommand 装饰(django_extensions/management/utils.py),执行前后会发出 pre_command / post_command 信号(定义见 django_extensions/management/signals.py),因此你可以挂接信号在导出前后做自定义处理。

依赖排序:尽量"一次成型"

Script._queue_models() 会对模型做拓扑式排序(dumpscript.py):借助 check_dependencies()(dumpscript.py),只有当某个模型的 ForeignKey / ManyToMany 目标要么已经在队列里、要么是自身、要么是 ContentType 时,才把它加入处理队列;否则放回队尾等待下一轮。

这个排序"不是必需的,但能让脚本更好看——更多实例能在第一轮就完整生成"(源码注释原文)。同时它内置了防死循环机制:MAX_CYCLES 轮内若待处理模型数不再减少,说明存在无法靠重排序解决的循环外键结构,此时把剩余模型强制并入队列,留待第二轮"强推"处理。

两轮生成:解决自引用与循环引用

ModelCode.get_lines() 采用两遍遍历(dumpscript.py):

  • 第一遍:为每个实例生成代码,遇到暂时解析不了的外键就把字段留在 waiting_list 中(抛 DoLater 异常跳过,见 dumpscript.py);
  • 第二遍:对所有仍有 waiting_list 的实例重新生成——这正是注释里说的 "After each instance has been processed, try again. This allows self referencing fields to work.",自引用字段因此得以解决。

对于仍残留的循环引用,Script.get_lines() 最后还有一轮 Re-processing(dumpscript.py),以 force=True 强制生成:无法在本脚本内解析的外键,会通过 orm_item_locator() 生成 importer.locate_object(...) 调用,在目标数据库上按主键现查现用。

字段值序列化规则

get_attribute_value()(dumpscript.py)是字段值到 Python 代码片段的转换中枢,按字段类型分派:

  • AutoField:默认直接跳过(SkipValue),交给数据库自增;
  • BooleanField:repr(bool(value)),因为 MySQL 等数据库可能把布尔存成 0/1;
  • FileField:repr(force_str(value)),因为文件存储重构后 repr() 不再直接返回路径;
  • ForeignKey:优先从 context 取变量名;指向 ContentType 的字段特判为 ContentType.objects.get(app_label=..., model=...);目标不在导出集合内或强制模式时生成 importer.locate_object(...) 延迟查找;否则抛 DoLater 留待下轮;
  • DateField / DateTimeField:dateutil.parser.parse("ISO格式"),其中时间值经过 timezone.make_aware 处理以保留时区信息;
  • 其他普通字段:直接 repr(value)。

orm_item_locator()(dumpscript.py)还会对未导出对象做一次"主键剥壳":若主键本身是另一个 ORM 对象(复合主键场景),会沿着主键链一路下钻到最终标量值,再把原始对象的干净字段字典一并传给 locate_object(),供目标库精确查找。

子类模型的跳过逻辑

InstanceCode.skip() 使用 Django 的 Collector 收集当前实例的关联对象(dumpscript.py):当且仅当该实例恰好被一个子类实例作为父模型引用时,判定为"会被子类创建",于是跳过它,并把它的 context 值记为 None——后续引用它的外键会抛 SkipValue 被静默跳过,避免重复创建。这就是原文档特性中"父模型仅在无其他子模型链接时导出"与"子类化模型"两项的底层实现。

命名冲突与注意事项(Caveats)

原文档在 Caveats 一节专门强调了一个易踩的坑:输出文件的命名不要与 import path 中的其他名字冲突。如果 app 名与脚本文件名相同,导入时解释器可能不是加载应用模块,而是错误地尝试从 dumpscript 文件本身加载模块,从而引发 ImportError。

# 错误:appname 与脚本名相同
$ ./manage.py dumpscript appname > dumps/appname.py

# 正确:加后缀区分
$ ./manage.py dumpscript appname > dumps/appname_all.py

# 正确:单模型导出同样加后缀
$ ./manage.py dumpscript appname.Somemodel > dumps/appname_somemodel.py

其他使用注意点汇总:

  • scripts 目录必须含 __init__.py 才能被 runscript 当作模块导入;
  • 运行生成的脚本前需安装 python-dateutil,否则脚本会自行退出并提示;
  • 事务行为:默认 run_import 整体包在一个 transaction.atomic 里,可通过 import_helper.py 覆写;
  • 生成脚本会原样记录当时的命令行(FILE_HEADER 中的 % " ".join(sys.argv)),便于日后追溯数据来源。

测试验证:仓库如何保证 dumpscript 可用

仓库测试 test_dumpscript.py 从四个角度验证了命令的可用性:

  1. test_runs:造一条 Name 记录后调用命令,断言 stdout 中出现该名字——验证命令能跑通;
  2. test_replaced_stdout / test_replaced_stderr:分别替换 stdout / stderr 后调用,断言脚本内容输出到 stdout、进度信息输出到 stderr,二者互不污染;
  3. test_valid_syntax:构造带外键、自引用 M2M、普通 M2M 的复杂数据(Name/Person/Note,见 tests/testapp/models.py),导出后用 ast.parse() 解析输出,保证生成物是语法合法的 Python;
  4. test_with_datetimefield:在 TIME_ZONE="Asia/Seoul" 设置下导出含 DateTimeField 的 Club/Note 数据,再把生成的脚本经 runscript 实际运行一遍,断言无异常——这是一次完整的"导出 → 重放"端到端回归测试。

这些测试同时印证了本文前述的多个事实:脚本走 stdout、进度走 stderr、日期字段使用 dateutil 解析、生成脚本可直接被 runscript 消费。

与 runscript 配合:完整的数据迁移/造数工作流

dumpscript 生成脚本的"标准消费者"正是 django-extensions 的 runscript 命令(文档见 docs/runscript.rst,源码见 runscript.py)。runscript 负责在 Django 上下文中执行任意 Python 脚本,其约定是:脚本必须实现 run() 函数,且脚本需放在 项目根/scripts/ 或 app/scripts/ 目录中(目录含 __init__.py 才能作为模块被导入)。

一个完整的工作流示例:

# 1. 导出数据到 scripts 目录(确保目录存在且含 __init__.py)
$ ./manage.py dumpscript appname > scripts/testdata.py

# 2. 清空目标库(新版 Django 可用 flush)
$ ./manage.py flush

# 3. 重放数据
$ ./manage.py runscript testdata

runscript 还支持 --script-args 向脚本传参、--chdir/--dir-policy 控制执行目录、-c/--continue-on-error 与 --traceback/--no-traceback 控制错误处理等能力(详见 docs/runscript.rst 与 runscript.py),完全可以把它接进 CI/CD 或自动化数据初始化流程。

小结

dumpscript 的价值在于把"数据导出"从机器可读(JSON/XML)升级为人可读、人可改、人可跑的 Python 代码:外键与多对多关系通过变量名自然衔接,自引用与循环引用靠两轮生成与 locate_object() 延迟查找兜底,模型演进中的增列/删列被天然忽略,父模型与子类模型的关系由 Collector 自动判定,AutoField 默认交给数据库自增。配合 runscript 与 import_helper.py 覆写钩子,你既可以"导出即重放"完成环境迁移,也可以在生成脚本上做二次编辑,批量构造数千条测试数据——这正是它在面对模型演进和复杂数据关系时比传统 fixture 更顺手的原因。

延伸阅读(仓库内)

登录后查看全文
django-extensions