Spring Boot工程開發流程
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\java
和src\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-starter
和spring-boot-starter-test
依賴,分別是Spring Boot的基礎依賴和基於Spring Boot的測試的依賴。
另外,如果在建立工程時,勾選依賴項時選中了Web
項,在src\main\resources
下預設就已經建立了static
和templates
資料夾,如果沒有勾選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
中,其實phone
和email
也是設定了unique
的,如果完整的實現,則還需要新增根據phone
查詢管理員的功能,和根據email
查詢管理員的功能,在不實現這2個功能的情況下,後續進行測試和使用時,應該不使用重複的phone
和email
值來測試或執行
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
時,只顯示info
、warn
、error
,不會顯示debug
、trace
級別的日誌!
當輸出日誌時,通過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
完成後,執行啟動類,即可啟動整個專案,在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
// 狀態碼,例如: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
// ===== 原有其它程式碼 =====
} ```
即使新增在類上,也只對當前類的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
:可以配置min
和max
屬性,同時限制最小值和最大值@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_count
、gmt_last_login
等資料,此次暫不考慮。
17.2.3. 在介面中新增抽象方法(含建立必要的VO類)
提示:所有的查詢結果,都應該使用VO類,而不要使用實體類,根據阿里的開發規範,每張資料表中都應該有id
、gmt_create
、gmt_modified
這3個欄位,而gmt_create
、gmt_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
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
- 建立統一的響應結果型別及相關型別
- 例如:
JsonResult
及State
如果第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);
// ===== 原有其它程式碼 =====
} ```
在GlobalExceptionHandler
的handleServiceException()
方法中新增更多分支,針對各異常進行判斷,並響應不同結果:
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.properties
或application.yml
這類檔案)中新增配置。
在.properties
檔案中:
properties
server.servlet.encoding.force=true
server.servlet.encoding.charset=utf-8
在.yml
檔案中:
yml
server:
servlet:
encoding:
force: true
charset: utf-8