Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

PHP 8 で作る JSON パーサ / php8-json-parser

shin1x1
December 11, 2020

PHP 8 で作る JSON パーサ / php8-json-parser

2020/12/12 phpcon2020
sample code: https://github.com/shin1x1/php8-toy-json-parser

shin1x1

December 11, 2020
Tweet

More Decks by shin1x1

Other Decks in Programming

Transcript

  1. 対象 JSON 対応する JSON は RFC8259 をベースに⼀部簡略化 https://tools.ietf.org/html/rfc8259 主に簡略化した箇所 数字は

    0 以上の整数表現のみ(負の数、浮動⼩数点、指数表記は省く) ⽂字列は \uNNNN によるコードポイント表現は省略 8
  2. 本発表で作る JSON パーサ JSON ⽂字列を⼊⼒して、PHP の値を出⼒する json_decode($json, associative: true) 相当

    ⽂字列: {"key1": 100} -> JsonParser -> PHP: ['key1' => 100] 2 つのコンポーネントで構成 字句解析器: Lexer クラス 構⽂解析器: Parser クラス 9
  3. 10

  4. 字句解析例 [1, "abc"] を以下に分解する [ : LeftSquareBarcketToken 1 : NumberToken(1)

    , : CommaToken abc : StringToken('abc') ] : RightSquareBracketToken 13
  5. JSON を構成するトークン 構造化トークン [ ] { } , : 数値トークン

    ⽂字列トークン リテラルトークン true false null 終端トークン 各トークンを型で表現したいので、クラスで実装 14
  6. 構造化トークン { を⽰すトークン final class LeftCurlyBracketToken implements Token { }

    } = RightCurlyBracketToken [ = LeftSquareBracketToken ] = RightSquareBracketToken : = ColonToken , = CommaToken 16
  7. 数値トークン 数値トークンを⽰し、値を保持するトークン final class NumberToken implements Token { // PHP

    8: constructor property promotion // PHP 8: Attribute public function __construct(#[Immutable] private int $value) { } public function getValue(): int { return $this->value; } } 17
  8. ⽂字列トークン ⽂字列トークンを⽰し、値を保持するトークン final class StringToken implements Token { public function

    __construct(#[Immutable] private string $value) { } public function getValue(): string { return $this->value; } } 18
  9. Lexer クラス: コンストラクタ ⽂字列を⼊⼒として、Token インスタンスを⽣成する ⽂字列をコンストラクタ引数で受け取る 現在位置と⽂字列⻑をコンストラクタでセット final class Lexer

    { private int $length; private int $position; public function __construct(private string $json) { $this->length = mb_strlen($this->json); $this->position = 0; } } 21
  10. Lexer クラス: consume() 現在位置の⽂字を 1 ⽂字取得 位置を 1 つ進める ⽂字列⻑に達したら

    null を返す private function consume(): ?string { if ($this->length <= $this->position) { return null; } $ch = $this->current(); $this->position++; return $ch; } 23
  11. Lexer クラス: getNextToken() トークンを取得するメインメソッド consume() で⽂字を取得し、それに応じてトークンを返す スペース、改⾏、タブ等なら do / while

    ループを周り続ける public function getNextToken(): Token { do { $ch = $this->consume(); if ($ch === null) { return new EofToken(); // null は終端なので EofToken } } while ($this->isSkipCharacter($ch)); return match ($ch) { // ⽂字ごとにトークンを⽣成 // (snip) } 24
  12. Lexer クラス: 構造化トークン それぞれの⽂字に応じてトークンを⽣成 想定外の⽂字が来たら例外をスローして処理を落としておく これやっておくと挙動がおかしい時でもすぐ気付ける // PHP 8: match

    expression return match ($ch) { '[' => new LeftSquareBracketToken(), ']' => new RightSquareBracketToken(), '{' => new LeftCurlyBracketToken(), '}' => new RightCurlyBracketToken(), ':' => new ColonToken(), ',' => new CommaToken(), default => throw new LexerException('Invalid character ' . $ch), }; } 25
  13. Lexer クラス: ⽂字列トークン " から始まって、次の " までを⽂字列トークンとする " が来たら⽂字列とみなして、⽂字列トークン⽣成メソッドを呼ぶ public

    function getNextToken(): Token { // (snip) return match ($ch) { // (snip) '"' => $this->getStringToken(), default => throw new LexerException('Invalid character ' . $ch), }; } 26
  14. $str に⽂字列の値を追加していく private function getStringToken(): StringToken { $str = '';

    while (true) { $ch = $this->consume(); if ($ch === null) { break; } if ($ch === '"') { return new StringToken($str); } if ($ch !== '\\') { $str .= $ch; continue; } 27
  15. \ でエスケープした⽂字に対応した値を $str に追加 $str .= match ($ch = $this->consume())

    { '"' => '"', '\\' => '\\', '/' => '/', 'b' => chr(0x8), 'f' => "\f", 'n' => "\n", 'r' => "\r", 't' => "\t", default => '\\' . $ch, }; } throw new LexerException('No end of string'); } 28
  16. Lexer クラス: 数値トークン 0 - 9 からはじまる連続した数字を数値トークンとする 0 - 9

    が来たら数値トークン⽣成メソッドを呼ぶ public function getNextToken(): Token { // (snip) return match ($ch) { // (snip) '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => $this->getNumberToken($ch), default => throw new LexerException('Invalid character ' . $ch), }; } 29
  17. current() で読んで、数値なら comsume() で位置を進める 数値以外なら getNextToken() に戻った時に再度読ませる private function getNumberToken(string

    $ch): NumberToken { $number = $ch; while (true) { $ch = $this->current(); if ('0' <= $ch && $ch <= '9') { $number .= $ch; $this->consume(); continue; } break; } return new NumberToken((int)$number); } 30
  18. Lexer クラス: リテラルトークン t f n から始まるならリテラルトークン取得メソッドを呼ぶ public function getNextToken():

    Token { // (snip) return match ($ch) { // (snip) 't' => $this->getLiteralToken('true', TrueToken::class), 'f' => $this->getLiteralToken('false', FalseToken::class), 'n' => $this->getLiteralToken('null', NullToken::class), default => throw new LexerException('Invalid character ' . $ch), }; } 31
  19. 引数で期待する⽂字列とトークンクラス名を指定 ⽂字列が期待したものと⼀致すればトークンを返す private function getLiteralToken(string $expectedName, string $klass): TrueToken|FalseToken|NullToken {

    $name = $expectedName[0]; for ($i = 1; $i < strlen($expectedName); $i++) { $ch = $this->consume(); if ($ch === null) { throw new LexerException('Unexpected end of text'); } $name .= $ch; } if ($name !== $expectedName) { throw new LexerException('Unexpected literal ' . $name); } return new $klass; } 32
  20. 35

  21. parse メソッドがメインメソッド ValueParser が解析して PHP の値 を決定 値が決定した後に EofToken 以外があれば例外をスロー

    public function parse(): array|string|int|float|bool|null { $token = $this->lexer->getNextToken(); $ret = ValueParser::parse($this->lexer, $token); if ($this->lexer->getNextToken() instanceof EofToken) { return $ret; } throw new ParserException(message: 'Unparsed tokens detected'); } } 37
  22. ValueParser クラス トークンを解析して PHP の値を返す まずはリテラルトークンから それぞれのリテラルから対応する PHP の値を返す final

    class ValueParser { public static function parse(Lexer $lexer, Token $token): array|string|int|bool|null { return match (true) { $token instanceof TrueToken => true, $token instanceof FalseToken => false, $token instanceof NullToken => null, default => throw new ParserException(token: $token), }; } } 38
  23. ValueParser クラス: ⽂字列、数値 ⽂字列トークン、数値トークンはインスタンスが値を持つのでそれを返す return match (true) { // (snip)

    $token instanceof StringToken => $token->getValue(), $token instanceof NumberToken => $token->getValue(), default => throw new ParserException(token: $token), }; 39
  24. ValueParser クラス: array LeftSquareBracketToken なら Array とみなして配列を⽣成 配列の⽣成は ArrayParser クラスにて⾏う

    public static function parse(Lexer $lexer, Token $token): array|string|int|bool|null { return match (true) { // (snip) $token instanceof LeftSquareBracketToken => ArrayParser::parse($lexer), default => throw new ParserException(token: $token), }; } 40
  25. Lexer から読み取ったトークンを状態に応じて処理 final class ArrayParser { // (snip) public static

    function parse(Lexer $lexer): array { $array = []; $state = self::STATE_START; while (true) { $token = $lexer->getNextToken(); if ($token instanceof EofToken) { break; } switch ($state) { // 状態によって処理を分岐 // snip } } throw new ParserException(message: 'No end of array'); } 42
  26. 状態: START ] なら、終了なので現在の配列を返す(空配列) それ以外は、要素値として ValueParser で値を取得して、配列に追加 , や :

    といった invalid なトークンは ValueParser で例外をスロー 状態を VALUE に変更 case self::STATE_START: if ($token instanceof RightSquareBracketToken) { return $array; } $array[] = ValueParser::parse($lexer, $token); $state = self::STATE_VALUE; break; 43
  27. 状態: VALUE ] なら、終了なので現在の配列を返す , なら、要素区切りなので、状態を COMMA に変更して break それ以外は、例外をスロー

    case self::STATE_VALUE: if ($token instanceof RightSquareBracketToken) { return $array; } if ($token instanceof CommaToken) { $state = self::STATE_COMMA; break; } throw new ParserException(token: $token); 44
  28. ValueParser クラス: Object LeftCurlyBracketToken なら Object とみなして連想配列を⽣成 連想配列の⽣成は ObjectParser クラスにて⾏う

    public static function parse(Lexer $lexer, Token $token): array|string|int|bool|null { return match (true) { // (snip) $token instanceof LeftCurlyBracketToken => ObjectParser::parse($lexer), default => throw new ParserException(token: $token), }; } 46
  29. Lexer から読み取ったトークンを状態に応じて処理 final class ObjectParser { // (snip) public static

    function parse(Lexer $lexer): array { $array = []; $key = ''; $state = self::STATE_START; while (true) { $token = $lexer->getNextToken(); if ($token instanceof EofToken) { break; } switch ($state) { // 状態によって処理を分岐 // (snip) } } throw new ParserException(message: 'No end of object'); } 48
  30. 状態: START(開始状態) } : 終了なので現在の連想配列を返す(空配列) ⽂字列: キーとしてトークン値を保持。状態を KEY にする それ以外なら例外をスロー

    case self::STATE_START: if ($token instanceof RightCurlyBracketToken) { return $array; } if ($token instanceof StringToken) { $key = $token->getValue(); $state = self::STATE_KEY; break; } throw new ParserException(token: $token); 49
  31. 状態: KEY : : キーと要素の区切り。状態を COLON にする それ以外なら例外をスロー case self::STATE_KEY:

    if ($token instanceof ColonToken) { $state = self::STATE_COLON; break; } throw new ParserException(token: $token); 50
  32. 状態: VALUE } : 終了なので現在の連想配列を返す , : 要素の区切り。状態を COMMA にする

    それ以外は例外をスロー case self::STATE_VALUE: if ($token instanceof RightCurlyBracketToken) { return $array; } if ($token instanceof CommaToken) { $state = self::STATE_COMMA; break; } throw new ParserException(token: $token); 52
  33. 状態: COMMA ⽂字列: キーとしてトークン値を保持。状態を KEY にする それ以外は例外をスロー case self::STATE_COMMA: if

    ($token instanceof StringToken) { $key = $token->getValue(); $state = self::STATE_KEY; break; } throw new ParserException(token: $token); 53
  34. JsonParser クラス Lexer と Parser を組み合わせたメインクラス final class JsonParser {

    public function parse(string $json): array|string|int|bool|null { $lexer = new Lexer\Lexer($json); $parser = new Parser\Parser($lexer); return $parser->parse(); } } 54
  35. テスト: JsonParserTest クラス JsonParser クラスの結果と json_decode() の結果が⼀致するか /** * @test

    * @dataProvider dataProvider */ public function parse(string $json) { $sut = new JsonParser(); $expected = json_decode($json, associative: true); $this->assertSame($expected, $sut->parse($json)); } 55
  36. public function dataProvider(): array { return [ 'test' => [

    '{ "key1": 123, "key2": " わ\" お", "true":true, "array":[123,2], "o": { "a":1, "b":[true,false,null] } }', ], ]; } 56
  37. ベンチマーク⽐較 10000 ループで JSON パース 実⾏時間 ⽐ PHP 8: JIT

    Compiler off 7.62s 1 PHP 8: JIT Compiler on 7.11s 0.93 json_decode() 0.034s 0.0045 圧倒的に json_decode() が速い!素直に json_decode() 使おう JIT Compiler があまり効果が出なかった 58