Hyperledger Fabric私有數(shù)據(jù)(Private Data Collections)源碼分析

說明:

基于Hyperledger Fabric 1.2的源碼;
自己閱讀源碼也是在學(xué)習(xí)和摸索中,有錯(cuò)誤的話歡迎指正,也有不少還不懂的地方,歡迎指導(dǎo)和討論。

主要流程

【接收交易proposal的過程】

  1. peer在調(diào)用chaincode執(zhí)行交易后,會(huì)依據(jù)collection配置在有權(quán)限的peer間散播私有數(shù)據(jù),并存儲(chǔ)私有數(shù)據(jù)到自己的transient store。
  2. 其他接收到散播私有數(shù)據(jù)的peer,會(huì)將私有數(shù)據(jù)存儲(chǔ)到自己的transient store。

【接收Block的過程】

  1. peer最終接收到block后,會(huì)檢查自己是否有權(quán)限獲取私有數(shù)據(jù),有權(quán)限的話從transient store中拿數(shù)據(jù),如果沒有,則去其他peer上獲取,最終私有數(shù)據(jù)會(huì)存儲(chǔ)到pvtdataStore中。

源碼分析

A. 接收交易proposal

在peer節(jié)點(diǎn)處理proposal(core/endorser/endorser.go的ProcessProposal函數(shù))的過程中,執(zhí)行模擬操作(SimulateProposal函數(shù))環(huán)節(jié)中,調(diào)用chaincode執(zhí)行交易以后,會(huì)判斷讀寫集結(jié)果中是否包含私有數(shù)據(jù),如果包含,則執(zhí)行以下操作(代碼如下):

if simResult.PvtSimulationResults != nil {
   if [cid.Name](http://cid.name/)== "lscc" {
      // TODO: remove once we can store collection configuration outside of LSCC
      txsim.Done()
      return nil, nil, nil, nil, errors.New("Private data is forbidden to be used in instantiate")
   }
   // 匯總私有數(shù)據(jù)collection配置和私有數(shù)據(jù)信息
   pvtDataWithConfig, err := e.AssemblePvtRWSet(simResult.PvtSimulationResults, txsim)
   // To read collection config need to read collection updates before
   // releasing the lock, hence txsim.Done()  moved down here
   txsim.Done()

   if err != nil {
      return nil, nil, nil, nil, errors.WithMessage(err, "failed to obtain collections config")
   }
   endorsedAt, err := e.s.GetLedgerHeight(chainID)
   if err != nil {
      return nil, nil, nil, nil, errors.WithMessage(err, fmt.Sprint("failed to obtain ledger height for channel", chainID))
   }
   // Add ledger height at which transaction was endorsed,
   // `endorsedAt` is obtained from the block storage and at times this could be 'endorsement Height + 1'.
   // However, since we use this height only to select the configuration (3rd parameter in distributePrivateData) and
   // manage transient store purge for orphaned private writesets (4th parameter in distributePrivateData), this works for now.
   // Ideally, ledger should add support in the simulator as a first class function `GetHeight()`.
   pvtDataWithConfig.EndorsedAt = endorsedAt
   // 在chainID為名的channel中,依據(jù)collection配置,散播私有數(shù)據(jù)
   if err := e.distributePrivateData(chainID, txid, pvtDataWithConfig, endorsedAt); err != nil {
      return nil, nil, nil, nil, err
   }
}

以上代碼內(nèi)容包括:

  1. 匯總私有數(shù)據(jù)collection配置和私有數(shù)據(jù)信息(core/endorser/pvtrwset_assembler.go的AssemblePvtRWSet函數(shù)):
    根據(jù)讀寫集中私有數(shù)據(jù)的合約名(namespace),從系統(tǒng)合約lscc處獲取其對應(yīng)的collection配置(即實(shí)例化合約時(shí)提供的collection_config.yaml),整理一番(代碼如下);
// AssemblePvtRWSet prepares TxPvtReadWriteSet for distribution
// augmenting it into TxPvtReadWriteSetWithConfigInfo adding
// information about collections config available related
// to private read-write set
func (as *rwSetAssembler) AssemblePvtRWSet(privData *rwset.TxPvtReadWriteSet, txsim CollectionConfigRetriever) (*transientstore.TxPvtReadWriteSetWithConfigInfo, error) {
   txPvtRwSetWithConfig := &transientstore.TxPvtReadWriteSetWithConfigInfo{
      PvtRwset:          privData,
      CollectionConfigs: make(map[string]*common.CollectionConfigPackage),
   }

   for _, pvtRwset := range privData.NsPvtRwset {
      namespace := pvtRwset.Namespace
      if _, found := txPvtRwSetWithConfig.CollectionConfigs[namespace]; !found {
         cb, err := txsim.GetState("lscc", privdata.BuildCollectionKVSKey(namespace))
         if err != nil {
            return nil, errors.WithMessage(err, fmt.Sprintf("error while retrieving collection config for chaincode %#v", namespace))
         }
         if cb == nil {
            return nil, errors.New(fmt.Sprintf("no collection config for chaincode %#v", namespace))
         }

         colCP := &common.CollectionConfigPackage{}
         err = proto.Unmarshal(cb, colCP)
         if err != nil {
            return nil, errors.Wrapf(err, "invalid configuration for collection criteria %#v", namespace)
         }

         txPvtRwSetWithConfig.CollectionConfigs[namespace] = colCP
      }
   }
   as.trimCollectionConfigs(txPvtRwSetWithConfig)
   return txPvtRwSetWithConfig, nil
}
  1. 在peer之間分發(fā)私有數(shù)據(jù):
    調(diào)用distributePrivateData函數(shù),該函數(shù)在生成EndorserServer時(shí)由外部傳值建立(peer/node/start.go的serve函數(shù)),即privDataDist函數(shù)(代碼如下)。
privDataDist := func(channel string, txID string, privateData *transientstore.TxPvtReadWriteSetWithConfigInfo, blkHt uint64) error {
   return service.GetGossipService().DistributePrivateData(channel, txID, privateData, blkHt)
}

...

serverEndorser := endorser.NewEndorserServer(privDataDist, endorserSupport)

其中,service.GetGossipService()返回gossipService的唯一實(shí)例(gossip/service/gossip_service.go的gossipServiceInstance),調(diào)用函數(shù)(gossip/service/gossip_service.go的DistributePrivateData函數(shù))完成在channel內(nèi)部的私有數(shù)據(jù)傳輸(代碼如下):

// DistributePrivateData distribute private read write set inside the channel based on the collections policies
func (g *gossipServiceImpl) DistributePrivateData(chainID string, txID string, privData *transientstore.TxPvtReadWriteSetWithConfigInfo, blkHt uint64) error {
   g.lock.RLock()
   // 根據(jù)channel名稱獲取私有數(shù)據(jù)的handler
   handler, exists := g.privateHandlers[chainID]
   g.lock.RUnlock()
   if !exists {
      return errors.Errorf("No private data handler for %s", chainID)
   }

   // 依據(jù)collection配置散播私有數(shù)據(jù)
   if err := handler.distributor.Distribute(txID, privData, blkHt); err != nil {
      logger.Error("Failed to distributed private collection, txID", txID, "channel", chainID, "due to", err)
      return err
   }

   if err := handler.coordinator.StorePvtData(txID, privData, blkHt); err != nil {
      logger.Error("Failed to store private data into transient store, txID",
         txID, "channel", chainID, "due to", err)
      return err
   }
   return nil
}

以上函數(shù)包括如下步驟:
1)根據(jù)channel名稱chainID獲取私有數(shù)據(jù)的handler;
2)依據(jù)collection配置散播私有數(shù)據(jù)(gossip/privdata/distributor.go的Distribute函數(shù),代碼如下):

// Distribute broadcast reliably private data read write set based on policies
func (d *distributorImpl) Distribute(txID string, privData *transientstore.TxPvtReadWriteSetWithConfigInfo, blkHt uint64) error {
   disseminationPlan, err := d.computeDisseminationPlan(txID, privData, blkHt)
   if err != nil {
      return errors.WithStack(err)
   }
   return d.disseminate(disseminationPlan)
}

Distribute函數(shù)包括以下步驟:
a)制定散播方案(gossip/privdata/distributor.go的computeDisseminationPlan函數(shù)):根據(jù)collection配置獲取策略,設(shè)置過濾器,生成私有數(shù)據(jù)消息,生成散播方案(dissemination的列表,dissemination包含msg和criteria);
b)根據(jù)散播方案,開啟協(xié)程啟動(dòng)散播(散播最終調(diào)用的函數(shù)需要收到接收端的ACK);
c)存儲(chǔ)私有數(shù)據(jù)到Transient Store(core/transientstore/store.go的PersistWithConfig函數(shù)):創(chuàng)建comisiteKey為鍵值(包括txid, uuid和blockHeight),存儲(chǔ)私有數(shù)據(jù),并創(chuàng)建兩個(gè)用于后期清理的索引(compositeKeyPurgeIndexByHeight和compositeKeyPurgeIndexByTxid),數(shù)據(jù)最后存儲(chǔ)在leveldb中,位于/var/hyperledger/production/transientStore路徑下。

B. 接收Block和私有數(shù)據(jù)

在peer啟動(dòng)時(shí)會(huì)啟動(dòng)Gossip服務(wù),其中會(huì)啟動(dòng)接收消息的協(xié)程
(調(diào)用過程:peer/node/start.go的serve函數(shù)—>core/peer/peer.go的Initialize函數(shù)—>core/peer/peer.go的createChain函數(shù)—>gossip/service/gossip_service.go的InitializeChannel函數(shù)—>gossip/state/state.go的NewGossipStateProvider函數(shù))
主要關(guān)注以下代碼:

// Listen for incoming communication
go s.listen()
// Deliver in order messages into the incoming channel
go s.deliverPayloads()

1. listen函數(shù)處理

func (s *GossipStateProviderImpl) listen() {
    defer s.done.Done()

    for {
        select {
        case msg := <-s.gossipChan:
            logger.Debug("Received new message via gossip channel")
            go s.queueNewMessage(msg)
        case msg := <-s.commChan:
            logger.Debug("Dispatching a message", msg)
            go s.dispatch(msg)
        case <-s.stopCh:
            s.stopCh <- struct{}{}
            logger.Debug("Stop listening for new messages")
            return
        }
    }
}

1)對于gossipChan的消息

(DataMessage類型,即區(qū)塊)

協(xié)程調(diào)用queueNewMessage函數(shù)(gossip/state/state.go的queueNewMessage函數(shù)),將同一channel的信息放到payload中(gossip/state/state.go的addPayload函數(shù))(代碼如下)。

gossipChan, _ := services.Accept(func(message interface{}) bool {
   // Get only data messages
   return message.(*proto.GossipMessage).IsDataMsg() &&
      bytes.Equal(message.(*proto.GossipMessage).Channel, []byte(chainID))
}, false)

...

// New message notification/handler
func (s *GossipStateProviderImpl) queueNewMessage(msg *proto.GossipMessage) {
   if !bytes.Equal(msg.Channel, []byte(s.chainID)) {
      logger.Warning("Received enqueue for channel",
         string(msg.Channel), "while expecting channel", s.chainID, "ignoring enqueue")
      return
   }

   dataMsg := msg.GetDataMsg()
   if dataMsg != nil {
      if err := s.addPayload(dataMsg.GetPayload(), nonBlocking); err != nil {
         logger.Warning("Failed adding payload:", err)
         return
      }
      logger.Debugf("Received new payload with sequence number = [%d]", dataMsg.Payload.SeqNum)
   } else {
      logger.Debug("Gossip message received is not of data message type, usually this should not happen.")
   }
}

2)對于commChan的消息

(RemoteStateRequest類型或RemoteStateResponse類型,即遠(yuǎn)程peer的請求或響應(yīng)信息)

該通道的消息是經(jīng)過remoteStateMsgFilter篩選的(主要檢查消息內(nèi)容,channel權(quán)限),協(xié)程調(diào)用dispatch函數(shù)(gossip/state/state.go的dispatch函數(shù)),分別處理state message和私有數(shù)據(jù)信息。這里主要關(guān)注私有數(shù)據(jù),如果獲得私有數(shù)據(jù),則進(jìn)行處理(gossip/state/state.go的privateDataMessage函數(shù)):
a)檢查channel,需要一致;
b)獲取私有數(shù)據(jù)信息和collection配置信息;
c)寫入Transient Store(最終還是調(diào)用core/transientstore/store.go的PersistWithConfig函數(shù));
d)返回ACK。

remoteStateMsgFilter := func(message interface{}) bool {
   receivedMsg := message.(proto.ReceivedMessage)
   msg := receivedMsg.GetGossipMessage()
   if !(msg.IsRemoteStateMessage() || msg.GetPrivateData() != nil) {
      return false
   }
   // Ensure we deal only with messages that belong to this channel
   if !bytes.Equal(msg.Channel, []byte(chainID)) {
      return false
   }
   connInfo := receivedMsg.GetConnectionInfo()
   authErr := services.VerifyByChannel(msg.Channel, connInfo.Identity, connInfo.Auth.Signature, connInfo.Auth.SignedData)
   if authErr != nil {
      logger.Warning("Got unauthorized request from", string(connInfo.Identity))
      return false
   }
   return true
}

// Filter message which are only relevant for nodeMetastate transfer
_, commChan := services.Accept(remoteStateMsgFilter, true)

...

func (s *GossipStateProviderImpl) dispatch(msg proto.ReceivedMessage) {
   // Check type of the message
   if msg.GetGossipMessage().IsRemoteStateMessage() {
      logger.Debug("Handling direct state transfer message")
      // Got state transfer request response
      s.directMessage(msg)
   } else if msg.GetGossipMessage().GetPrivateData() != nil {
      logger.Debug("Handling private data collection message")
      // Handling private data replication message
      s.privateDataMessage(msg)
   }

}

2. deliverPayloads函數(shù)處理

func (s *GossipStateProviderImpl) deliverPayloads() {
    defer s.done.Done()

    for {
        select {
        // Wait for notification that next seq has arrived
        case <-s.payloads.Ready():
            logger.Debugf("Ready to transfer payloads to the ledger, next sequence number is = [%d]", s.payloads.Next())
            // Collect all subsequent payloads
            for payload := s.payloads.Pop(); payload != nil; payload = s.payloads.Pop() {
                rawBlock := &common.Block{}
                if err := pb.Unmarshal(payload.Data, rawBlock); err != nil {
                    logger.Errorf("Error getting block with seqNum = %d due to (%+v)...dropping block", payload.SeqNum, errors.WithStack(err))
                    continue
                }
                if rawBlock.Data == nil || rawBlock.Header == nil {
                    logger.Errorf("Block with claimed sequence %d has no header (%v) or data (%v)",
                        payload.SeqNum, rawBlock.Header, rawBlock.Data)
                    continue
                }
                logger.Debug("New block with claimed sequence number ", payload.SeqNum, " transactions num ", len(rawBlock.Data.Data))

                // Read all private data into slice
                var p util.PvtDataCollections
                if payload.PrivateData != nil {
                    err := p.Unmarshal(payload.PrivateData)
                    if err != nil {
                        logger.Errorf("Wasn't able to unmarshal private data for block seqNum = %d due to (%+v)...dropping block", payload.SeqNum, errors.WithStack(err))
                        continue
                    }
                }
                if err := s.commitBlock(rawBlock, p); err != nil {
                    if executionErr, isExecutionErr := err.(*vsccErrors.VSCCExecutionFailureError); isExecutionErr {
                        logger.Errorf("Failed executing VSCC due to %v. Aborting chain processing", executionErr)
                        return
                    }
                    logger.Panicf("Cannot commit block to the ledger due to %+v", errors.WithStack(err))
                }
            }
        case <-s.stopCh:
            s.stopCh <- struct{}{}
            logger.Debug("State provider has been stopped, finishing to push new blocks.")
            return
        }
    }
}

從payload中獲取區(qū)塊,讀取區(qū)塊和私有數(shù)據(jù),提交區(qū)塊(gossip/state/state.go的commitBlock函數(shù)):
其中提交區(qū)塊函數(shù)commitBlock包括:存儲(chǔ)區(qū)塊(gossip/privdata/coordinator.go的StoreBlock函數(shù)),更新ledger高度。

其中,存儲(chǔ)區(qū)塊函數(shù)StoreBlock包括:
1)檢查區(qū)塊數(shù)據(jù)和header不為空;
2)驗(yàn)證區(qū)塊(core/committer/txvalidator/validator.go的Validate函數(shù)):協(xié)程驗(yàn)證各個(gè)交易(core/committer/txvalidator/validator.go的validateTx函數(shù)),最后統(tǒng)一整理結(jié)果,驗(yàn)證交易還是有些復(fù)雜的,大致包括:
??a)檢查交易信息格式是否正確(core/common/validation/msgvalidation.go的ValidateTransaction);
??b)檢查channel是否存在;
??c)檢查交易是否重復(fù);
??d)如果是交易,使用vscc檢查(core/committer/txvalidator/vscc_validator.go的VSCCValidateTx函數(shù));如果是config,則apply(core/committer/txvalidator/validator.go的Apply接口)。
最后會(huì)設(shè)置metadata的BlockMetadataIndex_TRANSACTIONS_FILTER的值。
可以閱讀Validate函數(shù)的注釋信息:

// Validate performs the validation of a block. The validation
// of each transaction in the block is performed in parallel.
// The approach is as follows: the committer thread starts the
// tx validation function in a goroutine (using a semaphore to cap
// the number of concurrent validating goroutines). The committer
// thread then reads results of validation (in orderer of completion
// of the goroutines) from the results channel. The goroutines
// perform the validation of the txs in the block and enqueue the
// validation result in the results channel. A few note-worthy facts:
// 1) to keep the approach simple, the committer thread enqueues
//    all transactions in the block and then moves on to reading the
//    results.
// 2) for parallel validation to work, it is important that the
//    validation function does not change the state of the system.
//    Otherwise the order in which validation is perform matters
//    and we have to resort to sequential validation (or some locking).
//    This is currently true, because the only function that affects
//    state is when a config transaction is received, but they are
//    guaranteed to be alone in the block. If/when this assumption
//    is violated, this code must be changed.
func (v *TxValidator) Validate(block *common.Block) error {

3)計(jì)算區(qū)塊中自己擁有的私有數(shù)據(jù)(這里自己擁有指payload中含有)的hash(gossip/privdata/coordinator.go的computeOwnedRWsets函數(shù));
4)檢查缺少的私有數(shù)據(jù),從自己的transient store中獲?。╣ossip/privdata/coordinator.go的listMissingPrivateData函數(shù)):
??a)檢查metadata的BlockMetadataIndex_TRANSACTIONS_FILTER和實(shí)際交易數(shù)目,獲取可以擁有私密數(shù)據(jù)的交易(gossip/privdata/coordinator.go的forEachTxn函數(shù)和inspectTransaction函數(shù));
??b)從Transient Store中獲取缺少的私有數(shù)據(jù)(gossip/privdata/coordinator.go的fetchMissingFromTransientStore函數(shù)—>fetchFromTransientStore函數(shù));
??c)整理數(shù)據(jù);
5)在指定的時(shí)間(peer.gossip.pvtData.pullRetryThreshold)內(nèi),從其他peer獲取缺少的私有數(shù)據(jù)(gossip/privdata/coordinator.go的fetchFromPeers函數(shù)),存入Transient Store中;
6)提交區(qū)塊和私有數(shù)據(jù)(core/committer/committer_impl.go的CommitWithPvtData函數(shù)),詳情見下分析;
7)依據(jù)高度,清理私密數(shù)據(jù)。

CommitWithPvtData函數(shù)處理

// CommitWithPvtData commits blocks atomically with private data
func (lc *LedgerCommitter) CommitWithPvtData(blockAndPvtData *ledger.BlockAndPvtData) error {
   // Do validation and whatever needed before
   // committing new block
   if err := lc.preCommit(blockAndPvtData.Block); err != nil {
      return err
   }

   // Committing new block
   if err := lc.PeerLedgerSupport.CommitWithPvtData(blockAndPvtData); err != nil {
      return err
   }

   // post commit actions, such as event publishing
   lc.postCommit(blockAndPvtData.Block)

   return nil
}

以上代碼內(nèi)容包括:
??a)如果是config block,調(diào)用系統(tǒng)合約cscc升級(jí)配置(core/committer/committer_impl.go的preCommit函數(shù));
??b)提交區(qū)塊和私密數(shù)據(jù)(core/ledger/kvledger/kv_ledger.go的CommitWithPvtData函數(shù))。

// CommitWithPvtData commits the block and the corresponding pvt data in an atomic operation
func (l *kvLedger) CommitWithPvtData(pvtdataAndBlock *ledger.BlockAndPvtData) error {
   var err error
   block := pvtdataAndBlock.Block
   blockNo := pvtdataAndBlock.Block.Header.Number

   logger.Debugf("Channel [%s]: Validating state for block [%d]", l.ledgerID, blockNo)
   // 檢查和準(zhǔn)備工作
   err = l.txtmgmt.ValidateAndPrepare(pvtdataAndBlock, true)
   if err != nil {
      return err
   }

   logger.Debugf("Channel [%s]: Committing block [%d] to storage", l.ledgerID, blockNo)

   l.blockAPIsRWLock.Lock()
   defer l.blockAPIsRWLock.Unlock()
   // 提交區(qū)塊和私密數(shù)據(jù)
   if err = l.blockStore.CommitWithPvtData(pvtdataAndBlock); err != nil {
      return err
   }
   logger.Infof("Channel [%s]: Committed block [%d] with %d transaction(s)", l.ledgerID, block.Header.Number, len(block.Data.Data))

   if utils.IsConfigBlock(block) {
      if err := l.WriteConfigBlockToSpecFile(block); err != nil {
         logger.Errorf("Failed to write config block to file for %s", err)
      }
   }

   logger.Debugf("Channel [%s]: Committing block [%d] transactions to state database", l.ledgerID, blockNo)
   // 提交stateDB
   if err = l.txtmgmt.Commit(); err != nil {
      panic(fmt.Errorf(`Error during commit to txmgr:%s`, err))
   }

   // History database could be written in parallel with state and/or async as a future optimization
   if ledgerconfig.IsHistoryDBEnabled() {
      logger.Debugf("Channel [%s]: Committing block [%d] transactions to history database", l.ledgerID, blockNo)
      // 提交historyDB
      if err := l.historyDB.Commit(block); err != nil {
         panic(fmt.Errorf(`Error during commit to history db:%s`, err))
      }
   }
   return nil
}

...

// validateAndPreparePvtBatch pulls out the private write-set for the transactions that are marked as valid
// by the internal public data validator. Finally, it validates (if not already self-endorsed) the pvt rwset against the
// corresponding hash present in the public rwset
func validateAndPreparePvtBatch(block *valinternal.Block, pvtdata map[uint64]*ledger.TxPvtData) (*privacyenabledstate.PvtUpdateBatch, error) {

CommitWithPvtData函數(shù)主要關(guān)注以下內(nèi)容:
????i)檢查和準(zhǔn)備工作(core/ledger/kvledger/txmgmt/txmgr/lockbasedtxmgr/lockbased_txmgr.go的ValidateAndPrepare函數(shù)):等待pvtdata清理完畢(core/ledger/kvledger/txmgmt/pvtstatepurgemgmt/purge_mgr.go的WaitForPrepareToFinish函數(shù)),檢查和準(zhǔn)備batch(core/ledger/kvledger/txmgmt/validator/valimpl/default_impl.go的ValidateAndPrepareBatch函數(shù)),主要關(guān)注私密數(shù)據(jù)的檢查,排除沒有權(quán)限的數(shù)據(jù),驗(yàn)證hash正確性;開啟state change監(jiān)聽;
????ii)寫入?yún)^(qū)塊(core/ledger/ledgerstorage/store.go的CommitWithPvtData函數(shù)),私密數(shù)據(jù)存入leveldb,位于/var/hyperledger/production/pvtdataStore路徑下,進(jìn)行清理操作;
????iii)提交交易數(shù)據(jù)(core/ledger/kvledger/txmgmt/txmgr/lockbasedtxmgr/lockbased_txmgr.go的Commit函數(shù)):開啟私密數(shù)據(jù)清理準(zhǔn)備,對過期的數(shù)據(jù)添加清理標(biāo)記,提交更新數(shù)據(jù)updates(包括私密數(shù)據(jù)和hash數(shù)據(jù)),清理操作;
????iv)提交歷史數(shù)據(jù)。
??c)發(fā)送事件消息(core/committer/committer_impl.go的postCommit函數(shù))。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容