Skip to content

4. 操作:模型

让我们回到 Spring 应用程序的架构 MVC:

在上一章中,我们探讨了将请求 [1] 引导至控制器及负责处理该请求的操作 [2a] 的过程,这一机制被称为路由。此外,我们还介绍了操作可能向浏览器返回的各种响应。 目前我们介绍的动作并未利用其接收到的请求。一个 [1] 请求携带了各种信息,Spring 会将这些信息以模型的形式传递给动作。 请勿将此术语与视图 V [2c] 的模型 M 混淆,该视图由操作生成:

  • 客户端的请求 HTTP 到达 [1];
  • 在 [2] 中,请求中包含的信息将被转换为操作模板 [3](通常但并非必然是一个类),该模板将作为操作 [4] 的输入;
  • 在 [4] 中,该操作将基于此模型生成响应。该响应包含两个组成部分:视图 V [6] 及其对应的模型 M [5];
  • 视图 V [6] 将使用其模型 M [5] 来生成发给客户端的响应 HTTP。

MVC 模型中,操作 [4] 属于 C(控制器),视图 [5] 的模型是 M,而视图 [6] 是 V

本章探讨了请求所携带的信息(本质上是字符串)与操作模型(可能是一个具有多种类型属性的类)之间的关联机制。

:术语 [Modèle d'action] 并非有效术语。

我们为这些新操作创建一个新的控制器:

  

控制器 [ActionModelController] 目前如下所示:


package istia.st.springmvc.controllers;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class ActionModelController {

}
  • 第 5 行:需注意,注解 [@RestController] 会导致发送给客户端的响应是控制器操作结果的字符串序列化;

4.1. [/m01]:GET的参数

我们添加以下 [/m01] 操作:



    // ----------------------- 使用 GET 获取参数------------------------
    @RequestMapping(value = "/m01", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m01(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • 第 4 行:该操作接受两个名为 [nom] 和 [age] 的参数。它们将通过 HTTP 和 GET 请求中同名的参数进行初始化;

在 Chrome 中,[1-3] 的结果如下:

  • 在 [1] 中,包含参数 [nom] 和 [age] 的请求 GET;
  • 在 [3] 中,可以看到操作 [/m01] 确实已获取了这些参数;

4.2. [/m02]:POST的参数

我们添加以下操作 [/m02]:



    // ----------------------- 使用 POST 获取参数------------------------
    @RequestMapping(value = "/m02", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m02(String nom, String age) {
        return String.format("Hello [%s-%s]!, Greetings from Spring Boot!", nom, age);
}
  • 第 4 行:该操作接受两个名为 [nom] 和 [age] 的参数。它们将通过 HTTP 和 POST 请求中同名的参数进行初始化;

使用 [Advanced rest Client] 得到的结果如下:

  • 在 [1-3] 中,包含使用 POST 请求以及 [nom] 和 [age] 参数的请求;
  • 在 [4-5] 中,将请求 POST 的标头 HTTP 设为 [Content-Type]。它应为 [Content-Type: application/x-www-form-urlencoded];
  • 在 [6] 中,[Form Data] 列出了操作 POST 的参数列表。此处可见参数 [nom] 和 [age];
  • 在 [7] 中,服务器响应显示操作 [/m02] 已成功获取参数 [nom] 和 [age];;

4.3. [/m03]:同名参数

我们在第2.5.2.8节中看到,多选列表可以向服务器发送同名参数。让我们看看操作如何获取这些参数。我们添加以下操作[/m03]:


    // ----------------------- 获取同名参数-----------------
    @RequestMapping(value = "/m03", method = RequestMethod.POST, produces = "text/plain;charset=UTF-8")
    public String m03(String nom[]) {
        return String.format("Hello [%s]!, Greetings from Spring Boot!", String.join("-", nom));
}
  • 第2行:该操作接受一个名为[nom[]]的参数。由于此处未指定请求类型,该参数将初始化为所有名称相同的参数值,无论这些参数来自GET还是POST;

结果如下:

  • 通过 POST 和 [1],发送参数 [2];
  • 同时在 URL 和 [3] 中也设置了参数;
  • 在 [4] 中,四个名称相同的参数 [nom]: [Query String parameters] 是 URL 的参数,[Form Data] 是已提交的参数;
  • 在 [5] 中,可以看到操作 [/m03] 已获取了名为 [nom] 的四个参数;

4.4. [/m04]:将操作的参数映射到 Java 对象中

假设有一个新的操作 [/m04],如下所示:


    // ------ 将参数映射到对象(Command Object)中 ---------------
    @RequestMapping(value = "/m04", method = RequestMethod.POST)
    public Personne m04(Personne personne) {
        return person;
}
  • 第 3 行:该操作的参数为以下类型的人员:

public class Personne {

    // 标识符
    private Integer id;
    // 姓名
    private String nom;
    // 年龄
    private int age;
....
    // 获取器和设置器
...
}
  • 为了创建参数 [Personne personne],Spring MVC 会生成一个 [new Personne()];
  • 随后,如果存在名称与所创建对象字段(如 [id, nom, age])对应的参数,则通过这些字段的 setter 方法进行实例化;
  • 第 4 行:该操作返回类型为 [Personne],因此会在发送给客户端之前被序列化为字符串。 我们已经看到,默认情况下执行的是 jSON 序列化。因此,客户端应收到某人的 jSON 字符串;

以下是一个示例:

  • 转换为 [1],参数 [id, nom, age] 用于构建对象 [Personne];
  • 在 [2] 中,该人员的字符串 jSON;

如果未发送某人的所有字段会发生什么?让我们试一试:

  • 转换为 [2],此时仅初始化了参数 [id];

4.5. [/m05]:获取 URL 的元素

假设存在以下新的操作 [/m05]:


    // ----------------------- 获取 URL 中的元素 ------------------------
    @RequestMapping(value = "/m05/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, String> m05(@PathVariable("a") String a, @PathVariable("b") String b) {
        Map<String, String> map = new HashMap<String, String>();
        map.put("a", a);
        map.put("b", b);
        return map;
}
  • 第 2 行:已处理的 URL 采用 [/m05/{a}/x/{b}] 的形式,其中 {param} 是 URL 的参数元素;
  • 第3行:通过注释[@PathVariable]提取URL的参数元素;
  • 第4-6行:检索到的[a]和[b]元素被放入一个字典中;
  • 第 7 行:响应将取自该字典中的字符串 jSON;

结果如下:

 

4.6. [/m06]:从 URL 中提取元素及参数

即如下所示的新操作 [/m06]:


    // -------- 从 URL 获取元素及参数 ----------------
    @RequestMapping(value = "/m06/{a}/x/{b}", method = RequestMethod.GET)
    public Map<String, Object> m06(@PathVariable("a") Integer a, @PathVariable("b") Double b, Double c) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("a", a);
        map.put("b", b);
        map.put("c", c);
        return map;
}
  • 第 3 行: 同时获取来自 URL 和 [Integer a, Double b] 的元素,以及一个参数(GET 或 POST)[Double c];
  • 第4-7行:将这些元素放入字典中;
  • 第8行:形成客户端的响应,因此客户端将从该字典中获取字符串 jSON;

以下是结果:

 

请注意路径 [http://localhost:8080/m06/100/x/200.43/] 末尾的 /。如果没有它,将得到以下错误结果:

 

4.7. [/m07]:获取完整的查询

假设存在以下新的操作 [/m07]:


    // ------ 访问查询 HttpServletRequest ------------------------
    @RequestMapping(value = "/m07", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m07(HttpServletRequest request) {
        // HTTP 的表头
        Enumeration<String> headerNames = request.getHeaderNames();
        StringBuffer buffer = new StringBuffer();
        while (headerNames.hasMoreElements()) {
            String name = headerNames.nextElement();
            buffer.append(String.format("%s : %s\n", name, request.getHeader(name)));
        }
        return buffer.toString();
}
  • 第 3 行:要求 Spring MVC 注入对象 [HttpServletRequest request],该对象封装了请求中可获取的所有信息;
  • 第5-10行:提取请求中的所有头部信息,将其组合成字符串并发送给客户端(第11行);

结果如下:

  • 在 [1] 中,查询的 HTTP 表头;
  • 变为 [2],即响应。其中确实包含了请求中的所有 HTTP 头部。

4.8. [/m08]:访问对象 [Writer]

考虑以下操作:


    // ----------------------- 写入器注入 ------------------------
    @RequestMapping(value = "/m08", method = RequestMethod.GET)
    public void m08(Writer writer) throws IOException {
        writer.write("Bonjour le monde !");
}
  • 第 3 行:Spring MVC 注入了 [Writer writer] 对象,该对象用于向客户端响应流中写入内容;
  • 第 3 行:该操作返回类型为 [void],这表明它必须自行构建发给客户的响应;
  • 第 4 行:在发给客户的响应流中添加文本;

结果如下:

  • 在 [2] 中,可以看到 HTTP [Content-Type] 头部未被发送;
  • 在 [3] 中,响应内容;

4.9. [/m09]:访问标头 HTTP

考虑以下操作:


    // ----------------------- RequestHeader 注入 ------------------------
    @RequestMapping(value = "/m09", method = RequestMethod.GET)
    public String m09(@RequestHeader("User-Agent") String userAgent) {
        return userAgent;
}
  • 第 3 行:注释 [@RequestHeader("User-Agent")] 用于检索标头 HTTP [User-Agent];
  • 第4行:输出该标头的文本;

结果如下:

  • 在 [2] 中,可获取标题 HTTP [User-Agent];
  • 变为 [3],操作 [/m08] 已正确检索到该标头;

Cookie通常是一个HTTP标头,由:

  • 服务器首次发送给客户端的 HTTP 头部;
  • 客户端随后会系统性地将其发回给服务器;

首先创建一个用于生成 Cookie 的操作:


    // ----------------------- Cookie 创建 ------------------------
    @RequestMapping(value = "/m10", method = RequestMethod.GET)
    public void m10(HttpServletResponse response) {
        response.addCookie(new Cookie("cookie1", "remember me"));
}
  • 第 3 行:注入 [HttpServletResponse response] 对象,以便完全控制响应;
  • 第 4 行:创建一个 Cookie,其键为 [cookie1],值为 [remember me](注意:Cookie 值中的带重音字符会导致错误);
  • 第 3 行:该操作不返回任何内容。此外,它也没有在响应正文中写入任何内容。因此,客户端将收到一个空文档。该响应仅用于添加一个名为 HTTP 的 Cookie 头部;

让我们来看一下结果:

  • 在 [1] 中:请求;
  • [2]:响应为空;
  • [3]:操作生成的 Cookie;

现在创建一个操作来获取该 Cookie,浏览器今后将在每次请求中发送该 Cookie:


    // ----------------------- Cookie 注入 ------------------------
    @RequestMapping(value = "/m11", method = RequestMethod.GET)
    public String m10(@CookieValue("cookie1") String cookie1) {
        return cookie1;
}
  • 第 3 行:注解 [@CookieValue("cookie1")] 用于获取键为 [cookie1] 的 Cookie;
  • 第 4 行:该值将作为对客户端的响应;

让我们看看结果:

  • 在 [2] 中,可以看到浏览器返回了该 Cookie;
  • 在 [3] 中,该操作已成功获取了该 Cookie;

4.11. [/m12]:访问 POST 的主体

提交的参数通常附带 HTTP [Content-Type: application/x-www-form-urlencoded] 头部。我们可以访问整个提交的字符串。我们创建以下操作:


    // ----------- 获取字符串类型 POST 的正文 -------------------------
    @RequestMapping(value = "/m12", method = RequestMethod.POST)
    public String m12(@RequestBody String requestBody) {
        return requestBody;
}
  • 第 3 行:注释 [@RequestBody] 用于提取 POST 的正文。此处假设其类型为 [String];
  • 第 4 行:将该主体返回给客户端;

以下是一个示例:

  • 转换为 [2],即已提交的值;
  • 转换为 [3],即请求的 HTTP [Content-Type] 头部;
  • 在 [4] 中,服务器响应;

POST参数并不总是采用我们迄今常用的简单形式[p1=v1&p2=v2]。让我们来看一个更复杂的例子:

  • 在 [2-3] 中:将提交的值以 [clé:value] 的形式输入;
  • 对于 [5],则提交的字符串应为 [clé:value];

对于类型 [Content-Type: application/x-www-form-urlencoded],提交的字符串必须采用 [p1=v1&p2=v2] 的形式。如果想提交任意内容,则使用类型 [Content-Type: text/plain]。以下是一个示例:

  • 在 [2-3] 中,需创建标题 HTTP [Content-Type]。 默认情况下,系统将使用 [5] 而非 [6] 中定义的值。[charset=utf-8] 属性至关重要。若缺少该属性,发布字符串中的重音字符将丢失;
  • 在 [4] 中,我们能够正确地从 [7] 中获取提交的字符串;

4.12. [/m13, /m14]:在 jSON 中获取提交的值

可以使用 HTTP [Content-Type: application/json] 表头提交参数。我们创建以下操作:


    // ----------------------- 获取 POST 的 jSON 主体
    @RequestMapping(value = "/m13", method = RequestMethod.POST, consumes = "application/json")
    public String m13(@RequestBody Personne personne) {
        return personne.toString();
}
  • 第2行:[consumes = "application/json"] 说明该操作等待一个主体 jSON;
  • 第3行:[@RequestBody] 代表该主体。该注释已与类型为 [Personne] 的对象相关联。主体 jSON 将自动反序列化到该对象中;
  • 第 4 行:使用方法 [Personne].toString() 返回一个值,该值不是发送的字符串 jSON;

以下是一个示例:

  • 在 [2] 中,将提交的字符串 jSON;
  • [3],即请求中的 [Content-Type];
  • 转换为 [4],即服务器的响应;

也可以用其他方式实现:


    // ----------------------- 从 POST 中提取正文 jSON 2 -------------------
    @RequestMapping(value = "/m14", method = RequestMethod.POST, consumes = "text/plain")
    public String m14(@RequestBody String requestBody) throws JsonParseException, JsonMappingException, IOException {
        Personne personne = new ObjectMapper().readValue(requestBody, Personne.class);
        return personne.toString();
}
  • 第 2 行:指定方法期望接收 [text/plain] 类型的流。Spring MVC 将把请求主体视为 [String] 类型(第 3 行);
  • 第 4 行:将字符串 jSON 反序列化为 [Personne] 对象(参见第 9.7 节,第 543 页);

结果如下:

  • 转换为 [3],应修正为 [text/plain];

4.13. [/m15]:获取会话

让我们回顾一下操作的执行架构:

控制器类在客户端请求开始时被实例化,并在请求结束时被销毁。因此,即使该类被反复调用,它也无法用于在两次请求之间保存数据。我们可能希望保存两种类型的数据:

  • Web 应用程序所有用户共享的数据。这些通常是只读数据;
  • 由同一客户端的请求共享的数据。这些数据存储在一个名为 Session 的对象中。此时,我们称之为客户端会话,以指代客户端的内存。同一客户端的所有请求均可访问该会话,并在其中存储和读取信息。

上文展示了操作可访问的内存类型:

  • 应用程序内存,其中大多包含只读数据,且所有用户均可访问;
  • 特定用户的内存(即会话),其中包含可读写数据,且同一用户的后续请求均可访问;
  • 上图未展示的还有请求内存(或称请求上下文)。用户的请求可能由多个连续的操作处理。请求上下文使操作 1 能够向操作 2 传递信息。

让我们通过一个示例来了解这些不同的内存:


    // ----------------------- 检索会话 ------------------------
    @RequestMapping(value = "/m15", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m15(HttpSession session) {
        // 从会话中获取密钥对象 [compteur]
        Object objCompteur = session.getAttribute("compteur");
        // 将其转换为整数以便递增
        int iCompteur = objCompteur == null ? 0 : (Integer) objCompteur;
        iCompteur++;
        // 将其放回会话中
        session.setAttribute("compteur", iCompteur);
        // 将其作为操作结果返回
        return String.valueOf(iCompteur);
}

Spring MVC 将用户的会话保存在类型为 [HttpSession] 的对象中。

  • 第 3 行:要求 Spring MVC 将对象 [HttpSession] 注入到操作的参数中;
  • 第 5 行:从中获取名为 [compteur] 的属性。 会话的行为类似于一个字典,即一组 [clé, valeur] 键值对。如果会话中不存在键 [compteur],则获取一个指针 null
  • 第 7 行:与键 [compteur] 关联的值将是一个 [Integer] 类型;
  • 第 8 行:递增计数器;
  • 第 10 行:更新会话中的计数器;
  • 第12行:将计数器的值发送给客户端;

当 [/m15] 首次执行时:

  • 第1次,第12行计数器的值为1;
  • 第二次执行时,第5行将获取该值1并将其更新为2;
  • ...

以下是一个执行示例:

  • 在 [1] 中,确实获取到了计数器的第一个值;
  • 在 [2] 中,服务器发送了一个会话 Cookie。其密钥为 [JSESSIONID],值则是针对每位用户唯一的字符串。 需要记住的是,浏览器会系统性地回传其接收到的cookie。因此,当再次请求操作[/m15]时,客户端将回传该cookie,这将使服务器能够识别它并将其关联到相应的会话中。正是通过这种方式,用户的会话状态得以维持;

现在来看第二个请求:

  • 在 [3] 中,可以看到客户端返回了会话 Cookie。值得注意的是,在服务器的响应中,已不再包含该会话 Cookie。现在是由客户端主动发送该 Cookie 以供识别;
  • 在 [4] 中,计数器的第二个值。它确实已被递增;

4.14. [/m16]:获取作用域对象 [session]

我们可能希望将用户的所有会话数据放入一个对象中,并仅将该对象放入会话中。我们将采用这种方式。我们将计数器放入以下对象 [SessionModel] 中:

  

package istia.st.sprinmvc.models;

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionModel {

    private int compteur;

    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }

}
  • 第 7 行:注解 [@Component] 是一个 Spring 注解(第 5 行),它将类 [SessionModel] 设为一个由 Spring 管理生命周期的组件;
  • 第 8 行:注解 [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] 同样是 Spring 注解(第 3-4 行)。当 Spring 遇到 MVC 时,会创建相应的类并将其放入用户的会话中。 [proxyMode = ScopedProxyMode.TARGET_CLASS] 属性至关重要。正是通过该属性,Spring MVC 才能为每个用户创建独立实例,而非为所有用户创建单一实例(单例);
  • 第 11 行:计数器;

要使这个新的Spring组件被识别,需要检查[Application]类中的应用程序配置:


package istia.st.springmvc.main;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan({"istia.st.springmvc.controllers"})
@EnableAutoConfiguration
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • 第 9 行:Spring 组件是在 [istia.st.springmvc.controllers] 包中查找的。这已不再足够。我们将该行修改如下:

@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })

我们添加了包含类 [SessionModel] 的包。

现在,我们添加以下操作:


    @Autowired
    private SessionModel session;
    
    // ------ 管理会话作用域对象 [Autowired] -----------
    @RequestMapping(value = "/m16", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m16() {
        session.setCompteur(session.getCompteur() + 1);
        return String.valueOf(session.getCompteur());
}
  • 第 1-2 行:将 Spring 组件 [SessionModel] 注入到控制器中。 在此需要提醒的是,Spring 控制器是一个单例。因此,向其中注入作用域更小的组件(此处为 [Session] 作用域)似乎自相矛盾。此时,[SessionModel] 组件上的 [@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)] 注解就发挥了作用。 每当控制器代码访问第2行中的[session]字段时,都会执行一个代理方法,以获取控制器当前正在处理的请求会话;
  • 第 6 行:操作参数中不再需要 [HttpSession] 对象;
  • 第 7 行:获取/递增计数器;
  • 第 8 行:返回其值;

以下是一个执行示例:

第一次

第二次

现在,我们再使用另一个浏览器来模拟第二位用户。这里我们选用 Opera 浏览器:

如上图 [1] 所示,这位第二位用户获取的计数器值为 1。这表明,他的会话与第一位用户的会话是不同的。 若查看客户端/服务器通信(Opera同样按Ctrl-Shift-I),可在[2]中看到,该第二用户的会话cookie与第一用户不同。这正是确保会话独立性的关键。

4.15. [/m17]:获取 [application] 的作用域对象

让我们回顾一下操作的执行架构:

我们已经知道如何构建用户会话。现在我们将构建一个作用域对象 [application],其内容为只读且对所有用户开放。我们引入类 [ApplicationModel],它将成为作用域对象 [application]:

 

package istia.st.springmvc.models;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Component;

@Component
public class ApplicationModel {

    // 计数器
    private AtomicLong compteur = new AtomicLong(0);

    // 获取器和设置器
    public AtomicLong getCompteur() {
        return compteur;
    }

    public void setCompteur(AtomicLong compteur) {
        this.compteur = compteur;
    }

}
  • 第 5 行:注解 [@Component] 使类 [ApplicationModel] 成为由 Spring 管理的组件。 Spring组件的默认性质是类型[singleton]:当Spring容器被实例化时(即通常在应用程序启动时),该组件会创建一个唯一的实例。我们可以利用这个生命周期,在单例中存储配置信息,这些信息将对所有用户可见;
  • 第 11 行:一个类型为 [AtomicLong] 的计数器。该类型具有一个名为 [incrementAndGet] 的原子方法。 这意味着,执行该方法的线程可以确保:在该线程读取计数器值(Get)与执行递增操作(increment)之间,不会有其他线程读取该值(Get)。否则,由于两个线程读取了相同的计数器值,本应递增2的计数器却只递增了1,从而导致错误;

我们创建以下新的操作 [/m17]:


@Autowired
    private ApplicationModel application;

    // ----- 管理应用程序作用域对象 [Autowired] ------------------------
    @RequestMapping(value = "/m17", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m17() {
        return String.valueOf(application.getCompteur().incrementAndGet());
    }
  • 第 1-2 行:将组件 [ApplicationModel] 注入控制器。这是一个单例。因此每个用户都将引用同一个对象;
  • 第 7 行:将作用域计数器 [application] 递增后返回;

以下是两个示例,一个使用 Chrome,另一个使用 Opera:

上图可见,两个浏览器都使用了同一个计数器,而会话的情况则并非如此。这两个浏览器代表了两个不同的用户,他们都能够访问作用域 [application] 中的数据。 一般而言,应避免在作用域对象 [application] 中放置读写信息,如上文计数器所示。因为不同用户的执行线程会同时访问作用域 [application] 中的数据。 如果存在可写信息,则必须像上文对类型 [AtomicLong] 所做的那样,对写入操作进行同步。并发访问是编程错误的根源。因此,建议仅在作用域对象 [application] 中放置只读信息。

4.16. [/m18]:使用 [@SessionAttributes] 检索 [session] 作用域对象

还有另一种方法可以获取 [session] 作用域中的信息。我们将以下对象存入会话:


package istia.st.springmvc.models;

public class Container {
    // 计数器
    public int compteur=10;

    // 获取器和设置器
    public int getCompteur() {
        return compteur;
    }

    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }
}

我们将使用该对象执行以下两个操作:


    // 使用 [@SessionAttribute] ----------------------
    @RequestMapping(value = "/m18", method = RequestMethod.GET)
    public void m18(HttpSession session) {
        // 在此将密钥 [container] 放入会话中
        session.setAttribute("container", new Container());
    }

    // 使用 [@ModelAttribute] ----------------------
    // 此处将注入会话密钥 [container]
    @RequestMapping(value = "/m19", method = RequestMethod.GET)
    public String m19(@ModelAttribute("container") Container container) {
        container.setCompteur(1 + container.getCompteur());
        return String.valueOf(container.getCompteur());
    }
  • 第3-6行:操作[/m18]不会返回任何结果。它仅用于在会话中创建一个键为[container]的对象;
  • 第11行:在操作[/m19]中,使用了注释[@ModelAttribute]。 该注解的行为相当复杂。该注解的参数 [container] 可以指代多种对象,特别是会话中的对象。为此,该对象必须已在类本身上通过注解 [@SessionAttributes] 进行声明:

@RestController
@SessionAttributes({"container"})
public class ActionModelController {
  • 上文第 2 行,将键 [container] 指定为会话属性的组成部分;

总结如下:

  • 在 [/m18] 中,键 [container] 被放入会话中;
  • 注解 [@SessionAttributes({"container"})] 使得该键可注入到带有注解 [@ModelAttribute("container")] 的参数中;
  • 虽然在接下来的运行示例中不可见,但标注为 [@ModelAttribute] 的信息会自动成为传递给视图 V 的模型 M 的一部分;

以下是一个运行示例。首先,通过操作 [/m18] [1] 将键 [container] 放入会话中。 随后,调用两次操作 [/m19],观察计数器递增。

4.17. [/m20-/m23]:使用 [@ModelAttribute] 注入信息

考虑以下新操作:


    // p 属性将包含在所有视图模板中 ----------------
    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
    }

    // ---------------实例化 @ModelAttribute --------------------------
    // 若在会话中,则会被注入
    // 如果控制器为该属性定义了方法,则会被注入
    // 若存在字符串转属性类型的转换器,则可能来自 URL 的字段
    // 否则将通过默认构造函数生成
    // 随后,模型的属性将使用 GET 或 POST 的参数进行初始化
    // 最终结果将成为该操作生成的模型的一部分
    
    // p 属性被注入到参数中------------------------
    @RequestMapping(value = "/m20", method = RequestMethod.GET)
    public Personne m20(@ModelAttribute("p") Personne personne) {
        return personne;
}
  • 第 2-5 行:定义了一个名为 [p] 的模型属性。这是视图 V 的模型 M,该模型在 Spring 中由类型 [Model] 表示。 一个模型的行为类似于一个 [clé, valeur] 键值对字典。 在此,键 [p] 与由方法 [getPersonne] 构建的对象 [Personne] 相关联。方法名称可以是任意名称;
  • 第17行:将键模板属性[p]注入到操作参数中。此注入操作遵循第8至12行的规则。 此处将采用第9行定义的情况。因此,第17行中的参数[Personne personne]将变为对象[Personne(7,'abcd',14)];
  • 第 18 行:返回对象 [personne] 供验证。该对象将在发送给客户端之前被序列化为 jSON。

以下是一个示例:

 

现在,让我们分析以下操作:


    // --------- p 属性自动成为视图 V 的模板 M 的一部分
    @RequestMapping(value = "/m21", method = RequestMethod.GET)
    public String m21(Model model) {
        return model.toString();
}

一个旨在显示视图 V 的操作必须构建该视图的模型 M。Spring MVC 通过类型 [Model] 管理该模型,该类型可注入到操作的参数中。 最初,该模型为空,或包含带有注解 [@ModelAttribute] 的信息。操作在将模型传递给视图之前,会决定是否对其进行补充。

  • 第 3 行:注入模型 M;
  • 第 4 行:我们需要查看其中的内容。将其序列化为字符串以发送给客户端。此处将使用方法 [Personne.toString]。因此该方法必须存在;

以下为执行示例:

 

上文可见以下指令:


    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
}

已在模板中创建了条目 [p, Personne(7,'abcd',14)]。情况总是如此。

现在考虑以下情况:


    // 否则将通过默认构造函数进行构建
    // 随后,模型的属性将使用 GET 或 POST 的参数进行初始化

配合以下操作:


    // --------- 模型属性 [param1] 属于该模型,但未被初始化
    @RequestMapping(value = "/m22", method = RequestMethod.GET)
    public String m22(@ModelAttribute("param1") String p1, Model model) {
        return model.toString();
}
  • 第 3 行:键模板属性 [param1] 不存在。 在这种情况下,关联的类型必须具有默认构造函数。此处类型 [String] 符合这一条件,但不能写成 [@ModelAttribute("param1") Integer p1],因为类 [Integer] 没有默认构造函数;
  • 第 4 行:返回模型以检查 [param1] 键的模型属性是否属于该模型;

以下是一个执行示例:

 

模板属性 [param1] 确实存在于模板中,但关联值的 [toString] 方法并未提供关于该值的任何信息。

现在考虑以下操作,其中我们显式地在模型中添加了一条信息:


    // --------- 模型属性 [param2] 被显式放入模型中
    @RequestMapping(value = "/m23", method = RequestMethod.GET)
    public String m23(String p2, Model model) {
        model.addAttribute("param2",p2);
        return model.toString();
}
  • 第 4 行:将第 3 行获取的值 [p2] 放入模型中,并关联到键 [param2]:

以下是一个执行示例:

 

如果操作参数是对象,规则会发生变化。以下是一个初步示例:


    // ------ 模型属性 [unePersonne] 被自动放入模型
    @RequestMapping(value = "/m23b", method = RequestMethod.GET)
    public String m23b(@ModelAttribute("unePersonne") Personne p1, Model model) {
        return model.toString();
}

该操作不会修改其接收的模板。结果如下:

可以看到,注解 [@ModelAttribute("unePersonne") Personne p1] 将人员 [p1] 放入了模型中,并关联了键 [unePersonne]。

现在考虑以下操作:


    // --------- 人员 p1 被自动放入模型
    // -------- 其键为其类名,首字母小写
    @RequestMapping(value = "/m23c", method = RequestMethod.GET)
    public String m23c(Personne p1, Model model) {
        return model.toString();
}
  • 第 4 行:未添加注释 [@ModelAttribute];

结果如下:

可以发现,参数 [Personne p1] 的存在将人员 [p1] 放入了模型中, 该人员关联的键为 [personne],即类名 [Personne] 的首字母小写形式。

4.18. [/m24]:操作模型的验证

考虑以下操作模板 [ActionModel01]:

 

package istia.st.springmvc.models;

import javax.validation.constraints.NotNull;

public class ActionModel01 {

    // 数据
    @NotNull
    private Integer a;
    @NotNull
    private Double b;

    // getter 和 setter
...
    }
  • 第 8 行和第 9 行:注释 [@NotNull] 是一个验证约束,表示被注释的数据不能取值 null

现在我们来看以下操作:


    // ----------------------- 模型验证 ------------------------
    @RequestMapping(value = "/m24", method = RequestMethod.GET)
    public Map<String, Object> m24(@Valid ActionModel01 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // 有错误吗?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            // 遍历错误列表
            for (FieldError error : result.getFieldErrors()) {
                buffer.append(String.format("[%s:%s:%s:%s:%s]", error.getField(), error.getRejectedValue(),
                        String.join(" - ", error.getCodes()), error.getCode(),error.getDefaultMessage()));
            }
            map.put("errors", buffer.toString());
        } else {
            // 无错误
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
}
  • 第3行:将实例化一个[ActionModel01]对象,并使用同名参数初始化其[a, b]字段。注释[@Valid]表示必须验证有效性约束。 该验证的结果将存储在类型为 [BindingResult] 的参数中(第二个参数)。将进行以下验证:
    • 由于注释 [@NotNull],参数 [a] 和 [b] 必须存在;
    • 由于类型为 [Integer a],参数 [a](其本质类型为 [String])必须可转换为类型 [Integer];
    • 由于类型为 [Double b],参数 [b](其本质类型为 [String])必须可转换为类型 [Double];

使用注解 [@Valid] 时,验证错误将报告到参数 [BindingResult result] 中。 若未添加注解 [@Valid],验证错误将导致操作崩溃,服务器会向客户端发送状态码为 500(内部服务器错误)的响应 HTTP。

  • 第 3 行:操作结果类型为 [Map]。该结果中的字符串 jSON 将被发送给客户端。构建两种类型的字典:
    • 失败时,构建一个包含条目 ['errors', value] 的字典,其中 [value] 是描述所有错误的字符串(第 13 行);
    • 成功时,构建一个包含条目 ['data',value] 的字典,其中 [value] 本身是一个包含两个条目的字典:['a', value], ['b', value](第19行);
  • 第9-12行:对于检测到的每个错误[error],构建字符串[error.getField(), error.getRejectedValue(), error.Codes, error.getDefaultMessage()]:
    • 第一个元素是出错的字段,如 [a] 或 [b],
    • 第二个元素是被拒绝的值,例如 [x],
    • 第三个元素是错误代码列表。我们稍后将探讨它们的作用;
    • 第四个元素是错误代码,它属于前面的列表;
    • 最后一个元素是默认错误消息。实际上可能存在多个错误消息;

以下是几个执行示例:

从上文可以看出:

  • 将 'x' 赋值给字段 [ActionModel01.a] 失败,且错误信息说明了原因;
  • 将'y'赋值给字段[ActionModel01.b]失败,错误信息说明了原因;

请注意字段 [a] 上的错误代码:[typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]。 在需要自定义错误消息时,我们将再次讨论这些错误代码。请注意,该错误代码为 [typeMismatch]。

另一个示例:

在此,未传递参数 [a] 和 [b]。因此,动作模型 [ActionModel01] 中的验证器 [@NotNull] 发挥了作用;

最后,得到了正确的值:

4.19. [m/24]:错误消息的自定义

让我们回到前一个示例的屏幕截图:

上图显示了默认的错误信息。显然,在实际应用中我们不能保留这些信息。我们可以自定义这些错误信息。为此,我们将借助错误代码。 上图中,我们可以看到字段 [a] 的错误对应以下代码:[typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch]。这些错误代码从最具体到最不具体依次排列:

  • [typeMismatch.actionModel01.a]:字段 [a] 类型错误,其类型应为 [ActionModel01];
  • [typeMismatch.a]:名为 [a] 的字段类型错误;
  • [typeMismatch.java.lang.Integer]:Integer 类型出现类型错误;
  • [typeMismatch]:类型错误;

此外还注意到,由 [error.getCode()] 生成的字段 [a] 的错误代码为 [typeMismatch](参见上图截图)。

我们将把这些错误信息放入一个属性文件中:

  

上述文件 [messages.properties] 将如下所示:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.model01.a=Le paramètre [a] doit être entier

每行格式如下:

    clé=message

此处,键为错误代码,值则是与该代码相关的错误信息。

回顾这两个字段的错误代码:

  • [typeMismatch.actionModel01.a - typeMismatch.a - typeMismatch.java.lang.Integer - typeMismatch],当参数 [a] 无效时;
  • [typeMismatch.actionModel01.b - typeMismatch.b - typeMismatch.java.lang.Double - typeMismatch:typeMismatch ],当参数 [b] 无效时;
  • [NotNull.actionModel01.a - NotNull.a - NotNull.java.lang.Integer - NotNull],当参数 [a] 缺失时;
  • 当参数 [b] 缺失时,生成 [NotNull.actionModel01.b - NotNull.b - NotNull.java.lang.Double - NotNull];

文件 [messages.properties] 必须包含针对所有可能错误情况的错误信息。对于以下情况:

  • 参数 [a] 和 [b] 缺失时,将使用代码 [NotNull];
  • 参数 [a] 错误时,我们为两个代码 [typeMismatch.actionModel01.a, typeMismatch] 设置了错误信息。我们将观察实际使用的是哪个;
  • 参数 [b] 错误,将使用代码 [typeMismatch];

若要使用文件 [messages.properties],需配置 Spring:

  

我们移除类 [Application] 中的配置注解:


package istia.st.springmvc.main;

import org.springframework.boot.SpringApplication;

public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Config.class, args);
    }
}
  • 第 8 行:启动 Spring Boot 应用程序。静态方法 [SpringApplication.run] 的第一个参数是现在用于配置应用程序的类;

[Config] 类如下:


package istia.st.springmvc.main;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }
}
  • 第11-13行:这里包含了之前位于类[Application]中的配置注解;
  • 第 14 行:要配置 Spring 应用程序 MVC,必须继承类 [WebMvcConfigurerAdapter];
  • 第 15 行:注解 [@Bean] 引入了一个 Spring 组件,即单例;
  • 第 16 行:定义了一个名为 [messageSource](即方法名)的 Bean。该 Bean 用于定义应用程序的消息文件,且必须使用此名称;
  • 第17-19行:告知Spring消息文件:
    • 位于项目类路径(Classpath)中的 [i18n] 文件夹内(第 18 行),
    • 文件名为 [messages.properties](第 18 行)。实际上,[messages] 只是消息文件名的根前缀,而非文件名本身。我们将看到,在国际化环境中,通常会存在多个消息文件,每个受支持的语言环境对应一个文件。 因此,法语版本可能为 [messages_fr.properties],英语版本则为 [messages_en.properties]。附加在根名称 [messages] 后的后缀是标准化格式,不能随意添加;

在项目 STS 中,必须将文件夹 [i18n] 放入资源文件夹中,因为该文件夹已被添加到项目的类路径中:

  

为了使用该文件,我们创建以下新操作:


// 模型验证,错误消息管理 ------------------------
    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
        // 结果字典
        Map<String, Object> map = new HashMap<String, Object>();
        // Spring 应用程序上下文
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // 区域设置
        Locale locale = RequestContextUtils.getLocale(request);
        // 错误?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            for (FieldError error : result.getFieldErrors()) {
                // 根据错误代码查找错误消息
                // 在消息文件中搜索该消息
                // 以表格形式显示的错误代码
                String[] codes = error.getCodes();
                // 字符串形式
                String listCodes = String.join(" - ", codes);
                // 搜索
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // 是否找到?
                if (msg == null) {
                    throw new Exception(String.format("Indiquez un message pour l'un des codes [%s]", listCodes));
                }
                // 已找到 - 将错误消息添加到错误消息列表中
                buffer.append(String.format("[%s:%s:%s:%s]", locale.toString(), error.getField(), error.getRejectedValue(),
                        String.join(" - ", msg)));
            }
            map.put("errors", buffer.toString());
        } else {
            // 成功
            Map<String, Object> mapData = new HashMap<String, Object>();
            mapData.put("a", data.getA());
            mapData.put("b", data.getB());
            map.put("data", mapData);
        }
        return map;
    }

该代码与操作 [/m24] 的代码类似。现说明两者的区别:

  • 第 3 行:将请求 [HttpServletRequest request] 注入到操作的参数中。我们稍后会用到它;
  • 第 7-8 行:获取 Spring 上下文。该上下文包含应用程序中的所有 Spring Bean,同时可用于访问消息文件;
  • 第 10 行:获取应用程序的区域设置。该术语将在下文进一步说明;
  • 第 15-31 行:针对每个错误,我们查找与这些错误代码之一对应的消息。这些代码按 [error.getCodes()] 中找到的顺序进行搜索。一旦找到消息,就停止搜索;
  • 第26行:从[messages.properties]中获取错误消息的方法:
    • 第一个参数是 [messages.properties] 中要查找的代码,
    • 第二个参数是一个参数数组,因为有时消息带有参数。但此处并非如此,
    • 第三个参数是所用的区域设置(取自第10行)。区域设置指所用的语言,[fr_FR]代表法国法语,[en_US]代表USA中的英语。 消息会在 messages_[locale].properties 中查找,例如 [messages_fr_FR.properties]。 如果该文件不存在,则在 [messages_fr.properties] 中查找该消息。如果该文件也不存在,则在 [messages.properties] 中查找该消息。正是最后一种情况对我们有效;
  • 第 25-29 行:有些出乎意料的是,当在消息文件中查找不存在的代码时,会抛出异常而非返回空指针
  • 第33-35行:处理没有错误消息的情况;
  • 第37-38行:构建错误字符串。其中包含区域设置和找到的错误消息;

以下是执行示例:

 

可以看出:

  • 应用程序的区域设置为 [fr_FR]。这是默认值,因为我们未对其进行初始化;
  • 两个字段使用的消息如下:

NotNull=Le champ ne peut être vide

另一个示例:

 

可以看出:

  • 参数 [a] 使用的错误消息如下:

typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
  • 参数 [b] 使用的错误信息如下:

typeMismatch=Format invalide

为何会有两种不同的错误信息?对于参数 [a],可能出现两种错误信息:


typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

错误代码是按照表 [error.getCodes()] 中的顺序进行检索的。该顺序是从最具体的代码到最通用的代码。因此,[typeMismatch.model01.a] 代码被最先检索到。

4.20. [/m25]:Spring 应用程序的国际化 MVC

既然我们已经学会了如何将错误消息本地化为法语,接下来我们还希望提供英语版本,这就涉及 Spring 应用程序的国际化 MVC。为了实现这一点,我们将扩展配置类 [Config],使其变为如下形式:


package istia.st.springmvc.main;

import java.util.Locale;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("i18n/messages");
        return messageSource;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }

    @Bean
    public CookieLocaleResolver localeResolver() {
        CookieLocaleResolver localeResolver = new CookieLocaleResolver();
        localeResolver.setCookieName("lang");
        localeResolver.setDefaultLocale(new Locale("fr"));
        return localeResolver;
    }
}
  • 第 28-32 行:创建一个请求拦截器。该拦截器扩展了 [HandlerInterceptor] 接口。此类会在请求被操作处理之前对其进行检查。 在此,拦截器 [localeChangeInterceptor] 将从传入的请求中查找名为 [lang] 的参数, GET 或 POST,并根据该参数更改应用程序的区域设置。 因此,如果参数是 [lang=en_US],应用程序的区域设置将变为 USA 对应的英语;
  • 第 34-37 行:重定义方法 [WebMvcConfigurerAdapter.addInterceptors] 以添加上述拦截器;
  • 第39-45行:用于配置将区域设置封装到Cookie中的方式。众所周知,Cookie可作为用户记忆载体,因为客户端浏览器会将其系统性地发回给服务器。前面的拦截器[localeChangeInterceptor]会创建一个封装了区域设置的Cookie。 第 42 行将此 Cookie 命名为 [lang]。该 Cookie 还用于更改区域设置;
  • 第 43 行:表明如果缺少 [lang] Cookie,则语言环境将设为 [fr];

简而言之,请求的区域设置可通过两种方式确定:

  • 传递名为 [lang] 的参数;
  • 发送名为 [lang] 的 Cookie。该 Cookie 会在执行前一种方法后自动生成;

为了利用该语言环境,我们将为语言环境 [fr] 和 [en] 创建消息文件:

 

文件 [messages_fr.properties] 内容如下:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier

文件 [messages_en.properties] 如下:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer

文件 [messages.properties] 是文件 [messages_en.properties] 的副本。需要说明的是,当未找到与请求语言环境对应的文件时,将使用文件 [messages.properties]。 在本例中,如果用户发送参数 [lang=en],由于文件 [messages_en.properties] 不存在,系统将使用文件 [messages.properties]。因此,用户将看到英文消息。

让我们试一试。首先,在 Chrome 开发者工具(Ctrl-Shift-I)中,检查您的 Cookie:

 

如果您有一个名为 [lang] 的 Cookie,请将其删除。然后使用 Chrome 请求 URL 和 [http://localhost:8080/m25]:

 

浏览器发送了以下 HTTP 头部:

GET /m25 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36
Referer: http://localhost:8080/m25
Accept-Encoding: gzip, deflate, sdch
Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.6,en;q=0.4

可以看出,这些标头中没有 [lang] cookie。在这种情况下,我们的代码会使用 [fr] 本地化设置。截图中显示的就是这一点。让我们尝试另一个情况:

  • 在 [1] 中,我们传入了参数 [lang=en],将区域设置切换为 [en];
  • 在 [2] 中,可以看到新的语言环境;
  • 在 [3] 中,消息已切换为英语;

现在来看 HTTP 的交互:

 

上文可见,服务器返回了一个 [lang] cookie。这会产生一个重要影响:由于浏览器将返回 [lang] cookie,因此下一次请求的语言设置将再次变为 [en]。 因此,我们应该保持消息为英文。让我们来验证一下:

 

上文可见,语言环境仍保持为 [en]。由于浏览器会系统性地发送该 Cookie,只要用户不通过发送 [lang] 参数(如下所示)进行更改,该语言环境将保持不变:

 

4.21. [/m26]:将区域设置注入到操作模板中

在前面的示例中,我们看到了一种从请求中获取区域设置的方法:


    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
...
        // 本地
        Locale locale = RequestContextUtils.getLocale(request);
// 有错误吗?

区域设置可以直接注入到操作的参数中。以下是一个示例:


    @RequestMapping(value = "/m26", method = RequestMethod.GET)
    public String m26(Locale locale) {
        return String.format("locale=%s", locale.toString());
}
 

从上文可以看出,系统并未对请求的区域设置进行有效性验证。然而,由于浏览器收到的区域设置 Cookie 不正确,其后续请求仍会在服务器端引发异常。

4.22. [/m27]:使用 Hibernate Validator 验证模型的有效性

考虑以下新操作:


    //使用 Hibernate Validator 验证模型 ------------------------
    @RequestMapping(value = "/m27", method = RequestMethod.POST)
    public Map<String, Object> m27(@Valid ActionModel02 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // 有错误吗?
        if (result.hasErrors()) {
            // 浏览错误列表
            for (FieldError error : result.getFieldErrors()) {
                map.put(error.getField(),
                        String.format("[message=%s, codes=%s]", error.getDefaultMessage(), String.join("|", error.getCodes())));
            }
        } else {
            // 无错误
            map.put("data", data);
        }
        return map;
}

这里是大家已经见过多次的代码:

  • 第3行:通过POST调用[/m27]操作;
  • 第8-11行,每个错误将由[champ, message]标识,其中:
    • 字段:出错的字段,
    • 消息:相关的错误消息以及错误代码列表;
  • 第14行:若无错误,则返回包含已提交值的字符串jSON;

第3行,使用以下操作模板[ActionModel02]:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel02 {

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
    private Boolean assertFalse;
    
    @NotNull(message = "La donnée est obligatoire")
    @AssertTrue(message = "Seule la valeur [true] est acceptée")
    private Boolean assertTrue;
    
    @NotNull(message = "La donnée est obligatoire")
    @Future(message = "Il faut une date postérieure à aujourd'hui")
    private Date dateInFuture;
    
    @NotNull(message = "La donnée est obligatoire")
    @Past(message = "Il faut une date antérieure à aujourd'hui")
    private Date dateInPast;
    
    @NotNull(message = "La donnée est obligatoire")
    @Max(value = 100, message = "Maximum 100")
    private Integer intMax100;
    
    @NotNull(message = "La donnée est obligatoire")
    @Min(value = 10, message = "Minimum 10")
    private Integer intMin10;
    
    @NotNull(message = "La donnée est obligatoire")
    @NotBlank(message = "La chaîne doit être non blanche")
    private String strNotBlank;
    
    @NotNull(message = "La donnée est obligatoire")
    @Size(min = 4, max = 6, message = "La chaîne doit avoir entre 4 et 6 caractères")
    private String strBetween4and6;
    
    @NotNull(message = "La donnée est obligatoire")
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$", message = "Le format doit être hh:mm:ss")
    private String hhmmss;
    
    @NotNull(message = "La donnée est obligatoire")
    @Email(message = "Adresse invalide")
    private String email;
    
    @NotNull(message = "La donnée est obligatoire")
    @Length(max = 4, min = 4, message = "La chaîne doit avoir 4 caractères exactement")
    private String str4;
    
    @Range(min = 10, max = 14, message = "La valeur doit être dans l'intervalle [10,14]")
    @NotNull(message = "La donnée est obligatoire")
    private Integer int1014;
    
    @URL(message = "URL invalide")
    private String url;

    // getter 和 setter

...
}

该类使用了来自两个包的验证约束:

  • [javax.validation.constraints](第5-13行);
  • [org.hibernate.validator.constraints](第 15-19 行);

该项目中包含这两个包的 Maven 依赖:

  

在此,我们将不使用国际化消息,而是使用通过 [message] 属性在约束内部定义的消息。为了测试此操作,我们将使用 [Advanced Rest Client]:

  • 在 [1-2] 中,查询 POST;
  • 在 [3] 中,应使用 HTTP [Content-Type] 作为头部;
  • 在 [4] 中,链接 [Add new value] 可用于添加一对 [paramètre, value];
  • 在 [5] 中,需添加 [ActionModel02] 的字段,此处为字段 [assertFalse]:

    @NotNull(message = "La donnée est obligatoire")
    @AssertFalse(message = "Seule la valeur [false] est acceptée")
private Boolean assertFalse;
  • 在 [6] 中,输入错误值以查看错误信息。上文所述的约束 [@AssertFalse] 要求字段 [assertFalse] 的值为 [false];
  • 在 [7] 中,服务器响应:触发了空字段的约束 [@NotNull],并返回了相应的错误消息;
  • 在 [8] 中,显示了字段 [assertFalse] 的消息(该字段未通过约束 [@AssertFalse] 的验证),以及该错误的代码。需注意,这些代码可能与国际化消息相关联;

以下是另一个示例:

 

Image

请读者测试各种错误情况,直至数据全部有效的 POST:

:日期格式采用英美格式:mm/dd/yyyy

4.23. [/m28]:错误消息的外部化

在类 [ActionModel02] 中,我们已将消息“硬编码”在代码中。建议将其提取到消息文件中。我们参照操作 [/m25] 的示例,创建如下新的操作模板 [ActionModel03]:

  

package istia.st.springmvc.models;

import java.util.Date;

import javax.validation.constraints.AssertFalse;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Future;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.URL;

public class ActionModel03 {

    @NotNull
    @AssertFalse
    private Boolean assertFalse;
    
    @NotNull
    @AssertTrue
    private Boolean assertTrue;
    
    @NotNull
    @Future
    private Date dateInFuture;
    
    @NotNull
    @Past
    private Date dateInPast;
    
    @NotNull
    @Max(value = 100)
    private Integer intMax100;
    
    @NotNull
    @Min(value = 10)
    private Integer intMin10;
    
    @NotNull
    @NotBlank
    private String strNotBlank;
    
    @NotNull
    @Size(min = 4, max = 6)
    private String strBetween4and6;
    
    @NotNull
    @Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$")
    private String hhmmss;
    
    @NotNull
    @Email
    private String email;
    
    @NotNull
    @Length(max = 4, min = 4)
    private String str4;
    
    @Range(min = 10, max = 14)
    @NotNull
    private Integer int1014;
    
    @URL
    private String url;

    // getter 和 setter
        ...
}

错误消息已导出到文件 [messages.properties] 中:

  

文件 [messages_fr.properties] 如下:


NotNull=Le champ ne peut être vide
typeMismatch=Format invalide
typeMismatch.actionModel01.a=Le paramètre [a] doit être entier
Range.actionModel03.int1014=La valeur doit être dans l'intervalle [10,14]
NotBlank.actionModel03.strNotBlank=La chaîne doit être non blanche
AssertFalse.actionModel03.assertFalse=Seule la valeur [false] est acceptée
Pattern.actionModel03.hhmmss=Le format doit être hh:mm:ss
Past.actionModel03.dateInPast=Il faut une date antérieure ou égale à celle d'aujourd'hui
Future.actionModel03.dateInFuture=Il faut une date postérieure à celle d'aujourd'hui
Length.actionModel03.str4=La chaîne doit avoir 4 caractères exactement
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Seule la valeur [true] est acceptée
Email.actionModel03.email=Adresse invalide
Size.actionModel03.strBetween4and6=La chaîne doit avoir entre 4 et 6 caractères
URL.actionModel03.url=URL invalide

错误消息已添加至第 4-16 行。其格式如下:

code=message

这些代码不能是任意内容,而是前一个操作 [/m27] 中显示的代码。例如:

Image

在消息文件中,[int1014]字段必须使用上述四个代码之一。

文件 [messages_en.properties] 如下所示:


NotNull=The field can't be empty
typeMismatch=Invalid format
typeMismatch.actionModel01.a=Parameter [a] must be an integer
Range.actionModel03.int1014=Value must be in [10,14] interval
NotBlank.actionModel03.strNotBlank=String can't be empty
AssertFalse.actionModel03.assertFalse=Only boolean [false] is allowed
Pattern.actionModel03.hhmmss=String format is hh:mm:ss
Past.actionModel03.dateInPast=Date must be before or equal to today's date
Future.actionModel03.dateInFuture=Date must be after today's date
Length.actionModel03.str4=String must be four characters long
Min.actionModel03.intMin10=Minimum 10
Max.actionModel03.intMax100=Maximum 100
AssertTrue.actionModel03.assertTrue=Only boolean [true] is allowed
Email.actionModel03.email=Invalid email
Size.actionModel03.strBetween4and6=String must be between four and six characters long
URL.actionModel03.url=Invalid URL

操作模板 [ActionModel03] 由以下操作调用:


// ----------------------- 错误消息的外部化 ------------------------
    @RequestMapping(value = "/m28", method = RequestMethod.POST)
    public Map<String, Object> m28(@Valid ActionModel03 data, BindingResult result, HttpServletRequest request) {
        Map<String, Object> map = new HashMap<String, Object>();
        // Spring 应用程序上下文
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // 本地化
        Locale locale = RequestContextUtils.getLocale(request);
        // 错误?
        if (result.hasErrors()) {
            for (FieldError error : result.getFieldErrors()) {
                // 根据错误代码查找错误消息
                // 在消息文件中搜索该消息
                // 以表格形式显示的错误代码
                String[] codes = error.getCodes();
                // 字符串形式
                String listCodes = String.join(" - ", codes);
                // 搜索
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {

                    }
                    i++;
                }
                // 是否找到?
                if (msg == null) {
                    msg = String.format("Indiquez un message pour l'un des codes [%s]", listCodes);
                }
                // 已找到 - 将错误添加到词典中
                map.put(error.getField(), msg);
            }
        } else {
            // 无错误
            map.put("data", data);
        }
        return map;
    }

我们之前已经对这种类型的代码进行过说明。唯一真正重要的是第 23 行:获取的错误消息取决于请求的区域设置。

以下是一个法语示例:

现在来看英语示例: