PHP 透過 fsockopen() / stream_socket_client() 可建立 TCP/UDP 連線,實作各類應用層協定客戶端。本文以 NNTP (Network News Transfer Protocol, RFC 977) 為例,示範連接新聞組伺服器、認證、選擇群組、抓取最新文章的完整流程。
resource fsockopen(
string $hostname,
int $port,
int &$errno = null,
string &$errstr = null,
float $timeout = ini_get("default_socket_timeout")
)
$hostname:域名或 IP$port:NNTP 標準埠 119(SSL 為 563)$timeout:連線逾時秒數,防止腳本卡死fgets()、fputs()、fclose() 操作| 指令 | 格式 | 說明 | 成功回應碼 |
|---|---|---|---|
| 連線問候 | — | 伺服器主動送出 | 200 / 201 |
| 認證 | AUTHINFO USER <user>\r\nAUTHINFO PASS <pass>\r\n | 私有伺服器需求 | 281 |
| 選擇群組 | GROUP <newsgroup>\r\n | 回傳文章數、首末編號 | 211 |
| 取得文章標頭 | HEAD <msgid|number>\r\n | 僅標頭 | 221 |
| 取得完整文章 | ARTICLE <msgid|number>\r\n | 標頭+內文 | 220 |
| 列出群組 | LIST\r\n | 所有可用群組 | 215 |
| 結束連線 | QUIT\r\n | — | 205 |
<?php
// 設定
$cfg = [
'host' => 'news.example.com',
'port' => 119,
'timeout' => 10,
'group' => 'comp.lang.php',
'user' => null, // 若需認證
'pass' => null,
'limit' => 10, // 抓取筆數
];
// 1. 建立連線
$errno = $errstr = '';
$conn = @fsockopen($cfg['host'], $cfg['port'], $errno, $errstr, $cfg['timeout']);
if (!$conn) {
die("連線失敗 [$errno]: $errstr\n");
}
stream_set_timeout($conn, $cfg['timeout']);
echo "已連線至 {$cfg['host']}:{$cfg['port']}\n";
// 2. 讀取問候訊息
$welcome = fgets($conn, 1024);
echo "伺服器: $welcome";
if (!preg_match('/^20[01]/', $welcome)) {
die("伺服器拒絕連線\n");
}
// 3. 認證(若需)
if ($cfg['user']) {
fwrite($conn, "AUTHINFO USER {$cfg['user']}\r\n");
$resp = fgets($conn, 1024);
if (!preg_match('/^381/', $resp)) die("帳號錯誤: $resp");
fwrite($conn, "AUTHINFO PASS {$cfg['pass']}\r\n");
$resp = fgets($conn, 1024);
if (!preg_match('/^281/', $resp)) die("密碼錯誤: $resp");
echo "認證成功\n";
}
// 4. 選擇新聞群組
fwrite($conn, "GROUP {$cfg['group']}\r\n");
$resp = fgets($conn, 1024);
echo "群組回應: $resp";
if (!preg_match('/^211\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/', $resp, $m)) {
die("選擇群組失敗\n");
}
list(, $count, $first, $last, $groupName) = $m;
echo "群組: $groupName, 文章數: $count, 號碼範圍: $first ~ $last\n";
// 5. 計算要抓取的文章編號(最新 N 篇)
$start = max($first, $last - $cfg['limit'] + 1);
$articles = [];
for ($num = $last; $num >= $start; $num--) {
// 取得文章標頭(較輕量)
fwrite($conn, "HEAD $num\r\n");
$headResp = fgets($conn, 1024);
if (!preg_match('/^221/', $headResp)) continue;
$headers = '';
while (($line = fgets($conn, 1024)) !== ".\r\n") {
$headers .= $line;
}
// 解析關鍵標頭
$subject = '';
$from = '';
$date = '';
foreach (explode("\n", $headers) as $h) {
if (preg_match('/^Subject:\s*(.*)/i', $h, $mm)) $subject = trim($mm[1]);
elseif (preg_match('/^From:\s*(.*)/i', $h, $mm)) $from = trim($mm[1]);
elseif (preg_match('/^Date:\s*(.*)/i', $h, $mm)) $date = trim($mm[1]);
}
$articles[] = [
'number' => $num,
'subject' => $subject,
'from' => $from,
'date' => $date,
];
}
// 6. 關閉連線
fwrite($conn, "QUIT\r\n");
fgets($conn, 1024);
fclose($conn);
// 7. 輸出結果
echo "\n=== 最新 {$cfg['limit']} 篇文章 ===\n";
foreach ($articles as $a) {
echo "[{$a['number']}] {$a['subject']}\n";
echo " From: {$a['from']}\n";
echo " Date: {$a['date']}\n\n";
}
?>
NNTP 多行回應(如 HEAD、ARTICLE、LIST)以單獨一行 .\r\n 結束。讀取迴圈必須檢測此終止條件,否則會阻塞。
新聞組文章常使用 ISO-8859-1、UTF-8、Big5、GB2312 等編碼。標頭 Content-Type: text/plain; charset=... 指定編碼,需配合 mb_convert_encoding() 統一轉 UTF-8。
XOVER 指令(RFC 2980)一次取得範圍內所有文章概覽(主旨、發信者、日期、大小等),避免逐筆 HEAD 往返延遲。PIPELINING(不等回應連續發送指令)可大幅降低 RTT 影響。$conn = fsockopen("ssl://news.example.com", 563, $errno, $errstr, 10);
// 或
$ctx = stream_context_create(['ssl' => ['verify_peer' => false]]);
$conn = stream_socket_client("ssl://news.example.com:563", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx);
stream_set_timeout() 避免永久阻塞。laminas-mail:整合於郵件/新聞組庫。PHP Socket 程式設計是理解網路協定運作的最佳切入點。透過 NNTP 實作,涵蓋了「連線建立 → 協定握手 → 指令/回應解析 → 多行資料讀取 → 異常處理」的完整客戶端生命週期。掌握此模式,即可輕鬆實作 SMTP 發信、POP3/IMAP 收信、IRC 機器人、HTTP 原始請求、自定義 TCP 協定等各類網路應用。