關於Flask-SQLAlchemy事務提交有趣的探討

最近在開發mdwiki的時候遇到這樣一個問題.Post is unbond to session.
我就好奇了git

post=Post.query.filter_by(location=location).first()
abspath=util.getAbsPostPath(post.location)
tagsList=[]
...
print(post in session) #False
post.tags=tagsList

這樣還報post不在session中的錯?沒有顯示調用db.session.commit()啊.
加一行測試:
print(post in session) #False
無奈,一個一個翻post=Post.query.filter_by(location=location).first()到post.tags=tagsList之間調用的每個函數,終於在util.getAbsPostPath找到可疑點github

def getAbsPostPath(location):
    with current_app.app_context():              
        abspath=os.path.join(current_app.config['PAGE_DIR'],location.replace('/',os.sep))+".md"
    return abspath

可是這裏也沒有顯式提交。只是多push了一個app_context,也不至於這樣吧?
無奈之下查看Flask-SQLAlchemy源碼,還好這貨只有兩個文件,比較少。
有這麼一段:sql

# 0.9 and later
        if hasattr(app, 'teardown_appcontext'):
            teardown = app.teardown_appcontext
        # 0.7 to 0.8
        elif hasattr(app, 'teardown_request'):
            teardown = app.teardown_request
        # Older Flask versions
        else:
            if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
                raise RuntimeError("Commit on teardown requires Flask >= 0.7")
            teardown = app.after_request

        @teardown
        def shutdown_session(response_or_exc):
            if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
                if response_or_exc is None:
                    self.session.commit()
            self.session.remove()
            return response_or_exc

這下明白了,原理是它監聽了app.teardown_appcontext事件,在該事件發生時會調用
self.session.remove()移除session。這樣一來把這一行註釋掉,直接使用config模塊就解決問題了flask

def getAbsPostPath(location):
       # with current_app.app_context(): 
       abspath=os.path.join(config.PAGE_DIR,location.replace('/',os.sep))+".md"

但同時看到了這個選項SQLALCHEMY_COMMIT_ON_TEARDOWN,是否是Flask-SQLAlchemy能夠配置請求執行完邏輯以後自動提交,而不用咱們每次都手動調用session.commit()?經過源碼看答案是確定的。
可是好奇的我仍是google之,而後在github上看到了這樣幾段有趣的討論:先貼地址
https://github.com/mitsuhiko/...
https://github.com/mitsuhiko/...
https://github.com/rosariomgo...session

而後在官網看到這樣一段:app

Consider SQLALCHEMY_COMMIT_ON_TEARDOWN harmful and remove from docs.ide

什麼?考慮移除這一特性?
剛剛知道這麼方便的特性,準備用來着,就要被移除?更況且源碼中也沒有提示要移除啊。
實際上是這樣的,做者準備在3.0版本移除SQLALCHEMY_COMMIT_ON_TEARDOWN這一特性,目前自2.1之後從文檔中移除了相關介紹。函數

爲何?接下來總結下大神們的探討。post

mattupstate commented on 31 Jan 2015: I'd guess that the reason is due
to the teardown_appcontext callback carrying a bug that, even if you
catch an exception during the app context, the response_or_exc will
never be None. In other words, teardown_appcontext suffers from a
general Python exception handling bug.測試

這位mattupstate說teardown_appcontext回調存在一個bug,就是即便你正確地捕獲了全部的bug,可是回調函數的第一個參數response_or_exc仍然不會爲None。這一點使人費解。因而我試驗了一發,包括沒有bug的情形,主動拋出並捕獲的情形,以及after_request中捕獲並拋出的情形,都發現response_or_exc爲None,沒有重現他所說的。why?好想知道爲何。猜想多是我Python版本,Flask版本的關係?繼續看吧

immunda commented on 3 Feb 2015 Sorry for the silence on this. Yep,
that's the motivation, moving away from the (flawed) magic. I'm
waiting to deprecate it entirely (3.0), because there's plans to
introduce a more explicit transaction decorator first.

我去,連Flask-SQLAlchemy做者都支持這一觀點,好吧,雖然我沒有重現該問題,可是仍是就這麼認爲吧,不用這個特性了。可是仍是好奇地看了一下其餘的觀點。

原來實際上問題是這樣的,見https://github.com/mitsuhiko/...

先貼上FLask-SQLAlchemy那部分代碼:

@teardown
        def shutdown_session(response_or_exc):
            if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
                if response_or_exc is None:
                    self.session.commit()
            self.session.remove()
            return response_or_exc

若是在app.teardown_request中或者在self.session.commit()時發生異常,而這個異常在這裏並無被捕獲,那麼self.session.remove()也就沒有執行,那麼這就會影響到下一個請求,下一個請求獲取到的session實際上是上一個帶回滾狀態的session,從而致使請求沒有按預期效果執行而失敗。至此問題算明白了。並非mattupstate這哥們形容的那樣。那麼這應該是flask實現機制致使的吧。繼續挖。
https://github.com/pallets/fl...
http://stackoverflow.com/ques...
這哥們garaden給Flask提交了代碼合併請求,關鍵部分以下

+    def wrap_teardown_func(teardown_func):
 +        @wraps(teardown_func)
 +        def log_teardown_error(*args, **kwargs):
 +            try:
 +                teardown_func(*args, **kwargs)
 +            except Exception as exc:
 +                app.logger.exception(exc)
 +        return log_teardown_error
 +
 +    if app.teardown_request_funcs:
 +        for bp, func_list in app.teardown_request_funcs.items():
 +            for i, func in enumerate(func_list):
 +                app.teardown_request_funcs[bp][i] = wrap_teardown_func(func)
 +    if app.teardown_appcontext_funcs:
 +        for i, func in enumerate(app.teardown_appcontext_funcs):
 +            app.teardown_appcontext_funcs[i] = wrap_teardown_func(func)

若是合併了這部分代碼以後,那麼之後註冊app.teardown_request和app.teardown_appcontext,時異常將會自動被捕獲。這在https://github.com/pallets/fl...能夠看到新版本Flask已經合併了這部分代碼,不存在該問題了。
後面討論看到

Recently PR pallets/flask#1822 got merged into Flask. Will this maybe
change the fact whether SQLALCHEMY_COMMIT_ON_TEARDOWN will still be
removed in future?

但這對於解決FLask-SQLAlchemy中的問題好像仍是沒有幫助?是否是我理解錯了?若是session.commit發生異常,session.remove這樣仍是不會執行?
後面看了https://github.com/pallets/fl...中的代碼,優化了application context從棧中pop的邏輯,此次的代碼提交確保了tear_down回調處理髮生異常時不會致使application context沒法從棧中彈出而影響後續請求。這下大體明白了。Flask-SQLAlchemy中的db.session依賴於Application Context,因此若是此次Flask能確保不管如何最後會正確彈出application context,那麼db.session也隨之銷燬了,那就不存在後續的影響了。可是,最後這句話我也不敢保證,只能是猜測。

因此,言歸正傳,若是不用SQLALCHEMY_COMMIT_ON_TEARDOWN這一特性,那麼咱們怎麼確保每次自動提交session呢?
第一種:不是自動,全手動模式commit(),看討論仍是有不少人喜歡這種方式的,不過我討厭每次都調用commit()
第二種:在after_request中進行提交commit,在teardown_request進行remove
雖然說Flask已經修正不須要捕獲也能夠,可是爲了編碼的優雅(暫時找不到好點的詞),仍是在dbsession_clean中進行了異常捕獲。

@app.after_request
def after_clean(resp,*args,**kwargs):
    db.session.commit()
    return resp
@app.teardown_request
def dbsession_clean(exception=None):
    try:
        db.session.remove()
    finally:
        pass

第三種:使用自定義裝飾器

def route(app_or_sub,rule,**options):
    def decorator(f):
        @wraps(f)
        def decorated_view(*args,**kwargs):
            res=f(*args,**kwargs)
            db.session.commit()
            return res
        endpoint = options.pop('endpoint', None)
        app_or_sub.add_url_rule(rule, endpoint, decorated_view, **options)
        return decorated_view
    return decorator
相關文章
相關標籤/搜索