WinSock2编程之打造完整的SOCKET池
在Winodows平臺上,網絡編程的主要接口就是WinSock,目前大多數的Windows平臺上的WinSock平臺已經升級到2.0版,簡稱為WinSock2。在WinSock2中擴展了很多很有用的Windows味很濃的SOCKET專用API,為Windows平臺用戶提供高性能的網絡編程支持。這些函數中的大多數已經不再是標準的“Berkeley”套接字模型的API了。使用這些函數的代價就是你不能再將你的網絡程序輕松的移植到“尤里平臺”(我給Unix +Linux平臺的簡稱)下,反過來因為Windows平臺支持標準的“Berkeley”套接字模型,所以你可以將大多數尤里平臺下的網絡應用移植到Windows平臺下。
如果不考慮可移植性(或者所謂的跨平臺性),而是著重于應用的性能時,尤其是注重服務器性能時,對于Windows的程序,都鼓勵使用WinSock2擴展的一些API,更鼓勵使用IOCP模型,因為這個模型是目前Windows平臺上比較完美的一個高性能IO編程模型,它不但適用于SOCKET編程,還適用于讀寫硬盤文件,讀寫和管理命名管道、郵槽等等。如果再結合Windows線程池,IOCP幾乎可以利用當今硬件所有可能的新特性(比如多核,DMA,高速總線等等),本身具有先天的擴展性和可用性。
今天討論的重點就是SOCKET池。很多VC程序員也許對SOCKET池很陌生,也有些可能很熟悉,那么這里就先討論下這個概念。
在Windows平臺上SOCKET實際上被視作一個內核對象的句柄,很多Windows API在支持傳統的HANDLE參數的同時也支持SOCKET,比如有名的CreateIoCompletionPort就支持將SOCKET句柄代替HANDLE參數傳入并調用。熟悉Windows內核原理的讀者,立刻就會發現,這樣的話,我們創建和銷毀一個SOCKET句柄,實際就是在系統內部創建了一個內核對象,對于Windows來說這牽扯到從Ring3層到Ring0層的耗時操作,再加上復雜的安全審核機制,實際創建和銷毀一個SOCKET內核對象的成本還是蠻高的。尤其對于一些面向連接的SOCKET應用,服務端往往要管理n多個代表客戶端通信的SOCKET對象,而且因為客戶的變動性,主要面臨的大量操作除了一般的收發數據,剩下的就是不斷創建和銷毀SOCKET句柄,對于一個頻繁接入和斷開的服務器應用來說,創建和銷毀SOCKET的性能代價立刻就會體現出來,典型的例如WEB服務器程序,就是一個需要頻繁創建和銷毀SOCKET句柄的SOCKET應用。這種情況下我們通常都希望對于斷開的SOCKET對象,不是簡單的“銷毀”了之(很多時候“斷開”的含義不一定就等價于“銷毀”,可以仔細思考一下),更多時候希望能夠重用這個SOCKET對象,這樣我們甚至可以事先創建一批SOCKET對象組成一個“池”,在需要的時候“重用”其中的SOCKET對象,不需要的時候將SOCKET對象重新丟入池中即可,這樣就省去了頻繁創建銷毀SOCKET對象的性能損失。在原始的“Berkeley”套接字模型中,想做到這點是沒有什么辦法的。而幸運的是在Windows平臺上,尤其是支持WinSock2的平臺上,已經提供了一套完整的API接口用于支持SOCKET池。
對于符合以上要求的SOCKET池,首先需要做到的就是對SOCKET句柄的“回收”,因為創建函數無論在那個平臺上都是現成的,而最早能夠實現這個功能的WinSock函數就是TransmitFile,如果代替closesocket函數像下面這樣調用就可以“回收”一個SOCKET句柄,而不是銷毀:(注意“回收”這個功能對于TransmitFile函數來說只是個“副業”。)
TransmitFile(hSocket,NULL,0,0,NULL,NULL,TF_DISCONNECT | TF_REUSE_SOCKET );
注意上面函數的最后一個參數,使用了標志TF_DISCONNECT和TF_REUSE_SOCKET,第一個值表示斷開,第二個值則明確的表示“重用”實際上也就是回收這個SOCKET,經過這個處理的SOCKET句柄,就可以直接再用于connect等操作,但是此時我們會發現,這個回收來的SOCKET似乎沒什么用,因為其他套接字函數沒法直接利用這個回收來的SOCKET句柄。
這時就要WinSock2的一組專用API上場了。我將它們按傳統意義上的服務端和客戶端分為兩組:
一、???????? 服務端:
SOCKET WSASocket(
? __in????????? int af,
? __in????????? int type,
? __in????????? int protocol,
? __in????????? LPWSAPROTOCOL_INFO lpProtocolInfo,
? __in???????? ?GROUP g,
? __in????????? DWORD dwFlags
);
BOOL AcceptEx(
? __in????????? SOCKET sListenSocket,
? __in????????? SOCKET sAcceptSocket,
? __in????????? PVOID lpOutputBuffer,
? __in????????? DWORD dwReceiveDataLength,
? __in????????? DWORD dwLocalAddressLength,
? __in????????? DWORD dwRemoteAddressLength,
? __out???????? LPDWORD lpdwBytesReceived,
? __in????????? LPOVERLAPPED lpOverlapped
);
BOOL DisconnectEx(
? __in????????? SOCKET hSocket,
? __in????????? LPOVERLAPPED lpOverlapped,
? __in????????? DWORD dwFlags,
? __in????????? DWORD reserved
);
二、???????? 客戶端:
SOCKET WSASocket(
? __in????????? int af,
? __in????????? int type,
? __in????????? int protocol,
? __in????????? LPWSAPROTOCOL_INFO lpProtocolInfo,
? __in????????? GROUP g,
? __in????????? DWORD dwFlags
);
BOOL PASCAL ConnectEx(
? __in????????? SOCKET s,
? __in????????? const struct sockaddr* name,
? __in????????? int namelen,
? __in_opt????? PVOID lpSendBuffer,
? __in????????? DWORD dwSendDataLength,
? __out???????? LPDWORD lpdwBytesSent,
? __in????????? LPOVERLAPPED lpOverlapped
);
BOOL DisconnectEx(
? __in????????? SOCKET hSocket,
? __in????????? LPOVERLAPPED lpOverlapped,
? __in????????? DWORD dwFlags,
? __in????????? DWORD reserved
);
注意觀察這些函數,似乎和傳統的“Berkeley”套接字模型中的一些函數“大同小異”,其實仔細觀察他們的參數,就已經可以發現一些調用他們的“玄機”了。
首先我們來看AcceptEx函數,與accept函數不同,它需要兩個SOCKET句柄作為參數,頭一個參數的含義與accept函數的相同,而第二個參數的意思就是accept函數返回的那個代表與客戶端通信的SOCKET句柄,在傳統的accept內部,實際在返回那個代表客戶端的SOCKET時,是在內部調用了一個SOCKET的創建動作,先創建這個SOCKET然后再“accept”讓它變成代表客戶端連接的SOCKET,而AcceptEx函數就在這里“擴展”(實際上是“閹割”才對)accept函數,省去了內部那個明顯的創建SOCKET的動作,而將這個創建動作交給最終的調用者自己來實現。AcceptEx要求調用者創建好那個sAcceptSocket句柄然后傳進去,這時我們立刻發現,我們回收的那個SOCKET是不是也可以傳入呢?答案是肯定的,我們就是可以利用這個函數傳入那個“回收”來的SOCKET句柄,最終實現服務端的SOCKET重用。
這里需要注意的就是,AcceptEx函數必須工作在非阻塞的IOCP模型下,同時即使AcceptEx函數返回了,也不代表客戶端連接進來或者連接成功了,我們必須依靠它的“完成通知”才能知道這個事實,這也是AcceptEx函數區別于accept這個阻塞方式函數的最大之處。通常可以利用AcceptEx的非阻塞特性和IOCP模型的優點,一次可以“預先”發出成千上萬個AcceptEx調用,“等待”客戶端的連接。對于習慣了accept阻塞方式的程序員來說,理解AcceptEx的工作方式還是需要費一些周折的。下面的例子就演示了如何一次調用多個AcceptEx:
//批量創建SOCKET,并調用對應的AcceptEx
for(UINT i = 0; i < 1000; i++)
{//調用1000次
//創建與客戶端通訊的SOCKET,注意SOCKET的創建方式
skAccept = ::WSASocket(AF_INET,
?????????????????? SOCK_STREAM,
?????????????????? IPPROTO_TCP,
?????????????????? NULL,
?????????????????? 0,
?????????????????? WSA_FLAG_OVERLAPPED);
if (INVALID_SOCKET == skAccept)
{
? ? throw CGRSException((DWORD)WSAGetLastError());
}
//創建一個自定義的OVERLAPPED擴展結構,使用IOCP方式調用
pAcceptOL = new CGRSOverlappedData(GRS_OP_ACCEPT
,this,skAccept,NULL);
pAddrBuf = pAcceptOL->GetAddrBuf();
//4、發出AcceptEx調用
//注意將AcceptEx函數接收連接數據緩沖的大小設定成了0,這將導致此函數立即返回,雖然與
//不設定成0的方式而言,這導致了一個較低下的效率,但是這樣提高了安全性,所以這種效率
//犧牲是必須的
if(!AcceptEx(m_skServer,
?????????????????? skAccept,
?????????????????? pAddrBuf->m_pBuf,
?????????????????? 0,//將接收緩沖置為0,令AcceptEx直接返回,防止拒絕服務攻擊
?????????????????? GRS_ADDRBUF_SIZE,
?????????????????? GRS_ADDRBUF_SIZE,
?????????????????? NULL,
?????????????????? (LPOVERLAPPED)pAcceptOL))
{
int iError = WSAGetLastError();
if( ERROR_IO_PENDING != iError
???? && WSAECONNRESET != iError )
{
???? if(INVALID_SOCKET != skAccept)
???? {
??????? ?::closesocket(skAccept);
??????? ?skAccept = INVALID_SOCKET;
???? }
? ?? if( NULL != pAcceptOL)
???? {
???????????? GRS_ISVALID(pAcceptOL,sizeof(CGRSOverlappedData));
delete pAcceptOL;
???? pAcceptOL = NULL;
???? }
? }
}
}
以上的例子只是簡單的演示了AcceptEx的調用,還沒有涉及到真正的“回收重用”這個主題,那么下面的例子就演示了如何重用一個SOCKET句柄:
if(INVALID_SOCKET == skClient)
{
throw CGRSException(_T("SOCKET句柄是無效的!"));
}
OnPreDisconnected(skClient,pUseData,0);
CGRSOverlappedData*pData
= new GRSOverlappedData(GRS_OP_DISCONNECTEX
,this,skClient,pUseData);
//回收而不是關閉后再創建大大提高了服務器的性能
DisconnectEx(skClient,&pData->m_ol,TF_REUSE_SOCKET,0);?
......
????? //在接收到DisconnectEx函數的完成通知之后,我們就可以重用這個SOCKET了
CGRSAddrbuf*pBuf = NULL;
pNewOL = new CGRSOverlappedData(GRS_OP_ACCEPT
,this,skClient,pUseData);
pBuf = pNewOL->GetAddrBuf();
//把這個回收的SOCKET重新丟進連接池
if(!AcceptEx(m_skServer,skClient,pBuf->m_pBuf,
???????????????? 0,//將接收緩沖置為0,令AcceptEx直接返回,防止拒絕服務攻擊
???????????????? GRS_ADDRBUF_SIZE, GRS_ADDRBUF_SIZE,
???????????????? NULL,(LPOVERLAPPED)pNewOL))
{
int iError = WSAGetLastError();
?? ?if( ERROR_IO_PENDING != iError
????? ? && WSAECONNRESET != iError )
?? ?{
?? ???? throw CGRSException((DWORD)iError);
?? ? }
}
//注意在這個SOCKET被重新利用后,重新與IOCP綁定一下,該操作會返回一個已設置的錯誤,這個錯誤直接被忽略即可
::BindIoCompletionCallback((HANDLE)skClient
,Server_IOCPThread, 0);
?
至此回收重用SOCKET的工作也就結束了,以上的過程實際理解了IOCP之后就比較好理解了,例子的最后我們使用了BindIoCompletionCallback函數重新將SOCKET丟進了IOCP線程池中,實際還可以再次使用CreateIoCompletionPort函數達到同樣的效果,這里列出這一步就是告訴大家,不要忘了再次綁定一下完成端口和SOCKET。
?? ?對于客戶端來說,可以使用ConnectEx函數來代替connect函數,與AcceptEx函數相同,ConnectEx函數也是以非阻塞的IOCP方式工作的,唯一要注意的就是在WSASocket調用之后,在ConnectEx之前要調用一下bind函數,將SOCKET提前綁定到一個本地地址端口上,當然回收重用之后,就無需再次綁定了,這也是ConnectEx較之connect函數高效的地方之一。
?? 與AcceptEx函數類似,也可以一次發出成千上萬個ConnectEx函數的調用,可以連接到不同的服務器,也可以連接到相同的服務器,連接到不同的服務器時,只需提供不同的sockaddr即可。
?? ?通過上面的例子和講解,大家應該對SOCKET池概念以及實際的應用有個大概的了解了,當然核心仍然是理解了IOCP模型,否則還是寸步難行。
在上面的例子中,回收SOCKET句柄主要使用了DisconnectEx函數,而不是之前介紹的TransmitFile函數,為什么呢?因為TransmitFile函數在一些情況下會造成死鎖,無法正常回收SOCKET,畢竟不是專業的回收重用SOCKET函數,我就遇到過好幾次死鎖,最后偶然的發現了DisconnectEx函數這個專用的回收函數,調用之后發現比TransmitFile專業多了,而且不管怎樣都不會死鎖。
最后需要補充的就是這幾個函數的調用方式,不能像傳統的SOCKET API那樣直接調用它們,而需要使用一種間接的方式來調用,尤其是AcceptEx和DisconnectEx函數,下面給出了一個例子類,用于演示如何動態載入這些函數并調用之:
class CGRSMsSockFun
{
public:
CGRSMsSockFun(SOCKET skTemp = INVALID_SOCKET)
{
???? if( INVALID_SOCKET != skTemp )
???? {
?????? LoadAllFun(skTemp);
???? ?}
}
public:
virtual ~CGRSMsSockFun(void)
{
}
protected:
BOOL LoadWSAFun(SOCKET& skTemp,GUID&funGuid,void*&pFun)
{
???? DWORD dwBytes = 0;
???? BOOL bRet = TRUE;
???? pFun = NULL;
???? BOOL bCreateSocket = FALSE;
???? try
???? {
?????? if(INVALID_SOCKET == skTemp)
?????? {
????????? skTemp = ::WSASocket(AF_INET,SOCK_STREAM,
???????????? IPPROTO_TCP,NULL,0,WSA_FLAG_OVERLAPPED);
bCreateSocket = (skTemp != INVALID_SOCKET);
?????? }
if(INVALID_SOCKET == skTemp)
?????? {
????????? throw CGRSException((DWORD)WSAGetLastError());
?????? }
?????? if(SOCKET_ERROR == ::WSAIoctl(skTemp,
??????????????? SIO_GET_EXTENSION_FUNCTION_POINTER,
??????????????? &funGuid,sizeof(funGuid),
??????????????? &pFun,sizeof(pFun),&dwBytes,NULL,
??????????????? NULL))
?????? {
???????????? pFun = NULL;
???????????? throw CGRSException((DWORD)WSAGetLastError());
?????? }
? }
? catch(CGRSException& e)
? {
???? ?if(bCreateSocket)
???? ?{
?????? ?::closesocket(skTemp);
???? ?}
? }
? return NULL != pFun;
}
protected:
LPFN_ACCEPTEX m_pfnAcceptEx;
LPFN_CONNECTEX m_pfnConnectEx;
LPFN_DISCONNECTEX m_pfnDisconnectEx;
LPFN_GETACCEPTEXSOCKADDRS m_pfnGetAcceptExSockaddrs;
LPFN_TRANSMITFILE m_pfnTransmitfile;
LPFN_TRANSMITPACKETS m_pfnTransmitPackets;
LPFN_WSARECVMSG m_pfnWSARecvMsg;
protected:
BOOL LoadAcceptExFun(SOCKET &skTemp)
{
???? GUID GuidAcceptEx = WSAID_ACCEPTEX;
???? return LoadWSAFun(skTemp,GuidAcceptEx
,(void*&)m_pfnAcceptEx);
}
BOOL LoadConnectExFun(SOCKET &skTemp)
{
???? GUID GuidAcceptEx = WSAID_CONNECTEX;
???? return LoadWSAFun(skTemp,GuidAcceptEx
,(void*&)m_pfnConnectEx);
}
BOOL LoadDisconnectExFun(SOCKET&skTemp)
{
???? GUID GuidDisconnectEx = WSAID_DISCONNECTEX;
???? return LoadWSAFun(skTemp,GuidDisconnectEx
,(void*&)m_pfnDisconnectEx);
}
BOOL LoadGetAcceptExSockaddrsFun(SOCKET &skTemp)
{
???? GUID GuidGetAcceptExSockaddrs
= WSAID_GETACCEPTEXSOCKADDRS;
???? return LoadWSAFun(skTemp,GuidGetAcceptExSockaddrs
,(void*&)m_pfnGetAcceptExSockaddrs);
}
BOOL LoadTransmitFileFun(SOCKET&skTemp)
{
???? GUID GuidTransmitFile = WSAID_TRANSMITFILE;
???? return LoadWSAFun(skTemp,GuidTransmitFile
,(void*&)m_pfnTransmitfile);
}
BOOL LoadTransmitPacketsFun(SOCKET&skTemp)
{
???? GUID GuidTransmitPackets = WSAID_TRANSMITPACKETS;
???? return LoadWSAFun(skTemp,GuidTransmitPackets
,(void*&)m_pfnTransmitPackets);
}
BOOL LoadWSARecvMsgFun(SOCKET&skTemp)
{
???? GUID GuidTransmitPackets = WSAID_TRANSMITPACKETS;
???? return LoadWSAFun(skTemp,GuidTransmitPackets
,(void*&)m_pfnWSARecvMsg);
}
public:
BOOL LoadAllFun(SOCKET skTemp)
{//注意這個地方的調用順序,是根據服務器的需要,并結合了表達式副作用
? //而特意安排的調用順序
? return (LoadAcceptExFun(skTemp) &&
???????????? LoadGetAcceptExSockaddrsFun(skTemp) &&
???????????? LoadTransmitFileFun(skTemp) &&
???????????? LoadTransmitPacketsFun(skTemp) &&
???????????? LoadDisconnectExFun(skTemp) &&
???????????? LoadConnectExFun(skTemp) &&
???????????? LoadWSARecvMsgFun(skTemp));
}
?
public:
GRS_FORCEINLINE BOOL AcceptEx (
????????? SOCKET sListenSocket,
????????? SOCKET sAcceptSocket,
????????? PVOID lpOutputBuffer,
????????? DWORD dwReceiveDataLength,
????????? DWORD dwLocalAddressLength,
????????? DWORD dwRemoteAddressLength,
????????? LPDWORD lpdwBytesReceived,
????????? LPOVERLAPPED lpOverlapped
????????? )
{
???? GRS_ASSERT(NULL != m_pfnAcceptEx);
???? return m_pfnAcceptEx(sListenSocket,
???????????? sAcceptSocket,lpOutputBuffer,
???????????? dwReceiveDataLength,dwLocalAddressLength,
???? ??????? dwRemoteAddressLength,lpdwBytesReceived,
???????????? lpOverlapped);
}
GRS_FORCEINLINE BOOL ConnectEx(
????????? SOCKET s,const struct sockaddr FAR *name,
????????? int namelen,PVOID lpSendBuffer,
????????? DWORD dwSendDataLength,LPDWORD lpdwBytesSent,
????????? LPOVERLAPPED lpOverlapped
????????? )
{
???? GRS_ASSERT(NULL != m_pfnConnectEx);
???? return m_pfnConnectEx(
???????????? s,name,namelen,lpSendBuffer,
???????????? dwSendDataLength,lpdwBytesSent,
???????????? lpOverlapped
???????????? );
}
GRS_FORCEINLINE BOOL DisconnectEx(
????????? SOCKET s,LPOVERLAPPED lpOverlapped,
????????? DWORD? dwFlags,DWORD? dwReserved
????????? )
{
???? GRS_ASSERT(NULL != m_pfnDisconnectEx);
???? return m_pfnDisconnectEx(s,
???????????? lpOverlapped,dwFlags,dwReserved);
}
GRS_FORCEINLINE VOID GetAcceptExSockaddrs (
????????? PVOID lpOutputBuffer,
????????? DWORD dwReceiveDataLength,
????????? DWORD dwLocalAddressLength,
????????? DWORD dwRemoteAddressLength,
????????? sockaddr **LocalSockaddr,
????????? LPINT LocalSockaddrLength,
????????? sockaddr **RemoteSockaddr,
????????? LPINT RemoteSockaddrLength
????????? )
{
???? GRS_ASSERT(NULL != m_pfnGetAcceptExSockaddrs);
???? return m_pfnGetAcceptExSockaddrs(
????????? lpOutputBuffer,dwReceiveDataLength,
????????? dwLocalAddressLength,dwRemoteAddressLength,
????????? LocalSockaddr,LocalSockaddrLength,
????????? RemoteSockaddr,RemoteSockaddrLength
????????? );
}
GRS_FORCEINLINE BOOL TransmitFile(
???? SOCKET hSocket,HANDLE hFile,
???? DWORD nNumberOfBytesToWrite,
???? DWORD nNumberOfBytesPerSend,
???? LPOVERLAPPED lpOverlapped,
???? LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers,
???? DWORD dwReserved
???? )
{
???? GRS_ASSERT(NULL != m_pfnTransmitfile);
???? return m_pfnTransmitfile(
???????????? hSocket,hFile,nNumberOfBytesToWrite,
???????????? nNumberOfBytesPerSend,lpOverlapped,
???????????? lpTransmitBuffers,dwReserved
???????????? );
}
GRS_FORCEINLINE BOOL TransmitPackets(
???? SOCKET hSocket,????????????????????????????
???? LPTRANSMIT_PACKETS_ELEMENT lpPacketArray,???????????????????? ??????????
???? DWORD nElementCount,DWORD nSendSize,???????????????
???? LPOVERLAPPED lpOverlapped,DWORD dwFlags??????????????????????????????
???? )
{
???? GRS_ASSERT(NULL != m_pfnTransmitPackets);
???? return m_pfnTransmitPackets(
???????????? hSocket,lpPacketArray,nElementCount,
nSendSize,lpOverlapped,dwFlags
???????????? );
}
GRS_FORCEINLINE INT WSARecvMsg(
????????? SOCKET s,LPWSAMSG lpMsg,
????????? LPDWORD lpdwNumberOfBytesRecvd,
????????? LPWSAOVERLAPPED lpOverlapped,
????????? LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
????????? )
{
???? GRS_ASSERT(NULL != m_pfnWSARecvMsg);
???? return m_pfnWSARecvMsg(
???????????? s,lpMsg,lpdwNumberOfBytesRecvd,
???????????? lpOverlapped,lpCompletionRoutine
???????????? );
}
/*WSAID_ACCEPTEX
? WSAID_CONNECTEX
? WSAID_DISCONNECTEX
? WSAID_GETACCEPTEXSOCKADDRS
? WSAID_TRANSMITFILE
? WSAID_TRANSMITPACKETS
? WSAID_WSARECVMSG
? WSAID_WSASENDMSG */
};
這個類的使用非常簡單,只需要聲明一個類的對象,然后調用其成員AcceptEx、DisconnectEx函數等即可,參數與這些函數的MSDN聲明方式完全相同,除了本文中介紹的這些函數外,這個類還包含了很多其他的Winsock2函數,那么都應該按照這個類中演示的這樣來動態載入后再行調用,如果無法載入通常說明你的環境中沒有Winsock2函數庫,或者是你初始化的不是2.0版的Winsock環境。
這個類是本人完整類庫的一部分,如要使用需要自行修改一些地方,如果不知如何修改或遇到什么問題,可以直接跟帖說明,我會不定期回答大家的問題,這個類可以免費使用、分發、修改,可以用于任何商業目的,但是對于使用后引起的任何問題,本人概不負責,有問題請跟帖。關于AcceptEx以及其他一些函數,包括本文中沒有介紹到得函數,我會在后續的一些專題文章中進行詳細深入的介紹,敬請期待。如果你有什么疑問,或者想要了解什么也請跟帖說明,我會在后面的文章中盡量說明。
總結
以上是生活随笔為你收集整理的WinSock2编程之打造完整的SOCKET池的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: IOCP浅析
- 下一篇: WSAIoctl 函数详解