干掉你的硬编码表单:JSON 驱动的表单生成如何改变了我构建 Django 应用的方式
如果你 Django 应用中的每一个表单——创建、编辑、批量更新、模态框、内联——都被定义为一个 Python 字典而非一个类,会怎样?两个生产系统教会我:表单是数据,不是代码,而这一区别是变革性的。
硬编码表单之痛
每个 Django 开发者都熟悉这套仪式。你定义一个模型。然后写一个 ModelForm。然后调整控件。然后加上一份 crispy 布局。然后客户说“加个字段”,你就得动四个文件。然后他们说“把那个字段做成条件显示”,你就得加 JavaScript。然后他们说“同一个表单,但要放进模态框里”,你就得复制一份模板。
把这一切乘以 20 个模型,你就在维护一支小小的表单类大军,每一个都略有不同,每一个都写死了,每一个都在悄悄地与它所代表的模型脱节。
我在两个截然不同的项目上撞上了这堵墙——一个是工业制造质量控制系统,一个是金融交易后台——两次都得出了相同的解决方案:别再写表单类,改为写表单配置。
核心思想:表单即数据
一个 Django 表单字段其实只是一组属性的集合:一个名称、一个类型、一个 widget、一个标签、它是否必填、它有哪些选项。这个描述中没有任何东西要求必须有一个 Python 类定义。它是数据,而数据应该存放在数据结构里。
这个洞见很简单:把字段定义为字典,将它们存储在一个 schema 中,并让单一的通用表单类在运行时根据该 schema 自行构建。
# 与其这样写:
class InspectionForm(forms.Form):
diameter = forms.FloatField(
label="Inner diameter",
widget=forms.NumberInput(attrs={"class": "form-control"}),
)
status = forms.ChoiceField(
choices=[("pass", "Pass"), ("fail", "Fail")],
widget=forms.Select(attrs={"class": "form-select"}),
)
# 不如这样写:
INSPECTION_SCHEMA = {
"fields": [
{
"name": "diameter",
"type": "FloatField",
"widget": "NumberInput",
"label": "Inner diameter",
"widget_attrs": {"class": "form-control"},
},
{
"name": "status",
"type": "ChoiceField",
"widget": "Select",
"label": "Status",
"choices": [("pass", "Pass"), ("fail", "Fail")],
"widget_attrs": {"class": "form-select"},
},
]
}
DynamicForm 引擎
整个系统建立在单个类之上。它将字符串标识符映射到 Django 的字段类和 widget 类,然后遍历 schema 来构建自身:
class DynamicForm(forms.Form):
FIELD_MAP = {
"CharField": forms.CharField,
"ChoiceField": forms.ChoiceField,
"FloatField": forms.FloatField,
"IntegerField": forms.IntegerField,
"BooleanField": forms.BooleanField,
"DateField": forms.DateField,
"DecimalField": forms.DecimalField,
"EmailField": forms.EmailField,
# ... every Django field type
}
WIDGET_MAP = {
"TextInput": forms.TextInput,
"Select": forms.Select,
"NumberInput": forms.NumberInput,
"Textarea": forms.Textarea,
"CheckboxInput": forms.CheckboxInput,
"DateInput": forms.DateInput,
# ... every Django widget type
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_cfg in self.scheme["fields"]:
field_class = self.FIELD_MAP[field_cfg["type"]]
widget_class = self.WIDGET_MAP[field_cfg["widget"]]
self.fields[field_cfg["name"]] = field_class(
label=field_cfg["label"],
widget=widget_class(
attrs=field_cfg.get("widget_attrs", {})
),
required=field_cfg.get("required", True),
choices=field_cfg.get("choices", []),
)
那就是整个引擎。每一种 Django 字段类型、每一种 widget 类型,都通过字符串查找来映射。一个新表单只是一个新字典,而不是一个新类。
案例研究一:制造业质量控制
第一个项目是为一家工业制造商打造的生产数据系统。质检员遵循标准作业程序(SOP),需要测量特定尺寸、检查表面处理,并为每个生产阶段记录合格/不合格的结果。
挑战在于:20 多种不同的检验表单,每一种都有针对工件类型和 SOP 步骤的独特字段。端板立车检验测量的东西与双极板铣削检验完全不同。
如果采用硬编码表单,那就是 20 多个表单类,每个都有自己的字段定义、自己的校验逻辑、自己的模板怪癖。而采用 blueprint 方式,则是 20 多个字典加一个表单类。
# 每一项 QC 检测都只是一个 schema 定义
QC_ENDPLATE_LATHE_SCHEMA = {
"fields": [
{
"name": "inner_diameter",
"widget": "TextInput",
"label": _("Inner diameter: 1846mm"),
},
{
"name": "outer_diameter",
"widget": "TextInput",
"label": _("Outer diameter: 1876-1876.1mm"),
},
{
"name": "surface_qualified",
"type": "ChoiceField",
"widget": "Select",
"label": _("Surface qualification"),
"choices": [
("qualified", _("Qualified")),
("unqualified", _("Unqualified")),
],
},
]
}
# 表单类只需一行
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
scheme = QC_ENDPLATE_LATHE_SCHEMA
采集到的数据以 JSON 形式存储在 JSONField 中,同时保留字段标签和字段值。这意味着检验记录是自描述的——你无需知道它由哪个 schema 生成,就能直接读懂它。
# 数据以自描述的 JSON 形式存储
{
"inner_diameter": {
"label": "Inner diameter: 1846mm",
"value": "1846.02"
},
"surface_qualified": {
"label": "Surface qualification",
"value": "qualified"
}
}
案例研究二:金融交易后台
第二个项目把这个模式推进得更远。一个加密货币交易后台需要为账户、佣金、风险画像、合规状态、资费方案等提供表单——每一种都有不同的字段、不同的权限、不同的提交方式(创建用 POST,批量更新用 PATCH),以及不同的渲染上下文(整页、模态框、行内)。
在这里,蓝图从字段定义成长为了一份完整的表单规格说明:
ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
"name": "account_create",
"title": "Create Account",
"column_count": 2,
"modal_size": "xl",
"method": "POST",
"api_config": "account:accounts_v1",
"success_message": "Account created successfully",
"fields": [
{
"name": "currency",
"type": "ChoiceField",
"label": "Currency",
"permission": "ACCOUNT_CURRENCY_UPDATE",
"api_choices": "common:unified_search",
"api_choices_args": ["assets-v1"],
"api_choices_autocomplete": True,
"row_class": "col-md-6",
},
{
"name": "commission_rate",
"type": "FloatField",
"label": "Commission Rate (%)",
"permission": "ACCOUNT_COMMISSION_UPDATE",
"widget_attrs": {"step": "0.01", "min": "0"},
"row_class": "col-md-6",
},
],
}
注意这一个字典里编码了哪些东西:HTTP 方法、API 端点、模态框尺寸、哪些字段需要哪些权限、从何处加载下拉选项、Bootstrap 网格布局,以及成功提示消息。这是一整套表单体验,纯粹以数据的形式呈现。
基于权限的字段可见性
这个金融平台增加了一项强大的功能:逐字段的权限控制。blueprint 中的每个字段都可以声明一个权限字符串。表单渲染器会检查当前用户的权限,并优雅降级:
class FormRenderer:
def _apply_field_permissions(self, fields, user):
user_perms = get_user_section_permissions(user)
for field in fields:
required_perm = field.get("permission")
if required_perm and required_perm not in user_perms:
field.setdefault("widget_attrs", {})
field["widget_attrs"]["disabled"] = True
field["widget_attrs"]["readonly"] = True
没有 ACCOUNT_COMMISSION_UPDATE 权限的用户仍能看到佣金费率字段——但它是只读的,且以灰色显示。不需要单独的模板,不需要 {% if perms.x %} 代码块,也不需要条件渲染逻辑。蓝图声明规则,渲染器负责执行。
通过 API 加载选项与自动补全
对于「Qualified/Unqualified」这类下拉框,静态选项列表足够用,但货币选择器需要从一个实时 API 拉取数据。蓝图通过两个属性来处理这一点:
api_choices- 一个返回选项的 Django URL nameapi_choices_autocomplete- 启用带服务端搜索的 Select2
表单引擎会解析这个 URL name,在渲染时获取选项,并在配置了的情况下接入 Select2 自动补全。蓝图的编写者从不写 JavaScript——他们只是声明数据存放在哪里。
为什么这比硬编码表单更好
在用这个模式构建了两个大型应用之后,我可以准确地表述它给你带来了什么:
- 添加一个表单是在添加数据,而不是代码。一个新的 QC 检查或一种新的账户类型就是一个新字典。没有新类、没有新模板、没有新 URL。系统会发现并渲染它。
- 更改一个表单是在更改一个地方。字段标签错了?改字典。需要一个新的下拉选项?更新 choices 列表。需要让某个字段变为可选?设置
"required": False。一个文件,一处更改,搞定。 - 表单变得可移植。一个蓝图只是一个字典。你可以将它序列化为 JSON、存储在数据库中、从配置文件加载,或通过 API 发送。表单引擎不在乎这个 schema 来自哪里。
- 一致性是自动的。每个表单都经过同一个渲染管线。Widget 属性、CSS 类、布局行为——它们全都由引擎统一标准化,而不是在各个表单类之间复制粘贴。
- 自描述的数据存储。当表单数据以带有标签和值的 JSON 形式保存时,存储的记录无需参考 schema 即可为人类所读。报告和导出变得轻而易举。
- 验证 mixin 干净地组合。动态表单仍然是一个 Django 表单。你可以为特定领域的验证(工件存在性检查、权限验证)添加 mixin,而无需触碰 schema。
何时该动用这个模式
动态表单并非总是正确的选择。对于一个只有两个字段的登录表单,ModelForm 更简单也更清晰。但在以下情况下,这一模式会大放异彩:
- 你有许多相似的表单,但字段集各不相同(质检检查、入职步骤、调查问卷页)
- 表单需要无需改动代码即可配置——由管理员、客户或外部系统来配置
- 你需要在众多表单上实现由权限驱动的字段控制,而又不必重复编写模板逻辑
- 表单数据需要以自描述的 JSON 形式存储,而不是存进固定的数据库列
- 你正在构建一个多租户平台,每个租户都需要略有不同的表单
前期成本是一个 DynamicForm 类(约 50 行)以及定义 schema 而非表单类的自律。回报则是一个这样的系统:添加第 21 个表单和添加第 2 个表单所花的力气一样多。
一个硬编码的表单是当下需求的一张快照。一份表单 schema 则是一份会随之演进的契约。写下这份契约吧。