基本的功能完成了但还有一些小问题,比如:如果添加了重名的课程,则需要到控制台中查看错误信息。
本节将采用异步验证器的方式判断添加的课程名是否重复,这样一来如果要添加的课程名已经存在于数据库中则直接在前台提示用户。
# 异步验证
在前面的章节中已经接触了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`,同步验证器验证失败,未调用异步验证器:
![](https://img.kancloud.cn/19/92/19928ac39b43d117a98cd28c0ed6b8ee_650x324.png)
课程名称输入`1`,同步验证器验证通过,调用异步验证器中的validate方法,触发语句`console.log(control);`在控制台中输出了AbstractControl信息:
![](https://img.kancloud.cn/ba/2b/ba2bde2ecc9d222a4615f5cc5fb7ccb4_689x386.png)
查看详情:
![](https://img.kancloud.cn/13/a1/13a1e9294ab598f35c26dafe046e3bbb_473x606.png)
可见在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">
```
![](.9_images/84.gif)
### 对接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实践
在前面的章节中学习过:位于数据流中的转发者是可以通过操作符来对过境的数据进行转变的。
![](.5_images/4620bf8a.png)
## 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输入至回调函数中
* ➌ 将回调函数中的返回数据做为新的数据流向后进行转发
![](https://img.kancloud.cn/76/cd/76cd9f4f9e9ed8331b0d02c652a81de5_879x444.png)
控制台中打印了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
![](https://img.kancloud.cn/c2/e6/c2e6bae8a1caeae9c93f63ff041c586d_491x187.png)
测试通过。
# 单元测试
最后执行`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();
});
});
```
错误:
![](https://img.kancloud.cn/62/0c/620c953e487482627aeab4fc8ec96834_653x147.png)
这是由于更新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
- 总结
- 开发规范
- 备用