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