spring cloud實現 rocketmq可靠一致性

1.前言

1.1 目的

  1. 爲開發測試提供指導性文件
  2. 爲系統今後的擴展提供參考
  3. 解決系統中消息不可達問題

1.2 範圍和功能

1.3 適用讀者

  1. 需要發送MQ分佈式系統的開發人員和測試人員
  2. 可靠消息服務的開發人員和測試人員

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 個模塊:
1. 上游應用,執行業務併發送指令給可靠消息服務並保留消息副本。
2. 可靠消息服務和 MQ消息組件,協調上下游消息的傳遞,並確保上下游數據的一致性。
3. 下游應用,監聽 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 消息

image

上游應用將本地業務執行和消息發送綁定在同一個本地事務中,保證要麼本地操作成功併發送 MQ 消息,要麼兩步操作都失敗並回滾。這裏可以採用自定義切面完成,後續會有介紹。

image

  1. 上游應用發送待確認消息到可靠消息系統。(本地消息落地)
  2. 可靠消息系統保存待確認消息並返回。
  3. 上游應用執行本地業務。
  4. 上游應用通知可靠消息系統確認業務已執行併發送消息。
  5. 可靠消息系統修改消息狀態爲發送狀態並將消息投遞到 MQ 中間件。

以上每一步都可能出現失敗情況,分析一下這 5 步出現異常後上遊業務和消息發送是否一致:

失敗步驟 現象 一致性
第1步 上游應用業務未執行,MQ消息未發送 一致
第2步 上游應用業務未執行,MQ消息未發送 一致
第3步 上游應用事物回滾,MQ消息未發送 一致
第4步 上游應用業務執行,MQ消息未發送 不一致
第5步 上游應用業務執行,MQ消息未發送 不一致

上游應用執行完成,下游應用尚未執行或執行失敗時,此事務即處於 BASE 理論的 Soft State 狀態。

image

3.2 下游應用監聽 MQ 消息並執行業務

  1. 下游應用監聽 MQ 消息並執行業務,並且將消息的消費結果通知可靠消息服務。(本地消息落地)
  2. 可靠消息的狀態需要和下游應用的業務執行保持一致,可靠消息狀態不是已完成時,確保下游應用未執行,可靠消息狀態是已完成時,確保下游應用已執行。
    下游應用和可靠消息服務之間的交互圖如下:
    這裏寫圖片描述

    1. 下游應用監聽 MQ 消息組件並獲取消息, 並存儲本地消息
    2. 下游系統通知可靠消息服務已接收到消息
    3. 可靠消息把消息更新爲已接收狀態
    4. 下游應用根據 MQ 消息體信息處理本地業務
    5. 下游應用向 MQ 組件自動發送 ACK 確認消息被消費
    6. 下游應用通知可靠消息系統消息被成功消費,可靠消息將該消息狀態更改爲以消費,任務表狀態修改爲已完成。
失敗步驟 現象 一致性
第1步 下游應用業務未接收MQ消息,MQ消息爲已發送未接收 不一致
第2步 通知可靠消息服務,接收到消息 不一致
第3步 下游應用異步通知 不一致
第4步 下游應用數據回滾,本地消息存儲成功,消息狀態爲已接收未成功消費 一致
第5步 MQ未收到ack確認 一致
第6步 下游應用異步通知 不一致

1. 下游應用監聽 MQ 消息組件並獲取消息, 並存儲本地消息
2. 下游系統通知可靠消息服務已接收到消息
3. 可靠消息把消息更新爲已接收狀態
4. 下游應用根據 MQ 消息體信息處理本地業務
5. 下游應用向 MQ 組件自動發送 ACK 確認消息被消費
6. 下游應用通知可靠消息系統消息被成功消費,可靠消息將該消息狀態更改爲已消費,任務表狀態修改爲已完成

3.3 生產者消息狀態確認

可靠消息服務定時監聽消息的狀態,如果存在狀態爲待確認並且超時的消息,則表示上游應用和可靠消息交互中的步驟 4 或者 5 出現異常。

可靠消息則攜帶消息體內的信息向上遊應用發起請求查詢該業務是否已執行。上游應用提供一個可查詢接口供可靠消息追溯業務執行狀態,如果業務執行成功則更改消息狀態爲已發送,否則刪除此消息確保數據一致。具體流程如下:

這裏寫圖片描述

3.4 消費者消息狀態確認

下游消費MQ服務異步通知可靠消息的過程中可能出現異常,在此可能導致兩個現象一、消息已接到但可靠消息沒有確認接到二、消息已成功消費但可靠消息沒有確認接到,爲此下游系統需要提供消費者消息狀態查詢接口,從而可靠消息重新確認.在確認過程中如果是可靠消息爲已消費而下游消費系統爲已接收則不進行更新操作. 具體流程如下:

這裏寫圖片描述

3.5 消息重投

消息已發送則表示上游應用已經執行,接下來則確保下游應用也能正常執行。
可靠消息服務發現可靠消息服務中存在消息狀態爲已發送並且超時的消息,則表示可靠消息服務和下游應用中存在異常的步驟,無論哪個步驟出現異常,可靠消息服務都將此消息重新投遞到 MQ 組件中供下游應用監聽。
下游應用監聽到此消息後,在保證冪等性的情況下重新執行業務並通知可靠消息服務此消息已經成功消費,最終確保上游應用、下游應用的數據最終一致性。具體流程如下:

這裏寫圖片描述

  1. 可靠消息服務定時查詢狀態爲已發送並超時的消息
  2. 可靠消息將消息重新投遞到 MQ 組件中
  3. 下游應用監聽消息,在滿足冪等性的條件下,重新執行業務。
  4. 下游應用通知可靠消息服務該消息已經成功消費。
  5. 更新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/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章