一切由实践中来,一切又要到实践中去。在没有进行正式的对接前还不能说当前的思想及实现是一定没有问题的。本节将利用上一小节中的/Teacher/me接口来实现简单的个人中心功能。 # 初始化 打开前台并来到app文件夹,使用`ng g c PersonalCenter`初始化个人中心 ``` panjiedeMac-Pro:app panjie$ ng g c PersonalCenter CREATE src/app/personal-center/personal-center.component.sass (0 bytes) CREATE src/app/personal-center/personal-center.component.html (30 bytes) CREATE src/app/personal-center/personal-center.component.spec.ts (685 bytes) CREATE src/app/personal-center/personal-center.component.ts (305 bytes) UPDATE src/app/app.module.ts (1356 bytes) ``` 将personal-center.component.spec.ts中的测试用例由`it`暂时修改为`fit`后启动单元测试。 ## C层 personal-center/personal-center.component.ts ```typescript import { Component, OnInit } from '@angular/core'; import {Teacher} from '../norm/entity/Teacher'; @Component({ selector: 'app-personal-center', templateUrl: './personal-center.component.html', styleUrls: ['./personal-center.component.sass'] }) export class PersonalCenterComponent implements OnInit { /** 绑定到V层 */ public teacher: Teacher; constructor() { } ngOnInit() { // 调用M层的相关方法 } } ``` ## V层 在V层显示当前登录用户的基本信息:用户名、姓名、性别、email四项信息。 personal-center.component.html ```html <div class="row"> <div class="col-sm-2 text-right">姓名</div> <div class="col-sm-10"> {{teacher.name}} </div> </div> <div class="row"> <div class="col-sm-2 text-right">用户名</div> <div class="col-sm-10"> {{teacher.username}} </div> </div> <div class="row"> <div class="col-sm-2 text-right">性别</div> <div class="col-sm-10"> {{teacher.sex}} </div> </div> <div class="row"> <div class="col-sm-2 text-right">email</div> <div class="col-sm-10"> {{teacher.email}} </div> </div> ``` ## 单元测试 在CV层的基础上测试CV层的数据绑定是否成功。测试步骤为:1.在C层设置teacher的值。2.V层获取显示的值并进行断言 测试的重点在于如何通过V层的class值获取到输出的问题,具体的过程略。 # 功能开发 像这种直接由后台获取数据后显示在V层的逻辑非常的简单。C层中直接调用M层的相关方法获取返回值即可。 ## M层 打开TeacherService新建me方法 service/teacher.service.ts ```typescript /** * 获取当前登录的教师 */ me(): Observable<Teacher> { const url = 'http://localhost:8080/Teacher/me'; return this.httpClient.get<Teacher>(url); } ``` ### 单元测试 service/teacher.service.spec.ts ```typescript fit('me', () => { // 获取service实例 const service: TeacherService = TestBed.get(TeacherService); // 调用测试方法 let result; service.me().subscribe((teacher) => { result = teacher; }); // 断言发起了特定的http请求 const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne('http://localhost:8080/Teacher/me'); expect(req.request.method).toEqual('GET'); // 模拟返回数据,请断言在订阅的方法中成功的接收到了数据 const mockReturnTeacher = new Teacher(null, null, null, null); req.flush(mockReturnTeacher); expect(result).toBe(mockReturnTeacher); }); ``` ## C层代码对接 personal-center/personal-center.component.ts ```typescript constructor(private teacherService: TeacherService) { } ngOnInit() { // 调用M层的相关方法 this.teacherService.me().subscribe((teacher) => { this.teacher = teacher; }); } ``` ### 单元测试 在单元测试中,沿用引用TestModule的方式。该方式能够自动注入TeacherService的替身TeacherStubService personal-center/personal-center.component.spec.ts ```typescript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ PersonalCenterComponent ], imports: [ TestModule ➊ ] }) .compileComponents(); })); fit('ngOnInit', () => { }); ``` * ➊ 引入TestModule 初充功能如下: personal-center/personal-center.component.spec.ts ```typescript fit('ngOnInit', () => { const teacherService = TestBed.get(TeacherService) as TeacherService; const mockReturnTeacher = new Teacher(null, null, null); spyOn(teacherService, 'me').and.returnValue(of(mockReturnTeacher)); component.ngOnInit(); expect(component.teacher).toBe(mockReturnTeacher); }); ``` 测试结果: ``` TypeError: this.teacherService.me is not a function ``` 此错误前面碰到过,原因是由于做为替身出现的TeacherStubService中并没有me方法,所以在调用`spyOn(teacherService, 'me')`出现了该错误。找到此替身被加入`me`方法 test/service/teacher-stub.service.ts ```typescript me(): Observable<Teacher> { return null; } ``` 出现错误如下: ``` TypeError: Cannot read property 'subscribe' of null ... at PersonalCenterComponent.ngOnInit (http://localhost:9876/_karma_webpack_/src/app/personal-center/personal-center.component.ts:17:29) ``` 错误显示在以下代码的执行过程中发出了null错误: personal-center/personal-center.component.ts ```typescript ngOnInit() { // 调用M层的相关方法 this.teacherService.me()★.subscribe((teacher) => { this.teacher = teacher; }); } ``` * ★ 此方法的返回值为null 这个此错误的原因如下: 在单元测试文件中的测试用例执行以前首先会构建一个测试组件,语句如下: ``` beforeEach(() => { fixture = TestBed.createComponent(PersonalCenterComponent); component = fixture.componentInstance; fixture.detectChanges(); ★ }); ``` * ★ 此语句首次被执行时,将调用`ngOnInit`方法一次。 该测试组件构造完成后,才会执行测试用例中的测试代码。而前面的错误就在于执行`fixture.detectChanges()`时自动调用的`ngOnInit`上。此时执行`ngOnInit`时,组件中的teacherService为替身TeacherStubService,所以在执行teacherService.me()方法时相当于调用的是TeacherStubService中的me()方法,而该方法的返回值被设置为null。便发生了在null上调用subscribe方法的错误。 >[success] 在构建组件时,组件中的ngOnInit方法会被自动调用一次。测试代码中的fixture.detectChanges()模似了该过程。 修正方法为设置TeacherService.me()方法的返回值: test/service/teacher-stub.service.ts ```typescript me(): Observable<Teacher> { return of(new Teacher(1, 'username', 'name')); } ``` >[info] 在生产环境中,会为此方法设置与后台返回字段一一对应的示例返回值。 再次运行单元测试通过。 # 集成测试 为个人中心设置路由及菜单项如下 app-routing.module.ts ```typescript const routes: Routes = [ { path: '', component: WelcomeComponent }, { path: 'personalCenter', component: PersonalCenterComponent }, { path: 'teacher', component: TeacherIndexComponent }, ``` nav/nav.component.ts ```typescript this.menus.push({url: 'student', name: '学生管理'}); this.menus.push({url: 'personalCenter', name: '个人中心'}); } ``` 启动前后台,并在数据表中添加登录用户后进行测试: ![](https://img.kancloud.cn/5f/70/5f70736737853e9889802ab296c7ba18_1248x366.gif) 测试成功,当访问个人中心时,显示了当前的登录用户信息。但看似一切正常的程序其实尚有bug:打开控制台会发现控制台中尚有错误: ![](https://img.kancloud.cn/d8/25/d82580f5bb43bdae1d459b2f157678e1_1384x131.png) 提示说在V层的第3行有在undefined上读取name属性的错误。 如果在个人中心处进行页面的刷新,还会产生如下错误: ![](https://img.kancloud.cn/04/f3/04f3fa8696d1eda7f2ac3ee60682973d_1248x366.gif) 刷新页面后个人中心中的内容不见了。。。 ## undefined上读取name属性 该错误的产生是由于向后台进行数据请求是异步产生了,在前面的章节中已经介绍过这个问题。在此重复介绍一遍。个人中心的C层代码如下: ```typescript /** 绑定到V层 */ public teacher: Teacher; constructor(private teacherService: TeacherService) { // ➊ 构造此类,此时this.teacher的类型为undefined } ngOnInit() ➋ { // 调用M层的相关方法 this.teacherService.me().subscribe➌((teacher) => { this.teacher = teacher; ➎ // ➏ 与V层绑定的数据this.teacher发生变化,再次进行渲染。此时this.teacher非undefiend }); // ➍ ngOnInit方法执行结束后,渲染V层(多次渲染)。此时this.teacher的类型为undefined发生错误 } ``` 执行方法➌时,由于该方法最终调用的是httpClient.get向后台发起请求,js无法判断此请求的时间,所以不会等待其返回以后再继续执行,而是选择直接执行➍。➍执行完毕后,请求后台有了结果,此时再执行➎。所以最终的执行顺序为 ➊ -> ➋ -> ➌ -> ➍ -> ➎ -> ➏ 为了更加清晰的看到上述的执行过程,使用以下代码改造该文件: ```typescript export class PersonalCenterComponent implements OnInit { /** 绑定到V层 */ public teacher: Teacher; constructor(private teacherService: TeacherService) { console.log('construct'); } ngOnInit() { console.log('ngInit'); // 调用M层的相关方法 this.teacherService.me().subscribe((teacher) => { console.log('return teacher'); this.teacher = teacher; console.log('set return teacher'); }); console.log('done'); } } ``` 然后再次测试(不要在个人中心页面刷新,应该先登录然后再点个人中心),控制台信息如下: ``` personal-center.component.ts:14 construct personal-center.component.ts:18 ngInit personal-center.component.ts:25 go PersonalCenterComponent.html:3 ERROR TypeError: Cannot read property 'name' of undefined PersonalCenterComponent.html:3 ERROR CONTEXT PersonalCenterComponent.html:3 ERROR TypeError: Cannot read property 'name' of undefined PersonalCenterComponent.html:3 ERROR CONTEXT personal-center.component.ts:21 return teacher personal-center.component.ts:23 set return teacher ``` 以上信息足以印证了刚刚对执行过程的猜测,也进一步的验证了在单元测试中`detectChanges()`方法的作用。在单元测试中需要手动的调用`detectChanges()`来完成V层的渲染,在非单元测试中angular会自动调用组件的`detectChanges`方法来完成组件的渲染,且会渲染多次,当连续两次渲染的结果相同时认为渲染成功终止当次渲染。 angular在V层提供了`?`来避免异步请求渲染出现在undefined上读取属性错误的问题。在进行渲染时angular遇到`?`关键字,则首先会查看用`?`标识的变量的类型是否为undefined。如果为undefined,则停止此字段的渲染,否则继续此字段的渲染。所以对V层进行如下的修正: ``` {{teacher?.name}} ``` 其它的字段请自行修正,将`teacher.name`修改为`teacher?.name`后,渲染到该字段时会首先对teacher是否为undefined进行判断。再次测试错误消失。 ## 刷新个人中心异常 刷新个人中心后未显示当前登录用户的信息。这是由于前台、后台的设计思想都有些问题造成的。 ### 前台 对前台而言,每次进行页面刷新后的首次请求会使用空的auth-token请求后台,这导致了后台为刷新的请求分配了新的auth-token,而此auth-token并未与任何的用户进行绑定。所以当用户使用最新分发的未与任何用户绑定的auth-token请求me接口时,接收到了空值,近而导致个人中心的内容为空。 ### 后台 对后台而言,用户请求me接口的前提应该是:用户使用了已绑定了用户的auth-token,因为只有在这个前提下,才可能给用户返回预期的值。而当使用了未绑定用户的auth-token时,后台应该给前台发送**请先完成系统登录**的前置条件,而非返回了空值。 在一下个小节中,将使用后台的拦截器完成接口的认证校验问题。每次请求后台均要进行拦截,如果当前请求访问的是用户登录接口则放行,如果是其它接口则对当前auth-token是否已绑定了用户,未绑定则返回相应的错误信息以提示前台。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.7](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.7) | - |