Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

https://github.com/shin1x1/php8-toy-json-parser 6

Slide 7

Slide 7 text

JSON パーサ 7

Slide 8

Slide 8 text

対象 JSON 対応する JSON は RFC8259 をベースに⼀部簡略化 https://tools.ietf.org/html/rfc8259 主に簡略化した箇所 数字は 0 以上の整数表現のみ(負の数、浮動⼩数点、指数表記は省く) ⽂字列は \uNNNN によるコードポイント表現は省略 8

Slide 9

Slide 9 text

本発表で作る JSON パーサ JSON ⽂字列を⼊⼒して、PHP の値を出⼒する json_decode($json, associative: true) 相当 ⽂字列: {"key1": 100} -> JsonParser -> PHP: ['key1' => 100] 2 つのコンポーネントで構成 字句解析器: Lexer クラス 構⽂解析器: Parser クラス 9

Slide 10

Slide 10 text

10

Slide 11

Slide 11 text

字句解析器: Lexer の実装 11

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

字句解析例 [1, "abc"] を以下に分解する [ : LeftSquareBarcketToken 1 : NumberToken(1) , : CommaToken abc : StringToken('abc') ] : RightSquareBracketToken 13

Slide 14

Slide 14 text

JSON を構成するトークン 構造化トークン [ ] { } , : 数値トークン ⽂字列トークン リテラルトークン true false null 終端トークン 各トークンを型で表現したいので、クラスで実装 14

Slide 15

Slide 15 text

Token インターフェイス トークンを⽰すマーカーインタフェース 各トークンで実装 interface Token { } 15

Slide 16

Slide 16 text

構造化トークン { を⽰すトークン final class LeftCurlyBracketToken implements Token { } } = RightCurlyBracketToken [ = LeftSquareBracketToken ] = RightSquareBracketToken : = ColonToken , = CommaToken 16

Slide 17

Slide 17 text

数値トークン 数値トークンを⽰し、値を保持するトークン 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

Slide 18

Slide 18 text

⽂字列トークン ⽂字列トークンを⽰し、値を保持するトークン final class StringToken implements Token { public function __construct(#[Immutable] private string $value) { } public function getValue(): string { return $this->value; } } 18

Slide 19

Slide 19 text

リテラルトークン true を⽰すトークン final class TrueToken implements Token { } false = FalseToken null = NullToken 19

Slide 20

Slide 20 text

終端トークン 終端を⽰すトークン Parser ではこれが出現したら処理を終了 final class EofToken implements Token { } 20

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

Lexer クラス: current() 現在位置の⽂字を 1 ⽂字取得 private function current(): string { return mb_substr($this->json, $this->position, 1); } 22

Slide 23

Slide 23 text

Lexer クラス: consume() 現在位置の⽂字を 1 ⽂字取得 位置を 1 つ進める ⽂字列⻑に達したら null を返す private function consume(): ?string { if ($this->length <= $this->position) { return null; } $ch = $this->current(); $this->position++; return $ch; } 23

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Lexer クラス: ⽂字列トークン " から始まって、次の " までを⽂字列トークンとする " が来たら⽂字列とみなして、⽂字列トークン⽣成メソッドを呼ぶ public function getNextToken(): Token { // (snip) return match ($ch) { // (snip) '"' => $this->getStringToken(), default => throw new LexerException('Invalid character ' . $ch), }; } 26

Slide 27

Slide 27 text

$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

Slide 28

Slide 28 text

\ でエスケープした⽂字に対応した値を $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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

引数で期待する⽂字列とトークンクラス名を指定 ⽂字列が期待したものと⼀致すればトークンを返す 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

Slide 33

Slide 33 text

構⽂解析器:Parser クラスの実装 33

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

35

Slide 36

Slide 36 text

Parer クラス コンストラクタで Lexer インスタンスを受け取る Lexer インスタンスの getNextToken() を読んでトークンを取得して処理 final class Parser { public function __construct(private Lexer $lexer) { } 36

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

ValueParser クラス: ⽂字列、数値 ⽂字列トークン、数値トークンはインスタンスが値を持つのでそれを返す return match (true) { // (snip) $token instanceof StringToken => $token->getValue(), $token instanceof NumberToken => $token->getValue(), default => throw new ParserException(token: $token), }; 39

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

ArrayParser クラス トークンの並びで意味を解析していく FSM(Finite State Machine: 有限状態機械) で実装 読み取ったトークンによって状態を変化 状態に応じて処理を分岐 41

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

状態: 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

Slide 44

Slide 44 text

状態: 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

Slide 45

Slide 45 text

状態: COMMA 要素値として ValueParser で値を取得して、配列に追加 状態を VALUE に変更 case self::STATE_COMMA: $array[] = ValueParser::parse($lexer, $token); $state = self::STATE_VALUE; break; 45

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

ObjectParser クラス Array と同様に FSM でトークンの並びを解析していく 47

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

状態: 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

Slide 50

Slide 50 text

状態: KEY : : キーと要素の区切り。状態を COLON にする それ以外なら例外をスロー case self::STATE_KEY: if ($token instanceof ColonToken) { $state = self::STATE_COLON; break; } throw new ParserException(token: $token); 50

Slide 51

Slide 51 text

状態: COLON 要素値として ValueParser で値を⽣成して、キーとペアで連想配列に格納 状態を VALUE にする case self::STATE_COLON: $array[$key] = ValueParser::parse($lexer, $token); $state = self::STATE_VALUE; break; 51

Slide 52

Slide 52 text

状態: 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

Slide 53

Slide 53 text

状態: COMMA ⽂字列: キーとしてトークン値を保持。状態を KEY にする それ以外は例外をスロー case self::STATE_COMMA: if ($token instanceof StringToken) { $key = $token->getValue(); $state = self::STATE_KEY; break; } throw new ParserException(token: $token); 53

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

テスト: 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

Slide 56

Slide 56 text

public function dataProvider(): array { return [ 'test' => [ '{ "key1": 123, "key2": " わ\" お", "true":true, "array":[123,2], "o": { "a":1, "b":[true,false,null] } }', ], ]; } 56

Slide 57

Slide 57 text

JsonParser vs json_decode() JIT Compiler あるし、もしかすると。。。 57

Slide 58

Slide 58 text

ベンチマーク⽐較 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

Slide 59

Slide 59 text

まとめ JSON パーサは⾔語の機能だけでシンプルに作れる プログラミング⾔語を学ぶ題材にちょうど良い コードの書き納め、書き初めに JSON パーサを書いてみよう! 59