做Access开发这些年,表单验证是所有人都绕不开的一个环节。早期我写过不少用BeforeUpdate加一堆If判断堆出来的验证代码,单个表单还能扛,一旦表单超过十来个,规则开始在不同事件里散落,改一个字段名要全局搜索替换,出个提示还要挨个改MsgBox,整套东西基本失控。后来我意识到,验证这件事本身不该依附于某个具体表单,它应该是一个独立、可复用、按规则驱动的架构。
这篇就聊聊我最近重构的一套“实时表单验证架构”。核心思路是把验证规则从表单事件里彻底剥离,集中到规则表和验证引擎里,通过事件自动触发,用户一输入完就得到反馈,不用等保存时才报错。这个方案适合所有用Access做中大型业务系统的场景,无论是自用管理工具还是给企业做的正式系统,都可以直接拿去用。文章会讲清楚架构分层、引擎实现、表单接线方式,还有我踩过坑之后的排查心得。
1. 为什么Access表单验证要做成架构,而不是继续写事件
先说个扎心的现实:大多数Access开发者做验证,是直接在表单事件里写代码。这种写法在小项目里效率很高,但只要你做过三五个以上表单的系统,就会明显感到不对劲。
1.1 传统验证方式的三宗罪
第一宗是代码重复。必填、格式、范围校验,这类基础规则几乎每个表单都有。复制粘贴一时爽,后面维护火葬场。你改了一处逻辑,忘了另外七个表单里的同样逻辑,数据口径马上就乱了。
第二宗是逻辑分散。一个字段可能在BeforeUpdate里查重,在AfterUpdate里判断联动,在Form_BeforeUpdate里做整体校验,还有可能在下拉框的Change事件里再做一次。规则拆得七零八落,出了问题根本说不清这个字段到底有哪些约束。
第三宗是用户交互差。大部分Access默认验证的交互是“输入全部完成,点保存,然后弹窗告诉你第3个字段不对”。用户得来回切窗口,记住错误提示,再回去改,体验非常糟糕。有些业务需要即时反馈——输入完一个字段马上知道合不合法,而不是憋到最后一起爆发。
1.2 实时验证与传统验证的本质区别
传统验证是“后验”,实时验证是“随验”。后验模式里,验证逻辑跑在数据提交前那一刻,规则负责“拦截”;随验模式里,验证逻辑跑在用户离开某个控件的那一刻,甚至跑在输入过程中,规则负责“引导”。
这两种模式对架构的要求完全不同。后验可以简单地在Form_BeforeUpdate里写一大串If,因为调用时机是固定的;但实时验证要响应控件事件,而且不同控件触发时机不一样——文本框可能用AfterUpdate,下拉框可能用Change,复选框可能用Click,日期选择可能用AfterUpdate加上输入时的事件。
如果这些事件处理逻辑还是散落在各个表单里,代码量会翻倍,因为每个控件都要绑定事件、维护状态、处理错误提示。这时你就需要一套东西把这些脏活累活统一接管,这就是架构的价值。
1.3 什么时候值得上这套架构
说实话,如果你只是做个一次性工具,三五个字段,用完即弃,那直接写BeforeUpdate完全没问题,上架构反而画蛇添足。但如果满足以下任意两条,我建议认真考虑:
- 表单数量超过十个,而且存在多张表之间的联动关系。
- 同一套验证规则需要在多个表单中复用,比如客户编码的命名规则、订单号格式校验。
- 业务上要求输入即时反馈,比如输入完编号就要立刻查重并提示。
- 后期可能加字段、改规则,而且希望改规则时不动代码。
我这次动手做这套架构,就是因为系统里的验证逻辑已经膨胀到近两百条,分散在四十多个表单里。我接手的时候想统计一下“客户编码到底有哪些校验”,花了两天还没数全。重构迫在眉睫。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构设计:把验证从表单里“抽”出来
实时验证架构的精髓就一句话:验证规则数据化,验证逻辑引擎化,表单只负责接线。
2.1 三层架构的核心划分
我把整套东西拆成了三层。
第一层是规则层,验证规则以记录的形式存在数据表里。这一层管“验证什么”。一条规则对应一个字段,包含类型、表达式、错误提示、启用状态、触发时机这些属性。
第二层是引擎层,一个独立的VBA类模块,运行时加载规则,对外提供验证接口。这一层管“怎么验证”。它不关心是哪个表单在调用,只根据传入的控件名和值去查规则、跑表达式、返回结果。
第三层是接口层,也就是各表单的接线代码。这一层管“什么时候验证”。表单加载时把自身注册给引擎,控件触发事件时调引擎的验证方法,表单保存前调引擎做最终确认。
这样做的好处是:规则变了改数据,不用改代码;要在新表单里启用同样规则,只要接入引擎就行;要调整验证提示风格,只改引擎一处。
2.2 验证引擎的工作流程
引擎的运行流程是这样的:
- 表单加载时,调用引擎的Attach方法,传入表单对象。
- 引擎根据表单名称,从规则表里加载属于这张表单的所有规则,按字段建立索引。
- 用户操作控件,事件触发后,表单调用引擎的ValidateField方法,传入控件对象。
- 引擎拿到控件名称,在规则索引里找到对应规则集合,逐条执行。
- 引擎把验证结果返回给表单,如果有错误,引擎同时负责修改控件外观状态和显示提示信息。
这里有个关键设计:引擎直接操作控件对象,而不是只返回值。因为实时验证的核心体验之一是错误反馈的即时性和视觉化——标红的输入框、黄色的提示标签、状态栏的文字,这些都应该是引擎统一处理的,不应该让每个表单自己去搞一套。
2.3 验证规则的表结构设计
规则表是整个架构的地基。我用的表结构如下:
| 字段名 | 数据类型 | 说明 |
|---|---|---|
| RuleID | 自动编号 | 主键,规则唯一标识 |
| FormName | 短文本 | 表单名称,确定规则属于哪张表单 |
| FieldName | 短文本 | 控件名称,确定规则作用于哪个控件 |
| RuleType | 数字 | 规则类型,1必填,2格式,3范围,4唯一性,5自定义 |
| RuleExpression | 长文本 | 规则表达式,具体逻辑定义 |
| ErrorMessage | 长文本 | 验证失败时的提示文字 |
| Severity | 数字 | 严重级别,1提示,2警告,3阻止 |
| ValidationTiming | 数字 | 触发时机,1失焦,2变更,3提交,4全部 |
| Enabled | 是/否 | 是否启用 |
| SortOrder | 数字 | 同字段多个规则时的执行顺序 |
设计的时候有几个细节,是后来实际使用中逐步补上的。
一个是RuleType。我做成了枚举,代码里硬编码对应关系,比如必填规则内部就是去判断Is Null或者空字符串;格式规则统一用Like模式匹配或者字符串函数做检查。这样规则表里不用存一段可执行代码,避免了VBA代码入库带来的安全问题。
另一个是ValidationTiming。这是实时验证最能出效果的地方——必填和格式类规则,最好是失焦时验证,因为用户刚输入完,马上给反馈最自然;唯一性检查这类代价高的规则,失焦时做一次就够了,不用每敲一个字符都做;而格式简单的规则可以放在变更时,边输入边给提示。不同时机可以组合,规则表里我用多选式枚举值,用按位运算判断。
还有一个是Severity。有些校验不一定是硬性阻止。比如“客户备注超过500字”,可以做成警告级别,允许用户继续填但不建议;而“订单金额必须大于0”就应该是阻止级别,不允许保存。实时验证的好处是,用户输入完了马上知道是警告还是阻止,不需要提交时才知道。
3. 核心实现:验证引擎类模块
引擎是本架构的中枢。我直接用两个类模块来实现:一个负责单条规则的对象封装,一个负责整体验证流程和表单绑定。
3.1 clsValidationRule 规则对象
这个类模块对应规则表里的一条记录,主要职责是:保存规则属性,提供Evaluate方法判断一个控件值是否通过。
vba复制Option Compare Database
Option Explicit
Public Enum RuleTypeEnum
RuleUnknown = 0
RuleRequired = 1
RuleFormat = 2
RuleRange = 3
RuleUnique = 4
RuleCustom = 5
End Enum
Private mstrFormName As String
Private mstrFieldName As String
Private mlngRuleType As RuleTypeEnum
Private mstrExpression As String
Private mstrMessage As String
Private mlngSeverity As Long
Private mlngTiming As Long
Private mblnEnabled As Boolean
Public Property Get FormName() As String
FormName = mstrFormName
End Property
Public Property Let FormName(ByVal Value As String)
mstrFormName = Value
End Property
Public Property Get FieldName() As String
FieldName = mstrFieldName
End Property
Public Property Let FieldName(ByVal Value As String)
mstrFieldName = Value
End Property
Public Property Get RuleType() As RuleTypeEnum
RuleType = mlngRuleType
End Property
Public Property Let RuleType(ByVal Value As RuleTypeEnum)
mlngRuleType = Value
End Property
Public Property Get Expression() As String
Expression = mstrExpression
End Property
Public Property Let Expression(ByVal Value As String)
mstrExpression = Value
End Property
Public Property Get Message() As String
Message = mstrMessage
End Property
Public Property Let Message(ByVal Value As String)
mstrMessage = Value
End Property
Public Property Get Severity() As Long
Severity = mlngSeverity
End Property
Public Property Let Severity(ByVal Value As Long)
mlngSeverity = Value
End Property
Public Property Get Timing() As Long
Timing = mlngTiming
End Property
Public Property Let Timing(ByVal Value As Long)
mlngTiming = Value
End Property
Public Property Get Enabled() As Boolean
Enabled = mblnEnabled
End Property
Public Property Let Enabled(ByVal Value As Boolean)
mblnEnabled = Value
End Property
Public Function Evaluate(ByVal CheckValue As Variant) As Boolean
Dim blnPass As Boolean
blnPass = True
Select Case mlngRuleType
Case RuleRequired
If IsNull(CheckValue) Or Trim(CheckValue & "") = "" Then
blnPass = False
End If
Case RuleFormat
If Not IsNull(CheckValue) And CheckValue & "" <> "" Then
If Len(mstrExpression) > 0 Then
If Not (CheckValue Like mstrExpression) Then
blnPass = False
End If
End If
End If
Case RuleRange
If Not IsNull(CheckValue) And IsNumeric(CheckValue) Then
Dim dblValue As Double
Dim dblMin As Double
Dim dblMax As Double
dblValue = CDbl(CheckValue)
dblMin = Val(Left(mstrExpression, InStr(mstrExpression, ",") - 1))
dblMax = Val(Mid(mstrExpression, InStr(mstrExpression, ",") + 1))
If dblValue < dblMin Or dblValue > dblMax Then
blnPass = False
End If
End If
Case RuleCustom
If Len(mstrExpression) > 0 Then
If Eval(mstrExpression & "(CheckValue)") = False Then
blnPass = False
End If
End If
End Select
Evaluate = blnPass
End Function
这段代码有几个小心思。格式规则用的是Like运算符,表达式里存的就是“[A-Z]{2}[0-9]{4}”这类通配符模式,比正则容易理解得多,也够用。范围规则把表达式约定成“min,max”的字符串,中间用逗号分隔,省得为两个值建两个字段。唯一性规则没法在单值判断里完成,必须查询表,所以这个Evaluate方法里没有处理,需要放到引擎层做,因为规则表达式在这里是表名和字段名的约定。
自定义规则我留了一个Eval的钩子,方便高级用户挂自己写的函数。要说明的是,VBA的Eval不是不能用来执行动态代码,但外部输入进规则表的内容会带来安全隐患,所以我只在完全信任的内部使用场景才启用。
3.2 clsValidatorEngine 引擎主体
引擎类是整个架构最重要的代码。它的职责包括:加载规则、绑定表单、根据控件查询并执行规则、修改控件状态、提供全部字段验证入口。
vba复制Option Compare Database
Option Explicit
Private mfrm As Form
Private mcolRules As Collection
Private mRulesIndex As Object
Private mblnAttached As Boolean
Private mblnValidating As Boolean
Public Sub Attach(ByVal TargetForm As Form)
Set mfrm = TargetForm
LoadRules mfrm.Name
mblnAttached = True
End Sub
Public Sub Detach()
Set mcolRules = Nothing
Set mRulesIndex = Nothing
Set mfrm = Nothing
mblnAttached = False
End Sub
Private Sub LoadRules(ByVal FormName As String)
Dim rs As DAO.Recordset
Dim objRule As clsValidationRule
Dim strSQL As String
Set mcolRules = New Collection
Set mRulesIndex = CreateObject("Scripting.Dictionary")
strSQL = "SELECT * FROM tblValidationRules WHERE FormName='" & Replace(FormName, "'", "''") & "' AND Enabled=True ORDER BY SortOrder;"
Set rs = CurrentDb.OpenRecordset(strSQL, dbOpenSnapshot)
Do While Not rs.EOF
Set objRule = New clsValidationRule
objRule.FormName = rs!FormName
objRule.FieldName = rs!FieldName
objRule.RuleType = rs!RuleType
objRule.Expression = rs!RuleExpression & ""
objRule.Message = rs!ErrorMessage & ""
objRule.Severity = rs!Severity & 0
objRule.Timing = rs!ValidationTiming & 0
objRule.Enabled = rs!Enabled
mcolRules.Add objRule
If Not mRulesIndex.Exists(rs!FieldName) Then
mRulesIndex.Add rs!FieldName, New Collection
End If
mRulesIndex(rs!FieldName).Add objRule
rs.MoveNext
Loop
rs.Close
Set rs = Nothing
End Sub
Public Function ValidateField(ByVal TargetControl As Control, Optional ByVal ValidTiming As Long = 4) As Boolean
Dim objRule As clsValidationRule
Dim colFieldRules As Collection
Dim blnAllPass As Boolean
Dim vntValue As Variant
If Not mblnAttached Then Exit Function
If mblnValidating Then Exit Function
mblnValidating = True
blnAllPass = True
' 根据时机过滤规则:目标耗时包含规则定义时机则执行
If mRulesIndex.Exists(TargetControl.Name) Then
Set colFieldRules = mRulesIndex(TargetControl.Name)
For Each objRule In colFieldRules
If (objRule.Timing And ValidTiming) = ValidTiming Then
On Error Resume Next
vntValue = TargetControl.Value
On Error GoTo 0
If objRule.RuleType = RuleUnique Then
If Not CheckUnique(TargetControl, objRule) Then
blnAllPass = False
ShowError TargetControl, objRule
End If
Else
If Not objRule.Evaluate(vntValue) Then
blnAllPass = False
ShowError TargetControl, objRule
End If
End If
End If
Next
End If
If blnAllPass Then
ClearErrorState TargetControl
Else
TargetControl.SetFocus
End If
mblnValidating = False
ValidateField = blnAllPass
End Function
Public Function ValidateAll() As Boolean
Dim ctl As Control
Dim blnPass As Boolean
Dim blnFinish As Boolean
Dim strFirstInvalidCtl As String
blnFinish = True
For Each ctl In mfrm.Controls
If ctl.ControlType = acTextBox Or ctl.ControlType = acComboBox Or ctl.ControlType = acCheckBox Or ctl.ControlType = acListBox Then
' 跳过禁用和锁定控件
If ctl.Enabled And Not ctl.Locked Then
If Not ValidateField(ctl, 4) Then
blnFinish = False
If Len(strFirstInvalidCtl) = 0 Then
strFirstInvalidCtl = ctl.Name
End If
End If
End If
End If
Next ctl
If Not blnFinish And Len(strFirstInvalidCtl) > 0 Then
mfrm.Controls(strFirstInvalidCtl).SetFocus
End If
ValidateAll = blnFinish
End Function
Private Function CheckUnique(ByVal TargetControl As Control, ByVal objRule As clsValidationRule) As Boolean
Dim rs As DAO.Recordset
Dim strSQL As String
Dim strTable As String
Dim strField As String
Dim strWhere As String
' 约定表达式格式:表名;字段名
strTable = Split(objRule.Expression, ";")(0)
strField = Split(objRule.Expression, ";")(1)
If IsNull(TargetControl.Value) Or TargetControl.Value & "" = "" Then
CheckUnique = True
Exit Function
End If
strWhere = "[" & strField & "]='" & Replace(TargetControl.Value & "", "'", "''") & "'"
' 排除当前记录
If mfrm.Dirty Then
If Not IsNull(mfrm.Bookmark) Then
strWhere = strWhere & " AND [ID]<>" & mfrm.Controls("ID").Value
End If
End If
strSQL = "SELECT COUNT(*) AS Cnt FROM [" & strTable & "] WHERE " & strWhere & ";"
Set rs = CurrentDb.OpenRecordset(strSQL, dbOpenSnapshot)
If rs!Cnt > 0 Then
CheckUnique = False
Else
CheckUnique = True
End If
rs.Close
Set rs = Nothing
End Function
这段代码覆盖了核心流程。ValidateField是外部调用最多的接口,允许传时机参数,这样同一个方法既可以在控件AfterUpdate时以失焦时机调用,也可以在Form_BeforeUpdate时以提交时机调用。ValidTiming用了按位设计:1是失焦、2是变更、4是提交,这样规则可以同时标记多个触发时机。
mblnValidating这个标志位非常关键。它防止验证过程中修改控件状态再次触发事件,造成递归调用。这在实时验证架构里几乎是必然会遇到的问题,后面我会详细展开。
ValidateAll方法遍历表单所有控件,统一执行提交级验证。它的返回值直接给Form_BeforeUpdate做Cancel判断,这样实时验证和保存前兜底验证共用一套规则,保证逻辑一致性。
3.3 错误展示与控件状态维护
验证结果不仅要返回布尔值,还要给用户直观的反馈。我把错误展示做成了三个层次:控件底色标红、控件的控件提示文本设置、状态栏汇总显示。
vba复制Private Sub ShowError(ByVal TargetControl As Control, ByVal objRule As clsValidationRule)
TargetControl.BackColor = RGB(255, 220, 220)
If objRule.Severity >= 3 Then
TargetControl.ControlTipText = objRule.Message
End If
If Len(objRule.Message) > 0 Then
SysCmd acSysCmdSetStatus, objRule.Message
End If
End Sub
Private Sub ClearErrorState(ByVal TargetControl As Control)
TargetControl.BackColor = RGB(255, 255, 255)
TargetControl.ControlTipText = ""
End Sub
这个细节看起来简单,实际使用效果提升很明显。用户在输入框里看到淡红色底色,鼠标放上去就能看到提示文字,屏幕底部状态栏还有具体说明。比弹窗温和得多,也比什么都不做直观得多。
要注意的是,窗体控件的BackColor在有条件格式的情况下会被覆盖。如果你的控件绑定了条件格式,引擎的标红逻辑可能不生效。我目前的处理方式是:引擎只负责验证,ConditionalFormatting优先级更高,遇到这种控件就把提示交给状态栏和ControlTipText,不强行标色。
4. 表单侧接线:实时触发的关键细节
引擎写得再好,表单不会接也是白搭。这个部分是最容易被忽略的,因为很多人以为调用一下验证方法就完事了,其实事件时机的设计直接影响用户体验。
4.1 事件绑定与初始化
每张需要验证的表单,只需要在最顶部声明一个引擎对象,然后在Form_Load里Attach,Form_Unload里Detach。
vba复制Option Compare Database
Option Explicit
Private mEngine As clsValidatorEngine
Private Sub Form_Load()
Set mEngine = New clsValidatorEngine
mEngine.Attach Me
End Sub
Private Sub Form_Unload(Cancel As Integer)
If Not mEngine Is Nothing Then
mEngine.Detach
Set mEngine = Nothing
End If
End Sub
Private Sub Form_BeforeUpdate(Cancel As Integer)
If Not mEngine.ValidateAll() Then
Cancel = True
MsgBox "存在未通过的验证项,请检查标红内容。", vbExclamation, "数据校验"
End If
End Sub
这个写法有个明显的好处:表单里不再堆任何验证代码。无论这张表单有多少字段、多少规则,代码量都是固定的三小段。新表单接入引擎的效率很高,复制这三段过去,改一下表单名就行。
Form_BeforeUpdate里的ValidateAll是兜底。实时验证可以拦截大部分错误,但难免有用户绕过控件直接通过VBA改数据,或者某些绑定控件的值被代码赋值变化,所以保存前必须再查一遍。这个原则我建议一定要保留:实时验证负责体验,提交验证负责数据安全。
4.2 控件事件与触发时机分配
表单层面的实时反馈,核心在每个控件的AfterUpdate事件里调用引擎方法。
vba复制Private Sub txtCustomerCode_AfterUpdate()
mEngine.ValidateField Me.txtCustomerCode, 1
End Sub
Private Sub cboOrderType_AfterUpdate()
mEngine.ValidateField Me.cboOrderType, 1
End Sub
这里统一用失焦时机(1)。因为AfterUpdate本来就是用户完成输入、焦点离开控件后触发的,这个时段做验证最自然。
为什么不用Change或者KeyUp?我踩过坑。Change在用户每敲一个字符就触发一次,做必填检查没问题,但做查重和格式检查就太重了,数据库查询会频繁执行。而且中文输入法状态下,Change事件触发次数非常离谱,组合拼音时一个词可能触发十来次,实时验证反而变成骚扰。
我推荐的主策略是:AfterUpdate里做完整校验,ShowError标红;Change里只做一件事——清除错误状态,把背景色恢复默认。这样用户修改了错误字段后,红色立刻消失,给一个“正在重新输入”的视觉反馈;输完离开时再统一判断,如果还不对再标红。既保证了即时性,又避免了频繁计算。
vba复制Private Sub txtCustomerCode_Change()
If mEngine Is Nothing Then Exit Sub
mEngine.ClearFieldError Me.txtCustomerCode
End Sub
ClearFieldError是在引擎里暴露的一个轻量方法,只恢复外观不执行验证,配合Change事件用起来非常顺手。
4.3 特殊情况:组合框、复选框与子表单
组合框的AfterUpdate时机和文本框一致,但要注意组合框可以手动输入文本,也可能选择下拉项。如果允许输入,验证的是输入的文本本身;如果不允许输入,验证的就是选中的项,这时候范围规则可能不适用,但必填规则必须保留。
复选框的验证相对简单。大部分业务场景里,复选框只需要必填规则——“是否同意协议必须勾选”这种。触发时机用Click而不是AfterUpdate就行,因为Click更直接,用户点一下立刻给反馈。
子表单的验证是另一个复杂点。子表单里的控件,事件代码写在子表单模块里,引擎也要在子表单里独立Attach。父表单保存时,要同时调用父引擎和子引擎的ValidateAll。我当时的做法是让父表单遍历所有子表单控件,逐个检查子表单的Dirty状态,再调用子表单的验证入口。
这里有个注意事项:不要试图在父表单引擎里加载子表单的规则、验证子表单的控件。子表单是独立的Form对象,控件集合也独立,跨表单控制容易出焦点和属性读写问题。老老实实让子表单自己验证,父表单只负责在保存前询问子表单“你验证完了没”。
5. 实操过程:从零搭建一套可复用的验证环境
理论说了一堆,实际操作起来其实不难。我按步骤记录一下搭环境的过程,你可以照着做。
5.1 第一步:建规则表和基础枚举
先建一张tblValidationRules表,字段按前面表格里的设计建好。你不需要手动录入所有规则,先把表和引擎跑通,然后做一个规则管理窗体,专门录入和维护验证规则。
规则管理窗体是这套架构配套的重要工具。它绑定规则表,左边按表单名筛选,右边列出规则明细,新增、修改、停用都在这里完成。业务人员想改提示文案,不用找开发者,直接进规则管理窗体就能改。这个对后期维护的帮助是巨大的。
5.2 第二步:创建类模块
在VBA编辑器里插入两个类模块,一个叫clsValidationRule,一个叫clsValidatorEngine,把前面贴的代码粘贴进去。注意类模块的Instancing属性保持默认的Private,不跨工程调用,不用设成PublicNotCreatable。
代码里有几个地方需要根据你的实际情况调整:
- 规则表名:我用的tblValidationRules,如果你的表名不同,改LoadRules里的SQL即可。
- 唯一性规则的表名字段约定:我用的分号分隔,比如“tblCustomer;CustomerCode”,你可以在规则管理窗体里做两个输入框拼接。
- 主键字段名:CheckUnique里默认用ID字段排除当前记录,如果你的主键不叫ID,需要改成实际字段名。
5.3 第三步:在标准模块里加几个快捷方法
引擎的调用入口虽然设计成实例方法,但我实际使用中会在标准模块里封装几个全局函数,省得每个表单都New一遍引擎的代码重复。
vba复制Public Function GetValidator() As clsValidatorEngine
Static objValidator As clsValidatorEngine
If objValidator Is Nothing Then
Set objValidator = New clsValidatorEngine
End If
Set GetValidator = objValidator
End Function
不过用Static单例有个风险:一个引擎对象同时只能绑定一个表单。如果你开了多个窗体,互相切换,单例就不够了。我最终采用的是每个表单独立声明引擎实例的方案,虽然代码多了三行,但可靠性和可维护性更好。代码重复三行不算什么,状态交叉才是大问题。
5.4 第四步:规则录入与测试
搭好代码框架后,最关键的一步是把历史表单里的验证逻辑翻译成规则数据。
举个例子,一个客户表单原本的验证是:
vba复制If IsNull(Me.txtCustomerCode) Or Trim(Me.txtCustomerCode) = "" Then
MsgBox "客户编码不能为空"
Me.txtCustomerCode.SetFocus
Exit Sub
End If
If Not (Me.txtCustomerCode Like "CUS[0-9][0-9][0-9][0-9]") Then
MsgBox "客户编码格式应为 CUS加四位数字"
Me.txtCustomerCode.SetFocus
Exit Sub
End If
翻译成规则就是两条记录:
| 字段 | 值 |
|---|---|
| FormName | frmCustomer |
| FieldName | txtCustomerCode |
| RuleType | 1(必填) |
| RuleExpression | 空 |
| ErrorMessage | 客户编码不能为空 |
| Severity | 3 |
| ValidationTiming | 1(失焦) |
| Enabled | True |
以及:
| 字段 | 值 |
|---|---|
| FormName | frmCustomer |
| FieldName | txtCustomerCode |
| RuleType | 2(格式) |
| RuleExpression | CUS[0-9][0-9][0-9][0-9] |
| ErrorMessage | 客户编码格式应为 CUS 加四位数字 |
| Severity | 3 |
| ValidationTiming | 1(失焦) |
| Enabled | True |
录入完成后,打开frmCustomer测试。正常输入“CUS1234”,控件应该保持正常状态;输入“CUS12”,光标离开后控件变红、状态栏出现提示;把内容清空再离开,应该出现必填提示。如果这些表现都正常,说明你已经完成了从“写死事件”到“规则驱动”的迁移。
5.5 第五步:处理旧表单的存量代码
迁移过程中最容易出问题的是旧代码残留。比如表单的BeforeUpdate里还有一堆旧的验证判断,新引擎又跑了一遍,结果一个字段报错两次。我建议的做法是:
- 先通过规则管理窗体把新规则全部配好。
- 然后把旧表单事件里的验证代码全部注释掉,保留调试入口。
- 跑一遍完整业务流程,对照规则管理窗体和事件代码,确保所有验证都被新规则覆盖。
- 确认无误后,彻底删除注释掉的旧代码。
这个流程顺序很重要。不要边删边配,容易出现验证真空期,数据进了一堆垃圾再回填就麻烦了。
6. 常见问题与排查技巧实录
架构跑起来之后,实际遇到的问题基本集中在事件冲突、性能、边界条件三块。我把遇到过的问题和排查方法整理在这里。
6.1 事件重入与无限循环:最隐蔽的坑
前面提到mblnValidating标志位就是为了解决这个问题的。
典型场景是这样:用户在文本框输入值,AfterUpdate事件触发,引擎验证失败,调用TargetControl.SetFocus把焦点设回去。SetFocus本身不会重新触发AfterUpdate,但如果引擎在ShowError里改了控件的ControlTipText,而控件有宏或者表达式绑定到ControlTipText变化,就可能触发新的更新事件。更常见的是,你在验证失败后修改了文本框的Value属性,这会触发Change事件;Change事件里如果写了ClearFieldError,那还好,只是把背景色清掉了;但如果你在Change里又调用了ValidateField,就陷入了无限循环。
排查这个问题用调试器最有说服力。在Engine的ValidateField入口和Change事件里各打一个断点,运行后观察调用栈,很快就能看到循环链条。
我的防御策略有三层:
- mblnValidating标志位,在验证期间禁止重入。
- ClearFieldError这类轻量方法里不再触发验证。
- 事件代码里统一约定:Change只清状态,AfterUpdate只验证,不交叉调用。
这三条约定在整个项目里强制实施后,再也没有出现过循环问题。
6.2 中文输入法下的Change事件异常
中文输入法会让Change事件的触发次数变得完全不可控。拼音组合过程中,编辑框内容每变一次,Change就触发一次。一个“zhongguo”可能要触发七八次。如果Change里做了数据库查询,系统马上就卡死。
我的项目里最终只在Change里做视觉清理,坚决不做任何计算密集操作。但即使这样,我也发现中文输入法下频繁清背景色会有闪烁感。
解决办法有两个思路:
- 给Change加一个计时器防抖。比如设置Timer控件,Change时重置计时器间隔300毫秒,Timer触发时才真正执行清理,这样能极大减少执行次数。代价是代码复杂度上来了。
- 更省事的方案是直接不用Change清状态,改在AfterUpdate里先清状态再执行验证。这样用户输入过程中没有视觉变化,离开时统一处理。这个方法简单可靠,我后来大部分项目都改成了这种方案。
6.3 性能问题:验证规则膨胀后的处理
规则少的时候引擎加载很快,但规则一旦上千条,每次打开表单都去规则表里查一遍、建索引,用户能明显感觉到卡顿。
优化方向有两个。一个是加载缓存:同一个表单第一次打开后把规则集合缓存到静态变量里,下次打开同一表单直接用缓存。因为规则表在运行过程中极少变更,就算变了,规则管理窗体里加一个“清空缓存”按钮就够用。
另一个是查询优化:LoadRules里只查当前表单的规则,用WHERE FormName限定,索引建在FormName上,数据量再大也不至于慢。如果规则表膨胀到几万条,那就不太适合放Access单文件里了,可以考虑拆分后端。
还有一个更容易忽视的性能坑:ValidateAll遍历所有控件,对每个控件都调用ValidateField,而ValidateField里对每个字段都查一次规则索引、执行若干规则。如果某张表单有60个控件、200条规则,每次保存要执行200次规则判断,其中可能有几十次是唯一的数据库查询。这个耗时会比较可观。
我的优化方案是给ValidateAll增加短路逻辑:遇到第一个阻止级别失败就返回False,不再继续查后面的。这样大多数错误场景都能快速响应,不会白白跑完所有规则。
6.4 与Access内置验证机制的关系处理
Access表层面有ValidationRule和ValidationText属性,窗体控件层面也有自带的验证规则。我的架构和它们并不是替代关系,而是互补。
表级验证是数据层的最后防线,无论从哪个入口改数据都会生效,这个绝对不能丢。我愿意保留表级ValidationRule,但把提示文本精简成一个通用信息,比如“数据不满足表级约束”,详细原因由前端引擎提示。这样即使有程序绕过表单直接写库,数据也不会被垃圾污染。
控件级别的ValidationRule和ValidationText,我建议在接入引擎后停用,因为两套验证同时工作会有重复提示,而且控件级验证的报错弹窗非常突兀,体验不如引擎的温和提示。如果你不确定哪些字段还在用控件级验证,可以打开表单设计器,逐个检查属性表里的ValidationRule有没有值,有值的一律清掉,把规则录入到引擎规则表里。
6.5 代码迁移时的常见错误速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 打开表单后引擎未生效 | Form_Load里没调用Attach,或者Attach时规则表为空 | 检查表单加载代码,用调试器确认mEngine已创建 |
| 验证规则重复执行 | 旧事件代码未清理,新引擎和新事件同时生效 | 按第5.5步的迁移流程,先停旧代码再删 |
| AfterUpdate验证无效 | 控件是绑定控件,引擎读Value时触发错误被On Error吞掉 | 检查控件的ControlSource是否正常,确认值读取成功 |
| 只有部分规则生效 | 规则表里SortOrder相同或未设置,加载顺序混乱 | 给每条规则设置唯一的SortOrder |
| 保存时总是提示验证失败但看不到标红 | 错误发生在子表单,父引擎无法标红子控件 | 子表单独立接入引擎,父表单只询问子表单验证结果 |
| 验证后控件焦点丢失 | 引擎里SetFocus失败,可能因为控件被隐藏或锁定 | 设置焦点前检查控件可见性和Enabled属性 |
7. 个人心得与后续扩展方向
这套架构做完之后,给我的最大感受是:Access项目进入维护期之后,拼的不是谁的代码写得炫,而是谁能更快地回答“这条规则到底在哪、改一下会不会影响别的地方”。把验证规则从代码里挪到数据表里,规则的定义和管理就从“找代码”变成了“查数据”,这本质上是把隐性知识显性化了。新人接手项目,不用一行行读VBA,打开规则管理窗体看一眼就知道业务约束是什么。
从我个人的使用经验看,这套方案在Access环境里最大的价值不是省了多少开发时间,而是降低了系统演进的成本。业务部门提了一个“客户编码格式要改成大写字母加六位数字”的需求,以前我要打开十几个表单改代码,现在只需要在规则管理窗体里把格式规则改一遍,保存即生效。改完以后还能通过规则表快速导出一份完整的验证清单发给业务确认,沟通效率也高了不少。
如果你打算上手这么一套架构,我的建议是别一上来就追求完美。先把必填、格式、范围这三类最基础的规则用起来,等运行稳定了,再逐步加上唯一性、自定义函数、多表联动这些进阶能力。规则表里的ValidationTiming和Severity这两个字段非常有用,但一开始不熟悉的话,可以全部默认成失焦触发、全部阻止级别,等用过一阵子,体会到不同时机的差别,再按需调整。
最后再分享一个实用小技巧:规则表里加一个Remark字段,每次修改规则时顺手填一句为什么改、谁让改的。这套架构配合版本管理,几乎可以还原每一次业务约束变更的前因后果。我在项目交付时也把规则表导成Excel附在文档里,客户运维人员反馈说这是整个交付包里他们翻得最多的文件。所以说,让验证规则“看得见、可维护”,往往比代码本身更有长远价值。
