基本的功能完成了但還有一些小問題,比如:如果添加了重名的課程,則需要到控制臺中查看錯誤信息。
本節將采用異步驗證器的方式判斷添加的課程名是否重復,這樣一來如果要添加的課程名已經存在于數據庫中則直接在前臺提示用戶。
# 異步驗證
在前面的章節中已經接觸了required、minLength、maxLength三個**同步**驗證器,只所以稱為同步驗證器是由于其驗證過程直接發生在前臺。而要添加的課程名稱是否與數據庫的課程名稱發生沖突,則需要借助于后臺進行判斷。JS中有兩種情況下執行異步操作,第一種情況是執行setTimeout方法時,第二種情況是發生資源請求時(與后臺通訊)時。所以借助于后臺才能驗證成功的驗證器被稱為異步驗證器。
新的知識點我們按由后到前的順序逐點進行開發。
# 后臺
對課程名稱的驗重需要接收課程名稱,返回值的類型定義為boolean,當傳入的名稱已存在于數據庫中的話返回true,當傳入的名稱在數據庫中不存在話返回false。
接口規范如下:
```
GET /Course/existsByName?name=xxx
true: 名稱已存在
false: 名稱不存在
```
## 倉庫層
repository/CourseRepository.java
```java
public interface CourseRepository extends CrudRepository<Course, Long> {
/**
* 課程名稱是否存在
* @param name 課程名稱
* @return true 存在
*/
boolean existsByName(String name);
}
```
### 單元測試
新建對應的單元測試并初始化如下:
repository/CourseRepositoryTest.java
```java
@SpringBootTest
@RunWith(SpringRunner.class)
public class CourseRepositoryTest {
@Autowired
CourseRepository courseRepository;
@Test
public void existsByName() {
// 生成隨機字符串的課程名
// 調用existsByName方法,斷言返回false
// 新建課程,課程名用上面生成的隨機字符串,保存課程
// 再次調用existsByName方法,斷言返回true
}
}
```
補充測試代碼如下:
repository/CourseRepositoryTest.java
```java
@Test
public void existsByName() {
// 生成隨機字符串的課程名
String name = RandomString.make(10);
// 調用existsByName方法,斷言返回false
Assert.assertFalse(this.courseRepository.existsByName(name));
// 新建課程,課程名用上面生成的隨機字符串,保存課程
Course course = new Course();
course.setName(name);
this.courseRepository.save(course);
// 再次調用existsByName方法,斷言返回true
Assert.assertTrue(this.courseRepository.existsByName(name));
}
```
單元測試通過。
## M層
在M層中僅僅做數據轉發即可。
service/CourseService.java
```java
/**
* 名稱是否存在
* @param name 課程名稱
* @return true 存在
*/
boolean existsByName(String name);
```
實現類:
service/CourseServiceImpl.java
```java
@Override
public boolean existsByName(String name) {
return this.courseRepository.existsByName(name);
}
```
### 單元測試
service/CourseServiceImplTest.java
```java
@Test
public void existsByName() {
String name = RandomString.make(10);
Mockito.when(this.courseRepository.existsByName(name)).thenReturn(false);
boolean result = this.courseService.existsByName(name);
Assert.assertFalse(result);
}
```
## C層
controller/CourseController.java
```java
@GetMapping("existsByName")
public boolean existsByName(@RequestParam String name) {
return this.courseService.existsByName(name);
}
```
### 單元測試
controller/CourseControllerTest.java
```java
@Test
public void existsByName() throws Exception {
String name = RandomString.make(4);
String url = "/Course/existsByName";
Mockito.when(this.courseService.existsByName(Mockito.eq(name))).thenReturn(false);
this.mockMvc.perform(MockMvcRequestBuilders.get(url)
.param("name", name))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("false"))
;
}
```
后臺的接口準備完畢后,開始進行前臺表單的異步驗證。
# 前臺
在正式的書寫異步驗證器前,在進行一些準備工作:在M層中添加對應的existsByName方法。
## M層
service/course.service.ts
```typescript
/**
* 課程名稱是否存在
* @param name 課程名稱
*/
existsByName(name: string): Observable<boolean> {
const url = this.url + '/existsByName';
return this.httpClient.get<boolean>(url, {params: {name}});
}
```
### 單元測試
service/course.service.spec.ts
```typescript
fit('existsByName', () => {
const service: CourseService = TestBed.get(CourseService);
const name = 'test';
let result;
service.existsByName(name).subscribe((data) => {
result = data;
});
const testController = TestBed.get(HttpTestingController) as HttpTestingController;
const request = testController.expectOne(req => req.url === 'http://localhost:8080/Course/existsByName');
expect(request.request.params.get('name')).toEqual('test');
expect(request.request.method).toEqual('GET');
});
```
## 異步驗證器
來到src/app/course文件夾下建立validator文件夾,建立UniqueNameValidator驗證器:
```
panjiedeMac-Pro:validator panjie$ ng g class UniqueNameValidator
CREATE src/app/course/validator/unique-name-validator.spec.ts (208 bytes)
CREATE src/app/course/validator/unique-name-validator.ts (37 bytes)
```
異步驗證器需要實現AsyncValidator接口中的validate方法,初始化如下:
course/validator/unique-name-validator.ts
```typescript
import {AbstractControl, AsyncValidator, ValidationErrors} from '@angular/forms';
import {Observable, of} from 'rxjs';
import {Injectable} from '@angular/core';
import {CourseModule} from '../course.module';
/**
* 課程名稱唯一性異步驗證器
*/
@Injectable({
providedIn: 'root'
})
export class UniqueNameValidator implements AsyncValidator? {
validate?(control: AbstractControl?): Promise<ValidationErrors? | null> | Observable<ValidationErrors? | null> {
console.log(control); ?
return of({uniqueName: true}); ?
}
}
```
* ? 實現AsyncValidator的validate方法。
* ? 驗證器對應驗證的表單內容。
* ? 返回值可以是promise(promise是Observable的簡化版,有了Observable以后使用promise的頻率較低)
* ? 返回值也可以是Observable
* ? 如驗證通過則返回null。如果未通過則返回字符串格式的鍵值對(ValidationErrors)
### 測試
啟動前后臺,將此驗證器添加到表單中的name字段上。
course/add/add.component.ts
```typescript
constructor(private formBuilder: FormBuilder,
private courseService: CourseService,
private uniqueNameValidator: UniqueNameValidator ?) {
}
ngOnInit() {
this.formGroup = this.formBuilder.group({
name: ['', [Validators.minLength(2), Validators.required],
this.uniqueNameValidator.validate?]
});
this.course = new Course();
}
```
* ? 注入異步驗證器
* ? 添加到name字段的異步驗證器中
課程名稱輸入`1`,同步驗證器驗證失敗,未調用異步驗證器:

課程名稱輸入`1`,同步驗證器驗證通過,調用異步驗證器中的validate方法,觸發語句`console.log(control);`在控制臺中輸出了AbstractControl信息:

查看詳情:

可見在AbstractControl中可以得到當前表單項的輸入值,異步驗證器的返回信息被添加到`errors`中。由此定義前臺V層的提示信息如下:
course/add/add.component.html
```html
<small id="nameMinLength" *ngIf="formGroup.get('name').errors && formGroup.get('name').errors.minlength" class="form-text text-danger">課程名稱不得少于2個字符</small>
<small id="nameUnique" *ngIf="formGroup.get('name').errors && formGroup.get('name').errors.uniqueName" class="form-text text-danger">當前課程名已被占用</small>
<div class="form-group">
```

### 對接M層
完成課程是否存在的邏輯功能是由CourseService中的existsByName方法實現的,在驗證器中注入CourseService以調用該功能
course/validator/unique-name-validator.ts
```typescript
export class UniqueNameValidator implements AsyncValidator {
static courseService: CourseService; ?★
constructor(courseService: CourseService) {
UniqueNameValidator.courseService = courseService;
}
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors? | null> {
return UniqueNameValidator.courseService?.existsByName(control.value); ?
return of({uniqueName: true}) ? ?
}
```
* ? 由于this作用域的問題,需要將注入的courseService對UniqueNameValidator的靜態變量courseService賦值。
* ? 異步驗證器要求返回的數據流中的數據類型是字符串形式的`鍵值對`
* ? CourseService中返回的數據流中的數據類型是boolean
>[info] ★這個問題相對比較復雜,需要對ts中的this作用域有較深的理解,在此不進行深入講解。在此不能使用this.courseService的原因是由于在name的異步驗證器中使用了`this.uniqueNameValidator.validate`,這意味著將`validate`函數脫離了`uniqueNameValidator`對象單獨使用。在調用`validate`函數時`this`將取決于被調用時的上下文【選學】。在生產環境訓,還有另外一種更有效的定義異步驗證器的方法,請在google中搜索`AsyncValidatorFn`以獲取更多知識。教程中為了更貼近于angular的官方文檔,使用了官方文檔中的示例方法。
將exists返回的boolean類型的數據流變成`鍵值對`形式的數據流轉發下卻便需要借助RxJS的map操作符了。
# RxJS實踐
在前面的章節中學習過:位于數據流中的轉發者是可以通過操作符來對過境的數據進行轉變的。

## map實踐一
在RxJS中使用map操作符來完成數據格式的轉換。
course/validator/unique-name-validator.ts
```typescript
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
return this.courseService.existsByName(control.value)
.pipe(map());
}
```
比如將courseService.existsByName方法傳輸過來的boolean類型的數據,轉換為`{uniqueName: true}`,則可以使用以下代碼完成:
course/validator/unique-name-validator.ts
```typescript
validate(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
return this.courseService.existsByName(control.value)
.pipe(map?((input?) => {
console.log(input);
const output = {uniqueName: true};
return output; ?
}));
}
```
* ? map 操作符接收的參數類型為:回調函數
* ? 將源數據流做為input輸入至回調函數中
* ? 將回調函數中的返回數據做為新的數據流向后進行轉發

控制臺中打印了input的值為false,說明input接收正確。在V層中顯示了`當前課程名已被占用`的提示語句說明返回的數據流的確為`{uniqueName: true}`
。
查看數據流后,完成邏輯:若existsByName返回的值為true,說明該名稱已被占用,則返回`{uniqueName: true}`;若若existsByName返回的值為false,說明該名稱未被占用,則返回`null`。
course/validator/unique-name-validator.ts
```typescript
.pipe(map((input) => {
if (input) {
return {uniqueName: true};
} else {
return null;
}
}));
```
重構如下:
course/validator/unique-name-validator.ts
```typescript
.pipe(map((input) => {
return input ? {uniqueName: true} : null;
}));
```
在箭頭函數中,如果函數體中僅有一行代碼且以return打頭,則還可以省略`{}`以及`return`進行如下縮寫:
```typescript
.pipe(map((input) =>
input ? {uniqueName: true} : null));
}
```
同時若輸入的參數個數為1,且無指定數據類型的需求時,還可以省略`()`:
```typescript
.pipe(map(input =>
input ? {uniqueName: true} : null));
}
```
刪除回車符后變更為:
```typescript
.pipe(map(input => input ? {uniqueName: true} : null));
```
### 測試
首先添加教師及班級基本數據,然后添加一個名稱為test的班級。接著刷新頁面,重新輸入班級名稱test

測試通過。
# 單元測試
最后執行`ng test`對全局進行測試。
```
ERROR in src/app/course/validator/unique-name-validator.spec.ts:5:12 - error TS2554: Expected 1 arguments, but got 0.
5 expect(new UniqueNameValidator()).toBeTruthy();
~~~~~~~~~~~~~~~~~~~~~~~~~
src/app/course/validator/unique-name-validator.ts:16:15
16 constructor(courseService: CourseService) {
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
An argument for 'courseService' was not provided.
```
在unique-name-validator.spec.ts中發生了語法錯誤:
course/validator/unique-name-validator.spec.ts
```typescript
describe('course -> validator -> niqueNameValidator', () => {
it('should create an instance', () => {
const courseService = new CourseStubService() as CourseService;
expect(new UniqueNameValidator(courseService)).toBeTruthy();
});
});
```
錯誤:

這是由于更新CourseService卻沒有對應更新其測試替身的原因造成的。
service/course-stub.service.ts
```typescript
existsByName(name: string): Observable<boolean> {
return of(false★);
}
```
再次測試全部通過。
>[success] ★如果將此處的返回值修改為true,則會觸發其它2個單元測試的錯誤,你知道這是為什么嗎?
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.9](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.9) | - |
| 異步驗證器 | [https://www.angular.cn/guide/form-validation#async-validation](https://www.angular.cn/guide/form-validation#async-validation) | 10 |
| map操作符 | [https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-map](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-map) | 5 |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用