关于编程模式的总结与思考(中):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层的调用抽象为行为参数。但在上述场景下,这种做法还是存在一些弊端:
- 每个服务的方法还是需要显式调用工具类方法
- 为了保证监控信息的齐全,还需要在参数里手动透传一些监控相关的信息
而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的高并发流量,可以与淘系各位技术大牛合作,思想激荡碰撞,共同提升,包含以下方向:
- 在淘宝IM基础上构建以用户实时意图感知、统一投放引擎为核心的全域触达体系,通过跨场景的触达方案,赋能淘系搜索、互动、用增等业务增长,每日触达亿级用户。
- 社交域基础平台服务,我的淘宝、淘友、互动等业务,服务上亿淘宝用户。
- 淘宝消息tab、千牛商家消息,通过建立平台,消费者,商家之间的链接,提升手淘DAU,助力商家更好的服务消费者,拥有亿级电商IM消息即时通讯产品,可以深入掌握分布式高可靠设计理念和架构方法论。
招聘持续火热🔥进行中,如果有兴趣可将简历发至lingye.jly@taobao.com,期待您的加入!