12

最近在开发mdwiki的时候遇到这样一个问题.Post is unbond to session.
我就好奇了

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找到可疑点

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源码,还好这货只有两个文件,比较少。
有这么一段:

        # 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模块就解决问题了

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...

然后在官网看到这样一段:

Consider SQLALCHEMY_COMMIT_ON_TEARDOWN harmful and remove from docs.

什么?考虑移除这一特性?
刚刚知道这么方便的特性,准备用来着,就要被移除?更何况源码中也没有提示要移除啊。
其实是这样的,作者准备在3.0版本移除SQLALCHEMY_COMMIT_ON_TEARDOWN这一特性,目前自2.1以后从文档中移除了相关介绍。

为什么?接下来总结下大神们的探讨。

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

xbynet
1k 声望124 粉丝

不雨花犹落,无风絮自飞