1. 概述
在開發 Web 應用程序時,我們經常需要在多個視圖中引用相同的屬性。例如,購物車內容可能需要在多個頁面上進行顯示。
將這些屬性存儲在用户的會話中是一個不錯的選擇。
在本教程中,我們將重點介紹一個簡單的示例,並探討兩種處理會話屬性的策略:
- 使用範圍代理 (Scoped Proxy)
- 使用 @SessionAttributes 註解
2. Maven 設置
我們將使用 Spring Boot Starter 來啓動我們的項目並引入所有必要的依賴。
我們的設置需要聲明一個父模塊、Web Starter 和 Thymeleaf Starter。
我們還將包含 Spring Test Starter 以在單元測試中提供一些額外的實用功能:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<relativePath/>
</parent>
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>這些依賴項的最新版本可以在 Maven 中央倉庫 中找到。
3. 示例用法
我們的示例將實現一個簡單的“TODO”應用程序。 我們將有一個用於創建 TodoItem 實例的表單,以及一個顯示所有 TodoItem 的列表視圖。
如果使用表單創建 TodoItem,則後續對錶單的訪問將使用最近添加的 TodoItem 的值進行預填充。 我們將利用 此功能來演示如何“記住”存儲在會話作用域中的表單值。
我們的 2 個模型類已實現為簡單的 POJO:
public class TodoItem {
private String description;
private LocalDateTime createDate;
// getters and setters
}public class TodoList extends ArrayDeque<TodoItem>{
}我們的 TodoList 類繼承了 ArrayDeque,以便我們通過 peekLast 方法方便地訪問最近添加的項目。
我們需要 2 個控制器類:一個用於每個我們將要查看的策略。它們在細微之處有所不同,但核心功能將在兩者中得到體現。每個控制器將具有 3 個 @RequestMapping 標註的方法:
- @GetMapping(“/form”) – 此方法將負責初始化表單並渲染表單視圖。如果 TodoList 不為空,該方法將使用最近添加的 TodoItem 預填充表單。
- @PostMapping(“/form”) – 此方法將負責將提交的 TodoItem 添加到 TodoList 並重定向到列表 URL。
- @GetMapping(“/todos.html”) – 此方法將簡單地將 TodoList 添加到 Model 以供顯示,並渲染列表視圖。
4. 使用範圍代理 (Scoped Proxy)
範圍代理是一種允許您在不直接訪問底層對象的情況下,以一種更安全和可控的方式與對象交互的技術。它通過創建一個代理對象,該代理對象封裝了對底層對象的訪問,並提供了額外的控制邏輯,例如驗證、轉換和日誌記錄。
以下是一個使用範圍代理的示例:
// 原始對象
const myObject = {
value: 10,
getValue: function() {
return this.value;
}
};
// 範圍代理
const proxyObject = new Proxy(myObject, {
get: function(target, property, value) {
console.log(`訪問屬性: ${property}`);
return target[property];
},
set: function(target, property, value) {
console.log(`設置屬性: ${property} 為 ${value}`);
target[property] = value;
return true;
}
});
// 使用代理對象
console.log(proxyObject.value); // 輸出: 訪問屬性: value, 10
proxyObject.value = 20; // 輸出: 設置屬性: value 為 20
console.log(proxyObject.value); // 輸出: 訪問屬性: value, 20
4.1. 配置
在本次配置中,我們的 TodoList 被配置為一個會話範圍的 @Bean,並由代理對象支持。由於 @Bean 是一個代理對象,因此我們可以將其注入到我們的單例範圍的 @Controller 中。
由於上下文初始化時不存在會話,Spring 會為 TodoList 創建一個代理對象,以便將其作為依賴項注入。目標 TodoList 實例將在根據請求時按需實例化。
對於 Spring 中關於 Bean 範圍的更深入討論,請參閲我們關於該主題的文章。
首先,我們在 @Configuration 類中定義我們的 Bean:
@Bean
@Scope(
value = WebApplicationContext.SCOPE_SESSION,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public TodoList todos() {
return new TodoList();
}接下來,我們將 Bean 聲明為 @Controller</em/> 的依賴,並像注入任何其他依賴一樣注入它:
@Controller
@RequestMapping("/scopedproxy")
public class TodoControllerWithScopedProxy {
private TodoList todos;
// constructor and request mappings
}
<p>最後,在請求中使用 Bean 僅僅涉及調用其方法:</p>
@GetMapping("/form")
public String showForm(Model model) {
if (!todos.isEmpty()) {
model.addAttribute("todo", todos.peekLast());
} else {
model.addAttribute("todo", new TodoItem());
}
return "scopedproxyform";
}4.2. 單元測試
為了使用範圍代理測試我們的實現,我們首先配置一個 SimpleThreadScope。 這將確保我們的單元測試準確地模擬代碼的運行時條件。
首先,我們定義一個 TestConfig 和一個 CustomScopeConfigurer:
@Configuration
public class TestConfig {
@Bean
public CustomScopeConfigurer customScopeConfigurer() {
CustomScopeConfigurer configurer = new CustomScopeConfigurer();
configurer.addScope("session", new SimpleThreadScope());
return configurer;
}
}現在我們可以先測試一個初始請求是否包含一個未初始化的 TodoItem:。
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestConfig.class)
public class TodoControllerWithScopedProxyIntegrationTest {
// ...
@Test
public void whenFirstRequest_thenContainsUnintializedTodo() throws Exception {
MvcResult result = mockMvc.perform(get("/scopedproxy/form"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertTrue(StringUtils.isEmpty(item.getDescription()));
}
}
我們還可以確認,提交請求會發出重定向,並且隨後提交表單請求會使用新增的 TodoItem 進行預填充:
@Test
public void whenSubmit_thenSubsequentFormRequestContainsMostRecentTodo() throws Exception {
mockMvc.perform(post("/scopedproxy/form")
.param("description", "newtodo"))
.andExpect(status().is3xxRedirection())
.andReturn();
MvcResult result = mockMvc.perform(get("/scopedproxy/form"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertEquals("newtodo", item.getDescription());
}4.3. 討論
使用範圍代理策略的關鍵特性在於,它不會影響請求映射方法簽名。這使得可讀性遠高於@SessionAttributes策略。
值得注意的是,控制器默認具有單例作用域。
這就是為什麼我們必須使用代理而不是簡單地注入非代理的會話範圍的 Bean。我們不能將具有較低作用域的 Bean 注入到具有較高作用域的 Bean 中。
嘗試這樣做,在這種情況下,將會引發一個帶有消息的異常,內容為:當前線程上“會話”作用域未激活。
如果我們願意定義具有會話作用域的控制器,則可以避免指定proxyMode。這在控制器創建成本較高的情況下可能存在缺點,因為一個控制器實例必須為每個用户會話創建。
請注意,TodoList 可供其他組件進行注入。這可能取決於用例,既可以是優點,也可以是缺點。如果將 Bean 使其對整個應用程序具有影響,則可以將其實例範圍設置為控制器,使用@SessionAttributes,如下一示例所示。
2. 使用 @SessionAttributes</em title="SessionAttributes"> 註解
5.1. 環境搭建
在這一步中,我們不將 TodoList 定義為 Spring 管理的 @Bean。相反,我們將其聲明為 @ModelAttribute,並指定 @SessionAttributes 註解,將其作用域設置為會話,用於控制器。
首次訪問我們的控制器時,Spring 會實例化一個實例並將其放置在 Model 中。由於我們還聲明瞭該 Bean 在 @SessionAttributes 中,Spring 會存儲該實例。
對於更深入地瞭解 Spring 中 @ModelAttribute 的討論,請參閲我們關於該主題的文章。
首先,我們通過在控制器中提供一個方法並使用 @ModelAttribute 註解來聲明我們的 Bean:
@ModelAttribute("todos")
public TodoList todos() {
return new TodoList();
}
接下來,我們使用 @SessionAttributes 告訴控制器將我們的 TodoList 視為會話範圍的數據:
@Controller
@RequestMapping("/sessionattributes")
@SessionAttributes("todos")
public class TodoControllerWithSessionAttributes {
// ... other methods
}最後,要在請求中引用該 Bean,請在 @RequestMapping</em/> 的方法簽名中提供對其的引用:
@GetMapping("/form")
public String showForm(
Model model,
@ModelAttribute("todos") TodoList todos) {
if (!todos.isEmpty()) {
model.addAttribute("todo", todos.peekLast());
} else {
model.addAttribute("todo", new TodoItem());
}
return "sessionattributesform";
}
在 @PostMapping 方法中,我們注入 RedirectAttributes 並在返回我們的 RedirectView 之前調用 addFlashAttribute。這與我們第一個示例的實現方式存在重要的差異:
@PostMapping("/form")
public RedirectView create(
@ModelAttribute TodoItem todo,
@ModelAttribute("todos") TodoList todos,
RedirectAttributes attributes) {
todo.setCreateDate(LocalDateTime.now());
todos.add(todo);
attributes.addFlashAttribute("todos", todos);
return new RedirectView("/sessionattributes/todos.html");
}Spring 使用了專門的 RedirectAttributes 實現的 Model 用於重定向場景,以支持 URL 參數的編碼。在重定向過程中,存儲在 Model 上的任何屬性通常僅對框架可用,前提是它們包含在 URL 中。
通過使用 addFlashAttribute,我們告訴框架,我們希望 TodoList 在重定向後仍然存在,無需將其編碼到 URL 中。
5.2. 單元測試
對錶單視圖控制器方法的單元測試與我們在第一個示例中看到的測試相同。對 @PostMapping 的測試略有不同,因為我們需要訪問閃存屬性以驗證行為:
@Test
public void whenTodoExists_thenSubsequentFormRequestContainsesMostRecentTodo() throws Exception {
FlashMap flashMap = mockMvc.perform(post("/sessionattributes/form")
.param("description", "newtodo"))
.andExpect(status().is3xxRedirection())
.andReturn().getFlashMap();
MvcResult result = mockMvc.perform(get("/sessionattributes/form")
.sessionAttrs(flashMap))
.andExpect(status().isOk())
.andExpect(model().attributeExists("todo"))
.andReturn();
TodoItem item = (TodoItem) result.getModelAndView().getModel().get("todo");
assertEquals("newtodo", item.getDescription());
}5.3. 討論
使用 @ModelAttribute 和 @SessionAttributes 策略存儲屬性在會話中的方法是一種簡單直接的解決方案,無需額外的上下文配置或 Spring 管理的 @Beans。
與我們之前的示例不同,需要在 @RequestMapping 方法中注入 TodoList。
此外,我們必須利用閃存屬性來處理重定向場景。
6. 結論
在本文中,我們探討了使用範圍代理和 @SessionAttributes 作為在 Spring MVC 中處理會話屬性的兩種策略。請注意,在這個簡單的示例中,存儲在會話中的任何屬性都只會在會話的生命週期內有效。
如果我們需要在服務器重啓或會話超時之間持久化屬性,我們可以考慮使用 Spring Session 以透明的方式處理信息的保存。請參閲我們關於 Spring Session 的文章以獲取更多信息。