前台准备完毕后。接下来进行后台的对接。后台对接主要实现两个接口:1. 根据ID获取某位学生信息接口。 2. 更新某个学生信息的接口。接口规范在[本章节](https://www.kancloud.cn/yunzhiclub/springboot_angular_guide/1378718)开始时已经给出。 前台的主体编辑功能完成后,再来观察相应的更新接口。此时发现接口的返回值并不符合前台的数据要求: 前台对编辑的返回值处理如下: ```javascript /** * 更新学生 * @param id id * @param student 学生 */ update(id: number, student: Student): Observable<Student➊> { const url = `http://localhost:8080/Student/${id}`; return this.httpClient.put<Student>(url, student); } ``` * ➊ 更新接口应该返回学生 但前期定义的接口规范返回的却是空内容: ![](https://img.kancloud.cn/74/cb/74cbeed4242a6cbff64327eadb20b009_910x162.png) 可以预见的是:如果按前面规定的接口规范开发,那么后台的更新接口将无法满足前台的功能需求。若想满足前台的功能的需求,则需要变更后台接口相应的返回值。由于此接口有了返回值,状态码也应该由204变更为200。 新的接口返回值如下: #### 响应(返回值)Responses | HTTP Code | Description | Schema | | --- | --- | --- | | **200** | 学生 | Student | 在相应功能的开发过程中,我们优先选择开发前台,然后再开发后台的开发步骤也是基于这种接口规范可能会变更的现实。这种开发步骤能够有效的避免一些接口定义无法满足前台实际需求的情况。先开发前台再开发后台,变更接口规范对后台造成的影响最小。但如果先开发后台再开发前台就完全不一样了。后台开发完成后,前台在使用的过程中发现适用有问题,此时就需要后台进行修改来适应前台。有时候这种修改会直接推翻后台的逻辑性,使原后台开发的接口的价值为0甚至为负值。 # 接口开发 先Thinking,再Coding: ![](https://img.kancloud.cn/21/bb/21bb73b3b14554c43ffdd39287e8a4fa_826x441.png) # GetById 按时序图的反方向进行初始化: 数据仓库层由于继承了Crud接口,save方法已经由该接口提供了,所以直接忽略。 服务层初始化:service/StudentService.java ``` public interface StudentService { ...java /** * 查找学生 * @param id 学生ID * @return 学生 */ Student findById(@NotNull Long id); ``` 服务层初始化:service/StudentServiceImpl.java ```java public class StudentServiceImpl implements StudentService { ... @Override public Student findById(@NotNull Long id) { return null; } ``` C层初始化:controller/StudentController.java ```java public class StudentController { ... /** * 通过ID查询学生 * @param id 学生ID * @return 学生 */ public Student getById(Long id) { return this.studentService.findById(id); } ``` > 回看KlassController中的获取某个班级时,会发现其方法名命名为:get;但此处被命名为getById。当某个方法参数较少时,可以采用`xxxBy参数a参数b`的形式来进行命名,当参数较多时,则一般直接命名为xxx。 此时在常规的开发方法中,便可以启用postman或是直接启动前开来进行功能开发了。在教程中对于已经学习过的知识点,我们仍然优先使用单元测试的方法进行功能开发。 ## 功能开发 功能开发过程仍然按从后到前的开发步骤,在单元测试还没有并熟练掌握前,这可以更好的支持传统的测试方法。 ### M层 service/StudentServiceImpl.java ```java public class StudentServiceImpl implements StudentService { ... @Override public Student findById(@NotNull Long id) { Assert.notNull(id, "id不能为null"); ➊ return this.studentRepository.findById(id).get(); ➋ } ``` * ➊ 非null校验,当传入null时,直接抛出异常并附带提示信息 * ➋ 调用仓库层返回学生 ### M层单元测试 单元测试的过程中,如果单元测试的代码过长或逻辑过于复杂,应该想办法进行拆分,将测试粒度变小。本方法的测试逻辑相对简单,是否将所有的功能放到一个单元测试中来进行测试,可以自主决定。教程中仍然采用粒度最小化原则进行测试。 测试粒度一:null测试 StudentServiceImplTest.java ```java /** * 参数为null测试 */ @Test(expected = IllegalArgumentException.class) public void findByIdNullArgument() { this.studentService.findById(null); } ``` 测试粒度2:调用测试 ```java /** * 调用测试 */ @Test public void findById() { // 准备调用时的参数及返回值 // 发起调用 // 断言返回值与预期相同 // 断言接收到的参数与预期相同 } ``` 按注释补充代码如下: ```java /** * 调用测试 */ @Test public void findById() { // 准备调用时的参数及返回值 Long id = new Random().nextLong(); Student mockReturnStudent = new Student(); Mockito.when(this.studentRepository.findById(id)).thenReturn(Optional.of(mockReturnStudent)); // 发起调用 Student student = this.studentService.findById(id); // 断言返回值与预期相同 Assertions.assertThat(student).isEqualTo(mockReturnStudent); // 断言接收到的参数与预期相同 ArgumentCaptor<Long> longArgumentCaptor = ArgumentCaptor.forClass(Long.class); Mockito.verify(this.studentRepository).findById(longArgumentCaptor.capture()); Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id); } ``` 单元测试通过,说明功能符合我们的预期。 ### C层 相对于M层,C层由于需要与前台对接,所以在测试的过程中测试点相对要多一些。 初始化 StudentControllerTest ```java @Test public void getById() { // 准备传入参数的数据 // 准备服务层替身被调用后的返回数据 // 按接口规范,向url以规定的参数发起get请求。 // 断言请求返回了正常的状态码 // 断言C层进行了数据转发(替身接收的参数值符合预期) // 断言返回的json数据符合前台要求 } ``` 按注释分步完成代码: ``` import org.assertj.core.internal.bytebuddy.utility.RandomString; ➋ @Test public void getById() throws Exception { // 准备传入参数的数据 Long id = new Random().nextLong(); // 准备服务层替身被调用后的返回数据 Student student = new Student(); student.setId(id); ➊ student.setSno(new RandomString(6).nextString()➋); ➊ student.setName(new RandomString(8).nextString()); ➊ student.setKlass(new Klass()); ➊ student.getKlass().setId(new Random().nextLong()); ➊ Mockito.when(this.studentService.findById(Mockito.anyLong())).thenReturn(student); // 按接口规范,向url以规定的参数发起get请求。 // 断言请求返回了正常的状态码 String url = "/Student/" + id.toString() ; MvcResult mvcResult = this.mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(MockMvcResultMatchers.status().isOk()) .andReturn(); // 断言C层进行了数据转发(替身接收的参数值符合预期) ArgumentCaptor<Long> longArgumentCaptor = ArgumentCaptor.forClass(Long.class); Mockito.verify(this.studentService).findById(longArgumentCaptor.capture()); Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id); // 断言返回的json数据符合前台要求 DocumentContext documentContext =JsonPath.parse(mvcResult.getResponse().getContentAsString()); ➌ LinkedHashMap studentHashMap = documentContext.json(); ➍ Assertions.assertThat(studentHashMap.get("id")).isEqualTo(Integer.valueOf(id.toString())); Assertions.assertThat(studentHashMap.get("sno")).isEqualTo(student.getSno()); Assertions.assertThat(studentHashMap.get("name")).isEqualTo(student.getName()); LinkedHashMap klassHashMap = (LinkedHashMap) studentHashMap.get("klass"); Assertions.assertThat(klassHashMap.get("id")).isEqualTo(Integer.valueOf(student.getKlass().getId().toString())); } ``` * ➊ 前台获取某个学生生,需要将这些值展示到V层或供C层使用,所以返回值在准备好这些数据。 * ➋ 另一种获取随机字符串的方法 * ➌➍ 通过两次转换,将json字符串转换为java中的LinkedHashMap对象 接下来,启动单元测试并按单元测试提示完善功能代码或修正单元测试代码: ``` java.lang.AssertionError: Status Expected :200 Actual :404 <Click to see difference> ``` 404错误说明请求的地址未找到,该错误的产生无非就两个原因:1. 请求时URL不小心拼写错了。 2. 后台没有对应建立好请求地址对应的映射。通过检查发现当前属于第2个原因。 ``` @GetMapping("{id}") ✚ public Student getById(Long id) { return this.studentService.findById(id); } ``` 再次进行测试: ``` Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id); org.junit.ComparisonFailure: Expected :555414603L ➊ Actual :null ``` * ➊ 该值是随机生成的,每次执行单元测试都会生成一个随机值,因面你本地显示的值与教程不同是正确的。 该错误提示我们:应该是使用传入的ID值来调用M层,但实际上却使用了null来调用。这是由于我们在C层未能成功的接收传入ID值造成的。 ```java public Student getById(@PathParam("id")✚ Long id) { return this.studentService.findById(id); } ``` 再次测试仍然是刚刚的错误,这仍然说明C层没有接收到传入的ID值。最终通过检查发现原来获取路径变量的应该使用`@PathVariable`而非`@PathParam`: ``` public Student getById(@PathVariable✚ Long id) { return this.studentService.findById(id); } ``` 再次进行单元测试,测试通过。单元测试看似写了较多的代码,但其实开发的效率并不低。在这种开发模式下,我们无需向数据库中写入真实的数据(实际上这项工作在一些稍大型的一些有外键约束的项目中非常的沉重),也不会额外启动一个前台或是类似于postman的工作。更重要的是还为此代码在后期项目更新的过程中提供了功能保障。长期来看,其不失为一种高效的开发方式。 ## JsonPath 在将json字符串变更为java可识别的对象时,使用了`JsonPath.parse`方法。实际上springboot已经内置了`JsonPah`并将其快速的应用到了模拟请求返回值的断言中。刚刚单元测试中对json数据的断言还可以改写成这样。 ``` @Test public void getById() throws Exception { // 准备传入参数的数据 Long id = new Random().nextLong(); // 准备服务层替身被调用后的返回数据 Student student = new Student(); student.setId(id); student.setSno(new RandomString(6).nextString()); student.setName(new RandomString(8).nextString()); student.setKlass(new Klass()); student.getKlass().setId(new Random().nextLong()); Mockito.when(this.studentService.findById(Mockito.anyLong())).thenReturn(student); // 按接口规范,向url以规定的参数发起get请求。 // 断言请求返回了正常的状态码 String url = "/Student/" + id.toString() ; MvcResult mvcResult = this.mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("id").value(id)) ✚ .andExpect(MockMvcResultMatchers.jsonPath("sno").value(student.getSno())) ✚ .andExpect(MockMvcResultMatchers.jsonPath("name").value(student.getName())) ✚ .andExpect(MockMvcResultMatchers.jsonPath("klass.id").value(student.getKlass().getId())) ✚ .andExpect(MockMvcResultMatchers.jsonPath("klass.name").value(student.getKlass().getName())) ✚ .andReturn(); // 断言C层进行了数据转发(替身接收的参数值符合预期) ArgumentCaptor<Long> longArgumentCaptor = ArgumentCaptor.forClass(Long.class); Mockito.verify(this.studentService).findById(longArgumentCaptor.capture()); Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id);f } ``` # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.7.4](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.7.4) | - | | JsonPath | [https://github.com/json-path/JsonPath](https://github.com/json-path/JsonPath)| - |