一個基于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