🧪

sqlalchemy+flask scope

2022/01/27に公開

web application が application container 上で実行されるという構成はよくあるパターンになっている。flask であれば一つの python process 内に複数の wsgi/asgi アプリケーションが同居している状態が対応する。一方で sqlalchemy は connection pooling などの機能を持っていて、それはプロセス内で共有された状態になる。

アプリケーションごとに sql 接続を管理したいので、コンテナ内の application context と同期して動作する仕組みが欲しくなる。このため sqlalchemy には scoped_session という仕組みがある。registry を使って、プログラムのどこからアクセスしても同じ Session instance が取得できる(ドキュメント参照)。

ややこしいのは scoped_session(factory) で返されるものも factory であるというところ。ドキュメントを注意深く読んで使い方を調べると、global な scoped_session (=factory) を経由する、と記載されている。scoped_session は基本的には接続パラメータなどを保持した Session factory で、ついでに Session のように振舞うことができて、Session メソッド呼び出しは registry にある実体に貫通するようになっている、と理解するとよい。

flask-sqlalchemy

実際、flask-sqlalchemy では scoped_session が global scope からアクセスできるように構成されている。scoped_session は flask と関係なく生成し、後から init_app で割り付けることができる。面白いのは sqlalchemy.event の引数に、SomeSessionOrFactory と指定されているものがあるところで、ORM event への登録に scoped_session が使える。つまり次のような順序で定義できる。

example_flask_sqlalchemy.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event

app = Flask(__name__)
db = SQLAlchemy()
print(type(db.session))
# <class 'sqlalchemy.orm.scoping.scoped_session'>

@event.listens_for(db.session, "before_commit")
def my_before_commit(session):
	print("my_before_commit called")

db.init_app(app)
with app.app_context():
	print(type(db.session()))
	# <class 'sqlalchemy.orm.session.SignallingSession'>

内容的にはflask_sqlalchemy は次の二つの段で構成されている。

  • scoped_session ident_func での Session instance の隔離
  • Flask.config の DB 接続情報(SQLALCHEMY_DATABASE_URI あるいは SQLALCHEMY_BINDS)を、current_app 経由で flask application context に応じて切り替える

結局 flask_sqlalchemy が application context ごとに切り替えるのは binds (engine, connector) になっている。一方でテーブル定義などは flask_sqlalchemy.SQLAlchemy インスタンスに格納される。init_app を複数回呼び出しているということは、つまりテーブル定義は app 間で共有された状態で、appごとに DB 接続先だけが切り替えられる状況になっている。

flask-sqlalchemy-session

flask-sqlalchemy よりもさらに疎結合を目指した flask-sqlalchemy-session というライブラリがある。疎結合になるぶん定義順序に気を使わないといけない。

example_flask_sqlalchemy_session.py
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from flask import Flask
from flask_sqlalchemy_session import flask_scoped_session, current_session

print(type(current_session))
# <class 'werkzeug.local.LocalProxy'>

session = flask_scoped_session(sessionmaker())
print(type(session))
# <class 'flask_sqlalchemy_session.flask_scoped_session'>

@event.listens_for(session, "before_commit")
def before_commit1(session):
    print("before_commit")

session.configure(bind=create_engine("sqlite://"))

app = Flask(__name__)
session.init_app(app)

with app.app_context():
	print(type(session()))
	# <class 'sqlalchemy.orm.session.Session'>
	print(type(current_session))
	# <class 'werkzeug.local.LocalProxy'>
	print(type(current_session()))
	# <class 'sqlalchemy.orm.session.Session'>
	
	print(current_session.execute)
	# <bound method execute of <flask_sqlalchemy_session.flask_scoped_session object at 0x7f38783bdf70>>

# XXX DONT DO THIS
@event.listens_for(current_session, "before_commit")
def before_commit2(session):
    print("before_commit2")

registry pattern を使用するという意味では、sqlalchemy の scoped_session も、flask/werkzeug の LocalProxy も同じことを目指している。つまり、どちらを使っても同じことになる。但し書きを強いて書くならば:

  • current_session は scoped_session に追加で、flask context の制限を付けたものになっている。
  • sqlalchemy の各種 API には scoped_session を使う。werkzeug.LocalProxy はかなり条件を選ぶし、無理に使う必要はない。
  • current_session は flask ハンドラ中でふわっと使う場面で良さがでる。

Discussion