Flask的url_for重定向问题和相应源码分析
在使用Nginx作为反向代理服务器,反代Flask应用时,url_for重定向老是出问题,先是找不到端口,然后又是将https重定向到了http。一番周折后虽然在网上找到了解决方法,但问题到底出在哪里我始终不太明白,这次索性点开了源码来研究了一下。
url_for函数源码分析
定位到flask的helper.py文件,先大概看一下url_for()这个函数的代码。
def url_for(endpoint, **values):
"""Generates a URL to the given endpoint with the method provided.
url_for函数的作用是:通过给定的endpoint端点和额外参数,结合请求上下文和应用上下文,生成一个url地址并返回。
其中的额外参数有:
- _external: 是否使用绝对路径
- _scheme:使用http还是https,设置这个参数时,必须使_external=True
- _anchor:锚点,可以定位到html中的某一个位置
- _method:http方法,post、get等
appctx = _app_ctx_stack.top
reqctx = _request_ctx_stack.top
if appctx is None:
raise RuntimeError('Attempted to generate a URL without the '
'application context being pushed. This has to be '
'executed when application context is available.')
进入函数以后,首先获取当前应用上下文和请求上下文。如果没有获取到应用上下文则直接抛出错误,因为flask不能脱离应用上下文处理请求。
# If request specific information is available we have some extra
# features that support "relative" URLs.
if reqctx is not None:
url_adapter = reqctx.url_adapter
blueprint_name = request.blueprint
if not reqctx.request._is_old_module:
if endpoint[:1] == '.':
if blueprint_name is not None:
endpoint = blueprint_name + endpoint
else:
endpoint = endpoint[1:]
else:
# TODO: get rid of this deprecated functionality in 1.0
if '.' not in endpoint:
if blueprint_name is not None:
endpoint = blueprint_name + '.' + endpoint
elif endpoint.startswith('.'):
endpoint = endpoint[1:]
external = values.pop('_external', False)
当请求上下文存在时,使用请求上下文的url_adapter成员变量。url_adapter中包含了构建url的关键信息,这里先略过,稍后再回来详细看这个变量。然后根据enpoint是否携带blueprint信息,分别处理了blueprint相关的工作。blueprint和访问端点以.
分割:blueprint.endpoint
,如果以.
开头,则使用默认blueprint。
# Otherwise go with the url adapter from the appctx and make
# the URLs external by default.
else:
url_adapter = appctx.url_adapter
if url_adapter is None:
raise RuntimeError('Application was not able to create a URL '
'adapter for request independent URL generation. '
'You might be able to fix this by setting '
'the SERVER_NAME config variable.')
external = values.pop('_external', True)
如果请求上下文不存在,那就直接使用应用上下文中的url_adapter成员变量。
anchor = values.pop('_anchor', None)
method = values.pop('_method', None)
scheme = values.pop('_scheme', None)
appctx.app.inject_url_defaults(endpoint, values)
提取已知参数,并认为未知参数由用户在应用中自定义了处理方法,inject_url_defaults
便是把剩下的未知参数交给用户自定义方法。
old_scheme = None
if scheme is not None:
if not external:
raise ValueError('When specifying _scheme, _external must be True')
old_scheme = url_adapter.url_scheme
url_adapter.url_scheme = scheme
如果设置了scheme参数,则将url_adapter中的url_scheme替换为scheme参数值。并将url_adapter中的scheme保存起来,待生成url后还原给url_adapter。注意如果设置了scheme而没有设置_external绝对路径,则直接抛出错误,因为使用相对路径不需要关心是http还是https。
try:
try:
rv = url_adapter.build(endpoint, values, method=method,
force_external=external)
finally:
if old_scheme is not None:
url_adapter.url_scheme = old_scheme
except BuildError as error:
# We need to inject the values again so that the app callback can
# deal with that sort of stuff.
values['_external'] = external
values['_anchor'] = anchor
values['_method'] = method
return appctx.app.handle_url_build_error(error, endpoint, values)
准备工作差不多了,调用url_adapter的build方法生成url。关于build方法,看看官方注释给的例子:
>>> m = Map([
... Rule('/', endpoint='index'),
... Rule('/downloads/', endpoint='downloads/index'),
... Rule('/downloads/<int:id>', endpoint='downloads/show')
... ])
>>> urls = m.bind("example.com", "/")
>>> urls.build("index", {})
'/'
>>> urls.build("downloads/show", {'id': 42})
'/downloads/42'
>>> urls.build("downloads/show", {'id': 42}, force_external=True)
'http://example.com/downloads/42'
Map是enpoint端点和相应url的映射表,这个表也是url_adapter的成员变量,build方法根据映射表和其他参数,生成url。
回到url_for的源码,如果build生成url成功,就清理现场,将old_scheme还给url_adapter。如果生成失败,就保留现场,让错误处理相关方法来解决。
if anchor is not None:
rv += '#' + url_quote(anchor)
return rv
最后,给url添加锚点,并返回。
了解了url_for重定向的大概流程,可以发现生成url的关键信息都放在上下文的url_adapter成员变量中。
这是进入url_for函数刚获取了应用和请求上下文后的变量状态。从这里就可以很清晰的看到请求上下文中的url_adapter。其中包括了:
- default_method: 默认http方法
- map: url和端点映射
- path_info: 请求路径
- query_args:请求变量
- script_name:脚本名,用来定位相对于server_name的位置
- server_name:服务器地址
- subdomain:子域名
- url_scheme:https还是http
有了这些信息,在加上url_for传入的参数,就可以唯一确定一个url了。然而,这些信息又是从哪里得到的呢?
定位到flask的app.py中的create_url_adapter函数。
def create_url_adapter(self, request):
if request is not None:
return self.url_map.bind_to_environ(request.environ,
server_name=self.config['SERVER_NAME'])
# We need at the very least the server name to be set for this
# to work.
if self.config['SERVER_NAME'] is not None:
return self.url_map.bind(
self.config['SERVER_NAME'],
script_name=self.config['APPLICATION_ROOT'] or '/',
url_scheme=self.config['PREFERRED_URL_SCHEME'])
如果请求上下文存在,就把请求的environ变量和自定义的SERVER_NAME参数传入bind_to_environ方法,从environ变量中获取需要的信息。如果请求不存在,也就没有了请求environ变量,那么就直接从配置中提取需要的信息。关于environ变量,参照wsgi协议中的定义。
继续定位到werkzeug的routing.py文件中的bind_to_environ方法。
environ = _get_environ(environ)
if 'HTTP_HOST' in environ:
wsgi_server_name = environ['HTTP_HOST']
if environ['wsgi.url_scheme'] == 'http' \
and wsgi_server_name.endswith(':80'):
wsgi_server_name = wsgi_server_name[:-3]
elif environ['wsgi.url_scheme'] == 'https' \
and wsgi_server_name.endswith(':443'):
wsgi_server_name = wsgi_server_name[:-4]
else:
wsgi_server_name = environ['SERVER_NAME']
if (environ['wsgi.url_scheme'], environ['SERVER_PORT']) not \
in (('https', '443'), ('http', '80')):
wsgi_server_name += ':' + environ['SERVER_PORT']
从environ变量中获取获取scheme、server_name、port等信息。这段代码和wsgi协议中的URL Reconstruction代码非常相似。werkzeug本身也是基于wsgi的底层框架,所以一路追踪到这里也算是到底层了。
直接跳到函数末尾
script_name = _get_wsgi_string('SCRIPT_NAME')
path_info = _get_wsgi_string('PATH_INFO')
query_args = _get_wsgi_string('QUERY_STRING')
return Map.bind(self, server_name, script_name,
subdomain, environ['wsgi.url_scheme'],
environ['REQUEST_METHOD'], path_info,
query_args=query_args)
这里传递了之前url_adapter所需的大多数变量,再通过Map类映射出url和端点关系,就构成了url_adapter。
溯源到这里,可以下结论了。*url_for函数通过传入的参数和wsgi协议中规定的各种environ变量的参数,最终构成了要跳转的url*。
url_for重定向问题分析
正常情况下url_for重定向肯定是没有问题的,但是用上nginx作为反向代理服务器后,就有可能出现问题了。
客户端的请求到达nginx后,nginx再转发给web后端。这时的请求是经过nginx修改后的,对应上述的environ变量可能会发生变换。
问题1:端口丢失
web_server监听5000端口,nginx监听8000端口,nginx配置如下:
server {
listen 8000;
server_name _; # 外部地址
location / {
proxy_pass http://127.0.0.1:5000;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
注意这里的proxy_set_header Host $host;
。$host不带端口信息, Nginx只能将原始请求的服务器地址(ip或者域名)传递给wsgi服务器,而不包含端口信息。服务器从environ变量中获取到的server_name也就不包含端口信息。$http_host带端口信息,所以需要改为proxy_set_header Host $http_host;
。
问题2:scheme丢失
通常在Nginx处添加CA证书,让应用使用https。这时候wsgi服务器与Nginx使用http,而Nginx和客户端使用https。这种情况下在nginx配置里添加proxy_set_header X-Forwarded-Proto $scheme;
将scheme存放在X-Forwarded-Proto
字段,传递给wsgi服务器。但是我们的url_for方法并不会去检查environ中的X-Forwarded-Proto
,而把scheme设置为wsgi与Nginx通信的http。这就使得重定向时会从https变换到http。
解决办法可以是在url_for的参数中添加external=True
和_scheme=https
,但是这要修改每一个使用url_for函数的地方。
另一个更推荐的方法是运用wsgi中间件:
class ReverseProxied(object):
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
scheme = environ.get('HTTP_X_FORWARDED_PROTO')
if scheme:
environ['wsgi.url_scheme'] = scheme
return self.app(environ, start_response)
app = Flask(__name__)
app.wsgi_app = ReverseProxied(app.wsgi_app)
在中间件中检查environ变量中的HTTP_X_FORWARDED_PROTO
,并将它的值赋给wsgi.url_scheme
。
关于wsgi中间件,参考wsgi文档
总结
Flask的作者在相关问题的issues里说:
The WSGI middleware is how you are supposed to fix it. This is not something Flask does.
开始并不理解,阅读源码后才明白,重定向问题是由于请求上下文中url_adapter没有获得正确的变量值,而进一步的,是由于wsgi没有处理好environ变量,将错误的值传递给了Flask。所以wsgi中间件才是需要的解决方案。
首先,我喜欢这个文章,尽管我还没来得及测试文中的方法是否能解决我的问题,但我看到博客的列表中近期没有新的文章,很有些担心,如此高质量的博客可以不写没用的文章,但不能关掉。。。鼓励一下博主。此博客已经收藏到我的收藏夹里。哎,能找到真正记录技术探索的文章不容易了。
太强了,成功帮我解决了重定向后https变http的问题