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

简介: 关于编程模式的总结与思考(下)

关于编程模式的总结与思考(中):https://developer.aliyun.com/article/1443300


从结构上看,上面两段代码其实是非常类似的:整个结构都是先判空,然后查询历史的未读成就数据,如果数据未初始化,则进行初始化,如果已经初始化,则对数据进行更新。只不过写入/清除对数据的初始化和更新逻辑并不相同。因此可以将数据初始化和更新抽象为行为参数,将剩余部分提取为公共方法,基于这样的思路重构后的代码如下:



/**
 * 创建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() {}

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


总结


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


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


团队介绍


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

  1. 在淘宝IM基础上构建以用户实时意图感知、统一投放引擎为核心的全域触达体系,通过跨场景的触达方案,赋能淘系搜索、互动、用增等业务增长,每日触达亿级用户。
  2. 社交域基础平台服务,我的淘宝、淘友、互动等业务,服务上亿淘宝用户。
  3. 淘宝消息tab、千牛商家消息,通过建立平台,消费者,商家之间的链接,提升手淘DAU,助力商家更好的服务消费者,拥有亿级电商IM消息即时通讯产品,可以深入掌握分布式高可靠设计理念和架构方法论。

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

目录
相关文章
|
6月前
|
安全 Java 数据安全/隐私保护
|
6月前
|
设计模式 算法 Java
关于编程模式的总结与思考(上)
关于编程模式的总结与思考(上)
80 0
|
分布式计算 前端开发 JavaScript
程范式解析:面向对象、函数式与声明式编程
程范式解析:面向对象、函数式与声明式编程
117 0
|
27天前
|
消息中间件 监控 测试技术
事件驱动架构是一种编程范式
【10月更文挑战第7天】事件驱动架构是一种编程范式
105 65
编程问题之响应式编程使用了哪些技术
编程问题之响应式编程使用了哪些技术
|
4月前
|
自然语言处理 开发者
编程问题之函数式编程有什么优点
编程问题之函数式编程有什么优点
|
6月前
|
存储 NoSQL Java
关于编程模式的总结与思考(中)
关于编程模式的总结与思考(中)
43 1
|
缓存 Java 程序员
函数式编程的Java编码实践:利用惰性写出高性能且抽象的代码
本文会以惰性加载为例一步步介绍函数式编程中各种概念,所以读者不需要任何函数式编程的基础,只需要对 Java 8 有些许了解即可。
函数式编程的Java编码实践:利用惰性写出高性能且抽象的代码
|
Scala 开发工具 git
剖析响应式编程的本质
剖析响应式编程的本质
剖析响应式编程的本质
|
JSON 监控 安全
关于不同编程语言相互调用的思考
关于不同编程语言相互调用的思考
关于不同编程语言相互调用的思考