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

5a2a398dff8ed473fdc9c7529c8d092e.gif

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

ca997f29e6aa1759d19a20b9b30d7d8c.png

前言

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

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

c265a2e49c5e3abf9875e32bd502f85a.png

模式清单

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

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

  • 模式解构

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

9c355178217320aec89a8e34df4f3a0d.png

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

  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中,在这里可以根据真实的业务需求灵活定义行为,甚至也可以结合其他设计模式做进一步抽象处理,在提供灵活拓展的同时减少重复代码。

  • 实践案例分析

该模式在静心守护项目中的许多功能模块都有使用,下面以称号解锁模块为例来介绍其实际应用。

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

0b5d8a49c936dc106c70ecf06f7ae8cc.png

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

4638246822f2d4f56ecee34a368c9ef0.png

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

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次后便不再展示......因此我们设计了一套疲劳度管控的模式,以降低后续诸如上述涉及疲劳度管控相关需求的开发成本。

  • 自顶向下的视角

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

13062cd291136c719111cc100a10aec4.png

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

  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失效时间,对应着疲劳度的管控周期

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 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对象-方法级别的粒度,监控效果如下:

4f7df1315c275a5a6814df60ebdeb330.png

一个失效案例

静心守护项目中对tair的使用方式是:通过一个抽象类对tair的各种基础操作进行封装(包括参数校验、响应判空、异常处理等),但将具体tair实例相关的参数设置行为抽象化,由实现类决定。各个业务场景的tair管理类最终会基于抽象类封装的基础操作来对tair进行数据访问。

如下图,AbstractLdbManager是封装

dc1811974b56c3414e33dda827c40d65.png

由于各个业务场景的tair管理实现类分散在各个业务包下,想要对它们进行统一切入比较困难。因此我们选择对抽象类进行切入。但这样就会遇到一个同类调用导致AOP失效的问题:抽象类本身不会有实例对象,因此基于CGLIB创建代理对象后,代理对象本质上调用的还是各个业务场景tair管理类的对象,而在使用这些对象时,我们不会直接调用tair抽象类封装的数据访问方法,而是调用这些业务tair管理对象进一步封装的带业务语义的方法,基于这些方法再去调用tair抽象类的数据访问方法。这种同类方法间接调用最终就导致了抽象类的方法没有如期被增强。文字描述兴许有些绕,可以参考下面的图:

1e0652a52280bcf9a0434861c434efb1.png

我们选择的解决方法则是从上面的MultiClusterTairManager入手,这个类是tair为我们提供的TairManger的一种默认实现,我们之前的做法是为该类实例化一个bean,然后提供给所有业务Tair管理类使用,也就是说所有业务Tair管理类使用的TairManager都是同一个bean实例(因为业务流量没那么大,一个tair实例暂时绰绰有余)。那么我们可以自己提供一个TairManager的实现,基于继承+组合MultiClusterTairManager的方式,只对我们项目内用到数据访问操作进行重写,并委托给原先的MultiClusterTairManager bean进行处理。这样我们可以在设置AOP切点时选择对自己实现的TairManager的所有方法做增强,进而避开上面的问题。经过这样改写后,上面的两张图会演变成下面这样:

1e8967f7c5a8971ec64448d621743a7c.png

775aceccf383ce95ac7078fc7726e117.png

基于注解切入

还有一种场景是我们要增强的方法分布毫无规律,可能都在同一个类中,但方法的名称毫无规律,也无法简单通过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() {}

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

a829c8843ef463b16ae69e0ac28fb240.png

总结

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

最后,如果大家有好的实践模式推荐或者建议,欢迎在评论区分享交流~

48723f37eaaf3ba1c3259fc07e52c83a.png

团队介绍

我们是淘天业务技术用户消息与社交团队,负责淘宝消息、客服、Push、分享、我淘、关系、社交互动等业务,涵盖淘宝APP中两个一级Tab,第三个消息tab和第五个我的淘宝tab,这里有一流的产品技术,为消费者提供更好的消息与社交服务;丰富的业务场景,为淘系业务增加助力;几十万QPS的高并发流量,可以与淘系各位技术大牛合作,思想激荡碰撞,共同提升,包含以下方向:

  1. 在淘宝IM基础上构建以用户实时意图感知、统一投放引擎为核心的全域触达体系,通过跨场景的触达方案,赋能淘系搜索、互动、用增等业务增长,每日触达亿级用户。

  2. 社交域基础平台服务,我的淘宝、淘友、互动等业务,服务上亿淘宝用户。

  3. 淘宝消息tab、千牛商家消息,通过建立平台,消费者,商家之间的链接,提升手淘DAU,助力商家更好的服务消费者,拥有亿级电商IM消息即时通讯产品,可以深入掌握分布式高可靠设计理念和架构方法论。

招聘持续火热🔥进行中,如果有兴趣可将简历发至lingye.jly@taobao.com,期待您的加入!

¤ 拓展阅读 ¤

3DXR技术 | 终端技术 | 音视频技术

服务端技术 | 技术质量 | 数据算法

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/286466.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【Vue2+3入门到实战】(19)Vuex状态管理器通过辅助函数 - mapState获取 state中的数据代码实现 详细讲解

目录 一、通过辅助函数 - mapState获取 state中的数据1.第一步&#xff1a;导入mapState (mapState是vuex中的一个函数)2.第二步&#xff1a;采用数组形式引入state属性3.第三步&#xff1a;利用**展开运算符**将导出的状态映射给计算属性 二、开启严格模式及Vuex的单项数据流1…

2024年美赛数学建模ABCDEF题思路选题分析

文章目录 1 赛题思路2 美赛比赛日期和时间3 赛题类型4 美赛常见数模问题5 建模资料 1 赛题思路 (赛题出来以后第一时间在CSDN分享) https://blog.csdn.net/dc_sinor?typeblog 2 美赛比赛日期和时间 比赛开始时间&#xff1a;北京时间2024年2月2日&#xff08;周五&#xff…

MVCC 并发控制原理-源码解析(非常详细)

基础概念 并发事务带来的问题 1&#xff09;脏读&#xff1a;一个事务读取到另一个事务更新但还未提交的数据&#xff0c;如果另一个事务出现回滚或者进一步更新&#xff0c;则会出现问题。 2&#xff09;不可重复读&#xff1a;在一个事务中两次次读取同一个数据时&#xff0c…

Java实现短信发送业务

1、业务需求 发送短信功能是一个很普遍的需求&#xff0c;比如验证码&#xff0c;快递单号&#xff0c;通知信息一类。 而在Java中实现短信功能相对简单&#xff0c;只需要调用短信服务商提供的API。接下来以阿里云为例&#xff0c;介绍如何实现短信发送功能&#xff0c;其他短…

运算符的优先级(规矩是人定的)

运算符的优先级&#xff08;规矩是人定的&#xff09; 什么是经典&#xff1f;经典就是理论不随时间变迁而变化。《东方不败》中的很多台词让人时不时想起来振聋发聩。比如 很多事情不是自己想的那样&#xff0c;规矩是人定的。 舔狗和有思想 从小到大&#xff0c;我们都学过…

使用sdf文件+urdf文件模拟机器人示例(不用把urdf转sdf)

gazebo版本&#xff1a;harmonic&#xff1b; <launch> <group> <let name"robot_description" value"$(command xacro $(find-pkg-share gazebo_pkg)/urdf/total.xacro)"/> <node pkg"rviz2" exec"rviz2" name…

前端文件上传组件最全封装+删除+下载+预览

前言&#xff1a;使用的是若依的框架element uivue2封装的。如果有不对的地方欢迎指出。后台管理使用&#xff0c;文件需要上传。回显列表&#xff0c;详情也需要回显预览 // 开始封装组件&#xff1a;封装在 src/components/FileUpload/index.vue中 <template><div c…

如何使用Pyxamstore快速解析Xamarin AssemblyStore文件

关于Pyxamstore Pyxamstore是一款针对Xamarin AssemblyStore文件&#xff08;assemblies.blob&#xff09;的强大解析工具&#xff0c;该工具基于纯Python 2.7开发&#xff0c;支持从一个APK文件中解包并重封装assemblies.blob和assemblies.manifest Xamarin文件。 什么是ass…

YOLOv5算法进阶改进(10)— 更换主干网络之MobileViTv3 | 轻量化Backbone

前言:Hello大家好,我是小哥谈。MobileViTv3是一种改进的模型架构,用于图像分类任务。它是在MobileViTv1和MobileViTv2的基础上进行改进的,通过引入新的模块和优化网络结构来提高性能。本节课就给大家介绍一下如何在主干网络中引入MobileViTv3网络结构,希望大家学习之后能够…

生活明朗,万物可爱人间值得,未来可期

这是一件洋溢着温馨 与童真的时尚佳作它以细腻的织法、柔暖的材质 给您的宝贝带来舒适的穿着体验独特的韩版设计&#xff0c;彰显时尚品味 让您的孩子从小就走在 潮流的前沿适合各种场合 无论是周末聚会还是平日上学 都能让您的孩子焕发自信光彩

Axure鲜花速递商城网站原型图,花店网站O2O本地生活电商平台

作品概况 页面数量&#xff1a;共 30 页 兼容软件&#xff1a;仅支持Axure RP 9/10&#xff0c;非程序软件无源代码 应用领域&#xff1a;鲜花网、花店网站、本地生活电商 作品特色 本作品为「鲜花购物商城」网站模板&#xff0c;高保真高交互&#xff0c;属于O2O本地生活电…

【k8s】deamonset文件和说明

目录 deamonset的相关命令 deamonset的定义 deamonset的使用场景 deamonset的例子 deamonset字段说明 serviceAccountName DaemonSet的结构及其各个部分的作用 deamonset的相关命令 #查看<name-space>空间内有哪些deamonset kubectl get DaemonSet -n <na…

华为OD机试 - 两个字符串间的最短路径问题(Java JS Python C)

题目描述 给定两个字符串,分别为字符串 A 与字符串 B。 例如 A字符串为 "ABCABBA",B字符串为 "CBABAC" 可以得到下图 m * n 的二维数组,定义原点为(0,0),终点为(m,n),水平与垂直的每一条边距离为1,映射成坐标系如下图。 从原点 (0,0) 到 (0,A) 为水…

普中STM32-PZ6806L开发板(HAL库函数实现-USART2 中断接收)

简介 实现USART2 的 中断接收&#xff0c; 发送数据。电路原理图 USART2接线 原理图USART2 在主芯片引脚 实物图 其他知识 APIs stm32f1xx_hal_uart.h /* 堵塞发送, pData是发送数据, Size发送数据大小, Timeout是超时时间 */ HAL_StatusTypeDef HAL_UART_Transmit(UAR…

md文件图片上传方案:Github+PicGo 搭建图床

文章目录 1. PicGo 下载2. 配置Github3. 配置PicGo4. PicGo集成Typora4.1 picGo监听端口设置 5. 测试 1. PicGo 下载 下载地址&#xff1a;https://molunerfinn.com/PicGo/ 尽量下载稳定版本 2. 配置Github 1. 创建一个新仓库&#xff0c;用于存放图片 2. 生成一个token&a…

数据库——SQL DDLDML使用

1.实验内容及原理 1. 在 Windows 系统中安装 VMWare 虚拟机&#xff0c;在 VMWare 中安装 Ubuntu 系统,并在 Ubuntu 中搭建 LAMP 实验环境。 2. 使用 MySQL 进行一些基本操作&#xff1a; &#xff08;1&#xff09;登录 MySQL&#xff0c;在 MySQL 中创建用户&#xff0c;…

[密码学]ECC加密

椭圆曲线加密 Ellipse Curve Cryptography 椭圆曲线上的离散对数问题 Ellipse Curve Discrete logarithm Problem 椭圆曲线 注意积分公式的分母&#xff0c;椭圆曲线由此得名。这种曲线和椭圆一点不像。 离散对数&#xff1a; yg^x mod p,对于给定的g,x,p求y很容易&#…

JVM工作原理与实战(三):字节码文件的组成

专栏导航 JVM工作原理与实战 RabbitMQ入门指南 从零开始了解大数据 目录 专栏导航 前言 一、基础信息 1.Magic魔数 2.主副版本号 3.其他信息 二、常量池 1.案例解析 三、方法 1.方法介绍 2.案例解析 四、字段 五、属性 总结 前言 JVM作为Java程序的运行环境&…

【致远OA】获取指定人员的协同已办列表

接口说明 按人员编码获取所有待办事项&#xff08;V6.0接口更新:不在传入ticket&#xff0c;改为传memberId人员ID&#xff09; 兼容版本 since V7.0 请求方式 V6.0之前http请求方式&#xff1a;GET http://ip:port/seeyon/rest/affairs/done 如 http://127.0.0.1/seeyon/…

Builder建造者模式(对象创建)

Builder 链接&#xff1a;建造者模式实例代码 解析 目的 在软件系统中&#xff0c;有时候面临着“一个复杂对象”的创建工作&#xff0c;其通常由各个部分的子对象用一定的算法构成&#xff1b;由于需求的变化&#xff0c;这 个复杂对象的各个部分经常面临着剧烈的变化&…