1. 概述

在Web应用开发中,我们经常需要在多个视图间共享同一属性。例如购物车内容需要在多个页面展示。用户Session是存储这类属性的理想位置。

本文通过一个简单示例,重点探讨两种处理Session属性的策略:

  • 使用作用域代理
  • 使用@SessionAttributes注解

2. Maven配置

使用Spring Boot快速搭建项目,核心依赖如下:

<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 Central获取。

3. 示例场景

实现一个简单的TODO应用:包含创建TodoItem的表单和展示所有TodoItem的列表视图。核心功能:

  • 通过表单创建TodoItem后,再次访问表单时自动填充最新项
  • 通过此特性演示Session属性"记忆"表单值的能力

模型类设计为简单POJO:

public class TodoItem {
    private String description;
    private LocalDateTime createDate;
    // getters and setters
}
public class TodoList extends ArrayDeque<TodoItem>{
}

TodoList继承ArrayDeque便于通过peekLast()获取最新项。需要两个Controller类(分别对应两种策略),核心功能相同,均包含三个@RequestMapping

  • @GetMapping("/form"):初始化表单并渲染视图,若TodoList非空则预填充最新项
  • @PostMapping("/form"):提交TodoItem到TodoList并重定向到列表页
  • @GetMapping("/todos.html"):添加TodoList到Model并渲染列表视图

4. 使用作用域代理

4.1 配置

TodoList声明为Session作用域的@Bean,并通过代理模式注入单例Controller。Spring在上下文初始化时创建代理对象,实际实例在请求需要时创建。

首先在@Configuration类中定义Bean:

@Bean
@Scope(
  value = WebApplicationContext.SCOPE_SESSION, 
  proxyMode = ScopedProxyMode.TARGET_CLASS)
public TodoList todos() {
    return new TodoList();
}

在Controller中注入:

@Controller
@RequestMapping("/scopedproxy")
public class TodoControllerWithScopedProxy {
    private TodoList todos;
    // constructor and request mappings
}

请求处理中直接调用方法:

@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模拟真实运行环境:

@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()));
    }
}

测试提交后表单预填充最新项:

@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 关键点分析

核心优势:不影响请求映射方法签名,代码可读性高
⚠️ 注意事项

  • Controller默认为单例,必须使用代理注入Session作用域Bean
  • 直接注入会抛出异常:Scope 'session' is not active for the current thread
  • 若将Controller设为Session作用域可避免代理,但创建成本高(每用户会话创建实例)
  • TodoList可被其他组件注入,需按需评估利弊

5. 使用@SessionAttributes注解

5.1 配置

不将TodoList声明为Spring管理的Bean,而是:

  1. 通过@ModelAttribute方法声明
  2. @SessionAttributes指定Session作用域

在Controller中声明:

@ModelAttribute("todos")
public TodoList todos() {
    return new TodoList();
}

用注解标记Session作用域:

@Controller
@RequestMapping("/sessionattributes")
@SessionAttributes("todos")
public class TodoControllerWithSessionAttributes {
    // ... other methods
}

请求处理中需显式注入:

@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";
}

重定向时使用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");
}

关键差异:重定向时通过Flash属性传递数据,避免URL编码。

5.2 单元测试

测试表单视图方法与代理策略相同,测试@PostMapping需访问Flash属性:

@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 关键点分析

优势:无需额外配置或Spring管理的Bean
劣势

  • 请求方法必须显式注入TodoList
  • 重定向场景需处理Flash属性

6. 总结

本文对比了两种Spring MVC中管理Session属性的策略:

  • 作用域代理:适合跨组件共享,需注意注入规则
  • @SessionAttributes:实现简单,但作用域限于Controller

两种方案均依赖Session生命周期,若需跨服务器重启持久化,可考虑Spring Session。完整代码见GitHub仓库


原始标题:Session Attributes in Spring MVC