Spring Boot工程開發流程

語言: CN / TW / HK

highlight: a11y-dark

我正在參加「掘金·啟航計劃」

1. 關於Spring Boot

Spring Boot是Spring官方的一個產品,其本質上是一個基於Maven的、以Spring框架作為基礎的進階框架,很好的支援了主流的其它框架,並預設完成了許多的配置,其核心思想是“約定大於配置”。

2. 建立Spring Boot工程

在IntelliJ IDEA中,在建立嚮導中選擇Spring Initializer即可開始建立Spring Boot工程,在建立嚮導的介面中,需要關注的部分有:

  • Group Id
  • Artifact Id

以上2個值會共同構成一個Package name,如果Artifact Id的名字中有減號,在Package name中會去除,推薦手動新增小數點進行分隔。

由於Spring Boot官方更新版本的頻率非常高,在建立專案時,隨便選取某個版本均可,當專案建立成功後,推薦開啟pom.xml,將<parent>中的<version>(即Spring Boot父專案的版本)改成熟悉的版本,例如:2.5.9

在建立過程中,還可以在建立嚮導的介面中勾選所需要依賴項,如果建立時沒有勾選,也可以在建立工程之後手動在pom.xml中新增。

3. Spring Boot工程的結構

由於Spring Boot工程本質上就是一個Maven工程,所以,目錄結構基本上沒有區別。

與普通Maven工程最大的不同在於:Spring Boot工程在src\main\javasrc\test\java下預設已經存在Package,是建立專案時指定的Package,需要注意:此Package已經被配置為Spring執行元件掃描的根包,所以,在編寫程式碼時,所有的元件類都必須放在此包或其子孫包中!通常,推薦將所有的類(及介面)都建立在此包及其子孫包下。

src\main\java下的根包下,預設就已經存在某個類,其類名是建立專案時指定的Artifact與Application單詞的組合,例如BootDemoApplication,此類中有main()方法,執行此類的main()就會啟動整個專案,如果當前專案是Web專案,還會自動將專案部署到Web伺服器並啟動伺服器,所以,此類通常也稱之為“啟動類”。

在啟動類上,預設添加了@SpringBootApplication註解,此註解的元註解中包含@SpringBootConfiguration,而@SpringBootConfiguration的元註解中包含@Configuration,所以,啟動類本身也是配置類!所以,允許將@Bean方法寫在此類中,或者某些與配置相關的註解也可以新增在此類上!

src\test\java下的根包下,預設就已經存在某個類,其類名是在啟動類的名稱基礎上添加了Tests單詞的組合,例如BootDemoApplicationTests,此類預設沒有新增public許可權,甚至其內部的預設的測試方法也是預設許可權的,此測試類上添加了@SpringBootTest註解,其元註解中包含@ExtendWith(SpringExtension.class),與使用spring-test時的@SpringJUnitTest註解中的元註解相同,所以,@SpringBootTest註解也會使得當前測試類在執行測試方法之前是載入了Spring環境的,在實際編寫測試時,可以通過自動裝配得到任何已存在於Spring容器中的物件,在各測試方法中只需要關注被測試的目標即可。

pom.xml中,預設已經添加了spring-boot-starterspring-boot-starter-test依賴,分別是Spring Boot的基礎依賴基於Spring Boot的測試的依賴

另外,如果在建立工程時,勾選依賴項時選中了Web項,在src\main\resources下預設就已經建立了statictemplates資料夾,如果沒有勾選Web則沒有這2個資料夾,可以後續自行補充建立。

src\main\resources資料夾下,預設就已經存在application.properties檔案,用於編寫配置,Spring Boot會自動讀取此檔案(利用@PropertySource註解)。

小結:

  • 建立專案後預設的Package不要修改,避免出錯
  • 在編碼過程中,自行建立的所有類、介面均放在預設的Package或其子孫包中
  • src\main\java下預設已存在XxxApplication是啟動類,執行此類中的main()方法就會啟動整個專案
  • 啟動類本身也是配置類
  • 配置都應該編寫到src\main\resources下的application.properties中,Spring Boot會自動讀取
  • 測試類也必須放在src\test\java下的預設Package或其子孫包中
  • 在測試類上新增@SpringBootTest註解,則其中的測試方法執行之前會自動載入Spring環境及當前專案的配置,可以在測試類中使用自動裝配

4. 在Spring Boot工程中使用Mybatis

需要新增相關依賴項:

  • mysql-connector-java
  • mybatis-spring-boot-starter

其依賴的程式碼為:

xml <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency>

說明:在Spring Boot工程,許多依賴項都是不需要顯式的指定版本號的,因為在父專案中已經對這些依賴項的版本進行了管理(配置版本號),如果一定需要使用特定的版本,也可以自行新增<version>節點進行配置

說明:在依賴項的原始碼中,當<scope>的值為runtime時,表示此依賴項是執行過程中需要的,但是,在編譯時並不需要參與編譯

需要注意:當添加了以上資料庫程式設計的依賴後,如果啟動專案,將失敗!

因為添加了資料庫程式設計的依賴項後,Spring Boot就會嘗試自動裝配資料來源(DataSource)等物件,裝配時所需的連線資料庫的配置資訊(例如URL、登入資料庫的使用者名稱和密碼)應該是配置在application.properties中的,但是,如果尚未配置,就會導致失敗!

關於連線資料庫的配置資訊,Spring Boot要求對應的屬性名是:

```

連線資料庫的URL

spring.datasource.url=???

登入資料庫的使用者名稱

spring.datasource.username=???

登入資料庫的密碼

spring.datasource.password=??? ```

在配置時,也必須使用以上屬性名進行配置,則Spring Boot會自動讀取這些屬性對應的值,用於建立資料來源物件!

例如,配置為:

```

連線資料庫的URL

spring.datasource.url=jdbc:mysql://localhost:3306/mall_ams?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

登入資料庫的使用者名稱

spring.datasource.username=root

登入資料庫的密碼

spring.datasource.password=1234 ```

由於Spring Boot在啟動時只是載入以上配置,並不會實際的連線到資料庫,所以,當以上配置存在時,啟動就不會報錯,但是,無法檢驗以上配置的值是否正確!

可以在測試類中新增測試方法,嘗試連線資料庫,以檢驗以上配置值是否正確:

```java @SpringBootTest class BootDemoApplicationTests {

@Autowired
DataSource dataSource;

@Test
void testGetConnection() throws Exception {
    System.out.println(dataSource.getConnection());
}

} ```

如果以上測試通過,則表示配置值無誤,可以正確連線到資料庫,如果測試失敗,則表示配置值錯誤,需檢查配置值及本地環境(例如MySQL是否啟動、是否已建立對應的資料庫等)。

5. 關於Profile配置

在Spring Boot中,對Profile配置有很好的支援,開發人員可以在src\main\resources下建立更多的配置檔案,這些配置檔案的名稱應該是application-???.properties(其中的???是某個名稱,是自定義的)。

例如:

  • 僅在開發環境中使用的配置值可以寫在application-dev.properties
  • 僅在測試環境中使用的配置值可以寫在application-test.properties
  • 僅在生產環境(專案上線的環境)中使用的配置值可以寫在application-prod.properties

當把配置寫在以上這類檔案後,Spring Boot預設並不會應用以上這些檔案中的配置,當需要應用某個配置時,需要在application.properties中啟用某個Profile配置,例如:

```

啟用Profile配置

spring.profiles.active=dev ```

提示:以上配置值中的dev是需要啟用的配置檔案的檔名字尾,當配置為dev時,就會啟用application-dev.properties,同理,如果以上配置值為test,就會啟用application-test.properties

6. 關於YAML配置

Spring Boot也支援使用YAML配置,在開發實踐中,YAML的配置也使用得比較多。

YAML配置就是把原有的.properties配置的擴充套件改為yml

YAML配置原本並不是Spring系列框架內建的配置語法,如果在專案中需要使用這種語法進行配置,解析這類檔案需要新增相關依賴,在Spring Boot中預設已新增此依賴。

在YAML配置中,原本在.properties的配置表現為使用多個小數點分隔的配置將改為換行使用2個空格縮排的語法,換行前的部分使用冒號表示結束,最後的屬性名與值之間使用冒號和1個空格進行分隔,如果有多條屬性在.properties檔案中屬性名有重複的字首,在yml中不必也不能重複寫。

例如,原本在.properties中配置為:

spring.datasource.username=root spring.datasource.password=123456

則在yml檔案中配置為:

spring: datasource: username: root password: 123456

提示:在IntelliJ IDEA中編寫yml時,當需要縮排2個空格時,仍可以使用鍵盤上的TAB鍵進行縮排,IntelliJ IDEA會自動將其轉換為2個空格。

無論是.properties還是yml,只是配置檔案的副檔名和檔案內部的配置語法有區別,對於Spring Boot最終的執行其實沒有任何表現上的不同。

7. 使用Druid資料庫連線池

Druid資料庫連線是阿里巴巴團隊研發的,在Spring Boot專案中,如果需要顯式的指定使用此連線池,首先,需要在專案中新增依賴:

xml <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.20</version> </dependency>

當添加了此依賴,在專案中需要應用時,需要在配置檔案中指定spring.datasource.type屬性,取值為以上依賴項的jar包中的DruidDataSource型別的全限定名。

例如,在yml中配置為:

```

Spring系列框架的配置

spring: # 連線資料庫的相關配置 datasource: # 使用的資料庫連線池型別 type: com.alibaba.druid.pool.DruidDataSource ```

8. 編寫持久層(資料訪問層)程式碼

資料持久化:在開發領域中,討論資料時,通常指定是正在執行或處理的資料,這些資料都是在記憶體中的,而記憶體(RAM)的特徵包含”一旦斷電,資料將全部丟失“,為了讓資料永久儲存下來,通常會將資料儲存到能夠永久儲存資料的介質中,通常是計算機的硬碟,硬碟上的資料都是以檔案的形式存在的,所以,當需要永久儲存資料時,可以將資料儲存到文字檔案中,或儲存到XML檔案中,或儲存到資料庫中,這些儲存的做法就是資料持久化,而文字檔案、XML檔案都不利於實現增刪改查中的所有資料訪問操作,而資料庫是實現增刪改查這4種操作都比較便利的,所以,一般在討論資料持久化時,預設指的都是使用資料庫儲存資料。

在專案中,會將程式碼(各類、介面)劃分一些層次,各層用於解決不同的問題,其中,持久層就是用於解決資料持久化問題的,甚至,簡單來說,持久層對應的就是資料庫程式設計的相關檔案或程式碼。

目前,使用Mybatis技術實現持久層程式設計,需要:

  • 編寫一次性的基礎配置
  • 使用@MapperScan指定介面所在的Base Package
  • 指定配置SQL語句的XML檔案的位置
  • 編寫每個資料訪問功能的程式碼
  • 在介面中新增必須的抽象方法
    • 可能需要建立相關的POJO類
  • 在XML檔案中配置抽象方法對映的SQL語句

關於一次性的配置,@MapperScan註解需要新增在配置類上,有2種做法:

  • 直接將此註解新增在啟動類上,因為啟動類本身也是配置類
  • 自行建立配置類,在此配置類上新增@MapperScan

如果採用以上的第2種做法,則應該在src\main\java的根包下,建立config.MybatisConfig類,並在此類使用@MapperScan註解:

```java package cn.tedu.boot.demo.config;

import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Configuration;

@Configuration @MapperScan("cn.tedu.boot.demo.mapper") public class MybatisConfig { } ```

另外,關於指定配置SQL語句的XML檔案的位置,需要在application.yml(或application.properties)中配置mybatis.mapper-locations屬性,例如:

```yml

Mybatis相關配置

mybatis: # 用於配置SQL語句的XML檔案的位置 mapper-locations: classpath:mapper/*.xml ```

基於以上的配置值,還應該在src/main/resources下自行建立名為mapper的資料夾。

至此,關於使用Mybatis實現資料庫程式設計的一次性配置結束!

接下來,可以使用任何你已知的Mybatis使用方式實現所需的資料訪問。

目前,設定目標為:最終實現”新增管理員賬號“的功能。則在資料訪問層需要做到:

  • 插入管理員資料
  • 建立cn.tedu.boot.demo.entity.Admin
  • cn.tedu.boot.demo.mapper包(不存在,則建立)下建立AdminMapper介面,並在介面中宣告int insert(Admin admin);方法
  • src/main/resources/mapper資料夾下通過貼上得到AdminMapper.xml檔案,在此檔案中配置與以上抽象方法對映的SQL語句
  • 編寫完成後,應該及時測試,測試時,推薦在src/test/java的根包下建立mapper.AdminMapperTests測試類,並在此類中編寫測試方法
  • 根據使用者名稱查詢管理員資料
  • 後續,在每次插入資料之前,會呼叫此功能進行查詢,以此保證”重複的使用者名稱不會被新增到資料庫中“
    • 即便在資料表中使用者名稱已經添加了unique,但是,不應該讓程式執行到此處
  • AdminMapper介面中新增Admin getByUsername(String username);方法
  • AdminMapper.xml檔案中新增與以上抽象方法對映的SQL語句
  • 編寫完成後,應該及時測試
  • 其它問題暫不考慮,例如在ams_admin中,其實phoneemail也是設定了unique的,如果完整的實現,則還需要新增根據phone查詢管理員的功能,和根據email查詢管理員的功能,在不實現這2個功能的情況下,後續進行測試和使用時,應該不使用重複的phoneemail值來測試或執行

9. 關於業務邏輯層(Service層)

業務邏輯層是被Controller直接呼叫的層(Controller不允許直接呼叫持久層),通常,在業務邏輯層中編寫的程式碼是為了保證資料的完整性和安全性,使得資料是隨著我們設定的規則而產生或發生變化。

通常,在業務邏輯層的程式碼會由介面和實現類元件,其中,介面被視為是必須的

  • 推薦使用基於介面的程式設計方式
  • 部分框架在處理某些功能時,會使用基於介面的代理模式,例如Spring JDBC框架在處理事務時

在介面中,宣告抽象方法時,僅以操作成功為前提來設計返回值型別(不考慮失敗),如果業務在執行過程可能出現某些失敗(不符合所設定的規則),可以通過丟擲異常來表示!

關於丟擲的異常,通常是自定義的異常,並且,自定義異常通常是RuntimeException的子類,主要原因:

  • 不必顯式的丟擲或捕獲,因為業務邏輯層的異常永遠是丟擲的,而控制器層會呼叫業務邏輯層,在控制器層的Controller中其實也是永遠丟擲異常的,這些異常會通過Spring MVC統一處理異常的機制進行處理,關於異常的整個過程都是固定流程,所以,沒有必要顯式丟擲或捕獲
  • 部分框架在處理某些事情時,預設只對RuntimeException的子孫類進行識別並處理,例如Spring JDBC框架在處理事務時

所以,在實際編寫業務邏輯層之前,應該先規劃異常,例如先建立ServiceException類:

```java package cn.tedu.boot.demo.ex;

public class ServiceException extends RuntimeException {

} ```

接下來,再建立具體的對應某種“失敗”的異常,例如,在新增管理員時,可能因為“使用者名稱已經存在”而失敗,則建立對應的UsernameDuplicateException異常:

```java package cn.tedu.boot.demo.ex;

public class UsernameDuplicateException extends ServiceException {

} ```

另外,當插入資料時,如果返回的受影響行數不是1時,必然是某種錯誤,則建立對應的插入資料異常:

```java package cn.tedu.boot.demo.ex;

public class InsertException extends ServiceException {

} ```

關於抽象方法的引數,應該設計為客戶端提交的資料型別或對應的封裝型別,不可以是資料表對應的實體型別!如果使用封裝的型別,這種型別在類名上應該新增某種字尾,例如DTO或其它字尾,例如:

```java package cn.tedu.boot.demo.pojo.dto;

public class AdminAddNewDTO implements Serializable { private String username; private String password; private String nickname; private String avatar; private String phone; private String email; private String description; // Setters & Getters // hashCode(), equals() // toString() } ```

然後,在cn.tedu.boot.demo.service包下宣告介面及抽象方法:

```java package cn.tedu.boot.demo.service;

public interface IAdminService { void addNew(AdminAddNewDTO adminAddNewDTO); } ```

並在以上service包下建立impl子包,再建立AdminServiceImpl類:

```java package cn.tedu.boot.demo.service.impl;

@Service // @Component, @Controller, @Repository public class AdminServiceImpl implements IAdminService {

@Autowired
private AdminMapper adminMapper;

@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
    // 通過引數獲取使用者名稱
    // 呼叫adminMapper的Admin getByUsername(String username)方法執行查詢
    // 判斷查詢結果是否不為null
    // -- 是:表示使用者名稱已經被佔用,則丟擲UsernameDuplicateException

    // 通過引數獲取原密碼
    // 通過加密方式,得到加密後的密碼encodedPassword
    // 暫時不加密,寫為String encodedPassword = adminAddNewDTO.getPassword();

    // 建立當前時間物件now > LocalDateTime.now()

    // 建立Admin物件
    // 補全Admin物件的屬性值:通過引數獲取username,nickname……
    // 補全Admin物件的屬性值:password > encodedPassword
    // 補全Admin物件的屬性值:isEnable > 1
    // 補全Admin物件的屬性值:lastLoginIp > null
    // 補全Admin物件的屬性值:loginCount > 0
    // 補全Admin物件的屬性值:gmtLastLogin > null
    // 補全Admin物件的屬性值:gmtCreate > now
    // 補全Admin物件的屬性值:gmtModified > now
    // 呼叫adminMapper的insert(Admin admin)方法插入管理員資料,獲取返回值

    // 判斷以上返回的結果是否不為1,丟擲InsertException異常
}

} ```

以上業務程式碼的實現為:

```java package cn.tedu.boot.demo.service.impl;

import cn.tedu.boot.demo.entity.Admin; import cn.tedu.boot.demo.ex.InsertException; import cn.tedu.boot.demo.ex.UsernameDuplicateException; import cn.tedu.boot.demo.mapper.AdminMapper; import cn.tedu.boot.demo.pojo.dto.AdminAddNewDTO; import cn.tedu.boot.demo.service.IAdminService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service public class AdminServiceImpl implements IAdminService {

@Autowired
private AdminMapper adminMapper;

@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
    // 通過引數獲取使用者名稱
    String username = adminAddNewDTO.getUsername();
    // 呼叫adminMapper的Admin getByUsername(String username)方法執行查詢
    Admin queryResult = adminMapper.getByUsername(username);
    // 判斷查詢結果是否不為null
    if (queryResult != null) {
        // 是:表示使用者名稱已經被佔用,則丟擲UsernameDuplicateException
        throw new UsernameDuplicateException();
    }

    // 通過引數獲取原密碼
    String password = adminAddNewDTO.getPassword();
    // 通過加密方式,得到加密後的密碼encodedPassword
    String encodedPassword = password;

    // 建立當前時間物件now > LocalDateTime.now()
    LocalDateTime now = LocalDateTime.now();

    // 建立Admin物件
    Admin admin = new Admin();
    // 補全Admin物件的屬性值:通過引數獲取username,nickname……
    admin.setUsername(username);
    admin.setNickname(adminAddNewDTO.getNickname());
    admin.setAvatar(adminAddNewDTO.getAvatar());
    admin.setPhone(adminAddNewDTO.getPhone());
    admin.setEmail(adminAddNewDTO.getEmail());
    admin.setDescription(adminAddNewDTO.getDescription());
    // 以上這些從一個物件中把屬性賦到另一個物件中,還可以使用:
    // BeanUtils.copyProperties(adminAddNewDTO, admin);
    // 補全Admin物件的屬性值:password > encodedPassword
    admin.setPassword(encodedPassword);
    // 補全Admin物件的屬性值:isEnable > 1
    admin.setIsEnable(1);
    // 補全Admin物件的屬性值:lastLoginIp > null
    // 補全Admin物件的屬性值:loginCount > 0
    admin.setLoginCount(0);
    // 補全Admin物件的屬性值:gmtLastLogin > null
    // 補全Admin物件的屬性值:gmtCreate > now
    admin.setGmtCreate(now);
    // 補全Admin物件的屬性值:gmtModified > now
    admin.setGmtModified(now);
    // 呼叫adminMapper的insert(Admin admin)方法插入管理員資料,獲取返回值
    int rows = adminMapper.insert(admin);

    // 判斷以上返回的結果是否不為1,丟擲InsertException異常
    if (rows != 1) {
        throw new InsertException();
    }
}

} ```

以上程式碼未實現對密碼的加密處理!關於密碼加密,相關的程式碼應該定義在別的某個類中,不應該直接將加密過程編寫在以上程式碼中,因為加密的程式碼需要在多處應用(新增使用者、使用者登入、修改密碼等),並且,從分工的角度上來看,也不應該是業務邏輯層的任務!所以,在cn.tedu.boot.demo.util(包不存在,則建立)下建立PasswordEncoder類,用於處理密碼加密:

```java package cn.tedu.boot.demo.util;

@Component public class PasswordEncoder {

public String encode(String rawPassword) {
    return "aaa" + rawPassword + "aaa";
}

} ```

完成後,需要在AdminServiceImpl中自動裝配以上PasswordEncoder,並在需要加密時呼叫PasswordEncoder物件的encode()方法。

10. 使用Lombok框架

在編寫POJO型別(包括實體類、VO、DTO等)時,都有統一的編碼規範,例如:

  • 屬性都是私有的
  • 所有屬性都有對應的Setter & Getter方法
  • 應該重寫equals()hashCode()方法,以保證:如果2個物件的字面值完全相同,則equals()對比結果為true,且hashCode()返回值相同,如果2個物件的字面值不相同,則equals()對比結果為false,且hashCode()返回值不同
  • 實現Serializable介面

另外,為了便於觀察物件的各屬性值,通常還會重寫toString()方法。

由於以上操作方式非常固定,且涉及的程式碼量雖然不難,但是篇幅較長,並且,當類中的屬性需要修改時(包括修改原有屬性、或增加新屬性、刪除原有屬性),對應的其它方法都需要修改(或重新生成),管理起來比較麻煩。

使用Lombok框架可以極大的簡化這些操作,此框架可以通過註解的方式,在編譯期來生成Setters & Getters、equals()hashCode()toString(),甚至生成構造方法等,所以,一旦使用此框架,開發人員就只需要在類中宣告各屬性、實現Serializable、新增Lombok指定的註解即可。

在Spring Boot中,新增Lombok依賴,可以在建立專案時勾選,也可以後期自行新增,依賴項的程式碼為:

xml <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

完成後,在各POJO型別中,將不再需要在原始碼新增Setters & Getters、equals()hashCode()toString()這些方法,只需要在POJO類上新增@Data註解即可!

當新增@Data註解,且刪除相關方法後,由於原始碼中沒有相關方法,則呼叫了相關程式碼的方法可能會報錯,但是,並不影響程式執行!

為了避免IntelliJ IDEA判斷失誤而提示了警告和錯誤,推薦安裝Lombok外掛,可參考:

  • http://doc.canglaoshi.org/doc/idea_lombok/IDEA-5-PLUGINS-LOMBOK.html

再次提示:無論是否安裝外掛,都不影響程式碼的編寫和執行!

11. Slf4j日誌框架

在開發實踐中,不允許使用System.out.println()或類似的輸出語句來輸出顯示關鍵資料(核心資料、敏感資料等),因為,如果是這樣使用,無論是在開發環境,還是測試環境,還是生產環境中,這些輸出語句都將輸出相關資訊,而刪除或新增這些輸出語句的操作成本比較高,操作可行性低。

推薦的做法是使用日誌框架來輸出相關資訊!

當添加了Lombok依賴後,可以在需要使用日誌的類上新增@Slf4j註解,然後,在類的任意中,均可使用名為log的變數,且呼叫其方法來輸出日誌(名為log的變數也是Lombok框架在編譯期自動補充的宣告並建立物件)!

在Slf4j日誌框架中,將日誌的可顯示級別根據其重要程度(嚴重程度)由低到高分為:

  • trace:跟蹤資訊
  • debug:除錯資訊
  • info:一般資訊,通常不涉及關鍵流程和敏感資料
  • warn:警告資訊,通常程式碼可以執行,但不夠完美,或不規範
  • error:錯誤資訊

在配置檔案中,可以通過logging.level.包名.類名來設定當前類的日誌顯示級別,例如:

yml logging.level.cn.tedu.boot.demo.service.impl.AdminServiceImpl: info

當設定了顯示的日誌級別後,僅顯示設定級別和更重要的級別的日誌,例如,設定為info時,只顯示infowarnerror,不會顯示debugtrace級別的日誌!

當輸出日誌時,通過log變數呼叫trace()方法輸出的日誌就是trace級別的,呼叫debug()方法輸出的日誌就是debug()級別的,以此類推,可呼叫的方法還有info()warn()error()

在開發實踐中,關鍵資料和敏感資料都應該通過trace()debug()進行輸出,在開發環境中,可以將日誌的顯示級別設定為trace,則會顯示所有日誌,當需要交付到生產環境中時,只需要將日誌的顯示級別調整為info即可!

預設情況下,日誌的顯示級別是info,所以,即使沒有在配置檔案中進行正確的配置,所有info、warn、error級別的日誌都會輸出顯示。

在配置時,屬性名稱中的logging.level部分是必須的,在其後,必須寫至少1級包名,例如:

yml logging.level.cn: trace

以上配置表示cn包及其子孫包下的所有類中的日誌都按照trace級別進行顯示!

在開發實踐中,屬性名稱通常配置為logging.level.專案根包,例如:

yml logging.level.cn.tedu.boot.demo: trace

在使用Slf4j時,通過log呼叫的每種級別的方法都被過載了多次(各級別對應除了方法名稱不同,過載的次數和引數列表均相同),推薦使用的方法是引數列表為(String format, Object... arguments)的,例如:

java public void trace(String format, Object... arguments); public void debug(String format, Object... arguments); public void info(String format, Object... arguments); public void warn(String format, Object... arguments); public void error(String format, Object... arguments);

以上方法中,第1個引數是將要輸出的字串的模式(模版),在此字串中,如果需要包含某個變數值,則使用{}表示,如果有多個變數值,均是如此,然後,再通過第2個引數(是可變引數)依次表示各{}對應的值,例如:

java log.debug("加密前的密碼:{},加密後的密碼:{}", password, encodedPassword);

使用這種做法,可以避免多變數時頻繁的拼接字串,另外,日誌框架會將第1個引數進行快取,以此提高後續每一次的執行效率。

在開發實踐中,應該對程式執行關鍵位置新增日誌的輸出,通常包括:

  • 每個方法的第1行有效語句,表示程式碼已經執行到此方法內,或此方法已經被成功呼叫
  • 如果方法是有引數的,還應該輸出引數的值
  • 關鍵資料或核心資料在改變之前和之後
  • 例如對密碼加密時,應該通過日誌輸出加密前和加密後的密碼
  • 重要的操作執行之前
  • 例如嘗試插入資料之前、修改資料之前,應該通過日誌輸出相關值
  • 程式走到某些重要的分支時
  • 例如經過判斷,走向丟擲異常之前

其實,Slf4j日誌框架只是日誌的一種標準,並不是具體的實現(感覺上與Java中的介面有點相似),常見有具體實現了日誌功能的框架有log4j、logback等,為了統一標準,所以才出現了Slf4j,同時,由於log4j、logback等框架實現功能並不統一,所以,Slf4j提供了對主流日誌框架的相容,在Spring Boot工程中,spring-boot-starter就已經依賴了spring-boot-starter-logging,而在此依賴下,通常包括Slf4j、具體的日誌框架、Slf4j對具體日誌框架的相容。

12. 密碼加密

【這並不是Spring Boot框架的知識點】

對密碼進行加密,可以有效的保障密碼安全,即使出現資料庫洩密,密碼安全也不會受到影響!為了實現此目標,需要在對密碼進行加密時,使用不可逆的演算法進行處理!

通常,不可以使用加密演算法對密碼進行加密碼處理,從嚴格定義上來看,所有的加密演算法都是可以逆向運算的,即同時存在加密和解密這2種操作,加密演算法只能用於保證傳輸過程的安全,並不應該用於保證需要儲存下來的密碼的安全!

雜湊演算法都是不可逆的,通常,用於處理密碼加密的演算法中,典型的是一些訊息摘要演算法,例如MD5、SHA256或以上位數的演算法。

訊息摘要演算法的主要特徵有:

  • 訊息相同時,摘要一定相同
  • 某種演算法,無論訊息長度多少,摘要的長度是固定的
  • 訊息不同時,摘要幾乎不會相同

在訊息摘要演算法中,以MD5為例,其運算結果是一個128位長度的二進位制數,通常會轉換成十六進位制數顯示,所以是32位長度的十六進位制數,MD5也被稱之為128位演算法。理論上,會存在2的128次方種類的摘要結果,且對應2的128次方種不同的訊息,如果在未超過2的128次方種訊息中,存在2個或多個不同的訊息對應了相同的摘要,則稱之為:發生了碰撞。一個訊息摘要演算法是否安全,取決其實際的碰撞概率,關於訊息摘要演算法的破解,也是研究其碰撞概率。

存在窮舉訊息和摘要的對應關係,並利用摘要在此對應關係進行查詢,從而得知訊息的做法,但是,由於MD5是128位演算法,全部窮舉是不可能實現的,所以,只要原始密碼(訊息)足夠複雜,就不會被收錄到所記錄的對應關係中去!

為了進一步提高密碼的安全性,在使用訊息摘要演算法進行處理時,通常還會加鹽!鹽值可以是任意的字串,用於與密碼一起作為被訊息摘要演算法運算的資料即可,例如:

java @Test public void md5Test() { String rawPassword = "123456"; String salt = "kjfcsddkjfdsajfdiusf8743urf"; String encodedPassword = DigestUtils.md5DigestAsHex( (salt + salt + rawPassword + salt + salt).getBytes()); System.out.println("原密碼:" + rawPassword); System.out.println("加密後的密碼:" + encodedPassword); }

加鹽的目的是使得被運算資料變得更加複雜,鹽值本身和用法並沒有明確要求!

甚至,在某些用法或演算法中,還會使用隨機的鹽值,則可以使用完全相同的原訊息對應的摘要卻不同!

推薦瞭解:預計算的雜湊鏈、彩虹表、雪花演算法。

為了進一步保證密碼安全,還可以使用多重加密,即反覆呼叫訊息摘要演算法。

除此以外,還可以使用安全係數更高的演算法,例如SHA-256是256位演算法,SHA-384是384位演算法,SHA-512是512位演算法。

一般的應用方式可以是:

```java public class PasswordEncoder {

public String encode(String rawPassword) {
    // 加密過程
    // 1. 使用MD5演算法
    // 2. 使用隨機的鹽值
    // 3. 迴圈5次
    // 4. 鹽的處理方式為:鹽 + 原密碼 + 鹽 + 原密碼 + 鹽
    // 注意:因為使用了隨機鹽,鹽值必須被記錄下來,本次的返回結果使用$分隔鹽與密文
    String salt = UUID.randomUUID().toString().replace("-", "");
    String encodedPassword = rawPassword;
    for (int i = 0; i < 5; i++) {
        encodedPassword = DigestUtils.md5DigestAsHex(
                (salt + encodedPassword + salt + encodedPassword + salt).getBytes());
    }
    return salt + encodedPassword;
}

public boolean matches(String rawPassword, String encodedPassword) {
    String salt = encodedPassword.substring(0, 32);
    String newPassword = rawPassword;
        for (int i = 0; i < 5; i++) {
            newPassword = DigestUtils.md5DigestAsHex(
                    (salt + newPassword + salt + newPassword + salt).getBytes());
    }
    newPassword = salt + newPassword;
    return newPassword.equals(encodedPassword);
}

} ```

13. 控制器層開發

Spring MVC是用於處理控制器層開發的,在使用Spring Boot時,在pom.xml中新增spring-boot-starter-web即可整合Spring MVC框架及相關的常用依賴項(包含jackson-databind),可以將已存在的spring-boot-starter直接改為spring-boot-starter-web,因為在spring-boot-starter-web中已經包含了spring-boot-starter

先在專案的根包下建立controller子包,並在此子包下建立AdminController,此類應該新增@RestController@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")註解,例如:

```java @RestController @RequestMapping(values = "/admins", produces = "application/json; charset=utf-8") public class AdminController {

} ```

由於已經決定了伺服器端響應時,將響應JSON格式的字串,為保證能夠響應JSON格式的結果,處理請求的方法返回值應該是自定義的資料型別,則從此前學習的spring-mvc專案中找到JsonResult類及相關型別,複製到當前專案中來。

接下來,即可在AdminController中新增處理“增加管理員”的請求:

```java @Autowired private IAdminService adminService;

// 注意:暫時使用@RequestMapping,不要使用@PostMapping,以便於直接在瀏覽器中測試 // http://localhost:8080/admins/add-new?username=root&password=1234 @RequestMapping("/add-new") public JsonResult addNew(AdminAddNewDTO adminAddNewDTO) { adminService.addNew(adminAddNewDTO); return JsonResult.ok(); } ```

完成後,執行啟動類,即可啟動整個專案,在spring-boot-starter-web中,包含了Tomcat的依賴項,在啟動時,會自動將當前專案打包並部署到此Tomcat上,所以,執行啟動類時,會執行此Tomcat,同時,因為是內建的Tomcat,只為當前專案服務,所以,在將專案部署到Tomcat時,預設已經將Context Path(例如spring_mvc_war_exploded)配置為空字串,所以,在啟動專案後,訪問的URL中並沒有此前遇到的Context Path值。

當專案啟動成功後,即可在瀏覽器的位址列中輸入網址進行測試訪問!

注意:如果是未新增的管理員賬號,可以成功執行結束,如果管理員賬號已經存在,由於尚未處理異常,會提示500錯誤。

關於處理異常,應該先在State中確保有每種異常對應的列舉值,例如本次需要補充InsertException對應的列舉值:

```java public enum State {

OK(200),
ERR_USERNAME(201),
ERR_PASSWORD(202),
ERR_INSERT(500); // 新增的列舉值

// 原有其它程式碼

} ```

然後,在cn.tedu.boot.demo.controller下建立handler.GlobalExceptionHandler類,用於統一處理異常,例如:

```java package cn.tedu.boot.demo.controller.handler;

import cn.tedu.boot.demo.ex.ServiceException; import cn.tedu.boot.demo.ex.UsernameDuplicateException; import cn.tedu.boot.demo.web.JsonResult; import cn.tedu.boot.demo.web.State; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice public class GlobalExceptionHandler {

@ExceptionHandler(ServiceException.class)
public JsonResult<Void> handleServiceException(ServiceException e) {
    if (e instanceof UsernameDuplicateException) {
        return JsonResult.fail(State.ERR_USERNAME, "使用者名稱錯誤!");
    } else {
        return JsonResult.fail(State.ERR_INSERT, "插入資料失敗!");
    }
}

} ```

完成後,重新啟動專案,當新增管理員時的使用者名稱沒有被佔用時,將正常新增,當用戶名已經被佔用時,會根據處理異常的結果進行響應!

由於在統一處理異常的機制下,同一種異常,無論是在哪種業務中出現,處理異常時的描述資訊都是完全相同的,也無法精準的表達錯誤資訊,這是不合適的!另外,基於面向物件的“分工”思想,關於錯誤資訊(異常對應的描述資訊),應該是由Service來描述,即“誰丟擲誰描述”,因為丟擲異常的程式碼片段是最瞭解、最明確出現異常的原因的!

為了更好的描述異常的原因,應該在自定義的ServiceException和其子孫類異常中新增基於父類的全部構造方法(5個),然後,在AdminServiceImpl中,當丟擲異常時,可以在異常的構造方法中新增String型別的引數,對異常發生的原因進行描述,例如:

```java @Override public void addNew(AdminAddNewDTO adminAddNewDTO) { // ===== 原有其它程式碼 =====

// 判斷查詢結果是否不為null
if (queryResult != null) {
    // 是:表示使用者名稱已經被佔用,則丟擲UsernameDuplicateException
    log.error("此賬號已經被佔用,將丟擲異常");
    throw new UsernameDuplicateException("新增管理員失敗,使用者名稱(" + username + ")已經被佔用!");
}

// ===== 原有其它程式碼 =====

// 判斷以上返回的結果是否不為1,丟擲InsertException異常
if (rows != 1) {
    throw new InsertException("新增管理員失敗,伺服器忙,請稍後再次嘗試!");
}

} ```

最後,在處理異常時,可以呼叫異常物件的getMessage()方法獲取丟擲時封裝的描述資訊,例如:

java @ExceptionHandler(ServiceException.class) public JsonResult<Void> handleServiceException(ServiceException e) { if (e instanceof UsernameDuplicateException) { return JsonResult.fail(State.ERR_USERNAME, e.getMessage()); } else { return JsonResult.fail(State.ERR_INSERT, e.getMessage()); } }

完成後,再次重啟專案,當用戶名已經存在時,可以顯示在Service中描述的錯誤資訊!

最後,當新增成功時,響應的JSON資料例如:

json { "state":200, "message":null, "data":null }

當用戶名衝突,新增失敗時,響應的JSON資料例如:

json { "state":201, "message":"新增管理員失敗,使用者名稱(liuguobin)已經被佔用!", "data":null }

可以看到,無論是成功還是失敗,響應的JSON中都包含了不必要的資料(為null的資料),這些資料屬性是沒有必要響應到客戶端的,如果需要去除這些不必要的值,可以在對應的屬性上使用註解進行配置,例如:

```java @Data public class JsonResult implements Serializable {

// 狀態碼,例如:200
private Integer state;
// 訊息,例如:"登入失敗,使用者名稱不存在"
@JsonInclude(JsonInclude.Include.NON_NULL)
private String message;
// 資料
@JsonInclude(JsonInclude.Include.NON_NULL)
private T data;

// ===== 原有其它程式碼 =====

} ```

則響應的JSON中只會包含不為null的部分。

此註解還可以新增在類上,則作用於當前類中所有的屬性,例如:

```java @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class JsonResult implements Serializable {

// ===== 原有其它程式碼 =====

} ```

即使新增在類上,也只對當前類的3個屬性有效,後續,當響應某些資料時,data屬性可能是使用者、商品、訂單等型別,這些型別的資料中為null的部分依然會被響應到客戶端去,所以,還需要對這些型別也新增相同的註解配置!

以上做法相對比較繁瑣,可以在application.properties / application.yml中新增全域性配置,則作用於當前專案中所有響應時涉及的類,例如在properties中配置為:

properties spring.jackson.default-property-inclusion=non_null

yml中配置為:

yml spring: jackson: default-property-inclusion: non_null

注意:當你需要在yml中新增以上配置時,字首屬性名可能已經存在,則不允許出現重複的字首屬性名,例如以下配置就是錯誤的:

yml spring: profiles: active: dev spring: # 此處就出現了相同的字首屬性名,是錯誤的 jackson: default-property-inclusion: non_null

正確的配置例如:

yml spring: profiles: active: dev jackson: default-property-inclusion: non_null

最後,以上配置只是“預設”配置,如果在某些型別中還有不同的配置需求,仍可以在類或屬性上通過@JsonInclude進行配置。

14. Validation框架

當客戶端向伺服器提交請求時,如果請求資料出現明顯的問題(例如關鍵資料為null、字串的長度不在可接受範圍內、其它格式錯誤),應該直接響應錯誤,而不是將明顯錯誤的請求引數傳遞到Service!

關於判斷錯誤,只有涉及資料庫中的資料才能判斷出結果的,都由Service進行判斷,而基本的格式判斷,都由Controller進行判斷。

Validation框架是專門用於解決檢查資料基本格式有效性的,最早並不是Spring系列的框架,目前,Spring Boot提供了更好的支援,所以,通常結合在一起使用。

在Spring Boot專案中,需要新增spring-boot-starter-validation依賴項,例如:

xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

在控制器中,首先,對需要檢查資料格式的請求引數新增@Valid@Validated註解(這2個註解沒有區別),例如:

java @RequestMapping("/add-new") public JsonResult<Void> addNew(@Validated AdminAddNewDTO adminAddNewDTO) { adminService.addNew(adminAddNewDTO); return JsonResult.ok(); }

真正需要檢查的是AdminAddNewDTO中各屬性的值,所以,接下來需要在此類的各屬性上通過註解來配置檢查的規則,例如:

```java @Data public class AdminAddNewDTO implements Serializable {

@NotNull // 驗證規則為:不允許為null
private String username;

// ===== 原有其它程式碼 =====

} ```

重啟專案,通過不提交使用者名稱的URL(例如:http://localhost:8080/admins/add-new)進行訪問,在瀏覽器上會出現400錯誤頁面,並且,在IntelliJ IDEA的控制檯會出現以下警告:

2022-06-07 11:37:53.424 WARN 6404 --- [nio-8080-exec-8] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [ org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors<EOL>Field error in object 'adminAddNewDTO' on field 'username': rejected value [null]; codes [NotNull.adminAddNewDTO.username,NotNull.username,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.username,username]; arguments []; default message [username]]; default message [不能為null]]

從警告資訊中可以看到,當驗證失敗時(不符合所使用的註解對應的規則時),會出現org.springframework.validation.BindException異常,則自行處理此異常即可!

如果有多個屬性需要驗證,則多個屬性都需要添加註解,例如:

```java @Data public class AdminAddNewDTO implements Serializable {

@NotNull
private String username;

@NotNull
private String password;

// ===== 原有其它程式碼 =====

} ```

首先,在State中新增新的列舉:

```java public enum State {

OK(200),
ERR_USERNAME(201),
ERR_PASSWORD(202),
ERR_BAD_REQUEST(400), // 新增
ERR_INSERT(500);

// ===== 原有其它程式碼 =====

} ```

然後,在GlobalExceptionHandler中新增新的處理異常的方法:

java @ExceptionHandler(BindException.class) public JsonResult<Void> handleBindException(BindException e) { return JsonResult.fail(State.ERR_BAD_REQUEST, e.getMessage()); }

完成後,再次重啟專案,繼續使用為null的使用者名稱提交請求時,可以看到異常已經被處理,此時,響應的JSON資料例如:

json { "state":400, "message":"org.springframework.validation.BeanPropertyBindingResult: 2 errors\nField error in object 'adminAddNewDTO' on field 'username': rejected value [null]; codes [NotNull.adminAddNewDTO.username,NotNull.username,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.username,username]; arguments []; default message [username]]; default message [不能為null]\nField error in object 'adminAddNewDTO' on field 'password': rejected value [null]; codes [NotNull.adminAddNewDTO.password,NotNull.password,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.password,password]; arguments []; default message [password]]; default message [不能為null]" }

關於錯誤提示資訊,以上內容中出現了不能為null的字樣,是預設的提示文字,可以通過@NotNull註解的message屬性進行配置,例如:

```java @Data public class AdminAddNewDTO implements Serializable {

@NotNull(message = "新增管理員失敗,請提交使用者名稱!")
private String username;

@NotNull(message = "新增管理員失敗,請提交密碼!")
private String password;

// ===== 原有其它程式碼 =====

} ```

然後,在處理異常時,通過異常資訊獲取自定義的提示文字:

java @ExceptionHandler(BindException.class) public JsonResult<Void> handleBindException(BindException e) { BindingResult bindingResult = e.getBindingResult(); String defaultMessage = bindingResult.getFieldError().getDefaultMessage(); return JsonResult.fail(State.ERR_BAD_REQUEST, defaultMessage); }

再次執行,在不提交使用者名稱和密碼的情況下,會隨機的提示使用者名稱或密碼驗證失敗的提示文字中的某1條。

在Validation框架中,還有其它許多註解,用於進行不同格式的驗證,例如:

  • @NotEmpty:只能新增在String型別上,不許為空字串,例如""即視為空字串
  • @NotBlank:只能新增在String型別上,不允許為空白,例如普通的空格可視為空白,使用TAB鍵輸入的內容也是空白,(雖然不太可能在此處出現)換行產生的空白區域也是空白
  • @Size:限制大小
  • @Min:限制最小值
  • @Max:限制最大值
  • @Range:可以配置minmax屬性,同時限制最小值和最大值
  • @Pattern:只能新增在String型別上,自行指定正則表示式進行驗證
  • 其它

以上註解,包括@NotNull是允許疊加使用的,即允許在同一個引數屬性上新增多個註解!

以上註解均可以配置message屬性,用於指定驗證失敗的提示文字。

通常:

  • 對於必須提交的屬性,都會新增@NotNull
  • 對於數值型別的,需要考慮是否新增@Range(則不需要使用@Min@Max
  • 對於字串型別,都新增@Pattern註解進行驗證

15. 解決跨域問題

在使用前後端分離的開發模式下,前端專案和後端專案可能是2個完全不同的專案,並且,各自己獨立開發,獨立部署,在這種做法中,如果前端直接向後端傳送非同步請求,預設情況下,在前端會出現類似以下錯誤:

Access to XMLHttpRequest at 'http://localhost:8080/admins/add-new' from origin 'http://localhost:8081' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

以上錯誤資訊的關鍵字是CORS,通常稱之為“跨域問題”。

在基於Spring MVC框架的專案中,當需要解決跨域問題時,需要一個Spring MVC的配置類(實現了WebMvcConfigurer介面的類),並重寫其中的方法,以允許指定條件的跨域訪問,例如:

```java package cn.tedu.boot.demo.config;

import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration public class SpringMvcConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
            .allowedOriginPatterns("*")
            .allowedMethods("*")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
}

} ```

16. 關於客戶端提交請求引數的格式

通常,客戶端向伺服器端傳送請求時,請求引數可以有2種形式,第1種是直接通過&拼接各引數與值,例如:

javascript // FormData // username=root&password=123456&nickname=jackson&phone=13800138001&[email protected]&description=none let data = 'username=' + this.ruleForm.username + '&password=' + this.ruleForm.password + '&nickname=' + this.ruleForm.nickname + '&phone=' + this.ruleForm.phone + '&email=' + this.ruleForm.email + '&description=' + this.ruleForm.description;

第2種方式是使用JSON語法來組織各引數與值,例如:

javascript let data = { 'username': this.ruleForm.username, // 'root' 'password': this.ruleForm.password, // '123456' 'nickname': this.ruleForm.nickname, // 'jackson' 'phone': this.ruleForm.phone, // '13800138001' 'email': this.ruleForm.email, // '[email protected]' 'description': this.ruleForm.description // 'none' };

具體使用哪種做法,取決於伺服器端的設計:

  • 如果伺服器端處理請求的方法中,在引數前添加了@RequestBody,則允許使用以上第2種做法(JSON資料)提交請求引數,不允許使用以上第1種做法(使用&拼接)
  • 如果沒有使用@RequestBody,則只能使用以上第1種做法

17. 處理登入

17.1. 開發流程

正常的專案開發流程大致是:

  • 先整理出當前專案涉及的資料的型別
  • 例如:電商類包含使用者、商品、購物車、訂單等
  • 再列舉各種資料型別涉及的資料操作
  • 例如:使用者型別涉及註冊、登入等
  • 再挑選相對簡單的資料型別先處理
  • 簡單的易於實現,且可以積累經驗
  • 在各資料型別涉及的資料操作中,大致遵循增、查、刪、改的開發順序
  • 只有先增,還可能查、刪、改
  • 只有查了以後,才能明確有哪些資料,才便於實現刪、改
  • 刪和改相比,刪一般更加簡單,所以先開發刪,再開發改
  • 在開發具體的資料操作時,應該大致遵循持久層 >> 業務邏輯層 >> 控制器層 >> 前端頁面的開發順序

17.2. 管理員登入-持久層

17.2.1. 建立或配置

如果是整個專案第1次開發持久層,在Spring Boot專案中,需要配置:

  • 使用@MapperScan配置介面所在的根包
  • 在配置檔案中通過mybatis.mapper-locations配置XML檔案的位置

如果第1次處理某種型別資料的持久層訪問,需要:

  • 建立介面
  • 建立XML檔案

本次需要開發的“管理員登入”並不需要再做以上操作

17.2.2. 規劃需要執行的SQL語句

需要執行的SQL語句大致是:

mysql select * from ams_admin where username=?

由於在ams_admin表中有大量欄位,同時,不允許使用星號表示欄位列表,則以上SQL語句應該細化為:

mysql select id, username, password, nickname, avatar, is_enable from ams_admin where username=?

提示:理論上,還應該查出login_count,當登入成功後,還應該更新login_countgmt_last_login等資料,此次暫不考慮。

17.2.3. 在介面中新增抽象方法(含建立必要的VO類)

提示:所有的查詢結果,都應該使用VO類,而不要使用實體類,根據阿里的開發規範,每張資料表中都應該有idgmt_creategmt_modified這3個欄位,而gmt_creategmt_modified這2個欄位都是用於特殊情況下排查問題的,一般情況下均不會使用,所以,如果使用實體類,必然存在多餘的屬性,同時,由於不使用星號作為欄位列表,則一般也不會查詢這2個欄位的值,會導致實體類物件中永遠至少存在2個屬性為null

根據以上提示,以前已經寫好的getByUsername()是不規範的,應該調整已存在此方法,本次並不需要新增新的抽象方法。

則先建立cn.tedu.boot.demo.pojo.vo.AdminSimpleVO類,新增此次查詢時需要的屬性:

```java package cn.tedu.boot.demo.pojo.vo;

@Data public class AdminSimpleVO implements Serializable { private Long id; private String username; private String password; private String nickname; private String avatar; private Integer isEnable; } ```

然後,在AdminMapper介面檔案中,將原有的Admin getByUsername(String username);改為:

java AdminSimpleVO getByUsername(String username);

注意:一旦修改了原有程式碼,則呼叫了原方法的程式碼都會出現錯誤,包括:

  • 測試
  • 業務邏輯層的實現類

應該及時修改錯誤的程式碼,但是,由於此時還未完成SQL配置,所以,相關程式碼暫時並不能執行。

17.2.4. 在XML中配置SQL

AdminMapper.xml中,需要調整:

  • 刪除<sql>中不必查詢的欄位,注意:此處的欄位列表最後不要有多餘的逗號
  • 修改<resultMap>節點的type屬性值
  • <resultMap>節點下,刪除不必要的配置

```xml

id, username, password, nickname, avatar, is_enable

```

17.2.5. 編寫並執行測試

此次並不需要編寫新的測試,使用原有的測試即可!

注意:由於本次是修改了原“增加管理員”就已經使用的功能,應該檢查原功能是否可以正常執行。

17.3. 管理員登入-業務邏輯層

17.3.1. 建立

如果第1次處理某種型別資料的業務邏輯層訪問,需要:

  • 建立介面
  • 建立類,實現介面,並在類上新增@Service註解

本次需要開發的“管理員登入”並不需要再做以上操作

17.3.2. 在介面中新增抽象方法(含建立必要的DTO類)

在設計抽象方法時,如果引數的數量超過1個,且多個引數具有相關性(是否都是客戶端提交的,或是否都是控制器傳遞過來的等),就應該封裝!

在處理登入時,需要客戶端提交使用者名稱和密碼,則可以將使用者名稱、密碼封裝起來:

```java package cn.tedu.boot.demo.pojo.dto;

@Data public class AdminLoginDTO implements Serializable { private String username; private String password; } ```

IAdminService中新增抽象方法:

java AdminSimpleVO login(AdminLoginDTO adminLoginDTO);

17.3.3. 在實現類中設計(打草稿)業務流程與業務邏輯(含建立必要的異常類)

此次業務執行過程中,可能會出現:

  • 使用者名稱不存在,導致無法登入
  • 使用者狀態為【禁用】,導致無法登入
  • 密碼錯誤,導致無法登入

關於使用者名稱不存在的問題,可以自行建立新的異常類,例如,在cn.tedu.boot.demo.ex包下建立UserNotFoundException類表示使用者資料不存在的異常,繼承自ServiceException,且新增5款基於父類的構造方法:

```java package cn.tedu.boot.demo.ex;

public class UserNotFoundException extends ServiceException { // 自動生成5個構造方法 } ```

再建立UserStateException表示使用者狀態異常:

```java package cn.tedu.boot.demo.ex;

public class UserStateException extends ServiceException { // 自動生成5個構造方法 } ```

再建立PasswordNotMatchException表示密碼錯誤異常:

```java package cn.tedu.boot.demo.ex;

public class PasswordNotMatchException extends ServiceException { // 自動生成5個構造方法 } ```

登入過程大致是:

```java public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) { // 通過引數得到嘗試登入的使用者名稱 // 呼叫adminMapper.getByUsername()方法查詢 // 判斷查詢結果是否為null // 是:表示使用者名稱不存在,則丟擲UserNotFoundException異常

// 【如果程式可以執行到此步,則可以確定未丟擲異常,即查詢結果不為null】
// 【以下可視為:存在與使用者名稱匹配的管理員資料】
// 判斷查詢結果中的isEnable屬性值是否不為1
// 是:表示此使用者狀態是【禁用】的,則丟擲UserStateException異常

// 【如果程式可以執行到此步,表示此使用者狀態是【啟用】的】
// 從引數中取出此次登入時客戶端提交的密碼
// 呼叫PasswordEncoder物件的matches()方法,對客戶端提交的密碼和查詢結果中的密碼進行驗證
// 判斷以上驗證結果
// true:密碼正確,視為登入成功
// -- 將查詢結果中的password、isEnable設定為null,避免響應到客戶端
// -- 返回查詢結果
// false:密碼錯誤,視為登入失敗,則丟擲PasswordNotMatchException異常

} ```

17.3.4. 在實現類中實現業務

AdminServiceImpl中重寫介面中新增的抽象方法:

```java @Override public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) { // 日誌 log.debug("即將處理管理員登入的業務,嘗試登入的管理員資訊:{}", adminLoginDTO); // 通過引數得到嘗試登入的使用者名稱 String username = adminLoginDTO.getUsername(); // 呼叫adminMapper.getByUsername()方法查詢 AdminSimpleVO queryResult = adminMapper.getByUsername(username); // 判斷查詢結果是否為null if (queryResult == null) { // 是:表示使用者名稱不存在,則丟擲UserNotFoundException異常 log.warn("登入失敗,使用者名稱不存在!"); throw new UserNotFoundException("登入失敗,使用者名稱不存在!"); }

// 【如果程式可以執行到此步,則可以確定未丟擲異常,即查詢結果不為null】
// 【以下可視為:存在與使用者名稱匹配的管理員資料】
// 判斷查詢結果中的isEnable屬性值是否不為1
if (queryResult.getIsEnable() != 1) {
    // 是:表示此使用者狀態是【禁用】的,則丟擲UserStateException異常
    log.warn("登入失敗,此賬號已經被禁用!");
    throw new UserNotFoundException("登入失敗,此賬號已經被禁用!");
}

// 【如果程式可以執行到此步,表示此使用者狀態是【啟用】的】
// 從引數中取出此次登入時客戶端提交的密碼
String rawPassword = adminLoginDTO.getPassword();
// 呼叫PasswordEncoder物件的matches()方法,對客戶端提交的密碼和查詢結果中的密碼進行驗證
boolean matchResult = passwordEncoder.matches(rawPassword, queryResult.getPassword());
// 判斷以上驗證結果
if (!matchResult) {
    // false:密碼錯誤,視為登入失敗,則丟擲PasswordNotMatchException異常
    log.warn("登入失敗,密碼錯誤!");
    throw new PasswordNotMatchException("登入失敗,密碼錯誤!");
}

// 密碼正確,視為登入成功
// 將查詢結果中的password、isEnable設定為null,避免響應到客戶端
queryResult.setPassword(null);
queryResult.setIsEnable(null);
// 返回查詢結果
log.debug("登入成功,即將返回:{}", queryResult);
return queryResult;

} ```

17.3.5. 編寫並執行測試

AdminServiceTests中新增測試:

```java @Sql({"classpath:truncate.sql", "classpath:insert_data.sql"}) @Test public void testLoginSuccessfully() { // 測試資料 String username = "admin001"; String password = "123456"; AdminLoginDTO adminLoginDTO = new AdminLoginDTO(); adminLoginDTO.setUsername(username); adminLoginDTO.setPassword(password); // 斷言不會丟擲異常 assertDoesNotThrow(() -> { // 執行測試 AdminSimpleVO adminSimpleVO = service.login(adminLoginDTO); log.debug("登入成功:{}", adminSimpleVO); // 斷言測試結果 assertEquals(1L, adminSimpleVO.getId()); assertNull(adminSimpleVO.getPassword()); assertNull(adminSimpleVO.getIsEnable()); }); }

@Sql({"classpath:truncate.sql"}) @Test public void testLoginFailBecauseUserNotFound() { // 測試資料 String username = "admin001"; String password = "123456"; AdminLoginDTO adminLoginDTO = new AdminLoginDTO(); adminLoginDTO.setUsername(username); adminLoginDTO.setPassword(password); // 斷言會丟擲UserNotFoundException assertThrows(UserNotFoundException.class, () -> { // 執行測試 service.login(adminLoginDTO); }); }

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"}) @Test public void testLoginFailBecauseUserDisabled() { // 測試資料 String username = "admin005"; // 通過SQL指令碼插入的此資料,is_enable為0 String password = "123456"; AdminLoginDTO adminLoginDTO = new AdminLoginDTO(); adminLoginDTO.setUsername(username); adminLoginDTO.setPassword(password); // 斷言會丟擲UserStateException assertThrows(UserStateException.class, () -> { // 執行測試 service.login(adminLoginDTO); }); }

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"}) @Test public void testLoginFailBecausePasswordNotMatch() { // 測試資料 String username = "admin001"; String password = "000000000000000000"; AdminLoginDTO adminLoginDTO = new AdminLoginDTO(); adminLoginDTO.setUsername(username); adminLoginDTO.setPassword(password); // 斷言會丟擲PasswordNotMatchException assertThrows(PasswordNotMatchException.class, () -> { // 執行測試 service.login(adminLoginDTO); }); } ```

17.4. 管理員登入-控制器層

17.4.1. 建立

如果是整個專案第1次開發控制器層,需要:

  • 建立統一處理異常的類
  • 新增@RestControllerAdvice
  • 建立統一的響應結果型別及相關型別
  • 例如:JsonResultState

如果第1次處理某種型別資料的控制器層訪問,需要:

  • 建立控制器類
  • 新增@RestController
  • 新增@RequestMapping

本次需要開發的“管理員登入”並不需要再做以上操作

17.4.2. 新增處理請求的方法,驗證請求引數的基本有效性

AdminLoginDTO的各屬性上新增驗證基本有效性的註解,例如:

```java package cn.tedu.boot.demo.pojo.dto;

import lombok.Data;

import javax.validation.constraints.NotNull; import java.io.Serializable;

@Data public class AdminLoginDTO implements Serializable {

@NotNull(message = "登入失敗,請提交使用者名稱!") // 新增
private String username;

@NotNull(message = "登入失敗,請提交密碼!") // 新增
private String password;

} ```

AdminController中新增處理請求的方法:

java @RequestMapping("/login") // 暫時使用@RequestMapping,後續改成@PostMapping public JsonResult<AdminSimpleVO> login(@Validated AdminLoginDTO adminLoginDTO) { AdminSimpleVO adminSimpleVO = adminService.login(adminLoginDTO); return JsonResult.ok(adminSimpleVO); }

17.4.3. 處理異常(按需)

先在State中新增新建立的異常對應列舉:

```java public enum State {

OK(200),
ERR_USERNAME(201),
ERR_PASSWORD(202),
ERR_STATE(203), // 新增
ERR_BAD_REQUEST(400),
ERR_INSERT(500);

// ===== 原有其它程式碼 =====

} ```

GlobalExceptionHandlerhandleServiceException()方法中新增更多分支,針對各異常進行判斷,並響應不同結果:

java @ExceptionHandler(ServiceException.class) public JsonResult<Void> handleServiceException(ServiceException e) { if (e instanceof UsernameDuplicateException) { return JsonResult.fail(State.ERR_USERNAME, e.getMessage()); } else if (e instanceof UserNotFoundException) { // 從此行起,是新增的 return JsonResult.fail(State.ERR_USERNAME, e.getMessage()); } else if (e instanceof UserStateException) { return JsonResult.fail(State.ERR_STATE, e.getMessage()); } else if (e instanceof PasswordNotMatchException) { return JsonResult.fail(State.ERR_PASSWORD, e.getMessage()); // 新增結束標記 } else { return JsonResult.fail(State.ERR_INSERT, e.getMessage()); } }

17.4.4. 測試

啟動專案,暫時通過 http://localhost:8080/admins/login?username=admin001&password=123456 類似的URL測試訪問。注意:在測試訪問之前,必須保證資料表中的資料狀態是符合預期的。

17.5. 管理員登入-前端頁面

18. 控制器層的測試

關於控制器層,也可以寫測試方式進行測試,在Spring Boot專案中,可以使用MockMvc進行模擬測試,例如:

```java package cn.tedu.boot.demo.controller;

import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest @AutoConfigureMockMvc // 自動配置MockMvc public class AdminControllerTests {

@Autowired
MockMvc mockMvc; // Mock:模擬

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginSuccessfully() throws Exception {
    // 準備測試資料,不需要封裝
    String username = "admin001";
    String password = "123456";
    // 請求路徑,不需要寫協議、伺服器主機和埠號
    String url = "/admins/login";
    // 執行測試
    // 以下程式碼相對比較固定
    mockMvc.perform( // 執行發出請求
            MockMvcRequestBuilders.post(url) // 根據請求方式決定呼叫的方法
            .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 請求資料的文件型別,例如:application/json; charset=utf-8
            .param("username", username) // 請求引數,有多個時,多次呼叫param()方法
            .param("password", password)
            .accept(MediaType.APPLICATION_JSON)) // 接收的響應結果的文件型別,注意:perform()方法到此結束
            .andExpect( // 預判結果,類似斷言
                    MockMvcResultMatchers
                            .jsonPath("state") // 預判響應的JSON結果中將有名為state的屬性
                            .value(200)) // 預判響應的JSON結果中名為state的屬性的值,注意:andExpect()方法到此結束
            .andDo( // 需要執行某任務
                    MockMvcResultHandlers.print()); // 列印日誌
}

} ```

執行以上測試時,並不需要啟動當前專案即可測試。

在執行以上測試時,響應的JSON中如果包含中文,可能會出現亂碼,需要在配置檔案(application.propertiesapplication.yml這類檔案)中新增配置。

.properties檔案中:

properties server.servlet.encoding.force=true server.servlet.encoding.charset=utf-8

.yml檔案中:

yml server: servlet: encoding: force: true charset: utf-8