一文讀懂即時通訊應用中的網絡心跳包機制:作用、原理、實現思路等


本文原文由作者“張小方”原創發布於“高性能服務器開發”微信公眾號,原題《心跳包機制設計詳解》,即時通訊網收錄時有改動。

1、引言

一般來說,沒有真正動手做過網絡通信應用的開發者,很難想象即時通訊應用中的心跳機制的作用。但不可否認,作為即時通訊應用,心跳機制是其網絡通信技術底層中非常重要的一環,有沒有心跳機制、心跳機制的算法實現好壞,都將直接影響即時通訊應用在應用層的表現——比如:實時性、斷網自愈能力、弱網體驗等等。

總之,要想真正理解即時通訊應用底層的開發,心跳機制必須掌握,而這也是本文寫作的目的,希望能帶給你啟發。

需要說明的是:本文中涉及的示例代碼是使用 C/C++ 語言編寫,但是本文中介紹的心跳包機制設計思路和注意事項,都是是些普適性原理,同樣適用於其他編程語言。雖然語言可以不同,但邏輯不會有差別!

學習交流:

- 即時通訊/推送技術開發交流4群:101279154[推薦]

- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM

(本文同步發布於:http://www.52im.net/thread-2697-1-1.html

2、相關文章

3、為什么需要心跳機制?

考慮以下兩種典型的即時通訊網絡層問題情型:

1)情形一:一個客戶端連接服務器以后,如果長期沒有和服務器有數據來往,可能會被防火牆程序關閉連接,有時候我們並不想要被關閉連接。例如,對於一個即時通訊軟件來說,如果服務器沒有消息時,我們確實不會和服務器有任何數據交換,但是如果連接被關閉了,有新消息來時,我們再也沒法收到了,這就違背了“即時通訊”的設計要求。

2)情形二:通常情況下,服務器與某個客戶端一般不是位於同一個網絡,其之間可能經過數個路由器和交換機,如果其中某個必經路由器或者交換器出現了故障,並且一段時間內沒有恢復,導致這之間的鏈路不再暢通,而此時服務器與客戶端之間也沒有數據進行交換,由於 TCP 連接是狀態機,對於這種情況,無論是客戶端或者服務器都無法感知與對方的連接是否正常,這類連接我們一般稱之為“死鏈”。

對於上述問題情型,即時通訊應用通常的解決思路:

1)針對情形一:此應用場景要求必須保持客戶端與服務器之間的連接正常,就是我們通常所說的“保活“。如上所述,當服務器與客戶端一定時間內沒有有效業務數據來往時,我們只需要給對端發送心跳包即可實現保活。

2)針對情形二:要解決死鏈問題,只要我們此時任意一端給對端發送一個數據包即可檢測鏈路是否正常,這類數據包我們也稱之為”心跳包”,這種操作我們稱之為“心跳檢測”。顧名思義,如果一個人沒有心跳了,可能已經死亡了;一個連接長時間沒有正常數據來往,也沒有心跳包來往,就可以認為這個連接已經不存在,為了節約服務器連接資源,我們可以通過關閉 socket,回收連接資源。

總之,心跳檢測機制一般有兩個作用:

1)保活;

2)檢測死鏈。

針對以上問題情型,即時通訊網的另一篇:《為何基於TCP協議的移動端IM仍然需要心跳保活機制?》,也非常值得一讀。

4、TCP的keepalive選項

PS:如你還不了解tcp的keepalive是什么,建議先閱讀:《TCP/IP詳解 - 第23章·TCP的保活定時器

操作系統的 TCP/IP 協議棧其實提供了這個的功能,即 keepalive 選項。在 Linux 操作系統中,我們可以通過代碼啟用一個 socket 的心跳檢測(即每隔一定時間間隔發送一個心跳檢測包給對端)。

代碼如下:

//on 是 1 表示打開 keepalive 選項,為 0 表示關閉,0 是默認值

inton = 1;

setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));

但是,即使開啟了這個選項,這個選項默認發送心跳檢測數據包的時間間隔是 7200 秒(2 小時),這時間間隔實在是太長了,一定也不使用。

我們可以通過繼續設置 keepalive 相關的三個選項來改變這個時間間隔,它們分別是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT。

示例代碼如下:

//發送 keepalive 報文的時間間隔

intval = 7200;

setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val));

//兩次重試報文的時間間隔

intinterval = 75;

setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));

intcnt = 9;

setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));

TCP_KEEPIDLE 選項設置了發送 keepalive 報文的時間間隔,發送時如果對端回復 ACK。則本端 TCP 協議棧認為該連接依然存活,繼續等 7200 秒后再發送 keepalive 報文;如果對端回復 RESET,說明對端進程已經重啟,本端的應用程序應該關閉該連接。

如果對端沒有任何回復,則本端做重試,如果重試 9 次(TCP_KEEPCNT 值)(前后重試間隔為 75 秒(TCP_KEEPINTVL 值))仍然不可達,則向應用程序返回 ETIMEOUT(無任何應答)或 EHOST 錯誤信息。

我們可以使用如下命令查看 Linux 系統上的上述三個值的設置情況:

[root@iZ238vnojlyZ ~]# sysctl -a | grep keepalive

net.ipv4.tcp_keepalive_intvl = 75

net.ipv4.tcp_keepalive_probes = 9

net.ipv4.tcp_keepalive_time = 7200

在 Windows 系統設置 keepalive 及對應選項的代碼略有不同:

//開啟 keepalive 選項

constcharon = 1;

setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char*)&on, sizeof(on);

// 設置超時詳細信息

DWORDcbBytesReturned;

tcp_keepalive klive;

// 啟用保活

klive.onoff = 1;

klive.keepalivetime = 7200;

// 重試間隔為10秒

klive.keepaliveinterval = 1000 * 10;

WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL);

5、應用層的心跳包機制設計

由於 keepalive 選項需要為每個連接中的 socket 開啟,這不一定是必須的,可能會產生大量無意義的帶寬浪費,且 keepalive 選項不能與應用層很好地交互,因此一般實際的服務開發中,還是建議讀者在應用層設計自己的心跳包機制。

那么如何設計呢?

從技術來講:心跳包其實就是一個預先規定好格式的數據包,在程序中啟動一個定時器,定時發送即可,這是最簡單的實現思路。

但是,如果通信的兩端有頻繁的數據來往,此時到了下一個發心跳包的時間點了,此時發送一個心跳包。這其實是一個流量的浪費,既然通信雙方不斷有正常的業務數據包來往,這些數據包本身就可以起到保活作用,為什么還要浪費流量去發送這些心跳包呢?

所以,對於用於保活的心跳包,我們最佳做法是:設置一個上次包時間,每次收數據和發數據時,都更新一下這個包時間,而心跳檢測計時器每次檢測時,將這個包時間與當前系統時間做一個對比,如果時間間隔大於允許的最大時間間隔(實際開發中根據需求設置成 15 ~ 45 秒不等),則發送一次心跳包。總而言之,就是在與對端之間,沒有數據來往達到一定時間間隔時才發送一次心跳包。

發心跳包的偽碼示例:

bool CIUSocket::Send()

{

    intnSentBytes = 0;

    intnRet = 0;

    while(true)

    {

        nRet = ::send(m_hSocket, m_strSendBuf.c_str(), m_strSendBuf.length(), 0);

        if(nRet == SOCKET_ERROR)

        {

            if(::WSAGetLastError() == WSAEWOULDBLOCK)

                break;

            else

            {

                LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort);

                Close();

                returnfalse;

            }

        }

        elseif(nRet < 1)

        {

            //一旦出現錯誤就立刻關閉Socket

            LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort);

            Close();

            returnfalse;

        }

        m_strSendBuf.erase(0, nRet);

        if(m_strSendBuf.empty())

            break;

 

        ::Sleep(1);

    }

    {

        //記錄一下最近一次發包時間

        std::lock_guard<std::mutex> guard(m_mutexLastDataTime);

        m_nLastDataTime = (long)time(NULL);

    }

    returntrue;

}

bool CIUSocket::Recv()

{

    intnRet = 0;

    charbuff[10 * 1024];

    while(true)

    {

        nRet = ::recv(m_hSocket, buff, 10 * 1024, 0);

        if(nRet == SOCKET_ERROR)                //一旦出現錯誤就立刻關閉Socket

        {

            if(::WSAGetLastError() == WSAEWOULDBLOCK)

                break;

            else

            {

                LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError());

                //Close();

                returnfalse;

            }

        }

        elseif(nRet < 1)

        {

            LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError());

            //Close();

            returnfalse;

        }

        m_strRecvBuf.append(buff, nRet);

        ::Sleep(1);

    }

    {

        std::lock_guard<std::mutex> guard(m_mutexLastDataTime);

        //記錄一下最近一次收包時間

        m_nLastDataTime = (long)time(NULL);

    }

    returntrue;

}

voidCIUSocket::RecvThreadProc()

{

    LOG_INFO("Recv data thread start...");

    intnRet;

    //上網方式

    DWORDdwFlags;

    BOOLbAlive;

    while(!m_bStop)

    {

        //檢測到數據則收數據

        nRet = CheckReceivedData();

        //出錯

        if(nRet == -1)

        {

            m_pRecvMsgThread->NotifyNetError();

        }

        //無數據

        elseif(nRet == 0)

        {          

            longnLastDataTime = 0;

            {

                std::lock_guard<std::mutex> guard(m_mutexLastDataTime);

                nLastDataTime = m_nLastDataTime;

            }

            if(m_nHeartbeatInterval > 0)

            {

                //當前系統時間與上一次收發數據包的時間間隔超過了m_nHeartbeatInterval

                //則發一次心跳包

                if(time(NULL) - nLastDataTime >= m_nHeartbeatInterval)

                    SendHeartbeatPackage();

            }

        }

        //有數據

        elseif(nRet == 1)

        {

            if(!Recv())

            {

                m_pRecvMsgThread->NotifyNetError();

                continue;

            }

            DecodePackages();

        }// end if

    }// end while-loop

    LOG_INFO("Recv data thread finish...");

}

同理,檢測心跳包的一端,應該是在與對端沒有數據來往達到一定時間間隔時才做一次心跳檢測。

心跳檢測一端的偽碼示例如下:

voidBusinessSession::send(constchar* pData, intdataLength)

{

    boolsent = TcpSession::send(pData, dataLength);

    //發送完數據更新下發包時間

    updateHeartbeatTime();     

}

voidBusinessSession::handlePackge(char* pMsg, intmsgLength, bool& closeSession, std::vector<std::string>& vectorResponse)

{

    //對數據合法性進行校驗

    if(pMsg == NULL || pMsg[0] == 0 || msgLength <= 0 || msgLength > MAX_DATA_LENGTH)

    {

        //非法刺探請求,不做任何應答,直接關閉連接

        closeSession = true;

        return;

    }

    //更新下收包時間

    updateHeartbeatTime();

    //省略包處理代碼...

}

voidBusinessSession::updateHeartbeatTime()

{

    std::lock_guard<std::mutex> scoped_guard(m_mutexForlastPackageTime);

    m_lastPackageTime = (int64_t)time(nullptr);

}

boolBusinessSession::doHeartbeatCheck()

{

    constConfig& cfg = Singleton<Config>::Instance();

    int64_t now = (int64_t)time(nullptr);

    std::lock_guard<std::mutex> lock_guard(m_mutexForlastPackageTime);

    if(now - m_lastPackageTime >= cfg.m_nMaxClientDataInterval)

    {

        //心跳包檢測,超時,關閉連接

        LOGE("heartbeat expired, close session");

        shutdown();

        returntrue;

    }

    return false;

}

void TcpServer::checkSessionHeartbeat()

{

    int64_t now = (int64_t)time(nullptr);

    if(now - m_nLastCheckHeartbeatTime >= m_nHeartbeatCheckInterval)

    {

        m_spSessionManager->checkSessionHeartbeat();

        m_nLastCheckHeartbeatTime = (int64_t)time(nullptr);

    }     

}

voidSessionManager::checkSessionHeartbeat()

{  

    std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);

    for(constauto& iter : m_mapSessions)

    {

        //這里調用 BusinessSession::doHeartbeatCheck()

        iter.second->doHeartbeatCheck();

    } 

}

需要注意的是:一般是客戶端主動給服務器端發送心跳包,服務器端做心跳檢測決定是否斷開連接,而不是反過來。從客戶端的角度來說,客戶端為了讓自己得到服務器端的正常服務有必要主動和服務器保持連接狀態正常,而服務器端不會局限於某個特定的客戶端,如果客戶端不能主動和其保持連接,那么就會主動回收與該客戶端的連接。當然,服務器端在收到客戶端的心跳包時應該給客戶端一個心跳應答。

6、帶業務數據的心跳包

上面介紹的心跳包是從純技術的角度來說的,在實際應用中,有時候我們需要定時或者不定時從服務器端更新一些數據,我們可以把這類數據放在心跳包中,定時或者不定時更新。

這類帶業務數據的心跳包,就不再是純粹技術上的作用了(這里說的技術的作用指的上文中介紹的心跳包起保活和檢測死鏈作用)。

這類心跳包實現也很容易,即在心跳包數據結構里面加上需要的業務字段信息,然后在定時器中定時發送,客戶端發給服務器,服務器在應答心跳包中填上約定的業務數據信息即可。

7、心跳包與流量

通常情況下,多數應用場景下,與服務器端保持連接的多個客戶端中,同一時間段活躍用戶(這里指的是與服務器有頻繁數據來往的客戶端)一般不會太多。當連接數較多時,進出服務器程序的數據包通常都是心跳包(為了保活)。所以為了減輕網絡代碼壓力,節省流量,尤其是針對一些 3/4 G 手機應用,我們在設計心跳包數據格式時應該盡量減小心跳包的數據大小。

8、心跳包與調試

如前文所述,對於心跳包,服務器端的邏輯一般是在一定時間間隔內沒有收到客戶端心跳包時會主動斷開連接。在我們開發調試程序過程中,我們可能需要將程序通過斷點中斷下來,這個過程可能是幾秒到幾十秒不等。等程序恢復執行時,連接可能因為心跳檢測邏輯已經被斷開。

調試過程中,我們更多的關注的是業務數據處理的邏輯是否正確,不想被一堆無意義的心跳包數據干擾實線。

鑒於以上兩點原因,我們一般在調試模式下關閉或者禁用心跳包檢測機制。

代碼示例大致如下:

ChatSession::ChatSession(conststd::shared_ptr<TcpConnection>& conn, intsessionid) :

TcpSession(conn),

m_id(sessionid),

m_seq(0),

m_isLogin(false)

{

    m_userinfo.userid = 0;

    m_lastPackageTime = time(NULL);

 

//這里設置了非調試模式下才開啟心跳包檢測功能

#ifndef _DEBUG

    EnableHearbeatCheck();

#endif

}

當然,你也可以將開啟心跳檢測的開關做成配置信息放入程序配置文件中。

9、心跳包與日志

實際生產環境,我們一般會將程序收到的和發出去的數據包寫入日志中,但是無業務信息的心跳包信息是個例外,一般會刻意不寫入日志,這是因為心跳包數據一般比較多,如果寫入日志會導致日志文件變得很大,且充斥大量無意義的心跳包日志,所以一般在寫日志時會屏蔽心跳包信息寫入。

我這里的建議是:可以將心跳包信息是否寫入日志做成一個配置開關,一般處於關閉狀態,有需要時再開啟。

例如,對於一個 WebSocket 服務,ping 和 pong 是心跳包數據,下面示例代碼按需輸出心跳日志信息:

void BusinessSession::send(std::string _view strResponse)

{  

    boolsuccess = WebSocketSession::send(strResponse);

 

    if(success)

    {

        boolenablePingPongLog = Singleton<Config>::Instance().m_bPingPongLogEnabled;

 

        //其他消息正常打印,心跳消息按需打印

        if(strResponse != "pong"|| enablePingPongLog)

        {

            LOGI("msg sent to client [%s], sessionId: %s, session: 0x%0x, clientId: %s, accountId: %s, frontId: %s, msg: %s",

                 getClientInfo(), m_strSessionId.c_str(), (int64_t)this, m_strClientID.c_str(), m_strAccountID.c_str(), BusinessSession::m_strFrontId.c_str(), strResponse.data());

        }

    }

}

附錄:更多相關技術文章

[1] 有關IM/推送的心跳保活處理:
應用保活終極總結(一):Android6.0以下的雙進程守護保活實踐
應用保活終極總結(二):Android6.0及以上的保活實踐(進程防殺篇)
應用保活終極總結(三):Android6.0及以上的保活實踐(被殺復活篇)
Android進程保活詳解:一篇文章解決你的所有疑問
Android端消息推送總結:實現原理、心跳保活、遇到的問題等
深入的聊聊Android消息推送這件小事
為何基於TCP協議的移動端IM仍然需要心跳保活機制?
微信團隊原創分享:Android版微信后台保活實戰分享(進程保活篇)
微信團隊原創分享:Android版微信后台保活實戰分享(網絡保活篇)
移動端IM實踐:實現Android版微信的智能心跳機制
移動端IM實踐:WhatsApp、Line、微信的心跳策略分析
Android P正式版即將到來:后台應用保活、消息推送的真正噩夢
全面盤點當前Android后台保活方案的真實運行效果(截止2019年前)
一文讀懂即時通訊應用中的網絡心跳包機制:作用、原理、實現思路等
>> 更多同類文章 ……

[2] 網絡編程基礎資料:
TCP/IP詳解 - 第11章·UDP:用戶數據報協議
TCP/IP詳解 - 第17章·TCP:傳輸控制協議
TCP/IP詳解 - 第18章·TCP連接的建立與終止
TCP/IP詳解 - 第21章·TCP的超時與重傳
技術往事:改變世界的TCP/IP協議(珍貴多圖、手機慎點)
通俗易懂-深入理解TCP協議(上):理論基礎
通俗易懂-深入理解TCP協議(下):RTT、滑動窗口、擁塞處理
理論經典:TCP協議的3次握手與4次揮手過程詳解
理論聯系實際:Wireshark抓包分析TCP 3次握手、4次揮手過程
計算機網絡通訊協議關系圖(中文珍藏版)
UDP中一個包的大小最大能多大?
P2P技術詳解(一):NAT詳解——詳細原理、P2P簡介
P2P技術詳解(二):P2P中的NAT穿越(打洞)方案詳解
P2P技術詳解(三):P2P技術之STUN、TURN、ICE詳解
通俗易懂:快速理解P2P技術中的NAT穿透原理
高性能網絡編程(一):單台服務器並發TCP連接數到底可以有多少
高性能網絡編程(二):上一個10年,著名的C10K並發連接問題
高性能網絡編程(三):下一個10年,是時候考慮C10M並發問題了
高性能網絡編程(四):從C10K到C10M高性能網絡應用的理論探索
高性能網絡編程(五):一文讀懂高性能網絡編程中的I/O模型
高性能網絡編程(六):一文讀懂高性能網絡編程中的線程模型
不為人知的網絡編程(一):淺析TCP協議中的疑難雜症(上篇)
不為人知的網絡編程(二):淺析TCP協議中的疑難雜症(下篇)
不為人知的網絡編程(三):關閉TCP連接時為什么會TIME_WAIT、CLOSE_WAIT
不為人知的網絡編程(四):深入研究分析TCP的異常關閉
不為人知的網絡編程(五):UDP的連接性和負載均衡
不為人知的網絡編程(六):深入地理解UDP協議並用好它
不為人知的網絡編程(七):如何讓不可靠的UDP變的可靠?
不為人知的網絡編程(八):從數據傳輸層深度解密HTTP
網絡編程懶人入門(一):快速理解網絡通信協議(上篇)
網絡編程懶人入門(二):快速理解網絡通信協議(下篇)
網絡編程懶人入門(三):快速理解TCP協議一篇就夠
網絡編程懶人入門(四):快速理解TCP和UDP的差異
網絡編程懶人入門(五):快速理解為什么說UDP有時比TCP更有優勢
網絡編程懶人入門(六):史上最通俗的集線器、交換機、路由器功能原理入門
網絡編程懶人入門(七):深入淺出,全面理解HTTP協議
網絡編程懶人入門(八):手把手教你寫基於TCP的Socket長連接
網絡編程懶人入門(九):通俗講解,有了IP地址,為何還要用MAC地址?
技術掃盲:新一代基於UDP的低延時網絡傳輸層協議——QUIC詳解
讓互聯網更快:新一代QUIC協議在騰訊的技術實踐分享
現代移動端網絡短連接的優化手段總結:請求速度、弱網適應、安全保障
聊聊iOS中網絡編程長連接的那些事
移動端IM開發者必讀(一):通俗易懂,理解移動網絡的“弱”和“慢”
移動端IM開發者必讀(二):史上最全移動弱網絡優化方法總結
IPv6技術詳解:基本概念、應用現狀、技術實踐(上篇)
IPv6技術詳解:基本概念、應用現狀、技術實踐(下篇)
從HTTP/0.9到HTTP/2:一文讀懂HTTP協議的歷史演變和設計思路
腦殘式網絡編程入門(一):跟着動畫來學TCP三次握手和四次揮手
腦殘式網絡編程入門(二):我們在讀寫Socket時,究竟在讀寫什么?
腦殘式網絡編程入門(三):HTTP協議必知必會的一些知識
腦殘式網絡編程入門(四):快速理解HTTP/2的服務器推送(Server Push)
腦殘式網絡編程入門(五):每天都在用的Ping命令,它到底是什么?
腦殘式網絡編程入門(六):什么是公網IP和內網IP?NAT轉換又是什么鬼?
以網游服務端的網絡接入層設計為例,理解實時通信的技術挑戰
邁向高階:優秀Android程序員必知必會的網絡基礎
全面了解移動端DNS域名劫持等雜症:技術原理、問題根源、解決方案等
美圖App的移動端DNS優化實踐:HTTPS請求耗時減小近半
Android程序員必知必會的網絡通信傳輸層協議——UDP和TCP
IM開發者的零基礎通信技術入門(一):通信交換技術的百年發展史(上)
IM開發者的零基礎通信技術入門(二):通信交換技術的百年發展史(下)
IM開發者的零基礎通信技術入門(三):國人通信方式的百年變遷
IM開發者的零基礎通信技術入門(四):手機的演進,史上最全移動終端發展史
IM開發者的零基礎通信技術入門(五):1G到5G,30年移動通信技術演進史
IM開發者的零基礎通信技術入門(六):移動終端的接頭人——“基站”技術
IM開發者的零基礎通信技術入門(七):移動終端的千里馬——“電磁波”
IM開發者的零基礎通信技術入門(八):零基礎,史上最強“天線”原理掃盲
IM開發者的零基礎通信技術入門(九):無線通信網絡的中樞——“核心網”
IM開發者的零基礎通信技術入門(十):零基礎,史上最強5G技術掃盲
IM開發者的零基礎通信技術入門(十一):為什么WiFi信號差?一文即懂!
IM開發者的零基礎通信技術入門(十二):上網卡頓?網絡掉線?一文即懂!
IM開發者的零基礎通信技術入門(十三):為什么手機信號差?一文即懂!
IM開發者的零基礎通信技術入門(十四):高鐵上無線上網有多難?一文即懂!
IM開發者的零基礎通信技術入門(十五):理解定位技術,一篇就夠
百度APP移動端網絡深度優化實踐分享(一):DNS優化篇
百度APP移動端網絡深度優化實踐分享(二):網絡連接優化篇
百度APP移動端網絡深度優化實踐分享(三):移動端弱網優化篇
技術大牛陳碩的分享:由淺入深,網絡編程學習經驗干貨總結
可能會搞砸你的面試:你知道一個TCP連接上能發起多少個HTTP請求嗎?
>> 更多同類文章 ……

(本文同步發布於:http://www.52im.net/thread-2697-1-1.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM