关于编程模式的总结与思考

703次阅读  |  发布于1年以前

淘宝创新业务的优化迭代是非常高频且迅速的,在这过程中要求技术也必须是快且稳的,而为了适应这种快速变化的节奏,我们在项目开发过程中采用了一些面向拓展以及敏捷开发的设计,本文旨在总结并思考其中一些通用的编程模式。

01 前言

静心守护业务是淘宝今年4月份启动的创新项目,项目的核心逻辑是通过敲木鱼、冥想、盘手串等疗愈玩法为用户带来内心宁静的同时推动文物的保护与修复,进一步弘扬我们的传统文化。

作为创新项目,业务形态与产品方案的优化迭代是非常高频且迅速的:项目从4月底投入开发到7月份最终外灰,整体方案经历过大的推倒重建,也经历过多轮小型重构优化,项目上线后也在做持续的迭代优化甚至改版升级。

02 模式清单

基于Spring容器与反射的策略模式

策略模式是一种经典的行为设计模式,它的本质是定义一系列算法, 并将每种算法分别放入独立的类中, 以使算法的对象能够相互替换,后续也能根据需要灵活拓展出新的算法。这里推荐的是一种基于Spring容器和反射结合的策略模式,这种模式的核心思路是:每个策略模式的实现都是一个bean,在Spring容器启动时基于反射获取每个策略场景的接口类型,并基于该接口类型再获取此类型的所有策略实现bean并记录到一个map(key为该策略bean的唯一标识符,value为bean对象)中,后续可以自定义路由策略来从该map中获取bean对象并使用相应的策略。

模式解构

模式具体实现方式大致如下面的UML类图所描述的:

其中涉及的各个组件及作用分别为:

  1. Handlerinterface):策略的顶层接口,定义的type方法表示策略唯一标识的获取方式。
  2. HandlerFactoryabstract class):策略工厂的抽象实现,封装了反射获取Spring bean并维护策略与其标识映射的逻辑,但不感知策略的真实类型。
  3. AbstractHandlerinterface or abstracr class):各个具体场景下的策略接口定义,该接口定义了具体场景下策略所需要完成的行为。如果各个具体策略实现有可复用的逻辑,可以结合模版方法模式在该接口内定义模版方法,如果模板方法依赖外部bean注入,则该接口的类型需要为abstract class,否则为interface即可。
  4. HandlerImplclass):各个场景下策略接口的具体实现,承载主要的业务逻辑,也可以根据需要横向拓展。
  5. HandlerFactoryImplclass):策略工厂的具体实现,感知具体场景策略接口的类型,如果有定制的策略路由逻辑也可以在此实现。

这种模式的主要优点有:

  1. 策略标识维护自动化:策略实现与标识之间的映射关系完全委托给Spring容器进行维护(在HandlerFactory中封装,每个场景的策略工厂直接继承该类即可,无需重复实现),后续新增策略不用再手动修改关系映射。
  2. 场景维度维护标识映射HandlerFactory中在扫描策略bean时是按照AbstractHandler的类型来分类维护的,从而避免了不同场景的同名策略发生冲突。
  3. 策略接口按场景灵活定义:具体场景的策略行为定义在AbstractHandler中,在这里可以根据真实的业务需求灵活定义行为,甚至也可以结合其他设计模式做进一步抽象处理,在提供灵活拓展的同时减少重复代码。

实践案例分析

该模式在静心守护项目中的许多功能模块都有使用,下面以称号解锁模块为例来介绍其实际应用。 我们先简单了解下该模块的业务背景:静心守护的成就体系中有一类是称号,如下图。用户可以通过多种行为去解锁不同类型的称号,比如说通过参与主玩法(敲木鱼、冥想、盘手串),主玩法参与达到一定次数后即可解锁特定类型的称号。当然后续也可能会有其他种类的称号:比如签到类(按照用户签到天数解锁)、捐赠类(按照用户捐赠项目的行为解锁),所以对于称号的解锁操作应该是面向未来可持续拓展的。

基于这样的思考,我选择使用上面的策略模式去实现称号解锁模块。该模块的核心类图组织如下:

下面是其中部分核心代码的分析解读:

public interface Handler<T> {
    /**
     * handler类型
     *
     * @return
     */
    T type();
}

如上文所说,Handler是策略的顶层抽象,它只定义了type方法,该方法用于获取策略的标识,标识的类型支持子接口定义。


@Slf4j
public abstract class HandlerFactory<T, H extends Handler<T>> implements InitializingBean, ApplicationContextAware {
    private Map<T, H> 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<H> getHandlerType() {
        Type type = ((ParameterizedType)getClass().getGenericSuperclass()).getActualTypeArguments()[1];
        //策略接口使用了范型参数
        if (type instanceof ParameterizedTypeImpl) {
            return (Class<H>) ((ParameterizedTypeImpl)type).getRawType();
        } else {
            return (Class<H>) type;
        }
    }

    @Override
    public void afterPropertiesSet() {
        // 获取所有 H 类型的 handlers
        Collection<H> 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在内存中维护策略与其标识的映射关系,后续可以直接通过标识或者对应的策略实现。这里有二个细节:

  1. 为什么HandlerFactory是abstract class?其实可以看到该类并没有任何抽象方法,直接将其定义为class也不会有什么问题。这里将其定义为abstract class主要是起到实例创建的约束作用,因为我们对该类的定义是工厂的抽象实现,只希望针对具体场景来创建实例,针对该工厂本身创建实例其实是没有任何实际意义的。
  2. getHandlerType方法使用了@SuppressWarnings注解并标记了unchecked。这里也确实是存在潜在风险的,因为Type类型转Class类型属于向下类型转换,是存在风险的,可能其实际类型并非Class而是其他类型,那么此处强转就会出错。这里处理了两种最通用的情况:AbstractHandler是带范型的class和最普通的class
@Component
public class TitleUnlockHandlerFactory
        extends HandlerFactory<String, BaseTitleUnlockHandler<BaseTitleUnlockParams>> {}

TitleUnlockHandlerFactory是策略工厂的具体实现,由于不需要在此定制策略的路由逻辑,所以只声明了相关的参数类型,而没有对父类的方法做什么覆盖。


public abstract class BaseTitleUnlockHandler<T extends BaseTitleUnlockParams> implements Handler<String> {

    @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<TitleAchievementVO> 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<GameplayTitleUnlockParams> {

    @Resource
    private BlessTitleAchievementDiamondConfig blessTitleAchievementDiamondConfig;

    @Resource
    private UserTitleTairManager userTitleTairManager;

    @Override
    protected TitleUnlockResult doUnlock(GameplayTitleUnlockParams params) {
        //获取称号元数据
        List<TitleMetadata> titleMetadata = blessTitleAchievementDiamondConfig.getTitleMetadata();

        if (CollectionUtils.isEmpty(titleMetadata)) {
            return null;
        }

        List<TitleAchievementVO> titleAchievements = new ArrayList<>();

        Result<DataEntry> 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;
    }

    ......
}

上面是一个策略的具体实现类的大致示例,可以看到该实现类核心明确了以下信息:

  1. 策略标识:给出了type方法的具体实现,返回了一个策略标识的常量
  2. 策略处理逻辑:此处是玩法类称号解锁的业务逻辑,读者无需关注其细节
  3. 称号解锁行参:给出了玩法类称号解锁所需的真实参数类型

抽象疲劳度管控体系

在我们的业务需求中经常会遇到涉及疲劳度管控相关的逻辑,比如每日签到允许用户每天完成1次、首页项目进展弹窗要求对所有用户只弹1次、首页限时回访任务入口则要对用户每天都展示一次,但用户累计完成3次后便不再展示......因此我们设计了一套疲劳度管控的模式,以降低后续诸如上述涉及疲劳度管控相关需求的开发成本。

自顶向下的视角

这套疲劳度管控体系的类层次大致如下图:

接下来我们自顶向下逐层进行介绍:

  1. FatigueLimiter(interface):FatigueLimiter是最顶层抽象的疲劳度管控接口,它定义了疲劳度管控相关的行为,比如:疲劳度的查询、疲劳度清空、疲劳度增加、是否达到疲劳度限制的判断等。

  2. BaseFatigueLdbLimiter(abstract class):疲劳度数据的存储方案可以是多种多样的,在我们项目中主要利用ldb进行疲劳度存储,而BaseFatigueLdbLimiter正是基于ldb【注:阿里内部自研的一款持久化k-v数据库,读者可将其理解为类似level db的项目】对疲劳度数据进行管控的抽象实现,它封装了ldb相关的操作,并基于ldb的数据操作实现了FatigueLimiter的疲劳度管控方法。但它并不感知具体业务的身份和逻辑,因此定义了几个业务相关的方法交给下层去实现,分别是:

    • scene:标识具体业务的场景,会利用该方法返回值去构造Ldb存储的key
    • buildCustomKey:对Ldb存储key的定制逻辑
    • getExpireSeconds:对应着Ldb存储kv失效时间,对应着疲劳度的管控周期
  3. Ldb周期性疲劳度管控的解决方案层(abstract class):在这一层提供了多种周期的开箱即用的疲劳度管控实现类,如BaseFatigueDailyLimiter提供的是天级别的疲劳度管控能力,BaseFatigueNoCycleLimiter则表示疲劳度永不过期,而BaseFatigueCycleLimiter则支持用户实现cycle方法定制疲劳度周期。

  4. 业务场景层:这一层则是各个业务场景对疲劳度管控的具体实现,实现类只需要实现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 Map<String, Integer> batchQueryLimit(List<String> 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, Set<String> achievementTypes) {

    if (CollectionUtils.isEmpty(achievementTypes)) {
        return true;
    }

    Result<DataEntry> 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<String, List<String>> achievementTypeIdMap) {

    if (MapUtils.isEmpty(achievementTypeIdMap)) {
        return true;
    }

    Result<DataEntry> 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, Supplier<UserUnreadAchievementsCache> initCacheSupplier,
                            Function<UserUnreadAchievementsCache, UserUnreadAchievementsCache> updater) {

    Result<DataEntry> 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<String, List<String>> 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<String> 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<EntranceAVO> 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<EntranceBVO> 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<EntranceCVO> 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();
        }    
    }

}

看到这样重复的代码结构而只是局部行为的不同,也许我们可以考虑着用上一节的函数式行为参数化进行重构:将重复的代码结构抽取为公共的工具方法,将对manager层的调用抽象为行为参数。但在上述场景下,这种做法还是存在一些弊端:

  1. 每个服务的方法还是需要显式调用工具类方法
  2. 为了保证监控信息的齐全,还需要在参数里手动透传一些监控相关的信息

而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 MtopResult<EntranceAVO> entranceA(EntranceARequest request) {
        //业务逻辑
        ......
    }

    @Override
    public MtopResult<EntranceBVO> entranceB(EntranceBRequest request) {
        //业务逻辑
        ......
    }

    @Override
    public MtopResult<EntranceCVO> 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() {}

上面的方法也有进一步改良的空间:在注解内增加属性来描述具体的业务场景,不同的切面根据业务场景来对捕获的方法进行过滤,只留下当前业务场景所需要的方法。不然按照现有的做法,如果新的切面也要基于注解来寻找切点,那只能定义新的注解,否则会与原先注解产生冲突。

总结

业务需求千变万化,对应的解法也见仁见智。在研发过程中对各种变化中不变的部分进行总结,从中提取出自己的模式与方法论进行整理沉淀,会让我们以后跑的更快。也正应了学生时期,老师常说的那句话:“我们要把厚厚的书本读薄才能装进脑子里。”

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8