第 9 章 表单验证服务指南

9.1. 表单概述
9.1.1. 什么是表单验证
9.1.2. 表单验证的形式
9.2. 设计
9.2.1. 验证逻辑与表现逻辑分离
9.2.2. 验证逻辑和应用代码分离
9.2.3. 表单验证的流程
9.3. 使用表单验证服务
9.3.1. 创建新数据
9.3.2. 修改老数据
9.3.3. 批量创建或修改数据
9.4. 表单验证服务详解
9.4.1. 配置详解
9.4.2. Validators
9.4.3. Form Tool
9.4.4. Field keys的格式
9.4.5. 外部验证
9.5. 本章总结

9.1. 表单概述

9.1.1. 什么是表单验证

在WEB应用中,表单验证是非常重要的一环。表单验证,顾名思义,就是确保用户所填写的数据符合应用的要求。例如下面这个“注册新帐户”的表单:

一个典型的表单页面

图 9.1. 一个典型的表单页面

在这个表单中,各字段需要符合以下规则:

表 9.1. 注册新帐户的规则

字段名规则
用户名必须由字母、数字、下划线构成。 · 用户名的
长度必须在某个范围内,例如,4-10个字符。
密码长度必须在某个范围内,例如,4-10个字符。
密码和用户名不能相同,以保证基本的安全性。
确认密码(再输一遍密码)必须和密码相同,确保用户没有打字错误。

从技术上讲,表单验证完全可以用手工书写代码的方式来实现。但是这样做既无趣,又容易出错,而且难以维护 —— 特别是当你需要修改验证规则时。因此,几乎所有的WEB框架都提供了表单验证的功能,使你能方便、快速地书写或修改表单验证的规则。

9.1.2. 表单验证的形式

验证WEB页面中的表单有如下几种形式:

9.1.2.1. 服务端批量验证

服务端批量验证

图 9.2. 服务端批量验证

服务端批量验证是最传统验证形式,它将所有表单字段一次性提交给服务器来验证。服务器对所有表单进行批量的验证后,根据验证的结果跳转到不同的结果页面。

9.1.2.2. 客户端验证

客户端验证

图 9.3. 客户端验证

客户端验证是利用Java Script对用户输入的数据进行逐个验证。

9.1.2.3. 服务端异步验证

服务端异步验证

图 9.4. 服务端异步验证

服务器异步验证是利用Java Script发出异步AJAX请求,来要求服务器验证单个或多个字段。如果网络延迟不明显,那么服务器异步验证给用户的体验类似于客户端验证。

9.1.2.4. 混合式验证

以上几种验证手段各有优缺点:

表 9.2. 各种表单验证的优缺点比较

验证形式功能性网络负荷用户体验简单性可靠性
服务端批量验证

由于验证逻辑存在于服务器上,可访问服务器的一切资源,功能最强。

高。

当用户填错任意一个字段时,所有的字段都必须在浏览器和服务器之间来回传输一次。所以它会给网络传输带来较高的负荷。

差。

由于网络负荷较高,造成的响应迟缓。此外,验证失败时必须整个页面被刷新。这些会给用户带来不好的体验。

简单

它的实现比较简单。

可靠

相对于其它几种方式,服务端批量验证也是最可靠的方式。因为Java Script可能会失效(因为浏览器不支持、Java Script被关闭、网站受攻击等原因),但服务器批量验证总不会失效。

客户端验证

弱。

由于验证逻辑存在于用户浏览器上,不能访问服务器资源,因此有一些功能无法实现,例如:检查验证码、确认注册用户ID未被占用等。

在验证时,不需要网络通信,不存在网络负荷。

响应速度极快,用户体验最好。

复杂。

因为需要JS编程。

不可靠。

由于下列原因,Java Script可能会失效,使得客户端验证被跳过:

  • 浏览器不支持

  • Java Script被关闭

  • 网站受攻击

服务端异步验证

由于验证逻辑存在于服务器上,可访问服务器的一切资源,功能也很强。

低。

每次验证,只需要发送当前被验证字段的数据即可,网络负荷较小。

较好。

由于网络负荷小,用户响应远快于服务端批量验证,用户体验好。

没有一种验证方法是完美的。但把它们结合起来就可以克服各自的缺点,达到较完美的境地:

  • 对所有字段做服务器端批量验证,即便Java Script失效,服务器验证可作为最后的防线。

  • 只要有可能,就对字段做客户端验证,确保最迅速的响应和较好的用户体验。

  • 对于必须访问服务器资源的验证逻辑,例如检查验证码、确认注册帐户ID未被占用等,采用服务器异步验证,提高用户体验。

以上混合形式的验证无疑是好的,但是它的实现也比较复杂。

目前Webx所提供的表单验证服务并没有实现客户端验证和服务端异步验证。这些功能将在后续版本中实现。在现阶段中,应用开发者必须手工编码Java Script来实现客户端验证和服务端异步验证。

9.2. 设计

9.2.1. 验证逻辑与表现逻辑分离

很容易想到的一种表单验证的实现,就是将表单验证的逻辑内嵌在页面模板中。例如,某些WEB框架实现了一些用来验证表单的JSP tags。类似下面的样子:

例 9.1. 将验证逻辑内嵌在页面模板中

<input type="text" name="loginId" value="${loginId}" />
<form:required value="${loginId}">
  <strong>Login ID is required.</strong>
</form:required>
<form:regexp value="${loginId}" pattern="^\w+$">
  <strong>Login ID is invalid.</strong>
</form:regexp>

将验证逻辑内嵌在页面模板中最大的问题是,验证逻辑和页面的表现逻辑完全混合在一起。当你需要修改验证规则时,你必须找出所有的页面,从复杂的HTML代码中,一个不漏地找到并修改它们。这是一件费时费力的工作,而且很容易出错。另一方面,嵌在页面模板中的验证规则是不能被多个页面共享和复用的。

Webx表单验证服务主张验证逻辑和页面表现逻辑完全分离。所有的验证规则都写在一个单独的配置文件中 —— 页面模板是不需要关心这些验证规则的。当你需要修改验证规则时,只需要修改独立的配置文件就可以了,并不用修改页面模板。

9.2.2. 验证逻辑和应用代码分离

另一种容易想到的方法,是把表单验证的逻辑写在Java代码中。例如,在Java代码中直接调用验证逻辑。更高级一点,也许可以通过annotation机制在Java代码中定义验证逻辑,像下面的样子:

例 9.2. 将验证逻辑内嵌在Java代码中

public class LoginAction {
    @Required
    @Regexp("^\\w+$")
    private String loginId;
    …
}

这样做的问题是,当你需要修改验证规则时,你必须一个不漏地找到所有定义annotations的那些代码,并修改它们。另一方面,annotation机制不容易扩展,很难方便地增加新的验证方案。

Webx表单验证服务主张验证逻辑和应用代码完全分离。所有的验证规则都写在一个单独的配置文件中 —— 应用程序的代码是不需要关心这些验证规则的。当你需要修改验证规则时,只需要修改独立的配置文件就可以了,并不需要修改程序代码。

9.2.3. 表单验证的流程

表 9.3. 一个基本的表单验证流程

步骤客户端浏览器WEB服务器页面效果
1.请求表单页面 → 
 ← 返回空白表单
2.用户填写表单,并提交 → 
 ← 验证表单数据,如果验证有错,则返回包含错误信息的表单页面,并提示出错信息。
3.用户修改表单,并再次提交(重复该步骤直至验证成功) → 
 ← 验证表单数据,如果验证通过,则转至下一个页面。通常是显示成功信息。

9.3. 使用表单验证服务

Webx表单验证服务可用来支持以下几种类型的表单需求:

表 9.4. 几种表单需求

需求名称说明
创建新数据也就是让用户在一个空白的表单上填写数据,并验证之。例如,注册一个新帐户。
修改老数据也就是让用户在已填有数据的表单上进行修改,并验证之。例如,修改帐户信息。
批量创建、修改数据也就是在一个表单中,一次性创建、修改多个数据对象。例如,管理员批量审核帐户。

9.3.1. 创建新数据

下面的例子实现了“注册一个新帐户”的功能。

为了实现表单验证的功能,需要由三个部分配合起来工作:

表 9.5. 验证表单所需的部件

部件名称说明
验证规则也就是form service的配置文件。
页面模板通过$form工具,生成表单页面。
Java代码接收表单数据,并作后续处理。

下面逐个介绍。

9.3.1.1. 定义验证规则

表单验证服务是一个基于Spring和Spring Ext的服务,可利用Schema来配置。示例如下:

例 9.3. 表单验证规则示例

<beans:beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:services="http://www.alibaba.com/schema/services"
    xmlns:fm-conditions="http://www.alibaba.com/schema/services/form/conditions"
    xmlns:fm-validators="http://www.alibaba.com/schema/services/form/validators"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xmlns:p="http://www.springframework.org/schema/p"
    xsi:schemaLocation="
        http://www.alibaba.com/schema/services
            http://localhost:8080/schema/services.xsd
        http://www.alibaba.com/schema/services/form/conditions
            http://localhost:8080/schema/services-form-conditions.xsd
        http://www.alibaba.com/schema/services/form/validators
            http://localhost:8080/schema/services-form-validators.xsd
        http://www.springframework.org/schema/beans
            http://localhost:8080/schema/www.springframework.org/schema/beans/spring-beans.xsd
    ">

    <services:form xmlns="http://www.alibaba.com/schema/services/form/validators"> 
        <services:group name="register"> 

            <services:field name="userId" displayName="登录名"> 
                <required-validator> 
                    <message>必须填写 ${displayName}</message> 
                </required-validator>
                <regexp-validator pattern="^[A-Za-z_][A-Za-z_0-9]*$">
                    <message>${displayName} 必须由字母、数字、下划线构成</message>
                </regexp-validator>
                <string-length-validator minLength="4" maxLength="10">
                    <message>${displayName} 最少必须由${minLength}个字组成,最多不能超过${maxLength}个字</message>
                </string-length-validator>
            </services:field>

            <services:field name="password" displayName="密码">
                <required-validator>
                    <message>必须填写 ${displayName}</message>
                </required-validator>
                <string-length-validator minLength="4" maxLength="10">
                    <message>${displayName} 最少必须由${minLength}个字组成,最多不能超过${maxLength}个字</message>
                </string-length-validator>
                <string-compare-validator notEqualTo="userId">
                    <message>${displayName} 不能与 ${userId.displayName} 相同</message>
                </string-compare-validator>
            </services:field>

            <services:field name="passwordConfirm" displayName="密码验证">
                <required-validator>
                    <message>必须填写 ${displayName}</message>
                </required-validator>
                <string-compare-validator equalTo="password">
                    <message>${displayName} 必须和 ${password.displayName} 相同</message>
                </string-compare-validator>
            </services:field>

        </services:group>

    </services:form>

</beans:beans>

<form>代表表单验证服务的配置。从这里开始定义表单验证的规则。

可以定义多个groups,每个group有一个唯一的名称,例如:“register”。每个group代表了一组需要验证的字段(field)。

每个field有一个在组中唯一的名称,例如:“userId”、“password”等。

每个field又包含了多个验证规则(validator)。

每个验证规则都包含了一段文字描述(message),如果用户填写的数据没有通过当前的规则的验证,那么用户将会看到这段文字描述,以解释出错的原因。

表单验证配置文件的结构

图 9.5. 表单验证配置文件的结构

9.3.1.2. 创建表单页面

创建表单页面需要使用一个pull tool工具,配置如下:

例 9.4. 表单验证pull tool的配置

<services:pull xmlns="http://www.alibaba.com/schema/services/pull/factories">
    <form-tool />
    ...
</services:pull>

上面的配置定义了一个$form工具。现在你可以在模板中直接使用它。

例 9.5. 表单验证的页面模板示例

#macro (registerMessage $field) 
    #if (!$field.valid) $field.message #end
#end

<form action="" method="post"> 
  <input type="hidden" name="action" value="UserAccountAction"/> 

  #set ($group = $form.register.defaultInstance) 
  
  <p>用户注册</p>

  <dl>
    <dt>用户名</dt>
    <dd>
        <div>
            <input type="text" name="$group.userId.key" value="$!group.userId.value"/> 
        </div>
        <div class="errorMessage">
            #registerMessage ($group.userId) 
        </div>
    </dd>

    <dt>密码</dt>
    <dd>
        <div>
            <input type="password" name="$group.password.key" value="$!group.password.value"/> 
        </div>
        <div class="errorMessage">
            #registerMessage ($group.password) 
        </div>
    </dd>

    <dt>再输一遍密码</dt>
    <dd>
        <div>
            <input type="password" name="$group.passwordConfirm.key" value="$!group.passwordConfirm.value"/> 
        </div>
        <div class="errorMessage">
            #registerMessage ($group.passwordConfirm) 
        </div>
    </dd>
  </dl>
  
  <p>
      <input type="submit" name="event_submit_do_register" value="立即注册!"/> 
  </p>

</form>

HTML form的action值为空,意思是把表单提交给当前页面。

这样,当用户填写表单有错时,应用会停留在当前表单页面,将表单数据连同错误提示一起显示给用户,要求用户修改。如果表单验证通过,应用必须通过重定向操作来转向下一个页面。

创建一个register group的实例。

利用新创建的group对象来生成表单字段,包括生成字段的名称$group.field.key,以及字段的值为$!group.field.value

定义velocity宏:仅当field验证通过时(即$group.field.valid=true),才显示错误信息。

对于空白表单和通过验证的字段而言,$group.field.validtrue

如果验证失败的话,显示验证出错消息。这里通过前面所定义的velocity宏来简化代码。

根据这参数,表单将会被交给UserAccountAction来处理。Action的职责是调用表单验证过程。假如验证通过,就保存数据,并重定向到下一个页面。

根据这个参数,表单被提交以后,系统会调用当前action(即UserAccountAction)的doRegister()方法。每个action类中,可以包含多个处理数据的动作,例如doCreatedoUpdatedoDelete等。

上面的Velocity页面模板演示了怎样利用表单验证服务创建一个帐户注册的HTML表单。关键技术解释如下:

创建group实例

$form.register.defaultInstance将会对register group创建一个默认的实例。绝大多数情况下,只需要创建唯一的default instance就足够了。但后面我们会讲到创建多实例的例子。

所创建的group instance(如register)必须先在规则配置文件中被定义。

创建一个group实例

图 9.6. 创建一个group实例

生成表单字段

一个表单字段包含名称和值两个部分。

字段的名称为$group.field.key。表单验证服务会自动生成一个字段名。这个字段名被设计成仅供系统内部解读的,而不是让外界的系统或人来解读的。它看起来是这个样子的:“_fm.r._0.p”。外界的系统不应该依赖于这个名称

字段的值为$!group.field.value。它的初始值(即用户填写数据之前)是null。但你也可以在配置文件中为它指定一个默认的初始值,例如:

例 9.6. 在表单验证规则中添加默认值

<services:field name="myfield" defaultValue="mydefault" ...>

因为值可能是null,因此在velocity中,需要以“$!”来标记它 —— Velocity认为null是一个错误,除非你以$!来标记它,告诉velocity忽略它。

需要注意的是,默认值只会影响field的初始值。一旦用户填写并提交了表单,那么$group.field.value的值将保持用户所填写的值不变 —— 不论验证失败或成功。

页面展现

一般来说,你需要定义CSS风格以便让表单的field和错误信息能以适当的格式来显示给用户。展现效果可能是像这个样子:

在页面中显示表单验证错误信息

图 9.7. 在页面中显示表单验证错误信息

表单系统不应该干预页面的具体展现方法,以下内容均和表单系统无关。例如:

  • Field展现的方式:textbox、checkbox、hidden field?

  • 错误信息的颜色、显示位置。

9.3.1.3. 创建Java代码(action)

用户提交表单后,由服务器端的Java代码读取并验证用户的数据。

在Webx中,这个功能通常由action来完成。前文已经提到,在HTML表单中,设置action字段,以及event_submit_do_register提交按钮,就可以让Webx框架调用UserAccountAction.doRegister()方法。

下面是UserAccountAction类的实现代码:

例 9.7. 创建用于处理提交数据的action代码

public class UserAccountAction {
    @Autowired
    private FormService formService; 

    public void doRegister(Navigator nav) throws Exception {
        Form form = formService.getForm(); 

        if (form.isValid()) { 
            Group group = form.getGroup("register"); 

            MyUser user = new MyUser(); 
            group.setProperties(user); 
            save(user);

            // 跳转到注册成功页面
            nav.redirectTo("registerSuccess"); 
        }
    }
}

注入form服务。

取得form对象,form对象中包含若干groups。

仅当表单验证成功时,才执行下去。

取得group对象。Group对象的名称必须和配置文件以及模板中的group名称相同。

将group中的数据灌入bean中。

处理完数据以后,利用Webx navigation接口跳转到“注册成功”页面。

例子中的MyUser对象是一个简单的Java Bean:

例 9.8. 被灌入group数据的Java Bean

public static class MyUser {
    private String userId;
    private String password;

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

Group.setProperties()方法将fields的值映射到同名的Java Bean properties中。然而这个对应关系是可以改变的,后文会再次讲到该问题。

是不是有点复杂?事实上,上面的代码可以通过Webx的参数注入机制加以简化。下面的代码可以完成完全相同的功能,但是代码却短得多。然而,理解前面的较复杂代码,将有助于你理解下面的简化代码

例 9.9. 创建用于处理提交数据的action代码(Annotations简化版)

public class UserAccountAction {
    public void doRegister(@FormGroup("register") MyUser user, Navigator nav) throws Exception {
        save(user);
        nav.redirectTo("registerSuccess");
    }
}

在这个简化版代码中,@FormGroup注解完成了前面复杂代码中的大部分功能,包括:

  • 验证表单,如果失败则不执行action,否则执行doRegister方法。

  • 取得form和register group对象,并将group中的数据注入到MyUser对象中。

9.3.2. 修改老数据

在前面的例子中,我们利用表单创建了一个新数据 —— 注册新帐户。它是从一个空白表单开始的,也就是说,在用户填写表单之前,表单是没有内容的,或只包含默认值的。另一种常见情况是修改老数据。例如“修改帐户资料”。和创建新数据的例子不同,在用户填写表单之前,表单里已经包含了从数据库中取得的老数据。

在创建新数据的模板和代码中,稍微添加一点东西,就可以实现修改老数据的功能。

9.3.2.1. 用screen来读取数据

修改老数据的第一步,是要取得老的数据。例如,取得要修改的帐户信息。在Webx中,这个任务是由screen来完成的:

例 9.10. 用screen取得表单验证的初始数据

public class UserAccount {
    @Autowired
    private UserManager userManager; 

    public void execute(Context context) throws Exception {
        User user = userManager.getUser(getCurrentUser().getId());
        context.put("user", user); 
    }
}

UserManager是一个业务接口。通过它,可以从数据库中取得当前登录帐户的信息。

随后,screen代码把所取得的user对象放到context中,这样,就可以在模板中用$user来引用它。

9.3.2.2. 表单页面

例 9.11. 用来修改数据的页面模板

#set ($group = $form.userAccount.defaultInstance)

$group.mapTo($user) 
...
<input type="hidden" name="$group.userId.key" value="$!group.userId.value"/> 
...

<input type="text" name="$group.lastName.key" value="$!group.lastName.value"/>
...
#userAccountMessage ($group.lastName)
...
<input type="submit" name="event_submit_do_update" value="修改"/> 

在前面“创建新数据”的页面上,加上和修改一点内容:

mapTo的功能是填充表单。

这行代码的意思是:用screen中所取得的user对象的值来填充表单,作为表单的初始值。和Group.setProperties()方法相反,mapTo将Java Bean properties的值映射到同名的fields中。

保存主键。

和创建新数据不同,在修改老数据时,一般需要在表单中包含主键。这个主键(user id)在数据库中唯一识别这一数据对象(user)。

应该避免用户改变主键。最简便的方法,就是用hidden字段来保存主键。

这个submit按钮将引导webx执行UserAccountAction.doUpdate方法。

需要注意的是,调用mapTo在下列情况下是无效的:

  • $user对象不存在(值为null)时,mapTo不做任何事。这样,你就可以让“创建新帐户”和“修改帐户信息”共用同一个模板。在新表单中,由于$user不存在,所以mapTo失效;而在更新表单中,就可以从$user中取得初始的数据。

  • 当用户提交表单以后,mapTo不做任何事。因为mapTo只会影响表单的初始数据。一旦用户修改并提交数据以后,mapTo就不会改变用户所修改的数据。

9.3.2.3. 用action来处理数据

修改老数据的action代码和创建新数据的action代码几乎相同,而且它们可以共享同一个UserAccountAction类:

例 9.12. 用来保存提交数据的action

public class UserAccountAction {
    public void doRegister(...) throws Exception {
        ...
    }

    public void doUpdate(@FormGroup("userAccount") MyUser user, 
                         Navigator nav) throws Exception {
        save(user);
        nav.redirectTo("updateSuccess");
    }
}

通过annotation取得的MyUser对象中,包含了通过hidden字段传过来的user id,以及其它所有字段的值。

9.3.3. 批量创建或修改数据

有时,我们需要在一个表单页面中批量创建或修改一批数据。例如,后台管理界面中,管理员可以一次审核10个帐户的信息。每个帐户的信息格式都是相同的:姓名、性别、年龄、地址等。表单验证服务完全支持这样的表单。

9.3.3.1. 用screen来读取批量数据

假如你希望做的是批量修改数据,很显然,你需要在screen代码中取得所有需要修改的数据。

例 9.13. 批量读取数据的screen

public class BatchUserAccount {
    @Autowired
    private UserManager userManager;

    public void execute(Context context) throws Exception {
        List<User> users = userManager.getUsers(getIds()); 
        context.put("users", users);
    }
}

和修改单个数据的screen代码不同的是,你需要一次性读取多个数据对象,并置入到context中。

9.3.3.2. 表单页面

例 9.14. 批量创建、修改数据的表单页面模板

<form action="" method="post">
  <input type="hidden" name="action" value="UserAccountAction"/>

  #foreach($user in $users) 
    #set ($group = $form.userAccount.getInstance($user.id)) 

    $group.mapTo($user)

    ...
    <input type="hidden" name="$group.userId.key" value="$!group.userId.value"/>

    ...
    <input type="text" name="$group.lastName.key" value="$!group.lastName.value"/>
    ...
    #userAccountMessage ($group.lastName)
    ...

  #end
  ...
  <input type="submit" name="event_submit_do_batch_edit" value="批量修改"/> 
</form>

为了批量创建、修改数据,需要在表单页面中利用foreach循环来遍历数据对象。其中,$users是由screen放入context中的对象列表。

对每个数据对象创建一个group实例。

指定action事件。这个submit按钮将引导webx执行UserAccountAction.doBatchEdit方法。

在前面的例子中,我们一直使用$form.xyz.defaultInstance来创建默认的group实例。而这里,我们改变了用法:$form.userAccount.getInstance($user.id)。每次调用该方法,就对一个group生成了一个实例(instance)。

创建多个group实例

图 9.8. 创建多个group实例

每个instance必须以不同的id来区分。最简单的方法,就是采用数据对象的唯一id来作为group instance的id。在这个例子中,我们采用$user的唯一id($user.id)来区分group instances。

前文讲过,default instance的field key是这个样子的:“_fm.r._0.p”。类似的,通过getInstance("myid")方法所取得的group中的field key是这样的:“_fm.u.myid.n”。很明显,form service就是依赖于field key中所包含的group instance id来区分同一group的不同instances的。

因为field key将作为HTML的一部分,所以group instance的id必须为满足下面的条件:只包含英文字母、数字、下划线、短横线的字符串

页面的其它部分和创建、修改单个数据的代码完全相同。只不过它们被循环生成了多次。 最后的结果是类似下面的样子:

批量修改数据的页面示例

图 9.9. 批量修改数据的页面示例

9.3.3.3. 用action来处理数据

和前面的例子类似,我们先用传统的方法来写action以便阐明原理,再用annotation来简化action代码。

例 9.15. 用来批量处理数据的action

public class UserAccountAction {
    @Autowired
    private FormService formService;

    public void doBatchEdit(Navigator nav) throws Exception {
        Form form = formService.getForm();

        if (form.isValid()) {
            Collection<Group> groups = form.getGroups("userAccount"); 

            for (Group group : groups) {
                MyUser user = new MyUser();

                group.setProperties(user);
                save(user);
            }

            nav.redirectTo("success");
        }
    }
}

通过这个方法,可以取得所有名称为“userAccount”的group instances,包括:user1user2、……。

取得group实例,除了例子中的form.getGroups(groupName)这种形式以外,还有以下几种变化:

  • 取得所有的group instances,无论其名称是什么:Collection<Group> groups = form.getGroups();

  • 取得指定group名称和instance key的group instances:Group user1Group = form.getGroup("userAccount", "user1");

下面是一个简化版的action,实现完全相同的功能。

例 9.16. 用来批量处理数据的action(Annotation简化版)

public class UserAccountAction {
    public void doBatchEdit(@FormGroup("userAccount") MyUser[] users,
                            Navigator nav) throws Exception {
        for (MyUser user : users) {
            save(user);
        }

        nav.redirectTo("success");
    }
}

9.4. 表单验证服务详解

9.4.1. 配置详解

9.4.1.1. 基本配置

例 9.17. 表单验证服务的基本配置

<services:form xmlns="http://www.alibaba.com/schema/services/form/validators"> 

    <services:group name="group1"> 
        <services:field name="field1"> 
            <validator /> 
            <validator />
            ...
        </services:field>

        <services:field name="field2" />
        ...
    </services:group>

    <services:group name="group2">
        ...
    </services:group>

    ...
</services:form>

开始配置表单验证服务。

每个表单验证服务可包含多个groups。

每个group可包含多个fields。

每个field可包含多个validators。

9.4.1.2. Post Only参数

例 9.18. 配置Post Only参数

<services:form postOnlyByDefault="true"> 
    <services:group name="group1" postOnly="true" /> 
</services:form>

如果不指定,postOnlyByDefault的默认值为true

如果不指定,那么postOnly的值取决于postOnlyByDefault。这意味着如果什么也不设置,所有postOnly的实际值均为true

如果一个group被设置成postOnly=true,那么,这个group将不接受通过GET方法提交的数据,只允许通过POST方式提交数据。这样可以略略增加系统的安全性,增加CSRF攻击的难度。

9.4.1.3. Trimming参数

例 9.19. 配置Trimming参数

<services:form>
    <services:group name="group1" trimmingByDefault="true"> 
        <services:field name="field1" trimming="true" /> 
    </services:group>
</services:form>

如果不指定,trimmingByDefault的默认值为true

如果不指定,trimming的值取决于trimmingByDefault。这意味着如果什么也不设置,所有trimming的实际值均为true

用户所提交的字符串数据中,两端的空白往往是无意义的。这些空白可能会影响验证规则的准确性。

如果设置了trimming=true参数,那么表单系统可以自动剪除字段值两端的空白字符,例如把“ my name ”(两端有空白)转变成“my name”(两端无空白)。

9.4.1.4. Display Name参数

例 9.20. 配置Display Name参数

<services:field name="field1" displayName="我的字段"> 
    <required-validator>
        <message>必须填写${displayName}</message> 
    </required-validator>
</services:field>

如果未指定displayName,那么其默认为field名称。也就是的“field1”。

在validator message中,可以引用${displayName}。这样做的好处是,validator message可以被复制给其它的field,而不是需要更动其信息内容。

Display Name是对当前field的一个描述信息。

9.4.1.5. 类型转换

例 9.21. 类型转换的配置

<services:form converterQuiet="true"> 
    <services:property-editor-registrar
        class="com.alibaba.citrus.service.configuration.support.CustomDateRegistrar"
        p:format="yyyy-MM-dd" /> 
</services:form>

如果converterQuiet=true,那么类型转换失败时,将取得默认值。否则,抛出异常。converterQuiet的默认值为true

类型转换采用Spring Property Editor机制。你可以通过注册新的registrar来增加新的类型转换方法。这段配置增加了一种将日期转成字符串的方式(用yyyy-MM-dd格式)。

下面的操作将用到类型转换:

表 9.6. 何时用到类型转换?

操作说明
Group.setProperties(bean)将Group中的所有fields值注入bean properties。
Group.mapTo(bean)用bean properties中的值初始化group fields。

9.4.1.6. 国际化

表单验证失败时,将在页面上显示错误信息。有两种方法可以定义错误信息:

  • 将错误信息直接定义在配置文件中。前文的例子所用的都是这种方案。

  • 将错误信息定义在Spring Message Source中,从而支持国际化。

为了将使用国际化(多语言)的错误信息,首先需要定义Spring Message Source。

例 9.22. 在Spring Message Source中定义错误信息

<bean id="messageSource"
       xmlns="http://www.springframework.org/schema/beans"
       class="org.springframework.context.support.ReloadableResourceBundleMessageSource"
       p:defaultEncoding="GB18030">
    <property name="basenames">
        <list>
            <value>form_msgs</value> 
        </list>
    </property>
</bean>

这段配置告诉spring去读取form_msgs开头的resource bundle文件,例如:

  • form_msgs.properties

  • form_msgs_zh_CN.properties

  • form.msgs_zh_TW.properties

请注意,Spring是从ResourceLoader中读取resource bundle文件的。因此,你可能需要配置Resource Loading以帮助spring找到这些消息文件。关于资源装载,请参见第 5 章 Resource Loading服务指南

使用message source以后,你可以省略validator中的message标签,但是每个validator必须指定id。表单系统将会从message source中查找指定的key:“form.<GroupName>.<FieldName>.<ValidatorID>”。

例 9.23. 配置validator ID

<services:form>
    <services:group name="register">
        <services:field name="userId">
            <required-validator id="required" /> 
        </services:field>
    </services:group>
</services:form>

指定了validator ID为required,根据格式“form.<GroupName>.<FieldName>.<ValidatorID>”,当前validator message的key为:“form.register.userId.required”。

假设message source定义文件及内容如下:

表 9.7. Message Source的内容

文件名内容
form_msgs_zh_CN.properties
form.register.userId.required = 必须填写用户名
form_msgs.properties
form.register.userId.required = User ID is required

对于以上的message source内容,在中文环境中(locale=zh_CN),将显示错误信息“必须填写用户名”;而在英文环境中(locale=en_US),将显示默认的错误信息“User ID is required”。

系统的当前locale是由SetLocaleRequestContext来决定的。关于SetLocaleRequestContext的设定和使用,请参见第 7 章 Request Contexts功能指南

此外,你还可以可以改变message source中key的前缀。

例 9.24. 改变message source key的前缀

<services:form messageCodePrefix="myform">
    ...
</services:form>

上面的配置将指导表单系统在message source中查找指定的key:“myform.GroupName.FieldName.ValidatorID”。

9.4.1.7. 切分表单服务

在实际的应用中,有时一个表单规则的配置文件会很长。将一个长文件切分成几个较短的文件,更有利于管理。表单验证服务支持导入多个form表单服务,从而实现分割较长配置文件的功能。

例 9.25. 切分表单服务

主配置文件:form.xml

<?xml version="1.0" encoding="UTF-8" ?>
<beans:beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:services="http://www.alibaba.com/schema/services"
    xmlns:beans="http://www.springframework.org/schema/beans">

    <beans:import resource="inc/form_part1.xml" /> 
    <beans:import resource="inc/form_part2.xml" /> 

    <services:form xmlns="http://www.alibaba.com/schema/services/form/validators" primary="true"> 
        <services:import form="part1" /> 
        <services:import form="part2" /> 

        ...
    </services:form>

</beans:beans>

导入包含着子表单服务的spring配置。

定义主表单服务时,必须指定primary="true"。否则spring将无法区分主从表单服务,从而导致注入FormService时失败。

导入指定ID的子表单服务。

子表单服务的配置文件:inc/form_part1.xmlinc/form_part2.xml

<!-- inc/form_part1.xml -->
<beans:beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:services="http://www.alibaba.com/schema/services"
    xmlns:beans="http://www.springframework.org/schema/beans">

    <services:form id="part1"> 
        ...
    </services:form>

</beans:beans>

<!-- inc/form_part2.xml -->
<beans:beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:services="http://www.alibaba.com/schema/services"
    xmlns:beans="http://www.springframework.org/schema/beans">

    <services:form id="part2"> 
        ...
    </services:form>

</beans:beans>

子表单服务必须指定ID。

导入子表单服务,意味着将子表单服务中的所有groups导入到主表单的空间。主表单中的groups将会覆盖被导入子表单中的groups。也就是说,假如主表单中存在一个group,它的名字和被导入的子表单中的group同名,那么子表单中的group将被忽略。

9.4.1.8. Group的继承和导入

在实际应用中,你会发现有一些groups的定义很相似。继承和导入的目的是让这些相似的groups之间可以共享共同的参数、字段和验证规则,避免重复定义。

下面是group继承的用法:

例 9.26. 继承一个group

<services:form>
    <services:group name="baseGroup">
        <services:field name="field1" >
            <validator1 />
            <validator2 />
        </services:field>
        <services:field name="field2" />
        <services:field name="field3" />
    </services:group>

    <services:group name="subGroup" extends="baseGroup">
        <services:field name="field1">
            <validator3 />
        </services:field>
        <services:field name="field4" />
    </services:group>

</services:form>

这段配置中,subGroup继承了baseGroup。其效果是:

  • baseGroup的参数(postOnlytrimmingByDefault)被subGroup继承,除非subGroup明确指定了该参数。

  • baseGroup中fields被subGroup继承。具体来说:

    • baseGroup中不同名的fields被直接添加到subGroup中。

    • baseGroup中同名的fields被subGroup中的继承。具体来说:

      • baseGroup field的参数(namedisplayNamedefaultValuetrimmingpropertyName)被subGroup field继承,除非subGroup field明确指定了该参数。

      • baseGroup field中的validators被全部添加到subGroup field中。

因此,上面配置所定义的subGroup和下面配置中的完全等效:

例 9.27. Group继承的效果

<services:form>
    <services:group name="subGroup">
        <services:field name="field1">
            <validator1 /><!-- 来自baseGroup -->
            <validator2 /><!-- 来自baseGroup -->
            <validator3 />
        </services:field>
        <services:field name="field2" /><!-- 来自baseGroup -->
        <services:field name="field3" /><!-- 来自baseGroup -->
        <services:field name="field4" />
    </services:group>
</services:form>

另一种和继承类似的功能是导入:

例 9.28. 导入groups和fields

<services:form>
    <services:group name="group1">
        <services:field name="field1" />
        <services:field name="field2" />
        <services:field name="field3" />
    </services:group>

    <services:group name="group2">
        <services:import group="group1" /> 
        <services:field name="field4" />
    </services:group>

    <services:group name="group3">
        <services:import group="group1" field="field1" /> 
       <services:field name="field5" />
    </services:group>

</services:form>

导入group1中的全部fields。导入以后,group2拥有的fields包括:field1field2field3field4。其中,field1field2field3均来自于group1

导入group1中的一个field。导入以后,group3拥有的fields包括:field1field5。其中,field1来自于group1

导入有两种形式,导入全部fields和导入一个field。

导入和继承都可以使group共享一些内容,但是,

  • 继承只能有一个base group,而导入可以有多次import;

  • 继承会合并同名的fields,而导入时则禁止同名fields的。在上例中,假如group2已经有了field1,那么再次导入group1field1将会报错。

9.4.1.9. 设置默认值

例 9.29. 设置表单字段的默认值

<services:field name="field1" defaultValue="defaultValue" />

当表单被创建时,所有的字段值默认都是空的 —— 除非你指定了defaultValue。需要注意的是,默认值只影响初始表单。对于用户已经提交数据的表单不起作用

假如一个field需要多个值,例如多选的checkbox,那么它可以设置一个具有多值的默认值。方法是:用逗号分隔多值。像下面的样子:

例 9.30. 设置多个值作为表单字段的默认值

<services:field name="field1" defaultValue="defaultValue1, defaultValue2, defaultValue3" />

9.4.1.10. Fields和properties

Fields和properties是两个重要词汇。

Fields

Fields是指HTML表单中的form fields,例如一个文本框textbox、复选框checkbox、hidden字段等。

Fields也是表单验证的基本单元。

Properties

Properties是指Java bean中的数据成员,例如:setName(String)方法定义了一个property:name

而在表单验证服务中,

  • Group.setProperties(bean)方法将fields中的值注入到bean的properties中。

  • Group.mapTo(bean)方法将bean properties的值设置成fields的初始值。

一般情况下,field的名称就是property的名称。然而有一些情况下,property名称和field名称会有出入。这时可以这样设置:

例 9.31. 设置不同的property和field名称

<services:field name="homeAddress" propertyName="home.address" />

其中,“homeAddress”为field名称。如果不指定propertyName的话,表单系统认为property名称也是“homeAddress”。然而在这里,指定了property名称为“home.address” —— spring支持这种多级的property。在上面的配置中,

  • 当做Group.setProperties()时,会执行bean.getHome().setAddress(value)

  • 而做Group.mapTo()时,会执行bean.getHome().getAddress()

9.4.1.11. Validator messages

每一个validator都可以附带一段message文字,这段文字会在validator验证失败时显示给用户看。配置validator message可以有下面两种写法:

例 9.32. Validator message的两种写法

<string-length-validator minLength="4" maxLength="10">
    <message>登录名最少必须由4个字组成,最多不能超过10个字</message>
</string-length-validator>

或者,你可以这样写:

<string-length-validator minLength="4" maxLength="10">
    <message>${displayName} 最少必须由${minLength}个字组成,最多不能超过${maxLength}个字</message>
</string-length-validator>

第二种形式使用了替换变量,例如:${displayName}等。这种方法有较多好处:

  • 易复制 —— 假如有多个fields中都包含string-length-validator,由于每个fields的名称(displayName)、validator的参数(minLengthmaxLength)都不同,第一种形式是不可复制的,而第二种形式是通用的、可复制的。

  • 易维护 —— 当validator的参数被修改时,例如minLength被修改,对于第一种形式,你必须同时修改message字符串 —— 这很可能被忘记;而第二种形式就不需要修改message字符串。

事实上,validator message是一个JEXL表达式,其格式详见:http://commons.apache.org/jexl/reference/syntax.html。 下面列出了在message中可用的变量和工具。

表 9.8. Validator message中可用的变量和工具

分类可用的变量和工具
当前Validator中的所有properties

不同的validator有不同的properties。

例如,对于<string-length-validator minLength="4" maxLength="10">

可取得的变量包括${minLength}${maxLength}

当前Field对象中的所有properties${displayName}Field显示名
${defaultValue}默认值(String
${defaultValues}默认值数组(String[]
${value}当前field的值(Object
${values}当前field的一组值(Object[]
特定类型的值,例如:${booleanValue}${intValue}等。
当前Group中的其它Field对象

例如:${userId}${password}等。

如果想取得其值,必须这样写:${userId.value}

也可取得其它Field properties,例如:${userId.displayName}

当前的Group对象${group}
当前的Form对象${form}
System properties

所有从System.getProperties()中取得的值,

例如:${user.dir}${java.home}

小工具

${utils},其中包含了很多静态方法。

例如:${utils.repeat("a", 10)}将会生成10个“a”。

详见com.alibaba.citrus.util.Utils类的API文档。

9.4.2. Validators

每个field都可以包含多个validators。Validators是用来验证当前field的正确性的。表单验证系统提供了很多常用的validators。然而,如果不够用,你还可以随时扩展出新的validators。

9.4.2.1. 验证必选项:<required-validator>

例 9.33. 配置<required-validator>

<services:field name="field1" displayName="我的字段">
    <required-validator>
        <message>必须填写${displayName}</message>
    </required-validator>
</services:field>

这是最常用的一个验证器。它的功能是确保用户填写了字段,即确保字段值非空。

需要注意的是,必选项验证器会受到trimming参数的影响。假如trimming=true(默认值),而用户输入了一组空白字符,那么仍然被认为“字段值为空”;反之,如果trimming=false,当用户输入空白时,会被认为“字段值非空”。

除了必选项验证器以外,其它绝大多数的验证器并不会判断字段值是否为空。例如:

例 9.34. 绝大多数的验证器并不会判断字段值是否为空

<services:field name="userId" displayName="登录名">
    <regexp-validator pattern="^[A-Za-z_][A-Za-z_0-9]*$">
        <message>${displayName} 必须由字母、数字、下划线构成</message>
    </regexp-validator>
</services:field>

在这个例子中,即便用户什么也没填,<regexp-validator>也会通过验证。换言之,<regexp-validator>只负责当字段值非空时,检查其是否符合特写的格式。

因此通常需要将必选项验证器和其它验证器配合起来验证。如下例:

例 9.35. 将必选项验证器和其它验证器配合起来验证

<services:field name="userId" displayName="登录名">
    <required-validator>
        <message>必须填写 ${displayName}</message>
    </required-validator>
    <regexp-validator pattern="^[A-Za-z_][A-Za-z_0-9]*$">
        <message>${displayName} 必须由字母、数字、下划线构成</message>
    </regexp-validator>
</services:field>

这样配置以后,就可以确保:

  • 字段值非空;

  • 字段值符合特定格式。

9.4.2.2. 验证字符串长度:<string-length-validator>

例 9.36. 配置<string-length-validator>

<services:field name="field1" displayName="我的字段">
    <string-length-validator minLength="4" maxLength="10">
        <message>${displayName} 最少必须由${minLength}个字组成,最多不能超过${maxLength}个字</message>
    </string-length-validator>
</services:field>

该验证器确保用户输入的字段值的字符数在确定的范围内。

可用的参数:

表 9.9. <string-length-validator>的可用参数:

参数名说明
minLength代表最少字符数,如不设置minLength代表最代表不设下限。
maxLength代表最多字符数,如果不设置maxLength则代表不设上限,允许任意多个字符。

9.4.2.3. 验证字符串字节长度:<string-byte-length-validator>

例 9.37. <string-byte-length-validator>的配置

<services:field name="field1" displayName="我的字段">
    <string-byte-length-validator minLength="4" maxLength="10" charset="UTF-8">
        <message>${displayName} 最少必须由${minLength}个字节组成,最多不能超过${maxLength}个字节</message>
    </string-byte-length-validator>
</services:field>

该验证器也是用来验证字符值的长度的。但是和前面所讲的<string-length-validator>不同的是,<string-byte-length-validator>会将字符串先转换成字节串,然后判断这个字节串的长度。

为什么需要判定字节的长度呢?因为在数据库中,常用字节长度而不是字符长度来表示一个字段的长度的。例如:varchar(20)代表该数据库字段可接受的字节长度为20字节,超过部分将被截断。20字节可填入:

  • 20个英文字母、数字,

  • 或者6个UTF-8编码的汉字,

  • 或者10个GBK编码的汉字。

可见20字节所能容纳的字符数是不确定的,取决于字符的类型(中文、英文、数字等),以及字符集编码的类型(ISO-8859-1UTF-8GBK等)。

可用的参数:

表 9.10. <string-byte-length-validator>的可用参数:

参数名说明
minLength代表最少字节数,如不设置minLength代表最代表不设下限。
maxLength代表最多字节数,如果不设置maxLength则代表不设上限,允许任意多个字符。
charset用来将字符串转换成字节,如不设置,则取当前线程的上下文编码。

9.4.2.4. 比较字符串:<string-compare-validator>

例 9.38. <string-compare-validator>的配置

<services:field name="field1" displayName="我的字段">
    <string-compare-validator notEqualTo="field2">
        <message>${displayName} 不能与 ${field2.displayName} 相同</message>
    </string-compare-validator>
</services:field>

该验证器将当前字段的值和另一个字段比较。

可用的参数:

表 9.11. <string-compare-validator>的可用参数:

参数名说明
equalTo确保当前字段值和指定的另一字段的值相同。
notEqualTo确保当前字段值和指定的另一字段的值不相同。
ignoreCase如果为true,则比较时忽略大小写。默认为false,即比较大小写。

9.4.2.5. 用正则表达式验证:<regexp-validator>

例 9.39. <regexp-validator>的配置

<services:field name="field1" displayName="我的字段">
    <regexp-validator pattern="^[A-Za-z_][A-Za-z_0-9]*$">
        <message>${displayName} 必须由字母、数字、下划线构成</message>
    </regexp-validator>
</services:field>

该验证器用正则表达式来验证字符串的格式。

可用的参数:

表 9.12. <regexp-validator>的可用参数:

参数名说明
pattern

用来匹配字符串的正则表达式。需要注意的是:

  • 表达式为部分匹配,例如:表达式“abc”可以匹配用户输入“xabcy”。如果期望匹配完整字符串,必须使用“^”和“$”标识符。例如表达式“^abc$”只能匹配“abc”而不能匹配“xabcy”。

  • 表达式支持否定匹配,例如:表达式“!abc”可以匹配所有不包含“abc”的字符串。

9.4.2.6. 验证电子邮件地址:<mail-address-validator>

例 9.40. <mail-address-validator>的配置

<services:field name="field1" displayName="我的字段">
    <mail-address-validator>
        <message>${displayName} 必须是合法电子邮件地址</message>
    </mail-address-validator>
</services:field>

该验证器用来确保用户输入了合法的电子邮件地址。

事实上,该验证器使用了一个比较宽松的正则表达式来验证电子邮件地址:“^\S+@[^\.]\S*$”。假如你觉得这个正则表达式不足以验证你所需要的邮件地址,你可以利用<regexp-validator>和自定义的正则表达式来直接验证。

9.4.2.7. 验证数字:<number-validator>

例 9.41. <number-validator>的配置

<services:field name="field1" displayName="我的字段1">
    <number-validator>
        <message>${displayName} 必须是数字</message>
    </number-validator>
</services:field>
<services:field name="field2" displayName="我的字段2">
    <number-validator numberType="int" lessThan="100">
        <message>${displayName} 必须是小于${lessThan}的整数</message>
    </number-validator>
</services:field>

该验证器用来确保用户输入了合法的数字。数字的合法性包括:格式的合法和范围的合法。

可用的参数:

表 9.13. <number-validator>的可用参数:

参数名说明
numberType数字的类型。可用的类型为:intlongfloatdoublebigDecimal。如不设置,默认值为int
equalTo可选数字范围:要求数字等于指定值。
notEqualTo可选数字范围:要求数字不等于指定值。
lessThan可选数字范围:要求数字小于指定值。
lessThanOrEqualTo可选数字范围:要求数字小于或等于指定值。
greaterThan可选数字范围:要求数字大于指定值。
greaterThanOrEqualTo可选数字范围:要求数字大于或等于指定值。

9.4.2.8. 比较数字:<number-compare-validator>

例 9.42. <number-compare-validator>的配置

<services:field name="field1" displayName="我的字段">
    <number-compare-validator greaterThanOrEqualTo="field2" lessThan="field3">
        <message>${displayName} 必须是位于 ${field2.displayName} 和 ${field3.displayName}之间的数字</message>
    </number-compare-validator>
</services:field>

该验证器将当前字段的值和另一个字段比较。

可用的参数:

表 9.14. <number-compare-validator>的可用参数:

参数名说明
numberType数字的类型。可用的类型为:intlongfloatdoublebigDecimal。如不设置,默认值为int
equalTo可选数字范围:要求数字等于指定字段的值。
notEqualTo可选数字范围:要求数字不等于指定字段的值。
lessThan可选数字范围:要求数字小于指定字段的值。
lessThanOrEqualTo可选数字范围:要求数字小于或等于指定字段的值。
greaterThan可选数字范围:要求数字大于指定字段的值。
greaterThanOrEqualTo可选数字范围:要求数字大于或等于指定字段的值。

9.4.2.9. 验证日期:<date-validator>

例 9.43. <date-validator>的配置

<services:field name="field1" displayName="我的字段">
    <date-validator format="yyyy-MM-dd">
        <message>${displayName} 必须是日期,格式为 ${format}</message>
    </date-validator>
</services:field>

该验证器用来确保用户输入正确的日期格式,也可以限定日期的范围。

可用的参数:

表 9.15. <date-validator>的可用参数:

参数名说明
format日期的格式,如不指定,默认为yyyy-MM-dd
minDate可选的日期范围:最早的日期。该日期格式也是用format参数来表示的。
maxDate可选的日期范围:最晚的日期。该日期格式也是用format参数来表示的。

9.4.2.10. 验证上传文件:<uploaded-file-validator>

例 9.44. <uploaded-file-validator>的配置

<services:field name="picture" displayName="产品图片">
    <uploaded-file-validator extension="jpg, gif, png">
        <message>${displayName}不是合法的图片文件</message>
    </uploaded-file-validator>
    <uploaded-file-validator maxSize="100K">
        <message>${displayName}不能超过${maxSize}字节</message>
    </uploaded-file-validator>
</services:field>

该验证器用来验证用户上传文件的大小、类型等信息。

可用的参数:

表 9.16. <date-validator>的可用参数:

参数名说明
minSize最小文件尺寸。可使用K/M等单位,例如:10K1M等。
maxSize最大文件尺寸。可使用K/M等单位,例如:10K1M等。
extension

允许的文件名后缀,多个后缀以逗号分隔。例如:gifjpgpng

注意,文件名是由浏览器传递给服务器的,因此验证器并不能保证保证文件确实是文件名后缀声明的格式

例如,xxx.jpg有可能是一个exe可执行文件。

contentType

允许的文件类型,多个类型以逗号分隔。

例如:image/gif, image/jpeg, image/pjpeg, image/jpg, image/png

注意,contentType是由浏览器传递给服务器的,因此验证器并不能保证文件确实是浏览器所声明的格式

其次,有些浏览器不会传送contentType。因此推荐使用extension文件名后缀验证,来取代contentType验证。

注意:(请补充阅读第 7 章 Request Contexts功能指南

  • 上传文件验证只能检查浏览器所声称的文件名后缀和类型,并不能保证文件名后缀和类型属实。如果你希望进一步检查文件的内容,可以结合request-contexts/parser/filter的功能。

  • 假如设置了request-contexts/parser/uploaded-file-whitelist,那么不符合要求的文件会在进入表单验证之前被删除。因此上传文件验证器的extension参数必须存在于uploaded-file-whitelist.extensions列表当中。

  • Upload服务可限制请求的总尺寸,大于该尺寸的请求会被全部忽略,以保证服务器的安全性 —— 这意味着对于这类超大请求,你根本读不到这个请求中的所有参数,当然也不可能执行到表单验证的阶段。

  • Upload服务可以限制每个上传文件的尺寸,大于指定尺寸的文件会被删除。但只要请求的总尺寸还是在许可范围内,那么除了被删文件以外,其它的参数和文件还是可以被取得的。因此,上传文件验证器的maxSize必须小于upload服务中设置的单个文件的最大尺寸才有意义。

9.4.2.11. 验证CSRF token

CSRF是跨站请求伪造(Cross-site request forgery)的意思,它是一种常见的WEB网站攻击方法。攻击者通过各种方法伪造一个请求,模仿用户提交表单的行为,从而达到修改用户的数据,或者执行特定任务的目的。为了假冒用户的身份,CSRF攻击常常和XSS攻击配合起来做,但也可以通过其它手段,例如诱使用户点击一个包含攻击的链接。

通过CSRF token,可以确保该请求确实是用户本人填写表单并提交的,而不是第三者伪造的,从而避免CSRF攻击。CSRF token验证器是用来确保表单中包含了CSRF token。

CSRF token的验证是每个表单都需要的安全功能,所以通常可利用group的继承功能来定义CSRF token验证器。

例 9.45. <csrf-validator>的配置

<services:group name="csrfTokenCheckGroup">
    <services:field name="csrfToken">
        <csrf-validator>
            <message>您提交的表单已过期</message>
        </csrf-validator>
    </services:field>
</services:group>

<services:group name="group1" extends="csrfTokenCheckGroup">
    ...
</services:group>

除了表单验证以外,实现CSRF验证还需要其它几个步骤:

  • 定义pull tool。

    例 9.46. 定义CSRF pull tool

    <services:pull xmlns="http://www.alibaba.com/schema/services/pull/factories">
        ...
        <csrfToken /> 
        ...
    </services:pull>

    定义了pull tool以后,可以在模板中以$csrfToken来引用它。

  • 在每一个表单中创建一个保存着CSRF token的hidden字段。

    例 9.47. 在模板中插入包含CSRF token的hidden字段

    <form action="" method="post">
      $csrfToken.hiddenField 
      <input type="hidden" name="action" value="LoginAction"/>
      ...
    </form>

    调用$csrfToken.hiddenField以后将创建一个包含CSRF long live token的hidden字段,等同于调用$csrfToken.longLiveHiddenField

    有两种CSRF token,你也可以用下面两种方法来创建它们:

    • 创建unique token:$csrfToken.uniqueHiddenField。这种类型的token不仅能防止CSRF攻击,还能防止重复提交表单。

    • 创建long live token:$csrfToken.longLiveHiddenField。这种类型的token只能防止CSRF攻击,不能防止重复提交表单。

  • 在pipeline中验证token。

    例 9.48. 

    <services:pipeline xmlns="http://www.alibaba.com/schema/services/pipeline/valves">
        ...
        <checkCsrfToken /> 
        ...
    </services:pipeline>

    此处可以指定一个tokenKey参数。如果不指定,将使用默认的token key:_csrf_token

  • 在表单验证中指定postOnly=true(默认值),有助于提高CSRF攻击的难度。

    例 9.49. 配置Post Only参数

    <services:form postOnlyByDefault="true">
    </services:form>

9.4.2.12. Custom Error – 由action来验证数据

有一些情况下,由validator来验证数据并不方便。例如,当我们注册帐户时,即便用户名的格式完全正确(由字母和数字构成,并在指定的字数范围之内),也有可能注册不成功的。原因是当前用户名已经被其它用户注册使用了。而判断用户名是否可用,最简单的办法是在action中通过访问数据库来确定。

Custom Error“验证器”就是用来满足这个需求。

例 9.50. <custom-error>的配置

<services:group name="register">
    <services:field name="userId" displayName="登录名">
        <required-validator>
            <message>必须填写 ${displayName}</message>
        </required-validator>
        <regexp-validator pattern="^[A-Za-z_][A-Za-z_0-9]*$">
            <message>${displayName} 必须由字母、数字、下划线构成</message>
        </regexp-validator>
        <string-length-validator minLength="4" maxLength="10">
            <message>${displayName} 最少必须由${minLength}个字组成,最多不能超过${maxLength}个字</message>
        </string-length-validator>
        <custom-error id="duplicatedUserId"> 
            <message>登录名“${userId}”已经被人注掉了,请尝试另一个名字</message>
        </custom-error>
    </services:field>
    ...
</services:group>

Custom Error“验证器”不做任何验证 —— 它把验证的责任交给action来做。但是除此以外,它和其它验证器完全相同 —— 你可以设置message,甚至可以用message source来实现国际化的错误提示。你不需要把错误提示写在代码中,或者启用另一种错误提示方案。

对于custom error,需要在action中有相应的支持,否则不会自动生效:

例 9.51. 用来生成custom error的action代码

public void doRegister(@FormField(name = "userId", group = "register") CustomErrors err, 
                           ...) throws Exception {
    try {
        ...
    } catch (DuplicatedUserException e) {
        Map<String, Object> params = createHashMap();
        params.put("userId", user.getUserId());

        err.setMessage("duplicatedUserId", params); 
    }
}

注入CustomErrors接口。和注入Field的方法相同,通过@FormField注解指明CustomErrors所在的 group名称以及field名称。

调用CustomeErrors.setMessage()方法。其中,“duplicatedUserId”就是配置文件中<custom-error>id

第二个参数params是可选的。它是一个Map,其中的所有值,都可以在<custom-error>message中访问到。例如,这里指定的userId参数值,就可以被message表达式${userId}所访问。

9.4.2.13. 条件验证

条件验证就是让某些validator仅在条件满足时才验证。条件验证有两种,单分支和多分支验证。

例 9.52. 单分支条件验证

<services:field name="other" displayName="其它建议">
    <if test="commentCode.value == 'other'">
        <required-validator>
            <message>必须填写 ${displayName}</message>
        </required-validator>
    </if>
</services:field>

例 9.53. 多分支条件验证

<services:field name="field1" displayName="我的字段">
    <choose>
        <when test="expr1">
            <validator />
        </when>
        <when test="expr2">
            <validator />
            <validator />
        </when>
        <otherwise>
            <validator />
        </otherwise>
    </choose>
</services:field>

在上面的配置示例中,<if><when>都支持的test参数,其内容为JEXL表达式。其格式详见:http://commons.apache.org/jexl/reference/syntax.html。JEXL表达式中可用的变量同Validator messages中可用的变量,请参见:第 9.4.1.11 节 “Validator messages”。 除此之外,条件分支还支持任意自定义的条件,方法是:

例 9.54. 在条件验证中自定义条件

<if xmlns:fm-conditions="http://www.alibaba.com/schema/services/form/conditions">
    <fm-conditions:condition class="xxx" /> 
</if>
...
<when xmlns:fm-conditions="http://www.alibaba.com/schema/services/form/conditions">
    <fm-conditions:condition class="xxx" /> 
</when>

实现类只需要实现Condition接口就可以了。

9.4.2.14. 多值验证

HTML表单字段均支持多值。比如:

例 9.55. 具有多值的HTML表单字段

<p>你喜欢吃哪些食物?</p>
<input type="checkbox" name="poll" value="italian" /> 意大利菜
<input type="checkbox" name="poll" value="french" /> 法国菜
<input type="checkbox" name="poll" value="chinese" /> 中国菜

当用户选择了多个复选框并提交以后,在表单系统中体现为数组:

field.getName();   // "poll"
field.getValues(); // "italian", "french", "chinese"

不仅仅是复选框,任何其它类型的输入框(textbox、hidden field、file upload等)都支持多值。然而前面所说的所有validator只对field中的第一个值进行验证。假如我希望对多个值同时进行验证,该怎么办呢?表单验证服务提供了一组用于多值验证的validators。

9.4.2.14.1. 验证值的数量

例 9.56. <multi-values-count-validator>的配置

<services:field name="poll" displayName="调查">
    <multi-values-count-validator minCount="1" maxCount="3"> 
        <message>至少选择${minCount}项,最多选择${maxCount}项</message>
    </multi-values-count-validator>
</services:field>

对用户提交的值的数量进行验证,迫使用户选择1-3项他喜欢的食物。

9.4.2.14.2. 要求所有值均通过验证

例 9.57. <all-of-values>的配置

<all-of-values>
    <message>${allMessages}</message>
    <validator />
    <validator />
</all-of-values>

只有当前字段的所有值都符合要求,<all-of-values>验证才会通过。其message支持${allMessages},它是一个List列表,可以用来显示所有未通过验证的validators的消息。

9.4.2.14.3. 要求任意一个值通过验证

例 9.58. <any-of-values>的配置

<any-of-values>
    <message>至少有一个${displayName}要符合要求</message>
    <validator />
    <validator />
</any-of-values>

只要当前字段有一个值通过验证,<any-of-values>验证就会通过。其message支持${valueIndex}代表被验证通过的值的序号;支持${allMessages},它是一个List列表,可以用来显示所有未通过验证的validators的消息。

9.4.2.14.4. 要求任意一个值都不通过验证

例 9.59. <none-of-values>的配置

<none-of-values>
    <message>所有${displayName}都不能符合要求</message>
    <validator />
    <validator />
</none-of-values>

只要当前字段有一个值通过验证,<none-of-values>验证就会失败。

9.4.2.15. 组合验证

组合验证就是将validators组合起来,类似于Java中的and(&&)、or(||)、not(!)等操作符的功能。

9.4.2.15.1. 要求所有validators通过验证

例 9.60. <all-of>的配置

<all-of>
    <validator />
    <validator />
</all-of>

只要有一个validator通不过验证,就失败。<all-of>不需要设置message,它的message就是第一个没有通过验证的validator的message

9.4.2.15.2. 要求任意一个validators通过验证

例 9.61. <any-of>的配置

<any-of>
    <message>${allMessages}</message>
    <validator />
    <validator />
</any-of>

只要有一个validator通过验证,<any-of>验证就会通过。其message支持${allMessages},它是一个List列表,可以用来可以用来显示所有未通过验证的validators的消息。

9.4.2.15.3. 要求任何一个validators都通不过验证

例 9.62. <none-of>的配置

<none-of>
    <message>${displayName}不符合要求</message>
    <validator />
    <validator />
</none-of>

只要有一个validator通过验证,<none-of>验证就会失败。

9.4.3. Form Tool

Form Tool是一个pull tool工具,配置如下:

例 9.63. Form Tool的配置

<services:pull xmlns="http://www.alibaba.com/schema/services/pull/factories">
    <form-tool />
    ...
</services:pull>

上面的配置定义了一个$form工具。可以在模板中直接使用它。下页简单介绍在模板中,$form工具的用法。

9.4.3.1. Form API

表 9.17. 有关Form的API

API用法说明
#if ($form.valid) ... #end判断当前form是否验证为合法,或者未经过验证。
#set ($group = $form.group1.defaultInstance)取得group1的默认实例,如果不存在,则创建之。
#set ($group = $form.group1.getInstance("id"))取得group1的指定id的实例,如果不存在,则创建之。
#set ($group = $form.group1.getInstance("id", false))取得group1的指定id的实例,如果不存在,则返回null
#foreach ($group in $form.groups) ... #end遍历当前form中所有group实例。
#foreach ($group in $form.getGroups("group1")) … #end遍历当前form中所有名为group1的实例。

9.4.3.2. Group API

表 9.18. 有关Group的API

API用法说明
#if ($group.valid) … #end判断当前group是否验证为合法,或者未经过验证(即初始表单)
#if ($group.validated) ... #end判断当前group是否经过验证(初始表单为未经过验证的表单)
$group.field1取得field1
#foreach ($field in $group.fields) ... #end遍历当前group中所有的fields
$group.mapTo($bean)将bean中的properties设置成group的初始值。 该操作只对初始表单有效。如果bean为null则忽略该操作。

9.4.3.3. Field API

创建一个HTML表单字段

例 9.64. 创建一个HTML表单字段

$field.displayName
<input type="text" name="$field.key" value="$!field.value" />

其中,displayName来自于配置文件。将displayName显示在页面中的好处是,确保页面与出错信息的措辞一致。

判断验证合法性,并显示错误消息

例 9.65. 判断验证合法性,并显示错误消息

#if (!$field.valid)
  <div class="error">$field.message</div>
#end
取得多值

例 9.66. 取得多值

#foreach ($value in $field.values) ... #end
创建checkbox和radiobox的默认值

例 9.67. 创建checkbox和radiobox的默认值

<input type="hidden" name="$field.absentKey" value="$value" />

或者简化为:

$field.getAbsentHiddenField($value)

Checkbox和radiobox有一个特性,当用户没有选中它们时,它们是没有值的(就像不存在一样)。这点对于表单验证会带来不便。

表单服务支持一种特殊的absentKey。通过它,可以为checkbox/radiobox设置默认值。这样,当用户没有选中任何checkbox或radiobox时,这个值就成为field的值。

创建附件

Field可以带一个附件。附件是一个对象,被序列化保存在hidden字段中。在下一次请求的时候,附件可以被恢复成对象。通过附件,可以让应用程序在表单中携带一些附加信息。 下页的代码会生成一个hidden字段,将$obj序列化保存在其中:

例 9.68. 创建附件

$field.setAttachment($obj)
$field.attachmentHiddenField

当你要取得它时,只要这样:

#set ($obj = $field.attachment)

判断是否有附件:

#if ($field.hasAttachment()) … #end

清除附件:

$field.cleanAttachment()

9.4.4. Field keys的格式

表单验证服务所生成的field key是这样的:“_fm.r._0.p”。它是由几部分组成:

表 9.19. 压缩格式的field key(以_fm.r._0.p为例)的组成

名称说明

_fm

固定的前缀。它是单词“form”的缩写。表单系统依此来识别该字段为需要验证的表单字段。

r

被压缩的group名称。

_0

代表group instance的唯一ID。_0是默认的ID。

p

被压缩的field名称。

在以上例子中,field keys被压缩了。压缩以后的field keys更短,但同时也比较难以阅读。由于在多数情况下,我们并不需要去理解field keys的含义,所以这样做并没有问题。但有一种情况,我们需要对表单进行单元测试或者集成测试。这种压缩的格式会为测试带来一定困难。为此,表单服务提供了另一种非压缩的格式可供使用。非压缩的格式和压缩格式类似,只不过其group和field的名称是完整的。例如,非压缩版的“_fm.register._0.password”和压缩版的_fm.r._0.p是等效的。

表 9.20. 非压缩格式的field key(以_fm.register._0.password为例)的组成

名称说明

_fm

固定的前缀,是单词“form”的缩写。表单系统依此来识别该字段为需要验证的表单字段。

register

完整的group名称。大小写不敏感,以下写法完全等效:registerRegisterrEgiSter

_0

代表group instance的唯一ID。_0是默认的ID。

password

完整的field名称。大小写不敏感,以下写法完全等效:passwordPasswordpaSswoRd

浏览器或单元测试的代码在提交表单数据时,可以混合使用压缩和非压缩的格式。但是在默认情况下,表单系统只会生成压缩格式的field keys。如果你希望表单系统生成非压缩的格式,你可以在配置文件中这样写:

例 9.69. 让表单系统生成非压缩的格式的field keys

<services:form fieldKeyFormat="uncompressed"> 
    ...
</services:form>

如果不指定,其默认值为compressed

[注意]注意

当表单系统被配置成fieldKeyFormat="uncompressed"时,系统就不支持压缩格式的field keys了。

当表单系统被配置成fieldKeyFormat="compressed"时,系统就同时支持压缩格式和非压缩格式的field keys。

9.4.5. 外部验证

表单验证服务是被设计成供一个应用的内部使用的服务。它所生成的压缩格式的field key,例如“_fm.r._0.p”,是不稳定的。它和配置文件中的group、field的名称、排列顺序有关,可能随着配置的变化而变化。即便是非压缩的格式,例如“_fm.register._0.password”,也会因配置文件中group、field命名的改变而改变。如果需要让外界系统来提交并验证表单,最好提供一个相对稳定的接口。所以外界系统最好不要依赖于这些内部的field keys。

如果真的需要让外界系统来提交并验证表单,可以做一个screen来转发这个请求。Screen的代码像这个样子:

例 9.70. 转发外部表单请求

public class RemoteRegister {
    public void execute(ParameterParser params, Form form, Navigator nav) throws Exception {
        Group group = form.getGroup("register"); 

        group.init(); 
        group.getField("userId").setValue(params.getString("userId")); 
        group.getField("password").setValue(params.getString("password")); 
        group.getField("passwordConfirm").setValue(params.getString("password")); 
        group.validate(); 

        nav.forwardTo("register", "registerAction", "register"); 
    }
}

创建register group的实例。

将request parameters中的参数设置到form group中。需要注意的是,request参数名和field名称不必相同。

验证表单。

内部重定向到register页面,并指明action参数和actionEvent参数(分别是“registerAction”和“register”)。

只需要访问下面的URL就可以实现从系统外部注册帐户的功能:

http://localhost:8081/myapp/remote_register.do?userId=xxx&password=yyy

9.5. 本章总结

表单服务是一个比较复杂但也相当强大的服务。虽然目前它还不支持客户端验证和服务端异步验证功能,但下一步会加上这些功能。

表单服务最重要的设计思想是:将验证规则与页面以及业务逻辑完全分离,使验证规则的扩展和维护变得非常容易。