一场67万行代码的应用重构
阿里妹导读
一、背景
工单系统商业化过程的演变
二、面临问题(我们迭代2年之后的代码版本)
代码多,需求开发运维成本高,排查问题难度大。
编译打包的jar包文件大,加载许多无用的组件和类,引了不少启动需要预热的富客户端,特别是对一些富客户端组件的依赖。构建、部署性能低。
链路冗长、吃资源、单次调用各种重复查询。
配置太多,迁移、部署成本高。
顶层不同商业场景的业务逻辑,耦合在主干逻辑里面。
三、重构的价值
简化开发运维成本;重新设计架构、分层,开发一套极致简洁且高内聚、低耦合的代码。
提升开发人效(最大价值点)。大幅减少梳理源代码时间,大幅度提升部署速度(之前部署一次25-30分钟,现在3分钟之内)。
降低资源成本。提升查询性能,降低对DB的压力;减少一些不必要的中间件的使用(比如之前所有动作记录都放redis);减少加载的组件类,降低整体对内存的消耗。
提升系统稳定性。架构简单清晰,链路清爽简短,代码极简有序,这是保障稳定性的根基。
清晰的代码架构和层次,方便后续对扩展能力的优化(扩展能力是工单最重要的能力)。
以扩展点的形式解耦电商版和钉版等商业场景的代码逻辑,加强系统稳定性。
四、技术方案
1、总体架构(代码)
新工单代码架构 v1.0
2、适配层(adapter)
3、商业能力层(service)
MTOP接口
OpenApi接口
电商版工单接口
工单活动
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());
}
}
public 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);
protected String bizId() {
return bizKeyUtils.buildBizKey(activityCtx.getUserParam().getBuId());
}
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);
}
protected void preExecute() {
}
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);
}
protected void postExecute() {
}
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);
}
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();
扩展点实现
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);
}
public 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<>();
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);
}
}
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);
}
}
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("工单类型列表");
private final String desc;
RouterScene(String desc) {
this.desc = desc;
}
}
工具类
4、域能力层(manager)
业务域能力
数据同步
外部服务集线器
基础工具
状态机流转引擎
流转:这块原来是一个模块来承载它的代码,经过分析发现里面有很多流程是我们不需要的,状态机流转引擎主要干了三件事:持久化变化后的新状态、发送流程结束消息、发送活动事件消息。以此反推重写了状态机流转引擎代码,精简后的代码放在一个package里面,可以看到代码并不多。
发布:工单状态机整体是用一个大json去存储的,里面涉及多个实体对象的结构,然后实体对象转换成特定的schema存储,原来这块逻辑是写在一个大方法里面,逻辑主线不清晰,基本是看不懂。重构后的主线逻辑就很清晰了。
5、数据访问层(dao)
"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<ActionKeyEnum, BaseActionHandler>来自动路由。
链路用queue解耦之后,traceId会变化,不利于排查问题,本次重构将traceId存储在queue表扩展字段里,扫表的时候会取出来覆盖,这样就保持了一个traceId串联整条链路。
package的组织层次不清晰,顶层平摊了太多内部才会感知的代码,淹没了主干逻辑。比如handler里面才会去感知的配置参数解析,上浮到了最顶层package里面,作为独立的层出现在了主干逻辑里面。干扰读者的注意力。实际上主干框架逻辑是不应该去感知各个handler里面具体业务场景。这样子我们在排查问题的时候,由于框架和具体业务纠结在一起,造成链路冗长,开发的注意力就会被分散,没法迅速定位到有效的代码逻辑。
五、综合效果分析
(1)域能力和工具类的高度内聚,不存在同样的能力有多套代码实现。
(2)删除了所有无用的开关逻辑、特殊业务场景、参数检验、灰度逻辑、无效封装、异常捕获、无效日志代码,特别是Result的封装。
(3)重写了大量弯弯绕绕的业务逻辑。
(1)去除了无用的pom依赖,全删了,然后一个个根据需要加。
(1)fatjar包大小缩减和pom依赖的减少,会大幅减少jar包下载、上传的时间。
(2)去除了对codePlatform富客户端的依赖,这个客户端会在启动的时候去加载各种数据完成初始化,用jstack命令观察这个点耗时5min中左右,最高到7、8分钟,偶尔会超时导致启动失败。
(1)去掉了没用的配置,全删了一个个根据需求加回来。
(2)有些万年不变的配置可以回归到代码里面。
六、上线方案(简单说明)
1、所有消息的topic切换成新的,避免新旧应用消息串扰带来不可控的问题。
2、通过流量统计工具统计的有流量的服务接口,查漏补缺。
4、灰度环境beta部分pod(比如搞5个pod,其中1个pod部署新应用,新应用的流量大约就是1/5),并逐步调整比例,直到全覆盖。
5、生产环境beta部分pod,并逐步调整比例,直到全覆盖。
9、对于一些应用场景复杂、测试用例无法全面覆盖的接口服务(比如定制逻辑调的服务),如何最大程度减少上线故障的发生,我们的思路是在新代码和老代码同时部署在生产环境灰度发布的时候:
当请求流量打到新代码,如果是调的查询接口,新代码会同时去调一下老代码的对应接口。对比新老接口的返回数据,如果不一样,就返回老接口数据,并告警。
当请求流量打到新代码,如果是调的写接口,新代码如果异常了(流程中断),就去调老代码对应接口,并告警。
读接口
写接口
七、对代码质量的思考
toC的业务系统:完全中心化的,面向不太确定的零散需求堆积(试错),追求快速迭代功能,抢占市场,追求业务爆发力,可以牺牲资源成本和代码质量,积累大量技术债,巨大的历史包袱。
toB的业务系统:比较确定的功能需求,高度产品化、一键化、轻量、扩展能力强、扩展成本低,对人效成本和资源成本非常敏感。追求极致的边际成本,不然就是外包业务。
不要很多if else,判断、校验、打日志等无关核心业务的逻辑代码,这些代码多了会让你的代码核心逻辑不突出,可读性自然差,看了100行代码,发现最后一行才是有效逻辑,很难受 。不要太多try catch,有异常鼓励抛出来(system error真的没啥用),有问题就得及时暴露,而不是隐藏,这个和项目管理一样的道理。
好的业务代码大部分应该是线性的逻辑,上面的输出就是下面的输入,而不是上下左右各种网状关联,对人脑不友好,这种代码最容易出问题(大部分业务代码可以做到,底层算法为了极致性能,确实会有较多的复杂的网状逻辑)。
代码好不好,看缩进齐不齐,缩进大开大合就说明代码充斥各种嵌套的if else之类。
好的代码设计和好的产品设计是一样的道理,本质上好坏就在于是否规划一个对人脑友好的思维导图。合理的工程组织结构,简洁清爽的代码(核心逻辑重点突出,类拆分、方法拆分合理)。每一个节点,都只能看到和他直接相关的下一级的最小范围。不要把下一级的逻辑上浮,也不要把本该属于这一级的逻辑下沉。那种一眼望去大量无序信息的代码或者产品,都是不太好的设计。
抽象轻量灵活的框架(以简化问题为导向,学习成本要低,框架出问题易排查)。
极致的收敛(高内聚):商业能力的收敛、工具类的收敛、域能力的收敛。
链路层次不要太复杂(to数据密集型应用)。
八、未来规划
欢迎加入【阿里云开发者公众号】读者群
微信扫码关注该文公众号作者