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

AT燒錄軟件Progisp和使用手冊(cè)和對(duì)于ISP編程進(jìn)入不了編程模式的總結(jié)

事件總線模式知識(shí)總結(jié)
關(guān)于risc-v啟動(dòng)部分的思考

C 語言編程習(xí)慣總結(jié)

評(píng)論