SpEL應用實戰

發表於2024-02-21

一、背景

資金平臺概述

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

需求場景

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

系統互動簡圖

圖片

丟擲問題

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

實現方案

方案 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 表示式,可以對資料進行復雜的驗證和處理。這使得驗證過程更加靈活和可配置。

*文/金橙五

本文屬得物技術原創,更多精彩文章請看:得物技術官網

未經得物技術許可嚴禁轉載,否則依法追究法律責任!

相關文章