• 通用异步 Windows Socket TCP 客户端组件的设计与实现


    编写 Windows Socket TCP 客户端其实并不困难,Windows 提供了6种 I/O 通信模型供大家选择。但本座看过很多客户端程序都把 Socket 通信和业务逻辑混在一起,剪不断理还乱。每个程序都 Copy / Parse 类似的代码再进行修改,实在有点情何以堪。因此本座利用一些闲暇时光写了一个基于 IOCP 的通用异步 Windows Socket TCP 高性能服务端组件和一个通用异步 Windows Socket TCP 客户端组件供各位看官参详参详,希望能激发下大家的灵感。本篇文章讲述客户端组件。闲话少说,我们现在步入正题。

    • 最重要的第一个问题:如何才能达到通用?

      答:很简单。

        1、限制组件的职能,说白了,通信组件的唯一职责就是接受和发送字节流,绝对不能参与上层协议解析等工作。不在其位不谋其政就是这个意思。

        2、与上层使用者解耦、互不依赖,组件与使用者通过接口方法进行交互,组件实现 ISocketClient 接口为上层提供操作方法;使用者通过 IClientSocketListener 接口把自己注册为组件的 Listener,接收组件通知。因此,任何使用者只要实现了 IClientSocketListener 接口都可以使用组件;另一方面,你甚至可以自己重新写一个实现方式完全不同的组件实现给使用者调用,只要该组件遵从 ISocketClient 接口。这也是 DIP 设计原则的体现(若想了解更多关于设计原则的内容请猛击这里 ^_^)。

    • 最重要的第二个问题:可用性如何,也就是说使用起来是否是否方便?

      答:这个问题问得很好,可用性对所有通用组件都是至关重要的,如果太难用还不如自己重头写一个来得方便。因此,ISocketClient 和 IClientSocketListener 接口设计得尽量简单易用(通俗来说就是“傻瓜化”),这两个接口的主要方法均不超过 5 个。

    • 最重要的第三个问题:组件的性能如何?

      作为底层的通用组件,性能问题是必须考虑的,绝对不能成为系统的瓶颈。而另一方面,从实际出发,毕竟只是一个客户端组件,它的并发性要求远没有服务端那么高。因此,组件在设计上充分考虑了性能、现实使用情景、可用性和实现复杂性等因素,确保满足性能要求的同时又不会写得太复杂。做出以下两点设计决策:

      1. 在单独线程中实现 Socket 通信交互。这样可以避免与主线程或其他线程相互干扰。
      2. I/O 模型选择 WSAEventSelect。细说一下选择这种 I/O 模型的原因:(各种 I/O 模型的性能比较可以参考:《Windows 网络编程(中文第二版)》第 154 页)
        • 阻塞模型:(不解析,你懂的^_^)
        • 非阻塞模型:(性能太低)
        • WSAAsyncSelect: (两个原因:a、性能太低;b、对于纯 Console 程序还要背负 HWND 实在是伤不起呀!)
        • 重叠 I/O:(有点复杂了)
        • 完成端口:(何必呢?)

      唉,理论的东西就先别吹那么多了,直接上代码吧,求你了 !!

      OK!先看看 ISocketClient 和 IClientSocketListener 的接口定义:

    复制代码
    // 组件操作类型
    enum EnSocketOperation
    {
    SO_UNKNOWN = 0,
    SO_ACCEPT = 1,
    SO_CONNECT = 2,
    SO_SEND = 3,
    SO_RECEIVE = 4,
    };

    // 组件监听器基接口
    class ISocketListener
    {
    public:
    enum EnHandleResult
    {
    HR_OK = 0,
    HR_IGNORE = 1,
    HR_ERROR = 2,
    };

    public:
      // 已发出数据通知
      virtual EnHandleResult OnSend(DWORD dwConnectionID, const BYTE* pData, int iLength) = 0;

      // 已接收数据通知
      virtual EnHandleResult OnReceive(DWORD dwConnectionID, const BYTE* pData, int iLength) = 0;

      // 关闭连接通知
      virtual EnHandleResult OnClose(DWORD dwConnectionID) = 0;

      // 通信错误通知
      virtual EnHandleResult OnError(DWORD dwConnectionID, EnSocketOperation enOperation, int iErrorCode) = 0;


    public:
    virtual ~ISocketListener() {}
    };

    // 服务端组件监听器接口(暂时无视之)
    class IServerSocketListener : public ISocketListener
    {
    public:
    // 接收连接通知
    virtual EnHandleResult OnAccept(DWORD dwConnectionID) = 0;
    // 服务关闭通知
    virtual EnHandleResult OnServerShutdown() = 0;
    };

    // 客户端组件监听器接口
    class IClientSocketListener : public ISocketListener
    {
    public:
      // 连接完成通知
      virtual
    EnHandleResult OnConnect(DWORD dwConnectionID) = 0;
    };

    // 服务端组件接口(暂时无视之)
    class ISocketServer
    {
    public:
    enum En_ISS_Error
    {
    ISS_OK = 0,
    ISS_SOCKET_CREATE = 1,
    ISS_SOCKET_BIND = 2,
    ISS_SOCKET_LISTEN = 3,
    ISS_CP_CREATE = 4,
    ISS_WORKER_THREAD_CREATE = 5,
    ISS_SOCKE_ATTACH_TO_CP = 6,
    ISS_ACCEPT_THREAD_CREATE = 7,
    };

    public:
    virtual BOOL Start (LPCTSTR pszBindAddress, USHORT usPort, long lThreadCount) = 0;
    virtual BOOL Stop () = 0;
    virtual BOOL Send (DWORD dwConnID, const BYTE* pBuffer, int iLen) = 0;
    virtual BOOL HasStarted () = 0;
    virtual En_ISS_Error GetLastError () = 0;
    virtual LPCTSTR GetLastErrorDesc() = 0;
    virtual BOOL GetConnectionAddress(DWORD dwConnID, CString& strAddress, USHORT& usPort) = 0;


    public:
    virtual ~ISocketServer() {}
    };

    // 服务端组件接口智能指针
    typedef auto_ptr<ISocketServer> ISocketServerPtr;

    // 客户端组件接口
    class ISocketClient
    {
    public:
    // 操作结果码
    enum En_ISC_Error
    {
    ISC_OK = 0,
    ISC_CLIENT_HAD_STARTED = 1,
    ISC_CLIENT_NOT_STARTED = 2,
    ISC_SOCKET_CREATE_FAIL = 3,
    ISC_CONNECT_SERVER_FAIL = 4,
    ISC_WORKER_CREATE_FAIL = 5,
    ISC_NETWORK_ERROR = 6,
    ISC_PROTOCOL_ERROR = 7,
    };

    public:
      // 启动通信
      virtual BOOL Start (LPCTSTR pszRemoteAddress, USHORT usPort) = 0;

      // 关闭通信
      virtual BOOL Stop () = 0;

      // 发送数据
      virtual BOOL Send (DWORD dwConnID, const BYTE* pBuffer, int iLen) = 0;

      // 是否已启动
      virtual BOOL HasStarted () = 0;

      // 获取错误码
      virtual En_ISC_Error GetLastError () = 0;

      // 获取错误描述
      virtual LPCTSTR GetLastErrorDesc() = 0;


    public:
    virtual ~ISocketClient() {}
    };

    // 客户端组件接口智能指针
    typedef auto_ptr<ISocketClient>
    ISocketClientPtr;
    复制代码

      

      ISocketClient 接口主要有以下三个方法:

    • Start():启动通信
    • Send():发送数据
    • Stop():停止通信

      IClientSocketListener 接口有以下五个通知方法:

    • OnConnect()
    • OnSend()
    • OnReceive()
    • OnClose()
    • OnError() 

       够简单了吧^_^,使用者只需通过三个方法操作组件,然后处理五个组件通知。下面我们再看看组件的具体实现,先看组件类定义:

    复制代码
    /* 组件实现类 */
    class CSocketClient : public ISocketClient
    {
    // ISocketClient 接口方法
    public:
      virtual BOOL Start (LPCTSTR pszRemoteAddress, USHORT usPortt);
      virtual BOOL Stop ();
      virtual BOOL Send (DWORD dwConnID, const BYTE* pBuffer, int iLen);
      virtual BOOL HasStarted () {return m_bStarted;}
      virtual En_ISC_Error GetLastError () {return sm_enLastError;}
      virtual LPCTSTR GetLastErrorDesc();

    private:
    BOOL CreateClientSocket();
    BOOL ConnectToServer(LPCTSTR pszRemoteAddress, USHORT usPort);
    BOOL CreateWorkerThread();
    // 网络事件处理方法
    BOOL ProcessNetworkEvent();
    void WaitForWorkerThreadEnd();
    BOOL ReadData();
    BOOL SendData();

    void SetLastError(En_ISC_Error code, LPCTSTR func, int ec);

    // 通信线程函数
    static
    #ifndef _WIN32_WCE
    UINT
    #else
    DWORD
    #endif
    WINAPI WorkerThreadProc(LPVOID pv);

    private:
    static const int RECEIVE_BUFFER_SIZE = 8 * 1024;
    static const int WORKER_THREAD_END_TIME = 3 * 1000;

    static const long DEFALUT_KEEPALIVE_TIMES = 3;
    static const long DEFALUT_KEEPALIVE_INTERVAL = 10 * 1000;


    // 构造函数
    public:

      CSocketClient(IClientSocketListener* pListener)
      : m_pListener(pListener) // 设置监听器对象
      , m_soClient(INVALID_SOCKET)

      , m_evSocket(NULL)
      , m_dwConnID(0)
      , m_hWorker(NULL)
      , m_dwWorkerID(0)
      , m_bStarted(FALSE)
    #ifdef _WIN32_WCE
      , sm_enLastError(ISC_OK)
    #endif
      {
        ASSERT(m_pListener);
      }

    virtual ~CSocketClient() {if(HasStarted()) Stop();}

    private:
      // 这是神马 ???
      CInitSocket m_wsSocket;


    SOCKET m_soClient;
    HANDLE m_evSocket;
    DWORD m_dwConnID;

    CCriSec m_scStop;
    CEvt m_evStop;
    HANDLE m_hWorker;

    #ifndef _WIN32_WCE
    UINT
    #else
    DWORD
    #endif
    m_dwWorkerID;

    CBufferPtr m_sndBuffer;
    CCriSec m_scBuffer;
    CEvt m_evBuffer;

    volatile BOOL m_bStarted;

    private:
      // 监听器对象指针
      IClientSocketListener* m_pListener;

    #ifndef _WIN32_WCE
    __declspec(thread) static En_ISC_Error sm_enLastError;
    #else
    volatile En_ISC_Error sm_enLastError;
    #endif
    };
    复制代码

      从上面的定义可以看出,组件实现类本身并没有提供额外的公共方法,它完全是可以被替换的。组件在构造函数中接收监听器对象,并且保存为其成员属性,因此可以在需要的时候向监听器发送事件通知。

      另外,不知各位看官是否注意到一个奇怪的成员属性:“CInitSocket m_wsSocket; ”,这个属性在其它地方从来都不会用到,那么它是干嘛的呢?在回答这个问题之前,首先想问问大家:Windows Socket 操作的整个操作过程中,第一个以及最后一个被调用的方法是什么?是 socket()、connect()、bind()、还是 closesocket() 吗?都错!答案是 —— ::WSAStartup() 和 ::WSACleanup()。每个程序都要调用一下这两个方法确实是很烦的,又不雅观。 其实,m_wsSocket 的唯一目的就是为了避免手工调用者两个方法,看看它的定义就明白了:

    复制代码
    class CInitSocket
    {
    public:
    CInitSocket(LPWSADATA lpWSAData = NULL, BYTE minorVersion = 2, BYTE majorVersion = 2)
    {
    LPWSADATA lpTemp = lpWSAData;
    if(!lpTemp)
    lpTemp = (LPWSADATA)_alloca(sizeof(WSADATA));

    m_iResult = ::WSAStartup(MAKEWORD(minorVersion, majorVersion), lpTemp);
    }

    ~CInitSocket()
    {
    if(IsValid())
          ::WSACleanup();
    }

    int GetResult() {return m_iResult;}
    BOOL IsValid() {return m_iResult == 0;}

    private:
    int m_iResult;
    };
    复制代码

       现在我们看看组件类实现文件中几个重要方法的定义:

    复制代码
    // 组件事件触发宏定义
    #define FireConnect(id) m_pListener->OnConnect(id)
    #define FireSend(id, data, len) (m_bStarted ? m_pListener->OnSend(id, data, len) : ISocketListener::HR_IGNORE)
    #define FireReceive(id, data, len) (m_bStarted ? m_pListener->OnReceive(id, data, len) : ISocketListener::HR_IGNORE)
    #define FireClose(id) (m_bStarted ? m_pListener->OnClose(id) : ISocketListener::HR_IGNORE)
    #define FireError(id, op, code) (m_bStarted ? m_pListener->OnError(id, op, code) : ISocketListener::HR_IGNORE)

    // 启动组件
    BOOL CSocketClient::Start(LPCTSTR pszRemoteAddress, USHORT usPort)
    {
    BOOL isOK = FALSE;

    if(HasStarted())
    {
    SetLastError(ISC_CLIENT_HAD_STARTED, _T(__FUNCTION__), 0);
    return isOK;
    }

    // 创建 socket
    if(CreateClientSocket())
    {
    // 连接服务器(内部会调用 FireConnect()
    if(ConnectToServer(pszRemoteAddress, usPort))
    {
    // 创建工作线程
    if(CreateWorkerThread())
    isOK = TRUE;
    else
    SetLastError(ISC_WORKER_CREATE_FAIL, _T(__FUNCTION__), 0);
    }
    else
    SetLastError(ISC_CONNECT_SERVER_FAIL, _T(__FUNCTION__), ::WSAGetLastError());
    }
    else
    SetLastError(ISC_SOCKET_CREATE_FAIL, _T(__FUNCTION__), ::WSAGetLastError());

    isOK ? m_bStarted = TRUE : Stop();

    return isOK;
    }

    // 关闭组件
    BOOL CSocketClient::Stop()
    {
    {
    CCriSecLock locallock(m_scStop);

    m_bStarted = FALSE;

    if(m_hWorker != NULL)
    {
    // 停止工作线程
    if(::GetCurrentThreadId() != m_dwWorkerID)
    WaitForWorkerThreadEnd();

    ::CloseHandle(m_hWorker);
    m_hWorker = NULL;
    m_dwWorkerID = 0;
    }

    if(m_evSocket != NULL)
    {
    // 关闭 WSAEvent
    ::WSACloseEvent(m_evSocket);
    m_evSocket = NULL;
    }

    if(m_soClient != INVALID_SOCKET)
    {
    // 关闭socket
    shutdown(m_soClient, SD_SEND);
    closesocket(m_soClient);
    m_soClient = INVALID_SOCKET;
    }

    m_dwConnID = 0;
    }

    // 释放其它资源
    m_sndBuffer.Free();
    m_evBuffer.Reset();
    m_evStop.Reset();

    return TRUE;
    }

    // 发送数据
    BOOL CSocketClient::Send(DWORD dwConnID, const BYTE* pBuffer, int iLen)
    {
    ASSERT(iLen > 0);

    if(!HasStarted())
    {
    SetLastError(ISC_CLIENT_NOT_STARTED, _T(__FUNCTION__), 0);
    return FALSE;
    }

    CCriSecLock locallock(m_scBuffer);

    // 把数据存入缓冲器
    m_sndBuffer.Cat(pBuffer, iLen);
      // 唤醒工作现场,发送数据
    m_evBuffer.Set();

    return TRUE;
    }

    // 工作线程函数
    #ifndef _WIN32_WCE
    UINT
    #else
    DWORD
    #endif
    WINAPI CSocketClient::WorkerThreadProc(LPVOID pv)
    {
    CSocketClient* pClient = (CSocketClient*)pv;

    TRACE0("---------------> 启动工作线程 <--------------- ");

    HANDLE hEvents[] = {pClient->m_evSocket, pClient->m_evBuffer, pClient->m_evStop};

    while(pClient->HasStarted())
    {
    // 等待 socket 事件、发送数据事件和停止通信事件
    DWORD retval = ::MsgWaitForMultipleObjectsEx(3, hEvents, WSA_INFINITE, QS_ALLINPUT, MWMO_INPUTAVAILABLE);

    if(retval == WSA_WAIT_EVENT_0)
    {
    // 处理网络消息
    if(!pClient->ProcessNetworkEvent())
    {
    if(pClient->HasStarted())
    pClient->Stop();

    break;
    }
    }
    else if(retval == WSA_WAIT_EVENT_0 + 1)
    {
    // 发送数据(内部调用 FireSend()
    if(!pClient->SendData())
    {
    if(pClient->HasStarted())
    pClient->Stop();

    break;
    }
    }
    else if(retval == WSA_WAIT_EVENT_0 + 2)
    break;
    else if(retval == WSA_WAIT_EVENT_0 + 3)
    // 消息循环
    ::PeekMessageLoop();
    else
    ASSERT(FALSE);
    }

    TRACE0("---------------> 退出工作线程 <--------------- ");

    return 0;
    }

    // 处理网络消息
    BOOL CSocketClient::ProcessNetworkEvent()
    {
    ::WSAResetEvent(m_evSocket);

    WSANETWORKEVENTS events;

    int rc = ::WSAEnumNetworkEvents(m_soClient, m_evSocket, &events);

    if(rc == SOCKET_ERROR)
    {
    int code = ::WSAGetLastError();
    SetLastError(ISC_NETWORK_ERROR, _T(__FUNCTION__), code);
         FireError(m_dwConnID, SO_UNKNOWN, code);

    return FALSE;
    }

    /* 可读取 */
    if(events.lNetworkEvents & FD_READ)
    {
    int iCode = events.iErrorCode[FD_READ_BIT];

    if(iCode == 0)
    // 读取数据(内部调用 FireReceive()
    return ReadData();
    else
         {
          SetLastError(ISC_NETWORK_ERROR, _T(__FUNCTION__), iCode);
          FireError(m_dwConnID, SO_RECEIVE, iCode);
          return FALSE;
    }
    }

    /* 可发送 */
    if(events.lNetworkEvents & FD_WRITE)
    {
    int iCode = events.iErrorCode[FD_WRITE_BIT];

    if(iCode == 0)
    // 发送数据(内部调用 FireSend()
    return SendData();
    else
    {
    SetLastError(ISC_NETWORK_ERROR, _T(__FUNCTION__), iCode);
            FireError(m_dwConnID, SO_SEND, iCode);
    return FALSE;
    }
    }

    /* socket 已关闭 */
    if(events.lNetworkEvents & FD_CLOSE)
    {
    int iCode = events.iErrorCode[FD_CLOSE_BIT];

    if(iCode == 0)
          FireClose(m_dwConnID);
    else
    {
    SetLastError(ISC_NETWORK_ERROR, _T(__FUNCTION__), iCode);
            FireError(m_dwConnID, SO_UNKNOWN, iCode);
    }

    return FALSE;
    }

    return TRUE;
    }
    复制代码

      

      从上面的代码可以看出:通信过程中,组件的使用者不需要对通信过程进行任何干预,整个底层通信过程对使用者来说是透明的,使用只需集中精力处理好几个组件通知。下面来看看组件的一个使用示例:

    复制代码
    /* 组件使用者:实现 IClientSocketListener */
    class CMainClient : public IClientSocketListener
    {
    // 这些方法会操作组件
    public:
    bool Login(LPCTSTR pszAddress, USHORT usPort, const T_101_Data* pData);
    bool Logout(const T_201_Data* pData);
    BOOL SendData(EnCommandType enCmdType, const TCommandData* pCmdData, WORD wCmdDataLen);
    long GetLastError();
    LPCTSTR GetLastErrorDesc();

    // 实现 IClientSocketListener
    public:
      virtual EnHandleResult OnConnect(DWORD dwConnectionID);
      virtual EnHandleResult OnSend(DWORD dwConnectionID, const BYTE* pData, int iLength);
      virtual EnHandleResult OnReceive(DWORD dwConnectionID, const BYTE* pData, int iLen);
      virtual EnHandleResult OnClose(DWORD dwConnectionID);
      virtual EnHandleResult OnError(DWORD dwConnectionID, EnSocketOperation enOperation, int iErrorCode);

    private:
    BOOL ParseReceiveBuffer();

    // 其它方法 。。。

    // 构造函数
    public:
      CMainClient()
      // 创建组件,并把自己设置为组件的监听器
      : m_pscClient(new CSocketClient(this))

      , m_dwConnID(0)
      {
      }

    virtual ~CMainClient() {}

    private:
      // 组件属性
      ISocketClientPtr m_pscClient;
    DWORD m_dwConnID;

    // 其它属性 。。。
    };
    复制代码
    复制代码
    BOOL CMainClient::Login(LPCTSTR pszAddress, USHORT usPort, const T_101_Data* pData)
    {
    // 启动通信
    return m_pscClient->Start(pszAddress, usPort) &&
    SendData(CS_C_LOGIN_REQ, pData, sizeof(T_101_Data));
    }

    BOOL CMainClient::Logout(const T_201_Data* pData)
    {
    if(pData)
    {
    SendData(CS_C_SET_STATUS, pData, sizeof(T_201_Data));
    ::WaitWithMessageLoop(LOGOUT_WAIT_TIME);
    }

    // 停止通信
    return m_pscClient->Stop();
    }

    BOOL CMainClient::SendData(EnCommandType enCmdType, const TCommandData* pCmdData, WORD wCmdDataLen)
    {
    const WORD wBufferLen = CMD_ADDITIVE_SIZE + wCmdDataLen;
    CPrivateHeapByteBuffer buffer(m_hpPrivate, wBufferLen);
    BYTE* pBuffer = buffer;

    memcpy(pBuffer, &wBufferLen, CMD_LEN_SIZE);
    pBuffer += CMD_LEN_SIZE;
    memcpy(pBuffer, &enCmdType, CMD_TYPE_SIZE);
    pBuffer += CMD_TYPE_SIZE;
    memcpy(pBuffer, pCmdData, wCmdDataLen);
    pBuffer += wCmdDataLen;
    memcpy(pBuffer, &CMD_FLAG, CMD_FLAG_SIZE);

    // 发送数据
    return m_pscClient->Send(m_dwConnID, buffer, wBufferLen);
    }

    long CMainClient::GetLastError()
    {
    // 获取通信错误码
    return m_pscClient->GetLastError();
    }

    LPCTSTR CMainClient::GetLastErrorDesc()
    {
    // 获取通信错误描述
    return m_pscClient->GetLastErrorDesc();
    }

    /* 处理连接成功事件 */
    ISocketListener::EnHandleResult CMainClient::OnConnect(DWORD dwConnectionID)
    {
    TRACE1("<CNNID: %d> 已连接 ", dwConnectionID);
    m_dwConnID = dwConnectionID;
    return HR_OK;
    }

    /* 处理数据已发出事件 */
    ISocketListener::EnHandleResult CMainClient::OnSend(DWORD dwConnectionID, const BYTE* pData, int iLength)
    {
    TRACE2("<CNNID: %d> 发出数据包 (%d bytes) ", dwConnectionID, iLength);
    return HR_OK;
    }

    /* 处理接收到数据事件*/
    ISocketListener::EnHandleResult CMainClient::OnReceive(DWORD dwConnectionID, const BYTE* pData, int iLen)
    {
    TRACE2("<CNNID: %d> 接收数据包 (%d bytes) ", dwConnectionID, iLen);

    ASSERT(pData != NULL && iLen > 0);

    // 保存数据
    m_rcBuffer.Cat(pData, iLen);

    // 解析数据
    return ParseReceiveBuffer() ? HR_OK : HR_ERROR;;
    }

    /* 处理通信关闭事件*/
    ISocketListener::EnHandleResult CMainClient::OnClose(DWORD dwConnectionID)
    {
    TRACE1("CNNID: %d> 关闭连接 ", dwConnectionID);

    // 清理缓冲区
    m_rcBuffer.Realloc(0);
    return HR_OK;
    }

    /* 处理通信错误事件 */
    ISocketListener::EnHandleResult CMainClient::OnError(DWORD dwConnectionID, EnSocketOperation enOperation, int iErrorCode)
    {
    TRACE3("<CNNID: %d> 网络错误 (OP: %d, CO: %d) ", dwConnectionID, enOperation, iErrorCode);

    // 清理缓冲区
    m_rcBuffer.Realloc(0);

    return HR_OK;
    }
    复制代码

      好了,码了一个晚上的字,累啊!到此为止吧,感谢收看~

      敬请继续收看《基于 IOCP 的通用异步 Windows Socket TCP 高性能服务端组件的设计与实现》,晚安 ^_^

      (想看源代码的朋友请狂点这里
     

  • 相关阅读:
    Direct2D 变换
    DWrite 文字
    Windows基础窗体编程
    .net delegate(委托类型)
    详说new和overrid区别
    类与结构区别
    IIS的Gzip压缩
    ASP.NET 状态服务和session丢失问题解决方案
    Fiddler使用
    Castle系列教程(转)
  • 原文地址:https://www.cnblogs.com/xumaojun/p/8541010.html
Copyright © 2020-2023  润新知