Save 37% off PRO during our Black Friday Sale! »

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

Ca17a082a30f4cbfed1d0a6dacbe3af2?s=47 shin1x1
PRO
December 11, 2020

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

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

Ca17a082a30f4cbfed1d0a6dacbe3af2?s=128

shin1x1
PRO

December 11, 2020
Tweet

Transcript

  1. PHP 8 で作る JSON パーサ 2020/12/12 phpcon 2020 @shin1x1

  2. @shin1x1 新原(しんばら) 雅司 1×1株式会社 Web アプリケーション開発 技術サポート PHP の現場 https://php-genba.shin1x1.com/

  3. 最近執筆したもの https://eh-career.com/engineerhub/entry/2020/10/20/103000

  4. この発表の対象 パーサを書いたことが無いけど興味がある⽅ JSON を知っている⽅ PHP 8 を使ったコードの実例を⾒たい⽅ 4

  5. Agenga JSON パーサ 字句解析器: Lexer の実装 構⽂解析器: Parser の実装 JsonParser

    vs json_decode() 5
  6. https://github.com/shin1x1/php8-toy-json-parser 6

  7. JSON パーサ 7

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

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

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

  11. 字句解析器: Lexer の実装 11

  12. 字句解析 ⽂字列をトークンに分解する トークン: JSON を構成する最⼩単位の部品 トークン以外の要素は落とす JSON ならスペースや改⾏、タブなどは読み⾶ばす あくまでトークンにするだけなので、順序(意味)は問わない ][1

    という⽂字列も Lexer では OK(後続の Parser でエラーとなる) 12
  13. 字句解析例 [1, "abc"] を以下に分解する [ : LeftSquareBarcketToken 1 : NumberToken(1)

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

    ⽂字列トークン リテラルトークン true false null 終端トークン 各トークンを型で表現したいので、クラスで実装 14
  15. Token インターフェイス トークンを⽰すマーカーインタフェース 各トークンで実装 interface Token { } 15

  16. 構造化トークン { を⽰すトークン final class LeftCurlyBracketToken implements Token { }

    } = RightCurlyBracketToken [ = LeftSquareBracketToken ] = RightSquareBracketToken : = ColonToken , = CommaToken 16
  17. 数値トークン 数値トークンを⽰し、値を保持するトークン 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
  18. ⽂字列トークン ⽂字列トークンを⽰し、値を保持するトークン final class StringToken implements Token { public function

    __construct(#[Immutable] private string $value) { } public function getValue(): string { return $this->value; } } 18
  19. リテラルトークン true を⽰すトークン final class TrueToken implements Token { }

    false = FalseToken null = NullToken 19
  20. 終端トークン 終端を⽰すトークン Parser ではこれが出現したら処理を終了 final class EofToken implements Token {

    } 20
  21. 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
  22. Lexer クラス: current() 現在位置の⽂字を 1 ⽂字取得 private function current(): string

    { return mb_substr($this->json, $this->position, 1); } 22
  23. Lexer クラス: consume() 現在位置の⽂字を 1 ⽂字取得 位置を 1 つ進める ⽂字列⻑に達したら

    null を返す private function consume(): ?string { if ($this->length <= $this->position) { return null; } $ch = $this->current(); $this->position++; return $ch; } 23
  24. 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
  25. 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
  26. Lexer クラス: ⽂字列トークン " から始まって、次の " までを⽂字列トークンとする " が来たら⽂字列とみなして、⽂字列トークン⽣成メソッドを呼ぶ public

    function getNextToken(): Token { // (snip) return match ($ch) { // (snip) '"' => $this->getStringToken(), default => throw new LexerException('Invalid character ' . $ch), }; } 26
  27. $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
  28. \ でエスケープした⽂字に対応した値を $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
  29. 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
  30. 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
  31. 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
  32. 引数で期待する⽂字列とトークンクラス名を指定 ⽂字列が期待したものと⼀致すればトークンを返す 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
  33. 構⽂解析器:Parser クラスの実装 33

  34. 構⽂解析 トークンから PHP の値を⽣成する AST(抽象構⽂⽊)を⽣成する⽅法もあるが、今回は PHP の値にする JSON としての意味を解釈して処理する ex.

    [ の次には、値か ] が来るはず ex. { の次には、⽂字列(キー)か } が来るはず 34
  35. 35

  36. Parer クラス コンストラクタで Lexer インスタンスを受け取る Lexer インスタンスの getNextToken() を読んでトークンを取得して処理 final

    class Parser { public function __construct(private Lexer $lexer) { } 36
  37. 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
  38. 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
  39. ValueParser クラス: ⽂字列、数値 ⽂字列トークン、数値トークンはインスタンスが値を持つのでそれを返す return match (true) { // (snip)

    $token instanceof StringToken => $token->getValue(), $token instanceof NumberToken => $token->getValue(), default => throw new ParserException(token: $token), }; 39
  40. 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
  41. ArrayParser クラス トークンの並びで意味を解析していく FSM(Finite State Machine: 有限状態機械) で実装 読み取ったトークンによって状態を変化 状態に応じて処理を分岐

    41
  42. 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
  43. 状態: 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
  44. 状態: 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
  45. 状態: COMMA 要素値として ValueParser で値を取得して、配列に追加 状態を VALUE に変更 case self::STATE_COMMA:

    $array[] = ValueParser::parse($lexer, $token); $state = self::STATE_VALUE; break; 45
  46. 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
  47. ObjectParser クラス Array と同様に FSM でトークンの並びを解析していく 47

  48. 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
  49. 状態: 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
  50. 状態: KEY : : キーと要素の区切り。状態を COLON にする それ以外なら例外をスロー case self::STATE_KEY:

    if ($token instanceof ColonToken) { $state = self::STATE_COLON; break; } throw new ParserException(token: $token); 50
  51. 状態: COLON 要素値として ValueParser で値を⽣成して、キーとペアで連想配列に格納 状態を VALUE にする case self::STATE_COLON:

    $array[$key] = ValueParser::parse($lexer, $token); $state = self::STATE_VALUE; break; 51
  52. 状態: 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
  53. 状態: COMMA ⽂字列: キーとしてトークン値を保持。状態を KEY にする それ以外は例外をスロー case self::STATE_COMMA: if

    ($token instanceof StringToken) { $key = $token->getValue(); $state = self::STATE_KEY; break; } throw new ParserException(token: $token); 53
  54. 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
  55. テスト: 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
  56. public function dataProvider(): array { return [ 'test' => [

    '{ "key1": 123, "key2": " わ\" お", "true":true, "array":[123,2], "o": { "a":1, "b":[true,false,null] } }', ], ]; } 56
  57. JsonParser vs json_decode() JIT Compiler あるし、もしかすると。。。 57

  58. ベンチマーク⽐較 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
  59. まとめ JSON パーサは⾔語の機能だけでシンプルに作れる プログラミング⾔語を学ぶ題材にちょうど良い コードの書き納め、書き初めに JSON パーサを書いてみよう! 59