先日のGoogle App Engineのコードを解説してみる

前エントリのコードはownerチェックが甘かったので、きちんとチェックするようにしました。稚拙なコードだとは思いますが、晒してみる勇気。


#!-*- coding:utf-8 -*-

日本語を使う際のおまじない。

import os
import cgi
import wsgiref.handlers
from google.appengine.api import users
from google.appengine.ext import db
from google.appengine.ext import webapp
from google.appengine.ext.webapp import template

必要なライブラリをインポートします。Google App Engineのチュートリアルに載っているものだけしか使っていませんので割愛。

class Magazine(db.Model):
name = db.StringProperty()
owner = db.UserProperty()

雑誌クラスMagazineの定義です。db.Modelを継承しています。db.StringPropertyは500バイトまでの文字列が保存可能で検索用のインデックスが作られます。db.UserPropertyはユーザ情報格納用です。Google App Engineには良く使われる型が色々と定義されています。0〜100のレーティングを格納出来るRatingPropertyやリンク情報を格納出来るLinkPropertyなどがあります。

class Author(db.Model):
name = db.StringProperty()
owner = db.UserProperty()

同様に著者クラスAuthorです。

class Work(db.Model):
name = db.StringProperty()
magazine = db.ReferenceProperty(Magazine)
author = db.ReferenceProperty(Author)
owner = db.UserProperty()

作品情報を格納するWorkクラス。作品の定義はタイトル単位ではなく、1話単位での扱いとなります。「ドラゴンボール」が作品ではなく、「ドラゴンボール 第123話」が作品になります。Workクラスはdb.ReferencePropertyを使って、Magazine・Authorクラスを参照しています。これによりMagazine:Work・Author:Workはそれぞれ多:1の関係となります。

WORKS_PER_MAGAZINE = 20

1雑誌あたりの作品数を20と定義しています。だいたいの雑誌はこれで足りると思いますが、余裕を持って増やしておいたほうがいいかもしれません。

ここからリクエストの処理をするクラスになります。すべてwebapp.RequestHandlerを継承しています。

class ListPage(webapp.RequestHandler):
def get(self):
if users.get_current_user():
magazines = Magazine.all()
magazines.filter('owner =', users.get_current_user())
magazines.order('name')
template_values = {
'magazines': magazines,
'logout_url': users.create_logout_url('/')
}
path = os.path.join(os.path.dirname(__file__), 'list.html')
self.response.out.write(template.render(path, template_values))
else:
self.redirect(users.create_login_url(self.request.uri))

一覧画面を処理するListPageクラスです。まずログインしていなければ、ログインページにリダイレクトします。続いて、すべてのMagazineからログインユーザがオーナーのものを名前順に並び替えて取得します。ログアウト用のURLと共に辞書型の変数template_valuesに格納し、テンプレートlist.htmlを呼び出します。

<html>
<body>
<ul id="magazine_list">
{% for magazine in magazines %}
<li><a href="/detail?key={{magazine.key}}">{{ magazine.name }}</a></li>
{% endfor %}
</ul>
<ul id="menu">
<li><a href="/edit">登録</a></li>
<li><a href="{{logout_url}}">ログアウト</a></li>
</ul>
</body>
</html>

list.htmlの内容。Google App Engineの標準テンプレートエンジンはDjangoというのですが、テンプレート中で出来ることが少ないため(例えば変数を作成する・引数が必要なメソッドを呼び出すなどが出来ない)、シンプルな内容になっています。

class DetailPage(webapp.RequestHandler):
def get(self):
if users.get_current_user():
key = self.request.get('key')
if not key:
self.response.out.write('400 Bad Request')
self.response.set_status(400)
return
try:
magazine = Magazine.get(key)
if magazine.owner != users.get_current_user():
self.response.out.write('403 Forbidden')
self.response.set_status(403)
return
works = Work.all()
works.filter('magazine =', magazine)
works.order('name')
template_values = {
'magazine': magazine,
'works': works,
}
path = os.path.join(os.path.dirname(__file__), 'detail.html')
self.response.out.write(template.render(path, template_values))
except:
self.response.out.write('404 Not Found')
self.response.set_status(404)
else:
self.redirect(users.create_login_url(self.request.uri))

一雑誌の詳細情報を表示するDetailPageです。ListPageに比べるとパラメータやアクセス権限、存在のチェックが入っているため、長いコードになっています。

まずログイン確認をした後、パラメータkeyが与えられているかを確認します。取得できなかった場合、「400 Bad Request」を返して、終了します。

次に実際にkeyからデータを取得しようと試みます。この際、存在しないkeyを取得しようとすると例外が発生するため、try-exceptで補足させます。例外が発生した場合、[404 Not Found」を返し、終了します。

データの取得が出来たら、アクセス権限のチェックを行います。オブジェクトのownerと現在のユーザが同一でなければ、「403 Forbidden」を返し、終了します。

すべてのチェック処理を通過した後はスムーズです。雑誌に掲載されていた作品一覧をWorkから取得し、テンプレートに渡して終了です。

<html>
<body>
<p>雑誌名:{{magazine.name}}</p>
<ul>
{% for work in works %}
<li>{{ work.name }}(<a href="author?key={{ work.author.key }}">{{ work.author.name }}</a>)</li>
{% endfor %}
</ul>
<form action="/delete" method="post">
<input type="hidden" name="key" value="{{ magazine.key }}"></input>
<p><input type="submit" value="削除"></input></p>
</form>
<form action="/edit" method="get">
<input type="hidden" name="key" value="{{ magazine.key }}"></input>
<p><input type="submit" value="編集"></input></p>
</form>
</body>
</html>

detail.htmlです。list.htmlと同様のループ処理に削除・編集画面へ遷移するボタンが追加されただけです。

class AuthorPage(webapp.RequestHandler):
def get(self):
if users.get_current_user():
key = self.request.get('key')
if not key:
self.response.out.write('400 Bad Request')
self.response.set_status(400)
return
try:
author = Author.get(key)
if author.owner != users.get_current_user():
self.response.out.write('403 Forbidden')
self.response.set_status(403)
return
works = Work.all()
works.filter("author =", author)
works.order('name')
template_values = {
'author': author,
'works': works,
}
path = os.path.join(os.path.dirname(__file__), 'author.html')
self.response.out.write(template.render(path, template_values))
except:
self.response.out.write('404 Not Found')
self.response.set_status(404)
else:
self.redirect(users.create_login_url(self.request.uri))


<html>
<body>
<p>著者名:{{author.name}}</p>
<ul>
{% for work in works %}
<li>{{ work.name }}({{ work.magazine.name }})</li>
{% endfor %}
</ul>
</body>
</html>

一著者の全作品を表示するページです。DetailPageと同じため、解説は割愛します。

class EditPage(webapp.RequestHandler):
def get(self):
if users.get_current_user():
magazine = None
works = None
empty_range = range(1, WORKS_PER_MAGAZINE + 1)
key = self.request.get('key')
if key:
try:
magazine = Magazine.get(key)
except:
self.response.out.write('404 Not Found')
self.response.set_status(404)
return
if magazine.owner != users.get_current_user():
self.response.out.write('403 Forbidden')
self.response.set_status(403)
return
works = Work.all()
works.filter('magazine =', magazine)
works.order('name')
empty_range = range(works.count() + 1, WORKS_PER_MAGAZINE + 1)
template_values = {
'range': empty_range,
'magazine': magazine,
'works': works
}
path = os.path.join(os.path.dirname(__file__), 'edit.html')
self.response.out.write(template.render(path, template_values))
else:
self.redirect(users.create_login_url(self.request.uri))

EditPageはget・postの2つのメソッドが定義されています。まずはgetから説明します。EditPageはパラメータkeyが与えられた場合は更新、そうでない場合は新規登録として動作します。

まずは新規登録用に各変数を初期化します。Pythonではループの際にリストが必要ですので、rangeメソッドでリストを作成します。range(min, max)でminからmax-1までのリストが作成されます。今回は1から20までループしたいので、range(1, WORKS_PER_MAGAZINE + 1)としています。

更新の場合はDetailPageと同様の処理を行い、更新対象の情報を取得します。この際、empty_rangeを1〜20から「登録済み作品数+1〜20」に変更します。

最後にいつものようにテンプレートを呼び出し終了です。

<html>
<body>
<form action="/edit" method="post">
{% if magazine %}
<input type="hidden" name="key" value="{{magazine.key}}"></input>
<p>雑誌名<input type="text" name="magazine_name" size="40" value="{{magazine.name}}"></input></p>
{% for work in works %}
<p>作品{{forloop.counter}}<input type="text" name="work[{{forloop.counter}}]_name" size="40" value="{{work.name}}"></input>
著者名<input type="text" name="work[{{forloop.counter}}]_author" size="40" value="{{work.author.name}}"></input></p>
{% endfor %}
{% for i in range %}
<p>作品{{i}}<input type="text" name="work[{{i}}]_name" size="40"></input>
著者名<input type="text" name="work[{{i}}]_author" size="40"></input></p>
{% endfor %}
<p><input type="submit" name="更新"></input></p>
{% else %}
<p>雑誌名<input type="text" name="magazine_name" size="40"></input></p>
{% for i in range %}
<p>作品{{i}}<input type="text" name="work[{{i}}]_name" size="40"></input>
著者名<input type="text" name="work[{{i}}]_author" size="40"></input></p>
{% endfor %}
<p><input type="submit" name="登録"></input></p>
{% endif %}
</form>
</body>
</html>

edit.htmlです。form要素を作成しているので、長いですが、難しいことはやっていません。ベタベタなつくりになっていますが、DRYに反しているので、Djangoのテンプレートを勉強してすっくりさせたいです。

    def post(self):
if users.get_current_user():
magazine = None
key = self.request.get('key')
if key:
try:
magazine = Magazine.get(key)
except:
self.response.out.write('404 Not Found')
self.response.set_status(404)
return
if magazine.owner != users.get_current_user():
self.response.out.write('403 Forbidden')
self.response.set_status(403)
return
else:
magazine = Magazine()
magazine.owner = users.get_current_user()
magazine.name = self.request.get('magazine_name')
magazine.put()
for old_work in Work.all().filter("magazine =", magazine):
if Work.all().filter('author =', old_work.author).count() == 1:
db.delete(old_work.author)
db.delete(old_work)
for i in range(1, WORKS_PER_MAGAZINE + 1):
work_name = "work[%d]_name" % i
author_name = "work[%d]_author" % i
if self.request.get(work_name):
work = Work()
work.name = self.request.get(work_name)
work.magazine = magazine
work.owner = users.get_current_user()
authorQuery = Author.all()
authorQuery.filter('name =', self.request.get(author_name))
authorQuery.filter('owner = ', users.get_current_user())
author = authorQuery.get()
if not author:
author = Author()
author.name = self.request.get(author_name)
author.owner = users.get_current_user()
author.put()
work.author = author
work.put()
self.response.out.write(
'<html><body><p>登録に成功しました。</p>'
'<p><a href="/">戻る</a></p></body></html>')
else:
self.redirect(users.create_login_url(self.request.uri))

EditPageクラスのpostメソッドです。同一URLにgetでアクセスした場合は、新規登録・更新用フォームを表示し、postの場合は実際に新規登録・更新処理を行うつくりになっています。

Magazine→Work→Authorという順に登録していきます。トランザクション処理はしていないので、途中でエラーが発生すると状態が不正になってしまいます。なぜトランザクション処理にしていないかというとGoogle App Engineではトランザクション中にデータの取得が出来ないという制限があるのです。もちろん、先に取得してから一気に更新処理を行えば可能ですが、習作として作っているアプリとしては構成がわかりにくくなるため、行っていません。

まず最初にMagazineの更新処理を行います。パラメータkeyが取得されて場合は更新であるため、定番のチェック処理を行い、そうでなければ新規登録なのでMagazine()で新しいオブジェクトを作成します。最後にputメソッドを呼び出し、登録します。

次に現在登録中のWorkをすべて削除します。20作品登録済みで、1作品だけ更新した場合でも、すべて削除してから、再登録します。効率的ではないのですが、バグになりにくい仕組みを優先しました。

Work削除処理中にAuthorの削除を行う場合があります。その筆者の他の作品が一つもない場合です。countメソッドで登録数をチェックし、1の場合、削除を行います。

続いて、Workの登録処理を行います。formのnameがwork[1]_nameからwork[20]_nameとして生成しているので、まずはループカウンタiからその名前を生成します。self.request.getメソッドで取得できた場合、実際の登録処理を行います。

著者が未登録だった場合は、新規登録を行いますが、すでに登録済みの場合は、同一のAuthorオブジェクトを指し示すようにします。

最後にself.response.out.writeでHTMLを出力して終了です。

class DeletePage(webapp.RequestHandler):
def post(self):
if users.get_current_user():
magazine = None
key = self.request.get('key')
if not key:
self.response.out.write('400 Bad Request')
self.response.set_status(400)
return
try:
magazine = Magazine.get(key)
except:
self.response.out.write('404 Not Found')
self.response.set_status(404)
return
if magazine.owner != users.get_current_user():
self.response.out.write('403 Forbidden')
self.response.set_status(403)
return
workQuery = Work.all()
workQuery.filter('magazine =', magazine)
workQuery.filter('owner =', users.get_current_user())
for work in workQuery:
if Work.all().filter("author =", work.author).count() == 1:
db.delete(work.author)
db.delete(work)
db.delete(magazine)
self.response.out.write(
'<html><body><p>削除に成功しました。</p>'
'<p><a href="/">戻る</a></p></body></html>')
else:
self.redirect(users.create_login_url(self.request.uri))

最後のRequestHandler、DeletePageです。目新しいコードはないです。パラメータ・存在・アクセス権チェックを行った後、Authorの参照数に注意しつつ、削除を行うだけです。

def main():
application = webapp.WSGIApplication(
[('/', ListPage),
('/list', ListPage),
('/detail', DetailPage),
('/author', AuthorPage),
('/edit', EditPage),
('/delete', DeletePage)],
debug=True)
wsgiref.handlers.CGIHandler().run(application)
if __name__ == "__main__":
main()

最後の最後にルーティング情報を定義するmainメソッドを定義し、起動するように設定してお仕舞です。

長文&&駄コードを読んでいただき、ありがとうございました。ツッコミなどいただけると大変喜びます。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする