Pythonでフレームワークを作ってみる

# 1. 概要
Pythonを使ってフレームワークを作ってみようと思います。開発環境にはVisual Studio 2019, Python 3.7を使用しています。
ここでのフレームワークとは、以下の条件を満たすものとします。
* エントリポイント（プログラムの開始地点）がフレームワーク側に存在する。
* 利用者が拡張機能を追加する際は、設定ファイル更新とソースコード追加だけで済む。

※2023/8/16更新：GUIで拡張機能一覧を表示する仕組みを追加しました。

# 2. ファイル構成
以下の構成で作成しました。拡張機能を追加する場合は、setting.jsonを更新してpluginフォルダにソースファイルを追加します。

Framework
├── ILogger.py
├── IPlugin.py
├── Logger.py
├── Main.py
├── PluginManager.py
├── plugin
│  └── Plugin.py
├── setting.json
└── Window.py

# 3. クラス構成
以下の構成で実装します。
<dl>
  <dt>Main</dt>
  <dd>エントリポイントとして利用するクラス。設定ファイルの読込はここで行う。</dd>
  <dt>ILogger</dt>
  <dd>ログ出力を行うためのメソッドを定義したインターフェース。拡張機能はこれを使ってログ出力を行う。</dd>
  <dt>Logger</dt>
  <dd>ログ出力機能を実装したクラス。ILoggerを継承。</dd>
  <dt>IPlugin</dt>
  <dd>拡張機能に必要なメソッドを定義したインターフェース。利用者が機能を追加する際は、これを継承したクラスを作成する必要がある。</dd>
  <dt>PluginManager</dt>
  <dd>拡張機能を管理するクラス。</dd>
  <dt>Window</dt>
  <dd>拡張機能一覧をGUIとして表示するためのクラス。</dd>
  <dt>Plugin</dt>
  <dd>拡張機能の実装クラス。利用者が作成する。</dd>
</dl>


クラス図はこんな感じです。拡張機能を追加する場合はIPluginを継承してILoggerを利用することになります。
```plantuml
class Main
class Logger
interface ILogger
interface IPlugin
class PluginManager
class Plugin
class Window

Main *-- Logger
Main *-- PluginManager
Main *-- Window
IPlugin <|-- Plugin
ILogger <|-- Logger
PluginManager o-- IPlugin
Window o-- PluginManager
Logger --o PluginManager
ILogger <-- IPlugin
ILogger --o Plugin
```

# 4. 設定ファイル
JSON形式で記述し、読み込むモジュールの配置場所と拡張機能を実行するためのクラスを指定します。また、GUIの大まかな設定をここに記載しています。
```json:setting.json
{
	"plugin":{
		"plugin.Plugin":"Plugin"
	},
	"window":{
		"title":"Framework",
		"geometry":"300x200"
	}
}
```

# 5. 実装
```python:Main.py
import json
from Logger import Logger
from PluginManager import PluginManager
from Window import Window

class Main:
    def __init__(self):
        self.logger = Logger()
        self.pluginManager = PluginManager(self.logger)
        self.window = Window(self.pluginManager)
        self.load_setting()

    def load_setting(self):
        with open('./setting.json') as _file:
            _json: dict = json.load(_file)
            self.pluginManager.load(_json.get('plugin'))
            self.window.setup(_json.get('window'))

if __name__ == "__main__":
    _main = Main()
    _main.window.mainloop()
    _main.pluginManager.shutdown()
```

```python:ILogger.py
from abc import ABCMeta, abstractmethod

class ILogger(metaclass=ABCMeta):
    @abstractmethod
    def log(self, info: str):
        pass
```

```python:Logger.py
from ILogger import ILogger

class Logger(ILogger):
    def log(self, info: str):
        print(info)
```

```python:IPlugin.py
from abc import ABCMeta, abstractmethod
from ILogger import ILogger

class IPlugin(metaclass=ABCMeta):
    @abstractmethod
    def get_name(self) -> str:
        pass

    def is_shutdown_enable(self) -> bool:
        return True

    def setup(self, logger: ILogger):
        return

    def start(self):
        return

    def shutdown(self):
        return
```

```python:PluginManager.py
from importlib import import_module
from Logger import Logger
from IPlugin import IPlugin

class PluginManager():
    def __init__(self, logger: Logger):
        self.executedIndex = -1
        self.logger = logger
        self.pluginList: list[IPlugin] = []

    def load(self, plugins: dict):
        for module, plugin in plugins.items():
            _plugin = getattr(import_module(module), plugin)
            if issubclass(_plugin, IPlugin):
                _instance: IPlugin = _plugin()
                self.logger.log('load ' + _instance.get_name())
                _instance.setup(self.logger)
                self.pluginList.append(_instance)

    def get_catalog(self) -> list:
        _list: list = []
        for plugin in self.pluginList:
            _list.append(plugin.get_name())
        return _list

    def start(self, index: int):
        if 0 <= index and index < len(self.pluginList):
            _isShutdownDone = False

            if self.executedIndex < 0:
                _isShutdownDone = True
            elif self.pluginList[self.executedIndex].is_shutdown_enable():
                self.pluginList[self.executedIndex].shutdown()
                _isShutdownDone = True

            if _isShutdownDone:
                self.executedIndex = index
                self.pluginList[index].start()
            else:
                self.logger.log('can not shutdown ' + self.pluginList[index].get_name())
        else:
            self.logger.log('invalid index ' + str(index))

    def shutdown(self):
        if 0 <= self.executedIndex:
            self.pluginList[self.executedIndex].shutdown()
```

```python:Window.py
import tkinter
from PluginManager import PluginManager

class Window():
    def __init__(self, pluginManager: PluginManager):
        self.pluginManager = pluginManager

    def setup(self, setting: dict):
        self.root = tkinter.Tk()
        self.root.title(setting.get('title', ''))
        self.root.geometry(setting.get('geometry', '100x100'))

        _message = tkinter.Label(self.root)
        _message.config(text='実行する機能を選択してください')
        _message.pack()

        _catalog = self.pluginManager.get_catalog()
        for index, title in enumerate(_catalog):
            _button = tkinter.Button(self.root)
            _button.config(text=title, command=self.start_plugin(index))
            _button.pack()

    def start_plugin(self, index: int):
        def func():
            self.pluginManager.start(index)
        return func

    def mainloop(self):
        self.root.mainloop()
```

```python:Plugin.py
from ILogger import ILogger
from IPlugin import IPlugin

class Plugin(IPlugin):
    def get_name(self) -> str:
        return 'Plugin'

    def is_shutdown_enable(self) -> bool:
        return True

    def setup(self, logger: ILogger):
        self.logger = logger

    def start(self):
        self.logger.log('start ' + self.get_name())

    def shutdown(self):
        self.logger.log('shutdown ' + self.get_name())
```

# 6. 実行結果
以下のGUIが表示されます。この"Plugin"というボタンは読み込んだモジュール名を表示したもので、押すと拡張機能が実行されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501246/3b3765a5-678d-63ec-938f-55c82c17e61b.png)

# 7. ポイント
* モジュールの読込にimportlibを使用しました。これにより、JSONから取得したモジュール名・クラス名を使って動的に読込先を指定することができます。
* ログ出力はprintするだけとなっているので、わざわざクラスとして分離する意味が無いのでは？と思うかもしれません。しかし拡張機能が増えていくことを想定すると、ログ出力機能は1つのクラスとしてまとめた方が後々便利になると思います。例えばGUIアプリを作るのであればログを画面上に表示したい場合があるかもしれませんし、loggingモジュールを使用する場合は各拡張機能それぞれで出力設定を管理するよりは1か所でまとめて設定できた方が保守性が優れていると思います。（どんなフレームワークを作るかによって実装が変わってくるので、ここではprintするだけにしました）
* GUIの生成にはTkinterを使用しました。GUIライブラリは他にもあるのですが、Pythonのバージョンや依存関係などに制限が発生しないよう、標準ライブラリを使いました。

# 8. 今後
現状は拡張機能の一覧だけしかGUIで表示できていませんので、ログ出力結果もまとめて表示できるようにしたいです。