2025/04/21

SpringBoot3 Web 3

CORS 跨來源資源共享

SAMEORIGIN 同源政策,是 Netscape 提出,所有支援 Javascript 的瀏覽器都使用這個策略,同源是 domain name, port, protocol 相同的意思,網頁執行一個 script 時,會檢查是否同源,非同源就會拒絕。

只要 domain name, port, protocol 某一個不同,就代表 cross origin。

CORS 允許伺服器指示瀏覽器允許從除其自身以外的任何來源載入資源。

spring MVC 本身透過在 method 上設定 @CrossOrigin

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") @Size(min = 5, max = 8) String userId) {
        User user = new User("test1", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }
}

@CrossOrigin 預設支援所有 request 來源、所有 http header, methods

@CrossOrigin 也可以用在 class

@Slf4j
@CrossOrigin(origins = "https://www.test.com", maxAge=3600)
@RestController
public class ResponseBodyController {
}

也可以放在 WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/user/**")
                .allowedMethods("GET", "POST")
                .allowedOrigins("https://www.test.com")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

安全性

加上 security library

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

啟動時會出現以下的 log

2024-11-20T16:36:50.740+08:00  WARN 5307 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 06ee988c-9983-4187-b040-5bfadde83893

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2024-11-20T16:36:50.784+08:00  INFO 5307 --- [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager

瀏覽 http://localhost:8080/demo1/test 時,會出現 ## Please sign in 的登入畫面

這時候填寫

username: user
password: 06ee988c-9983-4187-b040-5bfadde83893

就可以打開 index 頁面

可在 application.yml 修改預設的帳號密碼

spring:
  security:
    user:
      name: root
      password: password

自訂安全機制

可註冊 SecurityFilterChain 與 UserDetailsService 類別,實作自訂安全機制

SecurityConfig.java

package tw.com.test.demo1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests((authorize) -> {
            authorize.requestMatchers("/test/**").hasRole("TEST").requestMatchers("/**").permitAll();
        }).logout((logout) -> logout.logoutUrl("/")).formLogin(withDefaults()).build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("test").password("{noop}test").roles("ADMIN", "TEST").build());
        manager.createUser(User.withUsername("root").password("{noop}root").roles("ADMIN").build());
        return manager;
    }

}

REST service

實作 REST service 有兩種方式

  • RestTemplate (servlet)

  • WebClient (reactive)

RestTemplate (servlet)

pom.xml

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

自動設定類別 RestTemplateAutoConfiguration

註冊在 org.springframework.boot.autoconfigure.AutoConfiguration.imports

ResTemplate 並沒有任何預設的註冊

必須要先註冊一個 RestTemplate instance,可透過 restTemplateBuilder 產生

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .build();
    }
}

新增一個 controller 測試

package tw.com.test.demo1.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import tw.com.test.demo1.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/demo1/user/json/{uid}";
    private final RestTemplate restTemplate;

    @GetMapping("/restTemplate/{uid}")
    public User restTemplate(@PathVariable("uid") String uid) {
        return this.restTemplate.getForObject(GET_USERINFO_URL, User.class, uid);
    }

}

用 postman 測試 GET http://localhost:8080/demo1/restTemplate/10000

回傳結果

{
    "id": 10000,
    "age": 18,
    "username": "test1"
}

自訂 RestTemplate,加上 timeout, basic authentication

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(5))
                .basicAuthentication("test", "test")
                .build();
    }

也可以直接實作 RestTemplateCustomizer

package tw.com.test.demo1.handler;

import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.routing.HttpRoutePlanner;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;


@Slf4j
@Component
public class CustomRestTemplateCustomizer implements RestTemplateCustomizer {

    @Override
    public void customize(RestTemplate restTemplate) {
        HttpRoutePlanner routePlanner = new CustomRoutePlanner(new HttpHost("proxy.test.com"));
        RequestConfig requestConfig = RequestConfig.custom().build();
        HttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).setRoutePlanner(routePlanner).build();
        restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient));
    }

    static class CustomRoutePlanner extends DefaultProxyRoutePlanner {

        CustomRoutePlanner(HttpHost proxy) {
            super(proxy);
        }

        @Override
        protected HttpHost determineProxy(HttpHost target, HttpContext context) throws HttpException {
            log.info("hostName is {}", target.getHostName());
            if ("localhost".equals(target.getHostName())) {
                return null;
            }
            return super.determineProxy(target, context);
        }

    }

}

有用到 http client 5

        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
        </dependency>

也可以實作最底層的 HttpAccessor


WebClient (reactive)

WebClient 是使用 speign WebFlux 的 http client

pom.xml

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

User.java

package tw.com.test.demo3.bean;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
    private Integer age;
}

WebConfig.java

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        return webClientBuilder.build();
    }

}

CallRestController.java

package tw.com.test.demo3.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/user/json/{uid}";

    private final WebClient webClient;

    @GetMapping("/webClient/{uid}")
    public Mono<User> webClient(@PathVariable("uid") String uid) {
        return this.webClient.get().uri(GET_USERINFO_URL, uid).retrieve().bodyToMono(User.class);
    }

}

這是要呼叫的 rest url

package tw.com.test.demo3.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") String userId) {
        User user = new User((long)1000, "test", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }

}

以 GET method 呼叫 http://localhost:8080/webClient/10000

就能取得結果

{
    "id": 10000,
    "username": "test",
    "age": 18
}

自訂 WebClient

修改 WebConfig.java,改用 reactor netty 的 ReactorClientHttpConnector

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

//    @Bean
//    public WebClient webClient(WebClient.Builder webClientBuilder) {
//        return webClientBuilder.build();
//    }

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        HttpClient httpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3).doOnConnected(conn -> {
            conn.addHandlerLast(new ReadTimeoutHandler(3000));
            conn.addHandlerLast(new WriteTimeoutHandler(3000));
        });
        ReactorClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
        return webClientBuilder.clientConnector(connector).build();
    }

}

可改用 ClientHttpConnector 的其他實作的客戶端,目前 spring boot 支援

  • Reactor netty (default)

  • Jetty RS Client

  • Apache HttpClient

  • JDK HttpClient

2025/04/14

SpringBoot3 Web 2

template engine

  • FreeMaker

  • Thymeleaf -> 目前最常使用

  • Mustache

要使用 Thymeleaf 必須加上套件

pom.xml

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

spring 會自動透過 ThymeleafAutoConfiguration 自動設定

設定參數是 spring.thymeleaf.*

預設目錄為 src/main/resources/templates

預設模板檔案是 .html

預設檔案編碼為 UTF-8

Demo1Application.java

@Slf4j
@SpringBootApplication
@Controller
@RequiredArgsConstructor
public class Demo1Application {
    private final HttpServletRequest request;

    public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class);
    }

    @GetMapping(value = "/test/{content}")
    public String test(@PathVariable("content") String content) {
        request.setAttribute("content", content);
        return "test";
    }
}

templates/test.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <title>test</title>
    <link rel="stylesheet" type="text/css" th:href="@{/css/test.css}"/>
    <script th:src="@{/js/test.js}"></script>
</head>
<body>

<a id="content" th:utext="${content}" class="red" onclick="changeColor()"></a>

</body>
</html>

這樣就能瀏覽網頁 http://localhost:8080/demo1/test/content


異常處理

預設是映射到 /error 頁面,錯誤處理是 BasicErrorController (implements ErrorController)

自訂 global error

@ControllerAdvice@ExceptionHandler 製作 ErrorHandler

package tw.com.test.demo1.handler;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ResponseBody
    @ExceptionHandler(value = {Exception.class})
    public ResponseEntity<?> handleException(HttpServletRequest request, Throwable ex) {
        log.error("global exception:", ex);
        return new ResponseEntity<>("global exception", HttpStatus.OK);
    }

    @ResponseBody
    @ExceptionHandler(value = {MethodArgumentNotValidException.class})
    public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        StringBuilder sb = new StringBuilder("Parameter Error:");
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(", ");
        }
        String msg = sb.toString();
        return new ResponseEntity(msg, HttpStatus.OK);
    }

}

自訂異常頁面

在任何一個靜態目錄,建立 /error 目錄,並建立 error code html page

  • main/resources/public/error/404.html

  • main/resources/public/error/500.html


參數驗證

spring boot 支援 JSR-303 spec,可針對介面參數進行驗證,例如常用的 hibernate-validator 就實作了 JSR-303

        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
        </dependency>

spring boot 不需要加上 hibernate-validator,只需要引用 spring-boot-starter-validation

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

jakarta.validation-api-3.0.2.jar 裡面的 package: jakarta.validation.constraints,裡面有所有 constraints 的類別

  • AssertFalse, AssertTrue

  • DecimalMax, DecimalMin

  • Digits

  • Email

  • Future, FutureOrPresent

  • Max, Min

  • Negative, NegativeOrZero

  • NotBlank, NotEmpty, NotNull

  • Null

  • Past, PastOrPresent

  • Pattern

  • Positive, PositiveOrZero

  • Size

引用 spring-boot-starter-validation 後,就可使用 @Valid 或是 @Validated

@Valid 是 Jarkarta spec 的 annotation

@Validated 是 spring 支援的 JSR-303 annotation,功能比較強

常用做法是在 interface 類別 (ex: controller) 使用 @Validated ,約束註解放在 method 的參數,或是放在類別 member variable 上面

User.java

package tw.com.test.demo1.bean;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class User {

    public User(String userName, Integer age) {
        this.userName = userName;
        this.age = age;
    }

    private Long id;

    @NotNull
    @JsonProperty(value = "username", required = true)
    @Size(min = 5, max = 10)
    private String userName;

    @NotNull
    private Integer age;

    @JsonIgnore
    private String address;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String memo;

}

ResponseBodyController.java

package tw.com.test.demo1.controller;

import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import tw.com.test.demo1.bean.OrderInfo;
import tw.com.test.demo1.bean.User;
import tw.com.test.demo1.bean.UserXml;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") @Size(min = 5, max = 8) String userId) {
        User user = new User("test1", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }

    @GetMapping(value = "/user/xml/{userId}", produces = MediaType.APPLICATION_XML_VALUE)
    public UserXml getXmlUserInfo(@PathVariable("userId") String userId) {
        UserXml user = new UserXml();
        user.setName("test1name");
        user.setId(userId);

        List<OrderInfo> orderList = new ArrayList<>();
        OrderInfo orderInfo1 = new OrderInfo("001", 11, new Date());
        OrderInfo orderInfo2 = new OrderInfo("002", 22, new Date());
        OrderInfo orderInfo3 = new OrderInfo("003", 33, new Date());
        orderList.add(orderInfo1);
        orderList.add(orderInfo2);
        orderList.add(orderInfo3);
        user.setOrderList(orderList);

        return user;
    }

    @PostMapping(value = "/user/save")
    public ResponseEntity saveUser(@RequestBody @Validated User user) {
        user.setId(RandomUtils.nextLong());
        return new ResponseEntity(user, HttpStatus.OK);
    }

    @GetMapping("/")
    public String index() {
        return "index page.";
    }

}

驗證異常時,會出現 MethodArgumentNotValidException,在 global error 裡面註冊這個 Exception 的 handler

package tw.com.test.demo1.handler;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) {
        List<FieldError> errorList =  ex.getBindingResult().getFieldErrors();
        Map<String, String> errorMap = errorList.stream()
                .collect(Collectors.toMap(
                        value -> value.getField(),
                        value -> value.getDefaultMessage()));
        return new ResponseEntity<>(errorMap, new HttpHeaders(), HttpStatus.BAD_REQUEST);
    }

}

測試 http://localhost:8080/demo1/user/save

{
    "username": "11",
    "age": 11
}

結果

{
    "userName": "大小必須在 5 和 10 之間"
}

i18n

spring 預設會在類別路徑的根目錄搜尋是否有多國語言資源

自動設定類別是 org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration

設定參數

spring:
  messages:
    # i18n 檔名,預設為 messages,可指定多個
    basename: i18n/common, i18n/index
    encoding: UTF-8
    # 找不到時,使用目前 OS 的語言,預設為 true
    # false 就是使用 messages.properties
    fallback-to-system-locale: false
    # cache 時間(s),不設定代表永久 cache
#    cache-duration: 60

搭配 Thymeleaf

pom.xml

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

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

resources/i18n/common.properties

brand=Java

resources/i18n/index.properties

index.hi=hello
index.welcome=welcome

resources/i18n/index_zh_TW.properties

index.hi=你好
index.welcome=歡迎光臨

resources/templates/index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <title>index</title>
</head>
<body>

<label th:text="#{brand}"></label>
<label th:text="#{index.hi}"></label>
<label th:text="#{index.welcome}"></label>

</body>
</html>

ResponseBodyController.java

package tw.com.test.demo1.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Slf4j
@Controller
public class PageController {

    @GetMapping(value = "/")
    public String index() {
        return "index";
    }

}

瀏覽網頁 http://localhost:8080/demo1/

會根據 request header 產生頁面結果

Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6

response header

Content-Language: zh-TW
Content-Type: text/html;charset=UTF-8

切換 i18n

設定預設 locale,透過網址參數 url?lang=zh_TW 切換 locale

package tw.com.test.demo1.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

import java.util.Locale;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // 設定預設的 locale 為 en
    @Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver();
        sessionLocaleResolver.setDefaultLocale(Locale.US);
        return sessionLocaleResolver;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }

    /**
     * 切換語言攔截器,通過 url?lang=zh_TW 形式進行切換
     * @return
     */
    private LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
        localeChangeInterceptor.setParamName("lang");
        return localeChangeInterceptor;
    }
}

瀏覽網址

http://localhost:8080/demo1/?lang=

http://localhost:8080/demo1/?lang=zh_TW


session cluster

spring session 可將 session store 存放到第三方儲存庫中,可支援存在

  • Redis

  • JDBC

  • Hazelcast

  • MongoDB

session 的自動設定類別是 org.springframework.boot.autoconfigure.session.SessionAutoConfiguration

自動設定檔 org.springframework.boot.autoconfigure.AutoConfiguration.imports

Session 的參數綁定類別是 SessionProperties

參數 spring.session.*

Redis 有自己的設定擴充參數 spring.session.redis.* ,設定類別 RedisSessionProperties


以下以 redis 為例

pom.xml

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

        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

application.yml

spring:
  session:
    timeout: 30m
  data:
    redis:
      host: localhost
      port: 6379
      password: password

WebConfig.java

package tw.com.test.demo2;

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("/login/**");
    }

}

建立測試 controller

IndexController.java

package tw.com.test.demo2;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@RequiredArgsConstructor
@Controller
public class IndexController {

    private final HttpSession httpSession;

    @ResponseBody
    @RequestMapping("/login")
    public String login() {
        return "login page.";
    }

    @RequestMapping("/login/submit")
    public String loginSubmit(@RequestParam("username") String username) {
        if (StringUtils.isNotBlank(username)) {
            httpSession.setAttribute("username", username);
            return "/index";
        }
        return "/login";
    }

    @ResponseBody
    @RequestMapping("/index")
    public String index() {
        log.info("session id: {}", httpSession.getId());
        return "index page.";
    }

    @RequestMapping("/logout")
    public String logout() {
        httpSession.invalidate();
        return "/login";
    }

}

LoginInterceptor.java

package tw.com.test.demo2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        String username = (String) session.getAttribute("username");
        if (StringUtils.isBlank(username)) {
            response.sendRedirect("/login");
            return false;
        }
        return true;
    }

}

Demo2Application.java

package tw.com.test.demo2;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Demo2Application {

    public static void main(String[] args) {
        SpringApplication.run(Demo2Application.class, args);
    }

}

在 8080, 8081 啟動兩個 applications

java -jar application.jar --server.port=8080
java -jar application.jar --server.port=8081

還沒有登入時,瀏覽網址 http://localhost:8080/index 會轉到 http://localhost:8080/login

以這個網址登入

http://localhost:8080/login/submit?username=test

畫面是 index page

登入成功後,會有兩個 cookie: SESSIONID, SESSION

spring session 是使用 SESSION 跨 application instances

Cookie:
SESSIONID=snvMXo4CMMxNxnD9F/c98cBpqlXTmxpFoM+Mv7yYGCqw=; SESSION=NzVhMjM1ZWMtMDNiMy00ZGY3LWE5NjQtODgzMGQ0YWM4OTZl

到 redis 查詢

redis-cli -h localhost -p 6379 -a password
1) "spring:session:sessions:75a235ec-03b3-4df7-a964-8830d4ac896e"
localhost:6379> keys *
1) "spring:session:sessions:75a235ec-03b3-4df7-a964-8830d4ac896e"
localhost:6379> ttl "spring:session:sessions:75a235ec-03b3-4df7-a964-8830d4ac896e"
(integer) 1797

ttl 代表該 session id 的存活時間是 1797 秒,因為剛剛設定為 30 minutes

75a235ec-03b3-4df7-a964-8830d4ac896e 的 base64 encode 結果就是 NzVhMjM1ZWMtMDNiMy00ZGY3LWE5NjQtODgzMGQ0YWM4OTZl

如果呼叫 http://localhost:8081/logout ,會發現 8080, 8081 的網頁,都會變成需要 login 的狀態


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/");
    }
}

2025/03/31

SpringBoot3 - Spring Boot Log

spring 提供的 Logging :: Spring Boot 有以下幾種

name package
Java Util Logging spring-boot-starter-logging
Logback spring-boot-starter-logging
Log4j2 spring-boot-starter-log4j2

spring-boot-starter-logging 是預設引用的,所有 spring-boot-starter-* 都會引用 spring-boot-starter-logging。不需要自行引用。

spring-boot-starter-logging 使用 Commons Logging,支援 Java Util Logging、Log4j2、Logback

使用 starter 預設會使用 Logback,透過 logback 支援 Java Util Logging, Commons Logging, Log4J, or SLF4J。

Log Format

2024-11-15T17:25:40.765+08:00  INFO 4924 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
  • Date & Time

  • Log Level

  • Process ID

  • --- separator

  • [Application name] 如果有設定 spring.application.name 才會出現

  • [Thread name]

  • Correlation ID 如果有設定 tracing 才會出現

  • Logger name 通常是來源 class name 的縮寫

  • log message

Console Output

預設會出現 ERROR-level, WARN-level, and INFO-level messages

如果在 CLI 有加上 java -jar Demo1.jar --debug debug flag,就會出現 core loggers (embedded container, Hibernate, and Spring Boot) 更多的資訊。這邊不代表 application 的 DEBUG level message 會出現

CLI 也可以加上 --trace ,會增加 core logger 資訊


Color-coded Output

terminal 有支援 ANSI 時,就可以增加顏色

ex: %clr(%5p)

Level Color
FATAL Red
ERROR Red
WARN Yellow
INFO Green
DEBUG Green
TRACE Green

可以自訂顏色

  • blue

  • cyan

  • faint

  • green

  • magenta

  • red

  • yellow

ex: %clr(%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}){yellow}

File Output

spring boot 預設只會寫入 console log

要寫入 file log 必須設定 logging.file.namelogging.file.path,兩個都設定時,只會使用 logging.file.name。log file rotate 預設為 10M,log level 跟 console 一樣,會寫入 ERROR, WARN, INFO level

File Rotation

調整 log rotate 規則

要修改 application.properties(yaml),如果是 log4j 就要設定 log4j2.xml or log4j2-spring.xml

name desc
logging.logback.rollingpolicy.file-name-pattern filename pattern
logging.logback.rollingpolicy.clean-history-on-start 在 application 啟動時,要做 log cleanup
logging.logback.rollingpolicy.max-file-size logfile 的最大 size
logging.logback.rollingpolicy.total-size-cap log archives 刪除前的最大 size
logging.logback.rollingpolicy.max-history archive log files 保留幾個(default: 7)

Log Level

logging.level.<logger-name>=<level>

為 TRACE, DEBUG, INFO, WARN, ERROR, FATAL, or OFF 其中一個

logging.level.root 是設定 root logger

logging:
  level:
    root: "warn"
    org.springframework.web: "debug"
    org.hibernate: "error"

也可以設定環境變數,ex:

LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_WEB=DEBUG

Log Groups

ex: 一次設定所有 tomcat 相關的 logger level,group 合併後,再設定 level

在 applicaiton.yaml

logging:
  group:
    tomcat: "org.apache.catalina,org.apache.coyote,org.apache.tomcat"

logging:
  level:
    tomcat: "trace"

spring pre-defined logging groups

name loggers
web org.springframework.core.codec, org.springframework.http, org.springframework.web, org.springframework.boot.actuate.endpoint.web, org.springframework.boot.web.servlet.ServletContextInitializerBeans
sql org.springframework.jdbc.core, org.hibernate.SQL, org.jooq.tools.LoggerListener

Log Shutdown hook

要在 application 關閉前,release logging resource,要在 JVM exits 時,加上 shutdown hook

使用 jar 時,shutdown hook 會自動註冊

如要關閉 shutdown hook

logging:
  register-shutdown-hook: false

Custom Log Configuration

logging.config 決定 logging framework,然後就能使用該 framework 的設定檔

Logging System Customization
Logback logback-spring.xmllogback-spring.groovylogback.xml, or logback.groovy
Log4j2 log4j2-spring.xml or log4j2.xml
JDK (Java Util Logging) logging.properties

建議使用 logback-spring.xml,不要用 logback.xml,spring 可能會無法控制 log 初始化

以下設定參數,可轉換為環境變數

Spring Environment System Property Comments
logging.exception-conversion-word LOG_EXCEPTION_CONVERSION_WORD The conversion word used when logging exceptions.
logging.file.name LOG_FILE If defined, it is used in the default log configuration.
logging.file.path LOG_PATH If defined, it is used in the default log configuration.
logging.pattern.console CONSOLE_LOG_PATTERN The log pattern to use on the console (stdout).
logging.pattern.dateformat LOG_DATEFORMAT_PATTERN Appender pattern for log date format.
logging.charset.console CONSOLE_LOG_CHARSET The charset to use for console logging.
logging.threshold.console CONSOLE_LOG_THRESHOLD The log level threshold to use for console logging.
logging.pattern.file FILE_LOG_PATTERN The log pattern to use in a file (if LOG_FILE is enabled).
logging.charset.file FILE_LOG_CHARSET The charset to use for file logging (if LOG_FILE is enabled).
logging.threshold.file FILE_LOG_THRESHOLD The log level threshold to use for file logging.
logging.pattern.level LOG_LEVEL_PATTERN The format to use when rendering the log level (default %5p).
PID PID

使用 logback,可多使用這些參數

Spring Environment System Property Comments
logging.logback.rollingpolicy.file-name-pattern LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN Pattern for rolled-over log file names (default ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz).
logging.logback.rollingpolicy.clean-history-on-start LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START Whether to clean the archive log files on startup.
logging.logback.rollingpolicy.max-file-size LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE Maximum log file size.
logging.logback.rollingpolicy.total-size-cap LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP Total size of log backups to be kept.
logging.logback.rollingpolicy.max-history LOGBACK_ROLLINGPOLICY_MAX_HISTORY

Logback Extension

logback-spring.xml 裡面有幾個 logback extensions

profile-specific cofiguration

根據 active profile,<springProfile> 可 include/exclude 部分設定

<springProfile name="staging">
    <!-- configuration to be enabled when the "staging" profile is active -->
</springProfile>

<springProfile name="dev | staging">
    <!-- configuration to be enabled when the "dev" or "staging" profiles are active -->
</springProfile>

<springProfile name="!production">
    <!-- configuration to be enabled when the "production" profile is not active -->
</springProfile>

environment properties

<springProperty scope="context" name="fluentHost" source="myapp.fluentd.host"
        defaultValue="localhost"/>
<appender name="FLUENT" class="ch.qos.logback.more.appenders.DataFluentAppender">
    <remoteHost>${fluentHost}</remoteHost>
    ...
</appender>

example

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <property name="LOG_PATH" value="/var/log/blog"/>
    <property name="LOG_FILENAME" value="spring-profiles"/>

    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

    <springProfile name="default,ide">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <springProfile name="test,prod">
        <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <encoder>
                <pattern>${FILE_LOG_PATTERN}</pattern>
            </encoder>
            <file>${LOG_PATH}/${LOG_FILENAME}.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <fileNamePattern>${LOG_PATH}/${LOG_FILENAME}-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
                <maxHistory>7</maxHistory>
                <maxFileSize>100MB</maxFileSize>
                <totalSizeCap>1GB</totalSizeCap>
            </rollingPolicy>
        </appender>

        <root level="INFO">
            <appender-ref ref="FILE"/>
        </root>
    </springProfile>

</configuration>

Log4j2 Extension

可在 log4j2-spring.xml 設定使用

profile-specific configuration

<SpringProfile name="staging">
    <!-- configuration to be enabled when the "staging" profile is active -->
</SpringProfile>

<SpringProfile name="dev | staging">
    <!-- configuration to be enabled when the "dev" or "staging" profiles are active -->
</SpringProfile>

<SpringProfile name="!production">
    <!-- configuration to be enabled when the "production" profile is not active -->
</SpringProfile> 

Environment Properties Lookup

<Properties>
    <Property name="applicationName">${spring:spring.application.name}</Property>
</Properties>

log4j2 system properties

ex: ConsoleAppender 在 windows 使用 Jansi

log4j2.skipJansi=false

2025/03/17

SpringBoot3 - Spring Boot 啟動過程

啟動入口

@SpringBootApplication
public class Demo1Application {

    public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class);
    }

}

@SpringBootApplication 裡面就包含了

  • @SpringBootConfiguration

  • @EnableAutoConfiguration

    啟用應用的自動設定功能

  • @ComponentScan

    自動掃描 sub packages 裡面的 Spring Component


啟動 application

SpringApplication.run(Demo1Application.class);

也可以拆開

SpringApplication springApplication = new SpringApplication(Demo1Application.class);
springApplication.run(args);

也可以用 SpringApplicationBuilder

new SpringApplicationBuilder()
    .sources(Parent.class)
    .child(Demo1Application.class)
    .banerMode(Banner.Mode.OFF)
    .run(args);

jar 啟動

application 的 main 通常是在 console 啟動時被呼叫,如果打包成 jar,就會是另一種狀況

project 可用 mvn package 打包成 jar

|____org
| |____springframework
| | |____boot
| | | |____loader
| | | | |____ref
| | | | |____net
| | | | |____jarmode
| | | | |____launch
| | | | |____jar
| | | | |____zip
| | | | |____nio
| | | | |____log
|____META-INF
| |____MANIFEST.MF
| |____maven
| | |____tw.com.test
| | | |____demo1
| | | | |____pom.xml
| | | | |____pom.properties
| |____services
| | |____java.nio.file.spi.FileSystemProvider
|____BOOT-INF
| |____classes
| | |____tw
| | | |____com
| | | | |____test
| | | | | |____demo1
| | | | | | |____Demo1Application.class
| | |____application.yml
| |____layers.idx
| |____classpath.idx
| |____lib
  • BOOT-INF: 啟動 application 所需要的 classes

  • META-INF: application打包的相關描述檔案

  • org: spring boot 啟動所需要的引導類別


META-INF/MENIFEST.MF

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 17
Implementation-Title: demo1
Implementation-Version: 0.0.1-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: tw.com.test.demo1.Demo1Application
Spring-Boot-Version: 3.3.5
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

Main-Class: 啟動的引導類別

Start-Class: application 啟動類別


啟動的 log 可透過設定關閉

spring:
  main:
    log-startup-info: false

或是

SpringApplication springApplication = new SpringApplication(Demo1Application.class);
springApplication.setLogStartupInfo(false);
springApplication.run(args);

啟動失敗分析

由 FailureAnalyzer interface 處理

ex: PortInUseFailureAnalyzer 可處理重複使用 TCP Port 的 exception

可以自訂一個 PortInUseFailureAnalyzer

package tw.com.test.demo1;

import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;
import org.springframework.boot.web.server.PortInUseException;

public class PortInUseFailureAnalyzer extends AbstractFailureAnalyzer<PortInUseException> {
    @Override
    protected FailureAnalysis analyze(Throwable rootFailure, PortInUseException cause) {
        return new FailureAnalysis(cause.getPort()+"PortInUse", "check "+cause.getPort(), cause);
    }
}

然後在 META-INF/spring.factories

org.springframework.boot.diagnostics.FailureAnalyzer=\
  tw.com.test.demo1.PortInUseFailureAnalyzer

也可以自訂自己的 applicaton exception,以及對應的 FailureAnalyzer

啟動 Banner

Text to ASCII Art Generator (TAAG) 這是將 text 轉換為 ascii 文字的網頁工具。

將要列印的 application 文字,轉換為 ascii 後,儲存到 /resources/banner.txt。啟動 application 時,就會看到 banner 已經換掉了。

以下是一些相關設定

# banner.txt encoding
spring.banner.charset=UTF-8
# banner.txt filepath
spring.banner.location=classpath:banner.txt
# console/log/off
spring.main.banner-mode=console

啟動事件與 listener

spring-boot-3.3.5.jar

META-INF/spring.factories 裡面註冊的 listener,這些都實作了 Spring 的 ApplicationListener interface

# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.ClearCachesApplicationListener,\
org.springframework.boot.builder.ParentContextCloserApplicationListener,\
org.springframework.boot.context.FileEncodingApplicationListener,\
org.springframework.boot.context.config.AnsiOutputApplicationListener,\
org.springframework.boot.context.config.DelegatingApplicationListener,\
org.springframework.boot.context.logging.LoggingApplicationListener,\
org.springframework.boot.env.EnvironmentPostProcessorApplicationListener

啟動事件的順序

  1. ApplicationStartingEvent

    spring application 啟動前發送

  2. ApplicationEnvironmentPreparedEvent

    已知要在 context 使用 spring 的 Environment 時,在 context 建立之前發送

  3. ApplicationContextInitializedEvent

    ApplicationContext 準備好了,ApplicationContextInitializer 已被呼叫,在 bean 載入前發送

  4. ApplicationPreparedEvent

    在 context refresh 前,且在 bean 定義被載入後發送

  5. ApplicationStartedEvent

    在 Spring context refresh 以後,在 application / CLI runner 呼叫前發送

  6. AvailabilityChangeEvent

    跟著上一個事件後發送,ReadinessState.CORRECT,代表 application 已處於活動狀態

  7. ApplicationReadyEvent

    在 application / CLI runner 呼叫後發送

  8. AvailabilityChangeEvent

    跟著上一個事件後發送,ReadinessState.ACCEPTING_TRAFFIC,代表 application 已經準備接收 request

  9. ApplicationFailedEvent

    在啟動異常時發送

以上只有 SpringApplication 發出的 SpringApplicationEvents 事件。

以下事件會在 ApplicationPreparedEvent 後,ApplicationStartedEvent 前發送

  1. WebServerInitializedEvent

    WebServer 啟動後發送。包含 ServletWebServerInitialziedEvent, ReactiveWebServerInitializedEvent

  2. ContextRefreshedEvent

    在 ApplicationContext refresh 後發送


自訂事件 listener

Demo1ApplicationListener.java

package tw.com.test.demo1;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class Demo1ApplicationListener implements ApplicationListener<AvailabilityChangeEvent> {
    @Override
    public void onApplicationEvent(AvailabilityChangeEvent event) {
        log.info("got event: {}, state: {}", event, event.getState());
        if( ReadinessState.ACCEPTING_TRAFFIC == event.getState() ) {
            log.info("applcation is started!");
        }
    }
}

啟動測試

2024-11-15T16:26:43.269+08:00  INFO 4595 --- [           main] t.c.test.demo1.Demo1ApplicationListener  : got event: org.springframework.boot.availability.AvailabilityChangeEvent[source=org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@5674e1f2, started on Fri Nov 15 16:26:40 CST 2024], state: CORRECT
2024-11-15T16:26:43.273+08:00  INFO 4595 --- [           main] t.c.test.demo1.Demo1ApplicationListener  : got event: org.springframework.boot.availability.AvailabilityChangeEvent[source=org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@5674e1f2, started on Fri Nov 15 16:26:40 CST 2024], state: ACCEPTING_TRAFFIC
2024-11-15T16:26:43.273+08:00  INFO 4595 --- [           main] t.c.test.demo1.Demo1ApplicationListener  : applcation is started!

Runner

在 application 啟動前,執行一些 code

  • ApplicationRunner

  • CommandLineRunner

@Component 或是 lambda 的 @Bean兩種寫法

package tw.com.test.demo1;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

@SpringBootApplication
@Slf4j
public class Demo1Application {

    public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class);
    }

    @Component
    public class Demo1ApplicationRunner implements ApplicationRunner {
        @Override
        public void run(ApplicationArguments args) throws Exception {
            log.info("ApplicationRunner runner args={}", args);
        }
    }
//    @Bean
//    public ApplicationRunner applicationRunner() {
//        return new Demo1ApplicationRunner();
//    }

    @Bean
    public ApplicationRunner applicationRunner2() {
        return (args) -> {
            log.info("CommandLineRunner2 args={}", args);
        };
    }

    @Bean
    public CommandLineRunner commandLineRunner() {
        return (args) -> {
            log.info("CommandLineRunner args={}", args);
        };
    }
}

執行結果

2024-11-15T16:57:46.247+08:00  INFO 4793 --- [           main] tw.com.test.demo1.Demo1Application       : CommandLineRunner args={}
2024-11-15T16:57:46.248+08:00  INFO 4793 --- [           main] tw.com.test.demo1.Demo1Application       : CommandLineRunner2 args=org.springframework.boot.DefaultApplicationArguments@d535a3d
2024-11-15T16:57:46.249+08:00  INFO 4793 --- [           main] tw.com.test.demo1.Demo1Application       : ApplicationRunner runner args=org.springframework.boot.DefaultApplicationArguments@d535a3d

2025/03/10

SpringBoot3 - Spring Boot Starter

starter 包含 自動設定及相關 library

官方都是以 spring-boot-starter-* 命名。第三方要以 *-spring-boot-starter 命名,ex: druid-spring-boot-starter

分類

application

name desc
spring-boot-starter 核心 starter,含自動設定、log 及支援 YAML
spring-boot-starter-amqp Spring AMQP, Rabbit MQ
spring-boot-starter-aop Spring AOP
spring-boot-starter-artemis Apache Artemis,支援 JMS 的 MQ
spring-boot-starter-batch Spring Batch
spring-boot-starter-cache Spring Cache
spring-boot-starter-data-cassandra Cassandra + Spring Data Cassandra
spring-boot-starter-data-cassandra-reactive Cassandra + Spring Data Cassandra Reactive
spring-boot-starter-data-couchbase Couchbase + Spring Data Couchbase
spring-boot-starter-data-couchbase-reactive Couchbase + Spring Data Couchbase Reactive
spring-boot-starter-data-elasticsearch Elasticsearch + Spring Data Elasticsearch
spring-boot-starter-data-jdbc Spring Data JDBC
spring-boot-starter-data-jpa Spring Data JPA + Hibernate
spring-boot-starter-data-ldap Spring Data LDAP
spring-boot-starter-data-mongodb MongoDB + Spring Data MongoDB
spring-boot-starter-data-mongodb-reactive MongoDB + Spring Data MongoDB Reactive
spring-boot-starter-data-neo4j Neo4J + Spring Data Neo4J
spring-boot-starter-data-r2dbc Spring Data R2DBC
spring-boot-starter-data-redis Redis + Spring Data Redis + Lettuce
spring-boot-starter-data-redis-reactive Redis + Spring Data Redis Reactive + Lettuce Client
spring-boot-starter-data-rest Spring Data REST + Spring Data repositories,輸出 REST
spring-boot-starter-freemarker 以 FreeMarker View 建立 Spring Web application
spring-boot-starter-graphql Spring GraphQL
spring-boot-starter-grovvy-templates Groovy View 建立 Spring Web application
spring-boot-starter-hateoas Spring MVC + Spring HATEOAS 建立 RESTful Web application
spring-boot-starter-integration Spring Integration
spring-boot-starter-jdbc JDBC + HikariCP connection pool
spring-boot-starter-jersey JAX-RS + Jersey 建立 RESTful Web application,可替代 spring-boot-starter-web
spring-boot-starter-jooq jOOQ 存取 SQL database。可替代 spring-boot-starter-data-jpa 或 spring-boot-starter-jdbc
spring-boot-starter-json 讀寫 JSON
spring-boot-starter-mail Java Mail + Spring Mail Sender
spring-boot-starter-mustache 以 Mustache view 建立 Web Application
spring-boot-starter-oauth2-client Spring Security's OAuth2/OpenID 客戶端連線
spring-boot-starter-oauth2-resource-server Spring Security's OAuth2 資源伺服器
spring-boot-starter-quartz Quartz
spring-boot-starter-rsocket RSocket client and server
spring-boot-starter-security Spring Security
spring-boot-starter-test JUnit Jupiter + Hamcrest + Mockito
spring-boot-starter-thymeleaf Thymeleaf View 建立 MVC web application
spring-boot-starter-validation Java Bean Validation + Hibernate Validator
spring-boot-starter-web Spring MVC 建立 RESTful Web application,以 Tomcat 為內嵌伺服器
spring-boot-starter-web-services Spring Web Services
spring-boot-starter-webflux Spring Reactive Web 建立 WebFlux application
spring-boot-starter-websocket Spring WebSocket

如果官方沒有的 starter,可使用第三方自制的 Spring Boot Starter,ex: Dubbo, ZooKeeper, MyBatis

production

name desc
spring-boot-starter-actuator Sprign Boot Actuator,正式環境的監控與應用管理

technical

可排除或替換預設的技術套件

name desc
spring-boot-starter-jetty 以 Jetty 為 servlet container,替代 spring-boot-starter-tomcat
spring-boot-starter-log4j2 log4j2,替代 spring-boot-starter-logging
spring-boot-starter-logging Logback
spring-boot-starter-reactor-netty 以 Netty 作為內嵌的 reactive http server
spring-boot-starter-tomcat 以 Tomcat 為內嵌的 servlet container,這是預設的,也被用在 spring-boot-starter-web
spring-boot-starter-undertow 以 Undertow 作為內嵌的 servlet container,可替代 spring-boot-starter-tomcat

ex: 使用 Jetty 替代 tomcat

修改pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <!-- exclude tomcat -->
            <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-jetty</artifactId>
            <!-- <artifactId>spring-boot-starter-undertow</artifactId> -->
        </dependency>

自動設定

所有自動設定的類別都是由 spring-boot-autoconfigure 模組提供的

ref: # 深入理解自動配置原理之@SpringApplcation


MailSender

spring-boot-start-mail 提供了

  • org.springframework.mail.javamail.JavaMailSender

  • org.springframework.mail.javamail.JavaMailSenderImpl

另外有一個自動設定類別

  • org.springframework.boot.autoconfigure.mail.MailSnederAutoConfiguration

該類別被註冊到這個檔案裡面,檔案內容就是自動設定類別的字串

/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

pom.xml

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

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

application.yml

spring:
  mail:
    host: smtp.gmail.com
    username: service@larzio.com
    password: XXXXXX
    properties:
      "[mail.smtp.socketFactory.class]": javax.net.ssl.SSLSocketFactory
      "[mail.smtp.socketFactory.fallback]": false
      "[mail.smtp.socketFactory.port]": 465
      "[mail.smtp.connectiontimeout]": 5000
      "[mail.smtp.timeout]": 3000
      "[mail.smtp.writetimeout]": 5000

mail:
  from: service@test.com
  fromname: service
  bcc:
  subject: Spring Boot Mail Test

Demo1Application.java

package tw.com.test.demo1;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.web.bind.annotation.RestController;

@EnableConfigurationProperties({MailProperties.class})
@RequiredArgsConstructor
@SpringBootApplication
@RestController
public class Demo1Application {
    public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class);
    }
}

MailProperties.java

package tw.com.test.demo1;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties(prefix = "mail")
public class MailProperties {
    private String from;
    private String fromname;
    private String bcc;
    private String subject;

}

EmailController.java

package tw.com.test.demo1;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.io.UnsupportedEncodingException;

@Slf4j
@RequiredArgsConstructor
@RestController
public class EmailController {
    private final JavaMailSender javaMailSender;
    private final MailProperties mailProperties;

    @RequestMapping("/sendSimpleEmail")
    @ResponseBody
    public boolean sendSimpleEmail(@RequestParam("email") String email, @RequestParam("text") String text) {
        try {
            SimpleMailMessage msg = createSimpleMsg(email, text);
            javaMailSender.send(msg);
        } catch (Exception ex) {
            log.error("Error:", ex);
            return false;
        }
        return true;
    }

    @RequestMapping("/sendMimeEmail")
    @ResponseBody
    public boolean sendMimeEmail(@RequestParam("email") String email, @RequestParam("text") String text) {
        try {
            MimeMessage msg = createMimeMsg(email, text, "java.png");
            javaMailSender.send(msg);
        } catch (Exception ex) {
            log.error("Error:", ex);
            return false;
        }
        return true;
    }

    /**
     * @param email
     * @param text
     * @param attachmentClassPathFilename
     * @return
     * @throws MessagingException
     * @throws UnsupportedEncodingException
     */
    private MimeMessage createMimeMsg(String email, String text, String attachmentClassPathFilename) throws MessagingException, UnsupportedEncodingException {
        MimeMessage msg = javaMailSender.createMimeMessage();
        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(msg, true);
        mimeMessageHelper.setFrom(mailProperties.getFrom(), mailProperties.getFromname());
        mimeMessageHelper.setTo(email);
        if (!mailProperties.getBcc().equals("")) {
            mimeMessageHelper.setBcc(mailProperties.getBcc());
        }
        mimeMessageHelper.setSubject(mailProperties.getSubject());
        mimeMessageHelper.setText(text);
        mimeMessageHelper.addAttachment(attachmentClassPathFilename, new ClassPathResource(attachmentClassPathFilename));
        return msg;
    }

    /**
     * @param email
     * @param text
     * @return
     */
    private SimpleMailMessage createSimpleMsg(String email, String text) {
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setFrom(mailProperties.getFrom());
        msg.setTo(email);
        if (!mailProperties.getBcc().equals("")) {
            msg.setBcc(mailProperties.getBcc());
        }
        msg.setSubject(mailProperties.getSubject());
        msg.setText(text);
        return msg;
    }

}

測試

http://localhost:8080/sendMimeEmail?email=charley@maxkit.com.tw&text=hello

http://localhost:8080/sendSimpleEmail?email=charley@maxkit.com.tw&text=hello