Django Form 实践总结

1,299 阅读7分钟

在业务开发中,表单验证是较为重要的一环,经过验证后的数据才能存储进数据库。其中,表单验证不仅包括对恶意数据的检测,还包括了对业务逻辑上的一些检测。

之前笔者对Django Form的源码进行了一些浅显的分析,经过一段时间的使用以后,积累了一些使用的经验因此写下这篇博客来备忘。

关于Django Form源码分析的传送门如下:
Django Form源码分析之BaseForm验证逻辑
Django Form源码分析之Field验证逻辑

使用Django Form进行表单验证

一般来说,笔者在Django CBV中使用Django Form比较喜欢如下的代码流程:

class ExampleView(View):

    def get(self, request, *args, **)
        form = GetDataForm(data=request.GET)
        if form.is_valid():
            # run business logic code
        else:
            # form.error_detail()返回包含数据错误的字典
            return JsonResponse(form.error_detail())

    def post(self, request, *args, **)
        form = PostDataForm(data=request.POST)
        if form.is_valid():
            # run business logic code
        else:
            # form.error_detail()返回包含数据错误的字典,自行定义
            return JsonResponse(form.error_detail())

这个流程非常简单清晰,将根据不同HTTP方法传输过来的数据作为参数实例化一个DataForm,调用is_valid方法验证数据,如果数据验证准确,则可以运行业务逻辑,如果数据验证错误,需要以JSON格式返回错误信息给前端。

PS:笔者比较倾向于将业务逻辑绑定在具体的Form上面,作为一个Form的方法来实现,因为业务逻辑是跟传输过来的数据是紧耦合的,在业务逻辑中可以直接通过self.cleaned_data直接获得相关数据。在StackOverFlow还有关于业务逻辑代码安排在哪里的讨论,有兴趣的同学可以去看一下:Separation of business logic and data access in django

构造业务的BaseForm

在开发的时候,后端通常需要给出前端当表单验证错误的时候错误信息的数据格式。

举个例子,假设有一个用户表单对提交的两个数据进行验证:

# Form.py
from django import forms

class UserForm(forms.Form):

    name = forms.CharField()
    age = forms.IntegerField()

然后返回的JSON数据格式要求如下:

# Json Data Format
{
    "status": 2,
    "msg": "form validate error",
    "data": {
       'name': ['This field is required.'],
       'age': ['Enter a number.']
    }
}

很显然,Form.errors字典不能满足这种数据格式的需求。

所以需要开发者自行定义一个作为基类的BaseForm,并在BaseForm中定义error_detail方法,相当于对Form.erros的信息再进行一次封装并且按照规定格式返回。

所有的业务流程中定义的Form都应该继承于这一个BaseForm而并非django.forms.Form。

from django import forms

class BaseForm(forms.Form):

    def error_detail(self):
        error_response = {}

        error_response['status'] = 2
        error_response['msg'] = 'form validate error'

        error_response['data'] = self.errors

        return error_response

对Form.errors属性不熟悉的同学可以参考Django官方文档或者笔者之前写的对BaseForm的源码分析。

>>> from User.forms import UserForm
>>> not_valid_data = dict(name='more than 5 characters', age='')
>>> form = UserForm(data=not_valid_data)
>>> form.is_valid()
False
>>> response = form.error_detail()
>>> response
{'msg': 'form validate error', 'status': 1001, 'data': {'name': ['Ensure this value has at most 5 characters (it has 22).'], 'age': ['This field is required.']}}

数据的层次验证

Django Form提供了许多钩子供开发者做不同层次的验证,在Form.full_clean()方法中主要包括了三个层次的验证,并且这三个验证依次进行:

  • Form._clean_fields() 字段层次验证
  • Form._clean_form() 表单层次验证
  • Form._post_clean() ModelForm的额外验证

字段层次验证

字段层次的验证包括四个验证(前三个步骤在Field.clean中调用,最后一个步骤在Form._clean_fields里面调用),这四个步骤也是依次进行,但是一旦其中一个步骤抛出ValidationError异常,步骤终止,并被Form._clean_fields()捕捉该异常:

  • Field.to_python() 转换数据为Python对象
  • Field.validate() 检验数据是否为空值,除非允许该数据为空值
  • Field.run_validators() 运行该Field上被设置的validators,Django提供的扩展field大多在这里添加验证逻辑
  • Form.clean_{field_name} Django提供的钩子供开发者自行定义字段上的逻辑验证,比如业务逻辑

表单层次验证

表单层次的验证通常用于需要多个字段关联的数据验证(比如业务逻辑),实现方式非常简单,直接重写父类的Form.clean()方法即可。

在进行表单层次验证之前,已经经过了单个字段层次的验证,但是如果字段层次验证出现了错误,并不会阻止Form.clean()的执行。(有兴趣的同学看一下Form.full_clean方法的源码就知道了)

一般来说,如果字段层次验证错误,表单层次的验证应该不再执行,所以这里需要开发者留心一下。

举个简单的例子,仍以UserForm为例,假设如果用户的名字是young,则他的年龄不得超过20岁(谜之业务需求。。。)

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _


class UserForm(BaseForm):

    name = forms.CharField()
    age = forms.IntegerField()

    def clean(self):
        # 通过errors属性判定字段层次是否有错误,如果有则跳过表单层次验证
        if not self.errors:
            name = self.cleaned_data.get('name')
            age = self.cleaned_data.get('age')

            # 进行多个字段关联的数据验证
            if name == 'young' and age > 20:
                raise ValidationError(_("you are too old."), code='old')

        return self.cleaned_data

面向开发和面向用户的错误信息

当数据验证错误之后,后端需要返回一定格式的错误信息,这些错误信息一部分是直接向用户展示,一部分是向前端展示方便调试开发。

假设一个应用场景,修改一个课程的Model实例,用户提交必要的字段经过表单验证以后存入Model中:

# models.py

from django.db import models

# Create your models here.

NAME_LENGTH_LIMIT = 50

INTRO_LENGTH_LIMIT = 200


class Courses(models.Model):

    name = models.CharField(max_length=NAME_LENGTH_LIMIT)
    intro = models.CharField(max_length=INTRO_LENGTH_LIMIT)

前端需要发送三个字段来修改一个Courses的Model实例,分别是课程的id,课程的名字,课程的介绍。笔者根据这三个字段给出检验数据的Form的实现。

# constants.py

SUCCESS = 0

FIELD_FORMAT_ERROR = 1

COURSES_NOT_EXIST = 2

error_message = {

    0: "操作成功",

    1: 'Field格式错误',

    2: '课程不存在',

}


# forms.py
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

from Courses.models import Courses
from Courses.models import INTRO_LENGTH_LIMIT, NAME_LENGTH_LIMIT
from Courses.constants import error_message
from Courses.constants import COURSES_NOT_EXIST, FIELD_FORMAT_ERROR

class CoursesInfoChangeForm(BaseForm):

    courses_id = forms.IntegerField()
    name = forms.CharField(max_length=NAME_LENGTH_LIMIT, error_messages={'max_length': '名字长度不得超过50字'})
    intro = forms.CharField(max_length=INTRO_LENGTH_LIMIT, error_messages={'max_length': '介绍长度不得超过200字'})

    def clean_courses_id(self):
        # field validation of business logic
        courses_id = self.cleaned_data.get('courses_id')
        try:
            Courses.objects.get(pk=courses_id)
        except Courses.DoesNotExist:
            raise ValidationError(_(error_message[COURSES_NOT_EXIST]), code=COURSES_NOT_EXIST)

        return courses_id

    def clean(self):
        if not self.errors:
            # Do your form validation of business logic
            pass

        return self.cleaned_data
  • courses_id字段上的数据验证可以视为是业务逻辑上的验证,如果在clean_courses_id中发生了ValidationError可以直接把信息展示给用户。
  • name,intro字段上的数据验证,比如超过最大长度,这些错误信息应该作为前端调试和开发使用,不能直接展示给用户。

为了满足上面的需求,需要通过构造BaseForm来自定义返回的Json数据格式,规定Json格式如下:

# Json Data Error Example
{
    "status": 1,
    "msg": "field格式错误",
    'data': {
         'intro': ['介绍长度不得超过200字'], 
         'name': ['名字长度不得超过50字']
}

# Json Data Success Example
{
    "status": 0,
    "msg": "操作成功",
    "data": {}
}
  • 当status为1时表示Field格式错误,此时后端在data字段中给出字段的错误信息供前端调试和开发。
  • 当status不为1时,则表示业务逻辑上的验证(比如要修改的课程的id不存在),此时前端直接抓去msg信息展示给用户。

BaseForm源码实现如下:

# forms.py

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

from Courses.models import Courses
from Courses.models import INTRO_LENGTH_LIMIT, NAME_LENGTH_LIMIT
from Courses.constants import error_message
from Courses.constants import COURSES_NOT_EXIST, FIELD_FORMAT_ERROR


class BaseForm(forms.Form):

    def error_detail(self):
        error_response = {}

        # only return the first validation error
        validation_error_list = list(self.errors.as_data().values())[0]
        validation_error = validation_error_list[0]

        # if error is not about business logic, show it to the frontend
        # else show it to the user
        if validation_error.code not in error_message.keys():
            error_response['status'] = FIELD_FORMAT_ERROR
            error_response['msg'] = error_message[FIELD_FORMAT_ERROR]
            error_response['data'] = self.errors
        else:
            error_response['status'] = validation_error.code
            error_response['msg'] = error_message[validation_error.code]
            error_response['data'] = {}

        return error_response

在constants.py中建立一个含有所有表示业务逻辑状态码的字典,通过对一个ValidationError实例中的code属性是否包含在字典中进行判断,该错误信息应该展示给用户还是展示给前端。(只需要返回第一个错误即可)

代码测试结果:

# tests.py

def generate_string(length):
    s = ''
    for i in range(length):
        s += 'a'

    return s

# field error: name length overflow
not_valid_data1 = {
    'courses_id': 1,
    'name': generate_string(51),
    'intro': generate_string(201)
}


# business logic: courses_id not exist
not_valid_data2 = {
    'courses_id': 2,
    'name': 'less than 50 characters',
    'intro': 'less than 200 characters'
}


# Django manage.py shell
>>> from Courses.models import Courses
>>> Courses.objects.create(name='courses1', intro='courses1 introduction')
<Courses: Courses object>
>>> from Courses.forms import CoursesInfoChangeForm
>>> from Courses.tests import not_valid_data1, not_valid_data2
>>> first_form = CoursesInfoChangeForm(data=not_valid_data1)
>>> first_form.is_valid()
False
>>> first_form.error_detail()
{'msg': 'Field格式错误', 'status': 1, 'data': {'intro': ['介绍长度不得超过200字'], 'name': ['名字长度不得超过50字']}}
>>> second_form = CoursesInfoChangeForm(data=not_valid_data2)
>>> second_form.is_valid()
False
>>> second_form.error_detail()
{'msg': '课程不存在', 'status': 2, 'data': {}}

github传送门:github.com/9394974/lea…

以上是笔者使用Django Form的一点小小经验,有不足之处敬请指教。