一般情況下,在流程達到存儲引擎前,所有的驗證規(guī)則必須全部通過,盡量不要使用存儲引擎作為兜底方案。但有一種情況極為特殊,也就只有存儲引擎能夠優(yōu)雅的完成,那就是唯一鍵保護。
1. 規(guī)則驗證是準確性的基礎(chǔ)
規(guī)則驗證是業(yè)務(wù)穩(wěn)定性的重要保障手段,通過規(guī)則驗證,可以驗證和確保系統(tǒng)或業(yè)務(wù)邏輯的正確性和合規(guī)性,避免潛在的錯誤和問題。而規(guī)則的遺漏往往會伴隨著線上bug的出現(xiàn)。
相信每個開發(fā)人員都曾面對過以下情況:
- 未對入?yún)⑦M行非空判斷,在執(zhí)行邏輯時導(dǎo)致空指針異常(NullPointerException,簡稱NPE);
- 未正確驗證用戶權(quán)限,導(dǎo)致未授權(quán)操作發(fā)生,普通用戶也能執(zhí)行該操作,最終產(chǎn)生安全問題;
- 在數(shù)據(jù)被存儲到數(shù)據(jù)庫時,沒有進行完整性驗證,導(dǎo)致無效數(shù)據(jù)被存儲;
- 在業(yè)務(wù)邏輯中,未對可能拋出的異常進行適當?shù)奶幚恚瑢?dǎo)致系統(tǒng)無法正常運行;
- …
可見,驗證對流程極為重要,不合理的輸入會導(dǎo)致嚴重的業(yè)務(wù)問題。同時錯誤數(shù)據(jù)的影響也比想象中的大得多:
- 可能會導(dǎo)致整個寫流程異常中斷;
- 錯誤數(shù)據(jù)入庫后,會對所有的讀操作造成致命的傷害;
- 上游系統(tǒng)數(shù)據(jù)錯誤,下游系統(tǒng)紛紛“崩潰”;
2. 防御式編程
如何避免上述情況的發(fā)生,答案就在 防御式編程。
防御式編程(Defensive Programming)是一種軟件開發(fā)方法,目的是在代碼中預(yù)測可能出現(xiàn)的異常和錯誤情況,并用適當?shù)拇胧ζ溥M行處理,從而提高軟件的健壯性和穩(wěn)定性。通過防御式編程,軟件開發(fā)人員可以在軟件功能相對復(fù)雜的情況下,避免和減少由于程序錯誤所導(dǎo)致的不可預(yù)測的行為和不良影響,并保障軟件在部署和運行時的正確性和穩(wěn)定性,提高軟件可靠性和安全性。
防御式編程的核心思想是在代碼中盡量考慮一切可能出現(xiàn)的異常和錯誤情況,并在代碼中針對這些異常和錯誤情況做出相應(yīng)的處理。例如,可以使用異常捕獲機制處理可能出現(xiàn)的異常,充分利用代碼注釋和約束條件來規(guī)范輸入數(shù)據(jù),使用斷言(assert)來檢查代碼中的前置條件和后置條件等。
概念過于繁瑣,簡單理解:防御式編程就是:
- 不要相信任何輸入,在正式使用前,必須保證參數(shù)的有效性;
- 不相信任何處理邏輯,在流程處理后,必須保證業(yè)務(wù)規(guī)則仍舊有效;
對輸入?yún)?shù)保持懷疑,對業(yè)務(wù)執(zhí)行的前提條件保存懷疑,對業(yè)務(wù)執(zhí)行結(jié)果保持懷疑,將極大的提升系統(tǒng)的準確性!
3. 異常中斷還是返回值中斷?
在規(guī)則校驗場景,優(yōu)先使用異常進行流程中斷。
3.1. 異常中斷才是標配
在沒有提供異常的編程語言中,我們只能使用特殊返回值來表示異常,這樣的設(shè)計會將正常流程和異常處理流程混在一起,讓語言失去了可讀性。比如在 C 中,通用會使用 -1 或 NULL 來表示異常情況,所以在調(diào)用函數(shù)第一件事便是判斷 result 是 NULL 或 -1,比如以下代碼:
void readFileAndPrintContent(const char* filename) {
FILE* file = fopen(filename, "r");
if (file == NULL) {
// 文件無法打開,返回異常狀態(tài)
fprintf(stderr, "FAIled to open the file.n");
return; // 直接返回,表示發(fā)生異常
}
char line[256];
while (fgets(line, sizeof(line), file) != NULL) {
printf("%s", line);
}
fclose(file);
}
在 JAVA 語言中,引入了完整的異常機制,以更好的處理異常情況,該機制有如下特點:
- 邏輯分離,將正常處理和異常處理進行分離。異常機制可以將錯誤處理代碼從正常業(yè)務(wù)邏輯中分離出來,使得代碼更加清晰和易讀。同時,可以將異常處理代碼集中在一起,便于理解和維護;
- 異常傳播和捕獲。當異常在方法中被拋出時,可以選擇在當前方法中捕獲并處理異常,或者將異常繼續(xù)傳播給調(diào)用者,直到找到合適的異常處理器。這種靈活的異常傳播機制使得錯誤可以被適當?shù)靥幚恚粫斐沙绦虻闹袛啵?/li>
- 異常信息傳遞。在異常對象中,Java 提供了豐富的信息用于描述異常的發(fā)生原因和上下文。包括異常類別、異常消息、異常發(fā)生的位置等。這些信息可以幫助開發(fā)人員快速定位和修復(fù)異常,提高代碼的調(diào)試和維護效率;
在 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 表示發(fā)生異常
}
}
}
在日常業(yè)務(wù)開發(fā)中,當出現(xiàn)不符合業(yè)務(wù)預(yù)期時,直接通過異常對流程進行中斷即可。
3.2. 立即中斷還是階段中斷?
當出現(xiàn)不符合預(yù)期情況時,是直接拋出異常,還是完成整個階段在拋出異常?這個需要看業(yè)務(wù)場景!
參數(shù)驗證場景,需要對所有不合法信息進行收集,然后統(tǒng)一拋出異常,從而能夠讓用戶一目了然的看到所有問題信息,以方便進行統(tǒng)一修改。
而在業(yè)務(wù)場景,不符合規(guī)則時,需要直接進行異常中斷,避免對后續(xù)流程造成破壞。
4. 標準寫流程中的規(guī)則驗證
使用 DDD 進行開發(fā)時,一個標準的寫流程包括:
圖片
其中,涉及5大類規(guī)則驗證,如:
- 參數(shù)校驗。對入?yún)⑦M行基本校驗,比如是否為null、類型是否匹配等;
- 業(yè)務(wù)校驗。特指對前置業(yè)務(wù)規(guī)則或資源的校驗,比如庫存是否足夠、商品狀態(tài)是否可售等;
- 狀態(tài)校驗。特指聚合內(nèi)狀態(tài)校驗,核心為業(yè)務(wù)狀態(tài)機,比如只有訂單狀態(tài)為待支付時,才能進行支付成功操作;
- 固定規(guī)則校驗。如果聚合內(nèi)有固定規(guī)則,在進行持久化操作前,需對規(guī)則進行驗證,比如 訂單支付金額 = 所有商品售賣價格總和 - 各類優(yōu)惠總和;
- 存儲引擎校驗。基于存儲引擎特性進行的最后保障,比如在表中創(chuàng)建唯一索引,從而避免用戶的多次提交(冪等保護);
4.1. 參數(shù)校驗
這是最基礎(chǔ)的校驗,沒有太多的業(yè)務(wù)概念,只有簡單的參數(shù)。其目的是 對數(shù)據(jù)格式進行驗證。
針對這種通用能力,優(yōu)先借助框架來完成,常用框架主要有:
- validation 框架。主要用于解決簡單屬性的驗證;
- Verifiable + AOP。主要用于多個屬性的驗證;
4.1.1. Validation 框架
對于單屬性的驗證,可以使用 hibernate validation 框架來實現(xiàn)。Hibernate Validation 是一個基于 Java Bean 驗證規(guī)范(Java Bean Validation)的驗證框架,它提供了一系列的特性來實現(xiàn)對數(shù)據(jù)模型的驗證和約束,其特性主要包括:
- 提供了一組驗證注解,用于在數(shù)據(jù)模型的字段、方法參數(shù)、返回值等地方添加驗證約束。例如,
@NotNull
用于驗證字段不能為空,@Email
用于驗證郵箱格式,@Size
用于驗證字符串長度等; - 提供了許多內(nèi)置的驗證注解,用于實現(xiàn)常見的驗證需求。例如,
@NotBlank
用于驗證字符串非空且必須包含至少一個非空白字符,@Pattern
用于驗證字符串匹配指定的正則表達式,@Min
和@Max
用于驗證數(shù)字的最小值和最大值等; - 除了使用內(nèi)置的驗證注解外,Hibernate Validation 還允許開發(fā)者通過自定義約束注解來定義和應(yīng)用自定義的驗證規(guī)則。通過創(chuàng)建自定義的約束注解,可以實現(xiàn)更加靈活和符合業(yè)務(wù)需求的驗證規(guī)則;
- 允許將多個驗證約束分組,并在需要時對特定的驗證組進行驗證。這樣可以根據(jù)具體的場景選擇性地執(zhí)行驗證操作,從而實現(xiàn)更加細粒度的驗證控制;
- 提供了驗證器和驗證上下文等核心類,用于執(zhí)行驗證操作和獲取驗證結(jié)果。驗證器負責執(zhí)行驗證操作,而驗證上下文提供了豐富的方法來獲取驗證結(jié)果、獲取驗證錯誤信息等;
- 允許通過指定驗證注解的驗證順序,來控制驗證的執(zhí)行順序。這樣可以確保在需要按順序執(zhí)行驗證約束時,每個約束都會按照指定的順序進行驗證;
- 支持國際化,可以根據(jù)不同的語言環(huán)境為驗證錯誤消息提供多語言支持。開發(fā)者可以定義并配置驗證錯誤消息的資源文件,從而實現(xiàn)跨語言的驗證錯誤消息;
特性非常多,我們最常用的就是在模型字段、方法參數(shù)、返回值增加相應(yīng)功能的注解,比如在 CreateOrderCmd 中增加相關(guān)驗證注解,從而避免手寫代碼:
@Data
public class CreateOrderCmd {
@NotNull
private Long userId;
@NotNull
private Long productId;
@NotNull
@Min(1)
private int count;
}
4.1.2. Verifiable +AOP
有些參數(shù)驗證可能會比較復(fù)雜,需要對多個屬性進行判斷,此時 Validation 框架會顯得無能為力。
當然,可以制定相應(yīng)規(guī)范,在參數(shù)封裝的類上統(tǒng)一提供一個 validate 方法,并在進入方法后使用參數(shù)前調(diào)用該方法。但,規(guī)范由人執(zhí)行難免發(fā)生遺留。所以,最佳方案是將其內(nèi)化到框架。如下圖所示:
圖片
image
- 首先,定義一個接口 Verifiable,該接口只有一個 validate 方法;
- 其次,定義一個 ValidateIntercepter 類,基于前置攔截器對入?yún)⑦M行判斷,如果實現(xiàn) Verifiable 接口,則自動調(diào)用 validate 方法;
- 最后,基于 AOP 技術(shù)生成 Proxy,從而完成統(tǒng)一的參數(shù)校驗;
當需要對多個參數(shù)進行校驗時,只需要實現(xiàn) Verifiable 接口的 validate 方法即可,無需手工對 validate 進行調(diào)用。
4.2. 業(yè)務(wù)校驗
業(yè)務(wù)校驗是業(yè)務(wù)邏輯執(zhí)行的前置條件驗證,包括外部校驗和控制條件校驗。
通常情況下,業(yè)務(wù)校驗比較復(fù)雜,變化頻次也比較高,所以對擴展性要求很高。但,業(yè)務(wù)規(guī)則本身比較獨立,相互之間沒有太多的依賴關(guān)系。為了更好的應(yīng)對邏輯擴展,可以使用策略模型進行設(shè)計。如下圖所示:
圖片
4.2.1. 業(yè)務(wù)驗證器
業(yè)務(wù)驗證器就是策略模型中的策略接口。
核心代碼如下:
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);
}));
}
}
該接口非常簡單:
- 提供統(tǒng)一的 validate 方法定義;
- 繼承自 SmartComponent,可以通過 boolean support(A a) 來驗證該組件是否能被處理;
4.2.2. 共享數(shù)據(jù) Context
有了統(tǒng)一的策略接口后,需要使用 Context 模式對入?yún)⑦M行管理。Context 可以是簡單的數(shù)據(jù)容器,也可以是一個具有 LazyLoad 能力的加強容器,其核心功能就是在多個策略間實現(xiàn)數(shù)據(jù)的共享。
比如,在生單流程中的 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 方法時,將自動觸發(fā)數(shù)據(jù)加載,并將加載的數(shù)據(jù)設(shè)置到屬性上,再第二次訪問時,直接從屬性上獲取所需數(shù)據(jù)。
【注】對該部分感興趣,可以學(xué)習 《Command&Query Object 與 Context 模式》
4.2.3. 策略類管理
在有了策略接口 和 共享數(shù)據(jù) Context 后,接下來便是按照業(yè)務(wù)需求實現(xiàn)高內(nèi)聚低耦合的各種實現(xiàn)類。如下圖所示:
圖片
這些組件如何進行管理,詳見下圖:
圖片
- 首先,在啟動時,所有的 BusinessValidator 實例會被 Spring 注入到 ValidateService 中的 validators 集合;
- 在調(diào)用 validateBusiness 函數(shù)時,依次遍歷 validators 集合,找到能處理該 Context 的 validator 實例并執(zhí)行對應(yīng)的 validate 方法;
這樣做最大的好處便是,在驗證組件中徹底實現(xiàn)“開閉原則”:
- 增加新的業(yè)務(wù)邏輯時,只需增加一個新的 Spring 組件,系統(tǒng)將自動完成集成;
- 在修改某個業(yè)務(wù)校驗時,只會更改一個類,對其他校驗沒有影響;
認真思考后,可能會發(fā)現(xiàn):這其實是責任鏈模式的一種變形。但,由于實現(xiàn)非常簡單,在 Spring 框架中多次使用。
4.3. 狀態(tài)校驗
狀態(tài)校驗又成前置狀態(tài)驗證,是業(yè)務(wù)規(guī)則中最重要的一部分。
核心實體通常會有一個狀態(tài)屬性,狀態(tài)屬性的這些值共同組成一個標準的狀態(tài)機。如下圖所示:
圖片
這是一個訂單實體的狀態(tài)機,定義了各狀態(tài)間的轉(zhuǎn)換關(guān)系,這是領(lǐng)域設(shè)計中最為重要的一部分。當發(fā)生業(yè)務(wù)動作時,第一件事不是修改業(yè)務(wù)狀態(tài),而是判斷當前狀態(tài)下是否可以進行該操作。
比如,支付成功的核心業(yè)務(wù):
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);
}
在進入邏輯處理前,先對狀態(tài)進行判斷,只有 “已創(chuàng)建” 才能接收 支付成功操作,并將狀態(tài)轉(zhuǎn)換為 “已支付”。
4.4. 固定規(guī)則校驗
固定規(guī)則校驗使用場景不多,但其威力巨大,可以從根源上解決邏輯錯誤。
在訂單實體上存在大量的金額操作,比如:
- 優(yōu)惠券。用戶使用優(yōu)惠券后,用戶支付金額需減去優(yōu)惠金額,同時優(yōu)惠金額也會均攤到不同的訂單項上;
- 優(yōu)惠活動。和優(yōu)惠券對訂單的影響基本一致,但場景會更加復(fù)雜;
- 優(yōu)惠疊加。優(yōu)惠券和優(yōu)惠活動一起使用,共同對訂單進行修改;
- 手工改價。商家與用戶協(xié)商一致后,商家可以在后臺對訂單的金額進行修改;
訂單金額發(fā)生變化后,更新字段很多,但無論如何變化都需要滿足一個公式:支付金額 = 售賣金額總和 - 優(yōu)惠金額總和。
我們可以基于這個公式,在業(yè)務(wù)操作之后、數(shù)據(jù)庫更新之前對規(guī)則進行校驗,一旦規(guī)則不滿足則說明處理邏輯出現(xiàn)問題,直接拋出異常中斷處理流程。
4.4.1. JPA 支持
JPA 支持在數(shù)據(jù)保存或更新前對業(yè)務(wù)方法進行回調(diào)。
我們可以使用 回調(diào)注解 或 實體監(jiān)聽器 完成業(yè)務(wù)回調(diào)。
@PreUpdate
@PrePersist
public void checkBizRule(){
// 進行業(yè)務(wù)校驗
}
checkBizRule 方法上增加 @PreUpdate 和 @PrePersist,在保存數(shù)據(jù)庫或更新數(shù)據(jù)庫之前,框架自動對 chekBizRule 方法進行回調(diào),當方法拋出異常,處理流程被強制中斷。
也可以使用 實體監(jiān)聽器 進行處理,如下例所示:
// 首先,定義一個 OrderListenrer
public class OrderListener {
@PrePersist
public void preCreate(Order order) {
order.checkBiz();
}
@PostPersist
public void postCreate(Order order) {
order.checkBiz();
}
}
// 在 Order 實體上添加相關(guān)配置
@Data
@Entity
@Table
@Setter(AccessLevel.PRIVATE)
// 配置 OrderListener
@EntityListeners(OrderListener.class)
public class Order implements AggRoot<Long> {
// 省略部分非關(guān)鍵代碼
public void checkBizRule(){
// 進行業(yè)務(wù)校驗
}
}
4.4.2. MyBatis 支持
MyBatis 對實體的生命周期支持并沒有 JPA 中那么強大,但通過 Intercepter 仍舊能實現(xiàn)該功能。具體操作如下:
首先,自定義 Intercepter,判斷參數(shù)并調(diào)用規(guī)則校驗方法:
@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];
// 在這里可以對參數(shù)進行判斷,并執(zhí)行相應(yīng)的操作
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) {
// 可以在這里設(shè)置一些配置參數(shù)
}
}
然后,在 mybatis-config.xml 配置文件中增加 Intercepter 的配置,具體如下:
<configuration>
<!-- 其他配置 -->
<plugins>
<plugin interceptor="com.example.EntityInterceptor"/>
</plugins>
</configuration>
4.4.3. 業(yè)務(wù)框架擴展
Lego 框架對標準 Command 處理流程進行封裝,流程中對固定規(guī)則校驗進行了支持。如下圖所示:
圖片
image
在標準寫流程中的固定規(guī)則校驗階段會自動調(diào)用 ValidateService 中的 validateRule,整體結(jié)構(gòu)和 業(yè)務(wù)校驗基本一致,在這里就不在贅述。其中:
- 存在一個默認實現(xiàn) AggBasedRuleValidator,可以通過重寫聚合根上的 validate 方法來實現(xiàn) JPA 和 MyBatis 同樣的效果;
- 也可以自定義自己的 RuleValidator,將實現(xiàn)類注入到 Spring 容器即可完成與業(yè)務(wù)流程的集成;
4.5. 存儲引擎校驗
存儲引擎提供了非常豐富的數(shù)據(jù)校驗,比如 Not Null,Length、Unique 等;
一般情況下,在流程達到存儲引擎前,所有的驗證規(guī)則必須全部通過,盡量不要使用存儲引擎作為兜底方案。但有一種情況極為特殊,也就只有存儲引擎能夠優(yōu)雅的完成,那就是唯一鍵保護。
比如,在需要冪等保護時,我們通常將冪等鍵設(shè)置為唯一索引,從而保證不會出現(xiàn)重復(fù)提交的情況。
5. 小結(jié)
為了保證臟數(shù)據(jù)(不符合業(yè)務(wù)預(yù)期的數(shù)據(jù))不會進入到系統(tǒng),我們將“防御式編程”思想用到了極致,在一個標準的寫流程中共設(shè)立了5項關(guān)卡,從多維度多視角對數(shù)據(jù)進行保障:
- 參數(shù)校驗。不要相信任何的輸入信息,需要對系統(tǒng)輸入進行嚴格校驗;
- 業(yè)務(wù)校驗。業(yè)務(wù)操作往往會依賴一些前置條件,這些前置條件累加在一起甚至比核心操作還要復(fù)雜,如何提供相互隔離且可擴展的設(shè)計便成了這個階段的核心;
- 狀態(tài)校驗。對狀態(tài)機進行保護,任何操作都存在前置狀態(tài),在非法狀態(tài)下不允許執(zhí)行,這就是這個階段要解決的問題;
- 固定規(guī)則校驗。當上層業(yè)務(wù)變化多且復(fù)雜時,可能會對某些固定規(guī)則造成破壞,所以,在業(yè)務(wù)操作完成后、數(shù)據(jù)操作之前,可以再次對固定規(guī)則進行驗證;
- 存儲引擎校驗。校驗優(yōu)先在Java 代碼中完成,不要將存儲引擎校驗作為常態(tài)校驗。但,在唯一性保障方面,存儲引擎是最簡單最有效的策略;
5大關(guān)卡共同發(fā)力才能真正保障業(yè)務(wù)數(shù)據(jù)的有效性。