Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. PHP 8 で作る JSON パーサ
    2020/12/12 phpcon 2020
    @shin1x1

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  7. JSON パーサ
    7

    View Slide

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

    View Slide

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

    View Slide

  10. 10

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  35. 35

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide