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中间件才是需要的解决方案。

Comments
Write a Comment
  • 首先,我喜欢这个文章,尽管我还没来得及测试文中的方法是否能解决我的问题,但我看到博客的列表中近期没有新的文章,很有些担心,如此高质量的博客可以不写没用的文章,但不能关掉。。。鼓励一下博主。此博客已经收藏到我的收藏夹里。哎,能找到真正记录技术探索的文章不容易了。