SpEL應用實戰|得物技術

架構師修行手冊發表於2024-01-26


來源:得物技術

目錄

一、背景

    1. 資金平臺概述

    2. 需求場景

    3. 系統互動簡圖

    4. 丟擲問題

    5. 實現方案

    6. 思考

    7. 方案選用

二、引入SpEL

    1. 介紹

    2. SpEL的應用場景

    3. 簡單舉例

    4. 處理過程分析

三、SpEL應用實戰

    1. 配置表設計

    2. 實現動態引數處理策略

四、擴充套件-其他應用-Excel解析

    1. 需求

    2. 方案

五、總結

背景

資金平臺概述

為了監控集團各業務線的資金來源和去向,資金部需每天分析所有賬戶出金和入金情況。為此,我們提供了資金管理平臺,該平臺擁有賬戶收支流水和賬單拉取等功能,以及現金流打標能力,為資金部提供更加精準的現金流分析。

需求場景

資金管理平臺作為發起方,以賬戶維度請求支付系統下載渠道賬單(不同渠道傳參不同),解析流水落庫後做現金流打標。

系統互動簡圖

SpEL應用實戰|得物技術

丟擲問題

上述需求中資金平臺請求支付系統下載賬單功能這一點,考慮到不同渠道的賬戶,請求傳參不同,該場景如何做功能設計?

實現方案

  • 方案 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);}

// 不同渠道具體策略類@Componentpublic 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;    }}
@Componentpublic 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;    }}
@Componentpublic 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;    }}

// 呼叫類@Componentpublic 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應用實戰|得物技術

      SpEL應用實戰

      配置表設計

      維護渠道和其對應引數處理策略的關聯關係:

      • 渠道

      SpEL應用實戰|得物技術

      • 渠道 API 

      SpEL應用實戰|得物技術

      • 說明:每新增一個渠道接入時不需要進行程式碼開發,只需在配置表中維護關聯關係。根據 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@Servicepublic 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;    }}

      // 呼叫類@Componentpublic 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 列相對應)

        SpEL應用實戰|得物技術

        Excel Sample:

        SpEL應用實戰|得物技術

        總結

        總的來說,SpEL 表示式語言具備動態性、靈活性、可擴充套件性等優點。結合具體業務需求和系統設計,其可應用於很多系統場景:

        • Excel 解析:SpEL 可以用於解析 Excel 表格中的資料。可以使用 SpEL 表示式來指定需要解析的單元格、行、列等等,提取資料並應用相應的邏輯。這使得解析過程更加靈活和可擴充套件。

        • 規則引擎:在使用規則引擎時,SpEL 可以用於定義規則條件和執行動作。透過 SpEL 表示式,可以動態地根據特定的條件對資料進行處理和決策。這使得規則引擎可以根據實際情況在執行時進行靈活的判斷和決策。

        • 模板引擎:SpEL 可以用於填充模板資料。透過 SpEL 表示式,可以在模板中引用物件的屬性、方法或函式。這使得模板引擎可以根據物件的屬性動態地生成內容。

        • 配置檔案解析:SpEL 可以用於解析配置檔案中的動態值。透過 SpEL 表示式,可以在配置檔案中引用其他屬性或方法的值。這使得配置檔案具備動態性,可以根據實際情況進行動態的配置和調整。

        • 驗證規則:在資料驗證的場景中,SpEL 可以用於定義驗證規則。透過 SpEL 表示式,可以對資料進行復雜的驗證和處理。這使得驗證過程更加靈活和可配置。

        來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3005244/,如需轉載,請註明出處,否則將追究法律責任。

        相關文章