[PHP]stream_get_line完全ガイド|任意のデリミタで1行読み取りを自在にコントロールするストリーム関数

PHP

はじめに

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

パラメータ

パラメータ説明
$streamresource読み取り対象のストリームリソース
$lengthint読み取る最大バイト数(デリミタが来なくてもここで打ち切る)
$endingstringデリミタ文字列。ここまで読んで停止する(デリミタ自体は返り値に含まれない)

返り値

  • 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() との大きな違い)
EOFfalse を返す(空文字列と区別するため !== false で判定)
活用シーンカスタム区切り文字の解析・独自プロトコル・HTMLタグ分割・SQLスプリッター・バイナリフレーム読み取りなど

stream_get_line()fgets() の「改行専用」という制限を取り払い、任意のデリミタでストリームをトークン化できる強力な関数です。独自フォーマットやプロトコルの解析において、シンプルかつ効率的な読み取りループを実現します。

タイトルとURLをコピーしました