一、背景
資金平臺概述
為了監控集團各業務線的資金來源和去向,資金部需每天分析所有賬戶出金和入金情況。為此,我們提供了資金管理平臺,該平臺擁有賬戶收支流水和賬單拉取等功能,以及現金流打標能力,為資金部提供更加精準的現金流分析。
需求場景
資金管理平臺作為發起方,以賬戶維度請求支付系統下載渠道賬單(不同渠道傳參不同),解析流水落庫後做現金流打標。
系統互動簡圖
丟擲問題
上述需求中資金平臺請求支付系統下載賬單功能這一點,考慮到不同渠道的賬戶,請求傳參不同,該場景如何做功能設計?
實現方案
方案 1(簡寫):無腦堆 if else
缺點:每新增一個渠道,都要在原有程式碼基礎上新增引數處理邏輯,導致程式碼臃腫,難以維護,難以支援系統的持續演進和擴充套件。違反開閉原則,修改會對原有功能產生影響,增加了引入錯誤的風險。
/**
* 資金系統請求支付系統下載渠道賬單
*
* @param instCode 渠道名
* @param instAccountNo 賬戶
* @return 同步結果
*/
public String applyFileBill(String instCode, String instAccountNo) {
// 不同渠道入參組裝
FileBillReqDTO channelReq = new FileBillReqDTO();
if ("支付寶".equals(instCode)) {
channelReq.setBusinessCode("ALIPAY_" + instAccountNo + "_BUSINESS");
channelReq.setPayTool(4);
channelReq.setTransType(50);
} else if ("微信".equals(instCode)) {
channelReq.setBusinessCode("WX_" + instAccountNo);
channelReq.setPayTool(3);
channelReq.setTransType(13);
} else if ("通聯".equals(instCode)) {
channelReq.setBusinessCode("TL_" + instAccountNo);
channelReq.setPayTool(5);
channelReq.setTransType(13);
}
// ... 可以繼續新增其他渠道的處理邏輯
// 請求支付系統拉取賬單檔案,同步返回處理中,非同步MQ通知下載結果
BaseResult<FileBillResDTO> result = cnRegionDataFetcher.applyFileBill(channelReq, "資金賬單下載");
return "處理中";
}
方案 2:策略模式最佳化
優點:符合開閉原則,新增渠道接入時,只需建立新的具體策略實現類並實現介面即可,無需修改原有程式碼,系統靈活性和可擴充套件性較好。
缺點:每接入一個新渠道,還是存在程式碼開發和部署的工作量,且隨著渠道接入數量的增加,策略類數量增多,程式碼維護成本變高。
// 定義策略介面
public interface IChannelApplyFileStrategy {
/**
* 渠道匹配策略
*
* @param instCode 渠道名
* @return 是否匹配
*/
boolean match(String instCode);
/**
* 入參組裝
*
* @param instAccountNo 賬戶
* @return 請求支付入參
*/
FileBillReqDTO assembleReqData(String instAccountNo);
}
// 不同渠道具體策略類
@Component
public class AlipayChannelApplyFileStrategy implements IChannelApplyFileStrategy {
@Override
public boolean match(String instCode) {
return "支付寶".equals(instCode);
}
@Override
public FileBillReqDTO assembleReqData(String instAccountNo) {
FileBillReqDTO channelReq = new FileBillReqDTO();
channelReq.setBusinessCode("ALIPAY_" + instAccountNo + "_BUSINESS");
channelReq.setPayTool(4);
channelReq.setTransType(50);
return channelReq;
}
}
@Component
public class WechatChannelApplyFileStrategy implements IChannelApplyFileStrategy {
@Override
public boolean match(String instCode) {
return "微信".equals(instCode);
}
@Override
public FileBillReqDTO assembleReqData(String instAccountNo) {
FileBillReqDTO channelReq = new FileBillReqDTO();
channelReq.setBusinessCode("WX_" + instAccountNo);
channelReq.setPayTool(3);
channelReq.setTransType(13);
return channelReq;
}
}
@Component
public class TlbChannelApplyFileStrategy implements IChannelApplyFileStrategy {
@Override
public boolean match(String instCode) {
return "通聯".equals(instCode);
}
@Override
public FileBillReqDTO assembleReqData(String instAccountNo) {
FileBillReqDTO channelReq = new FileBillReqDTO();
channelReq.setBusinessCode("TL_" + instAccountNo);
channelReq.setPayTool(5);
channelReq.setTransType(13);
return channelReq;
}
}
// 呼叫類
@Component
public class ChannelApplyFileClient {
// IOC屬性自動注入策略實現類集合
@Resource
private List<IChannelApplyFileStrategy> iChannelApplyFileStrategies;
@Resource
private CNRegionDataFetcher cnRegionDataFetcher;
public String applyFileBill(String instCode, String instAccountNo) {
// 不同渠道入參組裝
IChannelApplyFileStrategy strategy = iChannelApplyFileStrategies.stream().filter(item -> item.match(instCode)).findFirst().orElse(null);
FileBillReqDTO channelReq = strategy.assembleReqData(instAccountNo);
// 請求支付系統拉取賬單檔案,同步返回處理中,非同步MQ通知下載結果
BaseResult<FileBillResDTO> result = cnRegionDataFetcher.applyFileBill(channelReq, "資金賬單下載");
return "處理中";
}
}
思考
上述兩種設計似乎對引數處理能力的抽象力度還不夠,是否能將其抽象為一個領域能力,以實現引數處理的動態化或可配置化,而不再依賴於硬編碼的引數處理邏輯。
基於這個設計思路,可以進行以下步驟:
- 定義領域模型:確定需要處理的領域物件和領域操作。在這個場景中,領域物件表示不同渠道,領域操作表示引數處理和介面呼叫。
- 建立配置表:設計一個配置表,用於儲存不同渠道和其對應的引數處理策略,該表可以包含渠道名稱和策略標識等欄位。
- 實現動態引數處理策略:根據配置表的資訊,在系統執行時動態載入和執行引數處理策略。可以使用 SpEL 表示式解析和反射的方式來實現。
- 配置關聯關係:透過配置表維護渠道和其對應引數處理策略的關聯關係。在新增渠道時,只需要在配置表中新增一條新的配置記錄,指明渠道名稱和對應的策略標識。
透過以上設計思路,可以實現一個可配置的領域能力,提高程式碼的可維護性和擴充套件性,同時降低了開發和部署的工作量。配置表的維護也提供了更大的靈活性,使得系統可以快速響應和適應不同渠道的變化和需求。
方案選用
為了實現不同渠道引數的動態化配置,我們引入了 Spring 表示式語言(SpEL)。透過使用 SpEL,我們可以將引數處理邏輯表達為字串表示式,並在執行時動態地解析和執行表示式,從而實現對不同渠道引數的處理。使用 SpEL 不僅提高了處理引數的靈活性和可配置性,還能更好地遵循物件導向設計原則和領域驅動設計思想,將引數處理視為一個具有獨立職責的領域模型。
二、引入SpEL
介紹
SpEL 即 Spring 表示式語言,是一種強大的表示式語言,可以在執行時評估表示式並生成值。SpEL 最常用於 Spring Framework 中的註解和 XML 配置檔案中的屬性,也可以以程式設計方式在 Java 應用程式中使用。
SpEL的應用場景
- 動態引數配置:可以透過 SpEL 將應用程式中的各種引數配置化,例如配置檔案中的資料庫連線資訊、業務規則等。透過動態配置,可以在執行時根據不同的環境或需求來進行靈活的引數設定。
- 執行時注入:使用SpEL,可以在執行時動態注入屬性值,而不需要在編碼時硬編碼。這對於需要根據當前上下文動態調整屬性值的場景非常有用。
- 條件判斷與業務邏輯:SpEL支援複雜的條件判斷和邏輯計算,可以方便地在執行時根據條件來執行特定的程式碼邏輯。例如,在許可權控制中,可以使用SpEL進行資源和角色的動態授權判斷。
- 表示式模板化:SpEL支援在表示式中使用模板語法,允許將一些常用的表示式作為模板,然後在執行時透過填充不同的值來生成最終的表示式。這使得表示式的複用和動態生成更加方便。
總的來說,SpEL可以提供更大的靈活性和可配置性,使得應用程式的引數配置和邏輯處理更為動態和可擴充套件。它的強大表達能力和執行時求值特性可以在很多場景下發揮作用,簡化開發和維護工作。
簡單舉例
/**
* 驗證數字是否大於10
*
* @param number 數字
* @return 結果
*/
public String spELSample(int number) {
// 建立ExpressionParser物件,用於解析SpEL表示式
ExpressionParser parser = new SpelExpressionParser();
String expressionStr = "#number > 10 ? 'true' : 'false'";
Expression expression = parser.parseExpression(expressionStr);
// 建立EvaluationContext物件,用於設定引數值
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("number", number);
// 求解表示式,獲取結果
return expression.getValue(context, String.class);
}
處理過程分析
給定一個字串最終解析成一個值,這中間至少經歷:字串->語法分析->生成表示式物件->新增執行上下文->執行此表示式物件->返回結果。
關於 SpEL 的幾個概念:
- 表示式(“幹什麼”):SpEL 的核心,所以表示式語言都是圍繞表示式進行的。
- 解析器(“誰來幹”):用於將字串表示式解析為表示式物件。
- 上下文(“在哪幹”):表示式物件執行的環境,該環境可能定義變數、定義自定義函式、提供型別轉換等等。
- Root 根物件及活動上下文物件(“對誰幹”):Root 根物件是預設的活動上下文物件,活動上下文物件表示了當前表示式操作的物件。
處理流程:
- 表示式解析:首先,SpEL 對錶達式進行解析,將其轉換為內部表示形式即抽象語法樹(AST)或者其他形式的中間表示。
- 上下文設定:在表示式求值之前,需要設定上下文資訊。上下文可以是一個物件,它包含了表示式中要引用的變數和方法。透過將上下文物件傳遞給表示式求值引擎,表示式可以訪問並操作上下文中的資料。
- 表示式求值:一旦表示式被解析和上下文設定完成,SpEL 開始求值表示式。求值過程遵循 AST 的結構,從根節點開始,逐級向下遍歷並對每個節點進行求值。求值過程可能涉及遞迴操作,直到所有節點都被求值。
結果返回:表示式求值的結果作為最終結果返回給呼叫者。返回結果可以是任何型別,包括基本型別、物件、集合等。
三、SpEL應用實戰
配置表設計
維護渠道和其對應引數處理策略的關聯關係:
渠道表
渠道 API 表
說明: 每新增一個渠道接入時不需要進行程式碼開發,只需在配置表中維護關聯關係。根據 inst_code 匹配對應策略標識 channel_code,根據策略標識找到具體引數處理策略表示式。
實現動態引數處理策略
// 定義解析工具類
@Slf4j
@Service
@CacheConfig(cacheNames = CacheNames.EXPRESSION)
public class ExpressionUtil {
private final ExpressionParser expressionParser = new SpelExpressionParser();
// 建立上下文物件,設定自定義變數、自定義函式
public StandardEvaluationContext createContext(String instAccountNo){
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("instAccountNo", instAccountNo);
// 註冊自定義函式
this.registryFunction(context);
return context;
}
// 註冊自定義函式
private void registryFunction(StandardEvaluationContext context) {
try {
context.addPropertyAccessor(new MapAccessor());
context.registerFunction("yuanToCent", ExpressionHelper.class.getDeclaredMethod("yuanToCent", String.class));
context.registerFunction("substringBefore", StringUtils.class.getDeclaredMethod("substringBefore",String.class,String.class));
} catch (Exception e) {
log.info("SpEL函式註冊失敗:", e);
}
}
// 開啟快取,使用解析器解析表示式,返回表示式物件
@Cacheable(key="'getExpressionWithCache:'+#cacheKey", unless = "#result == null")
public Expression getExpressionWithCache(String cacheKey, String expressionString) {
try {
return expressionParser.parseExpression(expressionString);
} catch (Exception e) {
log.error("SpEL表示式解析異常,表示式:[{}]", expressionString, e);
throw new BizException(ReturnCode.EXCEPTION.getCode(),String.format("SpEL表示式解析異常:[%s]",expressionString),e);
}
}
}
// 定義解析類:
@Slf4j
@Service
public class ExpressionService {
@Resource
private ExpressionUtil expressionUtil;
public FileBillReqDTO transform(ChannelEntity channel, String instAccountNo) throws Exception {
// 獲取上下文物件(變數設定、函式設定)
StandardEvaluationContext context = expressionUtil.createContext(instAccountNo);
// 獲取支付請求類物件
FileBillReqDTO target = ClassHelper.newInstance(FileBillReqDTO.class);
// t_channel_api表配置的api對映表示式
for (ChannelApiEntity api : channel.getApis()) {
// 透過反射獲取FileBillReqDTO類屬性名物件
Field field = ReflectionUtils.findField(FileBillReqDTO.class, api.getFieldCode());
// 表示式
String expressionString = api.getFieldExpression();
// 開啟快取,使用解析器解析表示式,返回表示式物件
Expression expression = expressionUtil.getExpressionWithCache(api.fieldExpressionKey(), expressionString);
// 透過表示式物件獲取解析後的結果值
Object value = expression.getValue(context, FileBillReqDTO.class);
// 將結果透過反射賦值給FileBillReqDTO物件中指定屬性欄位
field.setAccessible(true);
field.set(target, value);
}
// 返回解析賦值後的完整物件
return target;
}
}
// 呼叫類
@Component
public class ChannelApplyFileClient {
@Resource
private CNRegionDataFetcher cnRegionDataFetcher;
@Resource
private ExpressionService expressionService;
@Resource
private ChannelRepository channelRepository;
public String applyFileBill(String instCode, String instAccountNo) {
// 根據渠道碼查詢t_channel、t_channel_api表,返回ChannelEntity物件
ChannelEntity channel = channelRepository.findByInstCode(instCode);
// 透過SpEL解析t_channel_api表中表示式,並將值賦值給對應屬性中,返回完整請求物件
FileBillReqDTO channelReq = expressionService.transform(channel, instAccountNo);
// 請求支付系統拉取賬單檔案,同步返回處理中,非同步MQ通知下載結果
BaseResult<FileBillResDTO> result = cnRegionDataFetcher.applyFileBill(channelReq, "資金賬單下載");
return "處理中";
}
}
優點:透過領域能力抽象和 SpEL 的運用,實現引數處理的動態化或可配置化,不再依賴於硬編碼的引數處理邏輯,提高程式碼的可維護性和擴充套件性,同時降低了開發和部署的工作量,更好地遵循物件導向設計原則和領域驅動設計思想,成為一個具有獨立職責的領域模型。
四、擴充套件-其他應用-Excel解析
需求
資金平臺需從不同的渠道下載賬單,並對賬單進行解析,解析後的資料落入流水錶。注意不同渠道的賬單的頭欄位和格式存在差異。
方案
傳統的方式中,解析 Excel 通常需要透過建立實體類來對映 Excel 的結構和資料。每個實體類代表一個 Excel 行或列,需要手動編寫程式碼來將 Excel 資料解析為相應的實體物件。
而使用 SpEL 方式解析 Excel 則具有更加動態和靈活的特性,避免了顯式建立和維護大量的實體類。以下是使用 SpEL 方式動態解析 Excel 的一般步驟:
- 使用 Apache POI 等工具讀取 Excel 資料表。
- 根據配置表,將 Excel 中的列與 SpEL 表示式進行關聯。
- 使用 SpEL 解析器,在執行時解析這些 SpEL 表示式。
- 將解析後的結果做資料清洗後落表,應用於現金流打標業務。
配置表中維護的關聯關係:(表示式中 #source.column 變數表示列與 Excel Sample 列相對應)
Excel Sample:
五、總結
總的來說,SpEL 表示式語言具備動態性、靈活性、可擴充套件性等優點。結合具體業務需求和系統設計,其可應用於很多系統場景:
- Excel 解析:SpEL 可以用於解析 Excel 表格中的資料。可以使用 SpEL 表示式來指定需要解析的單元格、行、列等等,提取資料並應用相應的邏輯。這使得解析過程更加靈活和可擴充套件。
- 規則引擎:在使用規則引擎時,SpEL 可以用於定義規則條件和執行動作。透過 SpEL 表示式,可以動態地根據特定的條件對資料進行處理和決策。這使得規則引擎可以根據實際情況在執行時進行靈活的判斷和決策。
- 模板引擎:SpEL 可以用於填充模板資料。透過 SpEL 表示式,可以在模板中引用物件的屬性、方法或函式。這使得模板引擎可以根據物件的屬性動態地生成內容。
- 配置檔案解析:SpEL 可以用於解析配置檔案中的動態值。透過 SpEL 表示式,可以在配置檔案中引用其他屬性或方法的值。這使得配置檔案具備動態性,可以根據實際情況進行動態的配置和調整。
- 驗證規則:在資料驗證的場景中,SpEL 可以用於定義驗證規則。透過 SpEL 表示式,可以對資料進行復雜的驗證和處理。這使得驗證過程更加靈活和可配置。
*文/金橙五
本文屬得物技術原創,更多精彩文章請看:得物技術官網
未經得物技術許可嚴禁轉載,否則依法追究法律責任!