2010年12月11日土曜日

SQLAlchemyのモデルクラス用のFlaskコンバータを作ってみる

最近、PythonのWebフレームワークの中では、軽量級のFlaskをよく使っています。
Flaskは、wsgiツールキット的なWerkzeugを基盤に構築されたフレームワークです。
Flaskではroutingの仕組みは、Werkzeugのものをほぼそのまま使い、
関数を簡単にコントローラとして公開できるようなデコレータが用意されています。
これはたとえば以下のように使います。


@app.route("/post/<int:post_id>")
def show_post(post_id):
return "hello"


<int:post_id>というところに注目してください。
この定義により、"/post/10"や"/post/5"のようなURLにマッチし、
show_post関数の引数post_idに、マッチした部分をintに変換し
10や5といった値がわたってくる事になります。

この<int:hoge>の部分は、Converterと呼ばれる仕組みで自作することができるようになっています。
今回は、SQLAlchemyとからめて、idを受け取って、特定のモデルクラスのインスタンスを
抽出するようなConverterを書いてみたいと思います。
モチベーションとしては、以下のようにIDをintとして受け取って、
そのidでインスタンスを検索し、なかったら404にするような操作を、

@app.route("/post/<int:post_id>")
def show_post(post_id):
post = Post.query.get(post_id)
if not post:
raise werkzeug.exceptions.NotFound()
return post.hoge()

次のように、モデルと同名のコンバータを指定する事で、
自動でIDの文字列からインスタンスをfetchして引数として渡し、
もし該当IDがなければ、自動で404としてくれれば、便利かなという感じです。

@app.route("/post") # idが該当しなければ404
def show_post(post):
return post.hoge()


まず準備として、FlaskアプリとSQLAlchemyの初期化を行います。

from flask import Flask, request, url_for, redirect
from flaskext.sqlalchemy import SQLAlchemy
from sqlalchemy import Table, Column, Integer, Unicode
from sqlalchemy.orm import mapper
from werkzeug.routing import IntegerConverter, ValidationError

app = Flask(__name__)

app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///test.db"
db = SQLAlchemy(app)


アプリやデータベースの準備ができたら、
早速コンバータのベースクラスを定義してみましょう。


class BaseModelConverter(IntegerConverter):
def __init__(self, map):
super(BaseModelConverter, self).__init__(map)

def to_python(self, value):
id_ = super(BaseModelConverter, self).to_python(value)
obj = self.model.query.get(id_)
if not obj:
raise ValidationError()
return obj

def to_url(self, value):
return str(value.id)

IntegerConverterを継承するので、正規表現的に\d+にマッチするかのチェックが
まず行われます。
to_pythonメソッドでは、valueつまりマッチした部分の文字列を受け取ります。
IntegerConverter.to_pythonに渡し、int値を得ます。(この時点で失敗すればその中でValidationErrorが投げられます)
このint値、つまりIDをもとにself.model.query.get(id_)により、インスタンスを取得し、
Noneが得られた場合はValidationErrorとします。
to_urlメソッドの方は、その逆に、インスタンスを受けて、そのidをstrにしたものを返すだけです。

そして、モデルを定義するたびにコンバータを追加していると手間なので、
特定のベースモデルクラスを継承したモデルを定義すると自動で同名のコンバータを
追加するようなベースクラスを用意します。
ベースクラスとそのメタクラスの定義は次のようになります。



class BaseModelType(type):
def __init__(cls, name, bases, attrs):
super(BaseModelType, cls).__init__(name, bases, attrs)
if name == 'BaseModel':
return
conv = type(name + "Converter", (BaseModelConverter,), dict(model=cls))
app.url_map.converters[name] = conv

class BaseModel(object):
__metaclass__ = BaseModelType

BaseModelType.__init__の中では、
BaseModelのコンバータは不要なので、とりあえず名前で
BaseModelかどうかのチェックを行います。
そうでなければ、そのモデルの名前 + "Converter"
たとえば UserConverterのような名前の、BaseModelConverterを継承したクラスを生成します。
これは、

class UserConverter(BaseModelCOnverter):
model = User

と同等です。
そして得られたコンバータクラスを、app.url_map.convertersに、モデルと同じ名前で登録します。
BaseModelは、BaseModelTypeをメタクラスとして持ち後は空のクラスです。

ではこれをつかって試しにUserクラスを定義します。


user_table = Table(
"user_table", db.metadata,
Column('id', Integer, primary_key=True),
Column('name', Unicode(100)),
)

class User(BaseModel):
query = db.session.query_property()
def __init__(self, name):
self.name = name

mapper(User, user_table)


とりあえず、上記のように、idとnameだけを持つモデルを定義してみました。
あとはこれをつかったコントローラを書きます。
今回は紙面の都合上テンプレートの使用は省略して
直接文字列を返しておきます。


@app.route("/detail/<User:user>", methods=('GET',))
def detail(user):
return """
<html><body>
<h1>%s</h1>
</body></html>
""" % user.name


もう、説明することがないくらいシンプルなコントローラです。
あとは、Userインスタンスを登録し、そのリストを一覧でだせるようにしましょう。


@app.route("/", methods=('GET',))
def users():
userlist = "".join(
'<a href="http://www.blogger.com/%s">%s</a>' % (
url_for('detail', user=user), user.name)
for user in User.query.all())
return """
<html><body>
%s
<hr />
<form method="post" action="/">
<input type="text" name="name">
<input type="submit">
</form>
</body></html>
""" % userlist

@app.route("/", methods=('POST',))
def add_user():
user = User(request.form['name'])
db.session.add(user)
db.session.commit()
return redirect(url_for('users'))

url_for('detail', user=user)の部分で、UserConverter.to_urlが内部で呼び出され、
適切なURL、たとえば/detail/1 といったURLが生成されることになります。

これで準備はととのいました。
あとは、実行するだけです。



if __name__ == "__main__":
db.metadata.create_all(db.engine)
app.debug=True
app.run(host="127.0.0.1",port=8000)


と最後にかいて、ファイル全体をmain.pyなどの適当な名前で保存し、
python main.py
とすればサーバが立ち上がります。
適当にユーザを追加して動作を確認しましょう!

今回はモデルのIDをマッチする単純なものでしたが、
id以外のカラムにマッチしたり、難読化したidにマッチしたり、
制限時間付きのhmacトークンを埋め込むURLを生成したりといったことが
コンバータの追加で行う事ができます。ちょっとしたことではありますが、
コンバータで抽出、整合性のチェックを行い、失敗すれば404にしてくれるのは
地味に便利だったりします。
皆さんもFlaskやWerkzeugを使って便利なコンバータを使って、
すてきな Python Web FWライフを!
ということでつぎは、@r_rudi にバトンを渡したいと思います。よろしく!>@r_rudi



0 件のコメント:

コメントを投稿