Spring MVC 之传递模型数据到视图中

Spring MVC 之传递模型数据到视图中

在 《Spring MVC 起步》中,就编写超级简单的控制器来说,HomeController 已经是一个不错的样例了。但是大多数的控制器并不是这么简单。在 Spittr 应用中,我们需要有一个页面展现最近提交的 Spittle 列表。因此,我们需要一个新的方法来处理这个页面。

首先,需要定义一个数据访问的 Repository。为了实现解耦以及避免陷入数据库访问的细节之中,我们将 Repository 定义为一个接口,并在之后的《 Spring 和 JDBC 》实现它。此时,我们只需要一个能够获取 Spittle 列表的 Repository,如下所示的 SpittleRepository 功能已经足够了:

package syuez.data;

import java.util.List;

import syuez.Spittle;

public interface SpittleRepository {

    List<Spittle> findSpittles(long max, int count);

}

现在,我们让 Spittle 类尽可能的简单,如下面的程序清单 5.8 所示。它的属性包括消息内容、时间戳以及 Spittle 发布时对应的经纬度。

package syuez;

import java.util.Date;

import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;

public class Spittle {

    private final Long id;
    private final String message;
    private final Date time;
    private Double latitude;
    private Double longitude;

    public Spittle(String message, Date time) {
        this(null, message, time, null, null);
    }

    public Spittle(Long id, String message, Date time, Double longitude, Double latitude) {
        this.id = id;
        this.message = message;
        this.time = time;
        this.longitude = longitude;
        this.latitude = latitude;
    }

    public long getId() {
        return id;
    }

    public String getMessage() {
        return message;
    }

    public Date getTime() {
        return time;
    }

    public Double getLongitude() {
        return longitude;
    }

    public Double getLatitude() {
        return latitude;
    }

    @Override
    public boolean equals(Object that) {
        return EqualsBuilder.reflectionEquals(this, that, "id", "time");
    }

    @Override
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this, "id", "time");
    }

}

就大部分内容来看,Spittle 就是一个基本的 POJO 数据对象 —— 没有什么复杂的。唯一要注意的是,我们使用 Apache Common Lang 包来实现 equals() 和 hashCode() 方法。这些方法除了常规的作用以外,当我们为控制器的处理器方法编写测试时,它们也是有用的。

既然我们说到了测试,那么我们继续讨论这个话题并为新的控制器方法编写测试。如下的程序清单使用 Spring 的 MockMvc 来断言新的处理器方法中你所期望的行为。

import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;

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

import org.junit.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.servlet.view.InternalResourceView;

import syuez.Spittle;
import syuez.data.SpittleRepository;
import syuez.web.SpittleController;

public class SpittleControllerTest {
    @Test
    public void shouldShowRecentSpittles() throws Exception {
        List<Spittle> expectedSpittles = createSpittleList(20); // 模拟创建 20个 Spittle
        SpittleRepository mockRepository = mock(SpittleRepository.class); // Mock Repository
        when(mockRepository.findSpittles(Long.MAX_VALUE, 20))
                .thenReturn(expectedSpittles);

        SpittleController controller = new SpittleController(mockRepository);
        MockMvc mockMvc = standaloneSetup(controller)   // Mock Spring MVC
                .setSingleView(new InternalResourceView("/WEB-INF/views/spittles.jsp"))
                .build();

        mockMvc.perform(get("/spittles"))   // 对 "/spittles" 发起 GET 请求
                .andExpect(view().name("spittles"))
                .andExpect(model().attributeExists("spittleList"))
                .andExpect(model().attribute("spittleList",     // 断言期望的值
                        hasItems(expectedSpittles.toArray())));
    }

    private List<Spittle> createSpittleList(int count) {
        List<Spittle> spittles = new ArrayList<Spittle>();
        for (int i=0; i < count; i++) {
            spittles.add(new Spittle("Spittle " + i, new Date()));
        }
        return spittles;
    }
}

这个测试首先会创建 SpittleRepository 接口的 mock 实现,这个实现会从它的 findSpittles() 方法中返回 20 个 Spittle 对象。然后,它将这个 Repository 注入到一个新的 SpittleController 实例中,然后创建 MockMvc 并使用这个控制器。

需要注意的是,与 HomeController 不同,这个测试在 MockMvc 构造器上调用了 setSingleView()。这样的话,mock 框架就不用解析控制器中的视图名了。在很多场景中,其实没有必要这样做。但是对于这个控制器方法,视图名与请求路径是非常相似的,这样按照默认的视图解析规则时,MockMvc 就会发生失败,因为无法区分视图路径和控制器的路径。在这个测试中,构建 Internal-ResourceView 时所设置的实际路径是无关紧要的,但我们将其设置为与InternalResourceViewResolver 配置一致。

因为需要用到很多新的依赖,所以 pom.xml也需要更新下了:

  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>${spring.version}</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-jdbc</artifactId>
      <version>${spring.version}</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-test</artifactId>
      <version>${spring.version}</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.12.0</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>4.5.1</version>
      <scope>test</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.hamcrest/hamcrest -->
    <dependency>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest</artifactId>
      <version>2.2</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.13.2</version>
      <scope>test</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>4.0.1</version>
      <scope>provided</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/javax.servlet/jstl -->
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>jstl</artifactId>
      <version>1.2</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>2.1.212</version>
    </dependency>
  </dependencies>

然后测试对 /spittles 发起 GET 请求,然后断言视图的名称为 spittles 并且模型中包含名为 spittleList 的属性,在 spittleList 中包含预期的内容。 当然,如果此时运行测试的话,它将会失败。它不是运行失败,而是在编译的时候就会失败。这是因为我们还没有编写 SpittleController。现在,我们创建Spittle-Controller :

package syuez.web;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import syuez.data.SpittleRepository;

@Controller
@RequestMapping("/spittles")
public class SpittleController {

    private SpittleRepository spittleRepository;

    // 注入 spittleRepository
    @Autowired
    public SpittleController(SpittleRepository spittleRepository) {
        this.spittleRepository = spittleRepository;
    }

    @RequestMapping(method=RequestMethod.GET)
    public String spittles(Model model) {
        // 将 spittle 添加到模型中
        model.addAttribute(
                spittleRepository.findSpittles(Long.MAX_VALUE, 20)
        );
        return "spittles";  // 返回视图名
    }
}

我们可以看到 SpittleController 有一个构造器,这个构造器使用了 @Autowired 注解,用来注入 SpittleRepository。这个 SpittleRepository 随后又用在 spittles() 方法中,用来获取最新的 spittle 列表。

需要注意的是,我们在 spittles() 方法中给定了一个 Model 作为参数。这样,spittles() 方法就能将 Repository 中获取到的 Spittle 列表填充到模型中。Model 实际上就是一个 Map(也就是 key-value 对的集合),它会传递给视图,这样数据就能渲染到客户端了。当调用 addAttribute() 方法并且不指定 key 的时候,那么 key 会根据值的对象类型推断确定。在本例中,因为它是一个 List<Spittle>,因此,键将会推断为 spittleList 。

spittles() 方法所做的最后一件事是返回 spittles 作为视图的名字,这个视图会渲染模型。

如果你希望显式声明模型的 key 的话,那也尽可以进行指定。例如,下面这个版本的 spittles() 方法效果一样:

@RequestMapping(method=RequestMethod.GET)
public String spittles(Model model) {
  model.addAttribute("splittleList",
    spittleRepository.findSpittles(Long.MAX_VALUE, 20)
  );
  return "spittles";
}

如果你希望使用非 Spring 类型的话,那么可以用 java.util.Map 来代替 Model。下面这个版本的 spittles() 方法与之前的版本在功能上是一样的:

@RequestMapping(method=RequestMethod.GET)
public String spittles(Map model) {
  model.put("splittleList",
    spittleRepository.findSpittles(Long.MAX_VALUE, 20)
  );
  return "spittles";
}

既然我们现在提到了各种可替代的方案,那下面还有另外一种方式来编写spittles() 方法:

@RequestMapping(method=RequestMethod.GET)
public List<Spittle> spittles() {
  return spittleRepository.findSpittles(Long.MAX_VALUE, 20);
}

这个版本与其他的版本有些差别。它并没有返回视图名称,也没有显式地设定模型,这个方法返回的是 Spittle 列表。当处理器方法像这样返回对象或集合时,这个值会放到模型中,模型的 key 会根据其类型推断得出(在本例中,也就是 spittleList)。

而逻辑视图的名称将会根据请求路径推断得出。因为这个方法处理针对 /spittles 的 GET 请求,因此视图的名称将会是 spittles(去掉开头的斜线)。

不管你选择哪种方式来编写 spittles() 方法,所达成的结果都是相同的。模型中会存储一个 Spittle 列表,key 为 spittleList,然后这个列表会发送到名为 spittles 的视图中。按照我们配置 InternalResourceViewResolver 的方式,视图的 JSP 将会是 /WEB-INF/views/spittles.jsp

现在,数据已经放到了模型中,在 JSP 中该如何访问它呢?实际上,当视图是 JSP 的时候,模型数据会作为请求属性放到请求(request)之中。因此,在 spittles.jsp 文件中可以使用 JSTL(JavaServer Pages Standard Tag Library)的 <c:forEach> 标签渲染 spittle 列表:

<c:forEach items="${spittleList}" var="spittle" >
  <li id="spittle_<c:out value="spittle.id"/>">
    <div class="spittleMessage">
      <c:out value="${spittle.message}" />
    </div>
    <div>
      <span class="spittleTime">
        <c:out value="${spittle.time}" />
      </span>
      <span class="spittleLocation">(
        <c:out value="${spittle.latitude}" />, 
        <c:out value="${spittle.longitude}" />)
      </span>
    </div>
  </li>
</c:forEach>

图 5.3 为显示效果,能够让你对它在 Web 浏览器中是什么样子有个可视化的印象。

由于我们这里没有实现 SpittleRepository ,不能在浏览器中访问。

PS. JUnit + Mockito 单元测试之打桩when().thenReturn();

在上面的测试代码中:

when(mockRepository.findSpittles(Long.MAX_VALUE, 20))
                .thenReturn(expectedSpittles);

这里来简单聊下:

Mock 测试就是在测试过程中,对于某些不容易构造(如 HttpServletRequest 必须在Servlet 容器中才能构造出来)或者不容易获取的对象(如 JDBC 中的 ResultSet 对象, JPA 的 CRUDRepository ,需要执行数据库操作的),用一个虚拟的对象(Mock 对象)来创建(覆盖方法返回)以便测试的测试方法。

when(xxxx).thenReturn(yyyy);是指定当执行了这个方法的时候,返回 thenReturn 的值,相当于是对模拟对象的配置过程,为某些条件给定一个预期的返回值。

Mockito 中 when().thenReturn(); 这种语法来定义对象方法和参数(输入),然后在 thenReturn 中指定结果(输出)。此过程称为 Stub 打桩 。一旦这个方法被 stub 了,就会一直返回这个 stub 的值。


文本来源:5.2.3 传递模型数据到视图中 - Spring 实战(第四版) (gitbook.io) 有删改。

JUnit+Mockito单元测试之打桩when().thenReturn();_Moshow郑锴的博客-CSDN博客_thenreturn

SpringMVC 测试 mockMVC - LittleMoon - 博客园 (cnblogs.com)