在Web开发一章,我们学习了JavaEE中Web开发的基础:Servlet,总结来说:
Servlet规范定义了几种标准组件:Servlet、JSP、Filter和Listener
Servlet的标准组件总是运行在Servlet容器中,如Tomcat、Jetty、WebLogic等
直接使用Servlet进行Web开发好比直接在JDBC上操作数据库,比较繁琐。更好的方法是在Servlet基础上封装MVC框架,基于MVC开发Web应用,大部分时候,不需要接触Servlet API,省时省力。我们已经介绍过了如何编写MVC框架,当然自己写MVC主要是理解原理,要实现一个功能全面的MVC需要大量的工作和广泛的测试。所以,开发Web应用,首先要选择一个优秀的MVC框架。 Spring框架虽然可以集成任何Web框架,但是Spring本身开发的Spring MVC已经足够优秀,甚至已经不需要再集成其他MVC框架了。本章我们详细介绍如何基于Spring MVC开发Web应用。
使用Spring MVC Servlet容器会为每个Web应用程序自动创建一个唯一的ServletContext
实例,这个实例就代表了Web应用程序本身。如果直接使用Spring MVC,我们写出来的代码类似于:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Controller public class UserController { @GetMapping("/register") public ModelAndView register () { ... } @PostMapping("/signin") public ModelAndView signin (@RequestParam("email") String email, @RequestParam("password") String password) { ... } ... }
但是,Spring提供的是一个IoC容器,所有的Bean,包括Controller,都在Spring IoC容器中被初始化。而Servlet容器由JavaEE服务器提供(如Tomcat),Servlet容器对IoC容器一无所知,它们之间依靠什么进行联系,又以何种顺序初始化?
在理解上述问题前,我们先把基于Spring MVC开发的项目结构搭起来,这个标准的Maven Web项目结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 spring-web-mvc ├── pom.xml └── src └── main ├── java │ └── com │ └── itranswarp │ └── learnjava │ ├── AppConfig.java │ ├── DatabaseInitializer.java │ ├── entity │ │ └── User.java │ ├── service │ │ └── UserService.java │ └── web │ └── UserController.java ├── resources │ ├── jdbc.properties │ └── logback.xml └── webapp ├── WEB-INF │ ├── templates │ │ ├── _base.html │ │ ├── index.html │ │ ├── profile.html │ │ ├── register.html │ │ └── signin.html │ └── web.xml └── static ├── css │ └── bootstrap.css └── js └── jquery.js
其中,src/main/webapp
是标准Web目录,WEB-INF
存放web.xml
、编译的class、第三方jar,以及不允许浏览器直接访问的View模板和static目录(存放所有的静态文件)。
在src/main/resources
目录存放的是Java程序读取的classpath资源文件,除了JDBC的配置文件jdbc.properties
外,我们新增了一个logback.xml
,这是Logback默认查找的配置文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?xml version="1.0" encoding="UTF-8"?> <configuration > <appender name ="STDOUT" class ="ch.qos.logback.core.ConsoleAppender" > <layout class ="ch.qos.logback.classic.PatternLayout" > <Pattern > %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</Pattern > </layout > </appender > <logger name ="com.itranswarp.learnjava" level ="info" additivity ="false" > <appender-ref ref ="STDOUT" /> </logger > <root level ="info" > <appender-ref ref ="STDOUT" /> </root > </configuration >
上面给出了一个写入到标准输出的Logback配置,可以基于上述配置添加写入到文件的配置。
目录src/main/java
就是我们编写Java代码的地方了。
配置Spring MVC 和普通Spring配置一样,编写正常的AppConfig
后,只需加上@EnableWebMvc
注解,就激活了Spring MVC。
1 2 3 4 5 6 7 8 @Configuration @ComponentScan @EnableWebMvc @EnableTransactionManagement @PropertySource("classpath:/jdbc.properties") public class AppConfig { ... }
除了创建DataSource、JdbcTemplate、PlatformTransactionManager外,AppConfig需要额外创建几个用于Spring MVC的Bean:
1 2 3 4 5 6 7 8 9 @Bean WebMvcConfigurer createWebMvcConfigurer () { return new WebMvcConfigurer() { @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**" ).addResourceLocations("/static/" ); } }; }
WebMvcConfigurer
并不是必须的,但我们在这里创建一个默认的WebMvcConfigurer
,只覆写addResourceHandlers()
,目的是让Spring MVC自动处理静态文件,并且映射路径为/static/**
。
另一个必须创建的Bean是ViewResolver
,因为Spring MVC允许集成任何模板引擎,使用哪一个模板引擎就实例化一个对应的ViewResolver
:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Bean ViewResolver createViewResolver (@Autowired ServletContext servletContext) { PebbleEngine engine = new PebbleEngine.Builder().autoEscaping(true ) .cacheActive(false ) .loader(new ServletLoader(servletContext)) .extension(new SpringExtension()) .build(); PebbleViewResolver viewResolver = new PebbleViewResolver(); viewResolver.setPrefix("/WEB-INF/templates/" ); viewResolver.setSuffix("" ); viewResolver.setPebbleEngine(engine); return viewResolver; }
ViewResolver
通过指定prefix和suffix来确定如何查找View。上述配置使用Pebble引擎,指定模板文件存放在/WEB-INF/templates/
目录下。
剩下的Bean都是普通的@Component
,但Controller必须被标记为@Controller
。
如果是普通的Java应用程序,我们通过main()
方法可以很简单的创建一个Spring容器实例:
1 2 3 public static void main (String[] args) { ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); }
但现在是Web应用程序,而Web应用程序总是由Servlet容器创建,那么Springo容器应该由谁创建呢,在什么时候创建,Spring容器中的Controller又是如何通过Servlet调用的?
在Web应用中启动Spring容器有很多种方法,可以通过Listener启动,也可以通过Servlet启动,可以使用XML配置,也可以使用注解配置,这里我们介绍一种最简单的启动Spring容器的方式。
第一步,我们在web.xml
中配置Spring MVC提供的DispatcherServlet
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" "http://java.sun.com/dtd/web-app_2_3.dtd" > <web-app > <servlet > <servlet-name > dispatcher</servlet-name > <servlet-class > org.springframework.web.servlet.DispatcherServlet</servlet-class > <init-param > <param-name > contextClass</param-name > <param-value > org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value > </init-param > <init-param > <param-name > contextConfigLocation</param-name > <param-value > com.itranswarp.learnjava.AppConfig</param-value > </init-param > <load-on-startup > 0</load-on-startup > </servlet > <servlet-mapping > <servlet-name > dispatcher</servlet-name > <url-pattern > /*</url-pattern > </servlet-mapping > </web-app >
初始化参数contextClass
指定使用注解配置的AnnotationConfigWebApplicationContext
,配置文件的位置参数contextConfigLocation
指向AppConfig
的完整类名,最后把这个Servlet映射到/*
,即处理所有URL。
有了这个配置,Servlet容器会首先初始化Spring MVC的DispatcherServlet
,在DispatcherServlet
启动时,它根据配置AppConfig
创建了一个类型是WebApplicationContext的IoC容器,完成所有Bean的初始化,并将容器绑到ServletContext
上。
因为DispatcherServlet
持有IoC容器,能从IoC容器中获取所有@Controller
的Bean,因此,DispatcherServlet接收到所有HTTP请求后,根据Controller方法配置的路径,就可以正确地把请求转发到指定方法,并根据返回的ModelAndView
决定如何渲染页面。
最后,我们在AppConfig中通过main()
启动嵌入式Tomcat:
1 2 3 4 5 6 7 8 9 10 11 12 public static void main (String[] args) throws Exception { Tomcat tomcat = new Tomcat(); tomcat.setPort(Integer.getInteger("port" , 8080 )); tomcat.getConnector(); Context ctx = tomcat.addWebapp("" , new File("src/main/webapp" ).getAbsolutePath()); WebResourceRoot resources = new StandardRoot(ctx); resources.addPreResources( new DirResourceSet(resources, "/WEB-INF/classes" , new File("target/classes" ).getAbsolutePath(), "/" )); ctx.setResources(resources); tomcat.start(); tomcat.getServer().await(); }
上述Web应用程序就是我们使用Spring MVC时的一个最小启动功能集。
编写Controller 有了Web应用程序的基本结构,我们的重点就可以放在如何编写Controller上了。Spring MVC对Controller没有固定要求,也不需要实现特定接口。以UserController为例,编写Controller只需要遵循以下要点。
总是标记@Controller
,而不是@Component
:
1 2 3 4 @Controller public class UserController { ... }
一个方法对应一个HTTP请求路径,用@GetMapping
或@PostMapping
表示GET或POST请求:
1 2 3 4 5 6 7 @PostMapping("/signin") public ModelAndView doSignin ( @RequestParam("email") String email, @RequestParam("password") String password, HttpSession session) { ... }
需要接收HTTP参数以@RequestParam()
标注,可以设置默认值。如果方法参数需要传入HttpServletRequest
、HttpServletResponse
或者HttpSession
,直接添加这个类型的参数即可,Spring MVC会自动按类型传入。
返回的ModelAndView通常包含View的路径和一个Map作为Model,但也可以没有Model,例如:
1 return new ModelAndView("signin.html" );
返回重定向时既可以写new ModelAndView("redirect:/signin")
,也可以直接返回String:
1 2 3 4 5 6 7 public String index () { if (...) { return "redirect:/signin" ; } else { return "redirect:/profile" ; } }
如果在方法内部直接操作HttpServletResponse发送响应,返回null表示无需进一步处理:
1 2 3 4 5 6 7 8 public ModelAndView download (HttpServletResponse response) { byte [] data = ... response.setContentType("application/octet-stream" ); OutputStream output = response.getOutputStream(); output.write(data); output.flush(); return null ; }
对URL进行分组,每组对应一个Controller是很好的组织形式,并可以在Controller的class定义出添加URL前缀,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Controller @RequestMapping("/user") public class UserController { @GetMapping("/profile") public ModelAndView profile () { ... } @GetMapping("/changePassword") public ModelAndView changePassword () { ... } }
实际方法的URL映射总是前缀+路径,这种形式还可以有效避免不小心导致的重复URL映射。
可见,Spring MVC帮助我们编写既简单又灵活的Controller实现。
小结 使用Spring MVC时,整个Web应用程序按如下顺序启动:
启动Tomcat服务器
Tomcat读取web.xml并初始化DispatcherServlet
DispatcherServlet创建IoC容器并自动注册到ServletContext中
启动后,浏览器发送的HTTP请求全部由DispathcerServlet接收,并根据配置转发到指定的Controller的指定方法处理。
使用REST 使用Spring MVC开发Web应用程序的主要工作就是编写Controller。在Web应用中,除了需要使用MVC给用户显示页面外,还有一类API接口,我们称之为REST,通常输入输出都是JSON,便于第三方调用或者使用页面JavaScript与之交互。
直接在Controller中处理JSON是可以的,因为Spring MVC的@GetMapping
和@PostMapping
都支持指定输入和输出的格式。如果我们想接收JSON,输出JSON,可以这样写:
1 2 3 4 5 6 7 @PostMapping(value = "/rest", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") @ResponseBody public String rest (@RequestBody User user) { return "{\"restSupport\":true}" ; }
对应的Maven功程需要引入Jackson这个依赖。注意到@PostMapping
使用consumes
声明能接收的类型,使用produces
声明输出的类型,并且额外加了@ResponseBody
表示返回的String
无需额外处理,直接作为输出内容写入HttpServletResponse
。输入的JSON则根据注解@RequestBody
直接被Spring反序列化为User
这个Bean。
直接用Spring的Controller配合一大堆注解写REST太麻烦了,因此,Spring还额外提供了一个@RestController
注解。使用@RestController
替代@Controller
后,每个方法自动变成API接口方法,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @RestController @RequestMapping("/api") public class ApiController { @Autowired UserService userService; @GetMapping("/users") public List<User> users () { return userService.getUsers(); } @GetMapping("/users/{id}") public User user (@PathVariable("id") long id) { return userService.getUserById(id); } @PostMapping("/signin") public Map<String, Object> signin (@RequestBody SignInRequest signinRequest) { try { User user = userService.signin(signinRequest.email, signinRequest.password); return Map.of("user" , user); } catch (Exception e) { return Map.of("error" , "SIGNIN_FAILED" , "message" , e.getMessage()); } } public static class SignInRequest { public String email; public String password; } }
编写REST接口只需要定义@RestController
,然后,每个方法都是一个API接口,输入和输出只要能被Jackson序列化或反序列化为JSON。我们用浏览器测试GET请求,可直接显示JSON响应。
使用curl命令可以测试到JSON的输出,User能被正确序列化为JSON,但暴露了password属性,这是我们不愿意的。要避免输出password属性,可以把User复制到另一个UserBean对象,该对象只持有必要的属性,但这样做比较繁琐。另一种简单的方法是直接在User的password属性定义处加上@JsonIgnore
表示完全忽略该属性。
1 2 3 4 5 6 7 8 9 10 public class User { ... @JsonIgnore public String getPassword () { return password; } ... }
但是这样一来,如果写一个register(User user)
方法,那么该方法的User对象也拿不到注册时用户传入的密码了。如果要允许输入password,但不允许输出password,即在JSON序列化和反序列化时,允许写属性,禁用读属性,可以更精细地控制如下:
1 2 3 4 5 6 7 8 9 10 public class User { ... @JsonProperty(access = Access.WRITE_ONLY) public String getPassword () { return password; } ... }
同样地,可以使用@JsonProperty(access = Access.READ_ONLY)
允许输出,不允许输入。
集成Filter 在Servlet规范中,我们还可以使用Filter,如果要在Spring MVC中使用Filter,应该怎么做?
有同学可能发现了,Servlet默认按非UTF-8编码读取参数,为了修复这一问题,我们可以用一个EndoingFilter,在全局范围类给HttpServletRequest
和HttpServletResponse
强制设置为UTF-8。可以自己编写一个EncodingFilter,也可以直接使用Spring MVC自带的一个CharacterEncodingFilter
。配置Filter时,只需在web.xml
中声明即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <web-app > <filter > <filter-name > encodingFilter</filter-name > <filter-class > org.springframework.web.filter.CharacterEncodingFilter</filter-class > <init-param > <param-name > encoding</param-name > <param-value > UTF-8</param-value > </init-param > <init-param > <param-name > forceEncoding</param-name > <param-value > true</param-value > </init-param > </filter > <filter-mapping > <filter-name > encodingFilter</filter-name > <url-pattern > /*</url-pattern > </filter-mapping > ...</web-app >
因为这种Filter和我们业务关系不大,注意到CharacterEncodingFilter其实和Spring的IoC容器没有任何关系,两者均不知道互相的存在,所以配置Filter非常简单。
我们再考虑这样一个问题:如果允许用户使用Basic模式进行用户验证,即在HTTP请求中添加头Authorization: Basic email:password
,这个需求如何实现?
编写一个AuthFilter是最简单的实现方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Component public class AuthFilter implements Filter { @Autowired UserService userService; public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String authHeader = req.getHeader("Authorization" ); if (authHeader != null && authHeader.startsWith("Basic " )) { String email = prefixFrom(authHeader); String password = suffixFrom(authHeader); User user = userService.signin(email, password); req.getSession().setAttribute(UserController.KEY_USER, user); } chain.doFilter(request, response); } }
在Spring中创建的这个AuthFilter
是一个普通Bean,Servlet容器并不知道,所以它不起作用。如果我们直接在web.xml
中声明这个AuthFilter
,但AuthFilter
的实例是有Servlet容器而不是Spring容器(也就是IoC容器)初始化,因此@AutoWired
不生效,用于登录的UserService
成员变量将永远是null
。
所以,得通过一种方式,让Servlet容器实例化的Filter,间接引用Spring容器实例化的AuthFilter
。Spring MVC提供了一个DelegatingFilterProxy
来做这件事:
1 2 3 4 5 6 7 8 9 10 11 12 <web-app > <filter > <filter-name > authFilter</filter-name > <filter-class > org.springframework.web.filter.DelegatingFilterProxy</filter-class > </filter > <filter-mapping > <filter-name > authFilter</filter-name > <url-pattern > /*</url-pattern > </filter-mapping > ...</web-app >
我们来看实现原理:
Servlet容器从web.xml
读取配置,实例化DelegatingFilterProxy
,注意命名是authFilter
Spring容器扫描@Component
实例化AuthFilter
当DelegatingFilterProxy
生效后,它会自动查找注册在ServletContext上的Spring容器,再试图从容器中查找名为authFilter
的Bean,也就是我们用@Component
声明的AuthFilter
。
DelegatingFilterProxy
将请求代理给AuthFilter
,核心代码如下:
1 2 3 4 5 6 7 8 9 public class DelegatingFilterProxy implements Filter { private Filter delegate; public void doFilter (...) throws ... { if (delegate == null ) { delegate = findBeanFromSpringContainer(); } delegate.doFilter(req, resp, chain); } }
这是一个代理模式的简单应用,我们用图表表示它们之间的引用关系如下:
1 2 3 4 5 6 7 8 9 10 ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌─────────────────────┐ ┌───────────┐ │ │ │DelegatingFilterProxy│─│─│─ ─>│AuthFilter │ └─────────────────────┘ └───────────┘ │ │ ┌─────────────────────┐ │ │ ┌───────────┐ │ DispatcherServlet │─ ─ ─ ─>│Controllers│ │ │ └─────────────────────┘ │ │ └───────────┘ │ │ Servlet Container │ │ Spring Container ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
如果在web.xml中配置的Filter名称和Spring容器的Bean的名字不一致,那么需要指定Bean的名字:
1 2 3 4 5 6 7 8 9 <filter > <filter-name > basicAuthFilter</filter-name > <filter-class > org.springframework.web.filter.DelegatingFilterProxy</filter-class > <init-param > <param-name > targetBeanName</param-name > <param-value > authFilter</param-value > </init-param > </filter >
实际应用时,尽量保持名字一致,以减少不必要的配置。
注意:Basic认证模式并不安全,本节只用来作为使用Filter的示例。
使用Interceptor 在Web应用程序中,注意到使用Filter时,Filter由Servlet容器管理,它在Spring MVC的Web应用程序中作用范围如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 │ ▲ ▼ │ ┌───────┐ │Filter1│ └───────┘ │ ▲ ▼ │ ┌───────┐ ┌ ─ ─ ─│Filter2│─ ─ ─ ─ ─ ─ ─ ─ ┐ └───────┘ │ │ ▲ │ ▼ │ │ ┌─────────────────┐ │ │DispatcherServlet│<───┐ │ └─────────────────┘ │ │ │ ┌────────────┐ │ │ │ModelAndView││ │ └────────────┘ │ │ ▲ │ │ ┌───────────┐ │ │ ├───>│Controller1│────┤ │ │ └───────────┘ │ │ │ │ │ │ ┌───────────┐ │ │ └───>│Controller2│────┘ │ └───────────┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
上图虚线框就是Filter2的拦截范围,Filter实际上不知道后续处理是通过Spring MVC提供的DispathcerServlet
还是其他Servlet组件,因为Filter是Servlet规范定义的标准组件,它可以应用在任何基于Servlet的程序中。
如果只基于Spring MVC开发应用程序,还可以使用Spring MVC提供的一种功能类似Filter的拦截器:Interceptor。和Filter相比,Interceptor拦截范围不是后续整个处理流程,而是仅针对Controller的拦截。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 │ ▲ ▼ │ ┌───────┐ │Filter1│ └───────┘ │ ▲ ▼ │ ┌───────┐ │Filter2│ └───────┘ │ ▲ ▼ │ ┌─────────────────┐ │DispatcherServlet│<───┐ └─────────────────┘ │ │ ┌────────────┐ │ │ModelAndView│ │ └────────────┘ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ▲ │ ┌───────────┐ │ ├─┼─>│Controller1│──┼─┤ │ └───────────┘ │ │ │ │ │ │ ┌───────────┐ │ └─┼─>│Controller2│──┼─┘ └───────────┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ┘
上图虚线框就是Interceptor的拦截范围。注意到Controller的处理方法一般都类似这样:
1 2 3 4 5 6 7 @Controller public class Controller1 { @GetMapping("/path/to/hello") ModelAndView hello () { ... } }
所以,Interceptor的拦截范围其实就是Controller方法,实际上相当于基于AOP的方法拦截。因为Interceptor只拦截Controller方法,所以要注意,返回ModelAndView后,后续对View的渲染就脱离了Interceptor的拦截范围。
使用Interceptor的好处是Interceptor本身是Spring管理的Bean,因此注入任意的Bean都非常简单,此外可以应用多个Interceptor,并通过简单的@Order
指定顺序。
我们先写一个LoggerInterceptor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Order(1) @Component public class LoggerInterceptor implements HandlerInterceptor { final Logger logger = LoggerFactory.getLogger(getClass()); @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { logger.info("preHandle {}..." , request.getRequestURI()); if (request.getParameter("debug" ) != null ) { PrintWriter pw = response.getWriter(); pw.write("<p>DEBUG MODE</p>" ); pw.flush(); return false ; } return true ; } @Override public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { logger.info("postHandle {}." , request.getRequestURI()); if (modelAndView != null ) { modelAndView.addObject("__time__" , LocalDateTime.now()); } } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { logger.info("afterCompletion {}: exception = {}" , request.getRequestURI(), ex); } }
一个Interceptor必须实现HandlerInterceptor接口,可以选择实现preHandle()
、postHandle()
和afterCompletion()
方法。preHandle()
是Controller方法调用前执行,postHandle()
是Controller方法争藏返回后执行,而afterCompletion()
无论Controller方法是否抛异常都会执行,参数ex
就是Controller方法抛出的异常(未抛出异常是null
)。
在preHandle()
中也可以直接处理响应,然后返回false标识无需调用Controller方法继续处理了,通常在认证或者安全检查失败时直接返回错误响应。在postHandle()
中,因为捕获了Controller方法返回的ModelAndView
,所以可以继续在ModelAndView
中添加一些通用数据,很多页面需要的全局数据如CopyRight信息都可以放到这里,无需在每个Controller方法中重复添加。
我们添加一个AuthInterceptor
,用于替代上一节使用的AuthFilter
进行Basic认证的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Order(2) @Component public class AuthInterceptor implements HandlerInterceptor { final Logger logger = LoggerFactory.getLogger(getClass()); @Autowired UserService userService; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { logger.info("pre authenticate {}..." , request.getRequestURI()); try { authenticateByHeader(request); } catch (RuntimeException e) { logger.warn("login by authorization header failed." , e); } return true ; } private void authenticateByHeader (HttpServletRequest req) { String authHeader = req.getHeader("Authorization" ); if (authHeader != null && authHeader.startsWith("Basic " )) { logger.info("try authenticate by authorization header..." ); String up = new String(Base64.getDecoder().decode(authHeader.substring(6 )), StandardCharsets.UTF_8); int pos = up.indexOf(':' ); if (pos > 0 ) { String email = URLDecoder.decode(up.substring(0 , pos), StandardCharsets.UTF_8); String password = URLDecoder.decode(up.substring(pos + 1 ), StandardCharsets.UTF_8); User user = userService.signin(email, password); req.getSession().setAttribute(UserController.KEY_USER, user); logger.info("user {} login by authorization header ok." , email); } } } }
这个AuthInterceptor
是由Spring容器直接管理的,因此注入UserService
非常方便。
最后,要让拦截器生效,我们在WebMvcConfigurer
中注册所有的Interceptor:
1 2 3 4 5 6 7 8 9 10 11 @Bean WebMvcConfigurer createWebMvcConfigurer (@Autowired HandlerInterceptor[] interceptors) { return new WebMvcConfigurer() { public void addInterceptors (InterceptorRegistry registry) { for (var interceptor : interceptors) { registry.addInterceptor(interceptor); } } ... }; }
如果拦截器没有生效,请检查是否忘记了在WebMvcConfigurer中注册。
处理异常 在Controller中,Spring MVC还允许定义基于@ExceptionHandler
注解的异常处理方法,我们来看示例代码:
1 2 3 4 5 6 7 8 @Controller public class UserController { @ExceptionHandler(RuntimeException.class) public ModelAndView handleUnknowException (Exception ex) { return new ModelAndView("500.html" , Map.of("error" , ex.getClass().getSimpleName(), "message" , ex.getMessage())); } ... }
异常处理方法没有固定的方法签名,可以传入Exception
、HttpServletRequest
等,返回值可以是void
,也可以是ModelAndView
,上述代码通过@ExceptionHandler(RuntimeException.class)
表示当发生RuntimeException
的时候,就自动调用此方法处理。
注意到我们返回了一个新的ModelAndView
,这样在应用程序内部发生了预料之外的异常,可以给用户显示一个出错页面,而不是简单的500 Internal Server Error或404 Not Found。
可以编写多个错误处理方法,每个方法针对特定的异常,例如,处理LoginException
使得页面可以自动跳转到登录页。使用ExceptionHandler
时,要注意它仅作用于当前的Controller,即ControllerA中定义的ExceptionHander方法对ControllerB不起作用。那如果我们有很多Controller,每个Controller都需要处理一些通用异常,应该怎么避免重复代码?
处理CORS 在开发REST应用时,很多时候是通过页面的JavaScript和后端的REST API交互。在JavaScript与REST API交互的时候,有很多安全限制。默认情况下,浏览器按同源策略放行JavaScript调用API,即:
如果A站在域名a.com
页面的JavaScript调用A站自己的API时,没有问题
如果A站在域名a.com
页面的JavaScript调用B站b.com
的API时,将被浏览器拒绝访问,因为不满足同源策略
同源要求域名完全相同(a.com
和www.a.com
不同),协议要相同(http
和https
不同),端口要相同。
那么,在域名a.com
页面的JavaScript要调用b.com
的API时,还有没有办法?
有的,那就是CORS,全称叫Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。如果A站的JavaScript访问B站API时,B站能够返回响应头Access-Control-Allow-Origin: http://a.com
,那么浏览器就允许A站的JavaScript访问B站的API。注意到跨域访问能否成功,取决于B站是否愿意给A站返回一个正确的Access-Control-Allow-Origin
响应头,所以决定权永远在提供API的服务方手中。
使用Spring的@RestController
开发REST应用时,同样会面对跨域问题。如果我们允许指定的网站通过页面JavaScript访问这些REST API,就必须正确地设置CORS。我们一一介绍设置CORS的几种方法。
使用@CrossOrigin 在@RestController
的class级别或method级别定义一个@CrossOrigin
,例如:
1 2 3 4 5 6 @CrossOrigin(origins = "http://local.liaoxuefeng.com:8080") @RestController @RequestMapping("/api") public class ApiController { ... }
上述定义在ApiController
处的@CrossOrigin
指定了只允许来自local.liaoxuefeng.com
跨域访问,允许多个域名访问需要写成数组形式,例如origins = {"http://a.com", "https://www.b.com"}
。如果允许任何域名访问,写成origins = "*"
即可。
如果有多个REST Controller都需要使用CORS,那么每个Controller都必须标注@CrossOrigin
注解。
使用CorsRegistry 在WebMvcConfigurer
中定义一个全局CORS配置,下面是一个示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Bean WebMvcConfigurer createWebMvcConfigurer () { return new WebMvcConfigurer() { @Override public void addCorsMappings (CorsRegistry registry) { registry.addMapping("/api/**" ) .allowedOrigins("http://local.liaoxuefeng.com:8080" ) .allowedMethods("GET" , "POST" ) .maxAge(3600 ); } }; }
这种方式可以创建一个全局CORS配置,如果仔细地设计URL结构,那么可以一目了然地看到各个URL的CORS规则,推荐使用这种方式配置CORS。
使用CorsFilter 使用Spring提供的CorsFilter,我们在[集成Filter中详细介绍了将Spring容器内置的Bean暴露为Servlet容器的Filter的方法,由于这种配置方式需要修改web.xml
,也比较繁琐,所以推荐使用第二种方式。
国际化 在开发应用程序时,经常会遇到支持多语言的需求,这种支持多语言的功能称之为国际化,英文名是internationalization,缩写为i18n(因为首字母i和末字母n之间有18个字母)。还有针对特定地区的本地化功能,英文是localization,缩写为l10n,本地化是指根据地区调整类似姓名、日期的显示等。也有把上述两者合称为全球化,英文是globalization,缩写为g11n。
在Java中,支持多语言和本地化是通过MessageFormat
配合Locale
实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import java.text.MessageFormat;import java.util.Locale;public class Time { public static void main (String[] args) { double price = 123.5 ; int number = 10 ; Object[] arguments = { price, number }; MessageFormat mfUS = new MessageFormat("Pay {0,number,currency} for {1} books." , Locale.US); System.out.println(mfUS.format(arguments)); MessageFormat mfZH = new MessageFormat("{1}本书一共{0,number,currency}。" , Locale.CHINA); System.out.println(mfZH.format(arguments)); } }
对于Web应用程序,要实现国际化功能,主要是渲染View的时候,要把各种语言的资源文件提出来,这样,不同的用户访问同一个页面时,显示的语言就是不同的。
我们来看看在Spring MVC应用程序中如何实现国际货。
获取Locale 第一步获取用户的Locale。在Web应用程序中,HTTP规范规定了浏览器会在请求中携带Accept-Language
头,用来指示用户浏览器设定的语言顺序,如:
1 Accept-Language: zh-CN,zh;q=0.8,en;q=0.2
上述HTTP请求头表示优先选择简体中文,其次选择中文,最后选择英文。q
表示权重,解析后我们可获得一个根据优先级排序的语言列表,把它转换为Java的Locale
,即获得了用户的Locale
。大多数框架通常只返回权重最高的Locale
。
Spring MVC通过LocaleResolver
来自动从HttpServletRequest
中获取Locale
。有多种LocaleResolver
的实现类,其中最常用的是CookieLocaleResolver
:
1 2 3 4 5 6 7 @Bean LocaleResolver createLocaleResolver () { var clr = new CookieLocaleResolver(); clr.setDefaultLocale(Locale.ENGLISH); clr.setDefaultTimeZone(TimeZone.getDefault()); return clr; }
CookieLocaleResolver
从HttpServletRequest
中获取Locale
时,首先根据一个特定的Cookie判断是否指定了Locale
,如果没有,就从HTTP头获取,如果还没有,就返回默认的Locale
。
当用户第一次访问网站时,CookieLocaleResolver
只能从HTTP头获取Locale
,即使用浏览器的默认语言。通常网站也允许用户自己选择语言,此时,CookieLocaleResolver
就会把用户选择的语言存放到Cookie中,下一次访问时,就会返回用户上次选择的语言而不是浏览器默认语言。
提取资源文件 第二步是把写死在模板中的字符串以资源文件的形式存储在外部。
对于多语言,主文件名如果命名为messages
,那么资源文件必须按如下方式命名并放入classpath中:
默认语言,文件名必须为messages.properties
简体中文,Locale是zh_CN
,文件名必须为messages_zh_CN.properties
日文,Locale是ja_JP
,文件名必须为messages_ja_JP.properties
其它更多语言……
每个资源文件都有相同的key,例如,默认语言是英文,文件messages.properties
内容如下:
1 2 3 4 language.select =Language home =Home signin =Sign In copyright =Copyright©{0,number,#}
文件messages_zh_CN.properties
内容如下:
1 2 3 4 language.select =语言 home =首页 signin =登录 copyright =版权所有©{0,number,#}
创建MessageSource 第三步是创建一个Spring提供的MessageSource
实例,它自动读取所有的.properties
文件,并提供一个统一接口来实现“翻译”:
1 2 String text = messageSource.getMessage("signin" , null , locale);
其中,signin
是我们在.properties
文件中定义的key,第二个参数是Object[]
数组作为格式化时传入的参数,最后一个参数就是获取的用户Locale
实例。
创建MessageSource
如下:
1 2 3 4 5 6 7 8 9 @Bean("i18n") MessageSource createMessageSource () { var messageSource = new ResourceBundleMessageSource(); messageSource.setDefaultEncoding("UTF-8" ); messageSource.setBasename("messages" ); return messageSource; }
注意到ResourceBundleMessageSource
会自动根据主文件名自动把所有相关语言的资源文件都读进来。
再注意到Spring容器会创建不只一个MessageSource
实例,我们自己创建的这个MessageSource
是专门给页面国际化使用的,因此命名为i18n
,不会与其它MessageSource
实例冲突。
实现多语言 要在View中实现MessageSource加上Locale输出多语言,我们编写一个Interceptor,把相关资源注入到ModelAndView中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Component public class MvcInterceptor implements HandlerInterceptor { @Autowired LocaleResolver localeResolver; @Autowired @Qualifier("i18n") MessageSource messageSource; public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (modelAndView != null ) { Locale locale = localeResolver.resolveLocale(request); modelAndView.addObject("__messageSource__" , messageSource); modelAndView.addObject("__locale__" , locale); } } }
不要忘了在WebMvcConfigurer
中注册MvcInterceptor
。现在,就可以在View中调用MessageSource.getMessage()
方法来实现多语言:
1 <a href ="/signin" > {{ __messageSource__.getMessage('signin', null, __locale__) }}</a >
上述这种写法虽然可行,但格式太复杂了。使用View时,要根据每个特定的View引擎定制国际化函数。在Pebble中,我们可以封装一个国际化函数,名称就是下划线_
,改造一下创建ViewResolver
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 @Bean ViewResolver createViewResolver (@Autowired ServletContext servletContext, @Autowired @Qualifier("i18n") MessageSource messageSource) { PebbleEngine engine = new PebbleEngine.Builder() .autoEscaping(true ) .cacheActive(false ) .loader(new ServletLoader(servletContext)) .extension(createExtension(messageSource)) .build(); PebbleViewResolver viewResolver = new PebbleViewResolver(); viewResolver.setPrefix("/WEB-INF/templates/" ); viewResolver.setSuffix("" ); viewResolver.setPebbleEngine(engine); return viewResolver; }private Extension createExtension (MessageSource messageSource) { return new AbstractExtension() { @Override public Map<String, Function> getFunctions () { return Map.of("_" , new Function() { public Object execute (Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) { String key = (String) args.get("0" ); List<Object> arguments = this .extractArguments(args); Locale locale = (Locale) context.getVariable("__locale__" ); return messageSource.getMessage(key, arguments.toArray(), "???" + key + "???" , locale); } private List<Object> extractArguments (Map<String, Object> args) { int i = 1 ; List<Object> arguments = new ArrayList<>(); while (args.containsKey(String.valueOf(i))) { Object param = args.get(String.valueOf(i)); arguments.add(param); i++; } return arguments; } public List<String> getArgumentNames () { return null ; } }); } }; }
这样,我们可以把多语言页面改写为:
1 <a href ="/signin" > {{ _('signin') }}</a >
如果是带参数的多语言,需要把参数传进去:
1 <h5 > {{ _('copyright', 2020) }}</h5 >
使用其它View引擎时,也应当根据引擎接口实现更方便的语法。
切换Locale 最后,我们需要允许用户手动切换Locale
,编写一个LocaleController
来实现该功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Controller public class LocaleController { final Logger logger = LoggerFactory.getLogger(getClass()); @Autowired LocaleResolver localeResolver; @GetMapping("/locale/{lo}") public String setLocale (@PathVariable("lo") String lo, HttpServletRequest request, HttpServletResponse response) { Locale locale = null ; int pos = lo.indexOf('_' ); if (pos > 0 ) { String lang = lo.substring(0 , pos); String country = lo.substring(pos + 1 ); locale = new Locale(lang, country); } else { locale = new Locale(lo); } localeResolver.setLocale(request, response, locale); logger.info("locale is set to {}." , locale); String referer = request.getHeader("Referer" ); return "redirect:" + (referer == null ? "/" : referer); } }
在页面设计中,通常在右上角给用户提供一个语言选择列表。
小结 多语言支持需要从HTTP请求中解析用户的Locale,然后针对不同Locale显示不同的语言。Spring MVC应用程序通过MessageSource
和LocaleResolver
,配合View实现国际化。
异步处理 在Servlet模型中,每个请求都是由某个线程处理,然后,将响应写入IO流,发送给客户端。从开始处理请求,到写入响应完成,都是在同一个线程中处理的。
实现Servlet容器时,只要每处理一个请求,就创建一个新线程处理它,就能保证正确实现了Servlet线程模型。在实际产品中,例如Tomcat,总是通过线程池来处理请求,仍然符合一个请求从头到尾都由某一个线程处理。
这种线程模型非常重要,因为Spring的JDBC事务是基于ThreadLocal
实现的,如果在处理过程中,一会由线程A处理,一会由线程B处理,那事务就乱套了。此外,很多安全认证也是基于ThreadLocal
实现的,可以保证在处理请求的过程中,各个线程互不影响。
但是,如果一个请求处理的时间比较长,例如有几秒钟甚至更长,这种基于线程池的同步模型很快就把所有的线程耗尽,导致服务器无法响应新的请求。如果把长时间处理的请求改为异步处理,那么线程池的利用率会大大提高。Servlet从3.0规范开始添加了异步支持,允许对一个请求进行异步处理。
我们先来看看在Spring MVC中实现对请求进行异步处理的逻辑。首先建立一个Web工程,然后编辑web.xml
文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <web-app xmlns ="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version ="3.1" > <display-name > Archetype Created Web Application</display-name > <servlet > <servlet-name > dispatcher</servlet-name > <servlet-class > org.springframework.web.servlet.DispatcherServlet</servlet-class > <init-param > <param-name > contextClass</param-name > <param-value > org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value > </init-param > <init-param > <param-name > contextConfigLocation</param-name > <param-value > com.itranswarp.learnjava.AppConfig</param-value > </init-param > <load-on-startup > 0</load-on-startup > <async-supported > true</async-supported > </servlet > <servlet-mapping > <servlet-name > dispatcher</servlet-name > <url-pattern > /*</url-pattern > </servlet-mapping > </web-app >
和前面普通的MVC程序相比,这个web.xml
主要有几点不同:
不能再使用<!DOCTYPE ...web-app_2_3.dtd">
的DTD声明,必须用新的支持Servlet 3.1规范的XSD声明,照抄即可
对DispatcherServlet
的配置多了一个<async-supported>
,默认值是false
,必须明确写成true
,这样Servlet容器才会支持async处理
然后在Controller中编写async处理逻辑。我们以ApiController为例,演示如何异步处理请求。
第一种async处理方式是返回一个Callable,Spring MVC自动把返回的Callable放入线程池执行,等待结果返回后再写入响应:
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/users") public Callable<List<User>> users() { return () -> { try { Thread.sleep(3000 ); } catch (InterruptedException e) { } return userService.getUsers(); }; }
第二种async处理方式是返回一个DeferredResult对象,然后在另一个线程中设置此对象的值并写入响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @GetMapping("/users/{id}") public DeferredResult<User> user (@PathVariable("id") long id) { DeferredResult<User> result = new DeferredResult<>(3000L ); new Thread(() -> { try { Thread.sleep(1000 ); } catch (InterruptedException e) { } try { User user = userService.getUserById(id); result.setResult(user); } catch (Exception e) { result.setErrorResult(Map.of("error" , e.getClass().getSimpleName(), "message" , e.getMessage())); } }).start(); return result; }
使用DeferredResult
时,可以设置超时,超时会自动返回超时错误响应。在另一个线程中,可以调用setResult()
写入结果,也可以调用setErrorResult()
写入一个错误结果。
使用Filter 当我们使用async模式处理请求时,原有的Filter也可以工作,但必须在web.xml
中添加<async-supported>
并设置为true
。我们用两个Filter:SyncFilter和AsyncFilter分别测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <web-app ... > ... <filter > <filter-name > sync-filter</filter-name > <filter-class > com.itranswarp.learnjava.web.SyncFilter</filter-class > </filter > <filter > <filter-name > async-filter</filter-name > <filter-class > com.itranswarp.learnjava.web.AsyncFilter</filter-class > <async-supported > true</async-supported > </filter > <filter-mapping > <filter-name > sync-filter</filter-name > <url-pattern > /api/version</url-pattern > </filter-mapping > <filter-mapping > <filter-name > async-filter</filter-name > <url-pattern > /api/*</url-pattern > </filter-mapping > ...</web-app >
一个声明为支持<async-supported>
的Filter既可以过滤async请求,也可以过滤正常的同步请求,而未声明<async-supported>
的Filter无法支持async请求。如果一个普通的Filter遇到async请求时,会直接报错,因此,务必注意普通Filter的<url-pattern>
不要匹配async请求路径。
在logback.xml
配置文件中,我们输出格式加上[%thread]
,可以输出当前线程的名称:
1 2 3 4 5 6 7 8 9 <?xml version="1.0" encoding="UTF-8"?> <configuration > <appender name ="STDOUT" class ="ch.qos.logback.core.ConsoleAppender" > <layout class ="ch.qos.logback.classic.PatternLayout" > <Pattern > %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</Pattern > </layout > </appender > ...</configuration >
对于同步请求,每个Filter和ApiController都是由同一个线程执行。对于异步请求,AsyncFilter和ApiController是由同一个线程执行的,但是,返回响应的是另一个线程。
实际中,经常使用到的就是DeferredResult
,因为返回DeferredResult
时,可以设置超时、正常结果和错误结果,易于编写比较灵活的逻辑。使用async异步处理响应时,要时刻牢记,在另一个异步线程中的事务和Controller方法中执行的事务不是同一个事务,在Controller中绑定的ThreadLocal
信息也无法在异步线程中获取。
此外,Servlet 3.0规范添加的异步支持是针对同步模型打了一个“补丁”,虽然可以异步处理请求,但高并发异步请求时,它的处理效率并不高,因为这种异步模型并没有用到真正的“原生”异步。Java标准库提供了封装操作系统的异步IO包java.nio
,是真正的多路复用IO模型,可以用少量线程支持大量并发。使用NIO编程复杂度比同步IO高很多,因此我们很少直接使用NIO。相反,大部分需要高性能异步IO的应用程序会选择Netty 这样的框架,它基于NIO提供了更易于使用的API,方便开发异步应用程序。
使用WebSocket WebSocket是一种基于HTTP的长链接技术。传统的HTTP协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的定时轮询,效率低且实时性不高。
因为HTTP本身是基于TCP连接的,所以,WebSocket在HTTP协议的基础上做了一个简单的升级,即建立TCP连接后,浏览器发送请求时,附带几个头:
1 2 3 4 GET /chat HTTP/1.1 Host: www.example.com Upgrade: websocket Connection: Upgrade
就表示客户端希望升级连接,变成长连接的WebSocket,服务器返回升级成功的响应:
1 2 3 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade
收到成功响应时表示WebSocket“握手”成功,这样,代表WebSocket的这个TCP连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可以随时向服务器推送消息。双方推送消息既可以是文本消息,也可以是二进制消息,一般来说,绝大部分应用程序都会推送基于JSON的文本消息。
现代浏览器都已经支持WebSocket协议,服务器则需要底层框架支持。Java的Servelt规范从3.1开始支持WebSocket,所以,必须选择支持Servlet3.1或更高规范的Servlet容器,才能支持WebSocket。最新版本的Tomcat、Jetty等开源服务器均支持WebSocket。
我们以实际代码演示如何在Spring MVC中实现对WebSocket的支持。首先,我们需要在pom.xml
中加入以下依赖:
org.apache.tomcat.embed:tomcat-embed-websocket:9.0.26
org.springframework:spring-websocket:5.2.0.RELEASE
第一项是嵌入式Tomcat支持WebSocket的组件,第二项是Spring封装的支持WebSocket的接口。
接下来,我们要在AppConfig中加入Spring Web对WebSocket的配置,此处我们要创建一个WebSocketConfigurer
实例。
1 2 3 4 5 6 7 8 9 10 11 12 @Bean WebSocketConfigurer createWebSocketConfigurer ( @Autowired ChatHandler chatHandler, @Autowired ChatHandshakeInterceptor chatInterceptor) { return new WebSocketConfigurer() { public void registerWebSocketHandlers (WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, "/chat" ).addInterceptors(chatInterceptor); } }; }
此实例在内部通过WebSocketHandlerRegistry
注册能处理WebSocket的WebSocketHandler
,以及可选的WebSocket拦截器HandshakeInterceptor
。我们注入的这两个类都是自己编写的业务逻辑,后面我们详细讨论如何编写它们,这里只需关注浏览器连接到WebSocket的URL是/chat
。
处理WebSocket连接 和处理普通HTTP请求不同,没法用一个方法处理一个URL。Spring提供了TextWebSocketHandler
和BinaryWebSocketHandler
分别处理文本消息和二进制消息,这里我们选择文本消息作为聊天室的协议,因此,ChatHandler
需要继承自TextWebSocketHandler
:
1 2 3 4 @Component public class ChatHandler extends TextWebSocketHandler { ... }
当浏览器请求一个WebSocket连接后,如果成功建立连接,Spring会自动调用afterConnectionEstablished()
方法,任何原因导致WebSocket中断,Spring会自动调用afterConnectionClosed()
方法,因此,覆写这两个方法即可处理连接成功和结束后的业务逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Component public class ChatHandler extends TextWebSocketHandler { private Map<String, WebSocketSession> clients = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished (WebSocketSession session) throws Exception { clients.put(session.getId(), session); session.getAttributes().put("name" , "Guest1" ); } @Override public void afterConnectionClosed (WebSocketSession session, CloseStatus status) throws Exception { clients.remove(session.getId()); } }
每个WebSocket会话以WebSocketSession
表示,且已分配唯一ID。和WebSocket相关的数据,例如用户名称等,均可放入关联的getAttributes()
中。
用实例变量clients
持有当前所有的WebSocketSession
是为了广播,即向所有用户推送同一消息时,可以这么写:
1 2 3 4 5 6 String json = ... TextMessage message = new TextMessage(json);for (String id : clients.keySet()) { WebSocketSession session = clients.get(id); session.sendMessage(message); }
我们发送的消息是序列化后的JSON,可以用ChatMessage表示:
1 2 3 4 5 public class ChatMessage { public long timestamp; public String name; public String text; }
每收到一个用户的消息后,我们就需要广播给所有用户:
1 2 3 4 5 6 7 8 9 10 @Component public class ChatHandler extends TextWebSocketHandler { ... @Override protected void handleTextMessage (WebSocketSession session, TextMessage message) throws Exception { String s = message.getPayload(); String r = ... broadcastMessage(r); } }
如果要推送给指定的几个用户,那就需要在clients
中根据条件查找出某些WebSocketSession
,然后发送消息。
注意到我们在注册WebSocket时还传入了一个ChatHandshakeInterceptor
,这个类实际上可以从HttpSessionHandshakeInterceptor
继承,它的主要作用是在WebSocket建立连接后,把HttpSession的一些属性复制到WebSocketSession,例如,用户的登录信息等:
1 2 3 4 5 6 7 @Component public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor { public ChatHandshakeInterceptor () { super (List.of(UserController.KEY_USER)); } }
这样,在ChatHandler
中,可以从WebSocketSession.getAttributes()
中获取到复制过来的属性。
客户端开发 在完成了服务器端的开发后,我们还需要在页面编写一点JavaScript逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 var ws = new WebSocket('ws://' + location.host + '/chat' ); ws.addEventListener('open' , function (event ) { console .log('websocket connected.' ); }); ws.addEventListener('message' , function (event ) { console .log('message: ' + event.data); var msgs = JSON .parse(event.data); }); ws.addEventListener('close' , function ( ) { console .log('websocket closed.' ); });window .chatWs = ws;
用户可以在连接成功后任何时候给服务器发送消息:
1 2 var inputText = 'Hello, WebSocket.' ;window .chatWs.send(JSON .stringify({text : inputText}));
最后,连调浏览器和服务器端,如果一切无误,可以开多个不同的浏览器测试WebSocket的推送和广播。
和上一节我们介绍的异步处理类似,Servlet的线程模型并不适合大规模的长链接。基于NIO的Netty等框架更适合处理WebSocket长链接,我们将在后面介绍。
通过spring-websocket
可以简化WebSocket的开发。