はじめに
PHPでストリームから1行ずつデータを読み取る際、fgets() を使うのが一般的です。しかし fgets() は改行文字(\n)しかデリミタとして認識できません。
stream_get_line() は、任意の文字列をデリミタとして指定できる行読み取り関数です。CSV のフィールド区切り・カスタムプロトコルのメッセージ境界・HTMLタグの区切りなど、改行以外の区切りが登場するあらゆるストリーム処理に対応できます。
関数の基本情報
| 項目 | 内容 |
|---|---|
| 関数名 | stream_get_line() |
| 対応バージョン | PHP 5.0.0 以降 |
| 返り値 | string(成功時)/ false(EOF またはエラー時) |
| カテゴリ | ストリーム関数 |
構文
stream_get_line(resource $stream, int $length, string $ending = ""): string|false
パラメータ
| パラメータ | 型 | 説明 |
|---|---|---|
$stream | resource | 読み取り対象のストリームリソース |
$length | int | 読み取る最大バイト数(デリミタが来なくてもここで打ち切る) |
$ending | string | デリミタ文字列。ここまで読んで停止する(デリミタ自体は返り値に含まれない) |
返り値
string:デリミタまたは$lengthバイトに達するまでのデータ(デリミタ自体は含まれない)false:EOF に達したか、エラーが発生した場合
fgets() との違い
| 比較項目 | stream_get_line() | fgets() |
|---|---|---|
| デリミタ | 任意の文字列を指定可能 | \n(改行)固定 |
| デリミタの返り値への含有 | 含まれない | \n が含まれる |
| 最大読み取りバイト数 | 必須($length) | 省略可能 |
| 対象 | ストリームリソース | ストリームリソース |
| EOF 判定 | false を返す | false を返す |
<?php
// fgets() は改行を含んで返す
$line = fgets($fp); // "Hello\n"
// stream_get_line() はデリミタを含まずに返す
$line = stream_get_line($fp, 1024, "\n"); // "Hello"
基本的な使い方
<?php
$data = "field1|field2|field3|field4";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $data);
rewind($fp);
// "|" をデリミタとして読み取る
while (($token = stream_get_line($fp, 256, '|')) !== false) {
echo $token . PHP_EOL;
}
fclose($fp);
// 出力:
// field1
// field2
// field3
// field4
実践的なクラスベースの活用例
例1:カスタムデリミタCSVパーサー(DelimitedStreamParser)
パイプ(|)やタブ(\t)など、カンマ以外の区切り文字で構成されたストリームデータをフィールド単位でパースするクラスです。
<?php
class DelimitedStreamParser
{
private $fp;
private string $fieldDelimiter;
private string $recordDelimiter;
private int $maxFieldLength;
public function __construct(
$stream,
string $fieldDelimiter = '|',
string $recordDelimiter = "\n",
int $maxFieldLength = 4096
) {
$this->fp = $stream;
$this->fieldDelimiter = $fieldDelimiter;
$this->recordDelimiter = $recordDelimiter;
$this->maxFieldLength = $maxFieldLength;
}
/**
* 1レコード分のフィールドを配列で返す
* EOF なら null を返す
*
* @return string[]|null
*/
public function readRecord(): ?array
{
// まず1レコード(行)分を読み取る
$line = stream_get_line($this->fp, $this->maxFieldLength * 10, $this->recordDelimiter);
if ($line === false) return null;
// レコードをフィールドデリミタで分割
$fp = fopen('php://memory', 'r+');
fwrite($fp, $line);
rewind($fp);
$fields = [];
while (($field = stream_get_line($fp, $this->maxFieldLength, $this->fieldDelimiter)) !== false) {
$fields[] = $field;
}
fclose($fp);
return $fields;
}
/**
* 全レコードをジェネレータで返す
*
* @return \Generator<int, string[]>
*/
public function records(): \Generator
{
while (($record = $this->readRecord()) !== null) {
yield $record;
}
}
}
// 使用例(パイプ区切りデータ)
$data = "1001|山田太郎|東京都|engineer\n"
. "1002|鈴木花子|大阪府|designer\n"
. "1003|佐藤次郎|福岡県|manager\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $data);
rewind($fp);
$parser = new DelimitedStreamParser($fp, fieldDelimiter: '|', recordDelimiter: "\n");
foreach ($parser->records() as $i => $record) {
echo "ID:{$record[0]} 名前:{$record[1]} 都道府県:{$record[2]} 役職:{$record[3]}" . PHP_EOL;
}
fclose($fp);
// 出力:
// ID:1001 名前:山田太郎 都道府県:東京都 役職:engineer
// ID:1002 名前:鈴木花子 都道府県:大阪府 役職:designer
// ID:1003 名前:佐藤次郎 都道府県:福岡県 役職:manager
例2:カスタムプロトコルメッセージリーダー(ProtocolMessageReader)
END\r\n のような複数文字のデリミタでメッセージ境界が定義された独自プロトコルを、stream_get_line() で解析するクラスです。
<?php
class ProtocolMessageReader
{
private $stream;
private string $delimiter;
private int $maxMessageSize;
public function __construct(
$stream,
string $delimiter = "END\r\n",
int $maxMessageSize = 65536
) {
$this->stream = $stream;
$this->delimiter = $delimiter;
$this->maxMessageSize = $maxMessageSize;
}
/**
* 次のメッセージを読み取る
* ストリーム終端なら null を返す
*/
public function readMessage(): ?string
{
$message = stream_get_line($this->stream, $this->maxMessageSize, $this->delimiter);
return $message !== false ? trim($message) : null;
}
/**
* 全メッセージをジェネレータで返す
*
* @return \Generator<int, string>
*/
public function messages(): \Generator
{
$index = 0;
while (($msg = $this->readMessage()) !== null) {
if ($msg !== '') {
yield $index++ => $msg;
}
}
}
}
class ProtocolMessageDispatcher
{
/** @var array<string, callable> */
private array $handlers = [];
public function on(string $type, callable $handler): void
{
$this->handlers[$type] = $handler;
}
public function dispatch(string $rawMessage): void
{
// メッセージ形式: "TYPE:payload"
[$type, $payload] = array_pad(explode(':', $rawMessage, 2), 2, '');
if (isset($this->handlers[$type])) {
($this->handlers[$type])($payload);
} else {
echo "未知のメッセージタイプ: {$type}" . PHP_EOL;
}
}
}
// 使用例
$rawStream = "PING:hello\nEND\r\n"
. "DATA:{\"user\":\"taro\",\"score\":100}\nEND\r\n"
. "QUIT:\nEND\r\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $rawStream);
rewind($fp);
$reader = new ProtocolMessageReader($fp, delimiter: "END\r\n");
$dispatcher = new ProtocolMessageDispatcher();
$dispatcher->on('PING', fn($p) => echo "PONGを返す: {$p}" . PHP_EOL);
$dispatcher->on('DATA', fn($p) => echo "データ受信: {$p}" . PHP_EOL);
$dispatcher->on('QUIT', fn($p) => echo "接続終了処理" . PHP_EOL);
foreach ($reader->messages() as $msg) {
$dispatcher->dispatch($msg);
}
fclose($fp);
// 出力:
// PONGを返す: hello
// データ受信: {"user":"taro","score":100}
// 接続終了処理
例3:HTMLタグストリームスキャナー(HtmlTagScanner)
HTML ストリームを < と > をデリミタとして読み取り、タグとテキストノードを分離して解析するクラスです。
<?php
class HtmlTagScanner
{
private $stream;
private int $maxChunk;
public function __construct($stream, int $maxChunk = 8192)
{
$this->stream = $stream;
$this->maxChunk = $maxChunk;
}
/**
* トークン(テキストまたはタグ)をジェネレータで返す
*
* @return \Generator<int, array{type: string, content: string}>
*/
public function tokens(): \Generator
{
while (!feof($this->stream)) {
// タグの開始 "<" までのテキストを読む
$text = stream_get_line($this->stream, $this->maxChunk, '<');
if ($text !== false && $text !== '') {
yield ['type' => 'text', 'content' => $text];
}
if (feof($this->stream)) break;
// タグの終了 ">" まで読む("<" は既に消費済み)
$tag = stream_get_line($this->stream, $this->maxChunk, '>');
if ($tag !== false && $tag !== '') {
yield ['type' => 'tag', 'content' => '<' . $tag . '>'];
}
}
}
/**
* テキストノードだけを抽出して結合する
*/
public function extractText(): string
{
$parts = [];
foreach ($this->tokens() as $token) {
if ($token['type'] === 'text') {
$parts[] = $token['content'];
}
}
return implode('', $parts);
}
/**
* 使われているタグ名を集計する
*
* @return array<string, int>
*/
public function countTags(): array
{
$counts = [];
foreach ($this->tokens() as $token) {
if ($token['type'] !== 'tag') continue;
// タグ名だけ取り出す(属性・スラッシュを除外)
if (preg_match('/<\/?(\w+)/', $token['content'], $m)) {
$name = strtolower($m[1]);
$counts[$name] = ($counts[$name] ?? 0) + 1;
}
}
arsort($counts);
return $counts;
}
}
// 使用例
$html = '<html><head><title>PHPストリーム</title></head>'
. '<body><h1>見出し</h1><p>本文の<strong>テキスト</strong>です。</p></body></html>';
$fp = fopen('php://memory', 'r+');
fwrite($fp, $html);
rewind($fp);
$scanner = new HtmlTagScanner($fp);
echo $scanner->extractText() . PHP_EOL;
// → PHPストリーム見出し本文のテキストです。
rewind($fp);
$scanner2 = new HtmlTagScanner($fp);
foreach ($scanner2->countTags() as $tag => $count) {
echo "{$tag}: {$count}個" . PHP_EOL;
}
fclose($fp);
// 出力例:
// html: 2個
// head: 2個
// body: 2個
// ...
例4:ログファイルセクションリーダー(LogSectionReader)
--- のようなセパレータ行で区切られたログセクションを、stream_get_line() で1セクションずつ読み取るクラスです。
<?php
class LogSectionReader
{
private $fp;
private string $sectionDelimiter;
private int $maxSectionSize;
public function __construct(
$stream,
string $sectionDelimiter = "\n---\n",
int $maxSectionSize = 1_048_576
) {
$this->fp = $stream;
$this->sectionDelimiter = $sectionDelimiter;
$this->maxSectionSize = $maxSectionSize;
}
/**
* 次のセクションを読み取る
*/
public function readSection(): ?string
{
$section = stream_get_line($this->fp, $this->maxSectionSize, $this->sectionDelimiter);
return $section !== false ? $section : null;
}
/**
* 全セクションをジェネレータで返す
*
* @return \Generator<int, array{index: int, lines: string[], lineCount: int}>
*/
public function sections(): \Generator
{
$index = 0;
while (($raw = $this->readSection()) !== null) {
$lines = array_filter(explode("\n", trim($raw)), fn($l) => $l !== '');
if (empty($lines)) continue;
yield [
'index' => $index++,
'lines' => array_values($lines),
'lineCount' => count($lines),
];
}
}
}
class LogAnalyzer
{
private LogSectionReader $reader;
public function __construct(LogSectionReader $reader)
{
$this->reader = $reader;
}
/**
* ERROR を含むセクションのみ抽出する
*/
public function filterErrors(): array
{
$errorSections = [];
foreach ($this->reader->sections() as $section) {
$hasError = array_any(
$section['lines'],
fn($line) => str_contains($line, 'ERROR')
);
if ($hasError) {
$errorSections[] = $section;
}
}
return $errorSections;
}
}
// array_any は PHP 8.5 以降。それ以前は以下で代替:
if (!function_exists('array_any')) {
function array_any(array $array, callable $callback): bool {
foreach ($array as $item) {
if ($callback($item)) return true;
}
return false;
}
}
// 使用例
$log = "[INFO] 起動完了\n[INFO] DB接続成功\n"
. "---\n"
. "[ERROR] 認証失敗 user=guest\n[INFO] リトライ中\n"
. "---\n"
. "[INFO] 処理完了\n[INFO] シャットダウン\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $log);
rewind($fp);
$reader = new LogSectionReader($fp, sectionDelimiter: "\n---\n");
$analyzer = new LogAnalyzer($reader);
foreach ($analyzer->filterErrors() as $section) {
echo "=== セクション {$section['index']} ({$section['lineCount']}行) ===" . PHP_EOL;
foreach ($section['lines'] as $line) {
echo " {$line}" . PHP_EOL;
}
}
fclose($fp);
// 出力:
// === セクション 1 (2行) ===
// [ERROR] 認証失敗 user=guest
// [INFO] リトライ中
例5:SQLクエリストリームスプリッター(SqlStreamSplitter)
複数のSQLステートメントが連続するストリームを ; をデリミタとして1クエリずつ取り出し、個別に実行できる形に分割するクラスです。
<?php
class SqlStreamSplitter
{
private $stream;
private int $maxQuerySize;
public function __construct($stream, int $maxQuerySize = 65536)
{
$this->stream = $stream;
$this->maxQuerySize = $maxQuerySize;
}
/**
* SQL文をジェネレータで1つずつ返す
*
* @return \Generator<int, string>
*/
public function queries(): \Generator
{
while (($raw = stream_get_line($this->stream, $this->maxQuerySize, ';')) !== false) {
$query = trim($raw);
// コメント行や空クエリをスキップ
if ($query === '' || str_starts_with($query, '--')) continue;
yield $query . ';';
}
}
}
class SqlStreamExecutor
{
private SqlStreamSplitter $splitter;
private array $log = [];
public function __construct(SqlStreamSplitter $splitter)
{
$this->splitter = $splitter;
}
/**
* クエリを1つずつドライランして結果を返す(実際の実行は各自のDB接続に差し替え)
*/
public function dryRun(): array
{
$results = [];
foreach ($this->splitter->queries() as $i => $query) {
$type = $this->detectType($query);
$results[] = [
'index' => $i,
'type' => $type,
'query' => $query,
'length' => strlen($query),
];
}
return $results;
}
private function detectType(string $query): string
{
$first = strtoupper(strtok(trim($query), " \t\n"));
return match($first) {
'SELECT' => 'SELECT',
'INSERT' => 'INSERT',
'UPDATE' => 'UPDATE',
'DELETE' => 'DELETE',
'CREATE' => 'CREATE',
'DROP' => 'DROP',
'ALTER' => 'ALTER',
default => 'OTHER',
};
}
}
// 使用例
$sql = "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100));\n"
. "INSERT INTO users VALUES (1, '山田太郎');\n"
. "INSERT INTO users VALUES (2, '鈴木花子');\n"
. "-- コメント行\n"
. "SELECT * FROM users WHERE id = 1;\n"
. "UPDATE users SET name = '田中次郎' WHERE id = 2;\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $sql);
rewind($fp);
$splitter = new SqlStreamSplitter($fp);
$executor = new SqlStreamExecutor($splitter);
foreach ($executor->dryRun() as $result) {
echo "[{$result['type']}] ({$result['length']}バイト) {$result['query']}" . PHP_EOL;
}
fclose($fp);
// 出力:
// [CREATE] (52バイト) CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100));
// [INSERT] (38バイト) INSERT INTO users VALUES (1, '山田太郎');
// [INSERT] (38バイト) INSERT INTO users VALUES (2, '鈴木花子');
// [SELECT] (36バイト) SELECT * FROM users WHERE id = 1;
// [UPDATE] (46バイト) UPDATE users SET name = '田中次郎' WHERE id = 2;
例6:バイナリフレームリーダー(BinaryFrameReader)
CRLF(\r\n)など複数バイトのデリミタで区切られたバイナリフレームを読み取るクラスです。SMTP・POP3・FTP などのテキストベースプロトコルの解析に応用できます。
<?php
class BinaryFrameReader
{
private $stream;
private string $frameDelimiter;
private int $maxFrameSize;
private int $framesRead = 0;
public function __construct(
$stream,
string $frameDelimiter = "\r\n",
int $maxFrameSize = 8192
) {
$this->stream = $stream;
$this->frameDelimiter = $frameDelimiter;
$this->maxFrameSize = $maxFrameSize;
}
/**
* 次のフレームを読み取る
*
* @return array{index: int, data: string, size: int}|null
*/
public function readFrame(): ?array
{
$data = stream_get_line($this->stream, $this->maxFrameSize, $this->frameDelimiter);
if ($data === false) return null;
return [
'index' => $this->framesRead++,
'data' => $data,
'size' => strlen($data),
];
}
/**
* 全フレームをジェネレータで返す
*
* @return \Generator<int, array{index: int, data: string, size: int}>
*/
public function frames(): \Generator
{
while (($frame = $this->readFrame()) !== null) {
yield $frame;
}
}
public function framesRead(): int { return $this->framesRead; }
}
class SmtpResponseParser
{
/**
* SMTPレスポンスストリームを解析して応答コードとメッセージに分解する
*/
public function parse($stream): array
{
$reader = new BinaryFrameReader($stream, frameDelimiter: "\r\n");
$responses = [];
foreach ($reader->frames() as $frame) {
$line = $frame['data'];
if (preg_match('/^(\d{3})([ -])(.*)$/', $line, $m)) {
$responses[] = [
'code' => (int)$m[1],
'last' => $m[2] === ' ', // "-" は続きあり、" " は最終行
'message' => $m[3],
];
}
}
return $responses;
}
}
// 使用例(SMTPサーバー応答をシミュレート)
$smtpResponse = "220 smtp.example.com ESMTP ready\r\n"
. "250-smtp.example.com Hello\r\n"
. "250-SIZE 10240000\r\n"
. "250-STARTTLS\r\n"
. "250 HELP\r\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $smtpResponse);
rewind($fp);
$parser = new SmtpResponseParser();
$responses = $parser->parse($fp);
foreach ($responses as $r) {
$last = $r['last'] ? '最終' : '続く';
echo "[{$r['code']}][{$last}] {$r['message']}" . PHP_EOL;
}
fclose($fp);
// 出力:
// [220][最終] smtp.example.com ESMTP ready
// [250][続く] smtp.example.com Hello
// [250][続く] SIZE 10240000
// [250][続く] STARTTLS
// [250][最終] HELP
例7:マルチデリミタトークナイザー(MultiDelimiterTokenizer)
複数のデリミタを優先順位付きで試行しながらストリームをトークンに分解するクラスです。Markdown・設定ファイル・DSLなど複合的な区切りを持つフォーマットの字句解析に使えます。
<?php
class Token
{
public function __construct(
public readonly string $type,
public readonly string $value,
public readonly int $position,
) {}
public function __toString(): string
{
return "[{$this->type}] '{$this->value}' @{$this->position}";
}
}
class MultiDelimiterTokenizer
{
private $stream;
private int $position = 0;
private int $maxTokenSize;
/** @var array{delimiter: string, type: string}[] 優先順 */
private array $rules;
public function __construct($stream, array $rules, int $maxTokenSize = 4096)
{
$this->stream = $stream;
$this->rules = $rules;
$this->maxTokenSize = $maxTokenSize;
}
/**
* 全トークンをジェネレータで返す
*
* @return \Generator<int, Token>
*/
public function tokenize(): \Generator
{
// ストリーム全体を取得してメモリストリームで再処理
$content = stream_get_contents($this->stream, offset: 0);
$fp = fopen('php://memory', 'r+');
fwrite($fp, $content);
rewind($fp);
while (!feof($fp)) {
$found = false;
foreach ($this->rules as $rule) {
$startPos = ftell($fp);
$token = stream_get_line($fp, $this->maxTokenSize, $rule['delimiter']);
if ($token === false) break 2;
if ($token !== '') {
yield new Token($rule['type'], $token, $this->position);
$this->position += strlen($token);
}
$this->position += strlen($rule['delimiter']);
$found = true;
break;
}
if (!$found) break;
}
fclose($fp);
}
}
// 使用例:INI風の設定ファイルをトークナイズ
$config = "[database]\nhost=localhost\nport=3306\n[cache]\ndriver=redis\nttl=3600\n";
$fp = fopen('php://memory', 'r+');
fwrite($fp, $config);
rewind($fp);
// セクションヘッダー(\n区切り)→ キーバリューペア(= 区切りと \n 区切り)
$rules = [
['delimiter' => "\n", 'type' => 'LINE'],
];
$tokenizer = new MultiDelimiterTokenizer($fp, $rules);
foreach ($tokenizer->tokenize() as $token) {
if (str_starts_with($token->value, '[')) {
echo "SECTION: " . trim($token->value, '[]') . PHP_EOL;
} elseif (str_contains($token->value, '=')) {
[$key, $val] = explode('=', $token->value, 2);
echo " KEY={$key} VALUE={$val}" . PHP_EOL;
}
}
fclose($fp);
// 出力:
// SECTION: database
// KEY=host VALUE=localhost
// KEY=port VALUE=3306
// SECTION: cache
// KEY=driver VALUE=redis
// KEY=ttl VALUE=3600
関連する関数との比較
| 関数 | デリミタ | デリミタを返り値に含む | 最大長 |
|---|---|---|---|
stream_get_line() | 任意の文字列 | 含まない | 必須 |
fgets() | \n 固定 | 含む(\n を返す) | 省略可能 |
fgetcsv() | ,(変更可) | 含まない | 省略可能 |
stream_get_contents() | なし(全量取得) | — | 省略可能 |
fread() | なし(バイト数指定) | — | 必須 |
注意点とベストプラクティス
1. デリミタは返り値に含まれない
fgets() が \n を返り値に含むのとは異なり、stream_get_line() はデリミタを含みません。rtrim() による後処理が不要な点がメリットです。
// fgets() → 末尾の \n を除去する必要がある
$line = rtrim(fgets($fp), "\n");
// stream_get_line() → デリミタを含まないので後処理不要
$line = stream_get_line($fp, 1024, "\n");
2. $length は必須・余裕を持たせる
$length に達した場合、デリミタが来ていなくても読み取りが打ち切られます。フィールド・行の最大長より十分大きい値を設定しましょう。
// タイトな設定はデータが切れるリスクがある
$token = stream_get_line($fp, 10, '|'); // 10バイトを超えるフィールドは切れる
// 余裕のある設定
$token = stream_get_line($fp, 4096, '|');
3. EOF の正しい検出
stream_get_line() は EOF で false を返します。feof() との併用よりも、戻り値の false 判定でループ制御するのが確実です。
// 推奨
while (($token = stream_get_line($fp, 4096, '|')) !== false) {
// 処理
}
// feof() 併用でも可(ただし最後のデータが空の場合に注意)
while (!feof($fp)) {
$token = stream_get_line($fp, 4096, '|');
if ($token === false) break;
}
4. マルチバイト文字の注意
$length はバイト数であり文字数ではありません。日本語などマルチバイト文字が含まれる場合、バイト数で切断すると文字化けが起きる可能性があります。デリミタがバイト境界に来る設計のフォーマットに使用しましょう。
まとめ
| ポイント | 内容 |
|---|---|
| 基本動作 | 指定したデリミタが現れるまでストリームを読み取り、デリミタを除いた文字列を返す |
| デリミタ | 任意の文字列(複数バイトも可) |
$length | 最大読み取りバイト数(必須・デリミタが来なくても打ち切る) |
| デリミタの含有 | 返り値に含まれない(fgets() との大きな違い) |
| EOF | false を返す(空文字列と区別するため !== false で判定) |
| 活用シーン | カスタム区切り文字の解析・独自プロトコル・HTMLタグ分割・SQLスプリッター・バイナリフレーム読み取りなど |
stream_get_line() は fgets() の「改行専用」という制限を取り払い、任意のデリミタでストリームをトークン化できる強力な関数です。独自フォーマットやプロトコルの解析において、シンプルかつ効率的な読み取りループを実現します。
