一个基于Python装饰器的用户输入验证设计方案
情景
最近初学Python, 语法大概熟悉了之后就开始拿web.py做点小东西,web.py非常轻量,用起来感觉很舒服。但不过无论什么语言或者框架,web开发中有一个最大烦人之处就是表单验证,web.py提供了web.form来进行表单验证的统一处理,这个东西虽然用起来很简单,但是感觉还是不太合心意,首先这套验证机制跟web.py框架耦合的程度太高,而自己的架构是这样的,业务逻辑跟web逻辑完全分离,web仅仅是交互形式的一种,即使添加客户端C/S形式的服务或者是向开发者提供API,业务逻辑也是完全可用,不需要修改,这样对用户输入的验证是属于业务逻辑这一块,不应该跟web表单耦合在一起;另外感觉web.py这套东西还是有些简单,只支持每个表单的正则验证和最后表单提交的整体验证,而很多时候可能需要对用户进行丰富的错误提示,比如针对用户名的错误会具体到是不能为空还是长度错误或者格式错误等, 这个用web.py的form验证就感觉很别扭了。于是就决定自己设计一个用户输入的验证方案。
设计
web项目的开发多数都是遵循这么一个结构的设计,即DAO->Service->Controller->View, 按我前面说的,对用户的输入验证应是发生在Service这一层上,这一层的设计是接受用户输入的参数,然后进行验证处理,再进行业务相关的计算,最后输入结果。每个Service接口都应该返回一个结果,我一般都会把这个结果的内容抽象成一个一致类型的对象:
class Result(object): u''' 操作结果抽象 ''' def __init__(self, code, value=None): self.code = code #操作结果代号 self.value = value #操作结果值 def __str__(self): return "operation result, code: %s, value: %s" % (self.code, self.value)
这个结果对象包含两个属性,一个是操作结果的代码,一个是操作的值,举个例子,比如用户注册的接口,如果注册成功,那么就会返回一个这样的Result对象,code属性是'success', value属性是新注册用户分配的ID,如果用户名已经被占用,那么code属性就是'username_exised', value属性的值是None。客户端拿到code属性的值可以做响应的处理,如果是直接面向最终用户的web应用,那么就会去找到这个code对应的错误信息来展示给用户,所有的错误信息我是组织在一个单独的Python模块中(opresult.py):
reg = { 'success':u'注册成功', 'username_empty':u'用户名不得为空', 'username_format':u'用户名必须只能有数字、字母下划线组成', 'username_length':u'用户名长度必须在5到10个字符之间', 'username_existed':u'用户名已经存在', 'password_empty':u'密码不得为空', 'repassword_error':u'两次密码输入不一致', }reg是注册的接口名称,这样客户端通过接口名称和code就可以获取对应的提示。
由此,用户输入验证就是要把接口参数同这些code联系起来。对于参数验证,Python有天生的语言优势,那就是装饰器。一开始就想到了使用装饰器来描述参数验证需求,但这个装饰器需要哪些信息?怎么个形式?这个得从表单验证的需求开始看起,个人总结表单验证大抵不过这些判断条件:
1. 是否允许为空
2. 长度限制:比如密码的长度一般会不允许少于多少位
3. 格式限制:比如Email地址,需要正则判断
4. 逻辑限制:比如注册时判断用户名是否已经存在
初步根据这些判断条件设计出这么一个方案:
@checkarg(username={'allow_empty':False, 'regex':r'^[a-zA-Z\d_]+$', 'min-length':5, 'max-length':10, 'check_logic':[check_username_usable]}, password={'allow_empty':False,'regex':r'.{6,}'}, repassword={'allow-empty':False, 'check_logic': [(lambda **kw:(kw['password'] == kw['repassword'], "repassword_error"))]}) def reg(username, password, repassword): ....
每一个参数使用一个字典来描述验证信息, allow_empty是表示是否为空,regex为验证的正则表达式,min-length和max-length用来描述长度,check_logic用来配置其他的验证逻辑。然后如何把这些验证结果同code进行匹配呢?最开始是在这个验证信息的字典中有一项'code':{'allow_empty':'username_empty'}通过这样的形式去匹配错误提示,但是感觉这样整的这个参数太复杂了(感觉现在已经挺复杂了- -b),于是决定这个地方使用约定优于配置的形式,code的值为'参数名_错误类型'的形式,比如allow_empty如果验证了为空,那么会自动返回名为username_empty的code,如果是一些额外的处理逻辑呢?没法做约定,怎么办?那么就约定这些检测函数返回一个元组,第一个元素为一个bool值,表示成功失败,第二个参数为code,表示失败原因,比如判断两次密码是否输入一致的那个lambda:
lambda **kw:(kw['password'] == kw['repassword'], "repassword_error"
嗯,大体就是这样的一个设计。
实现
根据上面的设计,把最终的装饰器实现了出来, 逻辑比较简单,关于装饰器设计的一些细节可以参阅Python参考手册:
regex_cache = {} def checkarg(**args): u'''参数检测装饰器''' def _checkarg(function): def __checkarg(**func_kw): for key in func_kw: if key in args: #要验证的值 value = func_kw[key] #验证规则 valid_rules = args[key] #检测空 allow_empty = valid_rules.get('allow_empty') if not allow_empty: if not value or not value.strip(): return Result(key + "_empty") elif not value: #如果是空的并且忽略空检测,那么下面的就不需要检查了 continue; #检测长度 if 'min-length' in valid_rules: min_length = valid_rules['min-length'] if min_length > len(value): return Result(key + "_length") if 'max-length' in valid_rules: max_length = valid_rules['max-length'] if max_length < len(value): return Result(key + "_length") #检测正则 if 'regex' in valid_rules: #获取编译后的正则 regex = valid_rules['regex'] regexcmp = regex_cache.get(regex) if not regexcmp: regexcmp = re.compile(regex) regex_cache[regex] = regexcmp if not regexcmp.search(value): return Result(key + "_format") #检测其他逻辑 check_logics = valid_rules.get('check_logic') if check_logics: for logic in check_logics: result = logic(**func_kw) if not result[0]: return Result(result[1]) function(**func_kw) return __checkarg return _checkarg