LIFULL Creators Blog

「株式会社LIFULL(ライフル)」の社員によるブログです。

Node.js で Twig のプリプロセッサーを作って言語の機能拡張をしてみた話

技術開発部の相馬です。好きな JS モジュールバンドラーは Rollup です。

表題のとおりですが、今回は Node.js を使って PHP のテンプレートエンジンである Twig のプリプロセッサーを作り、言語機能の拡張をしてみた話についてご紹介したいと思います。

はじめに

弊社のメイン事業である LIFULL HOME'S の開発の歴史は長く、技術的負債と呼ばれるモノも多く存在しています。

これらの問題に対し、現在弊社ではエンジニアが組織として負債の解消に取り組むような体制が整っています。

フロントエンドの開発環境の改善については、以前ご紹介したので次の記事をご覧ください。

www.lifull.blog

今回は、テンプレートエンジンにまつわる改善についてご紹介したいと思います。

LIFULL HOME'S のサーバーサイドは PHP(Symfony)で構築されており、テンプレートエンジンには Twig を採用しています。

Twig には構文拡張の機構が備わっており、対応するトークンパーサーやノードなどを PHP のコードとして記述し、エクステンションとして登録することで独自のタグを解釈させることなどが可能です。

twig.symfony.com

しかし、当然のことながらこれらは Twig コードが PHP を生成するタイミングでのコンパイル時にしか干渉できず、ツールとしての再利用性はほとんど存在しません。

そこで、時代は AST(Abstract syntax tree)ということで「Node.js で Twig の parser/traverser/codegen を作ってしまおう!」という決断をしました。

Twig の三種の神器(parser/traverser/codegen)作成に関する詳細は、次の記事をご覧ください。

www.lifull.blog

作成言語に Node.js を採用した理由については、

  • 以前 Node.js 環境を整えたのでそのまま利用できたこと
  • 開発陣が JavaScript の取り扱いに長けていた

のが主な要因でした。

神器が整えば、あとは lint なり format なり好きにし放題ということで、手始めに言語のプリプロセッサーを作ってみることにしました。

なぜプリプロセッサーを作るのか?

Twig のバージョンを上げず に、擬似的に上位バージョンの API やメインストリームには存在しない API などを実装することができるからです。

一般的に、言語のメジャーバージョンアップなどビッグチェンジを含むようなインフラ寄りの改修にはテストを含め多くの工数がかかると思います。

これは弊社の Twig にも当てはまることで、歴史的な理由でアップデートが非常に困難(年単位での改修コストがかかる)な状態にあります。

そのため、開発者は数年前の機能(開発効率)のまま、現在も開発せざるを得ないという状況です。

開発効率の他にも、言語特性による脆弱性など LTS ではないバージョンを使い続けることのデメリットは多く考えられます。

このような状況でも、なんとかして開発効率をよくしたい、実装/レビューコスト削減など DX を高めたいという想いの元で生まれたのがプリプロセッサーを作るという考えでした。

プリプロセッサーを挟むことで実際のランタイムのバージョンでは扱えない構文が存在していても、実際のランタイムで扱えるよう機械的に変換することで、ランタイムへと干渉せずとも擬似的に機能拡張を行うことが可能です。

JS における Babel のように、特定のランタイム環境においても実行可能な状態として最新の構文をダウングレードコンパイルするように、Twig のプリプロセッサーにおいても特定の Twig のランタイムバージョンに合わせて出力を変更できるよう設計しました。

作ったプリプロセッサーについて

ここからは、作成したプリプロセッサーがどのようなものかについて触れてゆきたいと思います。

初期構想

PJ 初期段階で実装/導入したい内容としては次のようなことを考えていました。

  • Single File Component
  • 構文拡張
    • xembed
  • lint
  • plugin ベースの拡張

Single File Component

Vue.js や Svelte のようなモノを想像していただければ問題ないと思います。

テンプレートと同一のファイル内に style と script を記述し、影響範囲を狭めてそれぞれのファイル間の物理的距離を縮めるのが狙いです。

CSS に関しては Scoped CSS などで知られているように、テンプレート側と CSS の AST Node をマッチさせることで煩わしい class 命名からの開放なども可能だと考えていました。

構文拡張: xembed

Twig には embed という構文が存在していて、これがそれなりに便利で開発サイドからも需要の高い構文でした。

twig.symfony.com

しかし、LIFULL HOME'S で導入されている Twig バージョンは embed に未対応のバージョンで利用することはできませんでした。

embed を利用できないため、似たようなテンプレートを作成したい時にテンプレートの複製を強いられてしまい、開発効率にも大きく寄与する構文であったため、これとほぼ同等の機能をプリプロセッサー側で実装することにしました。

<!-- コンパイル元のファイル -->
{% xembed 'Bundle::tmp/overlay.base.html.twig' only %}
{% block modal %}
{% include 'Bundle::tmp/modal.html.twig' only %}
{% endblock%}
{% endxembed %}

<!-- コンパイル後のファイル -->
{% include "Bundle::xembed/88e0f115ebf99c3d31d738856b176767d0c229e7.html.twig" only %}

<!-- 88e0f115ebf99c3d31d738856b176767d0c229e7.html.twig -->
{% extends "Bundle::tmp/overlay.base.html.twig" %}
{% block modal %}
{% include "Bundle::tmp/modal.html.twig" only %}
{% endblock modal %}

やっていることは単純で、手動で行うテンプレートの複製をプログラムで透過的に自動で行えるようにしました。

また、仮に Twig のバージョンアップがあった際、本家の embed をそのまま置換可能なように embed の構文と同じインターフェイスをとるようにしました。

lint

Twig 開発上で起こしてしまいがちなミスを事前に防ぐことができるよう lint ツールを統合しています。

onSave でファイルのコンパイルと同時に lint を走らせることで、早いタイミングで開発者にフィードバックを与えるのが目的です。

用意した lint の種類としては次の 2 つです。

  • use only keyword in includeBlock
  • avoid Dynamic include in includeBlock
use only keyword in includeBlock

Twig には include というキーワードで他のテンプレートを利用することができるのですが、この際に only というキーワードを使って変数スコープを指定することができます。

<!-- 変数スコープを指定しない -> children.html から現在のテンプレート上の全ての変数を参照可能 -->
{% include 'children.html' %}

<!-- 変数スコープを指定する -> children.html では with によって渡された foo のみが参照可能 -->
{% include 'children.html' with {'foo': 'bar'} only %}

only をつけていないことで、変数スコープの特定が非常に難しく、修正やリファクタの際に調査コストが大きく膨む要因となってしまいます。

これを、only をつけていない実装があれば、自動で標準出力へと表示するようにしています。

warning: not exist only keyword in IncludeBlock
 at: tmp/parent.twig
  - {% include 'children.html' with {'foo': 'bar'} %}
avoid Dynamic include in includeBlock

こちらも include に関する内容で、動的なテンプレート名の構築を避けて欲しいという内容のものです。

<!-- このように template 名に変数を指定することが可能です -->
{% include params.template.view {"foo": "bar"} only %}

パラメータ名に読み込むテンプレート名を入れることで、場合によってはサーバサイドのかなーり深いところまで遡る必要があるため、こちらも調査コストが高くつきやすいです。

また、ファイル検索する際に grep で引っかからず影響調査から漏れてしまう可能性も高いです。

これらの理由から、動的な名前解決は非推奨として次のような出力をするようにしています。

warning: dynamic include was detected in IncludeBlock
 at: tmp/parent.twig
  - {% include params.template.view {"foo": "bar"} only %}

plugin ベースの拡張

プラグインによって機能拡張がしやすい設計としました。

plugin 設計は Rollup を参考にしました。型ファイルをお見せするとこんな感じです。

export interface IPlugin extends PluginCtx {
  name: string;
  postbuild?: (
    metaOrRootAST: SourceMapMeta | any,
    path: string
  ) => Promise<void>;
  postGenerateAll?: () => Promise<void>;
}

export type LifeCycleValue = 'postbuild' | 'postGenerateAll';

import type { SourceMapMeta } from '@/core';
import type { PluginCtx } from '@/plugins/pluginCxt';

ファイルの build(parse)が終わったタイミングで hook する function を記述し、ソースコードを生成する前に AST へ干渉できるようにしているのでアウトプットされるコードを変更することが可能です。

先ほどの lint の plugin はこんな感じで実装されています。

import { extractWithoutOnlyIncludeNode, generate } from '@/ast/twig';
import { log } from '@/util/console';

export default ({ silent }: { silent: boolean }): IPlugin => {
  return {
    name: 'warnWithoutOnly',
    postbuild: async (ast: any, path: string) => {
      if (silent || ast === null) {
        return;
      }
      const res = extractWithoutOnlyIncludeNode(ast);
      if (!res?.length) {
        return;
      }
      log(
        `warning:`,
        `not exist only keyword in IncludeBlock\n at: ${path}`,
        'yellowBright'
      );
      res.forEach((node) => {
        log(`  -`, `${generate(node)}`, 'yellowBright');
      });
    },
  };
};

// types
import type { IPlugin } from '@/plugins';

(AST 側が TS で実装されていないので、Node の型情報がないので現状は any で濁しています)

ボツ案

初期構想から実際にリリースするまでに削られた機能たちとその背景について触れてゆきたいと思います。

Single File Component

Style や Script を DOM 側と連携/制御させることが難しかったので諦めることにしました。

JSX などは HTML タグを JS オブジェクトとして扱うことができる一方、Twig では Twig の構文上 HTML タグをそれぞれ AST Node として扱うことは難しいです(unnkown な filter などが HTML タグの中に混在する可能性があり、構文解釈が難しい)

よって、Twig の Parser では HTML 部分はほとんど Raw string として扱うことしかできず、HTML Node に対してプログラムによる高度な制御ができませんでした。

Twig にはファイルの継承機能も備わっており、継承が発生した際の script の変数/実装スコープ制御もかなり大変そうな(導入しても扱うことがかなり大変そう)ことがわかったので諦めることにしました。

想定していたリスク

「技術的負債を解消するために言語やフレームワークを拡張することで新たな技術的負債を生んでしまわないか」ということは常に考慮していました。

オレオレ実装や秘伝のタレとして負の遺産的に継承されることは避けられるように、後方互換性や前方互換性についても留意して設計/開発を行いました。

戻しやすく捨てやすい、これをモットーに開発できたことが、途中オーバーエンジニアリングで道を踏み外しそうになった時の支えとなりました。

独自言語(?)を作ってみてわかった感想

テンプレートエンジン +α ライクなモノを作ろうとしてみてはじめて分かった知見もいくつか紹介してみたいと思います。

拡張子とハイライト問題

初期構想時はファイルの拡張子も変えてみようかという話もありました。

その際、Github にプッシュしてコードを見たときにまったくコードハイライトがされていないということが発生しました。

Github 側の実装に当てはまらない拡張子はプレーンテキスト(?)のように扱われてしまうようで、とてもコードレビューできる状態ではありませんでした。

ソフトウェア開発において、Github は大きな役割を担うインフラ的存在になってしまっているため、これを無視することはできませんでした。

(後日知ったのですが、Github にはハイライトを管理するリポジトリが存在しているらしく、ここに PR を送れば独自拡張子などにもハイライトを当てることができるようです github/linguist

まとめ

実のところ、ほとんどの実装は Twig の Parser 側が占めているので、プリプロセッサー側の処理はほとんどなくかなり薄い実装です。

紆余曲折あり当初の設計からはだいぶ機能を削っての導入になりましたが、今回の PJ を通して普段のアプリケーション開発では味わうことができないよい経験ができたと思います。

レガシーなテンプレートエンジンにもリッチな開発体験を、というキャッチフレーズで今後も邁進して参りたいと思います。