你可以用本指南了解什么是 Spring MVC,它的 @Controller、 @RestController 以及 DispatcherServlet 是如何工作的。此外,它与 Spring Boot 如何比较。
简介
什么是 Spring MVC?
Spring MVC 是 Spring 的 Web 框架。它允许你创建网站或者 RESTful 服务(想想 JSON/XML),并很好地集成到 Spring 生态系统中,比如,它为 Spring Boot 应用程序提供了 @Controller 和 @RestController。
MVC 基础:HttpServlet
在用 Java 编写 Web 应用程序时,不管用不用 Spring(MVC、Boot),你肯定是在谈及编写返回两种不同数据格式的应用程序:
- HTML:Web 应用程序创建可以在浏览器中查看的 HTML 页面。
- JSON/XML:Web 应用程序提供 RESTful 服务,生成 JSON 或者 XML。然后大量使用 JavaScript 的网站甚至其它 Web 服务都可以消耗这些服务提供的数据。
是的,还有其他的数据格式和用例,但是我们暂时忽略它们。
如果没有任何框架,你会如何编写这样的应用程序?就用普通 Java?
回答这个问题对于真正理解 Spring MVC 是必不可少的,所以不要因为认为它与 Spring MVC 无关而直接跳过。
答案
在最底层,每个Java Web 应用程序都由一个或多个 HttpServlet
组成。它们生成HTML、JSON 或 XML。
实际上,几乎每个 Java Web 框架都是建立在 HttpServlet 之上。
如何用 HttpServlet 编写 HTML 页面
下面我们看看一个超级简单的 HttpServlet,它返回一个非常简单的静态 HTML 页面。
package com.foo.springmvcarticle;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MyServletV1 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
if (req.getRequestURI().equals("/")) {
resp.setContentType("text/html");
resp.getWriter().print("<html><head></head><body><h1>Welcome!</h1><p>This is a very cool page!</p></body></html>");
}
else {
throw new IllegalStateException("Help, I don't know what to do with this url");
}
}
}
下面我们把这段代码分解一下。
public class MyServletV1 extends HttpServlet {
你的 servlet 继承了 Java 的 HttpServlet 类。
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
要处理 GET 请求,需要重写超类中的 doGet()
方法。对于 POST 请求,需要重写 doPost()
方法。所有其它 HTTP 方法一样。
if (req.getRequestURI().equals("/")) {
你的 servlet 需要确保传入的 URL是一个它知道如何处理的请求。目前,这个 servlet 只处理 "/"
,即,它要处理 www.foo.com
,而不是 www.foo.com/hello
。
resp.setContentType("text/html");
你需要在 ServletResponse 上设置适当的 Content-Type,以便让浏览器知道你发送的内容。在本例中,它是 HTML。
resp.getWriter().print("<html><head></head><body><h1>Welcome!</h1><p>This is a very cool page!</p></body></html>");
记住:网站只是 HTML 字符串!因此,您需要生成一个 HTML 字符串,以任何方式发送给 ServletResponse。一种方法是用响应的 writer。
编写完 servlet 之后,您将用 servlet 容器注册它,如Tomcat或 Jetty。如果您使用的是 servlet 容器的嵌入式版本,则运行 servlet 所需的所有代码如下所示:
package com.foo.springmvcarticle;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.Wrapper;
import org.apache.catalina.startup.Tomcat;
public class TomcatApplicationLauncher {
public static void main(String[] args) throws LifecycleException {
Tomcat tomcat = new Tomcat();
tomcat.setPort(8080);
tomcat.getConnector();
Context ctx = tomcat.addContext("", null);
Wrapper servlet = Tomcat.addServlet(ctx, "myServlet", new MyServletV2());
servlet.setLoadOnStartup(1);
servlet.addMapping("/*");
tomcat.start();
}
}
下面我们把这段代码分解一下。
Tomcat tomcat = new Tomcat();
tomcat.setPort(8080);
tomcat.getConnector();
配置一个新的Tomcat服务器,它将从8080端口启动。
Context ctx = tomcat.addContext("", null);
Wrapper servlet = Tomcat.addServlet(ctx, "myServlet", new MyServletV2());
这就是向Tomcat注册Servlet的方法。这是第一部分,您只需告诉Tomcat您的servlet。
servlet.addMapping("/*");
第二部分是让Tomcat知道servlet负责什么请求,即映射。/*
的映射意味着它负责所有传入的请求(/users
,/register
,/checkout
)。
tomcat.start();
就这样。现在运行 main()
方法,转到您最喜欢的浏览器中的端口8080(http://localhost:8080/,您将看到一个漂亮的HTML页面。
因此,本质上,只要您继续扩展doGet()
和doPost()
方法,您的整个web应用程序可能只包含一个servlet。我们来试试看。
如何用 HttpServlets 编写 JSON 端点
想象一下,除了您的(相当空的)HTML索引页,您现在还想为即将开发的前端提供 REST API。因此,您的 React 或 AngularJS 前端会调用这样的 URL:/api/users/{userId}
。
该端点应该为具有给定userId的用户返回JSON格式的数据。我们如何增强我们的 MyServlet
来做到这一点,又一次,不允许使用任何框架?
package com.foo.springmvcarticle;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class MyServletV2 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
if (req.getRequestURI().equals("/")) {
resp.setContentType("text/html");
resp.getWriter().print("<html><head></head><body><h1>Welcome!</h1><p>This is a very cool page!</p></body></html>");
} else if (req.getRequestURI().startsWith("/api/users/")) {
Integer prettyFragileUserId = Integer.valueOf(req.getRequestURI().lastIndexOf("/") + 1);
resp.setContentType("application/json");
// User user = dao.findUser(prettyFragileUserId)
// actually: jsonLibrary.toString(user)
resp.getWriter().print("{\n" +
" \"id\":" + prettyFragileUserId + ",\n" +
" \"age\": 55,\n" +
" \"name\" : \"John Doe\"\n" +
"}");
} else {
throw new IllegalStateException("Help, I don't know what to do with this url");
}
}
}
下面我们把这段代码分解一下。
} else if (req.getRequestURI().startsWith("/api/users/")) {
我们给 doGet 方法添加了另一个 if
,用于处理 /api/users/
调用。
Integer prettyFragileUserId = Integer.valueOf(req.getRequestURI().lastIndexOf("/") + 1);
我们做了一些极其脆弱的 URL 解析。这个 URL 的最后一部分是 userid,比如:/api/users/5
就是 5
。 在这里只假设用户总是传入一个有效的 int,实际上需要对它做验证!
resp.setContentType("application/json");
将 JSON 写入浏览器意味着要设置正确的 content-type。
// User user = dao.findUser(prettyFragileUserId)
// actually: jsonLibrary.toString(user)
resp.getWriter().print("{\n" +
" \"id\":" + prettyFragileUserId + ",\n" +
" \"age\": 55,\n" +
" \"name\" : \"John Doe\"\n" +
"}");
同样,JSON 只是文本,因此我们可以直接将其写入 HTTPServletResponse。您可能会使用 JSON 库将 User Java 对象转换为这种字符串,但是为了简单起见,我在这里不做说明。
用一个 Servlet 管理所有这些方法的问题
虽然我们上面的 servlet 能正常工作,但仍有相当多的问题即将出现:
- 你的 Servlet 需要手动执行很多 HTTP 有关的解析、检查请求 URL、处理字符串等等,换句话说:它需要知道用户想要做什么。
- 然后它还需要找到您想要显示的内容的数据。换句话说:它需要知道“怎么做”。在上面的例子中,是在数据库中查找用户,我们很方便地将其注释掉。
- 然后,它还需要将数据转换为 JSON 或 HTML,并设置适当的响应类型。
一个 Servlet 有太多不同的职责,对吧?如果你不必管那些 HTTP 解析,不再有请求 URI 和参数解析,不再有 JSON 转换,没有不再有 servlet 响应,是不是会更好一些?
这刚好就是 Spring MVC 的用武之地。
什么是Spring MVC 的 DispatcherServlet?
如果我告诉你 Spring MVC 实际上只是一个 servlet,就跟我们上面的超级 servlet 一样,会怎么样? (是的,这当然是一个小谎言)
我们来会会 DispatcherServlet。
Spring MVC 的 DispatcherServler 处理所有传入的 HTTP 请求(也就是说,它也称为前端控制器)。现在,这个处理到底是什么意思?
一个示例 HTTP 请求流程
想象一下“注册用户工作流”,用户填写表单,并将其提交给服务器,最后得到一个小小的成功 HTML 页面。
在这种情况下,你的 DispatcherServlet 需要做如下事情:
它需要查看传入的 HTTP 方法请求 URI 和任何请求参数。比如:
POST /register?name=john&age33
。它可能需要将传入的数据(请求参数或者正文)转换成漂亮的小 Java 对象,并将它们转发到你自己编写的
@Controller
类或@RestController
类。你的
@Controller
方法将一个新用户保存到数据库,可能会发送一封电子邮件,等等。它很可能会将其委托给另一个服务类,但现在让我们假设这发生在控制器内部。它需要接受 @Controller 的任何输出,并将其转换回
HTML/JSON/XML
。
DispatcherServlet 概述
整个过程看起来是这样的,忽略了相当数量的中间类,因为 DispatcherServlet 本身并不做所有的工作。
上图中的 ModelAndView 是什么? DispatcherServlet 究竟如何转换数据呢?
通过看一个真实的例子是最容易理解的。比如,如何用 Spring MVC 编写 HTML 网站?我们在下一节中找出答案。
如何用 @Controller 编写 HTML
只要你想用 Spring MVC(并且这也包括 Spring Boot)写 HTML 到一个客户端(比如浏览器),就会想写一个 @Controller 类。下面我们就开始吧。
如何在 Spring 中写一个 @Controller
对于上面的用户注册流程(POST /register?name=john&age33
),你需要编写如下类:
package com.foo.springmvcarticle;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class RegistrationController {
@PostMapping("/register")
public String registerUser(@RequestParam(required = false) Integer age, @RequestParam String name, Model model) {
User user = new User(name, age);
// TODO 把用户存到数据库
// userDao.save(user);
// TODO 发送注册电子邮件
// mailService.sendRegistrationEmail(user);
model.addAttribute("user", user);
return "registration-success";
}
}
下面我们把这段代码分解一下。
@Controller
public class RegistrationController {
Spring 中的控制器类就是用 @Controller
注解的类,它不需要实现特定的接口,也不需要继承另一个类。
@PostMapping("/register")
这一行是告诉我们的 DispatchServlet:只要有对路径 /register
的 POST 请求进来了(不管它是否包含请求参数,比如 ?username=
),就应该将请求分发到这个控制器方法。
public String registerUser(@RequestParam(required = false) Integer age, @RequestParam String name, Model model) {
请注意:方法名(registerUser
)不重要,叫什么都可以。
但是我们会指定每个请求应该包含两个请求参数,它们可以是URL(比如,age=10&name=joe
)的一部分,也可以在 POST 请求体中。而且,只有 name
参数是必填的(age
参数是可选的)。
而对于 age
参数,如果用户提供了,就将其自动转换为 Integer(如果提供的值不是有效的 Integer,就抛出一个异常)。
最后,Spring MVC 会自动将一个参数 model
注入到我们的控制器方法。这个模型是一个简单的映射,你需要在其中放置希望在最终 HTML 页面中显示的所有数据,稍后会详细介绍。
User user = new User(name, age);
// TODO 将用户存到数据库
// userDao.save(user);
// TODO 发送注册邮件
// mailService.sendRegistrationEmail(user);
可以对传入的请求数据执行任何所需的操作。比如创建一个用户,存到一个数据库,发送电子邮件等等。这里就是你的业务逻辑。
model.addAttribute("user", user);
将用户添加到模型中的 “user” 键下。这意味着,稍后可以在 HTML 模板中引用它,比如 ${user.name}
。稍后会有更详细的解释。
return "registration-success";
你的方法返回一个简单字符串,值为 registration-success
。这不仅仅是一个字符串,而是对你的视图的一个引用,即你希望 Spring 去渲染的 HTML 模板。
视图是什么样的?
下面我们忽略 Spring MVC 会如何(或者更确切地说是在哪里)找到该视图(即模板),先来看看你的 registration-success.html
模板应该是什么样子。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<p th:text="'Hello ' + ${user.name} + '!'"></p>
</body>
</html>
这只是一个简单的 HTML 页面,页面中包含了一个模板行。它打印出刚注册过的用户的姓名。
<p th:text="'Hello ' + ${user.name} + '!'"></p>
问题是,这个 th:text=
语法是什么玩意?是 Spring 特定的,还是其它的什么东西?
答案是 Spring MVC 实际上对 HTML 模板一无所知。它需要一个第三方模板库来完成所有的 HTML 模板化工作,并且不一定关心你选择什么模板库。
在上例中,你看到的是一个 Thymeleaf 模板。Thymeleaf 是一个在处理 Spring MVC 项目时非常流行的模板库选择。
Spring MVC 和模板库
有几种不同的模板库可以很好地与 Spring MVC 集成,你可以选择:Thymeleaf、Velocity、Freemarker、Mustache,甚至 JSP (即使那不是模板库)。
事实上,你必须显式地选择一个模板库,因为如果没有把这样的模板库添加到项目中,并正确配置好,那么你的 @Controller
方法就不会渲染出 HTML 页面,因为它不知道如何进行。
这也意味着你必须学习和理解特定模板库的语法,这取决于你所处的项目,因为它们彼此都略有不同。很有趣,对吧?
什么是 ViewResolver?
接下来,我们来考虑一下 Spring 会在哪里找到 @Controller 返回的 HTML 模板。
试图查找模板的类称为 ViewResolver
。因此,每当有请求进入你的控制器时,Spring 都会查看配置好的 ViewResolver,并要求它们依次找到具有给定名称的模板。如果没有配置任何 ViewResolver,就不起作用。
假如想集成 Thymeleaf,那么就需要一个 ThymeleafViewResolver。
package com.foo.springmvcarticle;
import org.springframework.context.annotation.Bean;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
public class ThymeleafConfig {
@Bean
public ThymeleafViewResolver viewResolver() {
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates");
templateResolver.setSuffix(".html");
// 忽略掉一些行...
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
// 忽略掉一些行...
viewResolver.setTemplateEngine(templateEngine);
return viewResolver;
}
}
下面我们把这段代码分解一下。
@Bean
public ThymeleafViewResolver viewResolver() {
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
最后,ThymeleafViewResolver 只是实现了 Spring 的 ViewResolver
接口。给定模板名称(请记住:registration-success
),ViewResolver 就可以找到实际的模板。
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
ThymeleafViewResolver 需要两个其他 Thymeleaf 相关的类才能正常工作。其中一个类是SpringResourceTemplateResolver
。它执行查找模板的实际工作。
注意:SpringResourceTemplateResolver 是一个 Thymeleaf 类。
templateResolver.setPrefix("classpath:/templates");
templateResolver.setSuffix(".html");
这实际上是在说(借助 Spring 资源语法):所有我们的模板都在类路径的 /templates
文件夹中。默认情况下,它们都是以 .html
结尾。这意味着:
每当 @Controller 返回一个类似于 registration-success
的字符串,ThymeleafViewResolver 就会查找一个模板:classpath:/templates/registration-success.html
。
附注:Spring MVC、Spring Boot 和控制器
你可能在想:在用 Spring Boot 开发项目时,我从来就没有配置过这样的 ViewResolver。这是对的。因为 只要你给项目添加爱一个像 spring-boot-starter-thymeleaf
这样的依赖,Spring Boot 就会自动为你配置一个。
默认情况下,它还把 ViewResolver 配置为查看你的 src/main/resources/template
目录。
所以,Spring Boot 实际上只是为你预先配置好了 Spring MVC。记住这一点。
小节:模型-视图-控制器
看过完整的 @Controller 和 ViewResolver 示例后,讨论 Spring 的模型-视图-控制器(Model-View-Controller)概念就容易得多了。
只需几个注解(@Controller、@PostMapping、@RequestParam),你就可以编写一个控制器负责接收请求数据,并对其进行相应的处理。
你的模型包含了你想在视图上渲染的所有数据。填充模型映射是你的工作。
你的视图只是一个 HTML 模板。它不关心数据(模型)从哪里来,或者当前 HTTP 请求是什么,甚至你是否有活动的 HTTP 会话。
这都与关注点分离有关。
乍一看,我们的 Spring @Controller 类的注解有点多,但是其可读性要好多的,而且与最开始的万能 Servlet 相比,它涉及的 HTTP 解析要少得多。
有关 @Controller 的更多信息
我们已经看到了 Spring MVC 在处理 HTTP 输入时给我们带来的一些便利。
你不必笨手笨脚地处理 requestURI,可以用一个注解来代替。
你不必笨手笨脚地进行请求参数类型转换,或者判断参数是可选的或必需的,可以用一个注解来代替。
下面我们来看看可以帮助我们处理传入的 HTTP 请求的最常见的注解。
@GetMapping 和 @RequestMappping
上面你已经看到过 @GetMapping
注解。它等同于 @RequestMapping
注解。我们看看是怎么回事:
@GetMapping("/books")
public void book() {
//
}
/* 这两个映射是相等的 */
@RequestMapping(value = "/books", method = RequestMethod.GET)
public void book2() {
}
@GetMapping
、@[Post|Put|Delete|Patch]Mapping
等同于 @RequestMapping(method=XXX)
。它只是指定映射的一种更新的方式(从 Spring 4.3+开始),所以如果你会在较老的遗留 Spring 项目中看到 RequestMapping
注解用了很多。
@RequestParam
对于 HTTP 请求参数,不管它是在 URL中(?key=value
),还是在被提交的表单请求体中,可以通过 @RequestParam
注解读出。
你已经看到过它执行了基本的类型转换(例如,从 HTTP 字符串参数转换为 int),以及检查是否是必需或者可选参数。
@PostMapping("/users") /* First Param is optional */
public User createUser(@RequestParam(required = false) Integer age, @RequestParam String name) {
// does not matter
}
如果你忘记在请求中提供必需的参数,就会得到一个 400 Bad Request
的响应码,并且如果是用 Spring Boot,还会得到一个默认的错误对象,看起来像这样子:
{"timestamp":"2020-04-26T08:34:34.441+0000","status":400,"error":"Bad Request","message":"Required Integer parameter 'age' is not present","path":"/users"}
如果你想更方便点,可以让 Spring 直接将所有 @RequestParam 转换成一个对象,而不需要任何必要的注解。只需将你的对象指定为方法参数即可。
你只需要确保你的类有对应的 getter/setter。
@PostMapping("/users") /* 如果有 getter 和 setter,Spring 自动转换 */
public User createUser(UserDto userDto) {
//
}
@PathVariable
除了请求参数外,另一种指定变量的流行方式是直接在请求 URI 中指定变量作为 @PathVariable
。因此,要获取 userId=123
的用户信息,你可以调用URL:Get/Users/123
。
@GetMapping("/users/{userId}") // (1)
public User getUser(@PathVariable String userId) {
// ...
return user;
}
- (1) 你只需要确保你的参数值与请求映射注解中的
{}
之间的那个值匹配即可。
此外,PathVariables
也可以是必需的或者可选的。
@GetMapping("/users/{userId}")
public User getUser(@PathVariable(required = false) String userId) {
// ...
return user;
}
当然,Pathvariable 可以直接被转换为 Java 对象(假设该对象有匹配的getter/setter)。
@GetMapping("/users/{userId}")
public User getUser(UserDto userDto) {
// ...
return user;
}
小节:@Controller
简而言之,用 Spring MVC 编写 HTML 页面时,你只需做几件事:
- 编写 @Controller,加几个注解。Spring 会负责用方便的方式向你显示请求输入(请求参数、路径变量)。
- 执行填充模型所需的任何逻辑。你可以方便地将模型注入任何控制器方法。
- 让你的 @Controller 知道你希望渲染哪个 HTML 模板,并以字符串形式返回模板名称。
- 每当请求传入时,Spring 会确保调用你的控制器方法,并获取结果模型和视图,将其渲染为 HTML 字符串,并返回给浏览器。
- (当然,你设置适当的模板库。只要你将所需的依赖添加到项目中,Spring Boot 就会自动为你搞定。)
就这样。
如何用 @RestController 写 XML 和 JSON
当编写 REST 风格的服务时,情况有所不同。你的客户端(浏览器或另一个 Web 服务)通常会创建 JSON 或 XML 请求。比方说,客户端发送一个 JSON 请求,你对其进行处理,然后发送方期望返回的是 JSON。
因此,发送者可能会将这段 JSON 作为 HTTP 请求体的一部分发送给你。
POST http://localhost:8080/users
###
{"email": "angela@merkel.de"}
但是在 Java 端(在你的 Spring MVC 程序中),你不想玩原生 JSON 字符串。在像上面那样接收请求时不想,在发送响应会客户端时也不想。你只想有由 Spring 自动转换的 Java 对象。
public class UserDto {
private String email;
//...
}
这也意味着你不需要再做在 @Controller
中渲染 HTML 时必须做的所有模型和视图处理工作。对于 Restful 服务,你不需要一个模板库来读 HTML 模板,并用模型数据来填充它,从而为你生成 JSON 响应。而是直接从 HTTP 请求到 Java 对象,以及从 Java 对象到 HTTP 响应。
可能你已经菜刀了,这正是通过编写 @RestController
,Spring MVC 允许你做的事情。
如何写 @RestController
为输出 XML或者 JSON,你需要做的第一件事情就是写一个 @RestController
,而不是 @Controller
。不过,@RestController 也是 @Controller,请参考后面的 FAQ,查看二者之间的区别到底是什么。
如果我们要为一个银行写一个 REST 控制器,它将返回一个用户的交易列表,那么代码可能就是这样的:
package com.foo.springmvcarticle;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
@RestController
public class BankController {
@GetMapping("/transactions/{userId}")
public List<Transaction> transactions(String userId) {
// find transaction by user
// List<Transaction> = dao.findByUserId(userId);
List<Transaction> transactions = Collections.emptyList();
return transactions;
}
}
我们把这段代码分解一下。
@RestController
public class BankController {
你用 @RestController
注解把 BankController 类注解了,这个注解就是通知 Spring:你不想通过通常的 ModelAndView 过程编写 HTML 页面,而是像把 XML/JSON(或者其它一些格式)直接写入 HTTP 响应体中。
public List<Transaction> transactions(String userId) {
你的控制器不再返回一个字符串(视图),而是返回一个 List<Transaction>
,你想让 Spring 将其转换为适当的 JSON 或者 XML 结构。你基本上是想让你的 Transaction 对象变成这样子:
[
{
"occurred": "28.04.2020 03:18",
"description": "McDonalds - Binging",
"id": 1,
"amount": 10
},
{
"occurred": "28.04.2020 06:18",
"description": "Burger King - Never enough",
"id": 2,
"amount": 15
}
]
但是 Spring MVC 怎么知道你的交易列表应该被转换成 JSON 呢?为什么不是 XML 呢?或者 YAML 呢?你的 @RestController 方法是如何知道假定的响应格式应该是什么呢?
为此,Spring 有了内容协商的概念。
(响应)内容协商机制:Accepter 标头
简而言之,内容协商就是客户端需要告诉服务器它希望从 @RestController 返回什么响应格式。
如何做到?通过在 HTTP 请求中指定 Accept
标头。
GET http://localhost:8080/transactions/{userid}
Accept: application/json
Spring MVC 会查看 Accept
标头,知道:客户端想要返回的是 JSON(application/json),所以我需要把 List<Transaction>
转换成 JSON。请注意,还有其他方法可以执行内容协商,不过 Accept 标头是默认的方式。
下面我们将这称为响应内容协商,因为它与你要发送回客户端的 HTTP 响应的数据格式有关。
不过内容协商也适用于传入的请求。下面我来看看如何做。
请求内容协商机制:Content-Type 标头
在创建 RESTful API 时,很有可能你还希望客户端能发送 JSON 或者 XML。下面我们还是用本章开头的示例,这个示例提供了一个 REST 端点来注册新用户:
POST http://localhost:8080/users
###
{"email": "angela@merkel.de"}
Spring 是怎么知道上面的请求体包含 JSON,而不是 XML 或 YAML 呢?
你可能猜对了,我们必须添加另一个标头,这次是 Content-Type
标头。
POST ...
Content-Type: application/json; charset=UTF-8
###
...
那么该请求对应的 @RestController 方法会是什么样子的呢?
package com.foo.springmvcarticle;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookingController {
@PostMapping("/transactions")
public Transaction transaction(@RequestBody TransactionDto dto) {
// 对数据执行某些操作..创建预订..将其转换为一个transaction
Transaction transaction = null;
return transaction;
}
}
下面我们把这段代码分解一下。
public Transaction transaction(@RequestBody TransactionDto dto) {
与 @RequestParam 或 @PathVariable 类似,你还需要另一个注解 @RequestBody。
@RequestBody 结合正确的 Content-Type
就会通知 Spring 它需要查看 HTTP 请求体,并将其转换为用户指定的任何 Content-Type
(在本例中是 JSON)。
// 对数据执行某些操作..创建预订..将其转换为一个transaction
Transaction transaction = null;
return transaction;
}
然后,你的方法就不必再关心原始 JSON 字符串了,它可以简单地使用 TransactionDTO,将其保存到数据库,将其转换为一个 Transaction 对象,或者任何你想要的东西。
这就是 Spring MVC 的威力。
Spring 如何转换数据格式?
这里只有一个小问题:Spring 知道 Accept 和 Content-Type 头,但不知道如何在 Java 对象和 JSON 或者 XML、YAML 之间转换。
这些脏活(也称序列化/反序列化)需要一个合适的第三方库来做。整合 Spring MVC 以及这些第三方库的类叫做 HttpMessageConverter
。
什么是 HttpMessageConverter?
HttpMessageConverter 是一个带有四个方法的接口(注意,为了更容易解释,我对接口稍作了简化,这样它在现实生活中就看起来更高级点)。
- canRead(MediaType):这个转换器能读 JSON、XML、YAML 等吗?传到这里的 MediaType 通常来自
Content-Type
请求头的值。 - canWrite(MediaType):这个转换器能写 JSON、XML、YAML 等吗?传到这里的 MediaType 通常来自
Accept
请求头的值。 - read(Object, InputStream, MediaType):从 JSON、XML、YAML 等 InputStream 读 Java 对象。
- write(Object, OutputStream, MediaType):将 Java 数据作为 JSON、XML、YAML 等写入 OutputStream。
简而言之,MessageConverter 需要知道它支持哪些 MediaType(想想:Application/json),然后需要实现两个方法来执行该数据格式的实际读/写。
有哪些 HttpMessageConverter ?
好在你不需要自己编写这些消息转换器。Spring MVC 带了一个类,它可以自动为你注册几个默认的 HTTPMessageConverter - 如果你在类路径上有合适的第三方库的话。
如果你不知道这个的话,那听起来就像魔术一样。无论如何,看看 Spring 的AllEncompassingFormHttpMessageConverter
(我喜欢这个名字):
private static final boolean jackson2XmlPresent;
private static final boolean jackson2SmilePresent;
private static final boolean gsonPresent;
private static final boolean jsonbPresent;
static {
下面我们把这段代码分解一下。
Spring MVC 检测类 javax.xml.bind.Binder
是否存在,如果是,就假定你已经向项目中添加了所需的库来执行JAXB 转换。
private static final boolean jackson2SmilePresent;
Spring MVC 检测两个类 ..jackson..ObjectMapper
和 ..jackson..JsonGenerator
是否存在,如果是,就假定你已经将 Jackson 添加到你的项目中执行 JSON 转换。
private static final boolean gsonPresent;
Spring MVC 检测类 ..jackson..XmlMapper
是否存在,如果是,就假定你已经向项目中添加 Jackson 的 XML 支持执行 XML 转换。
等等。并且在几行之后,Spring 就为它”检测到”的每个库添加一个 HttpMessageConverter。
public AllEncompassingFormHttpMessageConverter() {
if (!shouldIgnoreXml) {
try {
addPartConverter(new SourceHttpMessageConverter<>());
}
catch (Error err) {
// Ignore when no TransformerFactory implementation is available
}
附注:Spring MVC、Spring Boot 和 RestControlper
当创建 Spring Boot 项目时,你将会在背后自动使用 Spring MVC。不过 Spring Boot 也默认支持 JSON。
这就是为什么你可以用 Spring Boot 立即编写 JSON 端点的原因,因为正确的 HttpMessageConverter 会自动为你添加进来。
小节:@RestController
与 HTML 流程相比,JSON/XML 流程稍微简单一些,因为绕过了 Model 和 View 渲染。
@Controller 直接返回 Java 对象,这样 Spring MVC 就会方便地将这些对象序列化为 JSON/XML 或者用户在 HttpMessageConverter 的帮助下请求的任何其它格式。
不过,你需要确保两件事情:
在类路径上有相应的第三方库。
请确保每次请求都发送正确的
Accept
头或Content-Type
头。
结束
真是一次相当长的旅程。最后,我希望你从本文中学到了一些东西:
- Spring MVC 是一个不错的老 MVC 框架,它让你可以相当容易地编写 HTML 网站或 JSON/XML Web 服务。
- 它可以与很多模板库和数据转换库很好地整合,也可以与 Spring 生态圈的其余部分(如 Spring Boot)很好地整合。
- 它主要让你专注于编写业务逻辑,而不必太担心 Servlet 样板代码、HTTP请求/响应解析和数据转换。
今天就到这里吧。谢谢你的阅读。