既然是常用的功能,那么spring必然已经有了最佳实践。在进行最佳实践前,我们来简单汇制下时序图: ![](https://img.kancloud.cn/57/4b/574b504d0ad95f90f9dc703efacbef3f_1040x443.png) 考虑到该功能实现的复杂性,我们在此使用敏捷开发(agile development)的方法,先开发分页功能,再开发综合查询功能。 # CurdRepository 无论经过多少次转发,最终实现数据分页查询的必然是仓库层。StudentRepository继承了CurdRepository,进行spring为其自动实现了一些基本的增改查删的功能。我们打开CurdRepository来简单浏览一下这个文件: CurdRepository ``` package org.springframework.data.repository; import java.util.Optional; @NoRepositoryBean public interface CrudRepository<T, ID> extends Repository<T, ID> { <S extends T> S save(S var1); ➊ <S extends T> Iterable<S> saveAll(Iterable<S> var1); ➊ Optional<T> findById(ID var1); ➋ boolean existsById(ID var1); ➋ Iterable<T> findAll(); ➋ Iterable<T> findAllById(Iterable<ID> var1); ➋ long count(); ➋ void deleteById(ID var1); ➌ void delete(T var1); ➌ void deleteAll(Iterable<? extends T> var1); ➌ void deleteAll(); ➌ } ``` * ➊ 新增/更新功能 * ➋ 查询功能 * ➌ 删除功能 通过查看我们发现其提供的查询功能中并没有找到我们需要的分页功能。的确是这样,在spring中CurdRepository只提供了基本的增改查删功能,如果想实现更复杂的分页功能,则需要继承其它的接口。 # PagingAndSortingRepository spring为我们提供了`org.springframework.data.repository.PagingAndSortingRepository;`来满足对分页功能的需求,要想使用此接口给我们带来的功能,只需要继承该接口即可. repository/StudentRepository.java ``` package com.mengyunzhi.springBootStudy.repository; import com.mengyunzhi.springBootStudy.entity.Student; import org.springframework.data.repository.PagingAndSortingRepository; ① /** * 学生 */ public interface StudentRepository extends PagingAndSortingRepository<Student, Long>② { } ``` * ① 使用前先引入 * ② 和CrudRepository相同,继承该接口时,需要指定实体类型及实体的主健类型 此时,我们应该有个疑问:在历史的代码中,我们是通过间接调用CrudRepository的save方法来完成的数据新增功能。而当前修改了继承的接口,那么以前代码中间接调用CrudRepository.save方法还可以正常工作吗?为此,我们借助idea来看一下当前接口的继承关系: ![](https://img.kancloud.cn/28/fb/28fb948d0e6956479527ee5163ee6d8a_639x492.png) 依图所示,StudentRepository继承了PagingAndSortingRepository,PagingAndSortingRepository又继承了CrudRepository。因而我们在历史的代码中书写的学生保存的相关功能性代码仍然可用。在调用studentRepository的save方法时,它会按照继承的原则:此类没有则转向父类、父类没有则转向父父类,依此累推,最终仍然会调用到CrudRepository的save方法。 PagingAndSortingRepository中有两个方法: ``` @NoRepositoryBean public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> { Iterable<T> findAll(Sort var1); ➊ Page<T> findAll(Pageable var1); ➋ } ``` * ➊ 接收的参数类型为**排序**,返回值为**迭代器**,**迭代器**可以认为是数组的一种,与数组不同的是:我们获取数组中的子项时,不能够再使用索引的方法,而只能使用其它特定的方法。 * ➋ 接收的参数类型为\*\*(可)分页\*\*,返回值为**含有总页数及当前页数组的特定类型**。Page类型除包含总页数、当前页数据外,还包含了第几页、每页大小、总条数、排序规则、是否首页、是否尾页、是否还有下一页、是否还有上一页等其它的与分页相关的信息。 # 获取分页数据 要想获取分页数据,首先需要获取一个实现了Pageable接口的对象,该对象可使用`Pageable pageable = PageRequest.of(page, size)`来初始化。比如我们想获取每页10条情况下,第1页的数据则可以使用如下的方法: repository/StudentRepositoryTest.java(请新建) ``` package com.mengyunzhi.springBootStudy.repository; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; ① import org.springframework.data.domain.PageRequest; ② import org.springframework.data.domain.Pageable; ③ import org.springframework.test.context.junit4.SpringRunner; @SpringBootTest @RunWith(SpringRunner.class) public class StudentRepositoryTest { @Autowired StudentRepository studentRepository; @Test public void page() { Pageable pageable = PageRequest.of(0, 10); ➊ Page<Student> studentPage = studentRepository.findAll(pageable); ➋ return; ➌ } } ``` * ①②引入特定的类,由于有多个重名的类,所以在此处需要注意该类的位置。 * ➊ 初始化第0页、每页10条的分页查询条件 * ➋ 查询分页数据 * ➌ 加个冗余的return用于debug程序 接着我们在此处打个断点: ![](https://img.kancloud.cn/4f/20/4f20c13909259ca4e6a1e1b1700485b0_753x211.png) 然后用debug模式启动该单元测试 ![](https://img.kancloud.cn/c5/c8/c5c8593e184e27fcbe27ec67cff49751_565x223.png) 并展开studentPage如下: ![](https://img.kancloud.cn/39/c6/39c6dd26104cb2296539bcb0f4e9c600_518x277.png) 上图所示,返回值Page中含有: * 数据总条数0 * 当前面数据content * 分页信息pageable * 当前为第0页 * 每页10条数据 将如上对象直接返回给前台,完全可以满足我们的当前需求。 ## 数据测试 接下来,我们在测试中加入测试数据,再次debug看看实际的返回值 ``` @Autowired KlassRepository klassRepository; @Test public void page() { Klass klass = new Klass(); klass.setName("testKlass"); this.klassRepository.save(klass); for (int i = 0; i < 100; i++) { Student student = new Student(); student.setName(RandomString.make(4)); student.setSno(RandomString.make(6)); student.setKlass(klass); this.studentRepository.save(student); } Pageable pageable = PageRequest.of(2, 15); Page<Student> studentPage = studentRepository.findAll(pageable); return; } ``` 再次debug中断查看: ![](https://img.kancloud.cn/7f/49/7f4977737b1371780a12f187c14e7349_725x306.png) 查看content: ![](https://img.kancloud.cn/34/4b/344b4f0f8b1e65c281f88b3c3fa63973_467x280.png) 如此,我们便有了实现数据分页功能的基础。 # M层 当我们第一次使用某个功能的时候(还处于解决技术障碍中),我们的首页目标是借助于debug来弄清楚该功能的具体使用方法,传入值与返回的类型等,单元测试应该放在后面进行补充。TDD测试驱动开发仅限于我们对某个功能的实现不存在技术上的障碍时。在此,我们先完成M层的功能部分,再对应进行测试代码的编写. service/StudentService.java ``` /** * 查询分页信息 * * @param pageable 分页条件 * @return 分页数据 */ Page<Student> findAll(Pageable pageable); ``` service/StudentServiceImpl.java ``` @Override public Page<Student> findAll(Pageable pageable) { return this.studentRepository.findAll(pageable); } ``` ## 单元测试 按前面的经验, 整理单元测试代码如下: service/StudentServiceImplTest.java ``` /** * 分页查询 * 1. 模拟输入、输出、调用studentRepository * 2. 调用测试方法 * 3. 断言输入与输出与模拟值相符 */ @Test public void findAll() { Pageable mockInPageable = PageRequest.of(1, 20); ① List<Student> mockStudents = Arrays.asList(new Student()); ② Page<Student> mockOutStudentPage = new PageImpl<Student>( mockStudents, PageRequest.of(1, 20), 21); ➊ Mockito.when(this.studentRepository.findAll(Mockito.any(Pageable.class))) .thenReturn(mockOutStudentPage); ④ Page<Student> studentPage = this.studentService.findAll(mockInPageable); ⑤ Assertions.assertThat(studentPage).isEqualTo(mockOutStudentPage); ⑥ ArgumentCaptor<Pageable> pageableArgumentCaptor = ArgumentCaptor.forClass(Pageable.class); Mockito.verify(this.studentRepository).findAll(pageableArgumentCaptor.capture()); Assertions.assertThat(pageableArgumentCaptor.getValue()).isEqualTo(mockInPageable); ⑦ ``` * ① 模拟输入 * ② 初始化返回分页信息的本页数据部分 * ③ 使用 本页数据、 分页情况、总条件来初始化模拟返回值Page<Student> * ④ 模拟studentRepository.findAll方法的返回值 * ⑤ 调用被测试方法 * ⑥ 断言返回值 * ⑦ 断言传入参数 ![](https://img.kancloud.cn/6e/c3/6ec37d065a5c2efbe7b12f9df392e6fd_431x139.png) # C层 C层的代码也很简单: controller/StudentController.java ``` @GetMapping public Page<Student> findAll(@RequestParam int page, @RequestParam int size) { return this.studentService.findAll(PageRequest.of(page, size)); } ``` ## 单元测试一 为了更清楚的了解真实情况的返回值,我们暂且将单元测试中StudentService的注解由@MockBean改为@Autowired,然后模拟添加一些数据,看看真实情况下会给我们返回什么样的数据(注意:这违背了单元测试的原则。在单元测试中,我们的测试内容应该围绕输入与输出展开。对于被测试方法在执行期间调用其它的方法的,应该使用MOCK来进行模拟)。 contoroller/StudentControllerTest.java ``` @Autowired ✚ @MockBean ✘ private StudentService studentService; @Autowired private KlassRepository klassRepository; ① @Autowired private StudentRepository studentRepository; ① @Test public void findAll() throws Exception { logger.info("准备100条测试数据"); Klass klass = new Klass(); klass.setName("testKlass"); this.klassRepository.save(klass); for (int i = 0; i < 100; i++) { Student student = new Student(); student.setName(RandomString.make(4)); student.setSno(RandomString.make(6)); student.setKlass(klass); this.studentRepository.save(student); } logger.info("每页2条,请求第1页数据"); String url = "/Student?page=49&size=2"; ② this.mockMvc.perform(MockMvcRequestBuilders.get(url)) .andDo(MockMvcResultHandlers.print()) ③ .andExpect(MockMvcResultMatchers.status().isOk()); } ``` * ① 引入数据仓库 * ② 将每页大小、当前页两个查询参数直接拼接到URL中 * ③ 在控制台中打印返回的结果 启动单元测试后在控制台中得到如下返回信息: ``` Body = {"content":[{"id":99,"name":"FtJf","sno":"56IhJV","klass":{"id":1,"teacher":null,"name":"testKlass"}},{"id":100,"name":"WHpT","sno":"YVwSqA","klass":{"id":1,"teacher":null,"name":"testKlass"}}],"pageable":{"sort":{"sorted":false,"unsorted":true,"empty":true},"offset":98,"pageSize":2,"pageNumber":49,"paged":true,"unpaged":false},"totalPages":50,"totalElements":100,"last":true,"size":2,"number":49,"numberOfElements":2,"first":false,"sort":{"sorted":false,"unsorted":true,"empty":true},"empty":false} ``` 对其进行格式化后如下: ``` { ① "content": [ ②③ { ④ "id": 99, "name": "FtJf", "sno": "56IhJV", "klass": { "id": 1, "teacher": null, "name": "testKlass" } }, { ④ "id": 100, ⑤ "name": "WHpT", ⑤ "sno": "YVwSqA", ⑤ "klass": ⑤ { "id": 1, ⑥ "teacher": null, ⑥ "name": "testKlass" ⑥ } }], "pageable": ②⑦ { "sort": { "sorted": false, "unsorted": true, "empty": true }, "offset": 98, "pageSize": 2, "pageNumber": 49, "paged": true, "unpaged": false }, "totalPages": 50, ② "totalElements": 100, ② "last": true, ② "size": 2, ② "number": 49, ② "numberOfElements": 2, ② "first": false, ② "sort": ②⑧ { "sorted": false, "unsorted": true, "empty": true }, "empty": false ② } ``` * ① 返回值为一个对象 Page * ② 对象①的各个属性 * ③ 当前页内容 Array<Student> * ④ 数组中有两个对象 Student * ⑤ Student对象④的属性 * ⑥ Klass对象⑤的属性 * ⑦ 分页条件信息 * ⑧ 排序条件信息 如上所示,spring不仅仅返回了当前页的数据、分页条件、总页数、数据总数信息,还返回了是否尾页、每页大小、当前页码(0基)、当前页数据条数、是否首页、排序、当前数据是否为空信息。这些数据为前台提供了良好的支持。 ## 单元测试二 让我们恢复刚刚的测试,继续使用模拟的服务层来完成C层的测试。 ``` @Autowired ✘ @MockBean ✚ private StudentService studentService; @Autowired ✘ private KlassRepository klassRepository; ✘ @Autowired ✘ private StudentRepository studentRepository; ✘ @Test public void findAll() throws Exception { logger.info("初始化模拟返回数据"); List<Student> students = new ArrayList<>(); Klass klass = new Klass(); klass.setId(-2L); for (long i = 0; i < 2; i++) { Student student = new Student(); student.setId(-i - 1); student.setSno(RandomString.make(6)); student.setName(RandomString.make(4)); student.setKlass(klass); students.add(student); } logger.info("初始化分页信息及设置模拟返回数据"); Page<Student> mockOutStudentPage = new PageImpl<Student>( students, PageRequest.of(1, 2), 4 ); Mockito.when(this.studentService.findAll(Mockito.any(Pageable.class))) .thenReturn(mockOutStudentPage); logger.info("以'每页2条,请求第1页'为参数发起请求,断言返回状态码为200,并接收响应数据"); String url = "/Student"; MvcResult mvcResult = this.mockMvc.perform( MockMvcRequestBuilders.get(url) .param("page", "1") .param("size", "2")) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn(); logger.info("将返回值由string转为json,并断言接收到了分页信息"); LinkedHashMap① returnJson = JsonPath.parse(mvcResult.getResponse().getContentAsString()).json(); Assertions.assertThat(returnJson.get("totalPages")).isEqualTo(2); // 总页数 Assertions.assertThat(returnJson.get("totalElements")).isEqualTo(4); // 总条数 Assertions.assertThat(returnJson.get("size")).isEqualTo(2); // 每页大小 Assertions.assertThat(returnJson.get("number")).isEqualTo(1); // 第几页(0基) Assertions.assertThat(returnJson.get("numberOfElements")).isEqualTo(2); // 当前页条数 //todo: 断言获取到了content,类型为数组 ➊ return; } ``` 通过✚✘标记可以看到,由于Mock的加入,在进行C层功能代码的测试时,我们仅仅需要考虑C层直接调用的服务层StudentService.findAll的输入输出即可,而StudentService是否实现了其描述的功能以及如何实现的其描述的功能,我们完全不关心也不需要关心。 * ➊ 只所以在这里出现todo,是由于①LinkedHashMap这个容器可以装入任意类型,所以我们无法通过returnJson.get("content")来获取其content的数据类型。在第一次接触时,我们需要debug来帮我查看content的数据类型然后继续完成后续的操作。 为此,我们在此处打个断点: ![](https://img.kancloud.cn/b2/cd/b2cde832b89583857f74429396e4ac69_1464x220.png) 然后启动debug,并在debug控制台中找到returnJson: ![](https://img.kancloud.cn/25/7d/257df9b230e33ca65de476c80d8df2ad_672x333.png) 得到具体的类型后,我们继续完成测试: ``` import net.minidev.json.JSONArray; ★ ... @Test public void findAll() throws Exception { ... Assertions.assertThat(returnJson.get("numberOfElements")).isEqualTo(2); // 当前页条数 logger.info("测试content"); JSONArray content = (JSONArray★) returnJson.get("content"); Assertions.assertThat(content.size()).isEqualTo(2); // 返回了2个学生 logger.info("测试返回的学生"); for (int① i = 0; i < 2; i++) { LinkedHashMap studentHashMap = (LinkedHashMap) content.get(i); // 获取第一个学生 Assertions.assertThat(studentHashMap.get("id")).isEqualTo(-i - 1); Assertions.assertThat(studentHashMap.get("name").toString().length()).isEqualTo(4); Assertions.assertThat(studentHashMap.get("sno").toString().length()).isEqualTo(6); logger.info("测试返回学生所在的班级"); LinkedHashMap klassHashMap = (LinkedHashMap) studentHashMap.get("klass"); Assertions.assertThat(klassHashMap.get("id")).isEqualTo(-2); Assertions.assertThat(klassHashMap.get("name")).isEqualTo("test klass name"); } return; } ``` * ★ 注意此处的类型为:net.minidev.json.JSONArray * ① 此处是int,不是long。 >[success] 在C层的单元测试中,对每个前台需要的测试都加入相应的断言是非常有必要的。在生产项目中如果未对C层的输出字段进行断言,则必然发生在后台的敏捷开发中造成前台部分功能失效的问题。 # 总结 我们在本小节中花费了大量的精力来编写单元测试。在编写的过程中我们感受到:编写单元测试的难度远远超出了编写功能代码的难度;编写单元测试的时间远远的超出了编写功能代码的时间。而这,是非常有必要的。在软件开发的所有的专业课中,软件工程是在学习的时候最不容易引起重视但却在实战中起出保障软件质量关键一环的核心课程。如果你不希望自己以后编写的软件在每次更新后都会发生或多或少的非预期错误,如果你不希望自己本已经编写好的功能在其它团队成员的协助开发下变得不可用,如果你想做一个对前台负责的后台开发工程师、如果你不想在新的版本上线后天天打喷嚏、如果你希望随着需求的发展及新技术的普通而能够放开手脚的重构代码、如果你的目标是Engineer而不是Programmer,那么从现在起请注重**单元测试**! # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.1](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.1) | - |