1.前言
1.1 目的
- 為開發測試提供指導性檔案
- 為系統今後的擴充套件提供參考
- 解決系統中訊息不可達問題
1.2 範圍和功能
1.3 適用讀者
- 需要傳送MQ分散式系統的開發人員和測試人員
- 可靠訊息服務的開發人員和測試人員
1.4 讀者須知
本服務需要提供一個sdk和資料庫初始語句建立資料庫表,並且對外提供可掃描的domain、mapper、service,使用的技術框架zk + mapper3 + pagehelper + feign(edas) , 使用者(上游系統、下游系統) 只需要在對應的介面上寫上響應註解即可實現可靠訊息, 如果不熟悉上述框架,可選擇對應框架替換,比如redis替換zk,放棄mapper3和pagehelper使用傳統的mybatis,使用http介面替換fein(eads)的解決辦法,本文不提供替換的解決方案
1.5 參考文件
https://segmentfault.com/a/1190000011479826
複製程式碼
2 系統概述
本文為分散式系統解決方案,此方案涉及 3 個模組:
- 上游應用,執行業務併傳送指令給可靠訊息服務並保留訊息副本。
- 可靠訊息服務和 MQ訊息元件,協調上下游訊息的傳遞,並確保上下游資料的一致性。
- 下游應用,監聽 MQ 的訊息並執行自身業務並保留訊息副本。
2.1業務流程圖
2.2資料庫表設計
2.2.1 可靠訊息表
2.2.2 消費者確認表
2.2.3 消費者表
2.2.4 生產者表
2.2.5 釋出關係表
2.2.6 訊息重發記錄表
暫時未設計
2.2.7 訊息訂閱關係表
2.2.8 訊息訂閱TAG關係表
2.2.9 各個子系統訊息落地的訊息表
3 詳細設計
3.1 上游應用執行業務併傳送 MQ 訊息
上游應用將本地業務執行和訊息傳送繫結在同一個本地事務中,保證要麼本地操作成功併傳送 MQ 訊息,要麼兩步操作都失敗並回滾。這裡可以採用自定義切面完成,後續會有介紹。
- 上游應用傳送待確認訊息到可靠訊息系統。(本地訊息落地)
- 可靠訊息系統儲存待確認訊息並返回。
- 上游應用執行本地業務。
- 上游應用通知可靠訊息系統確認業務已執行併傳送訊息。
- 可靠訊息系統修改訊息狀態為傳送狀態並將訊息投遞到 MQ 中介軟體。
以上每一步都可能出現失敗情況,分析一下這 5 步出現異常後上遊業務和訊息傳送是否一致:
失敗步驟 | 現象 | 一致性 |
---|---|---|
第1步 | 上游應用業務未執行,MQ訊息未傳送 | 一致 |
第2步 | 上游應用業務未執行,MQ訊息未傳送 | 一致 |
第3步 | 上游應用事物回滾,MQ訊息未傳送 | 一致 |
第4步 | 上游應用業務執行,MQ訊息未傳送 | 不一致 |
第5步 | 上游應用業務執行,MQ訊息未傳送 | 不一致 |
上游應用執行完成,下游應用尚未執行或執行失敗時,此事務即處於 BASE 理論的 Soft State 狀態。
3.2 下游應用監聽 MQ 訊息並執行業務
-
下游應用監聽 MQ 訊息並執行業務,並且將訊息的消費結果通知可靠訊息服務。(本地訊息落地)
-
可靠訊息的狀態需要和下游應用的業務執行保持一致,可靠訊息狀態不是已完成時,確保下游應用未執行,可靠訊息狀態是已完成時,確保下游應用已執行。 下游應用和可靠訊息服務之間的互動圖如下:
-
下游應用監聽 MQ 訊息元件並獲取訊息, 並儲存本地訊息
-
下游系統通知可靠訊息服務已接收到訊息
-
可靠訊息把訊息更新為已接收狀態
-
下游應用根據 MQ 訊息體資訊處理本地業務
-
下游應用向 MQ 元件自動傳送 ACK 確認訊息被消費
-
下游應用通知可靠訊息系統訊息被成功消費,可靠訊息將該訊息狀態更改為以消費,任務表狀態修改為已完成。
失敗步驟 | 現象 | 一致性 |
---|---|---|
第1步 | 下游應用業務未接收MQ訊息,MQ訊息為已傳送未接收 | 不一致 |
第2步 | 通知可靠訊息服務,接收到訊息 | 不一致 |
第3步 | 下游應用非同步通知 | 不一致 |
第4步 | 下游應用資料回滾,本地訊息儲存成功,訊息狀態為已接收未成功消費 | 一致 |
第5步 | MQ未收到ack確認 | 一致 |
第6步 | 下游應用非同步通知 | 不一致 |
- 下游應用監聽 MQ 訊息元件並獲取訊息, 並儲存本地訊息
- 下游系統通知可靠訊息服務已接收到訊息
- 可靠訊息把訊息更新為已接收狀態
- 下游應用根據 MQ 訊息體資訊處理本地業務
- 下游應用向 MQ 元件自動傳送 ACK 確認訊息被消費
- 下游應用通知可靠訊息系統訊息被成功消費,可靠訊息將該訊息狀態更改為已消費,任務表狀態修改為已完成
3.3 生產者訊息狀態確認
可靠訊息服務定時監聽訊息的狀態,如果存在狀態為待確認並且超時的訊息,則表示上游應用和可靠訊息互動中的步驟 4 或者 5 出現異常。
可靠訊息則攜帶訊息體內的資訊向上遊應用發起請求查詢該業務是否已執行。上游應用提供一個可查詢介面供可靠訊息追溯業務執行狀態,如果業務執行成功則更改訊息狀態為已傳送,否則刪除此訊息確保資料一致。具體流程如下:
3.4 消費者訊息狀態確認
下游消費MQ服務非同步通知可靠訊息的過程中可能出現異常,在此可能導致兩個現象一、訊息已接到但可靠訊息沒有確認接到二、訊息已成功消費但可靠訊息沒有確認接到,為此下游系統需要提供消費者訊息狀態查詢介面,從而可靠訊息重新確認.在確認過程中如果是可靠訊息為已消費而下游消費系統為已接收則不進行更新操作. 具體流程如下:
3.5 訊息重投
訊息已傳送則表示上游應用已經執行,接下來則確保下游應用也能正常執行。 可靠訊息服務發現可靠訊息服務中存在訊息狀態為已傳送並且超時的訊息,則表示可靠訊息服務和下游應用中存在異常的步驟,無論哪個步驟出現異常,可靠訊息服務都將此訊息重新投遞到 MQ 元件中供下游應用監聽。 下游應用監聽到此訊息後,在保證冪等性的情況下重新執行業務並通知可靠訊息服務此訊息已經成功消費,最終確保上游應用、下游應用的資料最終一致性。具體流程如下:
- 可靠訊息服務定時查詢狀態為已傳送並超時的訊息
- 可靠訊息將訊息重新投遞到 MQ 元件中
- 下游應用監聽訊息,在滿足冪等性的條件下,重新執行業務。
- 下游應用通知可靠訊息服務該訊息已經成功消費。
- 更新consumer訊息記錄為已消費
3.6 刪除上游系統7天前成功傳送的訊息
在預傳送執行MQ訊息的時候本地訊息如果落庫則需要刪除訊息,否則業務系統需要額外提供查詢訊息傳送狀態介面, 這裡介紹兩種方法
第一種,RPC服務介面來實現, 在生產者和消費者註冊到可靠訊息的時候把生產者和消費者儲存到BeanFactory的Map裡在定時清理任務的時候去處理線上的RPC服務
第二種,發可靠訊息來實現, 確保100%到達
3.7 刪除下游系統7天前成功消費的訊息
在消費MQ訊息的時候本地訊息如果落庫則需要刪除訊息,否則業務系統需要額外提供查詢訊息傳送狀態介面,刪除實現同3.6
3.8 每天備份可靠訊息記錄
每天將成功訊息刪除並備份到對應資料庫提供歷史訊息查詢功能,當然如果你選擇mongo可以不考慮備份訊息
4 核心程式碼實現
這裡做一個說明,因為專案採用的是rocketmq,一個topic對應一個生產者,而可靠訊息採用的是中介軟體負責傳送訊息,又不能採用中介軟體的生產者為所有上游系統傳送訊息,這裡引入了zookeeper做註冊中心,所以依賴可靠訊息的服務,在啟動專案的時候會像中介軟體去註冊生產者,而中介軟體的watch機制會及時的更新生產者和消費者狀態,而中介軟體會為使用中介軟體的系統提供sdk,使用者無需關注實現,只需要引入中介軟體的sdk和對應的註解即可完成可靠訊息的傳送和消費,詳見下圖: 普通訊息傳送流程:
可靠訊息傳送流程: 可靠訊息傳送和消費流程:服務註冊
public static void startup(PaascloudProperties paascloudProperties, String host, String app) {
CoordinatorRegistryCenter coordinatorRegistryCenter = createCoordinatorRegistryCenter(paascloudProperties.getZk());
RegisterDto dto = new RegisterDto(app, host, coordinatorRegistryCenter);
Long serviceId = new IncrementIdGenerator(dto).nextId();
IncrementIdGenerator.setServiceId(serviceId);
registerMq(paascloudProperties, host, app);
}
private static void registerMq(PaascloudProperties paascloudProperties, String host, String app) {
CoordinatorRegistryCenter coordinatorRegistryCenter = createCoordinatorRegistryCenter(paascloudProperties.getZk());
AliyunProperties.RocketMqProperties rocketMq = paascloudProperties.getAliyun().getRocketMq();
String consumerGroup = rocketMq.isReliableMessageConsumer() ? rocketMq.getConsumerGroup() : null;
String namesrvAddr = rocketMq.getNamesrvAddr();
String producerGroup = rocketMq.isReliableMessageProducer() ? rocketMq.getProducerGroup() : null;
coordinatorRegistryCenter.registerMq(app, host, producerGroup, consumerGroup, namesrvAddr);
}
@Override
public void registerMq(final String app, final String host, final String producerGroup, final String consumerGroup, String namesrvAddr) {
// 註冊生產者
final String producerRootPath = GlobalConstant.ZK_REGISTRY_PRODUCER_ROOT_PATH + GlobalConstant.Symbol.SLASH + app;
final String consumerRootPath = GlobalConstant.ZK_REGISTRY_CONSUMER_ROOT_PATH + GlobalConstant.Symbol.SLASH + app;
ReliableMessageRegisterDto dto;
if (StringUtils.isNotEmpty(producerGroup)) {
dto = new ReliableMessageRegisterDto().setProducerGroup(producerGroup).setNamesrvAddr(namesrvAddr);
String producerJson = JSON.toJSONString(dto);
this.persist(producerRootPath, producerJson);
this.persistEphemeral(producerRootPath + GlobalConstant.Symbol.SLASH + host, DateUtil.now());
}
// 註冊消費者
if (StringUtils.isNotEmpty(consumerGroup)) {
dto = new ReliableMessageRegisterDto().setConsumerGroup(consumerGroup).setNamesrvAddr(namesrvAddr);
String producerJson = JSON.toJSONString(dto);
this.persist(consumerRootPath, producerJson);
this.persistEphemeral(consumerRootPath + GlobalConstant.Symbol.SLASH + host, DateUtil.now());
}
}
複製程式碼
消費註解 @MqProducerStore
@Around(value = "mqProducerStoreAnnotationPointcut()")
public Object processMqProducerStoreJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("processMqProducerStoreJoinPoint - 執行緒id={}", Thread.currentThread().getId());
Object result;
Object[] args = joinPoint.getArgs();
MqProducerStore annotation = getAnnotation(joinPoint);
MqSendTypeEnum type = annotation.sendType();
int orderType = annotation.orderType().orderType();
DelayLevelEnum delayLevelEnum = annotation.delayLevel();
if (args.length == 0) {
throw new TpcBizException(ErrorCodeEnum.TPC10050005);
}
MqMessageData domain = null;
for (Object object : args) {
if (object instanceof MqMessageData) {
domain = (MqMessageData) object;
break;
}
}
if (domain == null) {
throw new TpcBizException(ErrorCodeEnum.TPC10050005);
}
domain.setOrderType(orderType);
domain.setProducerGroup(producerGroup);
if (type == MqSendTypeEnum.WAIT_CONFIRM) {
if (delayLevelEnum != DelayLevelEnum.ZERO) {
domain.setDelayLevel(delayLevelEnum.delayLevel());
}
mqMessageService.saveWaitConfirmMessage(domain);
}
result = joinPoint.proceed();
if (type == MqSendTypeEnum.SAVE_AND_SEND) {
mqMessageService.saveAndSendMessage(domain);
} else if (type == MqSendTypeEnum.DIRECT_SEND) {
mqMessageService.directSendMessage(domain);
} else {
mqMessageService.confirmAndSendMessage(domain.getMessageKey());
}
return result;
}
複製程式碼
生產註解@MqConsumerStore
@Around(value = "mqConsumerStoreAnnotationPointcut()")
public Object processMqConsumerStoreJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("processMqConsumerStoreJoinPoint - 執行緒id={}", Thread.currentThread().getId());
Object result;
long startTime = System.currentTimeMillis();
Object[] args = joinPoint.getArgs();
MqConsumerStore annotation = getAnnotation(joinPoint);
boolean isStorePreStatus = annotation.storePreStatus();
List<MessageExt> messageExtList;
if (args == null || args.length == 0) {
throw new TpcBizException(ErrorCodeEnum.TPC10050005);
}
if (!(args[0] instanceof List)) {
throw new TpcBizException(ErrorCodeEnum.GL99990001);
}
try {
messageExtList = (List<MessageExt>) args[0];
} catch (Exception e) {
log.error("processMqConsumerStoreJoinPoint={}", e.getMessage(), e);
throw new TpcBizException(ErrorCodeEnum.GL99990001);
}
MqMessageData dto = this.getTpcMqMessageDto(messageExtList.get(0));
final String messageKey = dto.getMessageKey();
if (isStorePreStatus) {
mqMessageService.confirmReceiveMessage(consumerGroup, dto);
}
String methodName = joinPoint.getSignature().getName();
try {
result = joinPoint.proceed();
log.info("result={}", result);
if (CONSUME_SUCCESS.equals(result.toString())) {
mqMessageService.saveAndConfirmFinishMessage(consumerGroup, messageKey);
}
} catch (Exception e) {
log.error("傳送可靠訊息, 目標方法[{}], 出現異常={}", methodName, e.getMessage(), e);
throw e;
} finally {
log.info("傳送可靠訊息 目標方法[{}], 總耗時={}", methodName, System.currentTimeMillis() - startTime);
}
return result;
}
複製程式碼
定時清理所有訂閱者消費成功的訊息資料
@Slf4j
@ElasticJobConfig(cron = "0 0 0 1/1 * ?")
public class DeleteRpcConsumerMessageJob implements SimpleJob {
@Resource
private PaascloudProperties paascloudProperties;
@Resource
private TpcMqMessageService tpcMqMessageService;
/**
* Execute.
*
* @param shardingContext the sharding context
*/
@Override
public void execute(final ShardingContext shardingContext) {
ShardingContextDto shardingContextDto = new ShardingContextDto(shardingContext.getShardingTotalCount(), shardingContext.getShardingItem());
final TpcMqMessageDto message = new TpcMqMessageDto();
message.setMessageBody(JSON.toJSONString(shardingContextDto));
message.setMessageTag(AliyunMqTopicConstants.MqTagEnum.DELETE_CONSUMER_MESSAGE.getTag());
message.setMessageTopic(AliyunMqTopicConstants.MqTopicEnum.TPC_TOPIC.getTopic());
message.setProducerGroup(paascloudProperties.getAliyun().getRocketMq().getProducerGroup());
String refNo = Long.toString(UniqueIdGenerator.generateId());
message.setRefNo(refNo);
message.setMessageKey(refNo);
tpcMqMessageService.saveAndSendMessage(message);
}
}
複製程式碼
定時清理所有生產者傳送成功的訊息資料
@Slf4j
@ElasticJobConfig(cron = "0 0 0 1/1 * ?")
public class DeleteRpcExpireFileJob implements SimpleJob {
@Resource
private OpcRpcService opcRpcService;
/**
* Execute.
*
* @param shardingContext the sharding context
*/
@Override
public void execute(final ShardingContext shardingContext) {
opcRpcService.deleteExpireFile();
}
}
複製程式碼
定時清理所有生產者傳送成功的訊息資料
@Slf4j
@ElasticJobConfig(cron = "0 0 1 1/1 * ?")
public class DeleteRpcProducerMessageJob implements SimpleJob {
@Resource
private PaascloudProperties paascloudProperties;
@Resource
private TpcMqMessageService tpcMqMessageService;
/**
* Execute.
*
* @param shardingContext the sharding context
*/
@Override
public void execute(final ShardingContext shardingContext) {
final TpcMqMessageDto message = new TpcMqMessageDto();
message.setMessageBody(JSON.toJSONString(shardingContext));
message.setMessageTag(AliyunMqTopicConstants.MqTagEnum.DELETE_PRODUCER_MESSAGE.getTag());
message.setMessageTopic(AliyunMqTopicConstants.MqTopicEnum.TPC_TOPIC.getTopic());
message.setProducerGroup(paascloudProperties.getAliyun().getRocketMq().getProducerGroup());
String refNo = Long.toString(UniqueIdGenerator.generateId());
message.setRefNo(refNo);
message.setMessageKey(refNo);
tpcMqMessageService.saveAndSendMessage(message);
}
}
複製程式碼
處理髮送中的訊息資料
@Component
@Slf4j
@ElasticJobConfig(cron = "0/30 * * * * ?", jobParameter = "fetchNum=200")
public class HandleSendingMessageJob extends AbstractBaseDataflowJob<TpcMqMessage> {
@Resource
private TpcMqMessageService tpcMqMessageService;
@Value("${paascloud.message.handleTimeout}")
private int timeOutMinute;
@Value("${paascloud.message.maxSendTimes}")
private int messageMaxSendTimes;
@Value("${paascloud.message.resendMultiplier}")
private int messageResendMultiplier;
@Resource
private TpcMqConfirmMapper tpcMqConfirmMapper;
/**
* Fetch job data list.
*
* @param jobParameter the job parameter
*
* @return the list
*/
@Override
protected List<TpcMqMessage> fetchJobData(JobParameter jobParameter) {
MessageTaskQueryDto query = new MessageTaskQueryDto();
query.setCreateTimeBefore(DateUtil.getBeforeTime(timeOutMinute));
query.setMessageStatus(MqSendStatusEnum.SENDING.sendStatus());
query.setFetchNum(jobParameter.getFetchNum());
query.setShardingItem(jobParameter.getShardingItem());
query.setShardingTotalCount(jobParameter.getShardingTotalCount());
query.setTaskStatus(JobTaskStatusEnum.TASK_CREATE.status());
return tpcMqMessageService.listMessageForWaitingProcess(query);
}
/**
* Process job data.
*
* @param taskList the task list
*/
@Override
@Transactional(rollbackFor = Exception.class)
protected void processJobData(List<TpcMqMessage> taskList) {
for (TpcMqMessage message : taskList) {
Integer resendTimes = message.getResendTimes();
if (resendTimes >= messageMaxSendTimes) {
tpcMqMessageService.setMessageToAlreadyDead(message.getId());
continue;
}
int times = (resendTimes == 0 ? 1 : resendTimes) * messageResendMultiplier;
long currentTimeInMillis = Calendar.getInstance().getTimeInMillis();
long needTime = currentTimeInMillis - times * 60 * 1000;
long hasTime = message.getUpdateTime().getTime();
// 判斷是否達到了可以再次傳送的時間條件
if (hasTime > needTime) {
log.debug("currentTime[" + com.xiaoleilu.hutool.date.DateUtil.formatDateTime(new Date()) + "],[SENDING]訊息上次傳送時間[" + com.xiaoleilu.hutool.date.DateUtil.formatDateTime(message.getUpdateTime()) + "],必須過了[" + times + "]分鐘才可以再傳送。");
continue;
}
// 前置狀態
List<Integer> preStatusList = Lists.newArrayList(JobTaskStatusEnum.TASK_CREATE.status());
// 設定任務狀態為執行中
message.setPreStatusList(preStatusList);
message.setTaskStatus(JobTaskStatusEnum.TASK_EXETING.status());
int updateRes = tpcMqMessageService.updateMqMessageTaskStatus(message);
if (updateRes > 0) {
try {
// 查詢是否全部訂閱者都確認了訊息 是 則更新訊息狀態完成, 否則重發訊息
int count = tpcMqConfirmMapper.selectUnConsumedCount(message.getMessageKey());
int status = JobTaskStatusEnum.TASK_CREATE.status();
if (count < 1) {
TpcMqMessage update = new TpcMqMessage();
update.setMessageStatus(MqSendStatusEnum.FINISH.sendStatus());
update.setId(message.getId());
tpcMqMessageService.updateMqMessageStatus(update);
status = JobTaskStatusEnum.TASK_SUCCESS.status();
} else {
tpcMqMessageService.resendMessageByMessageId(message.getId());
}
// 前置狀態
preStatusList = Lists.newArrayList(JobTaskStatusEnum.TASK_EXETING.status());
// 設定任務狀態為執行中
message.setPreStatusList(preStatusList);
message.setTaskStatus(status);
tpcMqMessageService.updateMqMessageTaskStatus(message);
} catch (Exception e) {
log.error("重發失敗 ex={}", e.getMessage(), e);
// 設定任務狀態為執行中
preStatusList = Lists.newArrayList(JobTaskStatusEnum.TASK_EXETING.status());
message.setPreStatusList(preStatusList);
message.setTaskStatus(JobTaskStatusEnum.TASK_SUCCESS.status());
tpcMqMessageService.updateMqMessageTaskStatus(message);
}
}
}
}
}
複製程式碼
處理待確認的訊息資料
@Slf4j
@Component
@ElasticJobConfig(cron = "0 0/10 * * * ?", jobParameter = "fetchNum=1000")
public class HandleWaitingConfirmMessageJob extends AbstractBaseDataflowJob<String> {
@Resource
private TpcMqMessageService tpcMqMessageService;
@Resource
private UacRpcService uacRpcService;
@Value("${paascloud.message.handleTimeout}")
private int timeOutMinute;
private static final String PID_UAC = "PID_UAC";
/**
* Fetch job data list.
*
* @param jobParameter the job parameter
*
* @return the list
*/
@Override
protected List<String> fetchJobData(JobParameter jobParameter) {
MessageTaskQueryDto query = new MessageTaskQueryDto();
query.setCreateTimeBefore(DateUtil.getBeforeTime(timeOutMinute));
query.setMessageStatus(MqSendStatusEnum.WAIT_SEND.sendStatus());
query.setFetchNum(jobParameter.getFetchNum());
query.setShardingItem(jobParameter.getShardingItem());
query.setShardingTotalCount(jobParameter.getShardingTotalCount());
query.setTaskStatus(JobTaskStatusEnum.TASK_CREATE.status());
query.setProducerGroup(PID_UAC);
return tpcMqMessageService.queryWaitingConfirmMessageKeyList(query);
}
/**
*
*/
@Override
protected void processJobData(List<String> messageKeyList) {
if (messageKeyList == null) {
return;
}
List<String> resendMessageList = uacRpcService.queryWaitingConfirmMessageKeyList(messageKeyList);
if (resendMessageList == null) {
resendMessageList = Lists.newArrayList();
}
messageKeyList.removeAll(resendMessageList);
tpcMqMessageService.handleWaitingConfirmMessage(messageKeyList, resendMessageList);
}
}
複製程式碼
可靠訊息用法
例子
@MqProducerStore
public void resetLoginPwd(final MqMessageData mqMessageData, final UacUser update) {
log.info("重置密碼. mqMessageData={}, user={}", mqMessageData, update);
int updateResult = uacUserMapper.updateByPrimaryKeySelective(update);
if (updateResult < 1) {
log.error("使用者【 {} 】重置密碼失敗", update.getLoginName());
} else {
log.info("使用者【 {} 】重置密碼失敗", update.getLoginName());
}
}
複製程式碼
強制: 需要使用的使用加上述兩個註解,方法引數需要加入 MqMessageData
如果對本文感興趣,或者本文對您有所幫助,可靠參考github程式碼,本套程式碼是spring cloud E版本 + vue兩套全家桶實現
後端專案:https://github.com/paascloud/paascloud-master
https://gitee.com/passcloud/paascloud-master
登入入口:https://github.com/paascloud/paascloud-login-web
https://gitee.com/passcloud/paascloud-login-web
後端入口:https://github.com/paascloud/paascloud-admin-web
https://gitee.com/passcloud/paascloud-admin-web
前端入口:https://github.com/paascloud/paascloud-mall-web
https://gitee.com/passcloud/paascloud-mall-web
複製程式碼
如果有時間最好能給點加個星或者follow一下,筆者在這裡先謝過了。對不知道怎麼加星的朋友,請用電腦登入github或者碼雲,這裡兩個截圖
寫在最後
更多內容請參考paascloud 建站文件
https://document.paascloud.net/
複製程式碼