用 django-extensions dumpscript 将数据库数据导出为可执行的 Python 脚本
用 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):
- 解析
appname标签,调用get_models()得到待导出模型列表; - 初始化一个
context字典——用于登记"模型实例 → Python 变量名"的映射,后续所有外键引用都查这张表; - 构造
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 从四个角度验证了命令的可用性:
test_runs:造一条Name记录后调用命令,断言 stdout 中出现该名字——验证命令能跑通;test_replaced_stdout/test_replaced_stderr:分别替换 stdout / stderr 后调用,断言脚本内容输出到 stdout、进度信息输出到 stderr,二者互不污染;test_valid_syntax:构造带外键、自引用 M2M、普通 M2M 的复杂数据(Name/Person/Note,见 tests/testapp/models.py),导出后用ast.parse()解析输出,保证生成物是语法合法的 Python;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 更顺手的原因。
延伸阅读(仓库内)
- 命令实现:dumpscript.py
- 端到端测试:test_dumpscript.py
- 测试模型(含自引用 M2M、M2M-through、FK 等复杂关系):tests/testapp/models.py
- 配套重放命令文档:docs/runscript.rst
- 命令信号钩子:django_extensions/management/signals.py