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 的狀態


沒有留言:

張貼留言