はじめに

Ruby on RailsやDjangoなどのWebFrameWorkでRubyやPythonなどのコードを埋め込めるテンプレートファイルをみたことがある人は多いかと思います。
今回はその内部で動いている仕組みについてまとめています。

本記事で取り上げるプログラムはPythonで作っていますが、考え方はRubyでも同じですのでRubyを普段から使用している方にも参考になると思います(Python特有のコードはある程度説明を挟んでいます)。
また紹介するプログラムコードは筆者が数時間で書いたものなのでコードテクニックを参考とするよりは考え方を参考にすることを推奨しています。

対象読者

初級レベルのRubyやPythonの知識があればコードを読み解くには十分です。
下記のキーワードをある程度理解していると本記事をスムーズに読むことができると思います。

  • 親クラス、子クラス
  • メソッドのオーバーライド(特に演算子周りの定義)
  • クロージャー

アルゴリズム

早速ですが、Viewテンプレートエンジンを作るためのアルゴリズムを紹介します。

1: Viewテンプレートの規則をまとめよう
2: Viewをプログラム実行可能な文字列に変換しよう
3: プログラム実行可能な文字列を実行関数(eval, execなど)に渡そう

以上が大まかな流れになります。

ベースとなるコード

本題に入る前にViewテンプレートエンジンを作った時にコードがどれだけ綺麗になるかを比較できるようにベースとなったサンプルコードを掲載します。

import sys
from PySide2.QtWidgets import QApplication, QPushButton, QMainWindow
from PySide2.QtGui import QFont
import typing
import PySide2
from functools import wraps


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = QMainWindow()
    window.setGeometry(800, 300, 500, 500)
    window.setWindowTitle("pyside2の拡張")

    t = QPushButton("Data1")
    t.setFont(QFont("Meiryo", 7))
    t.setFixedSize(55, 35)
    t.setGeometry(100, 100, 300, 10)

    window.layout().addWidget(t)
    window.show()
    print(QPushButton.__doc__)
    sys.exit(app.exec_())

こちらはpyside2ライブラリを使用し、GUIアプリケーションを実装しているコードになります。
そのため実行するには pip install pyside2 とする必要があります。
また、使用するPythonバージョンは3.9系ですが、3.8系でも動くと思います。Python3.6未満はこの後にf文字列を使用するため実行は不可能になりますが、少し置き換えれば実行可能になると思います。

ベースとなるサンプルコードを整理する

Viewの規則を決めるためベースとなるコードを整理します。

import sys
from PySide2.QtWidgets import QApplication, QPushButton, QMainWindow
from PySide2.QtGui import QFont
import typing
import PySide2
from functools import wraps


def settingAttributes():
    def mySettingAttributes(function):
        @wraps(function)
        def wrapper(self, *args, **kwargs):
            attributes = args[0]
            if attributes is not None:
                for func, value in attributes.items():
                    if isinstance(value, list):
                        getattr(self, func)(*value)
                    else:
                        getattr(self, func)(value)
            return function(self, *args, **kwargs)

        return wrapper

    return mySettingAttributes


class CustomQPushButton(QPushButton):
    def __init__(self, text: str = "", parent: typing.Optional[PySide2.QtWidgets.QWidget] = None, attributes=None):
        super().__init__(text, parent)
        self.attribute(attributes)

    @settingAttributes()
    def attribute(self, attributes=None):
        pass


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = QMainWindow()
    window.setGeometry(800, 300, 500, 500)
    window.setWindowTitle("pyside2の拡張")

    t = CustomQPushButton("Data1", attributes={
        "setGeometry": [300, 100, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    })
    # このtを良い感じにしてあげたい
    # t = QPushButton("Data1")
    # t.setFont(QFont("Meiryo", 7))
    # t.setFixedSize(55, 35)
    # t.setGeometry(100, 100, 300, 10)

    window.layout().addWidget(t)
    window.show()
    print(QPushButton.__doc__)
    sys.exit(app.exec_())

settingAttributesメソッドはデコレーターです。

    @settingAttributes()
    def attribute(self, attributes=None):
        pass

このコードは次のような解釈ができます。

attribute = mySettingAttributes(attribute)

このようにPythonでは @settingAttributes() とデコレーターを簡単に実装することができます。
このデコレーター内部の処理は辞書型で渡ってきたデータを元にkey(pass)の形でプログラム実行します。
つまり、"setGeometry": [300, 100, 300, 10]setGeometry(100, 100, 300, 10) と同じになります。

更にQMainWindowでも同じようなことをします。

class MainWindow(QMainWindow):
    def __init__(self, attributes=None):
        super().__init__()
        self.attribute(attributes)

    @settingAttributes()
    def attribute(self, attributes=None):
        pass

    def __lshift__(self, widget):
        self.layout().addWidget(widget)
        return self

    def __pos__(self):
        self.show()
        return self

__lshift____pos__ は演算子を表すメソッドになります。
つまり、+ MainWindow() とやったときに __pos__() が動作することになります。

これを使うことでmain文が次のようになります。

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = + MainWindow(attributes={
        "setGeometry": [800, 300, 500, 500],
        "setWindowTitle": "pyside2の拡張"
    }) << CustomQPushButton("Data1", attributes={
        "setGeometry": [100, 100, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    }) << CustomQPushButton("Data2", attributes={
        "setGeometry": [100, 300, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    })
    sys.exit(app.exec_())

ボタンが1つだと寂しいと思い、Push可能なボタンを1つ増やしました。
このコードは下記のような解釈をすることができます。

+ A() << B() << C()
1: A()
2: +
3: << B()
4: << C()
の順に処理されます

余談ですが、<< ではなくメソッドチェインの時は処理順が変わりますので気になる方は是非実験してみてください。

1: Viewテンプレートの規則をまとめよう

さて、コードを整理できたところでViewテンプレートのルールを決めましょう。
テンプレートのルールのパターンは無限大で自分が使いやすいと思ったものをルールにして良いと思います。
今回筆者が決めたルールは

  • 空白に価値はなし
  • widgetに属性がついていることがわかりやすいようにする
  • widgetの前には % をつける
  • 属性の前には - をつける
  • 属性のキーと値の関係は間に : をいれる

これだけです。
必要に応じてルールを厳しく縛っていけば良いので最初は簡単に決めるのが良いと思います。
筆者が決めたルールを満たす書き方は

%window
  - setGeometry: [800, 300, 500, 500]
  - setWindowTitle: "pyside2の拡張"
%push_button
  - setText: "Data1"
  - setGeometry: [100, 100, 300, 10]
  - setFont: QFont("Meiryo", 7)
  - setFixedSize: [55, 35]
%push_button
  - setText: "Data2"
  - setGeometry: [100, 300, 300, 10]
  - setFont: QFont("Meiryo", 7)
  - setFixedSize: [55, 35]

となります。

2: Viewをプログラム実行可能な文字列に変換しよう

2の処理でやることを簡単にまとめると1で決めたテンプレートをベースとなるコードに変換するということです。 つまり、

MainWindow(attributes={
        "setGeometry": [800, 300, 500, 500],
        "setWindowTitle": "pyside2の拡張"
    }) << CustomQPushButton("Data1", attributes={
        "setGeometry": [100, 100, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    }) << CustomQPushButton("Data2", attributes={
        "setGeometry": [100, 300, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    })

このような文字列に変換できるようにすれば良いです。
筆者はこの処理の中でwidgetのタイプを確認して MainWindowCustomQPushButton に変換するのは面倒だと感じたため、次のようなクラスを実装しました。

class MyWidget:
    def __new__(cls, name, *args, **kwargs):
        if name == 'push_button':
            return CustomQPushButton.__new__(CustomQPushButton, *args, **kwargs)
        elif name == 'window':
            return MainWindow.__new__(MainWindow, *args, **kwargs)


class CustomQPushButton(QPushButton, MyWidget):
    def __init__(self, text: str = "", parent: typing.Optional[PySide2.QtWidgets.QWidget] = None, attributes=None):
        super().__init__(text, parent)
        self.attribute(attributes)

    @settingAttributes()
    def attribute(self, attributes=None):
        pass


class MainWindow(QMainWindow, MyWidget):
    def __init__(self, _name: str, attributes=None):
        super().__init__()
        self.attribute(attributes)

    @settingAttributes()
    def attribute(self, attributes=None):
        pass

    def __lshift__(self, widget):
        self.layout().addWidget(widget)
        return self

    def __pos__(self):
        self.show()
        return self

このコードは MyWidget("window") とすると MainWindow の型のインスタンスが返るようになっています。
これはインスタンスを作る時に __new__ が動き、その後型をチェックし __init__ が走る原理に注目した処理となっており、MyWidgetの __new__ が走り、条件に応じてMainWindowなどの __init__ が走る仕組みになっています。
この実装はあまり王道ではないので実験的なコードではない限り、参考にしない方が良いと思います。

さて、事前加工も済んだことなので本題の処理に入っていこうと思います。

class ViewTemplate:
    def __init__(self, file_name: str):
        self.file_name = file_name
        self._node_buf = list()
        self._node = list()

    def open_template(self):
        with open(self.file_name, encoding='utf-8') as f:
            for line in f:
                self._node_buf.append(line.replace('\n', '').replace(' ', ''))

    def create_node(self):
        _parent = None
        _buf = list()
        for line in self._node_buf:
            if line.startswith('%'):
                if _parent is not None:
                    self._node.append(_parent)
                _parent = ParentNode(line[1:])
            elif line.startswith('-'):
                _parent << AttributeNode(*line[1:].split(':'))
        if _parent is not None:
            self._node.append(_parent)


class ParentNode:
    def __init__(self, name):
        self.name = name
        self.attributes = list()

    def __lshift__(self, other):
        self.attributes.append(other)

    def render(self):
        return f'MyWidget("{self.name}", attributes={{{self._render_child()}}})'

    def _render_child(self):
        return ','.join(list(map(lambda x: x.render(), self.attributes)))


class AttributeNode:
    def __init__(self, key, value):
        self._key = key
        self._value = value

    def render(self):
        return f'"{self._key}": {self._value}'

create_nodeメソッドが今回の主役です。
ParentNodeやAttributeNodeは今後機能追加などをする際にやりやすくするために用意したものです。
処理の流れは以下のようになります。

1: テンプレートファイルを開き一行ずつ読み込みます
2: %があればWidgetの宣言として解釈し、ParentNodeに変換します
3: -があれば属性と判断し、AttributeNodeとして扱います
4: 属性であれば、直前のParentNodeに紐づけます
5: 2-4までをループし、ファイルを読み終わったら、ParentNode全てを配列として格納して終了です

ParentNodeのrenderを呼ぶと文字列に変換されます。

3: プログラム実行可能な文字列を実行関数(eval, execなど)に渡そう

最後にプログラム解釈可能文字列を実行するような機能を作れば完成です。

class ViewTemplate:
    ...

    def render(self):
        self.open_template()
        self.create_node()
        program_node = ' << '.join(list(map(lambda x: x.render(), self._node)))
        return eval(program_node)

以上です。 これをmainで呼ぶだけです。

if __name__ == '__main__':
    app = QApplication(sys.argv)
    template = ViewTemplate('main_view.dui')
    window = + template.render()
    sys.exit(app.exec_())

かなりスッキリしましたね。

まとめ

1: テンプレートファイル

%window
  - setGeometry: [800, 300, 500, 500]
  - setWindowTitle: "pyside2の拡張"
%push_button
  - setText: "Data1"
  - setGeometry: [100, 100, 300, 10]
  - setFont: QFont("Meiryo", 7)
  - setFixedSize: [55, 35]
%push_button
  - setText: "Data2"
  - setGeometry: [100, 300, 300, 10]
  - setFont: QFont("Meiryo", 7)
  - setFixedSize: [55, 35]

のルールを決める。

2: テンプレートファイルをプログラム実行可能な文字列

"""
MainWindow(attributes={
        "setGeometry": [800, 300, 500, 500],
        "setWindowTitle": "pyside2の拡張"
    }) << CustomQPushButton("Data1", attributes={
        "setGeometry": [100, 100, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    }) << CustomQPushButton("Data2", attributes={
        "setGeometry": [100, 300, 300, 10],
        "setFont": QFont("Meiryo", 7),
        "setFixedSize": [55, 35]
    })
"""

に変換する機能を作る。

3: プログラム実行可能な文字列を実行する。

以上のアルゴリズムを組むとViewテンプレートエンジンを作ることができました。
この考え方はhtmlテンプレートエンジンの開発などにも役に立つと思いますので是非参考にしてください。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる