# 注销 本节我们开始完成熟悉的注销功能。 ## 注销1.0 实现注销功能前,我们先复习下用户登录的步骤: 1. 在login组件上增加一个可以向外弹射事件的beLogin方法。 2. 在index组件的V层中,引入login组件,并关联beLogin方法至index组件的onLogin方法上。 3. 用户使用正确的用户名、密码登录后,login组件将登录成功的事件向上弹出。 4. index组件的onLogin方法接收到了弹出的事件,设置自己的login属性为true,进而完成了登录功能。 那么注销功能完全可以参考上述登录功能完成: 1. 在nav组件上增加一个可以向外弹射事件的beLogout方法。 2. 在index组件的V层中,引入nav组件,并关联beLogout方法至index组件的onLogout方法上。 3. 用户点击注销按钮后,nav组件将注销成功的事件向上弹出。 4. index组件的onLogout方法接收到了弹出的事件,设置自己的login属性为false,进而完成了注销功能。 思想有了,编码便成为了最简单的事情: ### beLogout 来到导航组件,新增一个用于发送数据的`beLogout`,再增加一个用于链接V层的`onSubmit`: ```typescript +import {Component, EventEmitter, OnInit, Output} from '@angular/core'; @Component({ selector: 'app-nav', @@ -7,10 +7,16 @@ import {Component, OnInit} from '@angular/core'; }) export class NavComponent implements OnInit { + @Output() + beLogout = new EventEmitter<void>(); 👈 + constructor() { } ngOnInit(): void { } + onSubmit(): void { + this.beLogout.emit(undefined); 👈 + } } ``` 当泛型被声明为void时,可以将数据设置为`undefined`。当然了,在大多数时候,留空也是可以了,比如上述代码完全可以重写为:`this.beLogout.emit();`,赶快试试吧。 V层绑定相关方法: ```html +++ b/first-app/src/app/nav/nav.component.html @@ -24,7 +24,7 @@ <a class="nav-link" routerLink="personal-center">个人中心</a> </li> </ul> - <form class="form-inline my-2 my-lg-0"> + <form class="form-inline my-2 my-lg-0" (ngSubmit)="onSubmit()"> <button class="btn btn-outline-light my-2 my-sm-0" type="submit">注销</button> </form> </div> ``` 最后我们使用单元测试来保证上述功能的正确性,即:用代码来测试代码。在团队开发中,这还可以起到保护我们当前代码功能的作用,当其它人(也极有可能是日后的自己)在开发其它功能时,单元测试通过说明当前的功能未被破坏。这在保证项目质量是非常有帮助的。 在进行单元测试,我们应该尽量的细化测试的粒度,比如把我们刚刚的功能分为两个测试点:测试V层点击注销按钮后,C层相应的方法是否被触发;测试C层onSubmit是否按我们的想法调用了beLogout的emit方法。 ```typescript +++ b/first-app/src/app/nav/nav.component.spec.ts @@ -22,4 +22,10 @@ describe('NavComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + fit('v层注销按钮', () => { + // 获取V层的注销按钮 + // 在c层的相关方法中安插间谍 + // 点击注销按钮,则间谍方法应该被调用 + }); }); ``` 正式动手前写写注释是个值得表扬的好习惯!接下来相信你已经有能力来完成该单元测试了。 ```typescript +++ b/first-app/src/app/nav/nav.component.spec.ts @@ -29,4 +29,10 @@ describe('NavComponent', () => { // 点击注销按钮,则间谍方法应该被调用 }); + fit('onSubmit', () => { + // 接收组件的beLogout发送数据的数据 + // 调用onSubmit方法 + // 如果的确在1步接收成功,就说明onSubmit方法成功的弹出了数据;否则,说明未成功,报异常 + + }); + }); ``` 对`beLogout.emit`有那么点点特殊。我们在前面学习过`EventEmitter`是可以按自己的意愿向上弹射数据,该数据可以由父组件绑定相应的方法的方式接收到。而在单元测试中如何使用代码的方式来接收呢?使用代码接收同样也很简单,而且我们早早的就接触到了它们: ```typescript +++ b/first-app/src/app/nav/nav.component.spec.ts @@ -31,7 +31,13 @@ describe('NavComponent', () => { fit('onSubmit', () => { // 接收组件的beLogout发送数据的数据 + component.beLogout.subscribe(() => { + console.log('接收到了数据'); + }); + // 调用onSubmit方法 + component.onSubmit(); + // 如果的确在1步接收成功,就说明onSubmit方法成功的弹出了数据;否则,说明未成功, 报异常 }); ``` 没错由于`EventEmitter`有按自己的意愿发送数据的特性,所以我们同样可以使用`subscribe`对其进行订阅(关注),此时一旦`EventEmitter`有新的动态`subscribe`中的函数则会被自动执行一次。 ![image-20210316153058778](https://img.kancloud.cn/23/fc/23fc0216445a6e2408c9f98eff2cad14_1320x114.png) 如果我们在此多执行几次`component.onSubmit();`,则会在控制台中多显示几次`接收到了数据`,请试试看。 控制台中同时还显示了一个异常,该异常提示说:不能在li上绑定`routerLinkActiveOptions`属性,因为angular不认识它。作用路由一部分的`routerLinkActiveOptions`存在于路由模块中,所以解决该错误的方法是在当前测试文件中引入路由(测试)模块: ```typescript +++ b/first-app/src/app/nav/nav.component.spec.ts @@ -1,6 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {NavComponent} from './nav.component'; +import {RouterTestingModule} from '@angular/router/testing'; describe('NavComponent', () => { let component: NavComponent; @@ -8,7 +9,8 @@ describe('NavComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [NavComponent] + declarations: [NavComponent], + imports: [RouterTestingModule] }) .compileComponents(); }); ``` 再次运行单元测试,错误消失: ![image-20210316153414961](https://img.kancloud.cn/82/65/82651a61059ae7baebb2be31c10d6035_1766x184.png) 继续回到对onSubmit方法的测试中,我们说使用`ng t`来测试代码成功与否是不应该来查看控制台确认的,那么如何来使用代码来保证`onSubmit方法成功的弹出了数据`呢?我们需要以下的小技巧: ```typescript +++ b/first-app/src/app/nav/nav.component.spec.ts @@ -33,14 +33,17 @@ describe('NavComponent', () => { fit('onSubmit', () => { // 接收组件的beLogout发送数据的数据 + let called = false; component.beLogout.subscribe(() => { console.log('接收到了数据'); + called = true; }); // 调用onSubmit方法 component.onSubmit(); // 如果的确在1步接收成功,就说明onSubmit方法成功的弹出了数据;否则,说明未成功, 报异常 + expect(called).toBeTrue(); }); }); ``` 如此以来,如果`subscribe`方法成功的接收到了向上弹出的空数据,则called变量必为true;反之如果called变量为true,则也能够说明`subscribe`方法成功的接收到了空数据。 ### onLogout 完成了注销组件的弹出方法后,接下来在`index`组件来对接这个注销事件: ```typescript +++ b/first-app/src/app/index/index.component.ts @@ -26,4 +26,10 @@ export class IndexComponent implements OnInit { // 将登录状态写入缓存 window.sessionStorage.setItem('login', 'true'); } + + onLogout(): void { + console.log('接收到注销组件的数据弹射,开始注销'); + this.login = false; + window.sessionStorage.removeItem('login'); + } } ``` 接着在V层中绑定注销组件的`(beLogout)`方法: ```html +++ b/first-app/src/app/index/index.component.html @@ -1,5 +1,5 @@ <!--登录成功后,在上面显示导航--> -<app-nav *ngIf="login"></app-nav> +<app-nav *ngIf="login" (beLogout)="onLogout()"></app-nav> <!--在下方显示路由对应的具体组件--> <router-outlet *ngIf="login"></router-outlet> ``` 我们再次借助单元测试来验证上述代码的正确与否,本着测试粒度最小化的原则,我们并不需要由nav组件的点击注销按钮开始测试,而仅仅需要测试:点nav组件向外弹射数据时,index组件是否成功的接收了数据即可。 > 单元测试的粒度控制的确需要一些时日才能运用自如,但幸运的是只要我们在上面加以时日便一定能运用自如。 ```typescript +++ b/first-app/src/app/index/index.component.spec.ts @@ -30,4 +30,11 @@ describe('IndexComponent', () => { expect(component).toBeTruthy(); fixture.autoDetectChanges(); }); + + fit('与注销组件对接', () => { + // 在index组件相应的方法中安插间谍 + // nav组件弹数据 + // index组件接收数据 + // 断言间谍方法被调用,则说明nav组件弹数据后,index相应的方法将被调用 + }); }); ``` 基本的思路有了,我们像聊天一下分步补充功能代码如下: ```typescript +++ b/first-app/src/app/index/index.component.spec.ts @@ -33,8 +33,13 @@ describe('IndexComponent', () => { fit('与注销组件对接', () => { // 在index组件相应的方法中安插间谍 + spyOn(component, 'onLogout'); + // nav组件弹数据 + // 如何来获取这个nav组件呢? + // index组件接收数据 // 断言间谍方法被调用,则说明nav组件弹数据后,index相应的方法将被调用 + expect(component.onLogout).toHaveBeenCalled(); }); }); ``` 完成功能时,我们发现**如何来获取NAV组件**我们还未掌握。在项目开发中,我们应该优先规整整个项目中尚未掌握的技术点,优先的来解决它们,当这个尚未掌握的点被解决后,一个项目大概什么时候能完工就能心中有点数了。 在单元测试中,我们可能通过放置测试组件的夹具`fixture.debugElement`来获取到测试过程中其它的组件: ```typescript +++ b/first-app/src/app/index/index.component.spec.ts @@ -7,6 +7,7 @@ import {HttpClientModule} from '@angular/common/http'; import {FormsModule} from '@angular/forms'; import {RouterTestingModule} from '@angular/router/testing'; import {NavComponent} from '../nav/nav.component'; +import {By} from '@angular/platform-browser'; 👈 describe('IndexComponent', () => { let component: IndexComponent; @@ -37,6 +38,8 @@ describe('IndexComponent', () => { // nav组件弹数据 // 如何来获取这个nav组件呢? + const navComponent = fixture.debugElement.query(By.directive(NavComponent)); + console.log(navComponent); // index组件接收数据 // 断言间谍方法被调用,则说明nav组件弹数据后,index相应的方法将被调用 ``` **注意**:整个项目中有好几个`By`,这里需要使用 👈 所指的这个。 运行单元测试,却好像**意外**的报错了: ![image-20210316155912930](https://img.kancloud.cn/69/e3/69e3d2445d9d73bb73ab87abcb6de160_972x166.png) 之所以说好像,是由于我们犯了**想当然、我认为、应该**的错误,只所以没有获取到nav组件,并不是由于我们的代码出现什么逻辑性的、关键的错误,而是当前的单元测试中的确就不存在nav组件: ![image-20210316163658036](https://img.kancloud.cn/5b/2d/5b2de87216b3d17683206b6fabced826_1338x424.png) 向下滚动单元测试便可以轻易发现当然是用户未登录状态,所以显示了登录组件,而nav组件由于未使用到,所以angular并没有实例化它(在用到的时候才实例化,这是节约资源的一种有效手段),那么此时获取不到nav组件当然是正常的。 那么是否需要使用模拟登录的方法来显示出nav组件呢?答案是否定的。因为我们完全不必这么做。在当前组件中,是否显示nav组件,取决于当前index组件中的login属性,所以预显示nav组件,仅仅将login属性的值设置为true便可以实现。 ```typescript +++ b/first-app/src/app/index/index.component.spec.ts @@ -33,6 +33,10 @@ describe('IndexComponent', () => { }); fit('与注销组件对接', () => { + // 显示民航组件 + component.login = true; + fixture.detectChanges(); 👈 + // 在index组件相应的方法中安插间谍 spyOn(component, 'onLogout'); ``` C层的属性变更后,必须通知测试夹具(fixture)重新渲染V层,否则V层将保持原样。 👈 ![image-20210317082615986](https://img.kancloud.cn/4b/70/4b70988b213f8c72bbbf42cc0303cb20_950x204.png) 最后我们在获取到的nav组上发送数据: ```typescript +++ b/first-app/src/app/index/index.component.spec.ts @@ -44,6 +44,8 @@ describe('IndexComponent', () => { // 如何来获取这个nav组件呢? const navComponent = fixture.debugElement.query(By.directive(NavComponent)); console.log('获取到了导航组件', navComponent); + const navComponentInstance = navComponent.componentInstance as NavComponent; 👈 + navComponentInstance.beLogout.emit(); // index组件接收数据 // 断言间谍方法被调用,则说明nav组件弹数据后,index相应的方法将被调用 ``` 通过`navComponent.componentInstance`来获取组件的实例,使用as关键字来指定一个类型。如果不使用as关键字指定类型则navComponentInstance变量的类型将被认为是any,这也是可以的,但你不应该这样做。 单元测试通过,说明index组件成功的获取了注销组件弹出的**注销**事件,至此两个组件的对接在单元测试的支持下被完美的完成了。 ![image-20210317083255039](https://img.kancloud.cn/8b/4f/8b4f2b4d6b0af2dfcfa6569f186ad68b_1110x220.png) 最后我们启用`ng s`来启动应用,使用用户名密码登录后再点击注销按钮,最终验证功能的正确性,过程略。 ## 后台注销2.0 我们刚刚看似完成了注销功能,但实际上这是一种极不负责的方式。纠其原因则是我们犯了一个在生产中经常容易犯的毛病:不按规范行事。 在上个小节中我们给出了后台注销的API: ```bash GET /teacher/logout ``` 但我们刚刚好像并没有使用到,这种不规范可以被坏人非常轻松的利用,比如我们刚刚使用了公共电脑登录本系统:登录、使用、注销。然后我们放心的离开了,现在坏人登场。坏人打开浏览器的控制台,来到Storge界面。 ![image-20210317090337187](https://img.kancloud.cn/ec/9c/ec9c4fc08313cb32a11d8d6aefd4259a_2566x418.png) 接下来加入如下信息: ![image-20210317090424756](https://img.kancloud.cn/24/c3/24c3961e9622f4580c6ff4a4ef20aa4b_1198x152.png) key值写login,value写入true,接着刷新浏览器。噔噔噔噔,一个由软小白开发的系统就这样成功的被坏人利用了。 有人说那我们是否可以在注销时把`x-auth-token`也清空,这样用于认证的`x-auth-token`没有了,坏人就没有办法访问一些后台对权限认证的资源(比如个人中心)了。没错,如果坏人很简单,这种思想是没有问题的。我们复习一下前后台使用cookie的认证模式: ![image-20210308143321976](https://img.kancloud.cn/fa/ac/faac94d9e1c8427c35175bf9f061ccd9_2510x1644.png) 在认证过程中,我们使用x-auth-token替换了cookie实现了用户认证。无论是cookie还是x-auth-token,这都像极了现实生活中的各种**会员卡**,或是没有密码的**信用卡**。在实际生活的日常消费中**信用卡**可做为消费凭证完成用户与银行的认证过程。那么我们应该如何来注销一张**信用卡**呢? 如果我们在注销时再聪明的把`x-auth-token`也一并清空,则实际上相当于我们在注销银行卡时没有去银行,而是直接把信用卡片仍入了垃圾桶。但是银行方的信用卡信息并未消除,卡片信息仍然有效。所以如果这张被弃用的信用卡作用被坏人由垃圾桶中拾取的话,是完全可以继续使用的。历史上我们使用的银行卡都是磁条式的,这种银行卡具有高度的可复制功能,所以在磁条卡的时代发生过不少的银行卡被盗刷的事件。而如果发现银行卡被盗刷,受害人只把自己手中的银行卡扔入垃圾桶是完全无济于事的。 这种坏人也很容易做到,它仅需要把握好一个做案时机即可:用户使用过程去下WC或是去吸只烟。整下过程如下: 1. 用户登录系统 2. 半路去吸烟 3. 坏人出场,去缓存中获取这个`x-auth-token`,接着离场 4. 用户继续使用系统 5. 用户注销 6. 然后坏人仅需要在浏览器的缓存中输入这个`x-auth-token`,同将`login`设置为`true`便可继续的操作本系统(甚至是同步的) 上述**模拟犯罪**的过程请自行尝试。相信你现在知道为什么我们在使用银卡时为什么要遵循以下规则了吧: 1. 如果可以办理芯片式的银行卡,则不应该办理磁条式的。因为磁条式银行卡可有可复制的特性。坏人可以使用相关的设备在瞬间复制一张具有相同信息的副卡出来。 2. 在消费刷卡时,不应该让银行卡离开自己的视线,不给坏人复制的机会。 3. 现在大多数的POS机会制定一个规则:如果当前的银行卡有芯片,则必须刷芯片才能完成支付。这是对储户的一种保护。 铺垫了这么多,一是为了使你的大脑更容易接受**按规范开发**的团队规范,使它由排斥、被动接受团队的基本规范转为主动接受;二是为了以下正确的代码做准备。 ```typescript +++ b/first-app/src/app/nav/nav.component.ts @@ -1,4 +1,5 @@ import {Component, EventEmitter, OnInit, Output} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; @Component({ selector: 'app-nav', @@ -10,13 +11,16 @@ export class NavComponent implements OnInit { @Output() beLogout = new EventEmitter<void>(); - constructor() { + constructor(private httpClient: HttpClient) { } ngOnInit(): void { } onSubmit(): void { - this.beLogout.emit(undefined); + const url = 'http://angular.api.codedemo.club:81/teacher/logout'; + this.httpClient.get(url) + .subscribe(() => this.beLogout.emit(undefined), + error => console.log('logout error', error)); } } ``` 如此我们便完成了注销功能:后台的注销、前台的注销。使用`ng s`进行相应测试,测试通过。 ## 本节作业 1. 完成nav组件`v层注销按钮`测试用例的编写。 2. 移除所有的`fit`,使用`ng t`来对全局进行测试,你将发现一些错误,请尝试修正它们。 | 名称 | 链接 | 备注 | | ----------------------- | ------------------------------------------------------------ | ------------------------- | | 对嵌套组件的测试 | [https://angular.cn/guide/testing-components-scenarios#nested-component-tests](https://angular.cn/guide/testing-components-scenarios#nested-component-tests) | | | 搭建http请求测试环境 | [https://angular.cn/guide/http#setup-for-testing](https://angular.cn/guide/http#setup-for-testing) | 你需要它来帮助你完成作业2 | | DebugElement | [https://angular.cn/guide/testing-components-basics#debugelement](https://angular.cn/guide/testing-components-basics#debugelement) | | | query | [https://angular.cn/api/animations/query](https://angular.cn/api/animations/query) | | | by | [https://angular.cn/api/platform-browser/By](https://angular.cn/api/platform-browser/By) | | | 本节源码(含作业2答案) | [https://github.com/mengyunzhi/angular11-guild/archive/step5.6.zip](https://github.com/mengyunzhi/angular11-guild/archive/step5.6.zip) | |