淘寶創新業務的優化迭代是非常高頻且迅速的,在這過程中要求技術也必須是快且穩的,而為了適應這種快速變化的節奏,我們在項目開發過程中采用了一些面向拓展以及敏捷開發的設計,本文旨在總結并思考其中一些通用的編程模式。
前言
靜心守護業務是淘寶今年4月份啟動的創新項目,項目的核心邏輯是通過敲木魚、冥想、盤手串等療愈玩法為用戶帶來內心寧靜的同時推動文物的保護與修復,進一步弘揚我們的傳統文化。
作為創新項目,業務形態與產品方案的優化迭代是非常高頻且迅速的:項目從4月底投入開發到7月份最終外灰,整體方案經歷過大的推倒重建,也經歷過多輪小型重構優化,項目上線后也在做持續的迭代優化甚至改版升級。
模式清單
基于Spring容器與反射的策略模式
策略模式是一種經典的行為設計模式,它的本質是定義一系列算法, 并將每種算法分別放入獨立的類中, 以使算法的對象能夠相互替換,后續也能根據需要靈活拓展出新的算法。這里推薦的是一種基于Spring容器和反射結合的策略模式,這種模式的核心思路是:每個策略模式的實現都是一個bean,在Spring容器啟動時基于反射獲取每個策略場景的接口類型,并基于該接口類型再獲取此類型的所有策略實現bean并記錄到一個map(key為該策略bean的唯一標識符,value為bean對象)中,后續可以自定義路由策略來從該map中獲取bean對象并使用相應的策略。
模式解構
模式具體實現方式大致如下面的UML類圖所描述的:
其中涉及的各個組件及作用分別為:
Handler(interface):策略的頂層接口,定義的type方法表示策略唯一標識的獲取方式。
HandlerFactory(abstract class):策略工廠的抽象實現,封裝了反射獲取Spring bean并維護策略與其標識映射的邏輯,但不感知策略的真實類型。
AbstractHandler(interface or abstracr class):各個具體場景下的策略接口定義,該接口定義了具體場景下策略所需要完成的行為。如果各個具體策略實現有可復用的邏輯,可以結合模版方法模式在該接口內定義模版方法,如果模板方法依賴外部bean注入,則該接口的類型需要為abstract class,否則為interface即可。
HandlerImpl(class):各個場景下策略接口的具體實現,承載主要的業務邏輯,也可以根據需要橫向拓展。
HandlerFactoryImpl(class):策略工廠的具體實現,感知具體場景策略接口的類型,如果有定制的策略路由邏輯也可以在此實現。
這種模式的主要優點有:
策略標識維護自動化:策略實現與標識之間的映射關系完全委托給Spring容器進行維護(在HandlerFactory中封裝,每個場景的策略工廠直接繼承該類即可,無需重復實現),后續新增策略不用再手動修改關系映射。
場景維度維護標識映射:HandlerFactory中在掃描策略bean時是按照AbstractHandler的類型來分類維護的,從而避免了不同場景的同名策略發生沖突。
策略接口按場景靈活定義:具體場景的策略行為定義在AbstractHandler中,在這里可以根據真實的業務需求靈活定義行為,甚至也可以結合其他設計模式做進一步抽象處理,在提供靈活拓展的同時減少重復代碼。
實踐案例分析
該模式在靜心守護項目中的許多功能模塊都有使用,下面以稱號解鎖模塊為例來介紹其實際應用。
我們先簡單了解下該模塊的業務背景:靜心守護的成就體系中有一類是稱號,如下圖。用戶可以通過多種行為去解鎖不同類型的稱號,比如說通過參與主玩法(敲木魚、冥想、盤手串),主玩法參與達到一定次數后即可解鎖特定類型的稱號。當然后續也可能會有其他種類的稱號:比如簽到類(按照用戶簽到天數解鎖)、捐贈類(按照用戶捐贈項目的行為解鎖),所以對于稱號的解鎖操作應該是面向未來可持續拓展的。
基于這樣的思考,我選擇使用上面的策略模式去實現稱號解鎖模塊。該模塊的核心類圖組織如下:
下面是其中部分核心代碼的分析解讀:
public interface Handler如上文所說,Handler是策略的頂層抽象,它只定義了type方法,該方法用于獲取策略的標識,標識的類型支持子接口定義。{ /** * handler類型 * * @return */ T type(); }
@Slf4j public abstract class HandlerFactory> implements InitializingBean, ApplicationContextAware { private Map handlerMap; private ApplicationContext appContext; /** * 根據 type 獲得對應的handler * * @param type * @return */ public H getHandler(T type) { return handlerMap.get(type); } /** * 根據 type 獲得對應的handler,支持返回默認 * * @param type * @param defaultHandler * @return */ public H getHandlerOrDefault(T type, H defaultHandler) { return handlerMap.getOrDefault(type, defaultHandler); } /** * 反射獲取泛型參數handler類型 * * @return handler類型 */ @SuppressWarnings("unchecked") protected Class getHandlerType() { Type type = ((ParameterizedType)getClass().getGenericSuperclass()).getActualTypeArguments()[1]; //策略接口使用了范型參數 if (type instanceof ParameterizedTypeImpl) { return (Class ) ((ParameterizedTypeImpl)type).getRawType(); } else { return (Class ) type; } } @Override public void afterPropertiesSet() { // 獲取所有 H 類型的 handlers Collection handlers = appContext.getBeansOfType(getHandlerType()).values(); handlerMap = Maps.newHashMapWithExpectedSize(handlers.size()); for (final H handler : handlers) { log.info("HandlerFactory {}, {}", this.getClass().getCanonicalName(), handler.type()); handlerMap.put(handler.type(), handler); } log.info("handlerMap:{}", JSON.toJSONString(handlerMap)); } @Override public void setApplicationContext(@Nonnull ApplicationContext applicationContext) throws BeansException { this.appContext = applicationContext; } }
HandlerFactory在前面也提到過,是策略工廠的抽象實現,封裝了反射獲取具體場景策略接口類型,并查找策略bean在內存中維護策略與其標識的映射關系,后續可以直接通過標識或者對應的策略實現。這里有二個細節:
為什么HandlerFactory是abstract class?其實可以看到該類并沒有任何抽象方法,直接將其定義為class也不會有什么問題。這里將其定義為abstract class主要是起到實例創建的約束作用,因為我們對該類的定義是工廠的抽象實現,只希望針對具體場景來創建實例,針對該工廠本身創建實例其實是沒有任何實際意義的。
getHandlerType方法使用了@SuppressWarnings注解并標記了unchecked。這里也確實是存在潛在風險的,因為Type類型轉Class類型屬于向下類型轉換,是存在風險的,可能其實際類型并非Class而是其他類型,那么此處強轉就會出錯。這里處理了兩種最通用的情況:AbstractHandler是帶范型的class和最普通的class。
@Component public class TitleUnlockHandlerFactory extends HandlerFactoryTitleUnlockHandlerFactory是策略工廠的具體實現,由于不需要在此定制策略的路由邏輯,所以只聲明了相關的參數類型,而沒有對父類的方法做什么覆蓋。> {}
public abstract class BaseTitleUnlockHandlerimplements Handler { @Resource private UserTitleTairManager userTitleTairManager; @Resource private AchievementCountManager achievementCountManager; @Resource private UserUnreadAchievementTairManager userUnreadAchievementTairManager; ...... /** * 解鎖稱號 * * @param params * @return */ public @CheckForNull TitleUnlockResult unlockTitles(T params) { TitleUnlockResult titleUnlockResult = this.doUnlock(params); if (null == titleUnlockResult) { return null; } List titleAchievements = titleUnlockResult.getUnlockedTitles(); if (CollectionUtils.isEmpty(titleAchievements)) { titleUnlockResult.setUnlockedTitles(new ArrayList<>()); return titleUnlockResult; } //基于注入的bean和計算出的稱號列表進行后置操作,如:更新成就計數、更新用戶稱號緩存、更新用戶未讀成就等 ...... return titleUnlockResult; } /** * 計算出要解鎖的稱號 * * @param param * @return */ protected abstract TitleUnlockResult doUnlock(T param); @Override public abstract String type(); }
BaseTitleUnlockHandler定義了稱號解鎖行為,并且在此確定了策略標識的類型為String。此外,該類是一個abstract class,是因為該類定義了一個模版方法unlockTitles,在該方法里封裝了稱號解鎖所要進行的一些公共操作,比如更新用戶的稱號計數、用戶的稱號緩存數據等,這些都依賴于注入的一些外部bean,而interface不支持非靜態成員變量,所以該類通過abstract class來定義。具體的稱號解鎖行為通過doUnlock定義,這也是該策略的具體實現類需要實現的方法。
另外也許你還注意到了doUnlock方法的行參是一個范型參數T,因為我們考慮到了不同類型稱號解鎖所需要的參數可能是不同的,因此在場景抽象接口側只依賴于稱號解鎖的公共參數類型,而在策略接口具體實現側才與該類型策略的具體參數類型進行耦合。
@Component public class GameplayTitleUnlockHandler extends BaseTitleUnlockHandler{ @Resource private BlessTitleAchievementDiamondConfig blessTitleAchievementDiamondConfig; @Resource private UserTitleTairManager userTitleTairManager; @Override protected TitleUnlockResult doUnlock(GameplayTitleUnlockParams params) { //獲取稱號元數據 List titleMetadata = blessTitleAchievementDiamondConfig.getTitleMetadata(); if (CollectionUtils.isEmpty(titleMetadata)) { return null; } List titleAchievements = new ArrayList<>(); Result result = userTitleTairManager.queryRawCache(params.getUserId()); //用戶稱號數據查詢異常 if (null == result || !result.isSuccess()) { return null; } if (Objects.equals(result.getRc(), ResultCode.SUCCESS)) { //解鎖新稱號 titleAchievements = unlockNewTitles(params, titleMetadata); } else if (Objects.equals(result.getRc(), ResultCode.DATANOTEXSITS)) { //初始化歷史稱號 titleAchievements = initHistoricalTitles(params, titleMetadata); } TitleUnlockResult titleUnlockResult = new TitleUnlockResult(); titleUnlockResult.setUserTitleCache(result); titleUnlockResult.setUnlockedTitles(titleAchievements); return titleUnlockResult; } @Override public String type() { return TitleType.GAMEPLAY; } ...... }
上面是一個策略的具體實現類的大致示例,可以看到該實現類核心明確了以下信息:
策略標識:給出了type方法的具體實現,返回了一個策略標識的常量
策略處理邏輯:此處是玩法類稱號解鎖的業務邏輯,讀者無需關注其細節
稱號解鎖行參:給出了玩法類稱號解鎖所需的真實參數類型
抽象疲勞度管控體系
在我們的業務需求中經常會遇到涉及疲勞度管控相關的邏輯,比如每日簽到允許用戶每天完成1次、首頁項目進展彈窗要求對所有用戶只彈1次、首頁限時回訪任務入口則要對用戶每天都展示一次,但用戶累計完成3次后便不再展示......因此我們設計了一套疲勞度管控的模式,以降低后續諸如上述涉及疲勞度管控相關需求的開發成本。
自頂向下的視角
這套疲勞度管控體系的類層次大致如下圖: ? 接下來我們自頂向下逐層進行介紹:
FatigueLimiter(interface):FatigueLimiter是最頂層抽象的疲勞度管控接口,它定義了疲勞度管控相關的行為,比如:疲勞度的查詢、疲勞度清空、疲勞度增加、是否達到疲勞度限制的判斷等。
BaseFatigueLdbLimiter(abstract class):疲勞度數據的存儲方案可以是多種多樣的,在我們項目中主要利用ldb進行疲勞度存儲,而BaseFatigueLdbLimiter正是基于ldb【注:阿里內部自研的一款持久化k-v數據庫,讀者可將其理解為類似level db的項目】對疲勞度數據進行管控的抽象實現,它封裝了ldb相關的操作,并基于ldb的數據操作實現了FatigueLimiter的疲勞度管控方法。但它并不感知具體業務的身份和邏輯,因此定義了幾個業務相關的方法交給下層去實現,分別是:
scene:標識具體業務的場景,會利用該方法返回值去構造Ldb存儲的key
buildCustomKey:對Ldb存儲key的定制邏輯
getExpireSeconds:對應著Ldb存儲kv失效時間,對應著疲勞度的管控周期
Ldb周期性疲勞度管控的解決方案層(abstract class):在這一層提供了多種周期的開箱即用的疲勞度管控實現類,如BaseFatigueDailyLimiter提供的是天級別的疲勞度管控能力,BaseFatigueNoCycleLimiter則表示疲勞度永不過期,而BaseFatigueCycleLimiter則支持用戶實現cycle方法定制疲勞度周期。
業務場景層:這一層則是各個業務場景對疲勞度管控的具體實現,實現類只需要實現scene方法來聲明業務場景的身份標識,隨后繼承對應的解決方案,即可實現快速的疲勞度管控。比如上面的DailyWishSignLimiter就對應著本篇開頭我們所說的“每日簽到允許用戶每天完成1次”,這就要求為用戶的簽到行為以天維度構建key同時失效時間也為1天,因此直接繼承解決方案層的BaseFatigueDailyLimiter即可。其代碼實現非常簡單,如下:
@Component public class DailyWishSignLimiter extends BaseFatigueLdbDailyLimiter { @Override protected String scene() { return LimiterScene.dailyWish; } }
有一個“異類”
也許你注意到了上面的類層次圖中有一個“異類”——HomeEnterGuideLimiter。它其實就是我們在上文說的“首頁限時回訪任務入口則要對用戶每天都展示一次,但用戶累計完成3次后便不再展示”,它的邏輯其實也很簡單:因為它有2條管控條件,所以需要繼承2個管控周期的解決方案——天維度和永久維度,最后實際使用的類再聚合了天維度和永久維度的實現類(每個實現類對應ldb的一類key)并實現了頂層的疲勞度管控接口,標識這也是一個疲勞度管理器。它們的代碼如下:
/** * 首頁入口引導限時任務-天級疲勞度管控 * */ @Component public class HomeEnterGuideDailyLimiter extends BaseFatigueLdbDailyLimiter { @Override protected String scene() { return LimiterScene.homeEnterGuide; } } /** * 首頁入口引導限時任務-總次數疲勞度管控 * */ @Component public class HomeEnterGuideNoCycleLimiter extends BaseFatigueLdbNoCycleLimiter { @Override protected String scene() { return LimiterScene.homeEnterGuide; } @Override protected int maxSize() { return 3; } } /** * 首頁入口引導限時任務-疲勞度服務 * */ @Component public class HomeEnterGuideLimiter implements FatigueLimiter { @Resource private FatigueLimiter homeEnterGuideDailyLimiter; @Resource private FatigueLimiter homeEnterGuideNoCycleLimiter; @Override public boolean isLimit(String customKey) { return homeEnterGuideNoCycleLimiter.isLimit(customKey) || homeEnterGuideDailyLimiter.isLimit(customKey); } @Override public Integer incrLimit(String customKey) { homeEnterGuideDailyLimiter.incrLimit(customKey); return homeEnterGuideNoCycleLimiter.incrLimit(customKey); } @Override public boolean isLimit(Integer fatigue) { throw new UnsupportedOperationException(); } @Override public MapbatchQueryLimit(List keys) { throw new UnsupportedOperationException(); } @Override public void removeLimit(String customKey) { homeEnterGuideDailyLimiter.removeLimit(customKey); homeEnterGuideNoCycleLimiter.removeLimit(customKey); } @Override public Integer queryLimit(String customKey) { throw new UnsupportedOperationException(); } /** * 查詢首頁限時任務的每日疲勞度 * * @param customKey 用戶自定義key * @return 疲勞度計數 */ public Integer queryDailyLimit(String customKey) { return homeEnterGuideDailyLimiter.queryLimit(customKey); } /** * 查詢首頁限時任務的全周期疲勞度 * * @param customKey 用戶自定義key * @return 疲勞度計數 */ public Integer queryNoCycleLimit(String customKey) { return homeEnterGuideNoCycleLimiter.queryLimit(customKey); } }
函數式行為參數化
Java 21在今年9月份發布了,而距離Java 8發布已經過去9年多了,但也許,我是說也許......我們有些同學對Java 8還是不太熟悉......
再談行為參數化
最早聽到“行為參數化”這個詞是在經典的Java技術書籍《Java 8實戰》中。在此書中,作者以一個篩選蘋果的案例,基于行為參數化的思維一步步優化重構代碼,在提升代碼抽象能力的同時,保證了代碼的簡潔性和可讀性,而其中的秘密武器就是Java 8所引入的Lambda表達式和函數式接口。Java 8發布已經9年,對于Lambda表達式,大多數同學都已經耳熟能詳,但函數式接口也許有同學不知道代表著什么。簡單來說,如果一個接口,它只有一個沒有被實現的方法,那它就是函數式接口。java.lang.function包下定義JDK提供的一系列函數式接口。如果一個接口是函數式接口,推薦用@FunctionalInterface注解來顯式標明。那函數式接口有什么用呢?如果一個方法的行參里有函數式接口,那么函數式接口對應的參數可以支持傳遞Lambda表達式或者方法引用。 那何為“行為參數化”?直觀地來說就是將行為作為方法/函數的參數來進行傳遞。在Java 8之前,這可以通過匿名類實現,而在Java 8以后,可以基于函數式特性來實現行為參數化,即方法參數定義為函數式接口,在具體傳參時使用Lambda表達式/方法。相比匿名類,后者在簡潔性上有極大的提升。 在我們的日常開發中,如果我們看到兩個方法的結構十分相似,只有其中部分行為存在差別,那么就可以考慮采用函數式的行為參數化來重構優化這段代碼,將其中存在差異的行為抽象成參數,從而減少重復代碼。
從實踐中來,到代碼中去
下面給出一個例子。在靜心守護項目中,我們基于ldb維護了用戶未讀成就的列表,在用戶進入到個人成就頁時,會查詢未讀成就數據,并對未讀的成就在成就列表進行置頂以及加紅點展示。下面是對用戶未讀成就列表進行新增和清除的兩個方法:
/** * 清除未讀成就 * * @param uid 用戶ID * @param achievementType 需要清除未讀成就列表的成就類型 * @return */ public boolean clearUnreadAchievements(long uid, SetachievementTypes) { if (CollectionUtils.isEmpty(achievementTypes)) { return true; } Result ldbRes = super.rawGet(buildKey(uid), false); //用戶稱號數據查詢失敗 if (Objects.isNull(ldbRes)) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); return false; } boolean success = false; ResultCode resultCode = ldbRes.getRc(); //不存在用戶稱號數據則進行初始化 if (Objects.equals(resultCode, ResultCode.DATANOTEXSITS)) { UserUnreadAchievementsCache userUnreadAchievementsCache = new UserUnreadAchievementsCache(); achievementTypes.forEach(type -> clearCertainTypeIds(userUnreadAchievementsCache, type)); success = putCache(uid, userUnreadAchievementsCache, DEFAULT_VERSION); } else if (Objects.equals(resultCode, ResultCode.SUCCESS)) { DataEntry ldbEntry = ldbRes.getValue(); //存在新數據則對其進行更新 if (Objects.nonNull(ldbEntry)) { Object data = ldbEntry.getValue(); if (data instanceof String) { UserUnreadAchievementsCache userUnreadAchievementsCache = JSON.parseObject(String.valueOf(data), UserUnreadAchievementsCache.class); achievementTypes.forEach(type -> clearCertainTypeIds(userUnreadAchievementsCache, type)) success = putCache(uid, userUnreadAchievementsCache, ldbEntry.getVersion()); } } } //緩存解鎖的稱號失敗 if (!success) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); } return success; }
/** * 寫入新的未讀成就 * * @param uid 用戶ID * @param achievementTypeIdMap 需要新增的成就類型和成就ID列表的映射 * @return */ public boolean writeUnreadAchievements(long uid, Map> achievementTypeIdMap) { if (MapUtils.isEmpty(achievementTypeIdMap)) { return true; } Result ldbRes = super.rawGet(buildKey(uid), false); //用戶稱號數據查詢失敗 if (Objects.isNull(ldbRes)) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); return false; } boolean success = false; ResultCode resultCode = ldbRes.getRc(); //不存在用戶稱號數據則進行初始化 if (Objects.equals(resultCode, ResultCode.DATANOTEXSITS)) { UserUnreadAchievementsCache userUnreadAchievementsCache = new UserUnreadAchievementsCache(); achievementTypeIdMap.forEach((key, value) -> updateCertainTypeIds(userUnreadAchievementsCache, key, value)); success = putCache(uid, userUnreadAchievementsCache, DEFAULT_VERSION); } else if (Objects.equals(resultCode, ResultCode.SUCCESS)) { DataEntry ldbEntry = ldbRes.getValue(); //存在新數據則對其進行更新 if (Objects.nonNull(ldbEntry)) { Object data = ldbEntry.getValue(); if (data instanceof String) { UserUnreadAchievementsCache userUnreadAchievementsCache = JSON.parseObject(String.valueOf(data), UserUnreadAchievementsCache.class); achievementTypeIdMap.forEach((key, value) -> updateCertainTypeIds(oldCache, key, value)); success = putCache(uid, userUnreadAchievementsCache, ldbEntry.getVersion()); } } } //緩存解鎖的稱號失敗 if (!success) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); } return success; }
從結構上看,上面兩段代碼其實是非常類似的:整個結構都是先判空,然后查詢歷史的未讀成就數據,如果數據未初始化,則進行初始化,如果已經初始化,則對數據進行更新。只不過寫入/清除對數據的初始化和更新邏輯并不相同。因此可以將數據初始化和更新抽象為行為參數,將剩余部分提取為公共方法,基于這樣的思路重構后的代碼如下:
/** * 創建or更新緩存 * * @param uid 用戶ID * @param initCacheSupplier 緩存初始化策略 * @param updater 緩存更新策略 * @return */ private boolean upsertCache(long uid, SupplierinitCacheSupplier, Function updater) { Result ldbRes = super.rawGet(buildKey(uid), false); //用戶稱號數據查詢失敗 if (Objects.isNull(ldbRes)) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); return false; } boolean success = false; ResultCode resultCode = ldbRes.getRc(); //不存在用戶稱號數據則進行初始化 if (Objects.equals(resultCode, ResultCode.DATANOTEXSITS)) { UserUnreadAchievementsCache userUnreadAchievementsCache = initCacheSupplier.get(); success = putCache(uid, userUnreadAchievementsCache, DEFAULT_VERSION); } else if (Objects.equals(resultCode, ResultCode.SUCCESS)) { DataEntry ldbEntry = ldbRes.getValue(); //存在新數據則對其進行更新 if (Objects.nonNull(ldbEntry)) { Object data = ldbEntry.getValue(); if (data instanceof String) { UserUnreadAchievementsCache userUnreadAchievementsCache = JSON.parseObject(String.valueOf(data), UserUnreadAchievementsCache.class); userUnreadAchievementsCache = updater.apply(userUnreadAchievementsCache); success = putCache(uid, userUnreadAchievementsCache, ldbEntry.getVersion()); } } } //緩存解鎖的稱號失敗 if (!success) { recordErrorCode(InteractErrorCode.UNREAD_ACHIEVEMENT_UPSERT_ERROR, ExceptionBizParams.builder().uid(uid).build()); } return success; } /** * 寫入新的未讀成就 * * @param uid 用戶ID * @param achievementTypeIdMap 需要新增的成就類型和成就ID列表的映射 * @return */ public boolean writeUnreadAchievements(long uid, Map > achievementTypeIdMap) { if (MapUtils.isEmpty(achievementTypeIdMap)) { return true; } return upsertCache(uid, () -> { UserUnreadAchievementsCache userUnreadAchievementsCache = new UserUnreadAchievementsCache(); achievementTypeIdMap.forEach((key, value) -> updateCertainTypeIds(userUnreadAchievementsCache, key, value)); return userUnreadAchievementsCache; }, oldCache -> { achievementTypeIdMap.forEach((key, value) -> updateCertainTypeIds(oldCache, key, value)); return oldCache; } ); } /** * 清除未讀成就 * * @param uid 用戶ID * @param achievementType 需要清除未讀成就列表的成就類型 * @return */ public boolean clearUnreadAchievements(long uid, Set achievementTypes) { if (CollectionUtils.isEmpty(achievementTypes)) { return true; } return upsertCache(uid, () -> { UserUnreadAchievementsCache userUnreadAchievementsCache = new UserUnreadAchievementsCache(); achievementTypes.forEach(type -> clearCertainTypeIds(userUnreadAchievementsCache, type)); return userUnreadAchievementsCache; }, oldCache -> { achievementTypes.forEach(type -> clearCertainTypeIds(oldCache, type)); return oldCache; } ); }
重構的核心是提取了upsert方法,該方法將緩存數據的初始化和更新策略以函數式接口進行定義,從而支持從調用側進行透傳,避免了模板方法的重復編寫。這是一個拋磚引玉的例子,在日常開發中,我們可以更多地嘗試用函數式編程的思維去思考和重構代碼,也許會發現另一個神奇的編程世界。
切面編程的一些實踐
AOP想必大家都已經十分熟悉了,在此便不再贅述其基本概念,而是開門見山直接分享一些AOP在靜心守護項目中的實際應用。
服務層異常統一收口
靜心守護項目采用了在阿里系統中常用的service-manager-dao的分層模式,其中service層是距離終端最近的一層。為了防止下層預期外的異常拋到終端,我們需要在service層對異常進行統一攔截并且記錄,同時最好將相關的錯誤碼、請求參數以及traceId都一并記下,便于問題排查。這個場景就非常適合使用AOP。在引入AOP之前,我們需要對每個service中面向終端的方法都進行異常攔截和監控日志打印的操作。比方說下面這個類,它有3個面向終端mtop【注:阿里內部自研的API網關平臺】服務的方法(api具體參數和名稱做了模糊化處理),這3個方法都采用了同樣的try-catch結構來進行異常捕捉和監控日志打印,其中存在大量的重復代碼,而更糟糕的事,如果后續增加新的方法,這樣的重復代碼還會不斷增加。
@Slf4j @HSFProvider(serviceInterface = MtopBlessHomeService.class) public class MtopBlessHomeServiceImpl implements MtopBlessHomeService { //依賴的bean注入 ...... @Override public MtopResult看到這樣重復的代碼結構而只是局部行為的不同,也許我們可以考慮著用上一節的函數式行為參數化進行重構:將重復的代碼結構抽取為公共的工具方法,將對manager層的調用抽象為行為參數。但在上述場景下,這種做法還是存在一些弊端:entranceA(EntranceARequest request) { try { startDiagnose(request.getUserId()); //該入口下的業務邏輯 ...... } catch (InteractBizException e) { log.error("Service invoke fail. Method name:{}, params:{}, errorCode:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceA", buildMethodParamsStr(request), e.getErrCode(), EagleEye.getTraceId()); recordErrorCode(e); return MtopUtils.errMtopResult(e.getErrCode(), e.getErrMsg()); } catch (Exception e) { log.error("Service invoke fail. Method name:{}, params:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceA", buildMethodParamsStr(request), EagleEye.getTraceId(), e); recordErrorCode(InteractErrorCode.SYSTEM_ERROR, ExceptionBizParams.builder().build()); return MtopUtils.sysErrMtopResult(); } finally { DiagnoseClient.end(); } } @Override public MtopResult entranceB(EntranceBRequest request) { try { startDiagnose(request.getUserId()); //該入口下的業務邏輯 ...... } catch (InteractBizException e) { log.error("Service invoke fail. Method name:{}, params:{}, errorCode:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceB", buildMethodParamsStr(request), e.getErrCode(), EagleEye.getTraceId()); recordErrorCode(e); return MtopUtils.errMtopResult(e.getErrCode(), e.getErrMsg()); } catch (Exception e) { log.error("Service invoke fail. Method name:{}, params:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceB", buildMethodParamsStr(request), EagleEye.getTraceId(), e); recordErrorCode(InteractErrorCode.SYSTEM_ERROR, ExceptionBizParams.builder().build()); return MtopUtils.sysErrMtopResult(); } finally { DiagnoseClient.end(); } } @Override public MtopResult entranceC(EntranceCRequest request) { try { startDiagnose(query.getUserId()); //該入口下的業務邏輯 ...... } catch (InteractBizException e) { log.error("Service invoke fail. Method name:{}, params:{}, errorCode:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceC", buildMethodParamsStr(request), e.getErrCode(), EagleEye.getTraceId()); recordErrorCode(e); return MtopUtils.errMtopResult(e.getErrCode(), e.getErrMsg()); } catch (Exception e) { log.error("Service invoke fail. Method name:{}, params:{}, trace:{}", "MtopBlessHomeServiceImpl.entranceC", buildMethodParamsStr(request), EagleEye.getTraceId(), e); recordErrorCode(InteractErrorCode.SYSTEM_ERROR, ExceptionBizParams.builder().build()); return MtopUtils.sysErrMtopResult(); } finally { DiagnoseClient.end(); } } }
每個服務的方法還是需要顯式調用工具類方法
為了保證監控信息的齊全,還需要在參數里手動透傳一些監控相關的信息
而AOP則不存在這些問題:AOP基于動態代理實現,在實現上述邏輯時對服務層的代碼編寫完全透明。此外,AOP還封裝了調用端方法的各種元信息,可以輕松實現各種監控信息的自動化打印。下面是我們提供的AOP切面。其中值得注意的點是切點的選擇要盡量準確,避免增強了不必要的方法。下面我們選擇的切點是mtop包下所有Impl結尾類的public方法。
@Aspect @Component @Slf4j public class MtopServiceAspect { /** * MtopService層服務 */ @Pointcut("execution(public com.taobao.mtop.common.MtopResult com.taobao.gaia.veyron.bless.service.mtop.*Impl.*(..))") public void mtopService(){} /** * 對mtop服務進行增強 * * @param pjp 接入點 * @return * @throws Throwable */ @Around("com.taobao.gaia.veyron.bless.aspect.MtopServiceAspect.mtopService()") public Object enhanceService(ProceedingJoinPoint pjp) throws Throwable { try { startDiagnose(pjp); return pjp.proceed(); } catch (InteractBizException e) { log.error("Service invoke fail. Method name:{}, params:{}, errorCode:{}, trace:{}", AspectUtils.extractMethodName(pjp), buildMethodParamsStr(pjp), e.getErrCode(), EagleEye.getTraceId()); recordErrorCode(e); return MtopUtils.errMtopResult(e.getErrCode(), e.getErrMsg()); } catch (Exception e) { log.error("Service invoke fail. Method name:{}, params:{}, trace:{}", AspectUtils.extractMethodName(pjp), buildMethodParamsStr(pjp), EagleEye.getTraceId(), e); recordErrorCode(InteractErrorCode.SYSTEM_ERROR, ExceptionBizParams.builder().build()); return MtopUtils.sysErrMtopResult(); } finally { DiagnoseClient.end(); } } }
存在這樣一個切面后,service層的代碼就可以變得非常簡潔:只需要純粹專注于業務邏輯。同樣以剛才的MtopBlessHomeServiceImpl類為例,在AOP改寫后的代碼里可以去除掉原先異常收口和監控相關的內容,而僅保留業務邏輯部分,代碼簡潔性大大提升。
@Slf4j @HSFProvider(serviceInterface = MtopBlessHomeService.class) public class MtopBlessHomeServiceImpl implements MtopBlessHomeService { //依賴的bean注入 ...... @Override public MtopResultentranceA(EntranceARequest request) { //業務邏輯 ...... } @Override public MtopResult entranceB(EntranceBRequest request) { //業務邏輯 ...... } @Override public MtopResult entranceC(EntranceCRequest request) { //業務邏輯 ...... } }
切點選擇的策略
除了服務層以外,我們還想對數據訪問層進行監控,監控項目中各種數據存儲工具的RT以及成功率相關指標,并且監控粒度要盡可能地貼近業務維度(整體的數據訪問監控直接通過eagleeye查看即可),便于具體問題的定位排查。這種面向層級別的邏輯定制,我們很自然而然地想到了AOP,這也正是它可以大顯身手的場景。 這節核心想要分享的則是切點的選擇。靜心守護項目的數據存儲主要依賴于Tair【注:阿里內部自研的高性能K-V存儲系統。根據存儲介質和使用場景不同又分為LDB、MDB、RDB】、Lindorm【注:阿里內部自研的大規模云原生多模數據庫服務】和Mysql,這三種存儲工具在代碼中的使用各不相同,導致切點的選擇策略也大相徑庭。
目標對象規律分布
如果我們要選擇增強的對象在項目中分布的非常規律,那么我們往往可以直接利用Spring AOP的PointCut語法來選擇切點。以靜心守護項目中的Mysql數據訪問對象為例:我們使用的ORM框架是mybatis,并且主要的用法是注解模式,所有的SQL邏輯都放在一個DAO包下,每個業務場景定義一個DAO結尾的Mapper接口,接口下的每個方法都對應著一種數據訪問的方式。因此在切點選擇時,我們可以直接選擇DAO包下以DAO結尾的類,并選擇其中public方法即可準確織入所有滿足條件的切點。
@Pointcut("execution(public * com.taobao.gaia.serverless.veyron.bless.dao.*DAO.*(..))") public void charityProjectDataAccess() { }
這樣實現的監控粒度是具體到每個DAO對象-方法級別的粒度,監控效果如下:
一個失效案例
靜心守護項目中對tair的使用方式是:通過一個抽象類對tair的各種基礎操作進行封裝(包括參數校驗、響應判空、異常處理等),但將具體tair實例相關的參數設置行為抽象化,由實現類決定。各個業務場景的tair管理類最終會基于抽象類封裝的基礎操作來對tair進行數據訪問。 如下圖,AbstractLdbManager是封裝
由于各個業務場景的tair管理實現類分散在各個業務包下,想要對它們進行統一切入比較困難。因此我們選擇對抽象類進行切入。但這樣就會遇到一個同類調用導致AOP失效的問題:抽象類本身不會有實例對象,因此基于CGLIB創建代理對象后,代理對象本質上調用的還是各個業務場景tair管理類的對象,而在使用這些對象時,我們不會直接調用tair抽象類封裝的數據訪問方法,而是調用這些業務tair管理對象進一步封裝的帶業務語義的方法,基于這些方法再去調用tair抽象類的數據訪問方法。這種同類方法間接調用最終就導致了抽象類的方法沒有如期被增強。文字描述興許有些繞,可以參考下面的圖:
我們選擇的解決方法則是從上面的MultiClusterTairManager入手,這個類是tair為我們提供的TairManger的一種默認實現,我們之前的做法是為該類實例化一個bean,然后提供給所有業務Tair管理類使用,也就是說所有業務Tair管理類使用的TairManager都是同一個bean實例(因為業務流量沒那么大,一個tair實例暫時綽綽有余)。那么我們可以自己提供一個TairManager的實現,基于繼承+組合MultiClusterTairManager的方式,只對我們項目內用到數據訪問操作進行重寫,并委托給原先的MultiClusterTairManager bean進行處理。這樣我們可以在設置AOP切點時選擇對自己實現的TairManager的所有方法做增強,進而避開上面的問題。經過這樣改寫后,上面的兩張圖會演變成下面這樣:
基于注解切入
還有一種場景是我們要增強的方法分布毫無規律,可能都在同一個類中,但方法的名稱毫無規律,也無法簡單通過private或者public來區別。針對這樣的場景,我們的做法是自定義注解,專門用于標識需要做增強的方法。比如靜心守護項目中lindorm相關的數據操作就是這樣。我們定義注解:
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface VeyronJoinPoint {}
并將該注解標識在需要增強的方法上,隨后通過下面的方式描述切點,即可獲取到所有需要增強的方法。
@Pointcut("@annotation(com.taobao.gaia.serverless.veyron.aspect.VeyronJoinPoint)") public void lindormDataAccess() {}
上面的方法也有進一步改良的空間:在注解內增加屬性來描述具體的業務場景,不同的切面根據業務場景來對捕獲的方法進行過濾,只留下當前業務場景所需要的方法。不然按照現有的做法,如果新的切面也要基于注解來尋找切點,那只能定義新的注解,否則會與原先注解產生沖突。
總結
業務需求千變萬化,對應的解法也見仁見智。在研發過程中對各種變化中不變的部分進行總結,從中提取出自己的模式與方法論進行整理沉淀,會讓我們以后跑的更快。也正應了學生時期,老師常說的那句話:“我們要把厚厚的書本讀薄才能裝進腦子里。”
審核編輯:湯梓紅
-
編程
+關注
關注
88文章
3615瀏覽量
93718 -
函數
+關注
關注
3文章
4331瀏覽量
62595 -
容器
+關注
關注
0文章
495瀏覽量
22061 -
spring
+關注
關注
0文章
340瀏覽量
14341
原文標題:關于編程模式的總結與思考
文章出處:【微信號:OSC開源社區,微信公眾號:OSC開源社區】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論