2025/04/07

SpringBoot3 Web 1

有兩種 Web

  • servlet 傳統的 MVC

  • reactive (webflux)

傳統的 MVC 用的是同步式 blocking IO,webflux 是使用非同步 non-blocking IO。目前大部分都還是使用 servlet

spring-boot-starter-web 可啟動 servlet web service,WebMvcAutoConfiguration 是自動設定類別

  • 對 ContentNegotiatingViewResolver 及 BeanNameViewResolver 的 bean 註冊

  • 支援靜態資源,含靜態網頁及 webjars

  • 對 Converter, Generic Converter, Formatter 的 bean 自動註冊

  • 支援 HttpMessageConverter 消息轉換器

  • 支援 messageCodesResolver 自動註冊

  • 自動使用 ConfigurableWebBindingInitializer 的 bean

embedded container

預設使用 tomcat

name spring-boot
Tomcat spring-boot-starter-tomcat
Jetty spring-boot-starter-jetty
Undertow spring-boot-starter-undertow

設定

ref: Serve Static Resources with Spring | Baeldung

ref: Serving JSP with Spring Boot 3

可參考 ServerProperties (Spring Boot 3.3.5 API) 類別裡面的參數設定

tomcat 可透過 server.tomcat.* 設定

server:
  port: 8081
  servlet:
    context-path: /demo1

只要實作 WebServerFactoryCustomizer 就能自訂 servlet container

CustomTomcatWebServerFactoryCustomizer.java

package tw.com.test.demo1.config;

import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
public class CustomTomcatWebServerFactoryCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {

    @Override
    public void customize(TomcatServletWebServerFactory server) {
        server.addConnectorCustomizers((connector) -> {
            connector.setPort(8088);
            connector.setAsyncTimeout(Duration.ofSeconds(20).toMillis());
        });
    }

}

執行結果

Tomcat started on port 8088 (http) with context path '/demo1'

切換 container

假設要把 tomcat 換成 undertow,修改 pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>

啟動時,可看到換成了Undertow

[           main] o.s.b.w.e.undertow.UndertowWebServer     : Undertow started on port 8081 (http) with context path '/demo1'

SSL

spring boot 可可定 ssl license,支援 https

自己產生 SSL 憑證

keytool -genkeypair -alias https -keyalg RSA -keysize 2048 -keystore keystore.jks -validity 36500
keytool -importkeystore -srckeystore keystore.jks -destkeystore keystore.p12 -deststoretype pkcs12

把 keystore.jks, keystore.p12 放到 resources 目錄

application.yml

server:
  port: 8443
  ssl:
    protocol: TLS
    # key-store-type={JKS|PKCS12}
    key-store-type: PKCS12
    key-store: classpath:keystore.p12
    key-store-password: password

這樣就能瀏覽網頁 https://localhost:8443/


如果需要同時支援 http 及 https,就把 https 放在上面設定檔中。另外用程式的方式,啟動支援 http

package tw.com.test.demo1.config;

import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.Connector;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;

@Configuration
public class ServerConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addAdditionalTomcatConnectors(createStandardConnector());
        return tomcat;
    }

    private Connector createStandardConnector() {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setPort(8080);
        return connector;
    }
}

啟動後會看到

Tomcat started on ports 8443 (https), 8080 (http) with context path '/demo1'

session persistence

server:
  port: 8080
  servlet:
    context-path: /demo1
    session:
      persistent: true
      store-dir: sessions
      tracking-modes:
        - cookie
        - url

server 必須要用 kill -15 pid 不能用 kill -9 pid 刪除 process,否則不會觸發 session persistence


shutdown graceful

server:
  shutdown: graceful

spring:
  lifecycle:
    # shutdown 的 timeout
    timeout-per-shutdown-phase: 20s

spring boot 的 graceful shudown 也是呼叫 JDK 的 Runtime.getRuntime().addShutdownHook()

不建議用 kill -9 pid 的方式 kill process,一般應該使用 kill -15 pid

自訂 Web 設定

如果不想使用 spring boot 預設的 web configuration,可透過 WebMvcConfigurer interface 自訂

ex: 增加 interceptor, 格式化器, 資源處理器

@Configuration
public class WebConfig implements WebMvcConfigurer {
}

註冊 interceptor

package tw.com.test.demo1.config;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("LoginInterceptor preHandle getRequestURI={},  getServletPath={}", request.getRequestURI(), request.getServletPath());
        HttpSession session = request.getSession();
        String userSession = (String) session.getAttribute("userSession");
        if( userSession==null) {
//            log.info("request.getContextPath()={}", request.getContextPath());
            String contextPath = request.getContextPath();
            response.sendRedirect(contextPath+"/index.html");
            return false;
        } else {
            return true;
        }
    }
}

取得 http session 裡面的 "userSession" 判斷是否已經登入

WebConfig.java

package tw.com.test.demo1.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
//                .addPathPatterns("/**")
                .excludePathPatterns("/**/*.{jsp|html|css|js|png|jpg}")
                .excludePathPatterns("/**/error")
                .excludePathPatterns("/static/**");
    }
}

application.yml

server:
  shutdown: graceful
  port: 8080
  servlet:
    context-path: /demo1

在 static 目錄加上測試檔案

  • /static/index.html

  • /static/js/test.js

  • /static/css/test.css

啟動後測試


Message Converter

讓 Java Bean 可以自動轉換為 JSON/XML

spring boot 支援以下三種 JSON libary

如覺得 JacksonHttpMessageConvertersConfiguration 不符合要求,可覆蓋

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        SimpleModule module = new SimpleModule();
        module.addDeserializer(String.class, new StringWithoutSpaceDeserializer(String.class));
        mapper.registerModule(module);

        converter.setObjectMapper(mapper);
        return converter;
    }
}

StringWithoutSpaceDeserializer.java

package tw.com.test.demo1.handler;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;

public class StringWithoutSpaceDeserializer extends StdDeserializer<String> {

    private static final long serialVersionUID = -1L;

    public StringWithoutSpaceDeserializer(Class<String> vc) {
        super(vc);
    }

    @Override
    public String deserialize(JsonParser p, DeserializationContext deserializationContext) throws IOException {
        return StringUtils.trimToEmpty(p.getText());
    }

}

這個 JSON 轉換器,設定 FAIL_ON_UNKNOWN_PROPERTIES 為 false。並配置一個 StringWithoutSpaceDeserializer,可過濾 http 參數首尾的空白


如果不用覆蓋的做法,可以增加自訂轉換器。也就是自訂 HttpMessageConverters

ref: # Day18 - HttpMessageConverter

ref: # HttpMessageConverter详解


類別轉換器

Converter 介面可以轉換參數的類別

package tw.com.test.demo1.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import tw.com.test.demo1.handler.CustomConverter;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new CustomConverter());
    }

}

CustomerConverter.java

package tw.com.test.demo1.handler;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.convert.converter.Converter;

@Slf4j
public class CustomConverter implements Converter<String, String> {

    @Override
    public String convert(String source) {
        if (StringUtils.isNotEmpty(source)) {
            source = source.trim();
        }
        return source;
    }

}

註冊 Servlet, Filter, Listener

手動註冊

spring boot 有以下三種註冊類別

  • ServletRegistrationBean

  • FilterRegistrationBean

  • ServletListenerRegistrationBean

以下是 Servlet 的 sample

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public ServletRegistrationBean registerServlet() {
        ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean(new RegisterServlet(), "/registerServlet");
        servletRegistrationBean.addInitParameter("name", "username");
        servletRegistrationBean.addInitParameter("sex", "male");
        servletRegistrationBean.setIgnoreRegistrationFailure(true);
        return servletRegistrationBean;
    }

}

RegisterServelet.java

package tw.com.test.demo1.servlet;

import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class RegisterServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String name = getServletConfig().getInitParameter("name");
        String sex = getServletConfig().getInitParameter("sex");

        ServletOutputStream outputStream = resp.getOutputStream();
        outputStream.println("name is " + name);
        outputStream.println("sex is " + sex);
    }

}

瀏覽網頁 http://localhost:8080/demo1/registerServlet 可看到結果

name is username
sex is male

自動掃描註冊

Servlet 3.0 以後不需要 web.xml,所有元件都可透過 annotation 註冊

  • @WebServlet

  • @WebFilter

  • @WebListener

因為 spring boot 內嵌的 tomcat 不會執行 ServletContainerInitializer interface。WebConfig 必須加上 @ServletComponentScan ,開啟掃描功能。

@Configuration
@RequiredArgsConstructor
@ServletComponentScan
public class WebConfig implements WebMvcConfigurer {

}

filter, servlet, listener 必須要在 WebConfig 的 sub package 裡面

JavaServlet.java

package tw.com.test.demo1.config.servlet;

import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.annotation.WebInitParam;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet(name = "javaServlet", urlPatterns = "/javaServlet", asyncSupported = true, initParams = {@WebInitParam(name = "name", value = "javaServlet"), @WebInitParam(name = "sex", value = "male")})
public class JavaServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String name = getServletConfig().getInitParameter("name");
        String sex = getServletConfig().getInitParameter("sex");

        ServletOutputStream outputStream = resp.getOutputStream();
        outputStream.println("name is " + name);
        outputStream.println("sex is " + sex);

    }

}

JavaFilter.java

package tw.com.test.demo1.config.servlet;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.annotation.WebInitParam;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;

@WebFilter(filterName = "javaFilter", urlPatterns = "/*", initParams = {@WebInitParam(name = "name", value = "javaFilter"), @WebInitParam(name = "code", value = "123456")})
@Slf4j
public class JavaFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

        String name = filterConfig.getInitParameter("name");
        String code = filterConfig.getInitParameter("code");
        log.info("JavaFilter init, name={}, code={}", name, code);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("JavaFilter doFilter");
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        log.info("JavaFilter destroy");
    }

}

ContextListener.java

package tw.com.test.demo1.config.listener;


import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@WebListener
public class ContextListener implements ServletContextListener {

    @Override
    public void contextInitialized(ServletContextEvent event) {
        log.info("application contextInitialized started");
    }

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        log.info("application contextDestroyed stopped");
    }
}

SessionListener.java

package tw.com.test.demo1.config.listener;

import jakarta.servlet.annotation.WebListener;
import jakarta.servlet.http.HttpSessionAttributeListener;
import jakarta.servlet.http.HttpSessionBindingEvent;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@WebListener("Session listener for the application")//description of the listener
public class SessionListener implements HttpSessionListener, HttpSessionAttributeListener {

    @Override
    public void attributeAdded(HttpSessionBindingEvent event) {
        log.info("SessionListener attributeAdded event={}", event);
    }

    @Override
    public void attributeRemoved(HttpSessionBindingEvent event) {
        log.info("SessionListener attributeRemoved event={}", event);
    }

    @Override
    public void attributeReplaced(HttpSessionBindingEvent event) {
        log.info("SessionListener attributeReplaced event={}", event);
    }

    @Override
    public void sessionCreated(HttpSessionEvent event) {
        log.info("SessionListener sessionCreated event={}", event);
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent event) {
        log.info("SessionListener sessionDestroyed event={}", event);
    }

}

啟動後,瀏覽網頁 http://localhost:8080/demo1/javaServlet

2024-11-19T16:52:05.640+08:00  INFO 7277 --- [           main] tw.com.test.demo1.Demo1Application       : Starting Demo1Application using Java 17 with PID 7277 (/Users/charley/project/idea/book/test/demo1/target/classes started by charley in /Users/charley/project/idea/book/test/demo1)
2024-11-19T16:52:05.653+08:00  INFO 7277 --- [           main] tw.com.test.demo1.Demo1Application       : No active profile set, falling back to 1 default profile: "default"
2024-11-19T16:52:06.982+08:00  INFO 7277 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
2024-11-19T16:52:06.998+08:00  INFO 7277 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2024-11-19T16:52:06.998+08:00  INFO 7277 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.31]
2024-11-19T16:52:07.081+08:00  INFO 7277 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/demo1]  : Initializing Spring embedded WebApplicationContext
2024-11-19T16:52:07.081+08:00  INFO 7277 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1271 ms
2024-11-19T16:52:07.143+08:00  INFO 7277 --- [           main] t.c.t.d.config.listener.ContextListener  : application contextInitialized started
2024-11-19T16:52:07.150+08:00  INFO 7277 --- [           main] t.c.t.demo1.config.servlet.JavaFilter    : JavaFilter init, name=javaFilter, code=123456
2024-11-19T16:52:07.317+08:00  INFO 7277 --- [           main] o.s.b.a.w.s.WelcomePageHandlerMapping    : Adding welcome page: class path resource [static/index.html]
2024-11-19T16:52:07.650+08:00  INFO 7277 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path '/demo1'
2024-11-19T16:52:07.665+08:00  INFO 7277 --- [           main] tw.com.test.demo1.Demo1Application       : Started Demo1Application in 2.869 seconds (process running for 4.439)
2024-11-19T16:52:43.224+08:00  INFO 7277 --- [nio-8080-exec-1] t.c.t.demo1.config.servlet.JavaFilter    : JavaFilter doFilter
2024-11-19T16:53:49.996+08:00  INFO 7277 --- [ionShutdownHook] o.s.b.w.e.tomcat.GracefulShutdown        : Commencing graceful shutdown. Waiting for active requests to complete
2024-11-19T16:53:50.007+08:00  INFO 7277 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown        : Graceful shutdown complete
2024-11-19T16:53:50.016+08:00  INFO 7277 --- [ionShutdownHook] t.c.t.demo1.config.servlet.JavaFilter    : JavaFilter destroy
2024-11-19T16:53:50.017+08:00  INFO 7277 --- [ionShutdownHook] t.c.t.d.config.listener.ContextListener  : application contextDestroyed stopped

動態註冊

InitServlet.java

package tw.com.test.demo1.config.servlet;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class InitServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String name = getServletConfig().getInitParameter("name");
        String sex = getServletConfig().getInitParameter("sex");

        resp.getOutputStream().println("name is " + name);
        resp.getOutputStream().println("sex is " + sex);

    }

}

有兩種方法

  1. @Bean
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public ServletContextInitializer servletContextInitializer() {
        return (servletContext) -> {
            ServletRegistration initServlet = servletContext.addServlet("initServlet", InitServlet.class);
            initServlet.addMapping("/initServlet");
            initServlet.setInitParameter("name", "initServlet");
            initServlet.setInitParameter("sex", "male");
        };
    }
}
  1. @Component
package tw.com.test.demo1.config;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRegistration;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.stereotype.Component;
import tw.com.test.demo1.config.servlet.InitServlet;

@Component
public class ServletConfig implements ServletContextInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        ServletRegistration initServlet = servletContext.addServlet("initServlet2", InitServlet.class);
        initServlet.addMapping("/initServlet2");
        initServlet.setInitParameter("name", "initServlet2");
        initServlet.setInitParameter("sex", "female");
    }
}

兩種 servlet url 都是由 InitServlet 處理

http://localhost:8080/demo1/initServlet

http://localhost:8080/demo1/initServlet2


靜態資源處理

spring boot 預設會載入 classpath 以下目錄的資源

  • /static

  • /public

  • /resources

  • /META-INF/resources

可用這個參數修改

spring:
  web:
    resources:
      static-locations: classpath:/static,classpath:/public,classpath:/resources,classpath:/META-INF/resources

預設會把靜態資源映射到 /** 路徑,可以用以下方式修改

spring:
  mvc:
    static-path-pattern: "/pub/**"

spring MVC 預設使用 ResourceHttpRequestHandler 處理靜態資源,也可以實作自己的處理器。這邊增加了兩個靜態資源映射

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
        registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/assets/");
    }
}