我是靠谱客的博主 要减肥含羞草,最近开发中收集的这篇文章主要介绍2019-11-30-BitCoin_MessagesBitCoin 报文格式,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

BitCoin 报文格式

分析比特币源码:

Main.cpp

ProcessMessage 处理不同类型的协议信息

chainParam.h protocol.h 一些报文的参数

COMMAND_SIZE=12, 报文头,数据包类型的ASCII表示形式,如果数据包内容是空的,用NULL填充

MESSAGE_SIZE_SIZE=sizeof(int), 数据内容的长度

CHECKSUM_SIZE=sizeof(int), 校验项目

#define MESSAGE_START_SIZE 4 报文头部长度

net.h net.cpp

PushMessage 封装数据包。报文头+数据内容+偏移长度+检验码

比特币网络通信中使用基于Tcp的通信协议

协议报文解析

1.协议头

通信发送的数据包中有一个报文的头部,格式是固定的,头部中包含一些内容的简介,比如数据包的长度、校验等。

在protocol.h中,定义了class CMessageHeader报文头类,主要信息有:单位字节(Byte)

/** Message header.

* (4) message start. 报文头存放的字节长度

* (12) command. 存放报文数据类别,根据command字段

* (4) size. 数据内容的长度

* (4) checksum. 校验值

*/

COMMAND_SIZE=12, //command字段的长度

MESSAGE_SIZE_SIZE=sizeof(int), //报文长度的长度

CHECKSUM_SIZE=sizeof(int), //校验值的长度

MESSAGE_SIZE_OFFSET=MESSAGE_START_SIZE+COMMAND_SIZE, //偏移量,size字段的索引值

CHECKSUM_OFFSET=MESSAGE_SIZE_OFFSET+MESSAGE_SIZE_SIZE,//偏移量,checksum字段的索引值

HEADER_SIZE=MESSAGE_START_SIZE+COMMAND_SIZE+MESSAGE_SIZE_SIZE+CHECKSUM_SIZE//整个报文头的总长度

​ char pchMessageStart[MESSAGE_START_SIZE]; //

​ char pchCommand[COMMAND_SIZE]; //存放command的字符串数组

在chainparama.h中定义了#define MESSAGE_START_SIZE 4

版本交互

以版本为例进行分析。首次与比特币网络节点(无论是和哪个节点通信,相互的交换版本信息是首先要进行的)通信发送的消息是版本信息。因为比特币网络中各种版本不兼容(去中心化的一个网络拓扑,版本不一致很正常)的话是不方便通信的。我们先来看看确认版本信息的几次握手示意过程:

  • 本地节点(即将加入到比特币网络的节点)发送VERSION报文至某一个远程节点(网络节点);
  • 远程节点发送VERSION报文;
  • 远程节点回复VERACK报文;
  • 远程节点设置协议版本为两者的最小值;
  • 本地节点回复VERACK报文;
  • 本地节点设置协议版本为两者的最小值;

在net.cpp中,

void CNode::PushVersion()

{int nBestHeight = g_signals.GetHeight().get_value_or(0);/// when NTP implemented, change to just nTime = GetAdjustedTime()int64_t nTime = (fInbound ? GetAdjustedTime() : GetTime());

​    CAddress addrYou = (addr.IsRoutable() && !IsProxy(addr) ? addr : CAddress(CService("0.0.0.0",0)));

​    CAddress addrMe = GetLocalAddress(&addr);RAND_bytes((unsigned char*)&nLocalHostNonce, sizeof(nLocalHostNonce));LogPrint("net", "send version message: version %d, blocks=%d, us=%s, them=%s, peer=%sn", PROTOCOL_VERSION, nBestHeight, addrMe.ToString(), addrYou.ToString(), addr.ToString());PushMessage("version", PROTOCOL_VERSION, nLocalServices, nTime, addrYou, addrMe,

​                nLocalHostNonce, FormatSubVersion(CLIENT_NAME, CLIENT_VERSION, std::vector<string>()), nBestHeight, true);

}

上述代码中,关键的一个函数是,PushMessage.(net.h中)

PushMessage(“version”, PROTOCOL_VERSION, nLocalServices, nTime, addrYou, addrMe,nLocalHostNonce, FormatSubVersion(CLIENT_NAME, CLIENT_VERSION, std::vector()), nBestHeight, true);

"version"作为报文command,传参到PushMessage中的BeginMessage。(net.h中)

template<typename T1, typename T2, typename T3, typename T4, typename T5, typename T6, typename T7, typename T8, typename T9>
    void PushMessage(const char* pszCommand, const T1& a1, const T2& a2, const T3& a3, const T4& a4, const T5& a5, const T6& a6, const T7& a7, const T8& a8, const T9& a9)
    {
        try
        {
            BeginMessage(pszCommand);
            ssSend << a1 << a2 << a3 << a4 << a5 << a6 << a7 << a8 << a9;
            EndMessage();
        }
        catch (...)
        {
            AbortMessage();
            throw;
        }
    }

PushMessage是一个多态重载函数,最多有9个模版变量作为参数变量。

先关心pszCommand,在pushversion函数中,传递的是“version”这个变量,进入了BeginMessage函数中。

 void BeginMessage(const char* pszCommand) EXCLUSIVE_LOCK_FUNCTION(cs_vSend)
    {
        ENTER_CRITICAL_SECTION(cs_vSend);
        assert(ssSend.size() == 0);
        ssSend << CMessageHeader(pszCommand, 0);
        LogPrint("net", "sending: %s ", SanitizeString(pszCommand));
    }

ssSend是CNode类(/** Information about a peer 这个类中存放节点的一些信息*/)中,CDataStream ssSend;定义的一个数据流对象,这个类是用来管理底层数据格式编码流入流出的。具体先不用管。

ssSend << CMessageHeader(pszCommand, 0);表明要对报文头进行初始化。

CMessageHeader::CMessageHeader(const char* pszCommand, unsigned int nMessageSizeIn)
{
    memcpy(pchMessageStart, Params().MessageStart(), MESSAGE_START_SIZE);
    strncpy(pchCommand, pszCommand, COMMAND_SIZE);
    nMessageSize = nMessageSizeIn;
    nChecksum = 0;
}

strncpy(pchCommand, pszCommand, COMMAND_SIZE);把参数pszCommand拷贝到了类CMessageHeader内变量pchCommand当中进行存储。把这个CMessageHeader对象 << 输入流 进入到ssSend当中。

我怀疑使用了以下的宏,把这些数据给封装起来了,但是这个宏我还暂时研究不透。

IMPLEMENT_SERIALIZE
  (
  READWRITE(FLATDATA(pchMessageStart));
  READWRITE(FLATDATA(pchCommand));
  READWRITE(nMessageSize);
  READWRITE(nChecksum);
)

追溯到这里,完成了BeginMessage(pszCommand);这个函数的操作,即把pszCommand传进了报文头部,并进行了初始化在ssSend当中。

如果PushMessage没有其他参数,则表明只进行传递报文头,如果还有其他参数,则可以传递最多9个参数,现以version的9个参数为例。

ssSend << a1 << a2 << a3 << a4 << a5 << a6 << a7 << a8 << a9;

//9个不同的模版变量,依次传进ssSend对象变量当中。到时候也是依次对应解码。这里,version传递的是下面的9个字段变量:

字段字节描述
version——PROTOCOL_VERSION4节点使用的协议版本标识
services——nLocalServices8该连接拥有的特点(比如可以接受header信息,还可以接受blcok信息)
timestamp——nTime8时间戳(秒为单位)
addr_you——addrYou26接收者的网络地址
addr_me—— addrMe26发送者的网络地址
nonce——nLocalHostNonce8节点的随机ID,用于侦测此连接
sub_version_num——FormatSubVersion(CLIENT_NAME, CLIENT_VERSION, std::vector())可变辅助版本信息
start_height——nBestHeight4发送者的区块高度
最后一个true,不知道是啥

EndMessage函数:

   void EndMessage() UNLOCK_FUNCTION(cs_vSend)
    {
        // The -*messagestest options are intentionally not documented in the help message,
        // since they are only used during development to debug the networking code and are
        // not intended for end-users.
        if (mapArgs.count("-dropmessagestest") && GetRand(GetArg("-dropmessagestest", 2)) == 0)
        {
            LogPrint("net", "dropmessages DROPPING SEND MESSAGEn");
            AbortMessage();
            return;
        }
        if (mapArgs.count("-fuzzmessagestest"))
            Fuzz(GetArg("-fuzzmessagestest", 10));

        if (ssSend.size() == 0)
            return;

        // Set the size
        unsigned int nSize = ssSend.size() - CMessageHeader::HEADER_SIZE;
        memcpy((char*)&ssSend[CMessageHeader::MESSAGE_SIZE_OFFSET], &nSize, sizeof(nSize));

        // Set the checksum
        uint256 hash = Hash(ssSend.begin() + CMessageHeader::HEADER_SIZE, ssSend.end());
        unsigned int nChecksum = 0;
        memcpy(&nChecksum, &hash, sizeof(nChecksum));
        assert(ssSend.size () >= CMessageHeader::CHECKSUM_OFFSET + sizeof(nChecksum));
        memcpy((char*)&ssSend[CMessageHeader::CHECKSUM_OFFSET], &nChecksum, sizeof(nChecksum));

        LogPrint("net", "(%d bytes)n", nSize);

        std::deque<CSerializeData>::iterator it = vSendMsg.insert(vSendMsg.end(), CSerializeData());
        ssSend.GetAndClear(*it);
        nSendSize += (*it).size();

        // If write queue empty, attempt "optimistic write"
        if (it == vSendMsg.begin())
            SocketSendData(this);

        LEAVE_CRITICAL_SECTION(cs_vSend);
    }
        // Set the size
        unsigned int nSize = ssSend.size() - CMessageHeader::HEADER_SIZE;
        memcpy((char*)&ssSend[CMessageHeader::MESSAGE_SIZE_OFFSET], &nSize, sizeof(nSize));

此时,ssSend:报文头部+a1+a2+a3+a4+a5+a6+a7+a8+a9,则ssSend.size()-CMessageHeader::HEADER_SIZE=(a1+a2+a3+a4+a5+a6+a7+a8+a9)的长度,这个长度需要存放在偏移地址为CMessageHeader::MESSAGE_SIZE_OFFSET的地方,这个地方长sizeof(nSize)。

 // Set the checksum
        uint256 hash = Hash(ssSend.begin() + CMessageHeader::HEADER_SIZE, ssSend.end());
        unsigned int nChecksum = 0;
        memcpy(&nChecksum, &hash, sizeof(nChecksum));
        assert(ssSend.size () >= CMessageHeader::CHECKSUM_OFFSET + sizeof(nChecksum));
        memcpy((char*)&ssSend[CMessageHeader::CHECKSUM_OFFSET], &nChecksum, sizeof(nChecksum));

要存放校验值checksum,这个值是通过hash计算得到的。把这个hash值编码到nchecksum当中,再存进CMessageHeader::CHECKSUM_OFFSET偏移地址上,这个存放的长度为sizeof(nChecksum)。

至此,PushMessage函数的主要功能就捋清楚了。在PushVersion当中,封装了带有“version”的报文头,还有节点使用的协议版本标识、该连接拥有的特点(比如可以接受header信息,还可以接受blcok信息)、时间戳(秒为单位)、接收者的网络地址、发送者的网络地址、节点的随机ID、辅助版本信息、发送者的区块高度、还有一个true不知道是干啥的。

接下来,看处理报文的函数。

在net.cpp当中,有一个处理messages的线程。

  // Process messages
    threadGroup.create_thread(boost::bind(&TraceThread<void (*)()>, "msghand", &ThreadMessageHandler));

线程中循环处理 ProcessMessages(pnode) 这个函数。

void ThreadMessageHandler()
{
    SetThreadPriority(THREAD_PRIORITY_BELOW_NORMAL);
    while (true)
    {
        bool fHaveSyncNode = false;

        vector<CNode*> vNodesCopy;
        {
            LOCK(cs_vNodes);
            vNodesCopy = vNodes;
            BOOST_FOREACH(CNode* pnode, vNodesCopy) {
                pnode->AddRef();
                if (pnode == pnodeSync)
                    fHaveSyncNode = true;
            }
        }

        if (!fHaveSyncNode)
            StartSync(vNodesCopy);

        // Poll the connected nodes for messages
        CNode* pnodeTrickle = NULL;
        if (!vNodesCopy.empty())
            pnodeTrickle = vNodesCopy[GetRand(vNodesCopy.size())];

        bool fSleep = true;

        BOOST_FOREACH(CNode* pnode, vNodesCopy)
        {
            if (pnode->fDisconnect)
                continue;

            // Receive messages
            {
                TRY_LOCK(pnode->cs_vRecvMsg, lockRecv);
                if (lockRecv)
                {
                    if (!g_signals.ProcessMessages(pnode))
                        pnode->CloseSocketDisconnect();

                    if (pnode->nSendSize < SendBufferSize())
                    {
                        if (!pnode->vRecvGetData.empty() || (!pnode->vRecvMsg.empty() && pnode->vRecvMsg[0].complete()))
                        {
                            fSleep = false;
                        }
                    }
                }
            }
            boost::this_thread::interruption_point();

            // Send messages
            {
                TRY_LOCK(pnode->cs_vSend, lockSend);
                if (lockSend)
                    g_signals.SendMessages(pnode, pnode == pnodeTrickle);
            }
            boost::this_thread::interruption_point();
        }

        {
            LOCK(cs_vNodes);
            BOOST_FOREACH(CNode* pnode, vNodesCopy)
                pnode->Release();
        }

        if (fSleep)
            MilliSleep(100);
    }
}

// requires LOCK(cs_vRecvMsg)
bool ProcessMessages(CNode* pfrom)
{
    //if (fDebug)
    //    LogPrintf("ProcessMessages(%u messages)n", pfrom->vRecvMsg.size());

    //
    // Message format
    //  (4) message start
    //  (12) command
    //  (4) size
    //  (4) checksum
    //  (x) data
    //
    bool fOk = true;

    if (!pfrom->vRecvGetData.empty())
        ProcessGetData(pfrom);

    // this maintains the order of responses
    if (!pfrom->vRecvGetData.empty()) return fOk;

    std::deque<CNetMessage>::iterator it = pfrom->vRecvMsg.begin();
    while (!pfrom->fDisconnect && it != pfrom->vRecvMsg.end()) {
        // Don't bother if send buffer is too full to respond anyway
        if (pfrom->nSendSize >= SendBufferSize())
            break;

        // get next message
        CNetMessage& msg = *it;

        //if (fDebug)
        //    LogPrintf("ProcessMessages(message %u msgsz, %u bytes, complete:%s)n",
        //            msg.hdr.nMessageSize, msg.vRecv.size(),
        //            msg.complete() ? "Y" : "N");

        // end, if an incomplete message is found
        if (!msg.complete())
            break;

        // at this point, any failure means we can delete the current message
        it++;

        // Scan for message start
        if (memcmp(msg.hdr.pchMessageStart, Params().MessageStart(), MESSAGE_START_SIZE) != 0) {
            LogPrintf("PROCESSMESSAGE: INVALID MESSAGESTART %sn", SanitizeString(msg.hdr.GetCommand()));
            fOk = false;
            break;
        }

        // Read header
        CMessageHeader& hdr = msg.hdr;
        if (!hdr.IsValid())
        {
            LogPrintf("PROCESSMESSAGE: ERRORS IN HEADER %sn", SanitizeString(hdr.GetCommand()));
            continue;
        }
        string strCommand = hdr.GetCommand();

        // Message size
        unsigned int nMessageSize = hdr.nMessageSize;

        // Checksum
        CDataStream& vRecv = msg.vRecv;
        uint256 hash = Hash(vRecv.begin(), vRecv.begin() + nMessageSize);
        unsigned int nChecksum = 0;
        memcpy(&nChecksum, &hash, sizeof(nChecksum));
        if (nChecksum != hdr.nChecksum)
        {
            LogPrintf("ProcessMessages(%s, %u bytes): CHECKSUM ERROR nChecksum=%08x hdr.nChecksum=%08xn",
               SanitizeString(strCommand), nMessageSize, nChecksum, hdr.nChecksum);
            continue;
        }

        // Process message
        bool fRet = false;
        try
        {
            fRet = ProcessMessage(pfrom, strCommand, vRecv);
            boost::this_thread::interruption_point();
        }
        catch (std::ios_base::failure& e)
        {
            pfrom->PushMessage("reject", strCommand, REJECT_MALFORMED, string("error parsing message"));
            if (strstr(e.what(), "end of data"))
            {
                // Allow exceptions from under-length message on vRecv
                LogPrintf("ProcessMessages(%s, %u bytes): Exception '%s' caught, normally caused by a message being shorter than its stated lengthn", SanitizeString(strCommand), nMessageSize, e.what());
            }
            else if (strstr(e.what(), "size too large"))
            {
                // Allow exceptions from over-long size
                LogPrintf("ProcessMessages(%s, %u bytes): Exception '%s' caughtn", SanitizeString(strCommand), nMessageSize, e.what());
            }
            else
            {
                PrintExceptionContinue(&e, "ProcessMessages()");
            }
        }
        catch (boost::thread_interrupted) {
            throw;
        }
        catch (std::exception& e) {
            PrintExceptionContinue(&e, "ProcessMessages()");
        } catch (...) {
            PrintExceptionContinue(NULL, "ProcessMessages()");
        }

        if (!fRet)
            LogPrintf("ProcessMessage(%s, %u bytes) FAILEDn", SanitizeString(strCommand), nMessageSize);

        break;
    }

    // In case the connection got shut down, its receive buffer was wiped
    if (!pfrom->fDisconnect)
        pfrom->vRecvMsg.erase(pfrom->vRecvMsg.begin(), it);

    return fOk;
}

这里就把之前pushMessage拼接的包给拆解了。

获取了strCommand,包中command字段已经被解析。

// Read header
        CMessageHeader& hdr = msg.hdr;
        if (!hdr.IsValid())
        {
            LogPrintf("PROCESSMESSAGE: ERRORS IN HEADER %sn", SanitizeString(hdr.GetCommand()));
            continue;
        }
        string strCommand = hdr.GetCommand();


报文长度

 // Message size
        unsigned int nMessageSize = hdr.nMessageSize;

检查校验值是否改变

 // Checksum
        CDataStream& vRecv = msg.vRecv;
        uint256 hash = Hash(vRecv.begin(), vRecv.begin() + nMessageSize);
        unsigned int nChecksum = 0;
        memcpy(&nChecksum, &hash, sizeof(nChecksum));
        if (nChecksum != hdr.nChecksum)
        {
            LogPrintf("ProcessMessages(%s, %u bytes): CHECKSUM ERROR nChecksum=%08x hdr.nChecksum=%08xn",
               SanitizeString(strCommand), nMessageSize, nChecksum, hdr.nChecksum);
            continue;
        }

ProcessMessage针对不同的command字段,做不同的处理

// Process message
        bool fRet = false;
        try
        {
            fRet = ProcessMessage(pfrom, strCommand, vRecv);
            boost::this_thread::interruption_point();
        }

在ProcessMessage中,举version的例子:

if (strCommand == "version")
    {
        // Each connection can only send one version message
  			// 一个连接只能发送一个版本信息,不能发送多个,不等于0说明之前已经处理过一次了,已经接收过一次了
        if (pfrom->nVersion != 0)
        {
            pfrom->PushMessage("reject", strCommand, REJECT_DUPLICATE, string("Duplicate version message"));
            Misbehaving(pfrom->GetId(), 1);
            return false;
        }

        int64_t nTime;
        CAddress addrMe;
        CAddress addrFrom;
        uint64_t nNonce = 1;
  		//解析发过来的包,和封包时的顺序一致
        vRecv >> pfrom->nVersion >> pfrom->nServices >> nTime >> addrMe;
  		// 如果发过来的版本比约定的最低版本还小,则发送reject,并断开连接
        if (pfrom->nVersion < MIN_PEER_PROTO_VERSION)
        {
            // disconnect from peers older than this proto version
            LogPrintf("partner %s using obsolete version %i; disconnectingn", pfrom->addr.ToString(), pfrom->nVersion);
            pfrom->PushMessage("reject", strCommand, REJECT_OBSOLETE,
                               strprintf("Version must be %d or greater", MIN_PEER_PROTO_VERSION));
            pfrom->fDisconnect = true;
            return false;
        }

        if (pfrom->nVersion == 10300)
            pfrom->nVersion = 300;
  
  			//继续解析包
        if (!vRecv.empty())
            vRecv >> addrFrom >> nNonce;
        if (!vRecv.empty()) {
            vRecv >> LIMITED_STRING(pfrom->strSubVer, 256);
            pfrom->cleanSubVer = SanitizeString(pfrom->strSubVer);
        }
        if (!vRecv.empty())
            vRecv >> pfrom->nStartingHeight;
  		//这个是前面我没搞明白的true的包解析
  		// 说是为了设置之后的第一个信息过滤
        if (!vRecv.empty())
            vRecv >> pfrom->fRelayTxes; // set to true after we get the first filter* message
        else
            pfrom->fRelayTxes = true;

  // 上面是解析数据
  // 下面就是获取发来过的报文之后的处理了
        if (pfrom->fInbound && addrMe.IsRoutable())
        {
            pfrom->addrLocal = addrMe;
            SeenLocal(addrMe);
        }

        // Disconnect if we connected to ourself
        if (nNonce == nLocalHostNonce && nNonce > 1)
        {
            LogPrintf("connected to self at %s, disconnectingn", pfrom->addr.ToString());
            pfrom->fDisconnect = true;
            return true;
        }

        // Be shy and don't send version until we hear
        if (pfrom->fInbound)
            pfrom->PushVersion();

        pfrom->fClient = !(pfrom->nServices & NODE_NETWORK);


        // Change version
        pfrom->PushMessage("verack");			//这里push是仅仅带了报文头而已
  			// 改变版本号
        pfrom->ssSend.SetVersion(min(pfrom->nVersion, PROTOCOL_VERSION));

        if (!pfrom->fInbound)
        {
            // Advertise our address
            if (!fNoListen && !IsInitialBlockDownload())
            {
                CAddress addr = GetLocalAddress(&pfrom->addr);
                if (addr.IsRoutable())
                    pfrom->PushAddress(addr);
            }

            // Get recent addresses
            if (pfrom->fOneShot || pfrom->nVersion >= CADDR_TIME_VERSION || addrman.size() < 1000)
            {
                pfrom->PushMessage("getaddr");
                pfrom->fGetAddr = true;
            }
            addrman.Good(pfrom->addr);
        } else {
            if (((CNetAddr)pfrom->addr) == (CNetAddr)addrFrom)
            {
                addrman.Add(addrFrom, addrFrom);
                addrman.Good(addrFrom);
            }
        }

        // Relay alerts
        {
            LOCK(cs_mapAlerts);
            BOOST_FOREACH(PAIRTYPE(const uint256, CAlert)& item, mapAlerts)
                item.second.RelayTo(pfrom);
        }

        pfrom->fSuccessfullyConnected = true;

        LogPrintf("receive version message: %s: version %d, blocks=%d, us=%s, them=%s, peer=%sn", pfrom->cleanSubVer, pfrom->nVersion, pfrom->nStartingHeight, addrMe.ToString(), addrFrom.ToString(), pfrom->addr.ToString());

        AddTimeData(pfrom->addr, nTime);
    }

太久远了,这里在提醒一下:

  • 本地节点(即将加入到比特币网络的节点)发送VERSION报文至某一个远程节点(网络节点);
  • 远程节点发送VERSION报文;
  • 远程节点回复VERACK报文;
  • 远程节点设置协议版本为两者的最小值;
  • 本地节点回复VERACK报文;
  • 本地节点设置协议版本为两者的最小值;

这里是处理收到的报文是verack的情况:取两者的最小值

    else if (strCommand == "verack")
    {
        pfrom->SetRecvVersion(min(pfrom->nVersion, PROTOCOL_VERSION));
    }

A:pushVersion ===> B:provessVersion ===> B:(provessVersion中)pushVersion ===> A:provessVersion & B:(provessVersion中)pushVerAck ===> A:(provessVersion中)pushVersion(但是B已经process一次了,退出) & A:(processVerAck)SetVersion & B:(provessVersion中)SetVersion ===>. A:(provessVersion中)pushVerAck ===> A:(provessVersion中)SetVersion & B:(provessVersion中)SetVersion

有点怪怪的。。。

参考:

1.https://github.com/bitcoin/bitcoin

2.https://www.gopherliu.com/2018/12/02/details-of-bitcoin-network/

最后

以上就是要减肥含羞草为你收集整理的2019-11-30-BitCoin_MessagesBitCoin 报文格式的全部内容,希望文章能够帮你解决2019-11-30-BitCoin_MessagesBitCoin 报文格式所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(58)

评论列表共有 0 条评论

立即
投稿
返回
顶部