Spring Web(Servlet技术栈)学习笔记

626 阅读53分钟

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的。

这一节讲的模模糊糊的,真心看不懂,就提起了这几点:

  1. 路径解析挺麻烦的,要考虑到可能的前缀"/",可能的后缀“;”,以及关键词(reserved word)不做关键词的时候的解码
  2. 建议把默认DispatcherServlet映射到"/" 或者 "/*",并且保证Servlet容器版本至少4.0,Spring MVC一般就没啥问题了。
  3. 如果用的Servlet版本是3.1,需要在MVC config里 把UrlPathHelper 的 alwaysUseFullPath 设置为true
  4. 还有一种场景是原始路径需要解码了才能用来比对,那就需要UrlPathHelper 设置 urlDecode=false
  5. 还有特殊场景是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已经帮我们搞定了:

blog.csdn.net/qq_37205350…

如果使用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处理异步请求的大致流程是:

  1. ServeletRequest.startAsync()被调用以开启异步模式。Servlet线程可以退出,但是干活的线程还在
  2. request.startAsync()返回AsyncContext,以供后续调用
  3. ServletRequest提供了当前DispatcherType的引用,以供后续调用

DeferredResult处理流程:

  1. controller返回DeferredResult,并保存到内存队列/列表里。
  2. Spring MVC 调用 request.startAsync()
  3. DispatcherServlet退出
  4. 从干活的线程获取DeferredResult,Spring MVC把请求返回Servlet容器
  5. 再次调用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:

mvc:annotation-driven的 元素。

可以配置的有:

    • 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 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来鉴权。方法是:

  1. 在连接时,STOMP客户端传鉴权header
  2. 使用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端即时通讯技术

  1. 后端普通rest接口,前端手搓代码定时短轮询。最早的时候用过这种方式,很努力了,前端代码仍然没啥可读性。后面其他项目需要复用页面,要稍微改个东西,原理告诉他们了,但是他们改的时候花的工时,比我预估的多挺多的。(那个时候我可没有技术选型的权力,轻拍)
  2. WebSocket,一次只能有一个连接,要约定好消息格式才好确定哪条消息走哪条逻辑,如果一方偷偷变了,也不是很好排查。不过它可以双向通信,功能也比较丰富(比如内置了心跳机制)。

读到后面,又回顾了部门之前的WebSocket后端代码,发现是我想错了。有了STOMP,我之前认为的痛点(路由)其实已经被解决了,只是之前团队过于随意的技术选型(选了原生WebSocket而不选STOMP),才让我有了错误认知。所以还是要沉下心来学习的。

  1. SSE 现在看着是可以根据请求路径来的,但是只能单向(服务器到浏览器),如果只是需要实时展示,sse是我心里觉得比较理想的,但是因为没有实战过,不好确定细节方面。体积似乎sse会大一些(传文本,websocket二进制,不传图片音频没啥子区别)。前面是刚看到SSE的时候的想法。读到后面觉得SSE的功能还是稍微单薄了些。对WebSocket好好根据业务封装一下用着吧~
  2. 似乎还有个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官方文档的问题在于

  1. 逻辑层次不够清晰
  2. 用语过于抽象

我怀疑是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>

参考资料

spring.io/security/cv…

developer.mozilla.org/zh-CN/docs/…

fetch.spec.whatwg.org/#methods

blog.csdn.net/u012894692/…

www.jianshu.com/p/0ec4e7afb…

spring.io/blog/2012/0…

www.jianshu.com/p/57fbfadac…