LIFULL Creators Blog

LIFULL Creators Blogとは、株式会社LIFULLの社員が記事を共有するブログです。自分の役立つ経験や知識を広めることで世界をもっとFULLにしていきます。

技術的負債の返済の足がかりにテンプレートのParserを作った話

プロダクトエンジニアリング部の中島です。

今回はフロントエンドのテンプレート部分についての負債やレガシーな機構に対する改善の取り組みについて紹介させていただきます。

背景

LIFULL社のメインサービスであるLIFULL HOME'SのメインリポジトリのサーバサイドはSymfony + Twig(※テンプレートエンジン)の構成を採用しています。

このリポジトリの歴史は古く、2011年頃から開発は行われており、今となってはレガシーな機構であったり、開発体験を損ねる負債的な記述も多くあります。

テンプレート部分で多くみられる問題のうちいくつかをピックアップすると弊社ではこのようなものが悩みのタネになっています

  • 変数などを用いた動的な部分テンプレートの呼び出しによるgrepしやすさの低下

  • 部分テンプレートをロードするときにスコープ制御(Twigだとonly属性)をつけ忘れてテンプレート間依存関係を不透明にしてしまう

  • テンプレートの深すぎる継承関係

  • 負債化してしまったTwig拡張関数の呼び出しが各所にちらばって残っている

などなど

新規実装部分にだけに着目しても、レビューによるチェックでこれらを抑える努力はすれど負債コードを誤って参考にした開発は度々あり、レビューの漏れやレビュアー/実装者の経験不足等で徐々にこういった負債は増えていきます。

こういった現状を長年みていると、次第にこれらを「commit-hook等で自動検出する仕組みがほしいな」と感じるようなり、その検知のためにテンプレート(twigファイル)をパースしてTraverseしたいという思いが芽生えてきました。

もしestoolsのようにプログラムを抽象構文木(AST)に変換するParserと、それを探索するTraverser、そのASTからコードを生成するCodegenの三種の神器があれば、強引な正規表現に頼ることなく問題コードを検出したり、レガシーな機構をモダンな機構に変換したりすることも可能になります。

github.com

こういった思いからLIFULL HOME'SでもTwigのParser/Traverser/Codegenを作成することにしました。

Parserを作成するためのステップ

私の知る限り、界隈では一般?的にこのようなステップをたどる感じになると思います。

  • 字句解析を行う(Lexer)
  • 字句をToken単位にまとめる(Tokenizer)
  • TokenをNode単位にまとめる(Parser)

実コードレベルでそれぞれのステップを見てみるとこんな感じになります

元コード

<div class="sample">
  {% spaceless %}
  <div>
    {{ a|some_filter(b) }}
  </div>
  {% endspaceless %}
</div>

Twigでは{% ~ %}で構文を、{{ xxx }} で値のプリンティングを表現します。

LexicalAnalyze (字句解析してTokenごとに分解)

TokenStream {
  tokens:
   [ Token { type: 0, value: '<div class="sample">\n  ' },
     Token { type: 1, value: '' },
     Token { type: 5, value: 'spaceless' },
     Token { type: 3, value: '' },
     Token { type: 0, value: '  <div>\n    ' },
     Token { type: 2, value: '' },
     Token { type: 5, value: 'a' },
     Token { type: 9, value: '|' },
     Token { type: 5, value: 'some_filter' },
     Token { type: 9, value: '(' },
     Token { type: 5, value: 'b' },
     Token { type: 9, value: ')' },
     Token { type: 4, value: '' },
     Token { type: 0, value: '\n  </div>\n  ' },
     Token { type: 1, value: '' },
     Token { type: 5, value: 'endspaceless' },
     Token { type: 3, value: '' },
     Token { type: 0, value: '</div>\n' },
     Token { type: -1, value: '' } ],
  current: 0,
  filename:
   '/path/to/twig-tools/sample/twig/017.html.twig' }

Parse (TokenStreamからNodeTreeへ)

Node {
  nodes:
   [ TextNode { value: '<div class="sample">\n  ' },
     SpacelessNode {
       body:
        Node {
          nodes:
           [ TextNode { value: '  <div>\n    ' },
             PrintNode {
               expr:
                FilterExpression {
                  node: NameExpression { name: 'a' },
                  filter: ConstantExpression { value: 'some_filter' },
                  args: Node { nodes: [ NameExpression { name: 'b' } ] } } },
             TextNode { value: '\n  </div>\n  ' } ] } },
     TextNode { value: '</div>\n' } ] }

ToAST (NodeTreeをASTとしてJSONフォーマットに変換する)

{ type: 'Node',
  nodes:
   [ { type: 'TextNode', value: '<div class="sample">\n  ' },
     { type: 'SpacelessNode',
       body:
        { type: 'Node',
          nodes:
           [ { type: 'TextNode', value: '  <div>\n    ' },
             { type: 'PrintNode',
               expr:
                { type: 'FilterExpression',
                  node: { type: 'NameExpression', name: 'a' },
                  filter: { type: 'ConstantExpression', value: 'some_filter' },
                  args:
                   { type: 'Node',
                     nodes: [ { type: 'NameExpression', name: 'b' } ] } } },
             { type: 'TextNode', value: '\n  </div>\n  ' } ] } },
     { type: 'TextNode', value: '</div>\n' } ] }

実装について

上述のサンプルデータでなんとなくそれぞれの役割的なものが見えてきたかと思います。

全てのフェーズをゴリゴリと自前で実装するのは仕様把握などの面で非常に大変ですが、そもそもTwig自体の中にLexer/Tokenizer/Parserの現行実装があるわけで、それを参考にしながら書いて、それに加えて、最終的なNodeをASTとしてJSONフォーマットで吐き出させる機構・そのASTを再帰的に探索するコード、ASTからのコード生成部分だけを作ればいいだけなので実はそこまでチャレンジングな取り組みというわけでもないのです。

TwigのLexerをみてみましょう。

Lexer.php

while ($this->cursor < $this->end) {
  ...
  switch ($this->state) {
    case self::STATE_DATA:
      $this->lexData();
      break;
    case self::STATE_BLOCK:
      $this->lexBlock();
      break;
    case self::STATE_VAR:
      $this->lexVar();
      break;
    case self::STATE_STRING:
      $this->lexString();
      break;
    ...
  }
}

テンプレート文字列を先頭から順次スキャンしていって構文部分のキーワードの出現をみながらモードを切り替え、それぞれをTokenとして分割していきます。

そうしてできたTokenの集まりをToken Streamというオブジェクト表現にしてParserでNode単位を作っていくわけです。

このNodeのSyntaxはNodeの種類によって変わるので、すべてのNodeのParserを作りきらないといけないわけなのでやや骨がおれますが、これも参考コードとしてTwigの既存のパーサがあるので明けない夜はないという感じで進められるわけです

Node一覧(github/twig)

また、Twig拡張でNodeを自前で増やしてる方々はそこのParserもかくことになります。

弊社の場合はSymfonyがTwigの拡張をいくつか(3~4個 )作っていたのと、自前で拡張を1~2個かいてたのでその分も書くことにしました。

かかった工数はTraverser{traverse|replace}/Codegen/Spec含めてだいたい半月程度です。

各NodeとそのSyntaxバリエーションの掛け合わせをすべて実装する
NodeとSyntaxバリエーションの一部

コード自体はまだ公開してはいないのですがこんな感じで動きます。 (TwigのバージョンによってLexer部分が変わるので公開してもあまりoss的に意味ないかなというのが本音です)

const {parser, codegen, traverser} = require('@lifull/twig-tools');

let template = `
<div class="sample">
  {% spaceless %}
  <div>
    {% include '/path/to/partial.html.twig' %}{# onlyをつけ忘れている #}
  </div>
  {% endspaceless %}
</div>
`;

let ast = parser.parse(template);

traverser.traverse(ast, {
  enter(node, parent) {
    if (node.type === 'IncludeNode' && !node.only) {
      console.warn('onlyをつけ忘れのinclude nodeがありました。規約違反です!');
      console.warn('>', codegen.generate(node));
    }
  }
})

実行

% node sample.js
onlyをつけ忘れのinclude nodeがありました。規約違反です!
> {% include "/path/to/partial.html.twig" %}

出力を見るとうまく検知できていることがわかります。

Traverser等のインタフェースはestoolsをとくに参考にして書きました。

おわりに

上述のコードからもテンプレートをAST化してTraverseできるようにすることにより、問題コードの検知や変換等の処理をたった数行でかけるようになったことがわかります。

現在はこの三種の神器(Parser/Traverser/Codegen)を用いて問題コードの検知や、古い機構の一括変換を遂行してレガシーなテンプレートと戦略的に向き合う努力を進めています。

人力で修正する前提では何年たっても実現できないような大規模なリファクタリングであっても、仕組みを整えることで実現に近づけることもあると思います。

これからも、緩やかではありますが、着実な改善を続けていきたいです。