1.背景
时值阿里巴巴集团客服技术对外商业化。工单系统是客服产品里面非常重要的系统,之前服务于集团内部各部门的客服业务,显然基于微服务的架构是比较合适的,应用分拆比较细,开发运维更加独立、灵活、高效。继承原来这套工单系统优秀的基础技术和产品设计,为客服系统商业化进程打下了坚实的基础。但随之也带来了一些问题:微服务的复杂性对于更关注资源成本且开发人手有限的toB业务团队,运维起来比较厚重。为了节省部署资源和降低运维成本,我们在代码层面进行了一次合并,经过2年的迭代,堆积大量的需求,原本清晰的架构体系,演变成了一个架构模糊,拥有67万行代码、46个module的超级应用。
工单系统商业化过程的演变
2.面临问题(我们迭代2年之后的代码版本)
- 代码多,需求开发运维成本高,排查问题难度大。
- 编译打包的jar包文件大,加载许多无用的组件和类,引了不少启动需要预热的富客户端,特别是对一些富客户端组件的依赖。构建、部署性能低。
- 链路冗长、吃资源、单次调用各种重复查询。
- 配置太多,迁移、部署成本高。
- 顶层不同商业场景的业务逻辑,耦合在主干逻辑里面。
3.重构的价值
- 简化开发运维成本;重新设计架构、分层,开发一套极致简洁且高内聚、低耦合的代码。
- 提升开发人效(最大价值点)。大幅减少梳理源代码时间,大幅度提升部署速度(之前部署一次25-30分钟,现在3分钟之内)。
- 降低资源成本。提升查询性能,降低对DB的压力;减少一些不必要的中间件的使用(比如之前所有动作记录都放redis);减少加载的组件类,降低整体对内存的消耗。
- 提升系统稳定性。架构简单清晰,链路清爽简短,代码极简有序,这是保障稳定性的根基。
- 清晰的代码架构和层次,方便后续对扩展能力的优化(扩展能力是工单最重要的能力)。
- 以扩展点的形式解耦电商版和钉版等商业场景的代码逻辑,加强系统稳定性。
4.技术方案
1、总体架构(代码)
工单系统与电商类系统相比,它业务逻辑复杂,但业务流程不复杂,它复杂之处在于数据组装,多样的数据视图。不像电商,会有各种商品、价格、库存、营销、交易等联动行为,逻辑和流程都比较复杂。因此工单从分层设计来讲3层(service、manager、dao)就足够了。
新工单代码架构 v1.0
2、适配层(adapter)
老hsf接口和新的hsf接口映射层,无实际业务逻辑,纯粹的接口映射和出入参转换。
3、商业能力层(service)
商业能力层,是客户每一个操作直接调用到的服务,能真正产生经济效益。这个层面的代码只会调用manager模块提供的域能力接口或外部服务接口(外部服务集线器)。工单系统service层可以分为几个部分:
- MTOP接口
工作台前端页面调用,重构之后重新划分为18个细分业务场景,对应18个接口。也可以归纳成几个大场景:工单活动执行、工单配置态、工单列表查询、工单详情展示、移动工单、访客工单、工单中心配置、工单评价、touch读写。
- OpenApi接口
开放给客户调用的,通过网关发布到公网,这块服务之前不少接口没有充分收敛,这次重构全部收敛到域能力,或者直接复用mtop接口。
- 电商版工单接口
这块是电商版工单场景下新写的接口,重构之后把电商相关服务(包括定时任务)放到独立的package。
- 工单活动
这里的工单活动模块是在HSF服务层之下,将所有工单活动核心逻辑抽象到一个个xxxActicity类里面。如:CaseCreateActivity、CaseTransferActivity等等。在这里设计了一个活动模板,如何设计的?直接上代码:
public abstract class BaseActivity<P extends ActivityCtx, R extends BaseResult> { protected static final EventProducer eventProducer = ofBean("eventProducer"); protected static final EventBusProvider eventBusProvider = ofBean(EventBusProvider.class); protected static final TaskProcessor taskProcessor = ofBean(TaskProcessor.class); protected static final ActionRecordManager actionRecordManager = ofBean(ActionRecordManager.class); protected P activityCtx; protected static <T> T ofBean(Class<T> clazz) { return SpringContext.getBean(clazz); } protected static <T> T ofBean(String beanId) { return SpringContext.getBean(beanId); } public R run() { R result; try { CheckResult checkResult = checkExtensionPermission(); if (Objects.nonNull(checkResult) && BooleanUtils.isFalse(checkResult.isPermission)) { return (R)new BaseResult().setSuccess(false).setWarningMsg(checkResult.warningMsg); } checkParams(); preExecute(); ActivityExtManager.getPreExeExt( this.bizId(), this.getClass() ).preExecute(activityCtx); result = execute(); setTaskStatus2Processing(); postExecute(); ActivityExtManager.getPostExeExt( this.bizId(), this.getClass() ).postExecute(activityCtx); if (BooleanUtils.isTrue(activityCtx.getIfRecordAction())) { recordAction(); } sendEvent(); } catch (NormalInterruptException e) { return (R)new BaseResult().setSuccess(false).setWarningMsg(e.getWarningMsg()); } finally { this.activityCtx = null; } return result; } protected abstract String bizId(); protected abstract void checkParams(); protected abstract void preExecute(); protected abstract R execute(); protected abstract void postExecute(); protected abstract void recordAction(); protected abstract void sendEvent(); public static <A extends BaseActivity<P, R>, P extends ActivityCtx, R extends BaseResult> A of(Class<A> clazz, P baseParam) { A instance; try { instance = clazz.newInstance(); } catch (InstantiationException | IllegalAccessException e) { throw new RuntimeException(e); } instance.activityCtx = baseParam; return instance; } private CheckResult checkExtensionPermission() { return null; } private static class CheckResult { public Boolean isPermission; public String warningMsg; } private void setTaskStatus2Processing() { TaskStatusToProcessing processing = this.getClass().getAnnotation(TaskStatusToProcessing.class); if (processing == null) { return; } TppTaskIdDO tppTaskIdDO = activityCtx.getTaskDO(); tppTaskIdDO.setTaskStatus(PROCESSING.getCode()); taskProcessor.doSetProcessing(tppTaskIdDO.getId(), activityCtx.getUserParam().getUserId()); } }
@Datapublic class ActivityCtx<Ctx extends ActivityCtx> { private UserParam userParam; private Map<String, Object> ext; private String reqSource; //是否记录动作记录 private Boolean ifRecordAction = Boolean.TRUE; private final Map<Class<?>, BaseDO> clazz2Domain = new ConcurrentHashMap<>(); public <T extends BaseDO> T getEntity(Class<T> tClass) { T t = (T)clazz2Domain.get(tClass); if (t == null) { throw new RuntimeException(String.format("cannot find entity %s", tClass.getSimpleName())); } return t; } public Ctx addEntity(BaseDO domain) { if (domain == null) { throw new RuntimeException("domain cannot be null"); } clazz2Domain.put(domain.getClass(), domain); return (Ctx)this; } public Ctx addEntities(BaseDO... domains) { for (BaseDO domain : domains) { if (domain == null) { throw new RuntimeException("domain cannot be null"); } clazz2Domain.put(domain.getClass(), domain); } return (Ctx)this; } public OspTppCaseDO getCaseDO() { OspTppCaseDO caseDO = (OspTppCaseDO)clazz2Domain.get(OspTppCaseDO.class); return caseDO; } public TppTaskIdDO getTaskDO() { if (clazz2Domain.containsKey(TppTaskIdDO.class)) { return (TppTaskIdDO)clazz2Domain.get(TppTaskIdDO.class); } if (clazz2Domain.containsKey(TppTaskCaseDO.class)) { return (TppTaskCaseDO)clazz2Domain.get(TppTaskCaseDO.class); } return null; } }
public class CaseCreateActivity extends BaseActivity<CaseCreateCtx, Table2<OspTppCaseDO, TppTaskIdDO>> { private static final CaseInstanceManager caseInstanceManager = ofBean(CaseInstanceManager.class); private static final StateMachineManager stateMachineManager = ofBean(StateMachineManager.class); private static final TaskInstanceManager taskInstanceManager = ofBean(TaskInstanceManager.class); private static final CaseTemplateManager caseTemplateManager = ofBean(CaseTemplateManager.class); private static final AccountServiceHub accountServiceHub = ofBean(AccountServiceHub.class); private static final BizKeyUtils bizKeyUtils = ofBean(BizKeyUtils.class); @Override protected String bizId() { return bizKeyUtils.buildBizKey(activityCtx.getUserParam().getBuId()); } @Override protected void checkParams() { Preconditions.checkArgument(activityCtx.getCaseParam() != null); Preconditions.checkArgument(activityCtx.getUserParam() != null); Preconditions.checkArgument(activityCtx.getEntity(TppCaseTypeDO.class) != null); Preconditions.checkArgument(activityCtx.getEntity(StateMachineDO.class) != null); Preconditions.checkArgument(activityCtx.getEntity(SrTypeDO.class) != null); Preconditions.checkArgument(activityCtx.getActionParam() != null); } @Override protected void preExecute() { } @Override protected Table2<OspTppCaseDO, TppTaskIdDO> execute() { OspTppCaseDO ospCaseDO = buildCaseDO(activityCtx.getUserParam(), activityCtx.getCaseParam(), activityCtx.getEntity(TppCaseTypeDO.class), activityCtx.getEntity(StateMachineDO.class)); caseInstanceManager.saveCase(ospCaseDO); activityCtx.addEntity(ospCaseDO); TppTaskIdDO taskIdDO = buildTaskDO(activityCtx.getUserParam(), ospCaseDO.getId(), activityCtx.getTaskParam(), activityCtx.getEntity(TppCaseTypeDO.class)); taskInstanceManager.saveTask(taskIdDO); activityCtx.addEntity(taskIdDO); return new Table2<OspTppCaseDO, TppTaskIdDO>().setT1(ospCaseDO).setT2(taskIdDO); } @Override protected void postExecute() { } @Override protected void recordAction() { if (activityCtx.getCaseParam().isFinish()) { activityCtx.getActionParam().setActionCode(ActionCodeEnum.CASE_FINISH.getCode()); } else { activityCtx.getActionParam().setActionCode(ActionCodeEnum.CASE_CREATE.getCode()); } com.xixikf.caze.utils.ActionMemoBuilder memoBuilder = activityCtx.getActionParam().getActionMemoBuilder(); memoBuilder.addOperatorNick(activityCtx.getUserParam().getUserName()); memoBuilder.addAcceptorNick(activityCtx.getUserParam().getMemberName()); JSONObject obj = new JSONObject(); obj.put("commonQueueId", activityCtx.getTaskParam().getCommonQueueId()); obj.put("sopCateId", activityCtx.getCaseParam().getSopCateId()); obj.put("srType", activityCtx.getCaseParam().getSrType()); memoBuilder.put("$custom", obj); memoBuilder.addActionKeyMemo( ActionMemoExposer.buildActionKeyMemo(activityCtx.getEntity(SrTypeDO.class).getFormCode(), activityCtx.getCaseParam().getFormData(), emptyIfNull(activityCtx.getCaseParam().getFormBizData())) ); LtppActionDO actionDO = actionRecordManager.saveAction(activityCtx.getActionParam().getActionCode(), memoBuilder.toJSONString(), activityCtx.getEntity(OspTppCaseDO.class), activityCtx.getEntity(TppTaskIdDO.class), activityCtx.getUserParam().getUserId()); activityCtx.addEntity(actionDO); } @Override protected void sendEvent() { OspTppCaseDO ospTppCaseDO = activityCtx.getEntity(OspTppCaseDO.class); SrTypeDO srTypeDO = activityCtx.getEntity(SrTypeDO.class); /* * 工单事件消息 */ CaseCreateEvent caseCreateEvent = new CaseCreateEvent(ospTppCaseDO, srTypeDO, activityCtx.getUserParam()); eventProducer.sendEvent(caseCreateEvent); eventBusProvider.sendEvent(caseCreateEvent); List<EventDO> eventDOList = JSONArray.parseArray(activityCtx.getEntity(StateMachineDO.class).getEventSchema(), EventDO.class); Optional<EventDO> optional = eventDOList.stream() .filter(eventDO -> eventDO.getEventStateType() == EventDO.caseEventType.START.code()).findFirst(); CaseStateInitEvent caseStateInitEvent = new CaseStateInitEvent(ospTppCaseDO, srTypeDO, optional.orElseThrow(() -> new RuntimeException("status[START] not found")), activityCtx.getUserParam()); eventBusProvider.sendEvent(caseStateInitEvent); /* * 任务事件消息 */ TaskCreateEvent taskCreateEvent = new TaskCreateEvent(activityCtx.getEntity(TppTaskIdDO.class), ospTppCaseDO, null); eventProducer.sendEvent(taskCreateEvent); eventBusProvider.sendEvent(taskCreateEvent); /* * 动作记录消息 */ ActionCreateEvent actionCreateEvent = new ActionCreateEvent(activityCtx.getEntity(LtppActionDO.class)); eventBusProvider.sendEvent(actionCreateEvent); //创建子工单消息 //todo for 自动外呼 if (!Arrays.asList(226410, 226411, 226412).contains(activityCtx.getCaseParam().getCaseType())) { if (activityCtx.getCaseParam().getParentCaseId() != null && activityCtx.getCaseParam().getParentCaseId() > 0) { OspTppCaseDO parentCaseDO = caseInstanceManager.loadCaseDO(activityCtx.getCaseParam().getParentCaseId()); SrTypeDO parentCaseTemplateDO = caseTemplateManager.getCaseTemplate(parentCaseDO.getSrType()); CaseChildCreateEvent caseChildCreateEvent = new CaseChildCreateEvent(parentCaseDO, parentCaseTemplateDO, activityCtx.getUserParam()); eventBusProvider.sendEvent(caseChildCreateEvent); } } //发送“创建工单”活动被执行消息 if (StringUtils.isNotBlank(activityCtx.getCaseParam().getChannelTouchId()) && StringUtils.isNumeric(activityCtx.getCaseParam().getChannelTouchId())) { OspTppCaseDO caseDO = caseInstanceManager.loadCaseDO(Long.parseLong(activityCtx.getCaseParam().getChannelTouchId())); SrTypeDO caseTemplate = caseTemplateManager.getCaseTemplate(caseDO.getSrType()); CaseCreate4ChannelEvent caseCreate4ChannelEvent = new CaseCreate4ChannelEvent(caseDO, caseTemplate, activityCtx.getUserParam()); eventBusProvider.sendEvent(caseCreate4ChannelEvent); } } }
Table2<OspTppCaseDO, TppTaskIdDO> createResult = BaseActivity.of(CaseCreateActivity.class, new CaseCreateCtx(userParam, caseParam, taskParam, actionParam, extParam).addEntities(srTypeDO, caseTypeDO, stateMachineDO) ).run();
- 扩展点实现
扩展点的设计是为了在主干代码中,解耦电商版和钉版工单这些不同业务场景的逻辑,提高系统的稳定性和可扩展性。考虑到扩展点的实现逻辑性质上是最顶层、最具体的业务逻辑,所以把扩展点的实现也放到了商业能力层,代码组织和扩展点实现示例如下:先看一下具体怎么用的:接下来介绍下扩展点框架怎么设计的,啥也别说,code first:
public interface MonoExtPoint<P, R> { R execExt(P param); }public interface BiExtPoint<P1, P2, R> { R execExt(P1 param1, P2 param2); }public interface TriExtPoint<P1, P2, P3, R> { R execExt(P1 param1, P2 param2, P3 param3); }
@Component@Slf4jpublic class CommonExtManager { private static final Map<String, MonoExtPoint> monoExtPoints = new ConcurrentHashMap<>(); private static final Map<String, BiExtPoint> biExtPoints = new ConcurrentHashMap<>(); private static final Map<String, TriExtPoint> triExtPoints = new ConcurrentHashMap<>(); @Autowired public void setMonoExtPoints(Collection<MonoExtPoint> monoExtList) { for (MonoExtPoint monoExt : emptyIfNull(monoExtList)) { CommRouter router = monoExt.getClass().getAnnotation(CommRouter.class); if (Objects.isNull(router)) { throw new RuntimeException("router annotation cannot be null"); } if (Strings.isBlank(router.key())) { throw new RuntimeException("router key cannot be null"); } String key = buildRouterKey(router.key(), router.scene()); if (monoExtPoints.containsKey(key)) { throw new RuntimeException(String.format("key[%s] is duplicate", router.key())); } monoExtPoints.put(key, monoExt); log.warn("MonoExtPoint:{}", key); } } @Autowired public void setBiExtPoints(Collection<BiExtPoint> biExtList) { for (BiExtPoint biExt : emptyIfNull(biExtList)) { CommRouter router = biExt.getClass().getAnnotation(CommRouter.class); if (Objects.isNull(router)) { throw new RuntimeException("router annotation cannot be null"); } if (Strings.isBlank(router.key())) { throw new RuntimeException("router key cannot be null"); } String key = buildRouterKey(router.key(), router.scene()); if (biExtPoints.containsKey(key)) { throw new RuntimeException(String.format("key[%s] is duplicate", router.key())); } biExtPoints.put(key, biExt); log.warn("BiExtPoint:{}", key); } } @Autowired public void setTriExtPoints(Collection<TriExtPoint> triExtList) { for (TriExtPoint triExt : emptyIfNull(triExtList)) { CommRouter router = triExt.getClass().getAnnotation(CommRouter.class); if (Objects.isNull(router)) { throw new RuntimeException("router annotation cannot be null"); } if (Strings.isBlank(router.key())) { throw new RuntimeException("router key cannot be null"); } String key = buildRouterKey(router.key(), router.scene()); if (triExtPoints.containsKey(key)) { throw new RuntimeException(String.format("key[%s] is duplicate", router.key())); } triExtPoints.put(key, triExt); log.warn("TriExtPoint:{}", key); } } public static <P, R> MonoExtPoint<P, R> getMonoExtPoint(String key, RouterScene scene, MonoExtPoint<P, R> defaultFuc) { if (Strings.isBlank(key) || Objects.isNull(scene)) { return defaultFuc; } String _key = buildRouterKey(key, scene); return (param) -> { try { MonoExtPoint<P, R> monoExtPoint = monoExtPoints.get(_key); if (monoExtPoint != null) { return monoExtPoint.execExt(param); } else { log.warn(String.format("key:[%s] not find implement", _key)); } } catch (Throwable e) { log.error(String.format("key:[%s] execute error", _key), e); return null; } return defaultFuc.execExt(param); }; } public static <P1, P2, R> BiExtPoint<P1, P2, R> getBiExtPoint(String key, RouterScene scene, BiExtPoint<P1, P2, R> defaultFuc) { if (Strings.isBlank(key) || Objects.isNull(scene)) { return defaultFuc; } String _key = buildRouterKey(key, scene); return (param1, param2) -> { try { BiExtPoint<P1, P2, R> biExtPoint = biExtPoints.get(_key); if (biExtPoint != null) { return biExtPoint.execExt(param1, param2); } else { log.warn(String.format("key:[%s] not find implement", _key)); } } catch (Throwable e) { log.error(String.format("key:[%s] execute error", _key), e); return null; } return defaultFuc.execExt(param1, param2); }; } public static <P1, P2, P3, R> TriExtPoint<P1, P2, P3, R> getTriExtPoint(String key, RouterScene scene, TriExtPoint<P1, P2, P3, R> defaultFunc) { if (Strings.isBlank(key) || Objects.isNull(scene)) { return defaultFunc; } String _key = buildRouterKey(key, scene); return (param1, param2, param3) -> { try { TriExtPoint<P1, P2, P3, R> triExtPoint = triExtPoints.get(_key); if (triExtPoint != null) { return triExtPoint.execExt(param1, param2, param3); } else { log.warn(String.format("key:[%s] not find implement", _key)); } } catch (Throwable e) { log.error(String.format("key:[%s] execute error", _key), e); return null; } return defaultFunc.execExt(param1, param2, param3); }; } private static String buildRouterKey(String key, RouterScene scene) { return String.format("%s_%s", scene.name(), key); } }
public enum RouterScene { CaseCard("touch工单卡片展示"), CaseCreate("工单创建"), DisplayColumn("工单列表展示字段"), SearchResult("工单列表返回结果"), BasicInfoView("工单基本信息卡片"), ResultConvert("列表查询结果转换"), AutoTaskCondition("自动任务条件"), DefaultActivities("默认活动列表"), RoleAndAuth("角色列表权限"), TransferRelations("工单转交关系"), ConfigRule("自动任务配置规则"), Protocol("协议"), ViewConf("视图配置"), CaseTypeList("工单类型列表"); @Getter private final String desc; RouterScene(String desc) { this.desc = desc; }}
- 工具类
工具类没啥好说的,都是这些年个人总结的好用工具方法,多用工具类,无论是自己写的还是其他三方的如guava。工具类最大的好处是使代码更简洁,增强代码的可读性。不然一堆与核心业务逻辑无关的判断、校验、转换等逻辑充斥在代码里,使得代码重点不突出,影响开发者理解代码的效率。
4、域能力层(manager)
域能力层是将工单这个大的业务域拆分成若干子域,每个子域所具备的核心能力(从另一个视角来看它应该是商业能力层会多次复用的能力,具备一定通用性特征),用于支持和实现其业务目标也就是商业能力。工单系统manager层可以分为几个部分:
- 业务域能力
本次重构是将工单域拆分为30个子业务域,拆分规则基本上是基于产品模型和实体模型综合考量。一般只考虑产品模型可能会拆的太粗,只考虑实体模型可能会拆的太细,所以先取两者之间的一个平衡,然后在开发过程中根据细节反馈调整。域能力的拆分是没有固定标准的,全看个人理解和经验。在本文中30个子域具体具备的能力就不一一展开了,有兴趣了解的可以去看看代码,全是细节。
- 数据同步
数据同步是整个工单系统中处理数据的异步链路,他虽然没有被商业能力层直接同步调用,但是它异步加工处理的数据,也会直接作用到商业能力层,因此无可厚非,这部分能力可以归到域能力层。这4条消费链路也是重新设计了流程和代码,下面看个核心case和task数据同步ES的链路流程设计:
- 外部服务集线器
这块是将工单依赖的外部服务按照提供者应用名称(一个应用对应一个类)对外部服务方法进行了收敛,封装了一层,入参基本不变,出参去掉了Result包装。这样做的目的:其一,可以统一监控外部服务的状况(当前是打印了简要的出入参日志);其二,方便后续工作中各类梳理评估,比如后面纪元替换xform。一条请求链路我们最关心的几个节点:网络请求、DB访问,一般链路追踪重点监控的主要也是远程服务和DB访问,因此监控好外部服务的调用情况,对于日常运维是非常有帮助的。
- 基础工具
支撑整个manager层的运转抽象出来的脱离具体业务场景的通用能力,包括:搜索参数构建器、消息生产/消费模板、缓存工具类、通用工具类。
- 状态机流转引擎
- 流转:这块原来是一个模块来承载它的代码,经过分析发现里面有很多流程是我们不需要的,状态机流转引擎主要干了三件事:持久化变化后的新状态、发送流程结束消息、发送活动事件消息。以此反推重写了状态机流转引擎代码,精简后的代码放在一个package里面,可以看到代码并不多。
- 发布:工单状态机整体是用一个大json去存储的,里面涉及多个实体对象的结构,然后实体对象转换成特定的schema存储,原来这块逻辑是写在一个大方法里面,逻辑主线不清晰,基本是看不懂。重构后的主线逻辑就很清晰了。
5、数据访问层(dao)
这就比较简单了,就是提供各个实体的mapper方法。原来应用里面是淘宝比较老版本的自己写的一套ORM框架,用起来是非常费劲,一次查询下来无论是看编译时还是运行时连访问的哪个表都不知道。这次是整体换成了mybatis,并且对其能力进行了增强,这里可以着重介绍下增强的逻辑。
@Table("ltpp_action")public interface LtppActionMapper extends BaseMapper<LtppActionDO> { //自己通过xml写的sql Long countBizId(Map<String, Object> where); //自己通过xml写的sql Long countByCondition(Map<String, Object> where); //自己通过xml写的sql List<LtppActionDO> queryByCondition(Map<String, Object> where); }
6、重新设计自动任务
自动任务是工单系统里面比较独立的模块,本质上就是消费工单产生的事件消息,通过校验,执行动作。因此它是一个典型的监听者模式。
自动任务新流程设计
重构前后的变化:
- 原来代码几乎都是if else来串联逻辑,类和函数的拆分比较随意,导致调用堆栈十分冗长,如洋葱一般剥了一层又一层。
- 新代码设计了几个Filter,事件过滤逻辑主线清晰,代码精简,可读性好。
- 老代码扫表分发queue任务,是自己开了多线程在里面写循环,逻辑显得比较凌乱,不好维护。
- 分布式扫表的逻辑,原来代码是基于配置做的,通过配置去指定哪台机器扫哪个租户的queue表,默认就是一台机器扫所有queue表。重构后是用queue表的id对机器数取模,来实现不同的机器分布式扫表。
- 对handler的路由处理,原来是if else,现在是通过Map来自动路由。
- 链路用queue解耦之后,traceId会变化,不利于排查问题,本次重构将traceId存储在queue表扩展字段里,扫表的时候会取出来覆盖,这样就保持了一个traceId串联整条链路。
- package的组织层次不清晰,顶层平摊了太多内部才会感知的代码,淹没了主干逻辑。比如handler里面才会去感知的配置参数解析,上浮到了最顶层package里面,作为独立的层出现在了主干逻辑里面。干扰读者的注意力。实际上主干框架逻辑是不应该去感知各个handler里面具体业务场景。这样子我们在排查问题的时候,由于框架和具体业务纠结在一起,造成链路冗长,开发的注意力就会被分散,没法迅速定位到有效的代码逻辑。
以上只是对比了几个比较明显方便展示出来的点,重构之后每一行代码都不一样,主要还是更加精简了,主线更清晰。自动任务这种异步链路,排查问题的效率非常重要,所以需要代码写的比较清晰简洁易于维护和理解。
5.综合效果分析
1、代码缩减的原因
(1)域能力和工具类的高度内聚,不存在同样的能力有多套代码实现。
(2)删除了所有无用的开关逻辑、特殊业务场景、参数检验、灰度逻辑、无效封装、异常捕获、无效日志代码,特别是Result的封装。
(3)重写了大量弯弯绕绕的业务逻辑。
(4)去掉原来自己开发的DB访问框架,统一为mybatis,并增加增强逻辑。2、fatjar包大小缩减的原因
(1)去除了无用的pom依赖,全删了,然后一个个根据需要加。
(2)对比较重的pom依赖,打印出mvn tree,一个个把间接依赖排掉,然后测试看看影响,这一步比较耗时。3、部署&启动速度提升的原因。
(1)fatjar包大小缩减和pom依赖的减少,会大幅减少jar包下载、上传的时间。
(2)去除了对codePlatform富客户端的依赖,这个客户端会在启动的时候去加载各种数据完成初始化,用jstack命令观察这个点耗时5min中左右,最高到7、8分钟,偶尔会超时导致启动失败。
(3)去掉了很多不需要注册spring的组件。4、nacos配置缩减的原因。
(1)去掉了没用的配置,全删了一个个根据需求加回来。
(2)有些万年不变的配置可以回归到代码里面。
(3)一些控制前端渲染结构的配置(一般是大json),这种其实本质就是代码,基本不会改动,在工程Resource目录里面以json文件的形式保存。
6.上线方案
1、所有消息的topic切换成新的,避免新旧应用消息串扰带来不可控的问题。
2、通过流量统计工具统计的有流量的服务接口,查漏补缺。
4、灰度环境beta部分pod(比如搞5个pod,其中1个pod部署新应用,新应用的流量大约就是1/5),并逐步调整比例,直到全覆盖。
5、生产环境beta部分pod,并逐步调整比例,直到全覆盖。
6、所有异常日志推送钉钉群,实时感知,实时解。
7、随时关注数据库性能监控,关注慢sql,关注监控告警,特别是核心接口的失败率。
8、灰度过程中如发现异常情况随时准备kill掉新应用pod。
9、对于一些应用场景复杂、测试用例无法全面覆盖的接口服务(比如定制逻辑调的服务),如何最大程度减少上线故障的发生,我们的思路是在新代码和老代码同时部署在生产环境灰度发布的时候:
- 当请求流量打到新代码,如果是调的查询接口,新代码会同时去调一下老代码的对应接口。对比新老接口的返回数据,如果不一样,就返回老接口数据,并告警。
- 当请求流量打到新代码,如果是调的写接口,新代码如果异常了(流程中断),就去调老代码对应接口,并告警。
这样可以保证在新代码里面执行的逻辑最大程度不会出问题,告警出来可以及时发现问题。为此开发了一个切面框架,不用侵入业务代码。操作也不复杂,打一个注解就行了(如下图)。后续其他应用重构上线也可以复用这个工具。
- 读接口
- 写接口
7.对代码质量的思考
“梳理”是我们日常工作最消耗时间的动作,需求开发、私有化部署、技术改造......第一步都是要梳理应用代码,费时且容易遗漏。所谓梳理2小时,写码5分钟。清晰简洁的代码梳理成本是极低的。摆脱沉重的梳理只有提升代码架构和质量,科学技术是第一生产力。如果把整个公司的协同看做一条依赖链路的话,开发和测试是基础服务,他的RT(耗时)会扩散到各个链路节点。现在市场,公司之间的竞争已经到了深水区,基本上你能做的别人也能做,不太可能再有一方拥有压倒性的优势(特别是toB,比如说不太可能出现销售能力很强,反正能拉到单子,光靠这个就建立了势不可挡的壁垒),一定就是在长时间的你追我赶过程中,看谁能在各方面多做好一点点,效率高一点点,在持续的时间累计下积累优势。从这些来看,好的架构和代码质量时间复利是比较大的。
1、业务形态的区别,注定了toC的代码直接用于toB业务是不合适的
- toC的业务系统:完全中心化的,面向不太确定的零散需求堆积(试错),追求快速迭代功能,抢占市场,追求业务爆发力,可以牺牲资源成本和代码质量,积累大量技术债,巨大的历史包袱。
- toB的业务系统:比较确定的功能需求,高度产品化、一键化、轻量、扩展能力强、扩展成本低,对人效成本和资源成本非常敏感。追求极致的边际成本,不然就是外包业务。
2、对开发的意义:摆脱烟冲式开发(烟冲式就是每次都要从头来一遍,每次都要关注全局,梳理全局,如果扩展点设计的好那就可以只关注扩展点,如果分层很合理,那就只关注对应的层面的问题,不用牵一发动全身)。像平时工作过程中,要求沉淀文档,及时总结,都是摆脱烟冲式的工作的思路,不用每次问题来了,要重新回忆,重新思考,重新到处问。3、总结几个标准:
- 不要很多if else,判断、校验、打日志等无关核心业务的逻辑代码,这些代码多了会让你的代码核心逻辑不突出,可读性自然差,看了100行代码,发现最后一行才是有效逻辑,很难受 。不要太多try catch,有异常鼓励抛出来(system error真的没啥用),有问题就得及时暴露,而不是隐藏,这个和项目管理一样的道理。
- 好的业务代码大部分应该是线性的逻辑,上面的输出就是下面的输入,而不是上下左右各种网状关联,对人脑不友好,这种代码最容易出问题(大部分业务代码可以做到,底层算法为了极致性能,确实会有较多的复杂的网状逻辑)。
- 代码好不好,看缩进齐不齐,缩进大开大合就说明代码充斥各种嵌套的if else之类。
- 好的代码设计和好的产品设计是一样的道理,本质上好坏就在于是否规划一个对人脑友好的思维导图。合理的工程组织结构,简洁清爽的代码(核心逻辑重点突出,类拆分、方法拆分合理)。每一个节点,都只能看到和他直接相关的下一级的最小范围。不要把下一级的逻辑上浮,也不要把本该属于这一级的逻辑下沉。那种一眼望去大量无序信息的代码或者产品,都是不太好的设计。
- 抽象轻量灵活的框架(以简化问题为导向,学习成本要低,框架出问题易排查)。
- 极致的收敛(高内聚):商业能力的收敛、工具类的收敛、域能力的收敛。
- 链路层次不要太复杂(to数据密集型应用)。
4、重构的核心不是一次性的代码改造,更为重要的是它所传达的写精致代码的理念,要形成一个对每一行代码讲究的,写到让自己满意的意识和氛围。不然开发的人多了,马上打回原形。...... 今天又想了想,感觉在紧密的业务开发节奏下,每个人的意识和水平不一样,做到这些不太现实,还是得靠个人的强行干预,你们说呢?5、不要认为一些小的问题不重要,大的点注意就行。比如命名很随意,无用的代码不删除,代码不格式化,风格规范不统一,idea的黄色提示不关注。。真实的规律可能是讲究的都讲究,不讲究的都不讲究。6、最后一句话:如果你发现一个问题的解决方案搞的很复杂,那一定是方案有问题,写代码也是一样,简单才是王道。“简单点,代码的方式简单点”。
8.未来规划
工单是扩展定制需求最旺盛的系统,目前工单已有的扩展方式比较重,不够灵活轻量,未来会基于重构后的工单,对扩展能力做一次升级,主要往低代码、灵活、丰富和加强配套工具的方向去思考。最大程度提升定开效率,降低边际成本。