需求背景
有時候為了配置的靈活性、應(yīng)用未來的需求變化、控制單張表的字段數(shù),避免字段過多,會把一些字段設(shè)置是Json格式。像下面這樣:
這樣的好處是,后面如果突然需要加多一個字段,就可以直接在加到這個json里面,既不用修改數(shù)據(jù)表,也不用修改程序,只要通知前端我在json里面加了一個字段就好了,對于未來字段設(shè)置不確定或者隨時改變的情況是非常方便。
但有一個不好的地方就是,配置起來很麻煩,后臺可能是給到一些不懂技術(shù)的人用,他們看到這么個東西就很一臉蒙蔽,甚至有時候技術(shù)自己可能出配著配著一不小心少了個引號,少個了逗號導(dǎo)致json格式錯誤,這就會導(dǎo)致程序解析json錯誤然后出現(xiàn)異常,影響了線上應(yīng)用。所以這樣的后臺對于懂技術(shù)不懂技術(shù)的人來說都是一個挑戰(zhàn)。
所以我就想著把這個json,拆解成一個表單的形式,像下面這樣:
這樣看起來是不是就直觀很多,不管是誰來用這個后臺都能很輕易的上手。
實現(xiàn)原理
原理也很簡單。
加載時:
第1步:把 json 解析出來,把 json 里的每個字段當(dāng)做是 model 的單個獨立的字段去處理,賦值到對象上;
第2步:自定義一個表單 form , 把這些從 json 解析出來的字段也顯示到管理后臺上。
寫入時:
第1步:接收自定義表單傳過來的數(shù)據(jù)后進來驗證(看需求要不要做一些數(shù)據(jù)驗證)
第2步:把數(shù)據(jù)封裝成 json 后再賦值到 model 上保存該 json 的字段,然后寫入數(shù)據(jù)庫保存。
實現(xiàn)思路就這么幾步,很簡單,只是編碼過程中會存在一些細節(jié)的問題,下面通過編碼來把上面的步驟走一遍。
編碼實現(xiàn)
解析 json 并賦值到 model 對象,當(dāng)成普通字段處理
要處理剛從數(shù)據(jù)庫讀出來的數(shù)據(jù),只需要重寫一下 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 中重寫這個方法,然后先調(diào)用父類的from_db方法完成數(shù)據(jù)的加載
@classmethod
def from_db(cls, db, field_names, values):
new = super().from_db(db, field_names, values)
# todo 在這里添加上 json 解析邏輯
return new
下面是我實現(xiàn)的把 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 解析異常,避免發(fā)生異常的時候會影響線上應(yīng)用。
但管理后臺該表單會沒有數(shù)據(jù),因為異常后沒有把 json 里的數(shù)據(jù)解析到 instance上
"""
new = super().from_db(db, field_names, values)
try:
# 迭代instance的字段,如果數(shù)據(jù)是以 { 開頭的說明是 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}
是設(shè)置到 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
的樣子,這樣方便圖片的上傳設(shè)置,否則會以圖片鏈接的形式顯示在后臺。
自定義 form 表單
上面我們已經(jīng)把 json 解析成對象的普通字段了,現(xiàn)在要做的就是把這些字段像 model 定義好的字段一樣顯示在后臺。
這里先假設(shè)數(shù)據(jù)表里有一個這樣的 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='任務(wù)內(nèi)容', max_length=20, required=False)
params___reward = CharField(label='任務(wù)獎勵說明', max_length=20, required=False)
params___adv___icon = ImageField(label='廣告ICON', required=False)
params___adv___title = CharField(label='任務(wù)標題', max_length=20, required=False)
params___adv___subtitle = CharField(label='任務(wù)副標題', 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
# 發(fā)生更改的圖片是 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 數(shù)據(jù)
data = {}
for key, value in self.fields.items():
if key.find('___') > - 1:
# 這里一個 for 循環(huán)是為了遞歸的封裝 dict.
# 如果 params___adv___icon、params___title --> {"params": {"title": ""}, "adv": {"icon": ""}}
parents = key.split('___')
# d: 當(dāng)前進行封裝的 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 轉(zhuǎn)成 json 賦值到對應(yīng)的字段
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 方法,在里面將特定數(shù)據(jù)封裝成 json 然后再保存到 model 中。代碼功能都帶注釋了。
clean 里面的邏輯最好是自己跟著實現(xiàn)一遍,調(diào)試一下,直觀的看封裝過程會更容易理解,單看代碼可能會有點難理解。
替換自帶 form
最后一步,把上面寫好的 form , 添加到admin中,還可以加一個tab,把自定義的表單單獨出來一個 tab ,避免很多字段揉雜在一起顯得亂。
class CustomAdmin(admin.ModelAdmin):
# ············
form = CustomForm
fieldsets = [
(None, {
'classes': ('suit-tab', 'suit-tab-general'),
'fields': [] # 這里放基礎(chǔ)的字段
}),
('跳轉(zhuǎn)鏈接配置', {
'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', '基礎(chǔ)'), ('link', '跳轉(zhuǎn)鏈接配置')]
# ············
最后效果圖如下:
頭部多了一個可切換的 tab,也可以設(shè)置多個tab,在 fieldsets 列表里面追加就行了。這個可以按自己的喜歡或者邏輯重新排版字段。
到此,我們的需求就完成啦,對比一開始的通過 json 字符串配置,難看、難配、易出錯,表單的形式就更人性化、更容易用啦。