PHP 實現 HTTP 表單請求伺服器

Dennis_Ritchie發表於2019-11-20

在前面的幾篇博文老司機帶你用 PHP 實現 Websocket 協議PHP 編寫基本的 Socket 程式中,我們使用到了基本的socket程式編寫技巧,在今天的這一篇博文中,我們將繼續深化這一知識,同時我們會用到HTTP協議的相關知識,藉助PHP實現表單上傳伺服器,作為一個後臺開發者,我們必須對這一塊知識,有深入的理解,今天要講的內容有些地方可能不是那麼容易理解(老司機講起來也不是那麼容易的,寫本篇博文的程式碼和博文總共花了2天半時間,因為在這裡要求大家對程式碼的掌控能力更強),希望大家仔細的琢磨分析程式碼,程式碼我已經長傳到了碼雲,倉庫為php-http-form-server,如果你看懂了我這裡寫的程式碼,以後不管你用什麼語言寫socket程式,程式就算再怎麼複雜,套路和這篇博文是一樣的。

高能預警

這篇部落格與以往的部落格有所不同,請一定要下載程式碼,對照著我下面講的看。

預期效果

  1. 徹底搞懂form表單提交(不再是一個黑匣子,因為我們自己實現了)
  2. 更加深入的理解http協議
  3. 複雜的socket程式設計技巧
  4. 練習程式碼掌控能力(這個很重要)

推薦連結

線上流程圖軟體

背景

在寫這篇博文之前,我也沒有寫過相關的程式碼,所以這算是第一次和大家一起實現這個功能,前天利用業餘時間編寫了程式碼,程式碼的基本思想很簡單,就是實現表單上傳這個功能,相信你如果理解了我今天所講的東西,大家以後對socket程式設計這塊,會更加的自信(就算是以後學習其他的語言,這也是非常有用的,基本程式設計思想才是最重要的),在閱讀下面的內容之前,如果你沒有socket程式設計相關的基本知識,請先閱讀我上面提到的PHP 編寫基本的 Socket 程式,這可能會需要你幾分鐘的閱讀時間。

知識背景

相信對於後臺的開發人員而言,form表單並不陌生,form表單的請求方法有2種,分別是GET和POST。但是我們今天的伺服器不會分析GET請求,我們需要把主要的經歷集中在POST請求,這才是form請求的精華和常用方式。form表單的編碼型別有2中,分別為application/x-www-form-urlencoded和multipart/form-data,下面一一分析。
application/x-www-form-urlencoded 這種編碼方式從它的名字後面的一部分就可以看出來urlencode,也就是說表單的內容和url的編碼方式是一樣的,這個大家總知道吧,比如說,一個form表單,有name="obama"和pwd="123456",那麼url方式編碼就是name=obama&pwd=123456,是不是很簡單,沒錯就是這麼簡單。只是如果欄位名和欄位值中包含特殊字元,會被編碼。比如說,name="=obama",pwd="&123456",那麼編碼之後的結果就像是這樣:name=%3Dobama&pwd=%26123456。再比如name="= obama",pwd="& 123456"(name和pwd中都包含空格),那麼編碼結果就是這樣:name=%3D+obama&pwd=%26+123456。具體的編碼如何轉換,我們暫時不必知道,php中已經有函式可以幫助我們urlencode,我們只需要知道的有2個字元不會被轉換,它就是 & 和 = ,請大家一定要記住這一點,切記,因為在我們後面的程式碼實現中,會用到這一點。

multipart/form-data 編碼要稍微複雜一些,但是有老司機在,不用慌,下面的內容是我伺服器收到的,但是被我處理過,二進位制檔案太長了,不利於我們的分析:

------WebKitFormBoundaryVBmvbscTaKzHhTLA
Content-Disposition: form-data; name="name"

obama
------WebKitFormBoundaryVBmvbscTaKzHhTLA
Content-Disposition: form-data; name="pwd"

123456
------WebKitFormBoundaryVBmvbscTaKzHhTLA
Content-Disposition: form-data; name="file"; filename="images.jpg"
Content-Type: image/jpeg

����JFIF���       ( %!read num:2048
����JFIF���       ( %!1!%)+...383-7(-.+
------WebKitFormBoundaryVBmvbscTaKzHhTLA--

上面的內容足以說明了multipart/form-data編碼的套路,仔細看上面的,你是不是發現------WebKitFormBoundaryVBmvbscTaKzHhTLA出現了4次,這是啥?如果你有這樣的疑問,非常好,在解釋它之前,我們還需要看另外一個東西,表單提交的時候,傳遞了下面的

PHP實現HTTP表單請求

為了大家看得更清楚,我採用了截圖的方式,標出來的內容和一般的請求有所不同,這是表單所特有的的,採用multipart/form-data編碼的時候,Content-Type就是multipart/form-data,那麼後面的boundary是啥呢(如果你不知道這個單詞的含義,可以查一下:邊界)。說白了,它的意思就是用來分割表單每一個輸入欄位的,這也就是我上面貼出來的文字,name,pwd,file欄位都被boundary給分割了,但是你要記住了,分割的時候,在boundary的前面加上了2個額外的“-”,所以總共6個"-",這個請大家一定要記住,我之前實現的時候,踩了這個坑,希望大家引以為戒。還有一點需要記住,在每一個post請求的請求體中,在最末尾也有一個boundary,這個boundary和前面說的基本是一樣的,為啥是基本?因為它的後面還有2個“-”(這個可是把我害慘了,一步一步除錯才發現這個問題)。

回到上面貼出來的post請求內容,除了我上面說的分割字串boundary,還有一個頭部是必須的,它就是Content-Disposition,它描述了當前欄位的後設資料,如果當前欄位只是普通文字的話,那麼就只有當前欄位的名稱,如果當前欄位代表的是檔案的話,除了剛才說的,還有一個filename欄位,表示當前上傳檔案的檔名。

另外一個就是Content-Type,他表示當前欄位的內容型別,我們這裡只管普通文字和檔案。對於普通文字來說,這個欄位可能是不存在的(我們不能假設它一定不存在),對於普通文字它的型別一定是以text開頭(這個結論很重要),比如:text/plain。對於圖片來說,它的型別以image開頭,我們只要知道這個足夠了,總的來說,我們只要能區分他是文字還是檔案就足夠了,至於他是什麼檔案型別,已經沒有任何討論的意義。

還有很重要的一點,大家需要非常注意,欄位值,比如說name這個欄位它的值為obama,和欄位的最後一個約束資訊(上面說的Content-Disposition,Content-Type,或者其他的什麼欄位)之間有個\r\n,下圖所示的紅色箭頭(欄位約束的每一行後面的\r\n不算在內,如果算在內的話,就有兩個連續的\r\n了),這個特性是我們用來區分欄位約束的欄位值的唯一依據。

PHP實現HTTP表單請求

最後要和大家說的是,我們永遠都不能猜測每次從套接字中能夠讀取多少個位元組,這是不現實也是沒有意義的,所以這大大增加了程式碼的複雜度,因為每一步操作之前,我們都要判斷內部緩衝區中是否有完整的匹配,這是不可或缺的,這也是為什麼這篇博文這麼長的原因,希望大家能夠明白我的良苦用心。因此下面我會仔細的給大家講解下流程。

程式碼流程

上面說了這麼多就是為了程式碼實現中走更少的彎路,程式碼已經被上傳到了碼雲,倉庫見部落格的開頭,因為這次的程式碼,相對於以前來說,流程可能稍微有些複雜,所以在講解程式碼實現之前,我先講程式碼的流程,請看下圖,為了畫出這個圖,硬是花費了一個小時,我靠,畫出這個圖比寫程式碼難多了,一點兒都不誇張,可能是因為我平時很少畫圖吧,如果流程圖不清晰,在程式碼倉庫中可以看到,希望大家認認真真看:

PHP 實現 HTTP 表單請求

下面分析上面的這個圖,跟著這張圖,你看懂了的話,後面的程式碼也不是問題:

  1. 讀取套接字,每次讀取2048位元組(這個你可以隨便設,一般不能太大)。
  2. 判斷http頭部是否已經解析完成。
  3. 如果http頭部沒有解析完成,就開始解析http頭部,我們知道http頭部的每一行末尾都是\r\n,所以匹配它的正規表示式為:/([^\r])\r\n/。檢查正規表示式的匹配結果,如果沒有匹配到,那麼說明緩衝區的位元組數量不夠,此時需要繼續讀取套接字,所以返回第1步,如果正規表示式匹配到了結果,說明已經匹配到了完整的行,但是這還是不夠,我們還需要檢查第一個子匹配是否為空,如果不為空的話,當前行是完整的http頭部,我們可以檢測Content-Type和Content-Length等等頭部,並且把它們存起來,再次執行第3步,如果為空,那麼說明http頭部已經解析完成了(http最後一個頭部的下一行只有\r\n),此時開始執行第4步。
  4. 此時http頭部已經解析完成了,我們需要開始解析它的請求體,請求體有多個欄位(欄位是啥?舉個例子,對於form表單來說,我假設有個input,它的name屬性值為“”name“”,所以我說name是一個欄位,它由三個部分組成,必須的部分包括boundary,約束資訊(Content-Disposition,可選的Content-Type,或者其他的欄位),還有欄位值,例如:obama)組成。對於每一個欄位來說,我們依次匹配boundary,Content-Disposition,匹配的依據是什麼呢?很簡單,每一行末尾都有\r\n,這個和http頭部的匹配原則是一樣的,上面我們提到過欄位值和約束資訊之間有一行,只包含\r\n,所以只要檢測到這樣的一行,我們就知道當前欄位可以開始匹配欄位值了,這個時候,我們可以設定當前的匹配狀態為開始匹配欄位值,執行流程跳轉到第5步。如果沒有匹配到這樣的行,我們需要一直匹配約束資訊,也就是執行第4步。
  5. 約束資訊匹配完了之後,我們就開始匹配欄位值了,那麼我們以什麼為依據呢?啥時候可以確定欄位值匹配到了呢?/\r\n-boundary(--)?\r\n/ 就是我們的匹配依據,只要我們執行正規表示式,匹配到了結果,也就代表匹配到了欄位值。在當前的程式中,如果當前欄位代表的是檔案,那麼直接本地儲存,如果是普通的文字資料則儲存在物件中。那麼你也許還會問,怎麼判斷所有的欄位匹配完成了呢?這個問題問的非常好,還記得我之前說的不,post請求體的最後一行的boundary後面有2個“-”,所以只要檢測到了這個,就代表匹配完成了,對於當前的正規表示式而言,就是檢測第一個匹配子組的值,如果不為空的話,就說明已經完成了,否則就需要繼續執行第4步,開始匹配下一個欄位。

上面就是整個程式碼的執行流程,請一定要理解,下面我們來看程式碼,再次提醒大家,請下載程式碼,對照著我上面講的,理解應該沒有問題。

實現程式碼

讀取套接字

$bytes_num = socket_recv($client_socket_handle, $buffer, 2048, 0);
$this->internal_buffer .= $buffer;
if (!$bytes_num) {
    echo "socket_recv  failed\n";
    exit(1);
} 

讀取套接字的程式碼很簡單,這裡解釋一下internal_buffer 屬性,我們讀取到的所有資料都會儲存到internal_buffer 中,它就是我們的內部緩衝區。

解析http頭部

if (!$this->http_header_parsed) {
    while (true) {
        if (preg_match("/([^\r]*)\r\n/", $this->internal_buffer, $match) > 0) {
            if (empty($match[1])) {
                //http 頭部匹配完成
                $this->http_header_parsed = true;
            } else {
                if (!$this->request_line_parsed) {
                    $this->request_line_parsed = true;
                    $line_parts = explode(" ", $match[1]);
                    $this->request_method = trim($line_parts[0], " ");
                    $this->uri = $line_parts[1];
                } else {
                    $parts = explode(":", $match[1]);
                    $key = $parts[0];
                    $value = trim($parts[1], " ");//value的左側可能有空格
                    $this->http_headers[$key] = trim($value, "\r\n ");
                    if (strcmp($key, "Content-Type") == 0) {
                        //檢查內容的型別
                        $multipart_type = "multipart/form-data";
                        //如果有上傳檔案的話
                        if (strncmp($multipart_type, $value, strlen($multipart_type)) == 0) {
                            $this->multiple_part_enabled = true;
                            //獲取邊界也就是boundary
                            $this->http_boundary = explode("=",
                                trim(substr($value, strlen($multipart_type) + 1), " "))[1];
                        } else {
                            //此時內容型別為application/x-www-form-urlencoded
                        }
                    } else if (strcmp("Content-Length", $key) == 0) {
                        //檢查內容的長度
                        $this->content_length = intval($value);
                    }
                }
            }
            $this->internal_buffer = substr($this->internal_buffer, strlen($match[0]));
        } else {
            break;
        }
        if ($this->http_header_parsed) {
            break;
        }
    }
}
if (!$this->http_header_parsed) {
  continue;
}

關於preg_match的使用,大家一定要先搞清楚,如果不清楚,請先參考文件preg_match,如果它的返回值大於0,也就是說匹配到了,雖然匹配到了,我們還是要先判斷$match[1]的值,是不是為空,就和之前分析的一樣,為空就說明頭部解析完成了,所以設定http_header_parsed 為true,如果不為空的話,正常解析。解析之前要檢查request_line_parsed的值,它表示請求行是否已經被解析(開始為false),所以第一行肯定是請求行,此時request_line_parsed設定true,那麼下一次匹配就不會進入到這個分支了,看到沒,最外層是個while迴圈,所以我們假設如果緩衝區裡面有足夠的的位元組數,preg_match還是會匹配到,$match[1]的檢查是必須的,我們假設頭部還是沒有解析完成,由於之前request_line_parsed設定為true,所以程式碼會進入else語句,這裡面的程式碼很簡單吧?http頭部的名稱和值以冒號(:)進行分割,分割完之後得到$parts,$key就是頭部名了,
$value就是頭部值了,我們把key,value儲存到了http_headers中,以備將來使用,獲取到這些值之後,我們檢測它是不是Content-Type,如果是的話,我們判斷$value是否包含multipart/form-data,如果包含的話,那麼說明當前的post請求體,是以boundary分割的,此時設定multiple_part_enabled 為true,同時也獲取到了boundary的值,儲存到http_boundary 中,至於為啥是這樣?看我上面的請求圖,很簡單。同樣的,我們也可以檢測Content-Length的值,這樣我們就可以知道post包體的大小了,這個在後面的程式碼中會使用到,在處理完一行http頭部之後,我們需要從內部緩衝區中刪除掉剛才已經讀取的這一行,也就是 substr($this->internal_buffer, strlen($match[0]))的作用了。如果之前preg_match返回值為0的話,那麼就說明緩衝區的位元組數是不夠的,此時執行else語句,它裡面就一行程式碼,break,此時程式碼會跳出內層while迴圈,跳出迴圈之後,因為$this->http_header_parsed的值為false,所以當前while迴圈執行完畢,所以程式碼從最外層while迴圈開始執行(上面沒有貼出來),程式碼如下:

while (true) {
    //每一次讀取2048位元組的資料
    $bytes_num = socket_recv($client_socket_handle, $buffer, 2048, 0);
    $this->internal_buffer .= $buffer;
    if (!$bytes_num) {
        echo "socket_recv  failed\n";
        exit(1);
    } else {
        echo "read num:" . $bytes_num . "\n";
        if (!$this->http_header_parsed) {
            while (true) {
                if (preg_match("/([^\r]*)\r\n/", $this->internal_buffer, $match) > 0)

經過多次解析http頭部之後,之前分析的$match[1]就是空的,所以 $this->http_header_parsed = true,此時http頭部解析完成,跳出內層while迴圈。

解析欄位

接著上面的程式碼,繼續分析:

if (strlen($this->internal_buffer) == 0) {
    continue;
}
if ($this->http_header_parsed) {
    if (strpos($this->request_method, "GET") === false) {
        if ($this->multiple_part_enabled) {
            //檢測boundary
            while (true) {
                //隱藏掉了
            }
        } else {
            //隱藏掉了
        }
    } else {
        echo "GET request not supported";
    }
}

在解析完http頭部之後,我們還需要檢查一下internal_buffer的長度,如果為0的話,那麼返回到最外層的while迴圈,繼續讀取套接字,和上面的一模一樣,這裡在解析欄位之前,還檢查了請求方法,我們之前就說過了,當前的程式不處理get請求的form表單請求,所以列印出"GET request not supported",就完事兒了。從前面的分析,我們知道multiple_part_enabled為true表示編碼型別為multipart/form-data,所以此時程式碼進入到if語句中,否則進入到else中,下面我們分兩種情況進行分析。

如果multiple_part_enabled為true的話,程式碼如下:

while (true) {
    if (!$this->start_match_field_content) {
        if (preg_match("/([^\r]*)\r\n/", $this->internal_buffer, $match) > 0) {
            if (!$this->boundary_start_matched) {
                $this->boundary_start_matched = true;
            }
            if (strncmp($match[1], "Content-Disposition:", strlen('Content-Disposition:')) == 0) {
                $disposition_parts = explode(';',
                    str_replace(" ", "", substr($match[1], strlen("Content-Disposition:"))));
                $this->current_field_name = str_replace("\"", "", substr($disposition_parts[1], 5));
                if (count($disposition_parts) > 2) {
                    $this->current_file_name = str_replace("\"", "",
                        substr(trim($disposition_parts[2], " "), strlen("filename=")));
                }
            }
            if (strncmp($match[1], "Content-Type:", strlen('Content-Type:')) == 0) {
                $content_value = str_replace(" ", "", substr($match[1], strlen("Content-Type:")));
                if (($pos = strpos($content_value, ";")) > 0) {
                    $this->current_field_content_type = substr($content_value, 0, $pos);
                } else {
                    $this->current_field_content_type = $content_value;
                }
            }
            if (empty($match[1])) {
                $this->start_match_field_content = true;
                if (strpos($this->current_field_content_type, "text") !== false
                    || empty($this->current_field_content_type)) {
                    $this->field_type_is_file = false;
                } else {
                    $this->field_type_is_file = true;
                }
            }
            $this->has_read_bytes_num += strlen($match[0]);
            $this->internal_buffer = substr($this->internal_buffer, strlen($match[0]));
        } else {
            break;
        }
    } else {
        if (preg_match("/\r\n--{$this->http_boundary}(--)?\r\n/",
                $this->internal_buffer, $match, PREG_OFFSET_CAPTURE, 0) > 0) {
            if ($this->field_type_is_file) {
                file_put_contents(__DIR__ . '/' . $this->current_file_name,
                    substr($this->internal_buffer, 0, $match[0][1]));
            } else {
                $this->http_form_data[$this->current_field_name]
                    = substr($this->internal_buffer, 0, $match[0][1]);
            }
            $this->internal_buffer = substr($this->internal_buffer, $match[0][1] + 2);
            $this->has_read_bytes_num += ($match[0][1] + 2);
            if (($this->content_length - $this->has_read_bytes_num) == (strlen($this->http_boundary) + 6)) {
                echo "client content parsed finished\n";
                //http內容解析完成
                foreach ($this->http_form_data as $key => $value) {
                    echo $key . "=>" . $value . "\n";
                }
            } else {
                $this->start_match_field_content = false;
                $this->boundary_start_matched = false;
                $this->form_part_field_matched = false;
                $this->field_content_type_checked = false;
                $this->current_field_name = null;
                $this->current_file_name = null;
                $this->current_field_content_type = null;
                $this->field_type_is_file = false;
            }
        } else {
            break;
        }
    }
}

程式碼有點兒長,所以首先從結構上看,這段程式碼根據$this->start_match_field_content的值分為2部分,$this->start_match_field_content表示啥呢?它表示是否開始匹配欄位值,這個值為預設為false,所以程式碼會進入到if中,這裡還是用到了preg_match,之前在分析流程時候,已經分析過它了,之所這麼做,因為每一行的末尾都有\r\n,這裡的preg_match如果返回0,就表示緩衝區的位元組數不夠了,所以break,退出while迴圈,這樣程式碼會從最外層while開始執行起,繼續讀取套接字。我們假設preg_match匹配到了,那麼程式碼會匹配Content-Disposition,Content-Type等欄位的值,current_field_name 記錄當前解析到的欄位名,比如說name,current_file_name 記錄著解析上傳檔案的名稱,比如我測試的時候是1240.gif,current_field_content_type 記錄著欄位的型別(之所以會判斷分號“;”,是因為可能會出現Content-Type:text/plain;charset=utf-8),從前面的程式碼流程中,我們就說了,欄位約束資訊和欄位值之間有一個只有\r\n的行,這就對應著上面的$match[1],所以如果$match[1]的為空的話, $this->start_match_field_content = true;就表示需要開始匹配欄位值了,但是這裡還有一個操作,判斷欄位是屬於檔案還是普通文字,如果是文字的話,那麼Content-type必定包含text字首或者Content-type缺失。

從上面的分析,知道$this->start_match_field_content 為true,表示可以開始匹配欄位值了,此時匹配也是通過preg_match實現的,具體的原理在流程分析已經仔細的講過了,如果preg_match的返回值等於0,就表示緩衝區的位元組數不夠了,所以break,退出while迴圈,這樣程式碼會從最外層while開始執行起,繼續讀取套接字。這次的preg_match呼叫和之前有所區別,我們傳遞了額外的一個引數PREG_OFFSET_CAPTURE,如果你不清楚它的用法,請參閱官方文件。簡單來說,如果傳遞了這個引數,就可以獲取到每一個匹配的偏移,包括完整匹配匹配子組

$this->field_type_is_file的值,表示當前欄位是檔案,還是文字,這裡還要再說一點,就是$match[0][1],它是什麼意思呢?剛才我們講到了,我們傳遞了額外的一個引數PREG_OFFSET_CAPTURE,所以對於完整匹配(完整匹配就是正規表示式匹配到的完整字串),$match[0][1]就表示完整匹配的第一個位元組在內部緩衝區中的偏移,同時也表示完整匹配的長度,所以 substr($this->internal_buffer, 0, $match[0][1]))的值就是欄位值的實際內容。同樣的,我們需要刪除掉緩衝區中已經讀取的內容,但是為什麼刪除的長度是 $match[0][1] + 2呢?因為在我們的正規表示式的前面有\r\n2個位元組,所以要加上他們(這個特別注意,因為欄位值的後面會有\r\n,這個對於欄位值是不需要的)。我們之前好像還沒說$this->has_read_bytes_num這個欄位是幹嘛用的對吧?在我們之前每一次解析post請求體的時候,不管是解析欄位約束資訊還是欄位值,都會記錄著當前消耗的位元組數,所以我們可以用這個值計算出是否已經完整的讀取了post請求體,具體計算公式在程式碼裡,我解釋一下,($this->content_length - $this->has_read_bytes_num)表示內部緩衝區中還未讀取的位元組數,(strlen($this->http_boundary) + 6),$this->http_boundary表示boundary,你已經知道了,關鍵是後面的6,6包含4個字元"-"(最後一個boundary後面有2個,前面已經說過了,加上前面的2個),還有一個\r還有一個\n。

下面我們分析編碼為application/x-www-form-urlencoded的情況,這種情況很簡單,具體的原理在這篇部落格的開頭,已經有過2詳細的論述,不多說,看程式碼:

if (strlen($this->internal_buffer) < $this->content_length) {
    //還有資料沒有讀取,不進行任何操作
} else {
    foreach (explode('&', $this->internal_buffer) as $pair) {
        $pair_parts = explode('=', $pair);
        $this->http_form_data[urldecode($pair_parts[0])] = urldecode($pair_parts[1]);
    }

    foreach ($this->http_form_data as $key => $value) {
        echo $key . "=>" . $value . "\n";
    }
}

這段程式碼首先判斷當前內部緩衝區的位元組數是不是和post請求體的內容長度(Content-Length的值)是一樣的,如果不一樣,說明套接字中還有資料待讀取,直接結束本次迴圈就行了。否則,資料讀取完畢,我們用&和=對請求體內容進行分割處理(至於為啥這麼做,看這篇部落格的開頭),值得注意的是欄位名和欄位值都有可能被編碼過,所以需要解碼,php的函式urldecode可以做到這一點,這種編碼方式非常簡單,就不多少了。

經過上面的分析,程式碼就算走完了,這裡的程式碼非常具有現實意義,希望大家理解。

告誡

上面仔細的分析過了程式碼的執行流程,但是請大家一定要下載原始程式碼,自己仔細看一哈,實踐是檢驗真理的唯一標準,如果你發現了程式碼中的錯誤,請聯絡我(聯絡方式在這篇博文的最後面)或者給我留言,都可以。

執行程式碼示例

主要是這2個檔案

PHP 實現 HTTP 表單請求

控制檯執行 php TcpServer.php ,然後再開啟form.html檔案,你可以修改這個檔案,以觀察不同的編碼的列印輸出,在我當前的測試情況下,控制檯列印如下:

PHP 實現 HTTP 表單請求

可以看到我的程式總共讀取了16次套接字,傳遞2個post欄位,同時在我的資料夾下面生成了如下的檔案

PHP 實現 HTTP 表單請求

學習不容易,希望大家堅持和忍耐,為了寫這篇部落格,都熬到了凌晨1點了,不懂的請問我,我會耐心解答。

PHP 實現 HTTP 表單請求

如果有不懂的地方,可以加我的qq:1174332406,或者是微信:itshardjs,希望大家學習愉快

相關文章