一般情況下,在流程達到存儲引擎前,所有的驗證規則必須全部通過,盡量不要使用存儲引擎作為兜底方案。但有一種情況極為特殊,也就只有存儲引擎能夠優雅的完成,那就是唯一鍵保護。
1. 規則驗證是準確性的基礎
規則驗證是業務穩定性的重要保障手段,通過規則驗證,可以驗證和確保系統或業務邏輯的正確性和合規性,避免潛在的錯誤和問題。而規則的遺漏往往會伴隨著線上bug的出現。
相信每個開發人員都曾面對過以下情況:
- 未對入參進行非空判斷,在執行邏輯時導致空指針異常(NullPointerException,簡稱NPE);
- 未正確驗證用戶權限,導致未授權操作發生,普通用戶也能執行該操作,最終產生安全問題;
- 在數據被存儲到數據庫時,沒有進行完整性驗證,導致無效數據被存儲;
- 在業務邏輯中,未對可能拋出的異常進行適當的處理,導致系統無法正常運行;
- …
可見,驗證對流程極為重要,不合理的輸入會導致嚴重的業務問題。同時錯誤數據的影響也比想象中的大得多:
- 可能會導致整個寫流程異常中斷;
- 錯誤數據入庫后,會對所有的讀操作造成致命的傷害;
- 上游系統數據錯誤,下游系統紛紛“崩潰”;
2. 防御式編程
如何避免上述情況的發生,答案就在 防御式編程。
防御式編程(Defensive Programming)是一種軟件開發方法,目的是在代碼中預測可能出現的異常和錯誤情況,并用適當的措施對其進行處理,從而提高軟件的健壯性和穩定性。通過防御式編程,軟件開發人員可以在軟件功能相對復雜的情況下,避免和減少由于程序錯誤所導致的不可預測的行為和不良影響,并保障軟件在部署和運行時的正確性和穩定性,提高軟件可靠性和安全性。
防御式編程的核心思想是在代碼中盡量考慮一切可能出現的異常和錯誤情況,并在代碼中針對這些異常和錯誤情況做出相應的處理。例如,可以使用異常捕獲機制處理可能出現的異常,充分利用代碼注釋和約束條件來規范輸入數據,使用斷言(assert)來檢查代碼中的前置條件和后置條件等。
概念過于繁瑣,簡單理解:防御式編程就是:
- 不要相信任何輸入,在正式使用前,必須保證參數的有效性;
- 不相信任何處理邏輯,在流程處理后,必須保證業務規則仍舊有效;
對輸入參數保持懷疑,對業務執行的前提條件保存懷疑,對業務執行結果保持懷疑,將極大的提升系統的準確性!
3. 異常中斷還是返回值中斷?
在規則校驗場景,優先使用異常進行流程中斷。
3.1. 異常中斷才是標配
在沒有提供異常的編程語言中,我們只能使用特殊返回值來表示異常,這樣的設計會將正常流程和異常處理流程混在一起,讓語言失去了可讀性。比如在 C 中,通用會使用 -1 或 NULL 來表示異常情況,所以在調用函數第一件事便是判斷 result 是 NULL 或 -1,比如以下代碼:
void readFileAndPrintContent(const char* filename) {
FILE* file = fopen(filename, "r");
if (file == NULL) {
// 文件無法打開,返回異常狀態
fprintf(stderr, "FAIled to open the file.n");
return; // 直接返回,表示發生異常
}
char line[256];
while (fgets(line, sizeof(line), file) != NULL) {
printf("%s", line);
}
fclose(file);
}
在 JAVA 語言中,引入了完整的異常機制,以更好的處理異常情況,該機制有如下特點:
- 邏輯分離,將正常處理和異常處理進行分離。異常機制可以將錯誤處理代碼從正常業務邏輯中分離出來,使得代碼更加清晰和易讀。同時,可以將異常處理代碼集中在一起,便于理解和維護;
- 異常傳播和捕獲。當異常在方法中被拋出時,可以選擇在當前方法中捕獲并處理異常,或者將異常繼續傳播給調用者,直到找到合適的異常處理器。這種靈活的異常傳播機制使得錯誤可以被適當地處理,而不會造成程序的中斷;
- 異常信息傳遞。在異常對象中,Java 提供了豐富的信息用于描述異常的發生原因和上下文。包括異常類別、異常消息、異常發生的位置等。這些信息可以幫助開發人員快速定位和修復異常,提高代碼的調試和維護效率;
在 Java 中異常處理變得簡單且嚴謹:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class FileReadExample {
public static void readFileAndPrintContent(String filename) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
public static void main(String[] args) {
try {
readFileAndPrintContent("example.txt");
} catch (IOException e) {
System.err.println("Exception occurred: " + e.getMessage());
System.exit(-1); // 返回錯誤代碼 -1 表示發生異常
}
}
}
在日常業務開發中,當出現不符合業務預期時,直接通過異常對流程進行中斷即可。
3.2. 立即中斷還是階段中斷?
當出現不符合預期情況時,是直接拋出異常,還是完成整個階段在拋出異常?這個需要看業務場景!
參數驗證場景,需要對所有不合法信息進行收集,然后統一拋出異常,從而能夠讓用戶一目了然的看到所有問題信息,以方便進行統一修改。
而在業務場景,不符合規則時,需要直接進行異常中斷,避免對后續流程造成破壞。
4. 標準寫流程中的規則驗證
使用 DDD 進行開發時,一個標準的寫流程包括:
其中,涉及5大類規則驗證,如:
- 參數校驗。對入參進行基本校驗,比如是否為null、類型是否匹配等;
- 業務校驗。特指對前置業務規則或資源的校驗,比如庫存是否足夠、商品狀態是否可售等;
- 狀態校驗。特指聚合內狀態校驗,核心為業務狀態機,比如只有訂單狀態為待支付時,才能進行支付成功操作;
- 固定規則校驗。如果聚合內有固定規則,在進行持久化操作前,需對規則進行驗證,比如 訂單支付金額 = 所有商品售賣價格總和 - 各類優惠總和;
- 存儲引擎校驗?;诖鎯σ嫣匦赃M行的最后保障,比如在表中創建唯一索引,從而避免用戶的多次提交(冪等保護);
4.1. 參數校驗
這是最基礎的校驗,沒有太多的業務概念,只有簡單的參數。其目的是 對數據格式進行驗證。
針對這種通用能力,優先借助框架來完成,常用框架主要有:
- validation 框架。主要用于解決簡單屬性的驗證;
- Verifiable + AOP。主要用于多個屬性的驗證;
4.1.1. Validation 框架
對于單屬性的驗證,可以使用 hibernate validation 框架來實現。Hibernate Validation 是一個基于 Java Bean 驗證規范(Java Bean Validation)的驗證框架,它提供了一系列的特性來實現對數據模型的驗證和約束,其特性主要包括:
- 提供了一組驗證注解,用于在數據模型的字段、方法參數、返回值等地方添加驗證約束。例如,@NotNull 用于驗證字段不能為空,@Email 用于驗證郵箱格式,@Size 用于驗證字符串長度等;
- 提供了許多內置的驗證注解,用于實現常見的驗證需求。例如,@NotBlank 用于驗證字符串非空且必須包含至少一個非空白字符,@Pattern 用于驗證字符串匹配指定的正則表達式,@Min 和 @Max 用于驗證數字的最小值和最大值等;
- 除了使用內置的驗證注解外,Hibernate Validation 還允許開發者通過自定義約束注解來定義和應用自定義的驗證規則。通過創建自定義的約束注解,可以實現更加靈活和符合業務需求的驗證規則;
- 允許將多個驗證約束分組,并在需要時對特定的驗證組進行驗證。這樣可以根據具體的場景選擇性地執行驗證操作,從而實現更加細粒度的驗證控制;
- 提供了驗證器和驗證上下文等核心類,用于執行驗證操作和獲取驗證結果。驗證器負責執行驗證操作,而驗證上下文提供了豐富的方法來獲取驗證結果、獲取驗證錯誤信息等;
- 允許通過指定驗證注解的驗證順序,來控制驗證的執行順序。這樣可以確保在需要按順序執行驗證約束時,每個約束都會按照指定的順序進行驗證;
- 支持國際化,可以根據不同的語言環境為驗證錯誤消息提供多語言支持。開發者可以定義并配置驗證錯誤消息的資源文件,從而實現跨語言的驗證錯誤消息;
特性非常多,我們最常用的就是在模型字段、方法參數、返回值增加相應功能的注解,比如在 CreateOrderCmd 中增加相關驗證注解,從而避免手寫代碼:
@Data
public class CreateOrderCmd {
@NotNull
private Long userId;
@NotNull
private Long productId;
@NotNull
@Min(1)
private int count;
}
4.1.2. Verifiable +AOP
有些參數驗證可能會比較復雜,需要對多個屬性進行判斷,此時 Validation 框架會顯得無能為力。
當然,可以制定相應規范,在參數封裝的類上統一提供一個 validate 方法,并在進入方法后使用參數前調用該方法。但,規范由人執行難免發生遺留。所以,最佳方案是將其內化到框架。如下圖所示:
- 首先,定義一個接口 Verifiable,該接口只有一個 validate 方法;
- 其次,定義一個 ValidateIntercepter 類,基于前置攔截器對入參進行判斷,如果實現 Verifiable 接口,則自動調用 validate 方法;
- 最后,基于 AOP 技術生成 Proxy,從而完成統一的參數校驗;
當需要對多個參數進行校驗時,只需要實現 Verifiable 接口的 validate 方法即可,無需手工對 validate 進行調用。
4.2. 業務校驗
業務校驗是業務邏輯執行的前置條件驗證,包括外部校驗和控制條件校驗。
通常情況下,業務校驗比較復雜,變化頻次也比較高,所以對擴展性要求很高。但,業務規則本身比較獨立,相互之間沒有太多的依賴關系。為了更好的應對邏輯擴展,可以使用策略模型進行設計。如下圖所示:
4.2.1. 業務驗證器
業務驗證器就是策略模型中的策略接口。
核心代碼如下:
public interface BaseValidator<A> extends SmartComponent<A> {
void validate(A a, ValidateErrorHandler validateErrorHandler);
default void validate(A a){
validate(a, ((name, code, msg) -> {
throw new ValidateException(name, code, msg);
}));
}
}
該接口非常簡單:
- 提供統一的 validate 方法定義;
- 繼承自 SmartComponent,可以通過 boolean support(A a) 來驗證該組件是否能被處理;
4.2.2. 共享數據 Context
有了統一的策略接口后,需要使用 Context 模式對入參進行管理。Context 可以是簡單的數據容器,也可以是一個具有 LazyLoad 能力的加強容器,其核心功能就是在多個策略間實現數據的共享。
比如,在生單流程中的 CreateOrderContext 定義如下:
@Data
public class CreateOrderContext implements CreateOrderContext{
private CreateOrderCmd cmd;
@LazyLoadBy("#{@userRepository.getById(cmd.userId)}")
private User user;
@LazyLoadBy("#{@productRepository.getById(cmd.productId)}")
private Product product;
@LazyLoadBy("#{@addressRepository.getDefaultAddressByUserId(user.id)}")
private Address defAddress;
@LazyLoadBy("#{@stockRepository.getByProductId(product.id)}")
private Stock stock;
@LazyLoadBy("#{@priceService.getByUserAndProduct(user.id, product.id)}")
private Price price;
}
其中 @LazyLoadBy 是一個功能加強注解,在第一次訪問屬性的 getter 方法時,將自動觸發數據加載,并將加載的數據設置到屬性上,再第二次訪問時,直接從屬性上獲取所需數據。
【注】對該部分感興趣,可以學習 《Command&Query Object 與 Context 模式》
4.2.3. 策略類管理
在有了策略接口 和 共享數據 Context 后,接下來便是按照業務需求實現高內聚低耦合的各種實現類。如下圖所示:
這些組件如何進行管理,詳見下圖:
- 首先,在啟動時,所有的 BusinessValidator 實例會被 Spring 注入到 ValidateService 中的 validators 集合;
- 在調用 validateBusiness 函數時,依次遍歷 validators 集合,找到能處理該 Context 的 validator 實例并執行對應的 validate 方法;
這樣做最大的好處便是,在驗證組件中徹底實現“開閉原則”:
- 增加新的業務邏輯時,只需增加一個新的 Spring 組件,系統將自動完成集成;
- 在修改某個業務校驗時,只會更改一個類,對其他校驗沒有影響;
認真思考后,可能會發現:這其實是責任鏈模式的一種變形。但,由于實現非常簡單,在 Spring 框架中多次使用。
4.3. 狀態校驗
狀態校驗又成前置狀態驗證,是業務規則中最重要的一部分。
核心實體通常會有一個狀態屬性,狀態屬性的這些值共同組成一個標準的狀態機。如下圖所示:
這是一個訂單實體的狀態機,定義了各狀態間的轉換關系,這是領域設計中最為重要的一部分。當發生業務動作時,第一件事不是修改業務狀態,而是判斷當前狀態下是否可以進行該操作。
比如,支付成功的核心業務:
public void paySuccess(PayByIdSuccessCommand paySuccessCommand){
if (getStatus() != OrderStatus.CREATED){
throw new OrderStatusNotMatch();
}
this.setStatus(OrderStatus.PAID);
PayRecord payRecord = PayRecord.create(paySuccessCommand.getChanel(), paySuccessCommand.getPrice());
this.payRecords.add(payRecord);
OrderPaySuccessEvent event = new OrderPaySuccessEvent(this);
this.events.add(event);
}
在進入邏輯處理前,先對狀態進行判斷,只有 “已創建” 才能接收 支付成功操作,并將狀態轉換為 “已支付”。
4.4. 固定規則校驗
固定規則校驗使用場景不多,但其威力巨大,可以從根源上解決邏輯錯誤。
在訂單實體上存在大量的金額操作,比如:
- 優惠券。用戶使用優惠券后,用戶支付金額需減去優惠金額,同時優惠金額也會均攤到不同的訂單項上;
- 優惠活動。和優惠券對訂單的影響基本一致,但場景會更加復雜;
- 優惠疊加。優惠券和優惠活動一起使用,共同對訂單進行修改;
- 手工改價。商家與用戶協商一致后,商家可以在后臺對訂單的金額進行修改;
訂單金額發生變化后,更新字段很多,但無論如何變化都需要滿足一個公式:支付金額 = 售賣金額總和 - 優惠金額總和。
我們可以基于這個公式,在業務操作之后、數據庫更新之前對規則進行校驗,一旦規則不滿足則說明處理邏輯出現問題,直接拋出異常中斷處理流程。
4.4.1. JPA 支持
JPA 支持在數據保存或更新前對業務方法進行回調。
我們可以使用 回調注解 或 實體監聽器 完成業務回調。
@PreUpdate
@PrePersist
public void checkBizRule(){
// 進行業務校驗
}
checkBizRule 方法上增加 @PreUpdate 和 @PrePersist,在保存數據庫或更新數據庫之前,框架自動對 chekBizRule 方法進行回調,當方法拋出異常,處理流程被強制中斷。
也可以使用 實體監聽器 進行處理,如下例所示:
// 首先,定義一個 OrderListenrer
public class OrderListener {
@PrePersist
public void preCreate(Order order) {
order.checkBiz();
}
@PostPersist
public void postCreate(Order order) {
order.checkBiz();
}
}
// 在 Order 實體上添加相關配置
@Data
@Entity
@Table
@Setter(AccessLevel.PRIVATE)
// 配置 OrderListener
@EntityListeners(OrderListener.class)
public class Order implements AggRoot<Long> {
// 省略部分非關鍵代碼
public void checkBizRule(){
// 進行業務校驗
}
}
4.4.2. MyBatis 支持
MyBatis 對實體的生命周期支持并沒有 JPA 中那么強大,但通過 Intercepter 仍舊能實現該功能。具體操作如下:
首先,自定義 Intercepter,判斷參數并調用規則校驗方法:
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MAppedStatement.class, Object.class})
})
public class EntityInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement statement = (MappedStatement) args[0];
Object parameter = args[1];
// 在這里可以對參數進行判斷,并執行相應的操作
if (parameter instanceof Order) {
Order order = (Order) parameter;
order.checkBizRule();
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以在這里設置一些配置參數
}
}
然后,在 mybatis-config.xml 配置文件中增加 Intercepter 的配置,具體如下:
<configuration>
<!-- 其他配置 -->
<plugins>
<plugin interceptor="com.example.EntityInterceptor"/>
</plugins>
</configuration>
4.4.3. 業務框架擴展
Lego 框架對標準 Command 處理流程進行封裝,流程中對固定規則校驗進行了支持。如下圖所示:
在標準寫流程中的固定規則校驗階段會自動調用 ValidateService 中的 validateRule,整體結構和 業務校驗基本一致,在這里就不在贅述。其中:
- 存在一個默認實現 AggBasedRuleValidator,可以通過重寫聚合根上的 validate 方法來實現 JPA 和 MyBatis 同樣的效果;
- 也可以自定義自己的 RuleValidator,將實現類注入到 Spring 容器即可完成與業務流程的集成;
4.5. 存儲引擎校驗
存儲引擎提供了非常豐富的數據校驗,比如 Not Null,Length、Unique 等;
一般情況下,在流程達到存儲引擎前,所有的驗證規則必須全部通過,盡量不要使用存儲引擎作為兜底方案。但有一種情況極為特殊,也就只有存儲引擎能夠優雅的完成,那就是唯一鍵保護。
比如,在需要冪等保護時,我們通常將冪等鍵設置為唯一索引,從而保證不會出現重復提交的情況。
5.校驗小結
為了保證臟數據(不符合業務預期的數據)不會進入到系統,我們將“防御式編程”思想用到了極致,在一個標準的寫流程中共設立了5項關卡,從多維度多視角對數據進行保障:
- 參數校驗。不要相信任何的輸入信息,需要對系統輸入進行嚴格校驗;
- 業務校驗。業務操作往往會依賴一些前置條件,這些前置條件累加在一起甚至比核心操作還要復雜,如何提供相互隔離且可擴展的設計便成了這個階段的核心;
- 狀態校驗。對狀態機進行保護,任何操作都存在前置狀態,在非法狀態下不允許執行,這就是這個階段要解決的問題;
- 固定規則校驗。當上層業務變化多且復雜時,可能會對某些固定規則造成破壞,所以,在業務操作完成后、數據操作之前,可以再次對固定規則進行驗證;
- 存儲引擎校驗。校驗優先在Java 代碼中完成,不要將存儲引擎校驗作為常態校驗。但,在唯一性保障方面,存儲引擎是最簡單最有效的策略;
5大關卡共同發力才能真正保障業務數據的有效性。