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