flaskにwebアプリをデプロイして躓いたこと


概要

いくつものエラーを解決して、ようやくflaskフレームワークによるWebアプリが完成しました。

内容はかなりシンプルかつジュニアレベルなもので
言ってしまえば単なるメモ帳のようなものです。
或いは自分しか存在しないtwitterのタイムラインといえば伝わりやすいでしょうか。
https://young-mesa-87008.herokuapp.com/

できること

・ユーザ登録
・メモの記録
・過去の記録を閲覧

実装してみた主な機能

パスワードはsalt・sha256関数によるパスワードのハッシュ化&認証
ログイン・ログアウトはsessionライブラリによるセッション管理を実装
herokuのDBアドオンによるClearDBサーバに対し、PyMysqlをドライバとして使用

開発で躓いたところ

その1:ログイン後、逐一セッションが切れる

ログインに成功して、ひとまず機能動作確認の為
ぐるぐるページ遷移したりガチャガチャ操作していると
突然 500 error が返されることがありました。

原因の究明

まず、heroku logs --tailでErrorの内容を見ると
デコレータによってsessionを確認した際、KeyErrorを起こしているようでした。

ERROR in app: Exception on /user/home [GET]
return super(SecureCookieSession, self).__getitem__(key)
KeyError: 'login'


そもそもの処理内容としましては
アプリ内で登録したユーザでログインを行った後、
メモを記載するページや過去の記録を閲覧するページに遷移できるのですが
その際、login_requiredデコレータで現在ログインしているユーザの情報をsessionから取得させるようにしていました。

app.py

# ログイン時に呼ばれる関数
@app.route('/login/try', methods=['POST'])
def login_try():
    name = request.form.get('name')
    pwd = request.form.get('pwd')
    result = check_user(name, str(pwd))
    if result:
        # ↓ ココで辞書型配列であるsessionに
        #   'login'をKey値としてnameを格納
        session['login'] = name

def login_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # ↓ ココで問い合わせ
        if not is_login():
            return redirect('/login')
        return func(*args, **kwargs)
    return wrapper

# セッション管理
def is_login():
    # 辞書型配列のsessionから loginをキーに持つ値を返す。 
    return 'login' in session

調査として、flaskのsession絡みのコード記事
app.secret_keyに関する質問記事を読むと、
sessionの確立にはapp.secret_keyをsetする必要があり
またkey値はアプリケーション内で同一のものを使用する必要がありました。

そこで自身のコードを見直してみると..

app.py
# ランダム文字列生成
def randomname(n):
    randlst = [random.choice(string.ascii_letters + string.digits) for i in range(n)]
    return ''.join(randlst)

# Flaskインスタンスと暗号化キーの指定
app = Flask(__name__)
app.secret_key = randomname(16)

上記のようにapp.secret_keyの値を関数によって算出し、
使用していることが原因のようでした。
アプリケーションを再起動するたびに、新しいキーが与えられるため、以前のキーは無効になります。
つまり、最初に定義したキーが無効になり、新しいsecret key の下でsessionが定義されるので、当然参照したsessionのKeyは空となり、KeyErrorが発生していたようです。

修正対応としては、
①iniファイルに機密情報(16文字のstring)を記載しておく
②iniファイルを読み込むインタラクティブなモジュール(ini.py)を用意
③ini.pyをメインモジュール(app.py)にimportして流用する
といった対応にしました。

app.py
import secret

app.secret_key = secret.ini_key
ini.py
import configparser

ini = configparser.ConfigParser()
ini.read('config.ini', encoding='UTF-8')
ini_key = ini['secret_key']['sec_key']
config.ini
[secret_key]
sec_key = XXXXXXXXXXXXXXXX

その2:複数のクエリを実行したい時、Pymysqlのconnentionが確立しない

これはselectやinsertのクエリをウォーターフォールで実行するテストを行った時に出会いました。以下のpyを呼び出すと、ins_queryの実行タイミングで
raise err.Error("Already closed")が返されてしまいます。

mysql.py

    # select
    def query(stmt, *args):
        try:
            with conn.cursor() as cursor:
                cursor.execute(stmt, (args))
                data = cursor.fetchall()
        finally:
            conn.close()
            cursor.close()
            return data

    # insert
    def ins_query(stmt, *args):
        try:
            with conn.cursor() as cursor:
                cursor.execute(stmt, (args))
                data = cursor.fetchall()
        finally:
            conn.commit()
            conn.close()
            cursor.close()
            return True

原因の究明

こちらの記事を参考にしました。
1つ目のquery()実行後、conn.close()を行い、
2つ目のins_query()実行時には、cursorを定義して接続しようとしていますが
一度閉じた接続を再接続するにはself.connnection.ping() を投げてやる必要があるようです。
以下のように、cursorによるDB接続試行前に、conn.ping()を書くことで
修正に至りました。

class MySQL:

    # select
    def query(stmt, *args):
        try:
       # ↓ここ
            conn.ping()
            with conn.cursor() as cursor:
                cursor.execute(stmt, (args))
                data = cursor.fetchall()
        finally:
            conn.close()
            cursor.close()
            return data

    # insert
    def ins_query(stmt, *args):
        try:
       # ↓ここ
            conn.ping()
            with conn.cursor() as cursor:
                cursor.execute(stmt, (args))
                data = cursor.fetchall()
        finally:
            conn.commit()
            conn.close()
            cursor.close()
            return True

その3:記録を投稿した時刻と、閲覧した時に返される時刻が9時間ずれている

例えば
投稿した時間→ 2/13 0:50の場合
表示される時間→2/12 15:50
9時間前に投稿したことになっていました。

現在時刻は以下のように取得しています。

app.py
# 今日日付取得
def get_today():
    d = datetime.datetime.now()
    today = (d.strftime('%Y-%m-%d'))
    return today

# 現在時刻取得
def get_time():
    t = datetime.datetime.now()
    time = (t.strftime('%H:%M:%S'))
    return time

原因の究明

恐らくタイムゾーンの設定ではないかと、ClearDBの設定値を疑いましたが
DBの設定値を直接変えることはできず、heroku上で変更が可能だそうです。
herokuのタイムゾーン設定変更

heroku config:add TZ=Asia/Tokyo --app [app-name]

終わりに

現在の業務が忙しく(言い訳)、なかなか勉強する時間が取れず(言い訳)
昨年6月頃から始めた学習を滞らせていましたが
ようやくサーバに自作アプリをアップロードさせることが出来、
一つ達成感を得ることが出来ました。

ただ、実装した機能は初歩中の初歩ですし、
まだまだ改善の余地があると思います。
それに今後商用アプリを作成するならばもっとセキュアにする必要もありますね。
今後もpythonやフロントサイドのoutputとして開発を積み上げていきたいです。

次回はAPI連携や機械学習を取り入れたサービスを創ろうと思います。
また、PHPやvue.jsといった言語も取り入れて、より動的なアプリを作ってみたいです。
それと、他のRuby on Rails, Django, Lavarelといったフレームワークも挑戦してみたいと思います。