經歷了幾次集成測試,你此時肯定被莫名的CORS錯誤搞的焦頭爛額。可能既不知道它是怎么產生的,又不知道它為何產生。在本教程的1.5.2小節前后臺對接初期便遇到了此CORS錯誤,在教程的2.2.4前后臺對接時仍然遇到了此CORS錯誤,在5.2.9小節的集成測試中又出現了此CORS錯誤。本節中嘗試帶領大家找找這錯誤的根本原因。
# OPTIONS
在教程中學習了使用get方法查詢數據、post方法新增數據、put方法更新數據、delete方法刪除數據。在前面的章節中已提到`瀏覽在進行跨域訪問時,如果發現請求的方法不是get(各瀏覽器處理的方式不同),那么將首先發起options請求`,而這個options請求在chrome的控制臺中并未發現。但換一個瀏覽器就不一樣了:使用firefox進行用戶登錄將在網絡中查看到如下信息:
在登錄前首先在登錄地址上發起options請求:

當發現`Access-Control-Allow-Methods`的值`PUT,DELETE,POST,GET,PATCH`包含`POST`時,而且`Access-Control-Allow-Origin`包含當前請求域名時才繼續發起`POST`請求;在以后發起的每次請求的響應中,瀏覽器還會比較`Access-Control-Allow-Methods`的值`PUT,DELETE,POST,GET,PATCH`是否包含當前請求的方法以及`Access-Control-Allow-Origin`的值是否包含當前請求域名。如未滿足包含條件將阻斷相應的請求。

## 測試一
注銷到當前登錄用戶,然后終止前臺服務,并使用`ng serve --port 4201`重新啟動前臺。使用瀏覽器打開`http://localhost:4201/`并使用用戶名密碼登錄:

options請求直接返回了403(無權限),瀏覽器未接收到`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`,自動終止了用戶登錄的post請求。
## 測試二
終止前臺服務,重新使用`ng serve`啟動前臺。找到后臺的`config/WebConfig.java`。刪除` .allowedMethods("PUT", "DELETE", "POST", "GET", "PATCH")`中的`"POST"`:
```java
.allowedOrigins("http://localhost:4200")
.allowedMethods("PUT", "DELETE", "GET", "PATCH")
.exposedHeaders("auth-token");
```
重新啟動后臺后在前臺`http://localhost:4200/`繼續嘗試登錄:

發起options請求時直接返回了403,瀏覽器未接收到`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`,瀏覽器同樣直接終止了用戶的登錄請求。
**注意:** 請恢復后臺代碼,重啟后臺后繼續學習。
# 錯誤重現
若想根本上解決CORS問題關鍵在于錯誤重現。此CORS錯誤可以按以下步驟重現。步驟一:啟動前臺、后臺,使用用戶進行登錄。步驟二:重新啟動后臺。此時在前臺進行操作將得到如下錯誤信息:

## 解決錯誤
可以在sessionStorage中清除isLogin達到清除錯誤的目的。間接的說明該錯誤是由于登錄緩存isLogin引起的。為了徹底弄清楚這個問題,點擊注銷并查看網絡請求信息:

用戶點擊注銷時,瀏覽器首先在注銷地址發起了options請求,接收到了`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`并符合發向注銷地址發請get請求的要求,近而繼續發起了get請求。

在該請求中并未接收到`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`信息,所以瀏覽器終止了此請求。只所以未接收到有效的`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`信息,是由于在后臺的認證攔截器對注銷功能進行了攔截:未登錄的用戶在訪問注銷接口時,將直接返回401未認證的狀態碼。也就是說當前后只支持已登錄的用戶發起注銷請求,未登錄的用戶無法發起注銷請求。這個邏輯并沒有任何問題。問題出在:如果由于后臺重啟的原因導致前臺的authToken失效,前臺需要在接收到401的狀態碼后及時的切換用戶的登錄狀態為:未登錄。而當前由于后臺返回401時未返回`Access-Control-Allow-Methods`以及`Access-Control-Allow-Origin`信息,從而導致了前臺代碼無法獲取該401狀態碼的信息。
綜上,似使用以下步驟依次解決由于后臺重啟的原因導致的authToken失效的問題。
### 加入Access-Control-Allow-Methods
找到后臺的認證攔截器,加入`Access-Control-Allow-Methods`信息:
interceptor/AuthInterceptor.java
```java
response.setStatus(401);
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,PATCH");
return false;
}
```
重新啟動后臺,再次點擊注銷按鈕:

控制臺信息:

### 加入Access-Control-Allow-Origin
interceptor/AuthInterceptor.java
```java
response.setStatus(401);
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,PATCH");
response.setHeader("Access-Control-Allow-Origin", "http://localhost:4200");
return false;
}
```

控制臺關于CORS的錯誤消失
### 當接收到401的在前臺完成注銷操作
后臺發出了401的信息則說明當前的認證用戶已經由于各種完成自動被后臺注銷掉了,那么此時應該在前臺主動的跳出登錄界面。我們在前臺中使用了攔截器來處理authToken,同樣的還可以在前臺的攔截器中統一處理401信息。
在數據流的轉發過程中若想獲取錯誤的數據流,則需要使用`tap`操作符。該操作符可接收3個回調函數做為參數,當執行成功時將調用第一個回調函數(必須傳入),當執行失敗時將調用第二個回調函數(選擇傳入),無論執行成功或失敗均調用第3個回調函數(選擇傳入)。
它的用法如下:
core/auth-token-interceptor.ts
```typescript
return next.handle(reqClone).pipe(map((httpEvent) => {
if (httpEvent instanceof HttpResponse) {
const httpResponse = httpEvent as HttpResponse<any>;
const authToken = httpResponse.headers.get('auth-token');
CacheService.setAuthToken(authToken);
}
return httpEvent;
}), tap(() => {}, () => {}));
}
```
當前需要對`網絡錯誤`進行攔截并判斷是否發生了401認證錯誤,所以在tap操作符中僅需要處理第二個回調函數即可:
core/auth-token-interceptor.ts
```typescript
}), tap(() => {
},
(event: HttpErrorResponse?) => {
if (event.status === 401) {
console.log('發生了401錯誤');
}
}));
}
```
* ? 當發生網絡錯誤時tap操作符會將響應的信息做為回調函數的參數傳入。

最后在攔截器中注入TeacherService并調用setIsLogin方法來實現當后臺發出401時前臺自動完成注銷的功能。
core/auth-token-interceptor.ts
```typescript
constructor(private teacherService: TeacherService) {
}
...
}), tap(() => {
},
(event: HttpErrorResponse) => {
if (event.status === 401) {
this.teacherService.setIsLogin(false);
}
}));
}
```
此時當后臺重新啟動后,無論前臺進行任何的非登錄操作均會因收到401狀態碼而導致前臺自動完成注銷操作。
# Chrome
Chrome瀏覽器可能是出于安全的角度考慮在網絡中默認關閉了`options`的請求。如果希望在Chrome的網絡中查看`options`請求,則可以在Chrome打開
`chrome://flags/#out-of-blink-cors`,將`Out of blink CORS`項改為`disabled`,然后重新啟動Chrome:

此時再次查看網絡,將查看到`options`方法的請求信息:

## 單元測試
由于在auth-token-interceptor引入了`TeacherService`,對應修正單元測試如下:
core/auth-token-interceptor.spec.ts
```typescript
import { AuthTokenInterceptor } from './auth-token-interceptor';
import {async, TestBed} from '@angular/core/testing';
import {TeacherService} from '../service/teacher.service';
import {TeacherStubService} from '../test/service/teacher-stub.service';
describe('AuthTokenInterceptor', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
providers: [
{provide: TeacherService, useClass: TeacherStubService}
]
})
.compileComponents();
}));
it('should create an instance', () => {
const teacherService: TeacherService = TestBed.get(TeacherService); ?
expect(new AuthTokenInterceptor(teacherService)).toBeTruthy(); ?
});
});
```
* ? 無法使用`TestBed.get(AuthTokenInterceptor)`來直接獲取`AuthTokenInterceptor`實例,你知道這是為什么嗎?
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.10](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.10) | - |
| tap操作符 | [https://rxjs-dev.firebaseapp.com/api/operators/tap](https://rxjs-dev.firebaseapp.com/api/operators/tap) | 10 |
- 序言
- 第一章:Hello World
- 第一節:Angular準備工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二節:Hello Angular
- 第三節:Spring Boot準備工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四節:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven國內源配置
- 4 package與import
- 第五節:Hello Spring Boot + Angular
- 1 依賴注入【前】
- 2 HttpClient獲取數據【前】
- 3 數據綁定【前】
- 4 回調函數【選學】
- 第二章 教師管理
- 第一節 數據庫初始化
- 第二節 CRUD之R查數據
- 1 原型初始化【前】
- 2 連接數據庫【后】
- 3 使用JDBC讀取數據【后】
- 4 前后臺對接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三節 CRUD之C增數據
- 1 新建組件并映射路由【前】
- 2 模板驅動表單【前】
- 3 httpClient post請求【前】
- 4 保存數據【后】
- 5 組件間調用【前】
- 第四節 CRUD之U改數據
- 1 路由參數【前】
- 2 請求映射【后】
- 3 前后臺對接【前】
- 4 更新數據【前】
- 5 更新某個教師【后】
- 6 路由器鏈接【前】
- 7 觀察者模式【前】
- 第五節 CRUD之D刪數據
- 1 綁定到用戶輸入事件【前】
- 2 刪除某個教師【后】
- 第六節 代碼重構
- 1 文件夾化【前】
- 2 優化交互體驗【前】
- 3 相對與絕對地址【前】
- 第三章 班級管理
- 第一節 JPA初始化數據表
- 第二節 班級列表
- 1 新建模塊【前】
- 2 初識單元測試【前】
- 3 初始化原型【前】
- 4 面向對象【前】
- 5 測試HTTP請求【前】
- 6 測試INPUT【前】
- 7 測試BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后臺對接【前】
- 第三節 新增班級
- 1 初始化【前】
- 2 響應式表單【前】
- 3 測試POST請求【前】
- 4 JPA插入數據【后】
- 5 單元測試【后】
- 6 惰性加載【前】
- 7 對接【前】
- 第四節 編輯班級
- 1 FormGroup【前】
- 2 x、[x]、{{x}}與(x)【前】
- 3 模擬路由服務【前】
- 4 測試間諜spy【前】
- 5 使用JPA更新數據【后】
- 6 分層開發【后】
- 7 前后臺對接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五節 選擇教師組件
- 1 初始化【前】
- 2 動態數據綁定【前】
- 3 初識泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再識單元測試【前】
- 7 其它問題
- 第六節 刪除班級
- 1 TDD【前】
- 2 TDD【后】
- 3 前后臺對接
- 第四章 學生管理
- 第一節 引入Bootstrap【前】
- 第二節 NAV導航組件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三節 footer組件【前】
- 第四節 歡迎界面【前】
- 第五節 新增學生
- 1 初始化【前】
- 2 選擇班級組件【前】
- 3 復用選擇組件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校驗【后】
- 7 唯一性校驗【后】
- 8 @PrePersist【后】
- 9 CM層開發【后】
- 10 集成測試
- 第六節 學生列表
- 1 分頁【后】
- 2 HashMap與LinkedHashMap
- 3 初識綜合查詢【后】
- 4 綜合查詢進階【后】
- 5 小試綜合查詢【后】
- 6 初始化【前】
- 7 M層【前】
- 8 單元測試與分頁【前】
- 9 單選與多選【前】
- 10 集成測試
- 第七節 編輯學生
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 功能開發【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成測試
- 7 @Input 異步傳值【前】
- 8 值傳遞與引入傳遞
- 9 @PreUpdate【后】
- 10 表單驗證【前】
- 第八節 刪除學生
- 1 CSS選擇器【前】
- 2 confirm【前】
- 3 功能開發與測試【后】
- 4 集成測試
- 5 定制提示框【前】
- 6 引入圖標庫【前】
- 第九節 集成測試
- 第五章 登錄與注銷
- 第一節:普通登錄
- 1 原型【前】
- 2 功能設計【前】
- 3 功能設計【后】
- 4 應用登錄組件【前】
- 5 注銷【前】
- 6 保留登錄狀態【前】
- 第二節:你是誰
- 1 過濾器【后】
- 2 令牌機制【后】
- 3 裝飾器模式【后】
- 4 攔截器【前】
- 5 RxJS操作符【前】
- 6 用戶登錄與注銷【后】
- 7 個人中心【前】
- 8 攔截器【后】
- 9 集成測試
- 10 單例模式
- 第六章 課程管理
- 第一節 新增課程
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 async管道【前】
- 4 優雅的測試【前】
- 5 功能開發【前】
- 6 實體監聽器【后】
- 7 @ManyToMany【后】
- 8 集成測試【前】
- 9 異步驗證器【前】
- 10 詳解CORS【前】
- 第二節 課程列表
- 第三節 果斷
- 1 初始化【前】
- 2 分頁組件【前】
- 2 分頁組件【前】
- 3 綜合查詢【前】
- 4 綜合查詢【后】
- 4 綜合查詢【后】
- 第節 班級列表
- 第節 教師列表
- 第節 編輯課程
- TODO返回機制【前】
- 4 彈出框組件【前】
- 5 多路由出口【前】
- 第節 刪除課程
- 第七章 權限管理
- 第一節 AOP
- 總結
- 開發規范
- 備用