# update
update的開發方法較getById方法稍微復雜一些有限。按開發的步驟首先進行初始化工作如下:
服務接口:
```
public interface StudentService {
...
/**
* 更新學生
* @param id ID
* @param student 更新的學生信息
* @return 學生
*/
Student update(Long id, Student student);
```
服務實現類:
```
public class StudentServiceImpl implements StudentService {
...
@Override
public Student update(Long id, Student student) {
return null;
}
```
C層:
```
public class StudentController {
...
public Student update(Long id, Student student) {
return null;
}
```
## M層功能開發與測試
做為新手,在進行更新的代碼編寫前應該首先來到[3.4.5](http://www.hmoore.net/yunzhiclub/springboot_angular_guide/1368364)、[3.4.6](http://www.hmoore.net/yunzhiclub/springboot_angular_guide/1368365) 回顧一下更新數據的思想。有了參考的代碼后,完成更新學生的功能便會相對簡單許多。
參考實序圖:

其功能性的代碼應該大概長這樣:
```
public class StudentServiceImpl implements StudentService {
...
@Override
public Student update(Long id, Student student) { ?
Student oldStudent = this.studentRepository.findById(id).get(); ?
Student newStudent = this.updateFields(student,oldStudent); ?
return this.studentRepository.save(newStudent); ?
}
/**
* 更新學生
* @param newStudent 新學生信息
* @param oldStudent 老學生信息
* @return 更新后的學生信息
*/
public Student updateFields(Student newStudent, Student oldStudent) {
// 更新各個字段后返回更新后的學生
return null;
}
```
* ? 對應時序圖中的序號2
* ? 對應時序圖中的序號2.1
* ? 對應時序圖中的序號2.2
* ? 對應時序圖中的序號2.3
如果如下定義時序圖:

則功能性代碼就應該變成這樣:
```
public class StudentServiceImpl implements StudentService {
...
@Override
public Student update(Long id, Student student) {
Student oldStudent = this.studentRepository.findById(id).get();
return this.updateFields(student,oldStudent);
}
/**
* 更新學生
* @param newStudent 新學生信息
* @param oldStudent 老學生信息
* @return 更新后的學生信息
*/
public Student updateFields(Student newStudent, Student oldStudent) {
// 更新各個字段
// 更新各個字段
return this.studentRepository.save(newStudent);
}
```
從功能實現上這兩種方法難分伯仲,但就可測試性而言,第二種時序圖在為其準備一些調用替身(spy)時會更輕松一些。為此,本例中采取第二個時序圖做為開發方案。
### 單元測試
雖然單元測試邏輯并不復雜,但提前寫點注釋整理下思路也大有益處。
```
public class StudentServiceImplTest {
...
@Test
public void update() {
// 準備替身及調用替身后的模擬返回值
// 調用update方法測試
// 斷言傳入參數符合預期
// 斷言返回值符合預期
}
@Test
public void updateFields() {
// 準備替身
// 調用updateFields方法
// 斷言傳入替身的參數符合預期(更新了學生信息)
// 斷言返回值符合預期
}
```
完善測試代碼:
```
@Test
public void update() {
// 準備替身及調用替身后的模擬返回值
// 第一個替身(間諜)
Long id = new Random().nextLong();
Student mockResultStudent = new Student();
Mockito.when(this.studentRepository.findById(id)).thenReturn(Optional.of(mockResultStudent));
// 第二個替身
```
# Mockito.spy
準備第一個替身我們已經輕車熟路,先準備測試的參數再準備相應的返回值。但第二個替身就不那么簡單了。這是因為此時要構造的替身與我們測試的方法位于同一個對象上。也就是我們要保證執行 `this.studentService`這個對象的update方法時執行是真實的方法,在但這個真實的update方法中調用本對象的`updateFields`時執行的卻是替身的方法。前面已有的知識要么該整個對象做為真實的對象看待,比如此時的`this.studentService`,要么將整個對象做為替身來看待,比如此時的`this.studentRepository`。但還沒有學習過如何將一個真實的對象的部分方法保留的同時,又將特定的方法變成替身方法(在部分方法上創建間諜)。實際上,筆者也嘗試查找過此類的解決方案。最終約以失敗告終。`Mockito`貌似早就得知了此時的需求,所以提供了另外一種思路來解決當前面臨的問題。雖然沒有辦法把一個真實的對象的部分方法替換掉,但是可以由真實對象的clone出一個替身。此時這個替身具有兩個特點:1.該替身由于是由真實的對象clone而來,所以真實對象上方法具有的功能,該替身上的方法中均有。2.由于其本質是替身,所以可以在該替身的任意方法上安排間諜。這樣一來便基于`this.studentService`clone出一個替身,將替換掉該替身上的`updateFields`方法,從而對`update`方法進行測試了。
> 我們說的替身(間諜)有兩種:第一種是對象的替身,該替身擁有原對的所有的功能。第二種是方法的替身(間諜),一旦某個方法被安排了間諜,那么訪問該方法那么間諜將替待原方法接受調用并替待原方法返回數據。
具體代碼如下:
```
@Test
public void update() {
// 準備替身及調用替身后的模擬返回值
// 第一個替身(間諜)
Long id = new Random().nextLong();
Student mockResultStudent = new Student();
Mockito.when(this.studentRepository.findById(id)).thenReturn(Optional.of(mockResultStudent)); ?
// 第二個替身.
StudentService studentServiceSpy = Mockito.spy(this.studentService); ?
StudentServiceImpl studentServiceImplSpy = (StudentServiceImpl) studentServiceSpy; ★?
Student mockResultStudent1 = new Student();
Mockito.doReturn(mockResultStudent1).when(studentServiceImplSpy).updateFields(Mockito.any(Student.class), Mockito.any(Student.class)); ?
```
* ? 由this.studentService clone出一個替身,該替身具有原studentService中的所有功能及屬性
* ? 由于updateFields方法未存在于StudentService接口上而是存在于StudentServiceImpl。所以我們沒有辦法對類型是StudentService的對象設置updateFields方法的替身。
* ? 但雖然注入時聲明的為StudentService,但實際注入的為StudentServiceImpl。所以實際上當前的this.studentService是基于StudentServiceImpl創建的實例,也就是說當前的this.studentService是有updateFields方法的。
* ? 基于此,在這里可以使用類型轉換將其轉換為StudentServiceImpl。
* ★ 看不懂的話可以暫時略過,照著抄上就好了。這就像乘客網上約一輛出租車時并沒有約定其有座椅加熱功能,但租車平臺派送過來的車帶了座椅加熱功能。雖然這個功能并沒有在規定的列表中,但它的確是實實在在的存在于當前的出租車了。
* ? 注意此處的語法為:Mockito.doReturn().when()。區別于?處的Mockito.when().thenReturn()
>[success] Mockito.doReturn().when() VS Mockito.when().thenReturn():大多數時候,這兩種用法無區別,推薦優先使用Mockito.when().thenReturn()。但與Mockito.spy配合使用時,則只能用Mockito.doReturn().when()。延伸閱讀:[Mockito: doReturn vs thenReturn](https://sangsoonam.github.io/2019/02/04/mockito-doreturn-vs-thenreturn.html)
替身設置完畢后,正式開始進行功能測試:
```
@Test
public void update() {
// 準備替身及調用替身后的模擬返回值
// 第一個替身(間諜)
Long id = new Random().nextLong();
Student mockResultStudent = new Student();
Mockito.when(this.studentRepository.findById(id)).thenReturn(Optional.of(mockResultStudent));
// 第二個替身. 1. 由this.studentService clone出一個替身,該替身具有原studentService中的所有功能及屬性
StudentService studentServiceSpy = Mockito.spy(this.studentService);
// 由于updateFields方法并不存在于StudentService接口上,所以預對updateFields設置替身
// 則需要對類型進行轉制轉換
// (雖然注入時聲明的為StudentService,但實際注入的為StudentServiceImpl,這是強制轉換的基礎)
StudentServiceImpl studentServiceImplSpy = (StudentServiceImpl) studentServiceSpy;
Student mockResultStudent1 = new Student();
Mockito.doReturn(mockResultStudent1).when(studentServiceImplSpy).updateFields(Mockito.any(Student.class), Mockito.any(Student.class));
// 調用update方法測試
Student student = new Student();
Student resultStudent = studentServiceImplSpy.update(id, student);
// 斷言傳入第一個替身參數符合預期
ArgumentCaptor<Long> longArgumentCaptor = ArgumentCaptor.forClass(Long.class);
Mockito.verify(this.studentRepository).findById(longArgumentCaptor.capture());
Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id);
// 斷言第二個替身參數符合預期:參數1為傳入update方法的學生,參數2為替身1的返回值
ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class);
ArgumentCaptor<Student> studentArgumentCaptor1 = ArgumentCaptor.forClass(Student.class);
Mockito.verify(studentServiceImplSpy).updateFields(studentArgumentCaptor.capture(), studentArgumentCaptor1.capture());
Assertions.assertThat(studentArgumentCaptor.getValue()).isEqualTo(student);
Assertions.assertThat(studentArgumentCaptor1.getValue()).isEqualTo(mockResultStudent);
// 斷言返回值就是第二個替身的返回值
Assertions.assertThat(resultStudent).isEqualTo(mockResultStudent1);
}
```
### updateFields
本方法主要是使用新傳入的學生信息更新原學生信息,并把更新后的信息存入數據庫。最后返回更新后的學生。則測試功能點有二:1. 更新學生信息。2.調用數據倉庫并返回其返回值
```
@Test
public void updateFields() {
// 準備替身
Student mockResultStudent = new Student();
Mockito.when(this.studentRepository.save(Mockito.any(Student.class))).thenReturn(mockResultStudent);
// 調用updateFields方法
StudentServiceImpl studentServiceImpl = (StudentServiceImpl) this.studentService;
Student newStudent = new Student();
newStudent.setKlass(new Klass()); ?
newStudent.setName(RandomString.make(8)); ?
newStudent.setSno(RandomString.make(4)); ?
Student oldStudent = new Student(); ?
oldStudent.setId(new Random().nextLong()); ?
Student resultStudent = studentServiceImpl.updateFields(newStudent, oldStudent);
// 斷言傳入替身的參數符合預期(更新了學生信息)
ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class);
Mockito.verify(this.studentRepository).save(studentArgumentCaptor.capture());
Student editedStudent = studentArgumentCaptor.getValue();
Assertions.assertThat(editedStudent.getId()).isEqualTo(oldStudent.getId()); ?
Assertions.assertThat(editedStudent.getName()).isEqualTo(newStudent.getName()); ?
Assertions.assertThat(editedStudent.getSno()).isEqualTo(newStudent.getSno()); ?
Assertions.assertThat(editedStudent.getKlass()).isEqualTo(newStudent.getKlass()); ?
// 斷言返回值符合預期
Assertions.assertThat(resultStudent).isEqualTo(mockResultStudent);
}
```
* ? 準備更新的信息
* ? 設置老學生ID
* ? 斷言老學生id不變
* ? 斷言其它信息更新成功
功能代碼如下:
```
public class StudentServiceImpl implements StudentService {
...
/**
* 更新學生
* @param newStudent 新學生信息
* @param oldStudent 老學生信息
* @return 更新后的學生信息
*/
public Student updateFields(Student newStudent, Student oldStudent) {
oldStudent.setSno(newStudent.getSno());
oldStudent.setName(newStudent.getName());
oldStudent.setKlass(newStudent.getKlass());
return this.studentRepository.save(oldStudent);
}
```
單元測試通過。
## C層
單元測試初始化如下:
```
public class StudentControllerTest {
...
@Test
public void update() {
// 準備傳入參數的數據
// 準備服務層替身被調用后的返回數據
// 按接口規范發起請求,斷言狀態碼正常,接收的數據符合預期
// 斷言C層進行了數據轉發(替身接收的參數值符合預期)
}
```
補充測試代碼如下:
```
@Test
public void update() throws Exception {
// 準備傳入參數的數據
Long id = new Random().nextLong();
// 準備服務層替身被調用后的返回數據
Student mockResult = new Student();
mockResult.setId(id);
mockResult.setName(RandomString.make(6));
mockResult.setSno(RandomString.make(4));
mockResult.setKlass(new Klass());
mockResult.getKlass().setId(new Random().nextLong());
mockResult.getKlass().setName(RandomString.make(10));
Mockito.when(this.studentService.update(Mockito.anyLong(), Mockito.any(Student.class))).thenReturn(mockResult);
JSONObject studentJsonObject = new JSONObject(); ?
JSONObject klassJsonObject = new JSONObject(); ?
studentJsonObject.put("sno", RandomString.make(4));
studentJsonObject.put("name", RandomString.make(6));
klassJsonObject.put("id", new Random().nextLong());
studentJsonObject.put("klass", klassJsonObject);
// 按接口規范發起請求,斷言狀態碼正常,接收的數據符合預期
String url = "/Student/" + id.toString();
this.mockMvc
.perform(MockMvcRequestBuilders.put(url)
.content(studentJsonObject.toString())
.contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("id").value(id)) ?
.andExpect(MockMvcResultMatchers.jsonPath("sno").exists()) ?
.andExpect(MockMvcResultMatchers.jsonPath("name").exists()) ?
.andExpect(MockMvcResultMatchers.jsonPath("klass.id").exists()) ?
.andExpect(MockMvcResultMatchers.jsonPath("klass.name").exists()) ?
;
// 斷言C層進行了數據轉發(替身接收的參數值符合預期)
ArgumentCaptor<Long> longArgumentCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Student> studentArgumentCaptor = ArgumentCaptor.forClass(Student.class);
Mockito.verify(this.studentService).update(longArgumentCaptor.capture(), studentArgumentCaptor.capture());
Assertions.assertThat(longArgumentCaptor.getValue()).isEqualTo(id);
Student resultStudent = studentArgumentCaptor.getValue();
Assertions.assertThat(resultStudent.getSno()).isEqualTo(studentJsonObject.get("sno"));
Assertions.assertThat(resultStudent.getName()).isEqualTo(studentJsonObject.get("name"));
Assertions.assertThat(resultStudent.getKlass().getId()).isEqualTo(klassJsonObject.get("id"));
Assertions.assertThat(resultStudent.getKlass().getName()).isEqualTo(klassJsonObject.get("name"));
}
```
* ? 構造json數據
* ? 同上一小節的測試,斷言ID值符合預期
* ? ID符合預期,則說明返回的對象是正確的。那么此時只需要驗證返回了前臺需要的字段即可
運行單元測試并按提示完成功能代碼或修正單元測試代碼:
```
java.lang.AssertionError: Status
Expected :200
Actual :405
```
期望狀態碼200,但接收到了405。405對應的是:Request method not supported。在上個小節中C層進行第一次單元測試時接收的狀態碼為404。與上一小節相同,在這同樣是由于沒有定義`RequestMapping`引發的錯誤,但為何此時接收的是405而不是404呢?這是由于向后臺以地址 `/Student/id`發起請求時,此地址正好對應了`getById`方法的請求路徑:
```
public class StudentController {
...
@GetMapping("{id}")
public Student getById(@PathVariable Long id) {
return this.studentService.findById(id);
}
```
雖然請求路徑對應上了,但請求的方法在getById方法上規定是的get,而非此時發起的`put`。也就是說當發生405錯誤時,說明找到了對應的請求路徑,而請求路徑對應的請求方法卻未對應成功。而404錯誤說明根本未找到對應的請求路徑。
如下修正代碼:
```
public class StudentController {
...
@PutMapping("{id}") ?
public Student update(Long id, Student student) {
return null;
}
```
再次進行單元測試,發生了如下錯誤:
```
.andExpect(MockMvcResultMatchers.jsonPath("id").value(id))
java.lang.AssertionError: No value at JSON path "id"
```
它說沒有在返回的json字符串上找到想到的id屬性,出現該錯誤首先想到的是由于C層中返回null引起的。所以首先想到的修正C層中的調用:
```java
public class StudentController {
...
@PutMapping("{id}")
public Student update(Long id, Student student) {
return this.studentService.update(id, student); ?
}
```
再次執行單元測試,錯誤依舊。這是由于我們的間諜設置`Mockito.when(this.studentService.update(Mockito.anyLong(), Mockito.any(Student.class))).thenReturn(mockResult)`沒有起作用。該間諜設置的意思是:當`this.studentService.update`傳入的第一個參數的值的類型為`Long`,且傳入的第二個參數的值的類型為`Student`時,將`mockResult`做為返回值返回。
debug一下看看更清晰:



debug發現此時的id為null,而null并不屬于類型Long。所以并不符合間諜設置中:`傳入的第一個參數的值的類型為`Long\`\`的條件,當然也就沒有執行間諜設置的程序了。
id為null的原因是由于沒有設置`@PathVariable`:
```
public class StudentController {
...
@PutMapping("{id}")
public Student update(@PathVariable? Long id, Student student) {
return this.studentService.update(id, student);
}
```
再次運行單元測試:
```
Assertions.assertThat(resultStudent.getSno()).isEqualTo(studentJsonObject.get("sno"));
org.junit.ComparisonFailure:
Expected :"ZEUg"
Actual :null
```
它說在斷言傳值時發生了錯誤,期望的是一個隨機值,但卻收到了null。按數據流向分析,產生此錯誤的原因也有三個:0:根本就沒有向C層傳值。1. C層根據就沒有成功接收到相應的值。 2. C層雖然成功接收到了,但卻沒有成功的進行轉發。在本例中,屬于錯誤1:C層中并沒有成功的接收。
```
public class StudentController {
...
@PutMapping("{id}")
public Student update(@PathVariable Long id, @RequestBody? Student student) {
return this.studentService.update(id, student);
}
```
單元測試顯示如下錯誤:
```
Assertions.assertThat(resultStudent.getKlass().getId()).isEqualTo(klassJsonObject.get("id"));
Assertions.assertThat(resultStudent.getKlass().getName()).isEqualTo(klassJsonObject.get("name")); ?
org.json.JSONException: No value for name
```
在斷言參數轉發時發現了錯誤。此錯誤類型同上一點。要么壓根沒發送,要么發送了沒接收,要么接收了沒轉發到下一個節點。而學生的接收與轉發是整體性的,既然已經接收并轉發了klass的id, 那么klass的name也必然會被成功的接收并轉發。此時問題點便聚集在第0點:根本沒有向C層傳值。
```
public class StudentControllerTest {
...
klassJsonObject.put("id", new Random().nextLong());
klassJsonObject.put("name", RandomString.make(6)); ?
studentJsonObject.put("klass", klassJsonObject);
```
再次運行單元測試,通過。
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.7.5](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.7.5) | \- |
| Mockito.spy | [https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html#spy-T-](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html#spy-T-) | \- |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用