Spring MVC
Spring网络有两个技术栈,最初的技术栈是Servlet,从Spring项目伊始就在spring-webmvc包里了。从Spring5.0开始又加入了Spring WebFlux,是基于reactive-stack技术栈的。
DispatcherServlet
网络框架,一般会设计成由一个中心的Servlet,提供公共的请求处理逻辑,把实际业务让配置的组件来处理。
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) {
// Load Spring web application configuration
AnnotationConfigWebApplicationContext context = new
AnnotationConfigWebApplicationContext();
context.register(AppConfig.class);
// Create and register the DispatcherServlet
DispatcherServlet servlet = new DispatcherServlet(context);
ServletRegistration.Dynamic registration = servletContext.addServlet("app",
servlet);
registration.setLoadOnStartup(1);
registration.addMapping("/app/*");
}
}
<web-app>
<listener>
<listener-class>
org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/app-context.xml</param-value>
</context-param>
<servlet>
<servlet-name>app</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-
class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value></param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>app</servlet-name>
<url-pattern>/app/*</url-pattern>
</servlet-mapping>
</web-app>
Spring Boot不是这么做的。Spring Boot内嵌了Servlet容器,使用Spring配置来初始化本身和Servlet容器。Filter和Servlet的声明都是从Spring检测到了再注册到Servlet容器的。
容器层级
在Spring里,针对网络场景的日期是WebApplicationContext,由它连接DispatcherServelet,还囊括了 ServletContext。
对大多数应用来说,WebApplicationContext就够了,当然也可以创建容器层级,即有一个根WebApplicationContext,多个DispatcherServlet(或其他Servlet),每个Servlet有自己的WebApplicationContext配置。根通常包含基建bean,比如数据库和其他需要被Servlet共享的业务服务。
结构
配置
Spring提供的Web场景相关的Bean
DispatcherServlet负责把网络请求分发给具体的bean处理,并且组装合适的网络返回。
-
- HandlerMapping -- 请求处理,前置处理和后置处理
- HandlerAdapter -- 帮助屏蔽请求处理的细节
- HandlerExceptionResolver -- 错误处理
- ViewResolver -- 视图展现方面
- LocaleResolver / LocaleContextResolver --多语言Locale
- ThemeResolver -- 视图主题
- MultipartResolver -- 上传下载
Web MVC Config
如果提供了自己的bean,会用业务提供的,没有才会用默认的。
默认的列在spring-webmvc\5.3.18:org\springframework\web\servlet\DispatcherServlet.properties
Servlet Config
在Servlet3.0+的环境下,配置Servlet容器, 在web.xml文件的基础上,可以选择代码补充配置。
Spring提供了接口WebApplicationInitializer帮助配置Servlet容器,还提供了AbstractDispatcherServletInitializer更方便点。
import org.springframework.web.WebApplicationInitializer;
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext container) {
XmlWebApplicationContext appContext = new XmlWebApplicationContext();
appContext.setConfigLocation("/WEB-INF/spring/dispatcher-config.xml");
ServletRegistration.Dynamic registration = container.addServlet("dispatcher", new DispatcherServlet(appContext));
registration.setLoadOnStartup(1);
registration.addMapping("/");
}
}
public class MyWebAppInitializer extends
AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return null;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[] { MyWebConfig.class };
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
}
如果使用的是基于XML的Spring配置,必须通过继承AbstractDispatcherServletInitializer的方式来配置了:
public class MyWebAppInitializer extends AbstractDispatcherServletInitializer {
@Override
protected WebApplicationContext createRootApplicationContext() {
return null;
}
@Override
protected WebApplicationContext createServletApplicationContext() {
XmlWebApplicationContext cxt = new XmlWebApplicationContext();
cxt.setConfigLocation("/WEB-INF/spring/dispatcher-config.xml");
return cxt;
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
}
每个filter添加一个根据它具体类型决定的默认名称,并且自动加入DispatcherServlet。
AbstractDispatcherServletInitializer的isAsyncSupported方法提供了对DispatcherServlet的异步支持,并把所有filter都映射上了。默认为true。
如果想要自定义一个DispatcherServlet,可以重写createDispatcherServlet方法。
请求处理
Dispatcher处理请求的流程:
WebApplicationContext的HandlerExceptionResolver是用来解析请求处理期间抛的错的。
针对HTTP缓存处理器,我们可以看下WebRequest的checkNotModified。后面会详细说。
也可以针对单个DispatcherServlet实例做个性化,方法是往Servlet的声明加初始化参数(init-param)。web.xml:
-
- contextClass -- 应该是ConfigurableWebApplicationContext的实现类,会由当前Servlet负责实例化并持有引用。默认用得是XmlWebApplicationContext。
- contextConfigLocation -- context配置的路径,多个的话用逗号做分隔符,如果有重复的,取后者
- namespace -- WebApplicationContext的命名空间,默认是 [servlet-name]-servlet
- throwExceptionIfNoHandlerFound -- 如果某个请求找不到合适的处理器,是否抛NoHandlerFoundException。 这个错可以被HandlerExceptionResolver捕获(或者在controller使用@ExcpetionHandler)。默认为false。DispatcherServlet会把返回status设置为404(NOT_FOUOND).
路径匹配
Servlet API,requestURI是 请求的全路径。又从路径中提取出了更细化的contextPath,servletPath, 和 pathInfo。Spring MVC要根据它们找到对应处理的handler(处理器)。lookupPath是最终用来找controller的。
这一节讲的模模糊糊的,真心看不懂,就提起了这几点:
- 路径解析挺麻烦的,要考虑到可能的前缀"/",可能的后缀“;”,以及关键词(reserved word)不做关键词的时候的解码
- 建议把默认DispatcherServlet映射到"/" 或者 "/*",并且保证Servlet容器版本至少4.0,Spring MVC一般就没啥问题了。
- 如果用的Servlet版本是3.1,需要在MVC config里 把UrlPathHelper 的 alwaysUseFullPath 设置为true
- 还有一种场景是原始路径需要解码了才能用来比对,那就需要UrlPathHelper 设置 urlDecode=false
- 还有特殊场景是DispatcherServlet 需要和其他Servlet共享URL space,通过前缀区分到底哪个。
拦截器(Interception)
所有HandlerMapping实现类都支持拦截器 。拦截器需要实现接口org.springframework.web.servlet.HandlerInterceptor,并且实现方法:
-
- Boolean preHandle(..),如果返回false,就认为拦截器就帮着处理好了,不会执行handler
- postHandle(..) -- 针对@ResponseBody and ResponseEntity请求,不会生效,因为在提交给postHandle之前,就会把请求提交给HandlerAdapter。针对这种场景,实现一个ResponseBodyAdvice,把它配置成Controller Advice,或者RequestMappingHandlerAdapter。
- afterCompletion(..) 请求结束后
异常
如果在request mapping(找controller)阶段出错,或者controller抛错了,DispatcherServlet会使用HandlerExceptionResolver链来解析异常和进行处理,通常是返回一个error response。
现成的HandlerExceptionResolver实现:
-
- SimpleMappingExceptionResolver -- 前后端不分离场景,错误类名和错误页面的映射,用来渲染
- DefaultHandlerExceptionResolver -- 解析SpringMVC抛的错并映射到HTTP状态码
- ResponseStatusExceptionResolver -- 解析@ResponseStatus注解,并根据注解的值映射HTTP状态码
- ExceptionHandlerExceptionResolver -- 使用标注了@Controller 或者 @ControllerAdvice 的类的@ExceptionHandler方法解析报错。
解析器链
通过在Spring中声明多个HandlerExceptionResolver bean就能组一个异常解析器链,要保证顺序的话,设置order属性即可。
HandlerExceptionResolver允许返回的类型有:
-
- ModelAndView执行错误展现页面
- 空ModelAndView,如果异常被处理了
- 如果当前解析器不处理对应异常,返回null,表示让后续解析器处理,如果所有的都不处理,会由Servlet容器处理。
容器的错误展示页
如果异常没有任何解析器处理,或者response status是一个错误状态(4xx,5xx),Servlet容器可以提供一个html错误页。如果想设置容器的默认错误页,可以在web.xml声明一个错误页映射项。Sevlet用类似右边的controller来渲染错误页。并没有提供java声明错误页面的口子,可以使用WebApplicationInitializer 和一个简化版的web.xml来实现。
@RestController
public class ErrorController {
@RequestMapping(path = "/error")
public Map<String, Object> handle(HttpServletRequest request) {
Map<String, Object> map = new HashMap<String, Object>();
map.put("status", request.getAttribute("javax.servlet.error.status_code"));
map.put("reason", request.getAttribute("javax.servlet.error.message"));
return map;
}
}
页面解析
SpringMVC提供了ViewResolver和View俩接口,支持页面视图解析。ViewResolver支持根据名称映射视图名称和视图。View处理页面渲染需要的数据。
-
- AbstractCachingViewResolver -- 缓存视图,禁用缓存把cache设置为false。如果碰到模板引擎需要动态加载模板,可以动态调用removeFromCache(String viewName, Locale loc)使缓存失效。
- UrlBasedViewResolver -- 视图名就是url地址
- InternalResourceViewResolver --jsp
- FreeMarkerViewResolver -- freemarker
- ContentNegotiatingViewResolver -- 除了名字,还可以根据header协商
- BeanNameViewResolver -- bean名称
- Handling
视图解析器也是可以成链的,优先级也是根据order来
- 重定向
如果一个视图的名字有前缀 redirect:,效果和返回RedirectView一样,UrlBasedViewResolver处理。
- 转发
forward:前缀,创建InternalResourceView,会执行RequestDispatcher.forward().
- 内容协商
ContentNegotiatingViewResolver并不解析,而是根据请求头Accept 和Content-Type 会让其他解析器干活。
多语言
DispatcherServlet在LocaleResolver的帮助下,可以自动根据客户端的确定语言。业务可以调用RequestContext.getLocale()获得结果。
Spring提供了这些LocaleResolver:
- 时区
LocaleContextResolver
用户的时区可以通过 RequestContext.getTimeZone()获取。
Spring的ConversionService 中注册的所有 Date/Time Converter 和 Formatter都会考虑用户时区。
- Header Resolver
根据请求的accept-language请求头决定locale,但不包括时区信息。
- Cookie Resolver
根据cookie信息解析。可以自定义:
<bean id="localeResolver" class="org.springframework.web.servlet.i18n.CookieLocaleResolver">
<property name="cookieName" value="clientlanguage"/>
<!-- in seconds. If set to -1, the cookie is not persisted (deleted when browser
shuts down) -->
<property name="cookieMaxAge" value="100000"/>
</bean>
- Session Resolver
Servlet 日期的 HttpSession。SessionLocaleResolver
- Locale Interceptor
LocaleChangeInterceptor添加到HandlerMapping里,就能动态更改时区信息。调用的是LocaleResolver的setLocale方法
<bean id="localeChangeInterceptor"
class="org.springframework.web.servlet.i18n.LocaleChangeInterceptor">
<property name="paramName" value="siteLanguage"/>
</bean>
<bean id="localeResolver"
class="org.springframework.web.servlet.i18n.CookieLocaleResolver"/>
<bean id="urlMapping"
class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
<property name="interceptors">
<list>
<ref bean="localeChangeInterceptor"/>
</list>
</property>
<property name="mappings">
<value>/**/*.view=someController</value>
</property>
</bean>
主题
现在都是前后端分离了,并且本人对搞样式的确不大感兴趣,就先不看了……需要了再回来补了
Multipart Resolver
org.springframework.web.multipart.MultipartResolver.
要开启Multipart,需要在容器里声明一个名字是multipartResolver的MultipartResolver。DispatcherServlet可以监测到它,并且在后续请求使用(1.post请求,2. content type 是multipart/form-data)
- Apache Commons FileUpload
要使用 Apache Commons FileUpload,配置一个名字是multipartResolver,类型是CommonsMultipartResolver的bean。并把commons-fileupload依赖加上。这种方式的好处是无论使用哪种网络容器,效果都是统一的。这种只能接收post请求,但是允许所有的content type。
- Servlet 3.0
需要在Servlet容器配置开启:
java代码实例如下,或者在web.xml中,servlet声明中增加。
public class AppInitializer extends
AbstractAnnotationConfigDispatcherServletInitializer {
// ...
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
// Optionally also set maxFileSize, maxRequestSize, fileSizeThreshold
registration.setMultipartConfig(new MultipartConfigElement("/tmp"));
}
}
然后增加名为multipartResolver的StandardServletMultipartResolver。
这种模式使用的是容器的multipart功能,默认的话可以识别所有类型的请求,只需要他们的content type以multipart/开头。但是不是所有容器都支持所有类别的请求(get post ... )……
日志
吹了一波自己的日志级别设置得挺好的。然后说如果觉得有不合适的,希望能告诉他们。
很喜欢里面这句话:
Good logging comes from the experience of using the logs.
好的日志来自于使用日志的经验。
- 敏感数据
DEBUG 和 TRACE日志有可能记录敏感信息。所以请求参数和请求头默认是masked(135XXXXXXXX)。如果想开启他们的日志,可以设置DispatcherServlet的enableLoggingRequestDetails。
public class MyInitializer
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return ... ;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return ... ;
}
@Override
protected String[] getServletMappings() {
return ... ;
}
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
registration.setInitParameter("enableLoggingRequestDetails", "true");
}
}
Filters
spring-web模块提供了4个自带的filter:
• Form Data
• Forwarded Headers
• Shallow ETag
• CORS。
Form Data
浏览器支持通过GET 或者 POST 提交数据,其他可以端也可以通过PUT, PATCH, 和 DELETE提交数据。Servlet API要求ServletRequest.getParameter*()只支持 POST请求的form字段。
spring-web提供了FormContentFilter,专门处理content-type是application/x-www-form-urlencoded的HTTP PUT, PATCH, 和 DELETE请求,从请求正文读取formData,包装ServletRequest,增加ServletRequest.getParameter*系列方法。
Forwarded Headers
RFC 7239 定义了请求头Forwarded表示被转发的请求的原始地址。还有一些非标准规范的请求头表达同一个意思,如X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Proto, X-Forwarded-Ssl, 和 X-Forwarded-Prefix。
ForwardedHeaderFilter支持修改请求:
-
- 根据Forwarded请求头修改请求的ip,端口等
- 去除Forwarded请求头,以免有其他影响
因为它需要对请求进行包装,所以必须在其他filter前面执行。后续filter要处理的应该是它提供的包装后的请求。
需要考虑到安全问题,要确定这个请求头是可信任的环节加的,而不是恶意增加的。如果把ForwardedHeaderFilter设置removeOnly=true,则只会去掉这个请求头,但不会使用它。
如果要支持异步请求,要把ForwardedHeaderFilter和DispatcherType.ASYNC以及DispatcherType.ERROR关联上。如果使用的是Spring的AbstractAnnotationConfigDispatcherServletInitializer,那所有的filter自动对所有DispatcherType都关联上的。但如果使用的是web.xml配置,或者使用的是springboot的FilterRegistrationBean,除了 DispatcherType.REQUEST,还 需要手工关联DispatcherType.ASYNC 和DispatcherType.ERROR。
Shallow ETag
ShallowEtagHeaderFilter会缓存请求返回,根据返回计算一个MD5。这就是一个“浅层” ETag 。下一次请求过来的时候,也会再次对请求返回计算MD5,计算出的MD5和If-None-Match请求头带的是一致的 ,会返回304 (NOT_MODIFIED)。
所以能节省网络IO,但是CPU(controller逻辑)还是会重新做一遍。如果要彻底,那需要其他策略,比如在controller层的HTTP Caching。
ShallowEtagHeaderFilter.writeWeakETag。 效果类似 W/"02a2d595e6ed9a0b24f027f2b63b134d6"
如果要支持异步,和Forwarded Header是要一样一样的操作。
CORS
Spring支持非常细颗粒度的CORS配置(在controller上加注解)。不过针对Spring Security场景,官方更建议使用自带的CorsFilter(必须在Spring Security前)。
Controller注解
@Component 表示当前bean的角色是网络组件。
@RestController 是一个复合注解@Controller +@ResponseBody。
@ResponseBody 方法返回是直接写进请求的response里的,不需要根据返回解析视图,也不需要根据html模板渲染。
- AOP代理
如果在controller上加上了 @Transactional注解,会在运行时创建AOP代理。针对这种场景,Spring建议使用class-based proxying(CGLIB),这也是controller的默认行为。
如果不是springboot项目,并且controller实现了接口,需要手工开启强制CGLIB。
<tx:annotation-driven proxy-target-class="true"/>
@EnableTransactionManagement(proxyTargetClass = true)
Request Mapping
@RequestMapping把请求映射到controller方法上。
还有用RequestMapping做元注解的复合注解:
-
- @GetMapping
- @PostMapping
- @PutMapping
- @DeleteMapping
- @PatchMapping
URI patterns
我理解URI pattern就是URI的匹配表达式,类似于正则表达式。Spring支持两种:
-
- PathPattern -- 说是提前parse好的,效率更高点。对WebFlux只有这一个选项
- AntPathMatche -- 直接String比对,也用在Spring找Resource的时候。效率没有PathPattern高。在5.3以前是默认选项也是唯一选项。5.3开始可以使用 MVC config换成PathPattern
AntPathMatcher支持的语法,PathPattern都支持,并且额外支持捕获类的,如{*spring}可以匹配末尾有0到多个spring结尾的路径。PathPattern还限制了**只支持在路径末尾的多个部分。
例子:
-
- "/resources/ima?e.png" - 匹配一个字符
- "/resources/*.png" - 0个或者多个字符
- "/resources/**" - 多个路径segment
- "/projects/{project}/versions" - 匹配路径一部分并存储为变量,可以通过 @PathVariable拿到
- "/projects/{project:[a-z]+}/versions" - 匹配路径部分并存储为变量,支持正则表达式
@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}
@Controller
@RequestMapping("/owners/{ownerId}")
public class OwnerController {
@GetMapping("/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}
}
URI variables变量的数据类型会自动转,转失败会抛TypeMismatchException。基础数据类型都是支持的,如果有自定义类型,需要使用Type Conversion和DataBinder来支持。
@PathVariable("customId")加参数就按照参数去找,不然就默认根据字段名去匹配。
{varName:regex}这种,冒号左边是变量名,右边是匹配正则:
@GetMapping("/{name:[a-z-]+}-{version:\d\.\d\.\d}{ext:\.[a-z]+}")
public void handle(@PathVariable String name, @PathVariable String version,
@PathVariable String ext) {
// 假设路径是/spring-web-3.0.5.jar,解析结果:
// name-spring-web,version-3.0.5,ext - jar
}
Pattern Comparison
如果有多个pattern都能匹配上一个url地址,会选择最匹配的那个。Pattern内部都是有比较规则的,规则都是一样的。
-
- PathPattern.SPECIFICITY_COMPARATOR
- AntPathMatcher.getPatternComparator(String path)
使用最具体的那个。一个URI变量算1分,通配符(*.)算1分。如果平分了,取比较长的那个。如果平分了并且长度相同,取uri变量多的那个。
默认的/**不算在里面,永远放最后一个。 /public/**也放后面(有俩星号的)
Suffix Match
Spring5.3开始,Spring MVC不会再默认加上后缀.* 匹配了。建议使用Accept请求头。使用路径匹配来确认文件后缀,在Spring团队经验里,会导致一系列其他不必要的问题,非常容易出错。他们还建议即使使用的是5.3以前的版本,也把这个功能给关闭了:
PathMatchConfigurer - useSuffixPatternMatching(false)
ContentNegotiationConfigurer - favorPathExtension(false)
Suffix Match and RFD
reflected file download (RFD) 攻击和XSS类似,都依赖于请求的输入会被反射到response上。区别是XSS是把JavaScript放进了HTML,而RFD让浏览器把response(实际是可执行脚本)作为文件下载文件,欺骗用户双击打开。
要阻止RFD攻击,SpringMVC返回时应该加上返回头Content-Disposition:inline;filename=f.txt 。
Consumable Media Types
请求匹配的话,可以使用consumes 属性,进一步根据请求的Content-Type细化:
@PostMapping(path = "/pets", consumes = "application/json") ①
public void addPet(@RequestBody Pet pet) {
// ...
}
consumes也支持协商表达式,比如!text/plain可以接收任何其他非text/plain的content type。
如果类和方法级别都有consumes,那方法的会覆盖类的,而不像其他的属性是extend(拓展)。
Producible Media Types
请求匹配的话,可以使用produces 属性,进一步根据请求的Accept细化:
@GetMapping(path = "/pets/{petId}", produces = "application/json") ①
@ResponseBody
public Pet getPet(@PathVariable String petId) {
// ...
}
支持规定字符集,也支持协商(如!text/plain )。
方法覆盖类的produces
Parameters, headers
@GetMapping(path = "/pets/{petId}", params = "myParam=myValue") ①
public void findPet(@PathVariable String petId) {
// 需要有个值为myValue,key为myParam的query param...
}
@GetMapping(path = "/pets", headers = "myHeader=myValue") ①
public void findPet(@PathVariable String petId) {
// 需要有个值为myValue,key为myParam的header...
}
虽然也可以用这种方式来限制 Content-Type和Accept,但最好还是用语义更贴切的 consumes 和 produces。
HTTP HEAD, OPTIONS
@GetMapping ( @RequestMapping(method=HttpMethod.GET))除了支持get请求,也支持head请求。javax.servlet.http.HttpServlet里提供了response的包装类,能确保 Content-Length 能填上写了的字节数量。
Head请求和Get请求的区别在于,head不返回响应体,只返回content-length(header上)。针对不加method的@RequestMapping,会把请求响应头Allow加到GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS。
Custom Annotations
可以创建自己的复合注解。
如果想自己定义一套解析逻辑,也可以定义自己的RequestMappingHandlerMapping ,重写方法 getCustomMethodCondition,返回 RequestCondition。
Explicit Registrations
hanler可以代码手工注册。
@Configuration
public class MyConfig {
@Autowired
public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler) throws NoSuchMethodException {//注入目标handler以及handler mapping
RequestMappingInfo info = RequestMappingInfo
.paths("/user/{id}").methods(RequestMethod.GET).build(); // 准备mapping的元数据
Method method = UserHandler.class.getMethod("getUser", Long.class); //获取handler方法
mapping.registerMapping(info, handler, method); //注册
}
}
Handler方法
由@RequestMapping注解的方法就是handler。它的签名有好几种都支持。我们可以从出入参来看。
入参
入参不支持Reactive类型的。
如果用Optional包一下,就相当于required=false。
- WebRequest, NativeWebRequest -- Servlet 层级的原始请求
- javax.servlet.ServletRequest, javax.servlet.ServletResponse, MultipartRequest, MultipartHttpServletRequest --具体的请求类型
- javax.servlet.http.HttpSession -- 强制使用会话。注意会话是线程不安全的,建议RequestMappingHandlerAdapter实例的synchronizeOnSession =true。
- javax.servlet.http.PushBuilder -- servlet4.0 HTTP/2,需要引入其他底层的包才行,没有试
- java.security.Principal -- 当前鉴权过的用户,一般是具体的实现类对象。
- HttpMethod
- java.util.Locale
- java.util.TimeZone +java.time.ZoneId
- java.io.InputStream, java.io.Reader -- 连接原始的request body
- java.io.OutputStream, java.io.Writer -- 连接原始的response body
- @PathVariable
- @MatrixVariable -- 类似/request;username=admin;password=123456;age=20 -- 需要手工开启
- @RequestParam
- @RequestHeader
- @CookieValue
- @RequestBody
- HttpEntity
- @RequestPart
如果有注解@RequestPart但是并没有任何文件上传过来,会抛ERROR 1632,因为拿不到文件
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is javax.servlet.ServletException: org.apache.tomcat.util.http.fileupload.impl.InvalidContentTypeException: the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is application/json] with root cause
- java.util.Map, org.springframework.ui.Model, org.springframework.ui.ModelMap
前后端不分离时使用,给视图传数据用的。
- RedirectAttributes
前后端不分离时使用,重定向过程中传数据用的
- @ModelAttribute -- 前后端不分离时使用,给视图传数据用的。
- Errors, BindingResult
- SessionStatus + class-level @SessionAttributes
前后端不分离场景获取session信息
- UriComponentsBuilder
- @SessionAttribute
- @RequestAttribute
- 其他 - 简单数据类型解析为@RequestParam,否则 @ModelAttribute.
出参
- @ResponseBody
- HttpEntity, ResponseEntity
- HttpHeaders
- String
- View -- 前后端不分离
- java.util.Map, org.springframework.ui.Model --前后端不分离
- @ModelAttribute --前后端不分离
- ModelAndView object --前后端不分离
- void
- DeferredResult
- Callable
- ListenableFuture, java.util.concurrent.CompletionStage, java.util.concurrent.CompletableFuture
- ResponseBodyEmitter, SseEmitter
- StreamingResponseBody
- Reactive types — Reactor, RxJava, or others through ReactiveAdapterRegistry
- 其他
类型转换
Spring支持基础数据类型的转换。
要支持自定义的类型转换,可以使用WebDataBinder或者使用FormattingConversionService注册Formatters。
Matrix变量
@RequestParam
可以绑定query parameter和(body中的)form data。除非required设为false,或者参数是Optional的,参数必填。如果接收的字段类型不是String,会自动类型转换。接form data需要content-type为application/x-www-form-urlencoded或者multipart/form-data。
如果需要接收form data,content-type应该是application/x-www-form-urlencoded,否则接收不到。
如果接收的字段类型是数组或者list,可以接收多个同名字段。
如果接收的字段类型是Map<String, String> (只取第一个)或者MultiValueMap<String, String>(取所有),并且注解的name没有设置,key就是name。
@RequestParam是非必要的,所有基础数据类型的参数,如果其他参数解析器不适用,就会被认为是@RequestParam。
@RequestHeader
使用@RequestHeader获取单个请求头
如果接收的类型是Map<String, String>, MultiValueMap<String,String>, HttpHeaders,获取所有的请求头。
现成已经约定好了类型可以是列表的请求头,虽然传过来是一个字符串(分隔符逗号)也是可以自动转成数组或者List的。
@CookieValue
@GetMapping("/demo")
public void handle(@CookieValue("JSESSIONID") String cookie) { ①
//...
}
@ModelAttribute
如果放在controller类的某个方法上,就会在这个controller所有其他@RequestMapping到的方法前执行。
package com.jeanne.lowcode.web.controller;
import com.jeanne.lowcode.web.vo.ModelAttributeDemoVo;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import javax.validation.Valid;
import java.util.Optional;
/**
* @author Jeanne 2023/6/22
**/
@Controller
public class ModelAttributeDemoController {
/**
* /accounts/type1
* /accounts/type2
* /accounts/type3
*/
@GetMapping("/accounts/{type}")
public void update(@Valid ModelAttributeDemoVo form, BindingResult result,
@ModelAttribute("type") ModelAttributeDemoVo vo,
@ModelAttribute(value = "type", binding=false) ModelAttributeDemoVo voNoBinding,
@ModelAttribute("demoVo") ModelAttributeDemoVo voAttribute
) {
System.out.println("");
}
@ModelAttribute()
public ModelAttributeDemoVo populate(WebRequest webRequest,
@PathVariable Optional<String> type, Model model ) {
ModelAttributeDemoVo demoVo = ModelAttributeDemoVo.builder().id(22l).name("demo_" + type).build();
type.ifPresentOrElse(
typeInner -> {
switch (typeInner) {
case "type1":
case "type2":
demoVo.setType(typeInner);
break;
default:
demoVo.setType("default");
}
},
()->{
demoVo.setType("default");
}
);
model.addAttribute("demoVo",demoVo);
return demoVo;
}
}
@SessionAttributes、@SessionAttribute、@RequestAttribute、Redirect Attributes、Flash Attributes
前后端分离场景下,不太用得上,先放一放~
Multipart
要支持上传下载,需要启用MultipartResolver(即作为bean注入容器)。SpringBoot已经帮我们搞定了:
如果使用Servlet3.0,也可以使用Part(javax.servlet.http.Part)接文件。
也可以把文件作为对象的成员接收。但是看了下,这个过程是 浏览器-tomcat临时文件-内存这样的,会多一道。
@RequestBody
读取请求体,并使用HttpMessageConverter反序列化回对象。只接收content-type是application/json类型。
支持MVC Config的Message Converters来配置自定义转换器。
支持javax.validation.Valid和Spring的@Validated,都会触发标准的Bean校验。默认校验失败会抛MethodArgumentNotValidException,导致请求返回400 (BAD_REQUEST)。也可以在Controller里用Errors或者BindingResult接收错误,自己处理。
看了下,如果是面对终端用户的,那一定是自己接收了,然后转成可读形式返回给前端的。类似这样:
public String useValidtion(@Valid @RequestBody ValidationVo validationVo,
BindingResult bindingResult) {
// 首先判断有过来的数据有没有问题,有问题把所有问题都捞出来,统一封装返回
// 没有问题再进行业务逻辑
}
自定义校验注解:
HttpEntity
似乎没啥意义?
@ResponseBody
把返回的报文体用HttpMessageConverter序列化。
@RestController=@Controller+@ResponseBody
reactive场景也适用。
可以使用MVC Config的 Message Converter自定义转换器。
ResponseEntity
ResponseEntity也支持reactive:
-
- ResponseEntity<Mono> or ResponseEntity<Flux>-- 立即返回status和header,但是body异步返回
- Mono<ResponseEntity>-- status, header和body都异步返回
Jackson JSON
Spring底层对json序列化和反序列化,是用的Jackson JSON的库。
Spring也支持Jackson的序列化视图(Serialization Views)。序列化视图允许只拿对象里的一部分字段来序列化。对应的注解是@JsonView,和@ResponseBody或者ResponseEntity一起使用。
Model(@ModelAttribute )
@ModelAttribute有三种用法
-
- 在@RequestMapping方法的参数上,从model获取或者创建一个对象,并通过WebDataBinder把它绑到请求上
- 在@Controller类或者@ControllerAdvice类的方法上,在这个类所有@RequestMapping方法之前执行,可以初始化model(干预model)。如果多个controller需要共享一个@ModelAttribute方法,使用@ControllerAdvice。
- 放在@RequestMapping方法上,表示返回值是一个model属性。
针对第二种场景,@ModelAttribute方法入参不能有@ModelAttribute,也不能访问请求体。
DataBinder
在@Controller和@ControllerAdvice类里,都可以使用@InitBinder方法初始化WebDataBinder。WebDataBinder提供这些功能:
-
- 把请求参数绑定到model上
- 把Spring支持的请求值(request parameters,path variables,headers, cookies)转为controller方法入参
- 在渲染html的时候把model对象转回字符串
@InitBinder支持controller级的java.beans.PropertyEditor、Spring Converter
和Formatter components。如果要全局的,还是得注册到MVC config的FormattingConversionService。
@InitBinder方法的入参,和controller的入参一样灵活(除了不支持@ModelAttribute),出参一定是void。
自定义转换,有两个方法,一个是自定义一个Editor并注册,另一个是自定义formatter。
Exceptions
@ExceptionHandler方法可以放在@Controller或者@ControllerAdvice类里面做错误捕获。
捕获的错误类型,是根据入参类型+注解的参数决定的最小集。
如果抛的错是有wrapped到更大的错误的,是按照最外层的来确定ExceptionHandler的:
Spring官方对ExceptionHandler的建议,我也是非常认同的,就是报错捕获,做的越细越好。最好是每种都单独声明一个ExceptionHandler,再加一个额外的缺省以免有漏网之鱼。
如果要做@ControllerAdvice类里加ExceptionHandler,异常处理链的顺序,与所在类的优先级是一致的。只会取第一个。
注意我随手写的测试代码里,虽然是一股脑都把异常返回给客户端了,但是真实项目里这样其实不好。最好的做法还是只把与配置或者校验相关的报错返回前端。其他的统一返回一个系统错误,然后把报错信息记日志。当然如果是要求不高的内部管理系统,也可以自己商量着怎么方便运维使用怎么来。
官方文档里提到如果想要的是内层的报错,也可以使用@ExceptionHanler方法拿出来再抛出去。但是一下子没想明白要咋搞。
SpringMVC对ExceptionHandler的支持在DispatcherServlet里面:
入参
@ExceptionHandler方法支持的入参类型:
-
- HandlerMethod
- javax.servlet.ServletRequest, javax.servlet.ServletResponse
- javax.servlet.http.HttpSession
- java.security.Principal
- HttpMethod
- java.util.Locale
- java.util.TimeZone, java.time.ZoneId
- java.io.OutputStream, java.io.Writer
- java.util.Map, org.springframework.ui.Model, org.springframework.ui.ModelMap
- RedirectAttributes
- @SessionAttribute
- @RequestAttribute
返回值
-
- @ResponseBody
- HttpEntity, ResponseEntity
- String -- 视图名字
- View
- java.util.Map, org.springframework.ui.Model
- @ModelAttribute
- ModelAndView object
- void -- 不走MVC,默认代码手工返回了
- 其他 --如果基本数据类型,被认为是Model attribute,否则就忽略
REST API异常
对应SpringMVC相关的异常,比如绑定失败,转换失败等,是用的ResponseEntityExceptionHandler。如果实现了全局异常捕获,建议实现ResponseEntityExceptionHandler来做更细的处理。
Controller Advice
对于@ExceptionHandler, @InitBinder, @ModelAttribute 方法,如果是在普通controller上,就只对当前controller生效。如果想要全局的,就要放在@ControllerAdvice或者@RestControllerAdvice类里。注意@ExceptionHandler全局的是在5.3版本之后才支持。
@ControllerAdvice 有 @Component,所以可以自动注册为bean。
@RestControllerAdvice =@ControllerAdvice + @ResponseBody,所以@ExceptionHandler方法返回的,会被处理成response的请求体,而不是渲染HTML视图。
SpringMVC里干活实现的是:RequestMappingHandlerMapping和ExceptionHandlerExceptionResolver。
@ExceptionHandler全局的放在最后处理。@ModelAttribute , @InitBinder全局的先处理。
@ControllerAdvice还有属性可以限定目标controller范围,是在运行时判断的,所以会对性能有一定的影响:
Functional Endpoints
Spring Web MVC内嵌了一个轻量级的functional programming模型WebMvc.fn,支持请求路由和处理。是注解模型的counterpart。(我看到应该是在这个spring-webmvc的org.springframework.web.servlet.function里)
在这个模型里,HTTP请求是使用HandlerFunction处理的。HandlerFunction入参是ServerRequest,返回值是ServerResponse。HandlerFunction相当于@RequestMapping的角色。
RouterFunction把请求转到handler function里,返回的是Optional。找不到就返回空的Optional。RouterFunction的角色相当于@RequestMapping。区别是它提供的不仅是数据,也提供方法。
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.servlet.function.RequestPredicates.*;
import static org.springframework.web.servlet.function.RouterFunctions.route;
PersonRepository repository = ...
PersonHandler handler = new PersonHandler(repository);
RouterFunction<ServerResponse> route = route()
.GET("/person/{id}", accept(APPLICATION_JSON), handler::getPerson)
.GET("/person", accept(APPLICATION_JSON), handler::listPeople)
.POST("/person", handler::createPerson)
.build();
public class PersonHandler {
// ...
public ServerResponse listPeople(ServerRequest request) {
// ...
}
public ServerResponse createPerson(ServerRequest request) {
// ...
}
public ServerResponse getPerson(ServerRequest request) {
// ...
}
}
HandlerFunction
ServerRequest和ServerResponse都是不可变接口。
ServerRequest
提供对HTTP方法、URI、请求头、查询参数的访问。注意请求体的访问是需要提供body方法的。
String string = request.body(String.class);
List<Person> people = request.body(new ParameterizedTypeReference<List<Person>>() {});
MultiValueMap<String, String> params = request.params();
ServerResponse
ServerResponse提供对HTTP response的访问,但是因为它是不可变的(immutable),所以可以使用build创建它。
Person person = new Person();
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person);
URI location = ...
ServerResponse.created(location).build();
// 仅body是异步提供
Mono<Person> person = webClient.get().retrieve().bodyToMono(Person.class);
ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(person);
// 所有都异步提供
Mono<ServerResponse> asyncResponse = webClient.get()
.retrieve()
.bodyToMono(Person.class)
.map(p -> ServerResponse.ok()
.header("Name", p.name())
.body(p));
ServerResponse.async(asyncResponse);
ServerResponse还提供了sse方法以发布Server-Sent Events。
public RouterFunction<ServerResponse> sse() {
return route(GET("/sse"), request -> ServerResponse.sse(
sseBuilder -> {
// Save the sseBuilder object somewhere..
})
);
}
// In some other thread, sending a String
sseBuilder.send("Hello world");
// Or an object, which will be transformed into JSON
Person person = ...
sseBuilder.send(person);
// Customize the event by using the other methods
sseBuilder.id("42")
.event("sse event")
.data(person);
// and done at some point
sseBuilder.complete();
Handler类
可以直接写成lambda:
HandlerFunction<ServerResponse> helloWorld =
request -> ServerResponse.ok().body("Hello World");
校验
RouterFunction
路由找handler的话,Spring建议,直接用RouterFunctions.route()就可以了,提供了get,post方法定义路由信息。路由信息可以是字符串路径。也可以RequestPredicate定义路径匹配规则。
RequestPredicate
RouterFunction<ServerResponse> route = RouterFunctions.route()
.GET("/hello-world",
accept(MediaType.TEXT_PLAIN),
request -> ServerResponse.ok().body("Hello World")
)
.build();
可以加逻辑运算:
-
- RequestPredicate.and(RequestPredicate)
- RequestPredicate.or(RequestPredicate)
Routes
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.servlet.function.RequestPredicates.*;
PersonRepository repository = ...
PersonHandler handler = new PersonHandler(repository);
RouterFunction<ServerResponse> otherRoute = ...
RouterFunction<ServerResponse> route = route()
.GET("/person/{id}", accept(APPLICATION_JSON), handler::getPerson)
.GET("/person", accept(APPLICATION_JSON), handler::listPeople)
.POST("/person", handler::createPerson)
.add(otherRoute)
.build();
路由嵌套
RouterFunction<ServerResponse> route = route()
.path("/person", builder -> builder
.GET("/{id}", accept(APPLICATION_JSON), handler::getPerson)
.GET(accept(APPLICATION_JSON), handler::listPeople)
.POST("/person", handler::createPerson))
.build();
RouterFunction<ServerResponse> route = route()
.path("/person", b1 -> b1
.nest(accept(APPLICATION_JSON), b2 -> b2
.GET("/{id}", handler::getPerson)
.GET(handler::listPeople))
.POST("/person", handler::createPerson))
.build();
启动服务器
@Configuration
@EnableMvc
public class WebConfig implements WebMvcConfigurer {
@Bean
public RouterFunction<?> routerFunctionA() {
// ...
}
@Bean
public RouterFunction<?> routerFunctionB() {
// ...
}
// ...
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// configure message conversion...
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// configure CORS...
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
// configure view resolution for HTML rendering...
}
}
Handler Function过滤
相当于@ControllerAdvice/ServletFilter,RouterFunction的before, after, 和 filter方法。
Spring提供了CorsFilter,跨域安全。
URI Links
UriComponents
UriComponentsBuilder提供了方法创建URI模板,并使用模板和参数创建URI。
UriBuilder
UriComponentsBuilder实现了UriBuilder,可以使用UriBuilderFactory创建一个UriBuilder。可以使用UriBuilderFactory配置RestTemplate和WebClient。DefaultUriBuilderFactory是UriBuilderFactory的默认实现类。内部使用的UriComponentsBuilder。
URI编码
加参数前encode
加参数后encode,不会转译特殊字符。
相对路径Servlet请求
可以使用ServletUriComponentsBuilder.fromRequest创建相对入参请求的URI。
Controller链接
注意从Controller获取uri,对应方法的返回值类型,不能是final的(比如String)。
MvcUriComponentsBuilder内部使用的是ServletUriComponentsBuilder。
Spring 5.1开始, MvcUriComponentsBuilder忽略请求头Forwarded和X-
Forwarded-* 。可以使用ForwardedHeaderFilter提取。
视图链接
异步请求
Servlet 3.0的异步请求处理,SpringMVC有三种集成:
-
- DeferredResult / Callable
- stream (SSE, raw data)
- reactive 类型
DeferredResult
Callable
Processing
Spring处理异步请求的大致流程是:
- ServeletRequest.startAsync()被调用以开启异步模式。Servlet线程可以退出,但是干活的线程还在
- request.startAsync()返回AsyncContext,以供后续调用
- ServletRequest提供了当前DispatcherType的引用,以供后续调用
DeferredResult处理流程:
- controller返回DeferredResult,并保存到内存队列/列表里。
- Spring MVC 调用 request.startAsync()
- DispatcherServlet退出
- 从干活的线程获取DeferredResult,Spring MVC把请求返回Servlet容器
- 再次调用DispatcherServlet,继续用返回值异步处理请求
错误处理
DeferredResult调用 setResult 放返回值,调用 setErrorResult放异常对象。可以用@ExceptionHandler捕获。
如果走Callable,直接抛出来就行。
Interception
相比postHanle和afterCompletion,异步的场景,需要使用AsyncHandlerInterceptor类型的HandlerInterceptor,定义afterConcurrentHandlingStarted。还有更细化的接口CallableProcessingInterceptor和DeferredResultProcessingInterceptor。
DeferredResult提供了两个回调onTimeout(Runnable)和onCompletion(Runnable)。
没找到其他地方加,似乎只能这样注册。
上一个截图里的就是乱来哈哈哈哈,只设置了一个其他的也会受影响(全局的),但是这个请求来之前又没有,会搞出很多奇奇怪怪的很难排查的场景。读到后面,找到了真正配置的地方:WebMvcConfigurer.configureAsyncSupport。
实在忍不住要吐槽spring-web的官方文档,水平也比IOC部分的差太多太多了!!!总是写的不清不楚的,代码示例也不够全。Technical Writing里甚至出现了有歧义的被动语态,也是醉醉的。
c.f. WebFlux
Servlet最初的设计是一次性把执行链上的动作都做完然后返回。异步请求是Servlet3.0新增的,允许应用先走完Servlet执行链上的动作,但是不关闭response。也就是说干活的线程还在,但是Servlet容器线程先让出去(以免其他请求进不来)。等处理完成之后,执行异步dispatch(到原url)。
Spring WebFlux就完全摒弃了Servlet api。它的整个设计哲学就完全是基于异步的。
Spring MVC和Spring WebFlux都支持异步和Reactive返回值类型。Spring MVC甚至支持streaming(包括reactive back pressure)。但是Spring MVC写到response的过程仍然是阻塞的,并且使用了新的线程。而WebFlux是基于non-blocking I/O,也不需要额外线程。
HTTP Streaming
- ResponseBodyEmitter
如果要产生多个异步结果并写入response,可以使用ResponseBodyEmitter产生对象流。
试了一下是最后一笔返回的,所以这算啥streaming???有啥意义???
- SSE
SseEmitter是ResponseBodyEmitter的子类,是根据W3C SSE标准写的。
这个用浏览器试了下就是Streaming的效果:
StreamingResponseBody:
Reactive Types
Spring MVC支持在controller中使用reactive客户端的库,支持reactive类型的返回值。
reactive返回值的处理方式是这样的:
-
- Mono(Reactor),Single (RxJava) -- 处理方式类似于DeferredResult
- Flux(Reactor),Observable (RxJava),Flux,Observable -- 处理方式类似于ResponseBodyEmitter和SseEmitter
- 其他的类似于DeferredResult<List<?>>
Spring对reactive类型的支持,在spring-core包里的ReactiveAdapterRegistry。
实际上写回response仍然是阻塞的,在单独的线程(非servlet容器线程)里执行的。可以是配置好的TaskExecutor。默认用的是SimpleAsyncTaskExecutor。
Disconnects
Servlet API并没有考虑客户端不可达的场景,并没有心跳包的机制。如果需要的话,还是考虑WebSocket技术栈,使用STOMP或者SockJS,都有自带的心跳包机制。
异步的配置
要支持异步请求,需要在容器级别开启配置。
Servlet容器
单用SpringMVC的话需要手工打开容器的异步支持。有两步,一个是DispatcherServlet 的asyncSupported设置成true,一个是filter要开启支持 javax.servlet.DispatchType.ASYNC类型。
springboot已经帮我们打开了容器的异步支持(这个是filter的,dispatcherServlet的一下没找着,等需要了再说):
web.xml:
<async-supported>true</async-supported>
<dispatcher>ASYNC</dispatcher>
Spring MVC
MVC已经提供了开启容器异步支持的配置。
代码开启:
WebMvcConfigurer.configureAsyncSupport
xml:
可以配置的有:
-
- AyncTaskExecutor
- 超时时间
- DeferredResultProcessingInterceptor和CallableProcessingInterceptor
另外,如果是 DeferredResult, ResponseBodyEmitter, 或者SseEmitter,可以直接在它们上面设置针对单个接口的超时时间的。
跨域资源共享(CORS)
Cross Origin Resource Sharing, 跨域资源共享,aka 访问控制 aka cors。更详细的解释可以看参考资料里的MDN链接。spring-web给出的w3c规范链接,也可以参考。
为了安全,浏览器会禁止同一域外的资源访问。比如在一个浏览器tab页里有你银行账号的信息,另一个tab页打开了evil.com,另一个tab页不能使用你的凭据去访问银行的api。
Cross-Origin Resource Sharing (CORS)是一个 W3C规范,被几乎所有浏览器所支持。CORS支持定义哪些跨域请求是可以被授权的 。其他还有两个方案,iframe和jsonp。spring官方文档是把它们称作安全性差一点 ,能力也弱一点。
请求分成三种: preflight(预检请求), simple(简单请求),和actual requests(实际请求)。
Spring MVC HandlerMapping内置了对CORS的支持。拿到请求后,HandlerMapping会检查请求相关的CORS配置,再执行相关动作。预检请求是直接处理的。而简单请求和实际请求,会 执行拦截, 校验,并设置响应头的cors header。
要开启支持跨域请求(请求有Origin请求头,但是和主机地址不一致),需要声明CORS配置。如果没有CORS配置,会直接拒绝预检请求。响应头不会加上CORS,后续的请求都会被浏览器直接拒绝。
- global
AbstractHandlerMapping.setCorsConfigurations -- CorsConfiguration
- local
handler级 @CrossOrigin
两者的合并策略不是覆盖,是合并。allowCredentials,maxAge这俩取local的。
处理逻辑在这仨类上:
-
- CorsConfiguration
- CorsProcessor, DefaultCorsProcessor
- AbstractHandlerMapping
@CrossOrigin
可以方法级,也可以controller类上。
默认@CrossOrigin允许
-
- 所有的origins
- 所有的headers.
- 所有的HTTP方法
allowCredentials默认为false(为true的话会保留用户信息如 cookies 、CSRF tokens)。如果手工改成了true,需要设置allowOrigins或者allowOriginPatterns,并且不能为"*"。
maxAge默认30分钟
全局配置
默认全局配置支持:
-
- 所有的origins
- 所有的headers.
- HTTP方法GET,HEAD, POST
allowCredentials- false
maxAge - 30分钟
Java配置
XML配置
CORS Filter
也可以使用内嵌的CorsFilter来获取CORS支持。
注意Spring Security有内嵌的cors支持。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:63343"));
configuration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/cors2/allow/**", configuration);
return source;
}
@Bean
public FilterRegistrationBean corsFilter() {
FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(corsConfigurationSource()));
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
}
Web Security
这一部分Spring官方文档写的就一个意思,要用他们的Spring Security吖~~真的不选Spring Security,建议用github.com/hdiv/hdiv(可能因为我git上下的文档有点老了,没有维护,它给的链接打不开,我自己找了HDIV的git地址贴上来了。)
但是事实上如果不用Spring Security,也是选的shiro多?
HTTP缓存
HTTP缓存(HTTP Caching)基本上是围绕着响应头Cache-Control和后续的请求头Last-Modified和ETag来解析的。缓存可以是公开的,比如代理里,也可以是非公开的,比如浏览器缓存。Cache-Control定义怎样缓存和重复使用返回消息。ETag请求,在响应没有更改的情况下,会收到不带响应体的,304响应。Last-Modified只考虑请求时间。
CacheControl
相关规范是RFC 7234(datatracker.ietf.org/doc/html/rf…)。
org.springframework.http.CacheControl提供了对Cahce-Control请求头的设置,并且作为参数用在这些地方:
-
- WebContentInterceptor
- WebContentGenerator
- Controllers
- Static Resources
Controllers
静态资源
建议上HTTP缓存。
Etag Filter
ShallowEtagHeaderFilter -- 计算ETag的时候只考虑header不考虑响应体(节省CPU)。
视图技术
现在一般前后端分离了。低代码生成配置使用了模板引擎Velocity,深感前辈们的不容易~~~
PDF and Excel
Spring推荐使用Apache POI生成Excel,使用OpenPDF生成PDF。
MVC配置
使用@EnableWebMvc开启MVC配置。会把Spring MVC的基建beans注册上。SpringBoot项目似乎不需要。
可以实现接口WebMvcConfigurer来做个性化设置。
XML的话,mvc:annotation-driven/下做设置。
类型转换
默认支持各种数字和数据类型的格式化,也提供了字段级的@NumberFormat和@DateTimeFormat注解。
要注册自定义formatter和converter:
xml这样的
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc
https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:annotation-driven conversion-service="conversionService"/>
<bean id="conversionService"
class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
<property name="converters">
<set>
<bean class="org.example.MyConverter"/>
</set>
</property>
<property name="formatters">
<set>
<bean class="org.example.MyFormatter"/>
<bean class="org.example.MyAnnotationFormatterFactory"/>
</set>
</property>
<property name="formatterRegistrars">
<set>
<bean class="org.example.MyFormatterRegistrar"/>
</set>
</property>
</bean>
</beans>
与@InitBinder方式添加的区别是,这种方式是全局的,@InitBinder可以做到比较细颗粒度的。
校验
一般来说校验都是通过加 ****@Valid @Validated。也可以设置全局的Validator实例(LocalValidatorFactoryBean)。
也可以通过@InitBinder方法注入Validator
拦截器
Content类型
content type是根据 请求头Accept 来确定的。也可以使用URL path extension, query parameter等。content type解析也可以个性化配置:
消息转换器
静态资源
EncodedResourceResolver 打gzip包
默认Servlet
DispatcherServlet -- /
DefaultServletHttpRequestHandler 缺省/**
路径匹配
高级Java配置
WebMvcConfigurer提供的是基础的配置,需要更细的配置,可以使用DelegatingWebMvcConfiguration。
高级XML配置
不支持,需要workaround。
HTTP/2
按理Servlet4一个支持HTTP/2,Spring Framework 5 支持Servlet4。代码层面没有任何区别,都是服务器配置。参考github.com/spring-proj…。
底层用的是javax.servlet.http.PushBuilder(@RequestMapping方法的入参之一),所以也可以手工强制使用。
REST客户端
- RestTemplate
异步客户端。已经在维护模式了,建议使用WebClient。
- WebClient
非阻塞的reactive客户端. 5.0版本时引入的,用以取代RestTemplate.
细节在Reactive技术栈的官方文档里。
WebSockets
WebSocket协议在RFC 6455规范里。一个TCP连接,进行双向通信。
全双工(Full Duplex)是通讯传输的一个术语。 通信允许数据在两个方向上同时传输,它在能力上相当于两个单工通信方式的结合
WebSocket是基于HTTP协议的,然后利用HTTP Upgrade header来转变成WebSocket。
服务器不是返回200,而是返回类似下面的:
握手成功之后, HTTP upgrade request 底层的TCP socket会一直保持开着,好让客户端和服务端继续发送和接收请求。
WebSocket只会有一个URL地址(最初建立连接的)。和HTTP不同,WebSocket是一个底层协议。WebSocket客户端和服务端可以协商使用更高层的协议(使用Sec-WebSocket-Protocol header)。比如STOMP。
WebSocket API
WebSocketHandler
创建WebSocket服务器:
-
- 实现WebSocketHandler
- 继承TextWebSocketHandler
- 继承BinaryWebSocketHandler
配置WebSocket的URL,是通过实现WebSocketConfig
Spring Websocket其实并不依赖SpringMVC。有WebSocketHttpRequestHandler都和,用WebSocketHandler和其他HTTP环境集成,会更容易一点。无论是直接使用WebSocketHandler还是使用如STOMP来间接使用,都要记住底层标准的WebSocket会话是不支持并发发送的(JSR-356)。可以选择使用ConcurrentWebSocketSessionDecorator来包装WebSocketSession。
WebSocket Handshake
WebSocketHandlerDecorator可以用来包装WebSocketHandler,增加一些额外动作。默认会加上错误捕获的,如果WebSocketHandler抛错了,会被ExceptionWebSocketHandlerDecorator捕获,断开连接,状态码是1011。
Deployment
Spring WebSocket API可以集成到SpringMVC,使用DispatcherServlet同时处理HTTP请求和HTTP的Websocket握手。如果想集成到其他的HTTP处理场景,可以调用WebSocketHttpRequestHandler。按 JSR-356来就可以了。
JSR-356(Java WebSocket API)提供了两种部署机制。一是在Servlet容器启动时扫类路径。另一个是在Servlet容器初始化的时候使用注册API。这个机制导致WebSocket没有办法像MVC一样有个DispatcherServlet做门面。
不过Spring通过接口RequestUpgradeStrategy和它的实现类(针对容器实现的,Tomcat, Jetty, GlassFish, WebLogic, WebSphere, and Undertow (and WildFly).),解决了这个问题。
支持JSR-356的Servlet容器,在启动时,都是要执行ServletContainerInitializer (SCI) 扫描的,这个扫描有可能会影响启动速度。可以使用web.xml的元素,启动和关闭部分网络组成(即可以关闭SCI扫描)。
Server Configuration
容器的底层WebSocket引擎都会暴露一下配置属性,比如消息缓冲区大小,超时时间等等。
服务端可以使用ServletServerContainerFactoryBean配置Tomcat, WildFly, 和 GlassFish的WebSocket。客户端可以使用WebSocketContainerFactoryBean(XML),ContainerProvider.getWebSocketContainer()(代码)。
针对Jetty容器,需要提供一个Jetty的WebSocketServerFactory,然后加入DefaultHandshakeHandler。
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(
echoWebSocketHandler(), "/echo"
).setHandshakeHandler(handshakeHandler());
}
@Bean
public DefaultHandshakeHandler handshakeHandler() {
WebSocketPolicy policy = new WebSocketPolicy(WebSocketBehavior.SERVER);
policy.setInputBufferSize(8192);
policy.setIdleTimeout(600000);
return new DefaultHandshakeHandler(
new JettyRequestUpgradeStrategy(new WebSocketServerFactory(policy)));
}
}
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:websocket="http://www.springframework.org/schema/websocket"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/websocket
https://www.springframework.org/schema/websocket/spring-websocket.xsd">
<websocket:handlers>
<websocket:mapping path="/echo" handler="echoHandler"/>
<websocket:handshake-handler ref="handshakeHandler"/>
</websocket:handlers>
<bean id="handshakeHandler" class="org.springframework...DefaultHandshakeHandler">
<constructor-arg ref="upgradeStrategy"/>
</bean>
<bean id="upgradeStrategy" class="org.springframework...JettyRequestUpgradeStrategy">
<constructor-arg ref="serverFactory"/>
</bean>
<bean id="serverFactory" class="org.eclipse.jetty...WebSocketServerFactory">
<constructor-arg>
<bean class="org.eclipse.jetty...WebSocketPolicy">
<constructor-arg value="SERVER"/>
<property name="inputBufferSize" value="8092"/>
<property name="idleTimeout" value="600000"/>
</bean>
</constructor-arg>
</bean>
</beans>
Allowed Origins
从Spring4.1.5开始,WebSocket和SockJS只接受同域的请求。当然也可以允许通过其他域的请求。这个当然是针对浏览器客户端的。其他客户端也拦不住。(datatracker.ietf.org/doc/html/rf…)
有三种模式:
-
- 仅限同域请求 -- 如果SockJS开启的话,响应头X-Frame-Options是SAMEORIGIN, JSONP 传输会被关闭(因为它不支持检查origin)
- 允许特定几个域的请求 -- 一个域的格式要以http://或者https://开头。如果SockJS开启的话,禁止iframe传输
- 允许所有请求 -- 允许的origin设置为*,所有传输都被允许。
SockJS Fallback
在公网,不是所有的客户端,都受我们控制的,可能有的不支持Upgrade请求头,或者会提前把websocket连接给官了。针对这些场景,Spring会尝试使用WebSocket,不行的话,就使用保底的HTTP技术SockJS,来模拟一个WebScoket。对开发来说API都是一样的。
针对SockJS协议,Spring的Servlet技术栈,提供了服务端和客户端的支持。
Overview
SockJS协议的目标是让应用可以用WebSocket的API,但是如果WebSocket不支持,可以退而求其次使用非WebSocket方案。
SockJS包括
-
- SockJS协议
- 用JavaScript写的客户端--浏览器使用
- SockJS服务端实现 -- spring-websocket里也集成了一个
- 用Java写的SockJS客户端 -- spring-websocket(版本4.1开始)里有一个
github.com/sockjs/sock…是一个JavaScript写的针对各种浏览器型号的库,它支持三种传输
-
- WebSocket
- HTTP Streaming
- HTTP长轮询(Long Polling)
SockJS客户端会先发送一个GET /info请求给服务端,然后决定使用哪种传输方式。优先级从高到低就是WebSocket、HTTP Streaming,实在没办法采用长轮询。所有请求的URL结构都是类似这样的:
https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
-
- {server-id} -- 集群路由能用到
- {session-id} -- 会话信息
- {transport} -- 传输类型。 websocket, xhr-streaming, 及其他
WebSocket传输只需要一次HTTP请求建立WebSocket握手,后续消息都在同一个socket上传输。
HTTP传输 分两种情况,Ajax/XHR streaming,服务端到客户端的消息是在一个socket上一直进行,而客户端到服务端的消息会涉及多次HTTP POST请求。长轮询每次在服务端返回客户端后,都会关闭当前连接。
SockJS的消息是彻底的精简了的。连接建立的时候,服务器会给客户端发送字母o(代表open),如果25秒(默认值)没有消息传输,会发送字母h(代表heartbeat),结束会话发送c(代表close)。SockJS的消息是以Json数组的格式传输的,如a['messag1','2']。其他的会报错。源码在这里:
要调试查看详细日志的话,把org.springframework.web.socket日志级别设置为TRACE即可。
开启SockJS
这个是Spring MVC场景。如果其他环境,使用SockJsHttpRequestHandler即可。
Heartbeats
默认如果25秒(IETF规范推荐的)没有传输,会发送心跳信息。这个时间可以使用heartbeatTime配置。tools.ietf.org/html/rfc620…
如果在WebSocket和SockJS上使用了STOMP,那心跳是由客户端和服务端协商出来的,SockJS心跳会被关闭。
Spring也支持配置TaskScheduler来规划心跳任务。任务是由线程池执行的,线程池配置是根据可用内核书决定的。
.withSockJS().setTaskScheduler(TaskScheduler对象)
Client Disconnects
HTTP streaming和HTTP长轮询都会需要让连接保持非常长的时间。
在Servlet容器里,是通过Servlet3的异步支持实现的。Servlet容器线程退出,另一个线程做业务,并写回响应。
ServletAPI在客户端断开之后,服务端是不知道的,容器只能在写响应的时候抛个错。由于SockJS默认25秒一个心跳,所以基本上都能在这个时间内发现客户端断了。
这种报错比较长,Spring尽量精简了报错日志DISCONNECTED_CLIENT_LOG_CATEGORY ( AbstractSockJsSession)。如果想看详细对着,可以把这个日志级别设为trace。
SockJS and CORS
如果允许跨域请求,SockJS协议使用CORS来支持跨域的 XHR streaming和长轮询传输。会自动加上CORS响应头,除非响应上已经有了相关响应头。也就说,如果应用已经提供了跨域支持(比如Servlet Filter),SockJS的CORS会被跳过的(SockJsService)。
也可以提供设置 SockJsService的suppressCors属性来阻止这些CORS响应头。
-
- Access-Control-Allow-Origin
- ccess-Control-Allow-Credentials--true
- Access-Control-Request-Headers
- Access-Control-Allow-Methods
- Access-Control-Max-Age -- 31536000 (1年)
源码方法在AbstractSockJsService.addCorsHeaders
emm……然鹅我并没有在源码里搜到addCorsHeaders方法。然后找到了判断跨域是在OriginHandshakeInterceptor.beforeHandshake里。调用了Spring-web的WebUtil.isSameOrigin方法。
咱们后端的跨域判断会比浏览器简单很多哈,只比较协议,ip和端口号即可。
传输类型枚举是TransportType。
Java客户端SockJsClient
提供测试用的。
SockJS Java客户端支持三种传输:websocket, xhr-streaming, xhr-polling。其他的只在浏览器场景有意义。有三种方式实现:
-
- StandardWebSocketClient
- JettyWebSocketClient
- WebSocketClient.
XhrTransport支持xhr-streaming和 xhr-polling。现在有两个实现类:
- RestTemplateXhrTransport
- JettyXhrTransport
示例代码:
List<Transport> transports = new ArrayList<>(2);
transports.add(new WebSocketTransport(new StandardWebSocketClient()));
transports.add(new RestTemplateXhrTransport());
SockJsClient sockJsClient = new SockJsClient(transports);
sockJsClient.doHandshake(new MyWebSocketHandler(), "ws://example.com:8080/sockjs");
文档还介绍了测试的话要怎么模拟多个用户,给的是Jetty容器的例子。Standard好像缺一个方法。需要的时候再研究了。
还给了个服务端配置demo:
STOMP
websocket支持两种消息,text和binary。(其实还有第三种--心跳消息^_^)。但是具体更细的就没有限制了。
stomp.github.io/stomp-speci…(Simple Text Oriented Messaging Protocol) 最初是为脚本语言(Ruby,Python,Perl)设计的,用来连接消息中间件的。
客户端可以使用命令SEND或者SUBSCRIBE来发送或者订阅消息, destination头标识消息类别和接受者。
在Spring的STOMP支持里,Spring WebSocket应用相当于客户端的 STOMP broker,消息会被路由到@Controller方法,或者路由到一个内存中的broker。内存broker会记录订阅信息,并把消息广播给订阅者。Spring也可以使用 STOMP broker(e.g. RabbitMQ, ActiveMQ)广播消息。
消息示例:
客户端订阅(服务端SimpMessagingTemplate发送)
客户端信息(服务端@MessageMapping获取)
destination的格式特意没有限制。不过有约定俗成/topic/..是一对多,/queue/是一对一。
STOMP服务器可以使用命令MESSAGE广播消息。
协议的详情请看这个链接:stomp.github.io/stomp-speci…
Benefits
STOMP之于WebScoket,相当于HTTP之于TCP,相当于SpringMVC之于Servlet API。
Spring推荐STOMP的原因:
- 不需要自己再发明一个消息协议和格式啦
- STOMP客户端都是现成的
- 如果需要,可以方便的对接消息队列
- 业务逻辑可以组织到@Controller实例里,根据STOMP的destination头路由分发
- 可以使用Spring Security做到destination颗粒度的安全限制
Enable STOMP
STOMP的支持在两个模块:spring-messaging和spring-websocket。
Flow of Messages
spring-messaging模块最早是在spring-integration里的,后面被单独拿出来,处理通用的消息场景。最重要的类有:
-
- Message -- 消息,包括header和payload
- MessageHandler -- 怎样处理消息
- MessageChannel -- 消息怎样流转
- SubscribableChannel -- 带有MessageHandler订阅者的MessageChannel
- ExecutorSubscribableChannel -- 使用Executor传递消息的SubscribableChannel
@EnableWebSocketMessageBroker或者websocket:message-broker都会开启上面说的组件。
这张图片里面有三个MessageChannel:
-
- clientInboundChannel -- 从客户端接收消息
- clientOutboundChannel -- 发送消息到客户端
- brokerChannel -- 应用内部消息的传递
如果要加上外部的消息队列,消息流转示意图是这样的:
和没有MQ场景的区别在于: 后者使用了代理中继器(broker relay)把消息通过TCP 传到了外部,当然把响应返回客户端也由代理中继器负责。
接收到消息的时候,消息会被解析成STOMP frames,再转为Spring的Message对象,接着送到clientInboundChannel。
@Controller的Bean负责处理STOMP消息,可能会通过brokerChannel传到一个中继器,中继器通过clientOutboundChannel广播给订阅者。
Controller注解
前提条件是加了@Controller,才会被扫描到。
@MessageMapping
表示能处理啥请求。可以放在类上也可以放方法上(注意, 测了下,如果某个方法和类的路径是一致的,也得加上@MessageMapping注解,里面就放“/”)。
路径可以是Ant-style path patterns,比如/thing*, /thing/**,也支持模板变量,如/thing/{id}。可以通过方法入参加注解@DestinationVariable获取。分隔符也可以使用点号。
1. 方法入参支持:
-
- Message
- MessageHeaders
- MessageHeaderAccessor,SimpMessageHeaderAccessor, and StompHeaderAccessor
- @Payload
- @Header
- @Headers
- @DestinationVariable
- java.security.Principal
2. 返回请求
@SendTo可以使用@SendToUser将返回消息发送回客户端。@SendTo指定返回路径,@SendToUser会把消息只返回请求的用户。也可以放在类上,就是类里所有方法的默认行为。方法上的注解会覆盖类上的。
也支持异步处理,只要返回ListenableFuture, CompletableFuture, 或者 CompletionStage即可。
注意@SendTo, @SendToUser是基于SimpMessagingTemplate的语法糖。面对更复杂的场景,可以直接使用SimpMessagingTemplate返回消息。
@SubscribeMapping
只能接收订阅消息。@MessageMapping支持的入参它也支持。返回值不会经过broker就直接返回给客户端。
不能加@SendTo或者@SendToUser,加了的话,就和@MessageMapping一样又会经过broker了。
消息流转就是,客户端过来的时候,会分两条线,一条broker,一条@SubscribeMapping,不保证先后顺序。
如果想要在订阅的时候加些动作,客户端可以这样做:
@Autowired
private TaskScheduler messageBrokerTaskScheduler;
// During initialization..
stompClient.setTaskScheduler(this.messageBrokerTaskScheduler);
// When subscribing..
StompHeaders headers = new StompHeaders();
headers.setDestination("/topic/...");
headers.setReceipt("r1");
FrameHandler handler = ...;
stompSession.subscribe(headers, handler).addReceiptTask(() -> {
// Subscription ready...
});
服务端可以在brokerChannel上注册ExecutorChannelInterceptor,提供方法。
@MessageExceptionHandler
放@Controller或者@ControllerAdvice bean里。
Sending Messages
可以使用SimpMessagingTemplate手工发送消息。如果用名字绑定的话,名字是brokerMessagingTemplate。
Simple Broker
内置的broker处理客户端订阅请求,保存到内存里,在有匹配路径消息的时候,把消息广播出去。路径可以是 Ant-style的。
可以这样配置:
External Broker
内置的Simple Broker不支持acks, receipts等消息类型,所以不适合集群场景。可以加上其他broker relay,然后这样开启:
相当于使用MessageHandler把请求转给了外部的broker。这样的话,需要创建一个TCP链接到外部broker。
这里我没有去试,不过我看spring写了需要加依赖io.projectreactor.netty:reactor-netty和io.netty:netty-all,所以可能更适合reactive技术栈?
连接Broker
每个STOMP broker relay会维护一个和broker的TCP连接(我理解如果是内存简单broker的话是没有的,应该是使用MQ的时候有)。这个连接是允许设置STOMP 用户名密码的(STOMP frame login和passcode headers)。可以xml设置也可以代码设置,字段分别是systemLogin和systemPasscode,默认值都是guest。
STOMP broker relay和MQ传输心跳消息也是使用的这个TCP连接,默认每10s一个,可配置。如果连接丢失,每5s尝试一次重连,直到连接上。
STOMP broker relay还会给每个连接着的客户端创建一个TCP连接。这些连接使用的是同一套用户名密码来鉴权的,也可以配置:clientLogin/clientPasscode,默认值也都是guest。
实现了ApplicationListener的bean可以接收到MQ失连和重连的消息。
每次重连正常是连接同一个ip端口。如果想每次不一样,可以提供addressSupplier:
如果云上环境,可以使用virtualHost,具体的解释没太看懂。需要了再研究啦。
Dots as Separators
用“/”当分隔符是网络场景的约定俗成,但是在消息系统里,用点号当成分隔符也挺多的。如果要用点号替换斜杠,可以这样配:
然后就可以这样使用了:
这个例子/app/是因为它的场景是使用MQ做broker,如果是内存broker,也是可以换成点号的。
Authentication
如果走的是WebSocket,那第一个请求结束一个HTTP的get请求(WebSocket握手),然后upgrade为WebSocket。如果浏览器不支持WebSocket,那HTTP Streaming实际也是HTTP请求。保底方案长轮询,就是一系列的HTTP请求了。
针对HTTP请求的鉴权,大部分WEB应用都已经有现成的轮子了,比如Spring Security。针对使用Cookie的session技术栈,用户提前登录了的话,每个HTTP上应该都有cookie,服务端应该有session信息,所以登录信息可以使用HttpServletRequest#getUserPrincipal()直接获取。Spring会自动把用户和WebScoket会话关联。
STOMP协议的CONNECT frame的 login 和 passcode header,是为STOMP over TCP设计的,和STOMP over websocket没关系,所以Spring会忽略这俩header。
Token Authentication
Spring 推荐了用Spring Security OAuth。
datatracker.ietf.org/doc/html/rf…规范没有对WebSocket的握手鉴权做任何预设,允许自行随意发挥。不过实操中嘛,浏览器客户端只使用标准鉴权头。SockJS JavaScript客户端也不影响发送HTTP请求头,但是允许把token作为查询参数(?token=123sssw)。也不像回事情
所以可以使用STOMP协议层的header来鉴权。方法是:
- 在连接时,STOMP客户端传鉴权header
- 使用ChannelInterceptor来做鉴权
注意要让他优先与Spring-Security的拦截器生效,所以需要WebSocketMessageBrokerConfigurer 加order,文档给了举例是@Order(Ordered.HIGHEST_PRECEDENCE + 99)。
Authorization
Spring Security提供了使用ChannelInterceptor的websocket-authorization。Spring Session也提供了WebSocket的集成。
指定发送到某个用户
SpringSTOMP可以识别以 /user/开头的目的地,比如说一个客户端订阅了/user/queue/position-updates,UserDestinationMessageHandler会处理把它转成针对这个用户的一个地址,如/queue/position-updates-user123。
针对发送端,路径可以定义成/user/{username}/queue/position-updates,UserDestinationMessageHandler会把它转成更细的地址。试了挺久都没成的,即使成功了,再后续写代码的过程中,我应该也还是会选择语义更清晰的@SendToUser或者convertAndSendToUser。
也可以使用@SendToUser注解。
针对一个用户有多个会话,默认是每个会话都会发,如果想只发一个(发送请求的那个),那把broadcast设为false。
消息顺序
broker把消息发布到clientOutboundChannel,clientOutboundChannel把消息写进WebSocket会话。clientOutboundChannel底层是有一个ThreadPoolExecutor的,所以客户端收到消息的顺序,并不一定和发布顺序一致。
如果想要按顺序来的话,那可以设置setPreservePublishOrder
设置了这个之后,消息会一条条发给clientOutboundChannel,稍微有点性能小代价。
事件
这ApplicationContext事件是可以使用Spring的接口ApplicationListener接收的:
-
- BrokerAvailabilityEvent -- broker可连或不可连接都会发送一个事件。简单broker其实就启动的时候连接一下,但是如果连的MQ,那网络一个抖动,可能就是一个断连一个复连(意会一下,我自己瞎编的词儿)。SimpMessagingTemplate需要订阅这个事件,以免在broker不可连接的时候发送消息。不过不管咋样,都得能处理好MessageDeliveryException异常。
- SessionConnectEvent -- 每次有客户端连上来事件包括了连接信息,比如会话ID,用户信息,以及其他自定义的header。可以使用 SimpMessageHeaderAccessor或者 StompMessageHeaderAccessor包装消息。
- SessionConnectedEvent -- 一般紧跟SessionConnectEvent之后,表示连上了
- SessionSubscribeEvent -- 接收到订阅事件
- SessionUnsubscribeEvent -- 接收到取消订阅事件
- SessionDisconnectEvent -- 会话结束,一个会话可以有多个SessionDisconnectEvent,所以处理的时候要考虑幂等性。
package com.jeanne.lowcode.websocket.stomp.eventlistners;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.broker.BrokerAvailabilityEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.*;
/**
* @author Jeanne 2023/6/29
**/
@Component
@Slf4j
public class StompEventListener {
@EventListener(BrokerAvailabilityEvent.class)
public void listenToBrokerAvailabilityEvent(BrokerAvailabilityEvent event) {
log.info("BrokerAvailabilityEvent,{}", event);
}
@EventListener(SessionConnectEvent.class)
public void listenToSessionConnectEvent(SessionConnectEvent event) {
log.info("SessionConnectEvent,{}", event);
}
@EventListener(SessionConnectedEvent.class)
public void listenToSessionConnectedEvent(SessionConnectedEvent event) {
log.info("SessionConnectedEvent,{}", event);
}
@EventListener(SessionSubscribeEvent.class)
public void listenToSessionSubscribeEvent(SessionSubscribeEvent event) {
log.info("SessionSubscribeEvent,{}", event);
}
@EventListener(SessionUnsubscribeEvent.class)
public void listenToSessionUnsubscribeEvent(SessionUnsubscribeEvent event) {
log.info("SessionUnsubscribeEvent,{}", event);
}
@EventListener(SessionDisconnectEvent.class)
public void listenToSessionDisconnectEvent(SessionDisconnectEvent event) {
log.info("SessionDisconnectEvent,{}", event);
}
}
拦截
ExecutorChannelInterceptor 每个消息的每个MessageHandler的每个方法都执行一次
ChannelInterceptor 每个消息每个方法就执行一次
STOMP Client
Spring提供了一个客户端
WebSocketClient webSocketClient = new StandardWebSocketClient();
WebSocketStompClient stompClient = new WebSocketStompClient(webSocketClient);
stompClient.setMessageConverter(new StringMessageConverter());
stompClient.setTaskScheduler(taskScheduler); // for heartbeats
WebSocketClient 也可以替换成SockJsClient。区别是后者可以保底用http。
模拟会话:
String url = "ws://127.0.0.1:8080/endpoint";
StompSessionHandler sessionHandler = new MyStompSessionHandler();
stompClient.connect(url, sessionHandler);
还有一些,我想着反正常用场景还是web端当客户端,所以就不研究了先,等需要了再说。Web客户端我都模拟得比较彻底了。在git上。
WebSocket Scope
性能
没有银弹,要考虑消息大小频率,是否需要阻塞等。
消息系统里,消息都是异步处理的,底层都会有个线程池。默认线程池大小是可用cpu*2。
对于ClientInboundChannel,如果是偏CPU消耗型的业务,可以把核心线程数设置到和cpu数量一致。如果是偏IO消耗型,或者需要等待数据库或者其他的返回,可以把核心线程数调高一点。
对于ClientOutboundChannel,就只涉及发送消息了,如果客户端的网络挺快的,核心线程数可以设置成和cpu数量一致,如果网络慢,那就得调大点了。但是事实上,客户端网络快慢,也没法控制和预测……所以还增加了俩参数
-
- sendTimeLimit -- 时间限制
- BufferSizeLimit -- 体积限制
关于消息的大小,容器层也是有限制的:Tomcat 8k,Jetty64K。所以客户端有可能把一条消息拆成多条STOMP消息(16k),需要服务端缓存和重新组装回原始消息。这个功能Spring已经提供了。
最后一点是扩容。简单broker是不支持的,要扩容就必须上MQ了。
Monitoring
只要有@EnableWebSocketMessageBroker或者websocket:message-broker,关键的基础设置component,就会自动收集数据和计数器。WebSocketMessageBrokerStats也会注册,每30分钟写一条info级别的日志,信息有:
官网倒是很详细解释了一圈。但是我感觉这个挺好懂的,没必要再记了。
Testing
反正我是写了个html相当于做e2e了。
官网说是提供了一个单元测试的例子。但是我已经对他没啥信心了。例子的地址是:github.com/rstoyanchev…
其他
Web端即时通讯技术
- 后端普通rest接口,前端手搓代码定时短轮询。最早的时候用过这种方式,很努力了,前端代码仍然没啥可读性。后面其他项目需要复用页面,要稍微改个东西,原理告诉他们了,但是他们改的时候花的工时,比我预估的多挺多的。(那个时候我可没有技术选型的权力,轻拍)
- WebSocket,一次只能有一个连接,要约定好消息格式才好确定哪条消息走哪条逻辑,如果一方偷偷变了,也不是很好排查。不过它可以双向通信,功能也比较丰富(比如内置了心跳机制)。
读到后面,又回顾了部门之前的WebSocket后端代码,发现是我想错了。有了STOMP,我之前认为的痛点(路由)其实已经被解决了,只是之前团队过于随意的技术选型(选了原生WebSocket而不选STOMP),才让我有了错误认知。所以还是要沉下心来学习的。
- SSE 现在看着是可以根据请求路径来的,但是只能单向(服务器到浏览器),如果只是需要实时展示,sse是我心里觉得比较理想的,但是因为没有实战过,不好确定细节方面。体积似乎sse会大一些(传文本,websocket二进制,不传图片音频没啥子区别)。前面是刚看到SSE的时候的想法。读到后面觉得SSE的功能还是稍微单薄了些。对WebSocket好好根据业务封装一下用着吧~
- 似乎还有个Comet,看着像work around。规范级的还是WebSocket和SSE。
Spring给了一个链接spring.io/blog/2012/0…,后面可以仔细看下。
注意一下WebSocket STOMP前端的轮子,现在随便搜可能搜到的是stompjs,但是它已经不维护了,取而代之的是github.com/JSteunou/we…,目的是为了支持ES6语法(ES6香啊~~)。
模拟复现跨域
因为CORS功能是由浏览器提供的,所以要用postman复现,会涉及到要写复杂的配置和脚本。所以用HTML+AXIOS+Jetbrain Idea来创建了场景。
前端html如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CORS Test</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const getRest = function (url) {
axios.get(url)
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
})
.then(function () {
});
};
getRest('http://localhost:8080/cors');
getRest('http://localhost:8080/cors/annotated');
getRest('http://localhost:8080/cors/annotated1');
</script>
</body>
</html>
直接双击打开的话origin是null,所以需要用idea打开(会启服务)
网络编程中的门面模式
Spring官方文档在webocket部分提起了,websocket的规范限制了它不能像MVC一样有个“front controller”(即DispatcherServlet)。DispacherServlet就相当于一个门面,下面是ChatGPT的相关解释。
In web development, a "front controller" is a design pattern that provides a centralized entry point for handling requests. In other words, it's a component that receives all incoming requests and then delegates them to the appropriate handler for processing.
Spring-Web的技术写作还是很有提升空间的
This is a significant limitation of JSR-356 that Spring’s WebSocket support addresses with server-specific RequestUpgradeStrategy implementations even when running in a JSR-356 runtime. Such strategies currently exist for Tomcat, Jetty, GlassFish, WebLogic, WebSphere, and Undertow (and WildFly).
这段如我是我,会写成:
This is a significant limitation of JSR-356. Spring's WebSocket Support addresses this limitation by using server-specific RequestUpgradeStrategy implementations. The RequestUpgradeStrategy iterface has implemantations for Tomcat, Jetty, GlassFish, WebLogic, WebSphere, and Undertow (and WildFly).
Spring官方文档的问题在于
- 逻辑层次不够清晰
- 用语过于抽象
我怀疑是non-native speaker或者没有技术背景的人写的。哈哈哈我的确是被translation-majored native speaker夸奖过非常专业的~~~虽然好多好多年没干这活了,功底还在。
WebSocket测试的前端demo
因为POSTMAN默认的都是最底层的WebSocket协议,这个起起来,捞消息然后放到postman里测,不然可能要去研究一下POSTMAN怎么集成STOMP了。
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Example</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/webstomp-client@1.2.6/dist/webstomp.min.js"></script>
</head>
<body>
<input type="text" id="name" placeholder="Enter your name">
<button onclick="send()">Send</button>
<ul id="topic"></ul>
<ul id="queue"></ul>
<script>
let url = "http://localhost:8080/websocket3"
const socket = new SockJS(url)
const stompClient = webstomp.over(socket);
console.log('socket: ' + socket);
console.log('stompClient: ' + stompClient);
console.log('start connect: ');
stompClient.connect({}, function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/demo', function (greeting) {
console.log("/topic/demo",greeting)
let content = JSON.parse(greeting.body).name;
let li = document.createElement('li');
li.appendChild(document.createTextNode(content));
document.getElementById('topic').appendChild(li);
});
});
console.log(' connected ');
function send() {
let name = document.getElementById('name').value;
if (name) {
let obj = {"id":55,"name":name,"remarks":"nothing"}
console.log('about to send',obj)
// stompClient.send('/app/demo', "{"id":55,"name":"lalala","remarks":"nothing"}", {} );
stompClient.send('/app/demo', JSON.stringify(obj), {} );
// stompClient.send('/app/demo', {}, obj);
// document.getElementById('name').value = '';
}
}
</script>
</body>
</html>
参考资料
developer.mozilla.org/zh-CN/docs/…