分类:
2008-10-14 14:58:05
在MFC中用正则表达式对窗体进行有效性验证
原著:
翻译:
下载源代码: (248KB)
原文出处:
本文发布后有更新参见文中的:[编辑更新]
我想利用本月的专栏描述一个有趣的应用程序,这个程序是用本期我的一篇文章:“”所讨论的 RegexWrap 库生成的。RegexForm 是一个基于正则表达式的MFC窗体有效性验证系统。为了实现这个程序,我首先实现了
RegexWrap。但因为许多细节与正则表达式本身无关,所以感觉在这里描述 RegexForm 更好些。
正如我在这篇文章中所言,正则表达式最实用的一个地方是验证用户输入。它可以轻松验证邮编、电话号码、信用卡号码——以及现实世界中各种类型的信息。一个正则表达式可以替换成打甚至上百行过程代码。UNIX
和 Web 编程语言如 Perl从一开始就有正则表达式,但在 Windows 世界或MFC,从来都是使用第三方库,一直到 .NET
框架才结束这个局面。[编辑更新——3/15/2005:ATL Server Library 中的 CAtlRegExp 和
CAtlREMatchContext 类为正则表达式提供支持。] 因此现在 .NET 提供一个完整的正则表达式库,为什么不在MFC应用程序中使用它呢?并且利用我在前述文章中描述的
RegexWrap 库,你甚至都不需要托管扩展或 /clr。
MFC 已经具备一种称为“对话框数据交换”(Dialog Data Exchange,即 DDX)以及“对话框数据验证”(Dialog
Data Validation,即 DDV)的机制来验证对话框输入。从技术上讲,DDX 只是在屏幕和你的对话框对象之间传输数据,而 DDV
才验证数据。当你从对话框的 OnOK 处理例程中调用 UpdateData 时 DDX 才开始工作。
// user pressed OK: void CMyDialog::OnOK() { UpdateData(TRUE); // 获得对话框数据 ... }
UpdateData 是一个虚拟 CWnd 函数,你可以在自己的对话框中重写这个函数。其布尔型(Boolean)参数告知是将信息拷贝到屏幕还是相反从屏幕拷贝信息。(你可以在 OnInitDialog 中调用 UpdateData(FALSE)以便初始化对话框)。默认的 CWnd 实现创建一个 CDataExchange 对象并将它传递到另一个虚拟函数,DoDataExchange,你得重写这个函数去调用专门的 DDX 函数来为单独的数据成员传递数据:
void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialog::DoDataExchange(pDX); DDX_Text(pDX, IDC_NAME, m_name); DDX_Text(pDX, IDC_AGE, m_age); ... // etc. }
这里 IDC_NAME 和 IDC_AGE 是编辑控制的 IDs,m_name 和 m_age 分别是 CString 和 int
数据成员。DDX_Text 将用户输入的 Name 和 Age 拷贝到 m_name 和 m_age(用一个重载顺便将 Age 转变成
int)。DDX 函数知道走哪条路,因为当从屏幕拷贝到对话框时,CDataExchange::m_bSaveAndValidate 为
TRUE,反之则为 FALSE。MFC 为各种数据和控制类型加载 DDX 函数。例如,DDX_Text
至少有一些重载函数用来将输入文本拷贝和转换成不同的类型,如 CString、int、double、COleCurrency
等等。DDX_Check 用来将复选框的状态转换成整型值,DDX_Radio 则对单选按钮做同样的事情。
DDX 函数传输数据;DDV 函数则验证它。例如,为了限制用户名称为 35个字符,你可以这样做:
// in CMyDialog::DoDataExchange DDX_Text(pDX, IDC_NAME, m_sName); // 获得/设置值 DDV_MaxChars(pDX, m_sName, 35); // 验证
为了限定你的用户年龄为 1-120之间的一个整数,你可以这样写:
// m_age is int DDX_Text(pDX, IDC_AGE, m_age); DDV_MinMaxInt(pDX, m_age, 1, 120);
虽然 DDX 工作表现得很好,DDV 是不免有点老土。MFC
在有效性验证方面所能做到的很有限。你可以在文本域中限制数字字符,不同类型的最小/最大约束。最小/最大是不错,但如果你想验证邮编或电话号码怎么办?MFC
对此无能为力。你不得不编写自己的 DDV 函数。当我第一次用正则表达式实现有效性验证时,我只要写一个函数即可,就像这样:
void DDV_Regex(CDataExchange* pDX, CString& val, LPCTSTR pszRegex) { if (pDX->m_bSaveAndValidate) { CMRegex r(pszRegex); if (!r.Match(val).Success()) { pDX->Fail(); // throws exception } } }
这使你很容易象下面这样用正则表达式验证输入:
// in CMyDialog::DoDataExchange DDX_Text(pDX, IDC_ZIP, m_zip); DDV_Regex(pDX, m_zip,_T("^\\d{5}(-\\d{4})?$"));
好酷啊,仅用四行代码就搞掂。(当然,那要假设你有 RegexWrap——否则你得使用托管扩展直接调用框架 Regex 类。)DDV_Regex 在 MFC 的 DDX/DDV 方案中工作表现很完美,但是当我开始添加更多的域时,我马上发现一些 DDX/DDV 的主要缺点,其一,如果域输入无效,则每个 DDV 函数都显示一个出错消息框并丢出异常,那么要是有五个无效域,用户就会看到五个消息框——真实糟透了!此外,在对 DDV 的调用中,我不想将正则表达式写死在代码中。但我拒绝 DDX/DDV 的主要理由是它太程序化。为了验证新的域,你不得不添加另外的数据成员以及在 DoDataExchange 加更多的代码,不久这个函数便膨胀臃肿,就像下面这样:
DDX_Text(pDX, IDC_FOO,...); DDV_Mumble(pDX, ...) DDX_Text(pDX, IDC_BAR,...); DDV_Bletch(...) ... // etc for 14 lines
为什么我非得要墨守成规编写过程指令来描述固有的验证规则呢?我的五条编程最高准则之一是:拒斥程序化代码。另一个是:一个表格胜过一千行代码。你肯定猜到我要干什么了。最终,我编写自己的对话框验证系统,一个基于规则的、表格驱动的验证系统。它依靠在
DDX 的最上层,但废掉了 DDV
并有好得多的用户接口。当然,它还易于使用,借助正则表达式进行验证。所有细节都封装在一个类中,CRegexForm,你可以在任何 MFC
对话框中使用这个类。
Figure 1 TestForm 里的工具提示
与往常一样,我编写了一个测试程序来示范它的工作原理。初看起来,TestForm 有点像再平常不过的基于 MFC
的对话框程序。它的主对话框中有几个编辑框——邮编、SSN(社会保险号),电话号码等。但当你通过对 TestForm
的测试,你会很快认识到它蕴含着许多玄机。如果你用tab键在输入域间移动,TestForm 会显示一个工具提示,它描述在这个输入域能输入什么(如图
Figure 1)。如果你敲入了一个非法字符——例如,在电话号码输入域敲入一个字符——TestForm
将拒绝接受该字符并发出蜂鸣声。当你按下确认键或OK键,你会得到一个描述所有无效输入域的出错信息框,如图 Figure 2
所示。所有的错误都显示在一个消息框中,而不是每个错误一个消息框。接着当用户tab到其中一个无效输入域时,TestForm
会再次在对话框本身的一个应用程序提供的“反馈”窗口内显示出错信息(如 Figure
3),因此用户不必记住错误信息所描述的内容,当他们更正每个无效输入域时,窗体会提醒他们。如果只有一个输入域无效,TestForm
会放弃消息框而直接在“反馈”窗口显示错误信息。
Figure 2 集中显示出错的输入域
所有这些神奇的特性都是由 CRegexForm 自己实现的。你只需使用它即可,使用方法相当直白,首先你得定义一个自己窗体。下面是
TestForm 的窗体内容,这些定义位于 MainDlg.cpp:
// form/field map BEGIN_REGEX_FORM(MyRegexForm) RGXFIELD(IDC_ZIP,RGXF_REQUIRED,0) RGXFIELD(IDC_SSN,0,0) RGXFIELD(IDC_PHONE,0,0) RGXFIELD(IDC_TOKEN,0,0) RGXFIELD(IDC_PRIME,RGXF_CALLBACK,0) RGXFIELD(IDC_FAVCOL,0,CMRegex::IgnoreCase) END_REGEX_FORM()
这个宏定义了一个静态表格,表格描述每个编辑控制域。大多数情况下你只需要控制 ID,还要有地方放标志和 RegexOptions。例如,在
TestForm 中,邮政编码是必输域(RGXF_REQUIRED),质数(Prime
Number)输入域使用回调(稍后会详细讨论),最喜爱的专栏作家(IDC_FAVCOL)指定 CMRegex::IgnoreCase,它使得大小写
不敏感。
Figure 3 Pietrek? I Think Not!
看着表格你可能想知道正则表达式在哪。回答是:在资源文件中。对于每个域/控制ID,CRegexForm
都期望有一个具有相同ID的资源串。资源串由五个子串组成,子串之间用新行符(''\n'')分隔。一般格式为:
“Name\nRegex\nLegalChars\nHint\nErrMsg”。以下是 IDC_ZIP 所用的串:
"Zip Code\n^\\d{5}(-\\d{4})?$\n[\\d-]\n##### or #####-####"
第一个子串“Zip
Code”是域名。第二个“^\d{5}(-\d{4})?$”,是用于验证邮编的正则表达式。(在资源串中必须敲入两个反斜线,目的是转义正则表达式中反斜线)。第三个子串是另外一个正则表达式,用来描述合法字符。对于邮编来说,即是“[\d-]”,意思是允许数字和连字符(hyphen)。如果输入域无字符限制,你可以通过敲入两个连续的新行符(“\n\n”意思是空子串)省略
LegalChars
检查。第四个子串全部为工具提示串。最后你可以提供第五个子串,如果该输入域无效则显示错误信息。对于邮编来说,它没有错误信息,所以
CRegexForm 产生一个默认的信息,形式为“Should be xxx”,xxx 被工具提示替代。“Should
be”本身即是另一个资源串(稍后还要说到)。这些子串中,只有第一个域是必输域。
为什么用资源串来保存所有信息,而不直接在域映射中编码处理呢?首先,将它放在映射中使得代码很笨拙。把这些乱七八糟的字符串放在不显眼的地方有利于代码更整洁。此外宏无法处理可选参数,根据你所用参数的多少,你需要多个宏,如:RGXFIELD3、RGXFIELD4
和 RGXFIELD5。这样不是太笨拙了嘛?使用资源串真正的好处在于容易本地化。翻译者可以翻译字符串并创建不同的资源
DLLs。甚至正则表达式本身都需要翻译(不同的国家和地域邮编可能是不同的),所以它们也放在资源串中。
此外,再让我顺便指出用正则表达式解析这些子串是多么容易。MFC 有一个 26 行的专门用来解析子串的函数
AfxExtractSubString,但 CRegexForm 使用 CMRegex 来做只要一行代码!
CString str; str.LoadString(nID); vectorsubstrs = CMRegex::Split(str, _T("\n"));
现在 substrs[i] 是第 i 个子串,如果你想知道有多少个子串,调用 substrs.size()即可。我真的很高兴我包装了
Split 函数来返回 STL vector。
一旦你用 BEGIN/END_REGEX_FORM 定义了自己的域映射并编写了自己的资源串,下一步要做的就是在对话框中实例化
CRegexForm 并进行初始化:
// in OnInitDialog m_rgxForm.Init(MyRegexForm, this, IDS_MYREGEXFORM, MYWM_RGXFORM_MESSAGE);
自然,CRegexForm
需要域映射并指向你的对话框;第二和第三个参数是另一个资源串和回调消息ID。与单独的域字符串一样,初始串由包含新行符分隔的多个子串组成。对于
TestForm,IDS_MYREGEXFORM 是“Error: %s\nRequired\nShould be: %s\nBad
Value”。第一个子串“Error: %s”是错误前缀。CRegexForm 用来显示“Error: xxx,”,xxx
是实际的出错信息。第二个子串,“Required,”是一个词/短语,当该域为必输域(RGXF_REQUIRED)时使用。第三个子串“Should
be: %s,”我前面已经描述过,CRegexForm 用它来产生出错信息。“Should be: xxx”中的 xxx
是域提示。最后一个子串,“Bad Value,”CRegexForm
可以用它来放任何信息,当域没有提示也没有出错信息时使用。用户是看不到此信息的,因为你肯定会为每一个域配一个提示或出错信息,对不对?
Init 的最后一个参数 MYWM_RGXFORM_MESSAGE 是应用程序定义的回调消息 ID,利用它可以使 CRegexForm
与你的应用程序沟通并做一些需要过程代码来定制验证的事情。如果你需要用数学算法来验证你的输入,你可以在域标志中设置
RGXF_CALLBACK,CRegexForm 将在进行验证时用通知代码 RGXNM_VALIDATEFIELD
方式向对话框发送回调消息。TestForm 使用回调来验证其 Prime Number 域;具体详细参见
。
CRegexForm 用其内部拥有的 CStrings 来进行 DDX,所以你不必为每个文本域定义对话框成员。你只要调用
CRegexForm 来传递数据即可。
void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialog::DoDataExchange(pDX); m_rgxForm.DoDataExchange(pDX); }
当你初始化 CRegexForm 时,它分配一个 protected 类型的 FLDINFO 结构数组,映射中的每个域都有一个这样的数组。FLDINFO 结构的成员之一是 FLDINFO::val,类型为 CString,用来保存当前的域值。CRegexForm 在内部使用以此 CString 作为参数的 DDX_Text。你可以通过调用 CRegexForm::GetFieldValue 或 SetFieldValue 获取或设置该内部域值,它们都用控制ID来区分域。
m_rgxForm.SetFieldValue(IDC_ZIP,_T("10025"));
CRegexForm 将所有值都当作文本对待,并将它们存储在 CStrings 中,同时提供 GetFieldValInt 和
GetFieldValDouble 方法来获得转换为 int 或 double 的值。对于其它类型,你得自己进行转换——或者你仍可以用 MFC
DoDataExchange 中的 DDX 函数。TestForm 有一个 “Populate”按钮,它调用
CRegexForm::SetFieldValue 将样板数据填充到窗体中,如图 Figure 3 所示。通常,CRegexForm 使用控制
ID 来区分输入域。它包含有 GetFieldName、GetFieldHint 和 GetFieldError
来获取域名、提示和出错信息——它们都带有一个参数就是控制 ID。
到此,我说明了如何创建域映射,编写资源串,初始化你的 CRegexForm 以及通过 DDX
来关联。所有这些都是序曲。真正的用户输入验证是在用户按下 OK 键后进行的:
void CMyDialog::OnOK() { UpdateData(TRUE); // 拷贝屏幕输入->对话框 int nBad = m_rgxForm.Validate(); if (nBad>0) { m_badFields = m_rgxForm.GetBadFields(); ... }
UpdateData 调用 MFC 的 DDX 机制,即调用对话框的 DoDataExchange。然后 DoDataExchange
调用 CRegexForm::DoDataExchange,从而将用户输入拷贝到其内部的 FLDINFO 结构。接着
CRegexForm::Validate 遍历输入域,针对域的正则表达式调用 CMRegex::Match
来验证每一个域。如果域输入无效,CRegexForm 便在其内部的 FLDINFO 中设置错误码 RGXERR_NOMATCH
或者必输于域如果为空,则设置 RGXERR_MISSING。Validate 返回无效域数量。如果有无效域,你可调用
CRegexForm::GetBadFields 来获得一个无效域 IDs 数组(STL
vector)。然后你可以遍历该数组以获取各个错误吗和出错信息。这便是 TestForm 中 CMainDlg 建立其错误消息框所做的事情,如
Figure 2 所示。如果只有一个域无效,CMainDlg 调用 CRegexForm::ShowBadField
高亮该输入域并在反馈窗口显示出错信息,如图 Figure 3 所示。如果所有域都没问题,TestForm 便显示一个消息框展示输入的值(参见
Figure 5)。
Figure 5 显示输入的数据
在实际应用中,你会将这些值拷贝到其最终目的地。 是 CMainDlg::OnOK 的全部代码。通过数据交换中的非耦合式的数据验证,CRegexForm 给予你更充分的 UI
控制,并使你避免 MFC 固有的缺陷。
我提到的反馈窗口,它由 CRegexForm 全权管理;你只需通过调用 CRegexForm::SetFeedBackWindow
提供一个这样的窗口即可。你可以为出错信息指定一种颜色。CRegexForm
还负责提示功能。默认情况下,当用户tab到某个新输入域时显示域提示(如图 Figure 1)。你可以调用 CRegexForm::SetShowHints(FALSE)来关闭提示功能。SetShowHints(TRUE,
nDelay, nTimeout) 可以再次打开提示功能,这里 nDelay 是显示提示之前要等待的毫秒数(默认值=250),nTimeout
是提示显示的毫秒数(默认值=0,表示一直显示)。当用户离开输入域时(EN_KILLFOCUS),CRegexForm
自动清除其提示。TestForm 使用 SetShowHints 实现复选框提示的开启和关闭(参见 Figure 1)。
还有另外一个特性是否提出来我有些举棋不定。CRegexForm
有一个选项来做立即验证。我不建议使用它,因为我觉得那是一种不好的GUI设计,但很多时候在没有输入有效的信息之前你不想让用户tab到下一个输入域。在这种情况下,你可以在域映射中使用
RGXF_IMMED,或调用 SetValidateImmed 来使所有域都进行立即验证。TestForm
有一个复选框专门控制立即验证功能的开关。如果选中,你会看到为什么立即验证不是个好主意。
最后,看看最小/最大和字符限制约束特性?最小/最大是正则表达式无法做到的一件事。虽然“.{0,35}”是一个描述最多35个字符长的字符串正则表达式,但你还是愿意使用
EM_LIMITTEXT 来限制编辑控制的文本长度,所以当用户敲入过长的字符时发出蜂鸣声。因为我容忍实现了一个窗体验证系统,但它缺乏 MFC
已经支持的特性,我引进了“伪正则表达式”的概念。例如,IDC_AGE 的正则表达式(Age
输入域)为“rgx:minmax:int:1:120,maxchars:3”。显然,它不是一个真正的正则表达式。它是一个伪表达式,它由
CRegexForm 自己来识别和解释。其一般格式是:“rgx:expr,expr,..expr”,这里每个表达式描述不同的约束。目前支持两种类型:“minmax:type:minval:maxval”(此处
type 是 int 或 double)以及“maxchars:maxval”。CRegexForm 负责做专门解析,并对 maxchars
使用 EM_LIMITTEXT,就像 DDV_MinMaxInt
一样。具体实现细节请下载源代码。至于资源子串,正则表达式解析它们简直是小菜一碟。
CRegexForm 所能做的事情我已经说完了。它是如何工作的呢?由于篇幅所限,恕不赘言,但我大概勾勒一下。CRegexForm 我的
CSubclassWnd 来子类化对话框。对之前不了解它的读者来说,CSubclassWnd
是我很早以前写的一个类,它使用Windows的子类化机制来截获发送到另一个窗口的消息。CSubclassWnd
真正酷的地方是它不需要在你的类层次中插入新类便能让你子类某个 MFC 窗口。我可以从 CDialog 派生出 CRegexForm,但那样你就得从
CRegexForm 派生自己的对话框。那么如果你已经自己实现了从 CDialog 派生的 CBetterDialog
怎么办呢?此时,你不得不进行外科手术来插入 CRegexForm,结果将不可预见。
这是MFC的一大缺陷:它用派生实现子类化,所以类层次确切地反映了 Windows 子类机制。但没必要这样,编写像 CRegexForm
这样的插件类来子类化你的对话框而不用改动你的类层次是更好的解决方法。对我来说,CSubclassWnd 是如此不可或缺,没有它我无法编程!
CRegexForm 使用 CSubclassWnd 截获 EN_KILLFOCUS 和 EN_SETFOCUS 以隐藏和显示提示,截获
EN_CHANGE 以便用户敲入任何信息后清除输入域的出错状态。CRegexForm 用我的 CPopupText 类实现本身的提示,CPopupText
类第一次讨论是在
。为了防止用户敲入不允许的字符,CRegexForm 为每一个具有 LegalChars
正则表达式的编辑控制安装了另一个 CSubclassWnd 派生的钩子。这是一个嵌套类,CRegexForm::CEditHook
截获发送到编辑控制的 WM_CHAR 消息,它吃掉任何非法输入字符并发出蜂鸣。详情参见 RegexWrap
的源代码,这个代码你可以从本期中我的另外一篇文章中下载:“”
您的提问和评论可发送到 Paul 的信箱: