Hyperledger Fabric私有数据(Private Data Collections)源码分析

说明:

基于Hyperledger Fabric 1.2的源码;
自己阅读源码也是在学习和摸索中,有错误的话欢迎指正,也有不少还不懂的地方,欢迎指导和讨论。

主要流程

【接收交易proposal的过程】

  1. peer在调用chaincode执行交易后,会依据collection配置在有权限的peer间散播私有数据,并存储私有数据到自己的transient store。
  2. 其他接收到散播私有数据的peer,会将私有数据存储到自己的transient store。

【接收Block的过程】

  1. peer最终接收到block后,会检查自己是否有权限获取私有数据,有权限的话从transient store中拿数据,如果没有,则去其他peer上获取,最终私有数据会存储到pvtdataStore中。

源码分析

A. 接收交易proposal

在peer节点处理proposal(core/endorser/endorser.go的ProcessProposal函数)的过程中,执行模拟操作(SimulateProposal函数)环节中,调用chaincode执行交易以后,会判断读写集结果中是否包含私有数据,如果包含,则执行以下操作(代码如下):

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")
   }
   // 汇总私有数据collection配置和私有数据信息
   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中,依据collection配置,散播私有数据
   if err := e.distributePrivateData(chainID, txid, pvtDataWithConfig, endorsedAt); err != nil {
      return nil, nil, nil, nil, err
   }
}

以上代码内容包括:

  1. 汇总私有数据collection配置和私有数据信息(core/endorser/pvtrwset_assembler.go的AssemblePvtRWSet函数):
    根据读写集中私有数据的合约名(namespace),从系统合约lscc处获取其对应的collection配置(即实例化合约时提供的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之间分发私有数据:
    调用distributePrivateData函数,该函数在生成EndorserServer时由外部传值建立(peer/node/start.go的serve函数),即privDataDist函数(代码如下)。
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的唯一实例(gossip/service/gossip_service.go的gossipServiceInstance),调用函数(gossip/service/gossip_service.go的DistributePrivateData函数)完成在channel内部的私有数据传输(代码如下):

// 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()
   // 根据channel名称获取私有数据的handler
   handler, exists := g.privateHandlers[chainID]
   g.lock.RUnlock()
   if !exists {
      return errors.Errorf("No private data handler for %s", chainID)
   }

   // 依据collection配置散播私有数据
   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
}

以上函数包括如下步骤:
1)根据channel名称chainID获取私有数据的handler;
2)依据collection配置散播私有数据(gossip/privdata/distributor.go的Distribute函数,代码如下):

// 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函数包括以下步骤:
a)制定散播方案(gossip/privdata/distributor.go的computeDisseminationPlan函数):根据collection配置获取策略,设置过滤器,生成私有数据消息,生成散播方案(dissemination的列表,dissemination包含msg和criteria);
b)根据散播方案,开启协程启动散播(散播最终调用的函数需要收到接收端的ACK);
c)存储私有数据到Transient Store(core/transientstore/store.go的PersistWithConfig函数):创建comisiteKey为键值(包括txid, uuid和blockHeight),存储私有数据,并创建两个用于后期清理的索引(compositeKeyPurgeIndexByHeight和compositeKeyPurgeIndexByTxid),数据最后存储在leveldb中,位于/var/hyperledger/production/transientStore路径下。

B. 接收Block和私有数据

在peer启动时会启动Gossip服务,其中会启动接收消息的协程
(调用过程:peer/node/start.go的serve函数—>core/peer/peer.go的Initialize函数—>core/peer/peer.go的createChain函数—>gossip/service/gossip_service.go的InitializeChannel函数—>gossip/state/state.go的NewGossipStateProvider函数)
主要关注以下代码:

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

1. listen函数处理

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类型,即区块)

协程调用queueNewMessage函数(gossip/state/state.go的queueNewMessage函数),将同一channel的信息放到payload中(gossip/state/state.go的addPayload函数)(代码如下)。

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类型,即远程peer的请求或响应信息)

该通道的消息是经过remoteStateMsgFilter筛选的(主要检查消息内容,channel权限),协程调用dispatch函数(gossip/state/state.go的dispatch函数),分别处理state message和私有数据信息。这里主要关注私有数据,如果获得私有数据,则进行处理(gossip/state/state.go的privateDataMessage函数):
a)检查channel,需要一致;
b)获取私有数据信息和collection配置信息;
c)写入Transient Store(最终还是调用core/transientstore/store.go的PersistWithConfig函数);
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函数处理

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中获取区块,读取区块和私有数据,提交区块(gossip/state/state.go的commitBlock函数):
其中提交区块函数commitBlock包括:存储区块(gossip/privdata/coordinator.go的StoreBlock函数),更新ledger高度。

其中,存储区块函数StoreBlock包括:
1)检查区块数据和header不为空;
2)验证区块(core/committer/txvalidator/validator.go的Validate函数):协程验证各个交易(core/committer/txvalidator/validator.go的validateTx函数),最后统一整理结果,验证交易还是有些复杂的,大致包括:
  a)检查交易信息格式是否正确(core/common/validation/msgvalidation.go的ValidateTransaction);
  b)检查channel是否存在;
  c)检查交易是否重复;
  d)如果是交易,使用vscc检查(core/committer/txvalidator/vscc_validator.go的VSCCValidateTx函数);如果是config,则apply(core/committer/txvalidator/validator.go的Apply接口)。
最后会设置metadata的BlockMetadataIndex_TRANSACTIONS_FILTER的值。
可以阅读Validate函数的注释信息:

// 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)计算区块中自己拥有的私有数据(这里自己拥有指payload中含有)的hash(gossip/privdata/coordinator.go的computeOwnedRWsets函数);
4)检查缺少的私有数据,从自己的transient store中获取(gossip/privdata/coordinator.go的listMissingPrivateData函数):
  a)检查metadata的BlockMetadataIndex_TRANSACTIONS_FILTER和实际交易数目,获取可以拥有私密数据的交易(gossip/privdata/coordinator.go的forEachTxn函数和inspectTransaction函数);
  b)从Transient Store中获取缺少的私有数据(gossip/privdata/coordinator.go的fetchMissingFromTransientStore函数—>fetchFromTransientStore函数);
  c)整理数据;
5)在指定的时间(peer.gossip.pvtData.pullRetryThreshold)内,从其他peer获取缺少的私有数据(gossip/privdata/coordinator.go的fetchFromPeers函数),存入Transient Store中;
6)提交区块和私有数据(core/committer/committer_impl.go的CommitWithPvtData函数),详情见下分析;
7)依据高度,清理私密数据。

CommitWithPvtData函数处理

// 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
}

以上代码内容包括:
  a)如果是config block,调用系统合约cscc升级配置(core/committer/committer_impl.go的preCommit函数);
  b)提交区块和私密数据(core/ledger/kvledger/kv_ledger.go的CommitWithPvtData函数)。

// 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)
   // 检查和准备工作
   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()
   // 提交区块和私密数据
   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函数主要关注以下内容:
    i)检查和准备工作(core/ledger/kvledger/txmgmt/txmgr/lockbasedtxmgr/lockbased_txmgr.go的ValidateAndPrepare函数):等待pvtdata清理完毕(core/ledger/kvledger/txmgmt/pvtstatepurgemgmt/purge_mgr.go的WaitForPrepareToFinish函数),检查和准备batch(core/ledger/kvledger/txmgmt/validator/valimpl/default_impl.go的ValidateAndPrepareBatch函数),主要关注私密数据的检查,排除没有权限的数据,验证hash正确性;开启state change监听;
    ii)写入区块(core/ledger/ledgerstorage/store.go的CommitWithPvtData函数),私密数据存入leveldb,位于/var/hyperledger/production/pvtdataStore路径下,进行清理操作;
    iii)提交交易数据(core/ledger/kvledger/txmgmt/txmgr/lockbasedtxmgr/lockbased_txmgr.go的Commit函数):开启私密数据清理准备,对过期的数据添加清理标记,提交更新数据updates(包括私密数据和hash数据),清理操作;
    iv)提交历史数据。
  c)发送事件消息(core/committer/committer_impl.go的postCommit函数)。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容