zoukankan      html  css  js  c++  java
  • Http请求工具实例编写

    HTTP协议工作方式首先客户端发送一个请求(request)给服务器,服务器在接收到这个请求后将生成一个响应(response)返回给客户端。
    在这个通信的过程中HTTP协议在以下4个方面做了规定:
    1. Request和Response的格式
    Request格式:

    HTTP请求行 
    (请求)头 
    空行 
    可选的消息体 

    注:请求行和标题必须以<CR><LF> 作为结尾(也就是,回车然后换行)。空行内必须只有<CR><LF>而无其他空格。在HTTP/1.1 协议中,所有的请求头,除Host外,都是可选的。

    实例:


    1. GET / HTTP/1.1  
    2.   
    3.   
    4. Host: gpcuster.cnblogs.com  
    5.   
    6.   
    7. User-Agent: Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.9.0.10) Gecko/2009042316 Firefox/3.0.10  
    8.   
    9.   
    10. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8  
    11.   
    12.   
    13. Accept-Language: en-us,en;q=0.5  
    14.   
    15.   
    16. Accept-Encoding: gzip,deflate  
    17.   
    18.   
    19. Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7  
    20.   
    21.   
    22. Keep-Alive: 300  
    23.   
    24.   
    25. Connection: keep-alive  
    26.   
    27.   
    28. If-Modified-Since: Mon, 25 May 2009 03:19:18 GMT  





    Response格式:


    HTTP状态行 
    (应答)头 
    空行 
    可选的消息体


    实例:


    1. HTTP/1.1 200 OK  
    2.   
    3.   
    4. Cache-Control: private, max-age=30  
    5.   
    6.   
    7. Content-Type: text/html; charset=utf-8  
    8.   
    9.   
    10. Content-Encoding: gzip  
    11.   
    12.   
    13. Expires: Mon, 25 May 2009 03:20:33 GMT  
    14.   
    15.   
    16. Last-Modified: Mon, 25 May 2009 03:20:03 GMT  
    17.   
    18.   
    19. Vary: Accept-Encoding  
    20.   
    21.   
    22. Server: Microsoft-IIS/7.0  
    23.   
    24.   
    25. X-AspNet-Version: 2.0.50727  
    26.   
    27.   
    28. X-Powered-By: ASP.NET  
    29.   
    30.   
    31. Date: Mon, 25 May 2009 03:20:02 GMT  
    32.   
    33.   
    34. Content-Length: 12173  






    消息体的内容(略)详细的信息请参考:RFC 2616。关于HTTP headers的简要介绍,请查看:Quick reference to HTTP headers


    2.建立连接的方式

    HTTP支持2中建立连接的方式:非持久连接和持久连接(HTTP1.1默认的连接方式为持久连接)。

    1)非持久连接

    让我们查看一下非持久连接情况下从服务器到客户传送一个Web页面的步骤。假设该贝面由1个基本HTML文件和10个JPEG图像构成,而且所有这些对象都存放在同一台服务器主机中。再假设该基本HTML文件的URL为:gpcuster.cnblogs.com/index.html。

    下面是具体步骡:

    1.HTTP客户初始化一个与服务器主机gpcuster.cnblogs.com中的HTTP服务器的TCP连接。HTTP服务器使用默认端口号80监听来自HTTP客户的连接建立请求。

    2.HTTP客户经由与TCP连接相关联的本地套接字发出—个HTTP请求消息。这个消息中包含路径名/somepath/index.html。

    3.HTTP服务器经由与TCP连接相关联的本地套接字接收这个请求消息,再从服务器主机的内存或硬盘中取出对象/somepath/index.html,经由同一个套接字发出包含该对象的响应消息。

    4.HTTP服务器告知TCP关闭这个TCP连接(不过TCP要到客户收到刚才这个响应消息之后才会真正终止这个连接)。

    5.HTTP客户经由同一个套接字接收这个响应消息。TCP连接随后终止。该消息标明所封装的对象是一个HTML文件。客户从中取出这个文件,加以分析后发现其中有10个JPEG对象的引用。

    6.给每一个引用到的JPEG对象重复步骡1-4。

    上述步骤之所以称为使用非持久连接,原因是每次服务器发出一个对象后,相应的TCP连接就被关闭,也就是说每个连接都没有持续到可用于传送其他对象。每个TCP连接只用于传输一个请求消息和一个响应消息。就上述例子而言,用户每请求一次那个web页面,就产生11个TCP连接。

    2)持久连接

    非持久连接有些缺点。首先,客户得为每个待请求的对象建立并维护一个新的连接。对于每个这样的连接,TCP得在客户端和服务器端分配TCP缓冲区,并维持TCP变量。对于有可能同时为来自数百个不同客户的请求提供服务的web服务器来说,这会严重增加其负担。其次,如前所述,每个对象都有2个RTT的响应延长——一个RTT用于建立TCP连接,另—个RTT用于请求和接收对象。最后,每个对象都遭受TCP缓启动,因为每个TCP连接都起始于缓启动阶段。不过并行TCP连接的使用能够部分减轻RTT延迟和缓启动延迟的影响。

    在持久连接情况下,服务器在发出响应后让TCP连接继续打开着。同一对客户/服务器之间的后续请求和响应可以通过这个连接发送。整个Web页面(上例中为包含一个基本HTMLL文件和10个图像的页面)自不用说可以通过单个持久TCP连接发送:甚至存放在同一个服务器中的多个web页面也可以通过单个持久TCP连接发送。通常,HTTP服务器在某个连接闲置一段特定时间后关闭它,而这段时间通常是可以配置的。持久连接分为不带流水线(without pipelining)和带流水线(with pipelining)两个版本。如果是不带流水线的版本,那么客户只在收到前一个请求的响应后才发出新的请求。这种情况下,web页面所引用的每个对象(上例中的10个图像)都经历1个RTT的延迟,用于请求和接收该对象。与非持久连接2个RTT的延迟相比,不带流水线的持久连接已有所改善,不过带流水线的持久连接还能进一步降低响应延迟。不带流水线版本的另一个缺点是,服务器送出一个对象后开始等待下一个请求,而这个新请求却不能马上到达。这段时间服务器资源便闲置了。

    HTTP/1.1的默认模式使用带流水线的持久连接。这种情况下,HTTP客户每碰到一个引用就立即发出一个请求,因而HTTP客户可以一个接一个紧挨着发出各个引用对象的请求。服务器收到这些请求后,也可以一个接一个紧挨着发出各个对象。如果所有的请求和响应都是紧挨着发送的,那么所有引用到的对象一共只经历1个RTT的延迟(而不是像不带流水线的版本那样,每个引用到的对象都各有1个RTT的延迟)。另外,带流水线的持久连接中服务器空等请求的时间比较少。与非持久连接相比,持久连接(不论是否带流水线)除降低了1个RTT的响应延迟外,缓启动延迟也比较小。其原因在于既然各个对象使用同一个TCP连接,服务器发出第一个对象后就不必再以一开始的缓慢速率发送后续对象。相反,服务器可以按照第一个对象发送完毕时的速率开始发送下一个对象。在http1.0协议中每次请求和响应都会创建一个新的tcp连接,http1.1之后才开始支持可以重用第一次请求的http连接, 默认支持长连接形式。 如果client或server端不想支持长连接,则需要在htt的header加上connection:close,如果支持,则设置header为connection:keep-alive。

    以上主要是简要阐述了http请求的流程,需要实现一个简易的httpclient,需要注意:

    1)短连接or长连接。
    2)header的解析与构建。
    3)body的解析与构建。
    4)chunk与content-length的不同解析方式。
    5)http method的不同。
    ....

    如下主要是根据实例化的httpRequest生成header,支持POST,GET,OPTIONS三种method。


    1. string TC_HttpRequest::encode()  
    2. {  
    3. //    assert(_requestType == REQUEST_GET || _requestType == REQUEST_POST || !_originRequest.empty());  
    4.   
    5.   
    6.     ostringstream os;  
    7.   
    8.   
    9.     if(_requestType == REQUEST_GET)  
    10.     {  
    11.         encode(REQUEST_GET, os);  
    12.     }  
    13.     else if(_requestType == REQUEST_POST)  
    14.     {  
    15.         setContentLength(_content.length());  
    16.         encode(REQUEST_POST, os);  
    17.         os << _content;  
    18.     }  
    19.     else if(_requestType == REQUEST_OPTIONS)  
    20.     {  
    21.         encode(REQUEST_OPTIONS, os);  
    22.     }  
    23.   
    24.   
    25.     return os.str();  
    26. }  





    header与body之间是两个 。

    1. void TC_HttpRequest::encode(int iRequestType, ostream &os)  
    2. {  
    3.     os << requestType2str(iRequestType) << " " << _httpURL.getRequest() << " HTTP/1.1 ";  
    4.     os << genHeader();  
    5.     os << " ";  
    6. }  





    便利所有的header key,用 进行换行分隔。

    1. string TC_Http::genHeader() const  
    2. {  
    3.     ostringstream sHttpHeader;  
    4.   
    5.   
    6.     for(http_header_type::const_iterator it = _headers.begin(); it != _headers.end(); ++it)  
    7.     {  
    8.         if(it->second != "")  
    9.         {  
    10.             sHttpHeader << it->first << ": " << it->second << " ";  
    11.         }  
    12.     }  
    13.   
    14.   
    15.     return sHttpHeader.str();  
    16. }  







    如下是一个HttpRequest的解析请求。通过TCP socket将构造的http request header发送至服务器http server端口。构建缓冲区循环接收返回数据,直到客户端完整的response包接收完毕或者服务器异常关闭。


    1. int TC_HttpRequest::doRequest(TC_HttpResponse &stHttpRsp, int iTimeout)  
    2. {  
    3.     //只支持短连接模式  
    4.     setConnection("close");     
    5.   
    6.   
    7.     string sSendBuffer = encode();  
    8.   
    9.   
    10.     string sHost;  
    11.     uint32_t iPort;  
    12.   
    13.   
    14.     getHostPort(sHost, iPort);  
    15.   
    16.   
    17.     TC_TCPClient tcpClient;  
    18.     tcpClient.init(sHost, iPort, iTimeout);  
    19.   
    20.   
    21.     int iRet = tcpClient.send(sSendBuffer.c_str(), sSendBuffer.length());  
    22.     if(iRet != TC_ClientSocket::EM_SUCCESS)  
    23.     {  
    24.         return iRet;  
    25.     }  
    26.   
    27.   
    28.     stHttpRsp.reset();  
    29.   
    30.   
    31.     string sBuffer;  
    32.   
    33.   
    34.     char *sTmpBuffer = new char[10240];  
    35.     size_t iRecvLen  = 10240;  
    36.   
    37.   
    38.     while(true)  
    39.     {  
    40.         iRecvLen = 10240;  
    41.   
    42.   
    43.         iRet = tcpClient.recv(sTmpBuffer, iRecvLen);  
    44.   
    45.   
    46.         if(iRet == TC_ClientSocket::EM_SUCCESS)  
    47.             sBuffer.append(sTmpBuffer, iRecvLen);  
    48.   
    49.   
    50.         switch(iRet)  
    51.         {  
    52.         case TC_ClientSocket::EM_SUCCESS:  
    53.             if(stHttpRsp.incrementDecode(sBuffer))  
    54.             {  
    55.                 delete []sTmpBuffer;  
    56.                 return TC_ClientSocket::EM_SUCCESS;  
    57.             }  
    58.             continue;  
    59.         case TC_ClientSocket::EM_CLOSE:  
    60.             delete []sTmpBuffer;  
    61.             stHttpRsp.incrementDecode(sBuffer);  
    62.             return TC_ClientSocket::EM_SUCCESS;  
    63.         default:  
    64.             delete []sTmpBuffer;  
    65.             return iRet;  
    66.         }  
    67.     }  
    68.   
    69.   
    70.     assert(true);  
    71.   
    72.   
    73.     return 0;  
    74. }  




    数据接收分为两部分,第一部分是header,通过header头的解读,进一步接收与解析body content,如果解析返回false,表示http response并未接收完成,继续接收。

    1. case TC_ClientSocket::EM_SUCCESS:  
    2.            if(stHttpRsp.incrementDecode(sBuffer))  
    3.            {  
    4.                delete []sTmpBuffer;  
    5.                return TC_ClientSocket::EM_SUCCESS;  
    6.            }  
    7.            continue;  




    当数据接收成功,将接收的buffer放入resp中进行解析:


    1. bool TC_HttpResponse::incrementDecode(string &sBuffer)  
    2. {  
    3.     //解析头部  
    4.     if(_headLength == 0)  
    5.     {  
    6.         string::size_type pos = sBuffer.find(" ");  
    7.   
    8.   
    9.         if(pos == string::npos)  
    10.         {  
    11.             return false;  
    12.         }  
    13.   
    14.   
    15.         parseResponseHeader(sBuffer.c_str());  
    16.   
    17.   
    18.         if(_status == 204)  
    19.         {  
    20.             return false;  
    21.         }  
    22.   
    23.   
    24.         http_header_type::const_iterator it = _headers.find("Content-Length");  
    25.         if(it != _headers.end())  
    26.         {  
    27.             _iTmpContentLength = getContentLength();  
    28.         }  
    29.         else  
    30.         {  
    31.             //没有指明ContentLength, 接收到服务器关闭连接  
    32.             _iTmpContentLength = -1;  
    33.         }  
    34.   
    35.   
    36.         _headLength = pos + 4;  
    37.   
    38.   
    39.         sBuffer = sBuffer.substr(_headLength);  
    40.   
    41.   
    42.         //重定向就认为成功了  
    43.         if((_status == 301 || _status == 302) && !getHeader("Location").empty())  
    44.         {  
    45.             return true;  
    46.         }  
    47.   
    48.   
    49.         //是否是chunk编码  
    50.         _bIsChunked = (getHeader("Transfer-Encoding") == "chunked");  
    51.   
    52.   
    53.         //删除头部里面  
    54.         eraseHeader("Transfer-Encoding");  
    55.     }  
    56.   
    57.   
    58.     if(_bIsChunked)  
    59.     {  
    60.         while(true)  
    61.         {  
    62.             string::size_type pos   = sBuffer.find(" ");  
    63.             if(pos == string::npos)  
    64.                 return false;  
    65.   
    66.   
    67.             //查找当前chunk的大小  
    68.             string sChunkSize       = sBuffer.substr(0, pos);  
    69.             int iChunkSize          = strtol(sChunkSize.c_str(), NULL, 16);  
    70.   
    71.   
    72.             if(iChunkSize <= 0)     break;      //所有chunk都接收完毕  
    73.   
    74.   
    75.             if(sBuffer.length() >= pos + 2 + (size_t)iChunkSize + 2)   //接收到一个完整的chunk了  
    76.             {  
    77.                 //获取一个chunk的内容  
    78.                 _content += sBuffer.substr(pos + 2, iChunkSize);  
    79.   
    80.   
    81.                 //删除一个chunk  
    82.                 sBuffer   =  sBuffer.substr(pos + 2 + iChunkSize + 2);  
    83.             }  
    84.             else  
    85.             {  
    86.                 //没有接收完整的chunk  
    87.                 return false;  
    88.             }  
    89.   
    90.   
    91.             setContentLength(getContent().length());  
    92.         }  
    93.   
    94.   
    95.         sBuffer = "";  
    96.   
    97.   
    98.         if(_iTmpContentLength == 0 || _iTmpContentLength == (size_t)-1)  
    99.         {  
    100.             setContentLength(getContent().length());  
    101.         }  
    102.   
    103.   
    104.         return true;  
    105.     }  
    106.     else  
    107.     {  
    108.         if(_iTmpContentLength == 0)  
    109.         {  
    110.             _content += sBuffer;  
    111.             sBuffer   = "";  
    112.   
    113.   
    114.             //自动填写content-length  
    115.             setContentLength(getContent().length());  
    116.   
    117.   
    118.             return true;  
    119.         }  
    120.         else if(_iTmpContentLength == (size_t)-1)  
    121.         {  
    122.             _content += sBuffer;  
    123.             sBuffer   = "";  
    124.   
    125.   
    126.             //自动填写content-length  
    127.             setContentLength(getContent().length());  
    128.   
    129.   
    130.             return false;  
    131.         }  
    132.         else  
    133.         {  
    134.             //短连接模式, 接收到长度大于头部为止  
    135.             _content += sBuffer;  
    136.             sBuffer   = "";  
    137.   
    138.   
    139.             size_t iNowLength = getContent().length();  
    140.   
    141.   
    142.             //头部的长度小于接收的内容, 还需要继续增加解析后续的buffer  
    143.             if(_iTmpContentLength > iNowLength)  
    144.                 return false;  
    145.   
    146.   
    147.             return true;  
    148.         }  
    149.     }  
    150.   
    151.   
    152.     return true;  
    153. }  





    该解析if(_headLength == 0)判断是否header已经开始接收,知道遇见


    1. string::size_type pos = sBuffer.find(" ");  
    2.   
    3.   
    4. if(pos == string::npos)  
    5. {  
    6.     return false;  
    7. }  




    则表示header接收完成,并解析完整的header,其中HTTP status 204HTTP状态码2XX 都表示成功。HTTP的204(no content)响应,表示执行成功,但没有数据返回,浏览器不用刷新页面,也不用导向新的页面。


    1.        parseResponseHeader(sBuffer.c_str());  
    2.   
    3.   
    4.        if(_status == 204)  
    5.        {  
    6.            return false;  
    7.        }  
    8.   
    9.   
    10.   
    11.   
    12. void TC_HttpResponse::parseResponseHeader(const char* szBuffer)  
    13. {  
    14.     const char **ppChar = &szBuffer;  
    15.   
    16.   
    17.     _headerLine = TC_Common::trim(getLine(ppChar));  
    18.   
    19.   
    20.     string::size_type pos = _headerLine.find(' ');  
    21.   
    22.   
    23.     if(pos != string::npos)  
    24.     {  
    25.     _version    = _headerLine.substr(0, pos);  
    26.   
    27.   
    28.     string left = TC_Common::trim(_headerLine.substr(pos));  
    29.   
    30.   
    31.     string::size_type pos1 = left.find(' ');  
    32.   
    33.   
    34.     if(pos1 != string::npos)  
    35.     {  
    36.         _status  = TC_Common::strto<int>(left.substr(0, pos));  
    37.   
    38.   
    39.         _about   = TC_Common::trim(left.substr(pos1 + 1));  
    40.     }  
    41.     else  
    42.     {  
    43.         _status  = TC_Common::strto<int>(left);  
    44.   
    45.   
    46.         _about   = "";  
    47.     }  
    48.   
    49.   
    50.     parseHeader(*ppChar, _headers);  
    51.     return;  
    52.     }  
    53.     else  
    54.     {  
    55.     _version = _headerLine;  
    56.     _status  = 0;  
    57.     _about   = "";  
    58.     }  
    59.   
    60.   
    61. //    throw TC_HttpResponse_Exception("[TC_HttpResponse_Exception::parseResponeHeader] http response format error : " + _headerLine);  
    62. }  





          

    接下来判断http response的content-length,如果明确返回则body字段确定,如果没有,则需要接收服务器直至关闭。


          

    1. http_header_type::const_iterator it = _headers.find("Content-Length");         
    2. if(it != _headers.end())  
    3. {  
    4.     _iTmpContentLength = getContentLength();  
    5. }  
    6. else  
    7. {  
    8.     //没有指明ContentLength, 接收到服务器关闭连接  
    9.     _iTmpContentLength = -1;  
    10. }  
    11.   
    12.   
    13. _headLength = pos + 4;  
    14.   
    15.   
    16.  _headLength = pos + 4;  
    17.   
    18.   
    19. sBuffer = sBuffer.substr(_headLength);  //把body提取出来  
    20.   
    21.   
    22.   
    23.   
    24. //重定向就认为成功了  
    25. if((_status == 301 || _status == 302) && !getHeader("Location").empty())  
    26. {  
    27.     return true;  
    28. }  
    29.   
    30.   
    31. //是否是chunk编码  
    32. _bIsChunked = (getHeader("Transfer-Encoding") == "chunked");  
    33.   
    34.   
    35. //删除头部里面  
    36. eraseHeader("Transfer-Encoding");  
    37. if(it != _headers.end())  
    38. {  
    39.     _iTmpContentLength = getContentLength();  
    40. }  
    41. else  
    42. {  
    43.     //没有指明ContentLength, 接收到服务器关闭连接  
    44.     _iTmpContentLength = -1;  
    45. }  
    46.   
    47.   
    48. _headLength = pos + 4;  
    49.   
    50.   
    51.  _headLength = pos + 4;  
    52.   
    53.   
    54. sBuffer = sBuffer.substr(_headLength);  //把body提取出来  
    55.   
    56.   
    57.   
    58.   
    59. //重定向就认为成功了  
    60. if((_status == 301 || _status == 302) && !getHeader("Location").empty())  
    61. {  
    62.     return true;  
    63. }  
    64.   
    65.   
    66. //是否是chunk编码  
    67. _bIsChunked = (getHeader("Transfer-Encoding") == "chunked");  
    68.   
    69.   
    70. //删除头部里面  
    71. eraseHeader("Transfer-Encoding");  







    接下来将开始循环接收数据,如果非chunk,分为三种情况0(没有body,即可就可以停止接收),-1(一直接收,知道服务器关闭),确定长度(接收完成即可以停止):


    1. {  
    2.         if(_iTmpContentLength == 0)  
    3.         {  
    4.             _content += sBuffer;   //将整体内容保存下来  
    5.             sBuffer   = "";     //清空接收缓存  
    6.   
    7.   
    8.             //自动填写content-length  
    9.             setContentLength(getContent().length());  
    10.   
    11.   
    12.             return true;  
    13.         }  
    14.         else if(_iTmpContentLength == (size_t)-1)  
    15.         {  
    16.             _content += sBuffer;  
    17.             sBuffer   = "";  
    18.   
    19.   
    20.             //自动填写content-length  
    21.             setContentLength(getContent().length());  
    22.   
    23.   
    24.             return false;  
    25.         }  
    26.     //有明确的content-length长度  
    27.         else  
    28.         {  
    29.             //短连接模式, 接收到长度大于头部为止  
    30.             _content += sBuffer;  
    31.             sBuffer   = "";  
    32.   
    33.   
    34.             size_t iNowLength = getContent().length();  
    35.   
    36.   
    37.             //头部的长度小于接收的内容, 还需要继续增加解析后续的buffer  
    38.             if(_iTmpContentLength > iNowLength)  
    39.                 return false;   //如果接收的字节大于content-length就可以停止了,否则继续接收下去  
    40.   
    41.   
    42.             return true;  
    43.         }  
    44. }  





    如果是chunk分片,则更加复杂一些:

    1. while(true)  
    2. {  
    3.     string::size_type pos   = sBuffer.find(" ");  
    4.     if(pos == string::npos)  
    5.         return false;  
    6.   
    7.   
    8.     //查找当前chunk的大小  
    9.     string sChunkSize       = sBuffer.substr(0, pos);  
    10.     int iChunkSize          = strtol(sChunkSize.c_str(), NULL, 16);  
    11.   
    12.   
    13.     if(iChunkSize <= 0)     break;      //所有chunk都接收完毕  
    14.   
    15.   
    16.     if(sBuffer.length() >= pos + 2 + (size_t)iChunkSize + 2)   //接收到一个完整的chunk了  
    17.     {  
    18.         //获取一个chunk的内容  
    19.         _content += sBuffer.substr(pos + 2, iChunkSize);  
    20.   
    21.   
    22.         //删除一个chunk  
    23.         sBuffer   =  sBuffer.substr(pos + 2 + iChunkSize + 2);  
    24.     }  
    25.     else  
    26.     {  
    27.         //没有接收完整的chunk  
    28.         return false;  
    29.     }  
    30.   
    31.   
    32.     setContentLength(getContent().length());  
    33. }  
    34.   
    35.   
    36. sBuffer = "";  
    37.   
    38.   
    39. if(_iTmpContentLength == 0 || _iTmpContentLength == (size_t)-1)  
    40. {  
    41.     setContentLength(getContent().length());  
    42. }  
    43.   
    44.   
    45. return true;  



    每一个chunk前两个字段为chunk的zise,用16进制标识,如果chunk size为0,表示没有chunk了,否则则接收完成一个chunk,如果当前chunk没有完成,则继续接收完成这个chunk后处理,
    如果当前chunk完成了,则将这个chunk在buffer里面删除,放到content内容中来。

  • 相关阅读:
    Golang中使用set
    go 删除数组元素
    golang slice 简单排序
    WSGI 配置禁止反向DNS查找
    OpenStack Restful API框架介绍
    kubebuilder controller 资料学习
    package controllerutil
    JavaWeb开发好资料
    Hibernate3.6中文手册
    软件项目版本号的命名规则及格式
  • 原文地址:https://www.cnblogs.com/lidabo/p/4585905.html
Copyright © 2011-2022 走看看