なぜフロントエンドとバックエンドを分離するのか
実を言うと、私はこれまでフロントエンド/バックエンド分離のプロジェクトを1つも完成させたことがない。C言語使いの私がそれまで書いてきた小物ツールはすべて、上位・下位のレイヤー間で大きなオブジェクトを引数に渡して呼び出す方式だった。しかも、共同開発しやすいように、見せかけのフロントエンド/バックエンド分離プロジェクトの枠組みを設計したこともある。
その後、友人にRedisデータベースを勧められ、PyQtと組み合わせてデスクトップアプリを作る場合もフロントエンド/バックエンド分離ができると知った。ただし、キーバリュー形式の通信に完全に頼ると、キーバリューテーブルがどうしても大きくなり、キー名を決めるのがとても頭の痛い問題になる。
しかも、これではC言語プロジェクトの発想から抜け出せていない。本質的には、フロントエンドとバックエンドのプロジェクトをそれぞれ独立して動かせるように、グローバル状態の構造体をRedisデータベースに置き換えただけであり、徹底的なモジュール化にはまだ届かない。
ブログや小規模なWebサイトをいくつか作るうちに、Web開発は本当にいいものだと感じた。特に、C言語の発想から解放されて、やりたい放題に書き散らかしながら爆速で開発できるのがいい。そこでPythonでのWeb開発スタイルに行き着き、Flask + Socket + WebView + Processという構成なら、マルチプロセスで並行処理もできるだろう。
そもそもなぜフロントエンド/バックエンド分離にこだわるのか。それは、ソフトウェアをさらにモジュール化して、より大規模なソフトウェアを作りたいからだ。
使い方
原作者の兄貴のリポジトリにはexampleがある。app.pyとtemplates配下の.htmlを自分のdemoプロジェクトに貼り付ければ、すぐ動かせる。足りないライブラリは、その都度インストールすればいい。
応用的な使い方
原作者の兄貴のサンプルにはWebViewもProcessも使われていない。デスクトップアプリとして完成させるには、フロントエンドとバックエンドの処理をマルチプロセスで動かせて、専用のアプリケーションウィンドウを持てることが必須だ。
この2つを組み合わせると、次のような例になる。元のライブラリのサンプルに追記・変更を加えた形だ。
import multiprocessing
from multiprocessing import Process
import webview
import os
import signal
from engineio.async_drivers import eventlet
async_mode = "eventlet" # 显式指明异步库
......
def run_flask():
socketio.run(app, host='127.0.0.1', port=5000) # 指定主机和端口
# socketio.run(app)
def run_webview():
webview.create_window('pyweb测试', 'http://127.0.0.1:5000') # 创建浏览器窗口
webview.start() # 启动 pywebview
if __name__ == '__main__':
multiprocessing.freeze_support()
flask_process = Process(target=run_flask)
flask_process.start()
webview_process = Process(target=run_webview)
webview_process.start()
webview_process.join()
os.kill(flask_process.pid, signal.SIGTERM)
このうちこの2行は、将来exe化するためのものだ。exeにパッケージングした後、async_modeを明示的に指定しないと動かない。
from engineio.async_drivers import eventlet
async_mode = "eventlet" # 显式指明异步库
この部分は、元のサンプルのSocket.IOサーバーとWebViewウィンドウを、それぞれ独立した2つのプロセスに切り離すためのものだ。
def run_flask():
socketio.run(app, host='127.0.0.1', port=5000) # 指定主机和端口
# socketio.run(app)
def run_webview():
webview.create_window('pyweb测试', 'http://127.0.0.1:5000') # 创建浏览器窗口
webview.start() # 启动 pywebview
if __name__ == '__main__':
multiprocessing.freeze_support()
flask_process = Process(target=run_flask)
flask_process.start()
webview_process = Process(target=run_webview)
webview_process.start()
webview_process.join()
os.kill(flask_process.pid, signal.SIGTERM)
特に注目すべきは multiprocessing.freeze_support() の1行だ。ProcessライブラリのWindowsにおける仕様上、この1行がないと、パッケージング後にプロセスが無尽蔵に起動してPCがフリーズしてしまい、シャットダウンして再起動するしかなくなる。Recipe Multiprocessing · pyinstaller/pyinstaller Wiki (github.com)
パッケージングについて
先ほどの応用的な使い方の例を踏まえると、コード上でのパッケージングの落とし穴はほぼ回避できる。しかし、フロントエンド/バックエンド分離のプログラムとなると、プロジェクト構成の複雑さがこれまでのプログラムと比べ物にならない。そのため、.specによる細かい設定が必須になる。たとえば、次のような例がある。
# myapp.spec
# -*- mode: python -*-
block_cipher = None
# Analysis部分用于分析应用程序的依赖
a = Analysis(['main.py'], # 应用程序的主脚本
pathex=['.'], # 应用程序路径
binaries=[], # 需要包含的二进制文件
datas=[('templates', 'templates')], # 包含 HTML 模板
hiddenimports=['flask', 'flask_socketio', 'eventlet', 'serial',
# 这部分是必须手动指明的
'engineio.async_eventlet','eventlet.hubs.epolls','eventlet.hubs.kqueue','eventlet.hubs.selects','dns','dns.dnssec','dns.e164','dns.edns','dns.entropy','dns.exception','dns.flags','dns.grange','dns.hash','dns.inet','dns.ipv4','dns.ipv6','dns.message','dns.name','dns.namedict','dns.versioned','dns.node','dns.opcode','dns.query','dns.rcode','dns.rdata','dns.rdataclass','dns.rdataset','dns.rdatatype','dns.renderer','dns.resolver','dns.reversename','dns.rrset','dns.set','dns.tokenizer','dns.tsig','dns.tsigkeyring','dns.ttl','dns.update','dns.version','dns.wiredata','dns.zone','av','fvcore','torch','torchvision','detectron2',
], # 隐式导入的模块
hookspath=[], # 自定义hook路径
hooksconfig={}, # hooks配置
runtime_hooks=[], # 运行时hook
excludes=[], # 排除的模块
win_no_prefer_redirects=False, # Windows下是否优先使用重定向
win_private_assemblies=False, # 是否使用私有程序集
cipher=block_cipher, # 数据加密
noarchive=False) # 是否不打包成zip文件
# PYZ部分用于打包Python字节码
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# EXE部分用于定义可执行文件的设置
exe = EXE(pyz,
a.scripts, # 指定需打包的脚本
[],
exclude_binaries=True, # 不打包二进制文件
name='myapp', # 应用程序名称
debug=False, # 是否启用调试模式
bootloader_ignore_signals=False, # 处理信号
strip=True, # 是否去除调试信息
upx=True, # 是否使用UPX压缩
console=False) # 是否使用控制台窗口
# COLLECT部分用于收集所有依赖项
coll = COLLECT(exe,
a.binaries, # 收集的二进制文件
a.zipfiles, # 收集的zip文件
a.datas, # 收集的数据文件
strip=False, # 是否去除调试信息
upx=True, # 是否使用UPX压缩
upx_exclude=[], # 不压缩的文件
name='myapp') # 最终应用程序名称
この中の「engineio.async_eventlet」など、ずらっと並んだ一団は、パッケージング後にexeを起動すると「パッケージが見つからない」とエラーになるものだ。実際、エラーで「◯◯がない」と言われたら、ここに追加していけばいい。この一団もほかの人のパッケージングコマンドから拝借してきたもので、何度かパッケージングを試すと本当にひとつずつエラーを出してきたので、いっそ全部突っ込んでしまった。
最後に、次のコマンドでパッケージングする。“my.spec”は自分で付けた.specファイル名だ。
pyinstaller my.spec
最終的にできたexeファイルを実行すると「.WebView2」というフォルダーが展開されるが、気にする必要はない。全体のサイズはだいたい30MB以内で、QtやPySideのような、でかすぎて話にならないパッケージよりはずっとマシだ。
あとがき
このことにこだわるのは、Qtが嫌いなのも関係している気がする。PyQtやPySideは制約が大きすぎる。まるで基地車が展開するようなもので、使わない機能を大量に抱え込んでいるうえ、フレームワーク内の機能しか使えない。ほかのパッケージとしょっちゅう衝突するし、Pythonが持つ強力なライブラリ群や、シンプルで頭を使わずに書けるという特性を完全に無駄にしている。
軽量・モジュール化・特化。エレガントさを追求するエンジニアは、本当はいいエンジニアなんじゃないだろうか。
サンプルプロジェクト
フロントエンドの基礎がまったくないので、とりあえず始められる程度のサンプルプロジェクトを簡単にひとつ作っただけだ。