需求背景
有时候为了配置的灵活性、应用未来的需求变化、控制单张表的字段数,避免字段过多,会把一些字段设置是Json格式。像下面这样:
这样的好处是,后面如果突然需要加多一个字段,就可以直接在加到这个json里面,既不用修改数据表,也不用修改程序,只要通知前端我在json里面加了一个字段就好了,对于未来字段设置不确定或者随时改变的情况是非常方便。
但有一个不好的地方就是,配置起来很麻烦,后台可能是给到一些不懂技术的人用,他们看到这么个东西就很一脸蒙蔽,甚至有时候技术自己可能出配着配着一不小心少了个引号,少个了逗号导致json格式错误,这就会导致程序解析json错误然后出现异常,影响了线上应用。所以这样的后台对于懂技术不懂技术的人来说都是一个挑战。
所以我就想着把这个json,拆解成一个表单的形式,像下面这样:
这样看起来是不是就直观很多,不管是谁来用这个后台都能很轻易的上手。
实现原理
原理也很简单。
加载时:
第1步:把 json 解析出来,把 json 里的每个字段当做是 model 的单个独立的字段去处理,赋值到对象上;
第2步:自定义一个表单 form , 把这些从 json 解析出来的字段也显示到管理后台上。
写入时:
第1步:接收自定义表单传过来的数据后进来验证(看需求要不要做一些数据验证)
第2步:把数据封装成 json 后再赋值到 model 上保存该 json 的字段,然后写入数据库保存。
实现思路就这么几步,很简单,只是编码过程中会存在一些细节的问题,下面通过编码来把上面的步骤走一遍。
编码实现
解析 json 并赋值到 model 对象,当成普通字段处理
要处理刚从数据库读出来的数据,只需要重写一下 Model 类的一个类方法from_db
下面这一段是源码的from_db方法
@classmethod
def from_db(cls, db, field_names, values):
if len(values) != len(cls._meta.concrete_fields):
values_iter = iter(values)
values = [
next(values_iter) if f.attname in field_names else DEFERRED
for f in cls._meta.concrete_fields
]
new = cls(*values)
new._state.adding = False
new._state.db = db
return new
那我们要做的就是在自己的 Model 中重写这个方法,然后先调用父类的from_db方法完成数据的加载
@classmethod
def from_db(cls, db, field_names, values):
new = super().from_db(db, field_names, values)
# todo 在这里添加上 json 解析逻辑
return new
下面是我实现的把 json 解析成 model 对象字段的代码,我封装成一个类,哪个 model 需要解析 json 的直接继承这个类就好了
class JsonTransToField(models.Model):
@staticmethod
def get_image_name(image_url):
"""
解析图片url,去掉url前缀,保留图片名称
如:http://test.xxx.com/media/test.png --> test.png
"""
image_url_prefix = f'{MEDIA_DOMAIN}/media/'
image_name = image_url.replace(image_url_prefix, '')
return image_name
@staticmethod
def dict_to_field(instance, data, prefix):
"""
prefix: 字段名前缀。
核心逻辑。把 json 解析到成 model 对象的普通字段。
如:{'test': '123'} --> instance.test = '123'
"""
for key, value in data.items():
# 递归解析 json 里的子 json
if isinstance(value, dict):
JsonTransToField.dict_to_field(instance, value, f'{prefix}___{key}')
continue
# 解析图片 url 成 ImageField。 v.find('alipay-xx.oss') 这段是因为图片都是存放在阿里去上,用来判断该字段是否图片字段
if isinstance(value, str) and value.find('alipay-xx.oss') > -1:
setattr(instance, f'{prefix}___{key}', ImageFieldFile(instance, models.ImageField(name=f'{prefix}___{key}'),
JsonTransToField.get_image_name(value)))
else:
setattr(instance, f'{prefix}___{key}', value)
@classmethod
def from_db(cls, db, field_names, values):
"""
捕获 json 解析异常,避免发生异常的时候会影响线上应用。
但管理后台该表单会没有数据,因为异常后没有把 json 里的数据解析到 instance上
"""
new = super().from_db(db, field_names, values)
try:
# 迭代instance的字段,如果数据是以 { 开头的说明是 json,进行解析操作
fields = new.__dict__.copy()
for field, value in fields.items():
if isinstance(value, str) and value.startswith('{'):
data = json.loads(value)
JsonTransToField.dict_to_field(new, data, field)
except Exception:
LogUtil.error("解析life json异常", traceback.format_exc())
return new
class Meta:
abstract = True
有 2 个地方说明一下:
- 代码中的
{prefix}___{key}
是设置到 instance 上的字段名,prefix 指的是 json 字段的字段名,后面接 3个下划线(因为2个下划线是外键的读取方法,避免冲突),然后接上 json 是的 key。如:params={"test":"123"} --> params___test -
setattr(instance, f'{prefix}___{key}', ImageFieldFile(instance, models.ImageField(name=f'{prefix}___{key}'), JsonTransToField.get_image_name(value)))
这段代码是为了把图片解析成 ImageFieldFile 类型,这样图片在后台显示的样式就是上图广告ICON
的样子,这样方便图片的上传设置,否则会以图片链接的形式显示在后台。
自定义 form 表单
上面我们已经把 json 解析成对象的普通字段了,现在要做的就是把这些字段像 model 定义好的字段一样显示在后台。
这里先假设数据表里有一个这样的 json 字段,方便理解:
params = {"task": "", "reward": "", "adv": {"icon": "", "title": "", "subtitle": ""}, "link": "", "link_type": "TO_APPLET_PAGE", "app_id": "", "path": ""}
我们定义一个 form 如下:
class CustomForm(ModelForm):
params___task = CharField(label='任务内容', max_length=20, required=False)
params___reward = CharField(label='任务奖励说明', max_length=20, required=False)
params___adv___icon = ImageField(label='广告ICON', required=False)
params___adv___title = CharField(label='任务标题', max_length=20, required=False)
params___adv___subtitle = CharField(label='任务副标题', max_length=20, required=False)
params___link = CharField(label='链接', max_length=150, required=False)
params___link_type = TypedChoiceField(label='链接类型', choices=[('TO_APPLET_PAGE', '小程序'), ('TO_H5', 'H5'), ('TO_APPLET_LOCAL_PAGE', '本地页面')], required=False)
params___app_id = CharField(label='appId', required=False)
params___path = CharField(label='path', required=False)
# 重写__init__方法。初始化 form 的时候,把 instance 中解析出来的 json 字段添加到 form 的 initial 中,否则后台不会显示出来
def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
initial=None, error_class=ErrorList, label_suffix=None,
empty_permitted=False, instance=None, use_required_attribute=None,
renderer=None):
super(CustomForm, self).__init__(data, files, auto_id, prefix,
initial, error_class, label_suffix,
empty_permitted, instance, use_required_attribute,
renderer)
if instance is not None:
for k, field in instance.__dict__.items():
if k.find('___') > - 1:
self.initial[k] = getattr(instance, k)
def save_image(self, instance, file, name):
"""
封装成ImageFieldFile,并保存上传的图片资源
"""
# 没有上传图片是 'None'
if str(file) == 'None':
return ''
image = models.ImageField(upload_to='you store folder/', name=name)
image_file = ImageFieldFile(instance, image, str(file))
image_file._file = file
# 发生更改的图片是 InMemoryUploadedFile 类型,这种情况才需要保存图片资源
if isinstance(file, InMemoryUploadedFile):
image_file.save(image_file.name, image_file.file, save=False)
return image_file
# 核心逻辑。提交表单,把自定义表单字段组装成json
def clean(self):
# data: 存放 dict 数据
data = {}
for key, value in self.fields.items():
if key.find('___') > - 1:
# 这里一个 for 循环是为了递归的封装 dict.
# 如果 params___adv___icon、params___title --> {"params": {"title": ""}, "adv": {"icon": ""}}
parents = key.split('___')
# d: 当前进行封装的 dict
d = {}
p = data
for parent in parents[:-1]:
d = p.setdefault(parent, {})
p = d
# 图片资源则保存图片或上传到云存储,然后包装成完整的访问 url
# parent[-1] 就是是里面一层的字段名。如:params___adv___icon --> ['params', 'adv', 'icon']
if isinstance(value, ImageField):
image_file = self.save_image(self.instance, self.cleaned_data.get(key), key)
if image_file:
image_file = f'{MEDIA_DOMAIN}/media/{image_file.name}'
d[parents[-1]] = image_file
else:
d[parents[-1]] = self.cleaned_data.get(key, '')
# 最后把封装好的 dict 转成 json 赋值到对应的字段
for k, v in data.items():
setattr(self.instance, k, json.dumps(v, ensure_ascii=False))
class Meta:
model = You Model
fields = '__all__'
这里主要是重写了 ModelForm 的 clean 方法,在里面将特定数据封装成 json 然后再保存到 model 中。代码功能都带注释了。
clean 里面的逻辑最好是自己跟着实现一遍,调试一下,直观的看封装过程会更容易理解,单看代码可能会有点难理解。
替换自带 form
最后一步,把上面写好的 form , 添加到admin中,还可以加一个tab,把自定义的表单单独出来一个 tab ,避免很多字段揉杂在一起显得乱。
class CustomAdmin(admin.ModelAdmin):
# ············
form = CustomForm
fieldsets = [
(None, {
'classes': ('suit-tab', 'suit-tab-general'),
'fields': [] # 这里放基础的字段
}),
('跳转链接配置', {
'classes': ('suit-tab', 'suit-tab-link'),
'fields': ['params___task', 'params___reward', 'params___adv___icon', 'params___adv___title',
'params___adv___subtitle', 'params___link', 'params___link_type', 'params___app_id', 'params___path'] # 这里放自定义表单的字段
})]
suit_form_tabs = [('general', '基础'), ('link', '跳转链接配置')]
# ············
最后效果图如下:
头部多了一个可切换的 tab,也可以设置多个tab,在 fieldsets 列表里面追加就行了。这个可以按自己的喜欢或者逻辑重新排版字段。
到此,我们的需求就完成啦,对比一开始的通过 json 字符串配置,难看、难配、易出错,表单的形式就更人性化、更容易用啦。