在上个小节中我们使用单元测试的方法学习了综合查询。这在学习过程中是无可厚非的,但却不是单元测试的正规用法。单元测试更多的是用于保障自己某个方法功能的正确性,而非来其它方法。学习某个知识点最佳的方法是按官方的推荐建立个小的demo,然后在demo中添加模拟数据,并在模拟数据的基础上按官方的指导进行练习。 在进行综合查询时,我们往往会将其抽离成单元的包,并在包中针对每个实体建立单元的类。 # 初始化 综合查询的包起名为specs,我们在repository下建立specs包,并在该包中建立StudentSpecs.java ``` panjiedeMac-Pro:mengyunzhi panjie$ tree . └── springBootStudy ├── SpringBootStudyApplication.java ├── config │   └── WebConfig.java ├── controller │   ├── KlassController.java │   ├── StudentController.java │   └── TeacherController.java ├── entity │   ├── Klass.java │   ├── Student.java │   └── Teacher.java ├── repository │   ├── KlassRepository.java │   ├── StudentRepository.java │   ├── TeacherRepository.java │   └── specs │   └── StudentSpecs.java ★ └── service ├── KlassService.java ├── KlassServiceImpl.java ├── StudentService.java └── StudentServiceImpl.java ``` 编辑该文件,并建立以下静态(依赖于类而不依赖于具体的对象)方法以完成条件查询功能: # 查询条件:班级 在实体的设置中,学生与班的关系是:学生`属于`某个班级。因而在综合查询的方法中,我们对应将其查询方法命名为:belongToKlass repository/specs/StudentSpecs.java ``` /** * 学生综合查询 * */ public class StudentSpecs { /** * 属于某个班级 * @param klass 班级 * @return */ public static Specification<Student> belongToKlass(Klass klass➊) { return new Specification<Student>() { @Override public Predicate toPredicate(Root<Student> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder) { return criteriaBuilder.equal(root.get("klass").as(Klass.class), klass➋); } }; } } ``` * ➊ 将查询条件Klass做为静态方法的参数传入 * ➋ 构建给定查询时,直接使用该传入参数 ## 单元测试 使用IDEA自对生成对应的测试文件StudentSpecTest.java ``` @SpringBootTest @RunWith(SpringRunner.class) public class StudentSpecsTest { private static final Logger logger = LoggerFactory.getLogger(StudentSpecsTest.class); @Autowired private KlassRepository klassRepository; @Autowired private StudentRepository studentRepository; @Test public void belongToKlass() { logger.info("初始化测试数据"); Klass klass = new Klass(); klass.setName("testKlass"); this.klassRepository.save(klass); Student student = new Student(); student.setName("testName"); student.setSno("032282"); student.setKlass(klass); this.studentRepository.save(student); List students = this.studentRepository.findAll(StudentSpecs.belongToKlass(klass)); ① Assertions.assertThat(students.size()).isEqualTo(1);① klass.setId(-1L); ② students = this.studentRepository.findAll(StudentSpecs.belongToKlass(klass)); ② Assertions.assertThat(students.size()).isEqualTo(0); ② } } ``` * ① 以klass进行综合查询,断言条数为1 * ② 将klass的ID设置为-1,断言查询的条数为0。预测:jpa是根据关联实体的ID值进行查询的 # 查询条件:姓名 查询姓名的方法我们命名为:containingName,顾名思义:只要姓名中包含有某个关键字,即为符合条件。 ``` public class StudentSpecs { ... public static Specification<Student>➋ containingName(String name) { return new Specification<Student>()➋ { @Override public Predicate toPredicate(Root<Student> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder)➌ { return criteriaBuilder.like(root.get("name").as(String.class), String.format("%%%s%%➊", name)); } }; } } ``` * ➊ 在字符串格式化时,由于%是其关键字,所以想表示该`%`为字符串的文本时,需要将`%`转义为`%%`。所以当name为`zhangsan`时,上述格式代码将格式化为`%zhangsan%` 由于在方法中的返回值中规定了类型Specification<Student>➋,所以在书写时即使我们删除new Specification<Student>()➋代码,也不会造成误解;又由于Specification接口中仅声明了一个方法➌,所以即使我们不书写该方法名,编译器也知道我们必然是实现的该方法,故以上方法还可以简写为: ``` public class StudentSpecs { ... public static Specification<Student> containingName(String name) { return (Specification<Student>) (root, criteriaQuery, criteriaBuilder) -> { return criteriaBuilder.like(root.get("name").as(String.class), String.format("%%%s%%", name)); ➊ }; } } ``` 又由于return➊的代码仅有一行,而又同时声明了方法的返回值类型,所以即使不写return,编译器也知道我们要返回这行代码的结果,所以上述代码又可以进行下简写为: ``` public class StudentSpecs { ... public static Specification<Student> containingName(String name) { return (Specification<Student>) (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder.like(root.get("name").as(String.class), String.format("%%%s%%", name)); } } ``` 而上述简写后的代码称为:lambda表过式。 ## 单元测试 由于本功能的测试也需要进行数据的初始化工作,所以在进行编码前先对原测试代码进行小幅度重构: StudentSpecsTest.java ``` private Student student; ✚ @Before public void before() { logger.info("初始化测试数据"); Klass klass = new Klass(); klass.setName("testKlass"); this.klassRepository.save(klass); this.student = new Student(); ✚ this.student.setName("testName"); this.student.setSno("032282"); this.student.setKlass(klass); this.studentRepository.save(this.student); } @Test public void belongToKlass() { Klass klass = this.student.getKlass(); ✚ List students = this.studentRepository.findAll(StudentSpecs.belongToKlass(klass)); ``` 重构后再次执行belongToKlass测试,以保障重构未对历史的单元测试功能造成影响 。然后开始书写本测试: ``` @SpringBootTest @RunWith(SpringRunner.class) public class StudentSpecsTest { ... /** * name测试 * 1. 原文 * 2. left * 3. middle * 4. right * 5. 不包含 */ @Test public void containingName() { List students = this.studentRepository.findAll(StudentSpecs.containingName("testName")); Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.containingName("tes")); Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.containingName("stNa")); Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.containingName("tName")); Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.containingName("testName12")); Assertions.assertThat(students.size()).isEqualTo(0); } } ``` * 在测试中,我们分别对原文、以部分关键字开始、关键字取中、以部分关键字结束以及不符合的条件分别进行了测试,以此来保障代码的健壮性 # 条件查询:sno 有了刚刚的经验,对学号的条件查询便很轻松了,方法我们命名为startWithSno StudentSpecs.java ``` public class StudentSpecs { ... public static Specification<Student> startWithSno(String sno) { return (Specification<Student>) (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder.like(root.get("sno").as(String.class), String.format("%s%%①", sno)); } } ``` * ① 以%结尾 ## 单元测试 ``` @SpringBootTest @RunWith(SpringRunner.class) public class StudentSpecsTest { ... /** * sno测试 * 1. 原文 * 2. 左 * 3. 中 */ @Test public void startWithSno() { List students = this.studentRepository.findAll(StudentSpecs.startWithSno("032282")); ① Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.startWithSno("032")); ② Assertions.assertThat(students.size()).isEqualTo(1); students = this.studentRepository.findAll(StudentSpecs.startWithSno("3228")); ③ Assertions.assertThat(students.size()).isEqualTo(0); } ... } ``` * ① 原文 * ② 以部分关键字开始 * ③ 取中,断言取出0条 # 组合查询 有了上述三个查询条件后,便可以轻验的使用他们来进行组合查询了。按分层的理论,对数据进行查询的基础操作应该属于repository,所以打开repository/StudentRepository.java,并新建如下方法: ``` /** * 综合查询 * @param name containing 姓名 * @param sno startWith 学号 * @param klass equal 班级 * @param pageable * @return */ default➊ Page findAll(String name, String sno, Klass klass, Pageable pageable) { Specification<Student> specification = StudentSpecs.containingName(name) ➌ .and(StudentSpecs.startWithSno(sno)) ➍ .and(StudentSpecs.belongToKlass(klass)); ➎ return this➋.findAll(specification, pageable); } ``` * ➊ 在接口中的方法需要用default进行修饰 * ➋ this表示本(实现了这个接口的)对象 我们此时的需求是三个条询条件做`交集`,所以是`并 and`的关系,对应查询条件为:`➌ and ➍ and ➎` ## 单元测试 每个单元测试均应着重测试对应方法的功能,在本方法中StudentSpecs.containingName方法属于方法调用,原则上并不在我们的测试范围以内,本测试应该着重测试的是三个查询条件是否生效以及三个查询条件间的关系是否为`and` 打开repository/StudentRepositoryTest.java,在原findAll方法的基础上继续进行测试: ``` @Test public void findAll() { List<Student> oldStudentList = (List<Student>) this.studentRepository.findAll(); /* 初始化2个班级并持久化*/ Klass klass = new Klass(); klass.setName("testKlass"); this.klassRepository.save(klass); Klass klass1 = new Klass(); klass1.setName("testKlass1"); this.klassRepository.save(klass1); Student student = new Student(); student.setName("testStudentName"); student.setSno("032282"); student.setKlass(klass); this.studentRepository.save(student); /* 初始化2个不同班级的学生并持久化 */ Student student1 = new Student(); student1.setName("testStudentName1"); student1.setSno("032291"); student1.setKlass(klass1); this.studentRepository.save(student1); Page studentPage = this.studentRepository.findAll("testStudentName", "032282", klass, PageRequest.of(0, 2)); ① Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(1); studentPage = this.studentRepository.findAll("testStudentName12", "032282", klass, PageRequest.of(0, 2)); ② Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(0); studentPage = this.studentRepository.findAll("testStudentName", "0322821", klass, PageRequest.of(0, 2)); ③ Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(0); studentPage = this.studentRepository.findAll("testStudentName", "032282", klass1, PageRequest.of(0, 2)); ④ Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(0); } ``` * ① 加入全部查询字段 * ② 变更name值后查询到0条,说明name字段在查询中生效了同时查询条件为and的关系 * ③ sno字段,原理同上 * ④ klass字段,原理同上 单元测试通过,说明功能代码正确。 # NULL 前面我们在弃用第一种方法的时候给出的理由是该方法并不支持null值。那么第二种的综合查询方法是否支持传入null呢?我们在原测试的基础上加入对null值的测试: repository/StudentRepositoryTest.java ``` @Test public void findAll() { ... studentPage = this.studentRepository.findAll("testStudentName", "032282", klass1, PageRequest.of(0, 2)); Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(0); studentPage = this.studentRepository.findAll(null, "032282", klass, PageRequest.of(0, 2)); logger.info("传入的name为null, 得到了{}条数据", studentPage.getTotalElements()); } ``` 测试结果为: ``` 2019-11-29 14:48:38.495 INFO 51728 --- [ main] c.m.s.repository.StudentRepositoryTest : 传入的name为null, 得到了0条数据 ``` 该语句没有报异常,当name为null,返回了0条数据。这是由于JPA在进行综合查询时,当接收到值为null的name时,在查询过程中会生成类似于`name is null`的语句。但这明显与我们实际需求的并不相同。我们的需求是:当用户未传入name,即name的值为null时,应该忽略该name条件。而不是查询name为null的记录。 ## 处理NULL 我们在程序编写时有一个不成文的规则:如果未标明该参数不能为null,那么表示该参数可以为null;如果该参数不能为null,那么需要使用`@NotNull`来标注。是否允许参数为null,要根据现实的情况来设定。比如我们此时在进行综合查询时,调用者是需求将name为null的值传入的,那么就该name就必须可以为null,而我们要做的则是对null值进行处理。但既然是分页查询,那么规定用户必须传入分页的条件信息。 对name的null处理,我们放到repository/specs/StudentSpec.java中 ``` public class StudentSpecs { ... public static Specification<Student> containingName(String name) { if (name != null) { ① return (Specification<Student>) (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder.like(root.get("name").as(String.class), String.format("%%%s%%", name)); } else { return Specification.where(null); ② } } ... } ``` * ① 对参数值进行null判断 * ② 当参数为null时,返回一个空规范,该空规范不在查询中起任何作用 此时我们重新进行单元测试: ``` 2019-11-29 15:03:25.004 INFO 63864 --- [ main] c.m.s.repository.StudentRepositoryTest : 传入的name为null, 得到了1条数据 ``` 则在查询条件中当name为null做的是忽略处理,而非null查询。 > 注意:在StudentRepositoryTest中来测试StudentSpec代码是正确性是错误的,请自行修正。 ### sno的null处理 sno的处理方式与name的方式相同,请自行完成。 ### klass的null处理 jpa在进行关联查询时,实际上查询的是关联实体的主键值信息,也就是说:当传入klass时,JPA是依据klass的id值来进行查询的,如果未传入id, ``` @Test public void findAll() { ... studentPage = this.studentRepository.findAll(null, null, new Klass()★, PageRequest.of(0, 2)); Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(1); ``` 在进行相关的查询时便会报以下异常信息: ``` org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.mengyunzhi.springBootStudy.entity.Klass; nested exception is java.lang.IllegalStateException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: com.mengyunzhi.springBootStudy.entity.Klass ``` 他大体是说:你即然要以klass为查询条件对student表进行查询,那么klass最少得是咱数据表中存在的数据吧(如果不存在klass,是不可能将其设置为student的外键值的)。 所以我们还需要对klass及其id值是否为null进行处理: repository/specs/StudentSpecs.java ``` public static Specification<Student> belongToKlass(Klass klass) { if (null == klass || null == klass.getId()) { return Specification.where(null); } return (Specification<Student>) (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder.equal(root.get("klass").as(Klass.class), klass); } ``` ### pageable的null处理 前面三个参数均进行了null的处理,当方法`default Page findAll(String name, String sno, Klass klass, Pageable pageable) {`中的第四个参数为null时,JPA会为我们自动处理吗? 我们继续增加测试语句: ``` @Test public void findAll() { ... studentPage = this.studentRepository.findAll(null, null, null, null); Assertions.assertThat(studentPage.getTotalElements()).isEqualTo(2); ``` 运行测试则得到以下异常: ``` 2019-11-29 15:22:04.815 INFO 79226 --- [ main] o.h.h.i.QueryTranslatorFactoryInitiator : HHH000397: Using ASTQueryTranslatorFactory java.lang.NullPointerException ``` 这是个空指针异常,该异常是在程序执行时在调用某个对象的某个方法时,由于该对象为null产生的。也就是说pageable为null也是不可行的。做为一个负责任的开发者,我们需要用更人性化的方式来告知我们团队的其它成员pageable是不能够为null的. #### @NotNull 最基本的,我们需要在参数上添加一个`@NotNull`注解来表时该参数是不可以为null的。 ``` import javax.validation.constraints.NotNull; ... public interface StudentRepository extends PagingAndSortingRepository<Student, Long>, JpaSpecificationExecutor { ... default Page findAll(String name, String sno, Klass klass, @NotNull➊ Pageable pageable) { ... } } ``` * ➊ 告知队友:该参数不能为null 虽然我们在此明确的告知队友说:该参数不能为null。但有时候不怕神一样的对手...。@NotNull的作用仅仅是`mark 标记`,除此之外,它什么作用都没有。当然也就不能够指望说在队友传入null时,编译器会报异常了(实质上除非是显性的传入null,编译器是无法判断传入的值是否可能为null的)。 #### 异常 使用异常的方式来告知队友我们是不支持null的可以降低他调用我们产生空指针错误的排错难度,虽然说即使我们什么也不做,有经验的队友在调用我们发生null异常时仍然可以根据异常及我们的代码来判断出具体是出了什么问题,但这种做法总归是不负责任的。这就像我们该死的中国联通,A处理业务时发生了错误,然后告知你去哪哪哪找B,到了B那又告知你去哪哪找C,最后C再让你去找A。做为客户的我们,更希望的是"首问负责制",也就说我调用你发生的问题,你就应该直接告诉我怎么解决,而不是让客户去替你DEBUG。 ``` public interface StudentRepository extends PagingAndSortingRepository<Student, Long>, JpaSpecificationExecutor { default Page findAll(String name, String sno, Klass klass, @NotNull Pageable pageable) { if (null == pageable) { throw new IllegalArgumentException("传入的Pageable不能为null"); ➊ } ... ``` * ➊ 当接收的参数为null,抛出`非法参数异常` 此时当我们继续以null调用时: ``` @Test public void findAll() ... studentPage = this.studentRepository.findAll(null, null, null, null); ``` 则会得到如下异常提示: ``` org.springframework.dao.InvalidDataAccessApiUsageException: 传入的Pageable不能为null; nested exception is java.lang.IllegalArgumentException: 传入的Pageable不能为null ``` 同时,我们还可以在控制台中找到该异常的位置 : ![](https://img.kancloud.cn/67/ae/67ae7ed2603f6942a73042a3e39c1bb4_1343x130.png) 由于该参数异常是较常规的写法,所以spring友好的为我们进行了封装,当进行参数的异常声明时,我们也可以这样: ``` import org.springframework.util.Assert; ... public interface StudentRepository extends PagingAndSortingRepository<Student, Long>, JpaSpecificationExecutor { default Page findAll(String name, String sno, Klass klass, @NotNull Pageable pageable) { Assert.notNull(pageable, "传入的Pageable不能为null"); ✚ if (null == pageable) { ✘ throw new IllegalArgumentException("传入的Pageable不能为null"); ✘ } ✘ ``` 效果相同。最后我们进行异常测试。测试该异常的方法有两种:第一种是在已经长的不行的findAll方法上再加入几行,并用try catch来进行捕获;第二种是单独在建立一个测试用例来专门测试异常。一般情况下,我们更习惯于使用第一种;而使用第二种也是正确的做法。在单元测试时,力求将测试的粒度最小化,把各个测试用例需要用到的具有共性的代码抽离到@Before方法中,然后在各个测试用例中尽量只测试一个简单到不能分解的功能。这样的做的好处最少有两点:1. 由于每次只测一小点,所以可以减小我们测试时的思维量,避免烧脑;2. 当单元测试发生错误时,由于测试代码的行数较长,所以修正代码的工作量也会很小。在一般的风格指南或是开发规范上都会以类似的话术:如果一个函数内的代码行数超过40行,便可以思索一下能不能在不影响程序结构的前提下对其进行分割了。 原文对于长函数是这么解释的: <hr /> 我们承认长函数有时是合理的, 因此并不硬性限制函数的长度. 如果函数超过 40 行, 可以思索一下能不能在不影响程序结构的前提下对其进行分割. 即使一个长函数现在工作的非常好, 一旦有人对其修改, 有可能出现新的问题, 甚至导致难以发现的 bug. 使函数尽量简短, 以便于他人阅读和修改代码. 在处理代码时, 你可能会发现复杂的长函数. 不要害怕修改现有代码: 如果证实这些代码使用 / 调试起来很困难, 或者你只需要使用其中的一小段代码, 考虑将其分割为更加简短并易于管理的若干函数. <hr /> ``` import org.springframework.dao.InvalidDataAccessApiUsageException; ➊ @SpringBootTest @RunWith(SpringRunner.class) public class StudentRepositoryTest { ... @Test(expected = InvalidDataAccessApiUsageException.class) public void findAllWithPageableIsNull() { this.studentRepository.findAll("name", "sno", new Klass(), null); } ``` * ➊ 此处需要捕获InvalidDataAccessApiUsageException而非IllegalArgumentException(spring对其进行了封装),了解即可。 # 总结 本小节我们费劲周折,目标就是在学生仓库中建立一个可以支持多字段综合查询及分页的findAll方法。一旦有了此方法的支持,我们便可以轻松的service中对其进行调用,进而实现对学生表的综合查询。在程序的编写过程中,我们进行了充分的分层及单元测试、友好的null提示,目标仅为一个:**编写易读、友好、易维护的代码。** # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.4](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.4) | - |