Skip to content

4. 操作:模型

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

在上一章中,我们探讨了将请求 [1] 路由到控制器及其处理该请求的操作 [2a] 的过程,这一机制被称为路由。 我们还介绍了操作可以返回给浏览器的各种响应。到目前为止,我们介绍的操作均未处理提交给它们的请求。一个请求 [1] 携带了各种信息,Spring MVC 会以模型的形式将这些信息 [2a] 呈现给操作。这个术语不应与操作生成的 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

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

:术语 [Action Model] 并非公认术语。

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

  

目前,[ActionModelController] 将如下所示:


package istia.st.springmvc.controllers;
 
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class ActionModelController {
 
}
  • 第 5 行:请注意,[@RestController] 注解会导致发送给客户端的响应是控制器操作结果的字符串序列化;

4.1. [/m01]:GET 参数

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


 
    // ----------------------- retrieve parameters with 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 行:该操作接受两个名为 [name] 和 [age] 的参数。它们将使用 HTTP GET 请求中同名的参数进行初始化;

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

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

4.2. [/m02]:POST 参数

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


 
    // ----------------------- retrieve parameters with 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 行:该操作接受两个名为 [name] 和 [age] 的参数。它们将使用 HTTP POST 请求中同名的参数进行初始化;

使用 [Advanced REST Client] 获得的结果如下:

  • 在 [1-3] 中,包含参数 [name] 和 [age] 的 POST 请求;
  • 在[4-5]中,我们为POST请求设置了HTTP头[Content-Type]。其值必须为[Content-Type: application/x-www-form-urlencoded];
  • 在 [6] 中,[表单数据] 提供了 POST 操作的参数列表。这里可以看到 [name] 和 [age] 这两个参数;
  • 在[7]中,服务器响应显示 [/m02] 操作已成功获取 [name] 和 [age] 参数;

4.3. [/m03]:名称相同的参数

我们在第2.5.2.8节中看到,多选列表可能会向服务器发送名称相同的参数。让我们看看操作如何检索它们。我们添加以下 [/m03] 操作:


    // ----------------------- retrieve parameters with the same names-----------------
    @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 行:该操作接受一个名为 [name] 的参数。由于此处未指定请求类型,因此无论 GET 还是 POST 请求,所有名称为 [name] 的参数都将在此处初始化;

结果如下:

  • 使用 POST [1] 方法时,我们会发送参数 [2];
  • 参数也包含在 URL 中 [3];
  • 在 [4] 中,四个名称相同的参数 [name]:[查询字符串参数] 是 URL 参数,[表单数据] 是提交的参数;
  • 在 [5] 中,我们可以看到 action [/m03] 获取了四个名为 [name] 的参数;

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

考虑以下新操作 [/m04]:


    // ------ map parameters to a Command Object ---------------
    @RequestMapping(value = "/m04", method = RequestMethod.POST)
    public Personne m04(Personne personne) {
        return person;
}
  • 第 3 行:该操作将以下类型的 Person 作为参数:

public class Personne {
 
    // identifier
    private Integer id;
    // name
    private String nom;
    // age
    private int age;
....
    // getters and setters
...
}
  • 为了创建 [Person] 参数,Spring MVC 会调用 [new Person()];
  • 随后,如果存在与创建对象的字段 [id, name, age] 同名的参数,则会通过其 setter 方法对这些参数进行实例化;
  • 第 4 行:该操作返回 [Person] 类型,因此会在发送给客户端之前将其序列化为字符串。我们看到,默认情况下执行的是 JSON 序列化。因此,客户端应收到一个人的 JSON 字符串;

以下是一个示例:

  • 在 [1] 中,用于构建 [Person] 对象的参数 [id, name, age];
  • 在 [2] 中,该人员的 JSON 字符串;

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

  • 在 [2] 中,只有 [id] 参数被初始化;

4.5. [/m05]:从 URL 中检索元素

考虑以下新操作 [/m05]:


    // ----------------------- retrieve elements from 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]:


    // -------- retrieve elements from URL and parameters---------------
    @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] 操作:


    // ------ access the HttpServletRequest query ------------------------
    @RequestMapping(value = "/m07", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m07(HttpServletRequest request) {
        // HTTP headers
        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 行:我们从请求中获取所有 HTTP 头部,将其组合成一个字符串,然后发送给客户端(第 11 行);

结果如下:

  • 在 [1] 中,请求的 HTTP 头部;
  • 在 [2] 中,即响应。请求中的所有 HTTP 头确实都包含在其中。

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

考虑以下操作:


    // ----------------------- injection de 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 行:该 Action 返回 [void] 类型,表示它必须自行构建发给客户端的响应;
  • 第 4 行:向发往客户端的响应流中添加文本;

结果如下:

  • 在[2]中,我们看到HTTP头部[Content-Type]未被发送;
  • 在[3]中,响应;

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

考虑以下操作:


    // ----------------------- injection of RequestHeader ------------------------
    @RequestMapping(value = "/m09", method = RequestMethod.GET)
    public String m09(@RequestHeader("User-Agent") String userAgent) {
        return userAgent;
}
  • 第 3 行:[@RequestHeader("User-Agent")] 注解用于获取 [User-Agent] HTTP 头;
  • 第 4 行:返回该标头的文本;

结果如下:

  • 在 [2] 中,HTTP 标头 [User-Agent];
  • 在 [3] 中,操作 [/m08] 正确地获取了此标头;

Cookie 通常是服务器首次向客户端发送的 HTTP 头;

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

首先,让我们创建一个用于生成 Cookie 的操作:


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

让我们来看一下结果:

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

现在,让我们创建一个操作来获取这个 Cookie,浏览器将随每次请求发送该 Cookie:


    // ----------------------- Cookie injection ------------------------
    @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 请求的请求体

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


    // ----------- retrieve the body of a POST of type String------------------------
    @RequestMapping(value = "/m12", method = RequestMethod.POST)
    public String m12(@RequestBody String requestBody) {
        return requestBody;
}
  • 第 3 行:[@RequestBody] 注解允许您获取 POST 请求体。此处,我们假设其类型为 [String];
  • 第 4 行:我们将此请求体返回给客户端;

以下是一个示例:

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

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

  • 在 [2-3] 中:我们将提交的值以 [key:value] 的形式输入;
  • 在 [5] 中,提交的字符串;

当类型为 [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] 提交参数。我们创建以下操作:


    // ----------------------- retrieve the jSON body from a POST
    @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] 表示此请求主体。该注解已与类型为 [Person] 的对象相关联。JSON 主体将自动反序列化为该对象;
  • 第 4 行:我们使用 [Person].toString() 方法返回与发送的 JSON 字符串不同的内容;

以下是一个示例:

  • 在 [2] 中,提交的 JSON 字符串;
  • 在 [3] 中,请求的 [Content-Type];
  • 在 [4] 中,服务器的响应;

您还可以通过其他方式实现相同的功能:


    // ----------------------- retrieve the jSON body from a POST 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 字符串被反序列化为 [Person] 对象(参见第 9.7 节第 542 页);

结果如下:

  • 在 [3] 中,请务必使用 [text/plain];

4.13. [/m15]: 获取会话

让我们重新审视一个操作的执行架构:

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

  • Web 应用程序所有用户共用的数据。这通常是只读数据;
  • 来自同一客户端的不同请求之间共享的数据。此类数据存储在一个名为“会话”(Session)的对象中。我们将其称为客户端会话,以指代客户端的内存。来自同一客户端的所有请求均可访问该会话,并可向其中存储或从中读取信息。

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

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

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


    // ----------------------- retrieve session ------------------------
    @RequestMapping(value = "/m15", method = RequestMethod.GET, produces = "text/plain;charset=UTF-8")
    public String m15(HttpSession session) {
        // retrieve the [counter] key object from the session
        Object objCompteur = session.getAttribute("compteur");
        // convert it to an integer to increment it
        int iCompteur = objCompteur == null ? 0 : (Integer) objCompteur;
        iCompteur++;
        // put it back in the session
        session.setAttribute("compteur", iCompteur);
        // we return it as the result of action
        return String.valueOf(iCompteur);
}

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

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

当 [/m15] 首次执行时:

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

以下是一个执行示例:

  • 在 [1] 中,我们确实得到了计数器的第一个值;
  • 在 [2] 中,服务器已发送了一个会话 Cookie。该 Cookie 的键为 [JSESSIONID],其值是针对每位用户生成的唯一字符串。 请记住,浏览器会始终将收到的 Cookie 发回。因此,当我们第二次请求 [/m15] 操作时,客户端会将此 Cookie 发回,这将使服务器能够识别它并将其与会话关联起来。这就是用户会话的维护方式;

让我们来看第二个请求:

  • 在[3]中,我们可以看到客户端发送了会话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;
 
    // ------ manage a scope object 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] 组件通过 [@Autowired] 注入到控制器中。请注意,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 {
 
    // meter
    private AtomicLong compteur = new AtomicLong(0);
 
    // getters and setters
    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)。否则,由于两个线程会读取相同的计数器值,导致计数器本应增加两个却只增加了一个,从而引发错误;

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


@Autowired
    private ApplicationModel application;
 
    // ----- manage an application scope object [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 {
    // the meter
    public int compteur=10;
 
    // getters and setters
    public int getCompteur() {
        return compteur;
    }
 
    public void setCompteur(int compteur) {
        this.compteur = compteur;
    }
}

我们将通过以下两个操作使用此对象:


    // use of [@SessionAttribute] ----------------------
    @RequestMapping(value = "/m18", method = RequestMethod.GET)
    public void m18(HttpSession session) {
        // here we put the key [container] in the session
        session.setAttribute("container", new Container());
    }
 
    // use of [@ModelAttribute] ----------------------
    // the session's [container] key will be injected here
    @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] 注入数据

请考虑以下新操作:


    // the p attribute will be included in all [Model] view models ----------------
    @ModelAttribute("p")
    public Personne getPersonne() {
        return new Personne(7,"abcd", 14);
    }
 
    // ---------------instanciation of @ModelAttribute --------------------------
    // will be injected if it is in the
    // will be injected if the controller has defined a method for this attribute
    // can come from the URL fields if a String --> type converter exists for the attribute
    // otherwise is built with the default constructor
    // then the model attributes are initialized with the parameters of GET or POST
    // the final result will be part of the model produced by the action
 
    // the p attribute is injected into the arguments------------------------
    @RequestMapping(value = "/m20", method = RequestMethod.GET)
    public Personne m20(@ModelAttribute("p") Personne personne) {
        return personne;
}
  • 第 2–5 行:定义一个名为 [p] 的模型属性。这是视图 V 的模型 M,在 Spring MVC 中由 [Model] 类型表示。模型的行为类似于 [键, 值] 对的字典。这里,键 [p] 与由 [getPerson] 方法构造的 [Person] 对象相关联。方法名可以是任意名称;
  • 第 17 行:键为 [p] 的模型属性被注入到操作的参数中。此注入遵循第 8–12 行中的规则。此处属于第 9 行定义的情况。因此,在第 17 行中,参数 [Person person] 将是对象 [Person(7, 'abcd', 14)];
  • 第 18 行:返回 [person] 对象以供验证。该对象将在发送给客户端之前被序列化为 JSON。

以下是一个示例:

 

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


    // --------- attribute p is automatically included in the M model of view V
    @RequestMapping(value = "/m21", method = RequestMethod.GET)
    public String m21(Model model) {
        return model.toString();
}

一个希望显示视图 V 的操作必须构建其模型 M。Spring MVC 通过 [Model] 类型来管理这一点,该类型可以注入到操作的参数中。初始时,该模型为空,或者包含带有 [@ModelAttribute] 注解的信息。操作在将模型传递给视图之前,可能会对该模型进行丰富处理,也可能不进行。

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

以下是执行示例:

 

在上文中,我们可以看到以下说明:


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

已在模型中创建了一个条目 [p, Person(7, "abcd", 14)]。情况总是如此。

现在考虑以下情况:


    // sinon est construit avec le constructeur par défaut
    // ensuite les attributs du modèle sont initialisés avec les paramètres du GET ou du POST

使用以下操作:


    // --------- model attribute [param1] is part of the model but is not initialized
    @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] 方法并未提供有关该值的任何信息。

现在考虑以下操作,其中我们显式地将信息放入模型中:


    // --------- the model attribute [param2] is explicitly set in the model
    @RequestMapping(value = "/m23", method = RequestMethod.GET)
    public String m23(String p2, Model model) {
        model.addAttribute("param2",p2);
        return model.toString();
}
  • 第 4 行:将第 3 行获取的值 [p2] 作为键 [param2] 添加到模型中:

以下是一个执行示例:

 

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


    // ------ the template attribute [unePersonne] is automatically set in the template
    @RequestMapping(value = "/m23b", method = RequestMethod.GET)
    public String m23b(@ModelAttribute("unePersonne") Personne p1, Model model) {
        return model.toString();
}

该操作不会修改传入的模型。结果如下:

我们可以看到,注解 [@ModelAttribute("unePersonne") Personne p1] 已将人员 [p1] 添加到模型中,并将其与键 [unePersonne] 关联。

现在,让我们考虑以下操作:


    // --------- person p1 is automatically included in the model
    // -------- with class name as key, 1st character lowercase
    @RequestMapping(value = "/m23c", method = RequestMethod.GET)
    public String m23c(Personne p1, Model model) {
        return model.toString();
}
  • 第 4 行:我们没有添加 [@ModelAttribute] 注解;

结果如下:

我们可以看到,[Person p1] 参数的存在将人 [p1] 放入了模型中,并与键 [person] 相关联,该键是 [Person] 类的名称,其首字母为小写。

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

考虑以下动作模型 [ActionModel01]:

 

package istia.st.springmvc.models;
 
import javax.validation.constraints.NotNull;
 
public class ActionModel01 {
 
    // data
    @NotNull
    private Integer a;
    @NotNull
    private Double b;
 
    // getters and setters
...
    }
  • 第 8 行和第 9 行:[@NotNull] 注解是一种验证约束,用于指定被注解的数据不能为

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


    // ----------------------- model validation ------------------------
    @RequestMapping(value = "/m24", method = RequestMethod.GET)
    public Map<String, Object> m24(@Valid ActionModel01 data, BindingResult result) {
        Map<String, Object> map = new HashMap<String, Object>();
        // mistakes?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            // browsing the error list
            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 {
            // no errors
            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] 类型,原本为 [String] 类型的 [a] 参数必须可转换为 [Integer] 类型;
    • 由于 [Double b] 类型的要求,原本为 [String] 类型的 [b] 参数必须能够转换为 [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]:类型为 [ActionModel01] 的 [a] 字段发生类型错误;
  • [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

其中,"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] 缺失时;
  • [NotNull.actionModel01.b - NotNull.b - NotNull.java.lang.Double - NotNull] 当参数 [b] 缺失时;

[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 消息文件:
    • 位于项目类路径中的 [i18n] 文件夹内(第 18 行),
    • 文件名为 [messages.properties](第 18 行)。实际上,[messages] 是消息文件名的根前缀,而非文件名本身。 我们将看到,在国际化的背景下,可能存在多个消息文件,每个受支持的语言环境对应一个。因此,我们可能会有用于法语的 [messages_fr.properties] 和用于英语的 [messages_en.properties]。添加到 [messages] 根目录后的后缀是标准化的。你不能随意使用任何后缀;

在 STS 项目中,[i18n] 文件夹必须放置在 resources 文件夹内,因为它会被添加到项目的类路径中:

  

要使用此文件,我们需要创建以下新操作:


// model validation, error message handling ------------------------
    @RequestMapping(value = "/m25", method = RequestMethod.GET)
    public Map<String, Object> m25(@Valid ActionModel01 data, BindingResult result, HttpServletRequest request)
            throws Exception {
        // results dictionary
        Map<String, Object> map = new HashMap<String, Object>();
        // spring application context
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // local
        Locale locale = RequestContextUtils.getLocale(request);
        // mistakes?
        if (result.hasErrors()) {
            StringBuffer buffer = new StringBuffer();
            for (FieldError error : result.getFieldErrors()) {
                // search for error msg using error codes
                // the msg is searched in the message files
                // error codes in table format
                String[] codes = error.getCodes();
                // in chain form
                String listCodes = String.join(" - ", codes);
                // research
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {
 
                    }
                    i++;
                }
                // have we found?
                if (msg == null) {
                    throw new Exception(String.format("Indiquez un message pour l'un des codes [%s]", listCodes));
                }
                // found - add error msg to error msg chain
                buffer.append(String.format("[%s:%s:%s:%s]", locale.toString(), error.getField(), error.getRejectedValue(),
                        String.join(" - ", msg)));
            }
            map.put("errors", buffer.toString());
        } else {
            // ok
            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] 表示英语(美国)。 消息会在 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] 将检查传入的 GET 或 POST 请求中是否包含名为 [lang] 的参数,并根据该参数更改应用程序的区域设置。因此,如果参数为 [lang=en_US],应用程序的区域设置将变为美国英语;
  • 第 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 中访问网址 [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 {
...
        // local
        Locale locale = RequestContextUtils.getLocale(request);
// mistakes?

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


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

如上所示,系统并未验证请求的区域设置是否有效。然而,由于收到的区域设置 Cookie 不正确,浏览器的后续请求会触发服务器端异常。

4.22. [/m27]: 使用 Hibernate Validator 验证模型

考虑以下新操作:


    //model validation with 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>();
        // mistakes?
        if (result.hasErrors()) {
            // browsing the error list
            for (FieldError error : result.getFieldErrors()) {
                map.put(error.getField(),
                        String.format("[message=%s, codes=%s]", error.getDefaultMessage(), String.join("|", error.getCodes())));
            }
        } else {
            // no errors
            map.put("data", data);
        }
        return map;
}

这里是我们在之前多次见过的代码:

  • 第 3 行:通过 POST 请求调用 [/m27] 操作;
  • 第 8–11 行:每个错误将通过 [字段, 消息] 进行标识,其中:
    • 字段:发生错误的字段,
    • 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;
 
    // getters and setters
 
...
}

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

  • 第 5–13 行使用 [javax.validation.constraints];
  • 第 15–19 行使用 [org.hibernate.validator.constraints];

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

  

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

  • 在 [1-2] 中,POST 请求;
  • 在 [3] 中,要使用的 HTTP 头部 [Content-Type];
  • 在 [4] 中,[添加新值] 链接允许您添加 [参数, 值] 对;
  • 在 [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

请读者尝试各种错误情况,直到成功提交所有有效数据:

注意:日期格式采用英美格式: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;
 
    // getters and setters
        ...
}

错误消息被封装在 [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]:


// ----------------------- externalization of error messages ------------------------
    @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 application context
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // local
        Locale locale = RequestContextUtils.getLocale(request);
        // mistakes?
        if (result.hasErrors()) {
            for (FieldError error : result.getFieldErrors()) {
                // search for error msg using error codes
                // the msg is searched in the message files
                // error codes in table format
                String[] codes = error.getCodes();
                // in chain form
                String listCodes = String.join(" - ", codes);
                // research
                String msg = null;
                int i = 0;
                while (msg == null && i < codes.length) {
                    try {
                        msg = ctx.getMessage(codes[i], null, locale);
                    } catch (Exception e) {
 
                    }
                    i++;
                }
                // have we found?
                if (msg == null) {
                    msg = String.format("Indiquez un message pour l'un des codes [%s]", listCodes);
                }
                // we have found - we add the error to the dictionary
                map.put(error.getField(), msg);
            }
        } else {
            // no errors
            map.put("data", data);
        }
        return map;
    }

我们已经讨论过这种类型的代码。真正重要的是第 23 行:返回的错误消息取决于请求的区域设置。

以下是一个法语示例:

现在来看英文版本: