第十章 控制器

我们在第二部分已经学习过控制器的基础知识,现在我们将继续深入探讨控制器的更多细节知识,更详细地了解odoo是如何处理一个web请求的。从而为我们更为灵活地处理业务提供更多的可能性。因此,其实本章的内容已经超出了控制器的范畴,但是作为处理请求的核心部分,我们依旧使用控制器作为本章的标题。

引子

我们已经写了这么多的代码,关于控制器的代码中,想必大家一定对request不会陌生。那么,这里有一个问题要问一下大家:

  • request对象的本质是什么?

如果你不知道如何回答这个问题,那么请继续往下看。

request

要想回答这个问题,我们先来看一下request的定义:

_request_stack = werkzeug.local.LocalStack()
request = _request_stack()

由此可知,request是使用了werkzeug库的LocalStack对象(实际上是LocalStackProxy对象)。

关于LocalStack的更多知识, 网上有一篇文章写的很详细, 有兴趣的同学可以进行了解.

那么, httprequest等参数又是何时被赋值给request的呢? 在回答这个问题之前, 我们需要再认识另外一个对象-WebRequest

WebRequest

WebReqeust是odoo中所有Request的父类型,其作用是进行请求的初始化封装。我们在进行一个web请求的过程中肯定需要执行对应的数据库名称、当前的用户id等变量,这些都是在WebRequest中进行加载的。

 def __init__(self, httprequest):
    self.httprequest = httprequest
    self.httpresponse = None
    self.disable_db = False
    self.endpoint = None
    self.endpoint_arguments = None
    self.auth_method = None
    self._cr = None
    self._uid = None
    self._context = None
    self._env = None

    # prevents transaction commit, use when you catch an exception during handling
    self._failed = None

    # set db/uid trackers - they're cleaned up at the WSGI
    # dispatching phase in odoo.service.wsgi_server.application
    if self.db:
        threading.current_thread().dbname = self.db
    if self.session.uid:
        threading.current_thread().uid = self.session.uid

我们常用的request并非最原始的web请求,原始的web请求被封装在httprequest变量中。odoo的web服务器使用的werkzeug,这里httprequest就是werkzeug的请求对象。

cr

WebRequest的cr属性返回当前数据库的游标。如果当前尚未绑定数据库则会引发异常。

uid

uid返回当前请求对象的用户UID

env

返回请求的环境变量对象env。

lang

返回当前的上下文的语言设置。

csrf_token

我们在请求web也页面时,odoo会给我们返回一个防跨域请求的token值,每次请求都要带着这个放跨域的请求值才会被认为合法请求。那么csrf_token是如何生成的呢?

WebRequest的内部有一个crsf_token的方法,其代码如下:

def csrf_token(self, time_limit=3600):
    """ Generates and returns a CSRF token for the current session

    :param time_limit: the CSRF token should only be valid for the
                        specified duration (in second), by default 1h,
                        ``None`` for the token to be valid as long as the
                        current user's session is.
    :type time_limit: int | None
    :returns: ASCII token string
    """
    token = self.session.sid
    max_ts = '' if not time_limit else int(time.time() + time_limit)
    msg = '%s%s' % (token, max_ts)
    secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
    assert secret, "CSRF protection requires a configured database secret"
    hm = hmac.new(secret.encode('ascii'), msg.encode('utf-8'), hashlib.sha1).hexdigest()
    return '%so%s' % (hm, max_ts)

从上面的定义我们可以看出来csrf_token的生成机制:

  1. session的sid和超时时间max_ts组成待加密的字符串msg
  2. 数据库的密钥作为加密密钥secret
  3. 使用hmac的sha1算法以secret为密钥,msg为待加密字符进行加密
  4. 算出的16进制结果+o+超时时间即为crsf_token。

crsf_token的验证机制

前面讲到了csrf_token的生成机制,那么odoo又是如何对csrf_token进行验证的呢?

def validate_csrf(self, csrf):
    if not csrf:
        return False

    try:
        hm, _, max_ts = str(csrf).rpartition('o')
    except UnicodeEncodeError:
        return False

    if max_ts:
        try:
            if int(max_ts) < int(time.time()):
                return False
        except ValueError:
            return False

    token = self.session.sid

    msg = '%s%s' % (token, max_ts)
    secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
    assert secret, "CSRF protection requires a configured database secret"
    hm_expected = hmac.new(secret.encode('ascii'), msg.encode('utf-8'), hashlib.sha1).hexdigest()
    return consteq(hm, hm_expected)

验证的机制也很简单,首先验证请求中的token的时间是否小于当前时间,如果是,那么意味着该token已经失效,需要重新获取。否则将结果进行拆分,因为request.session.sid和secret都不会变,则重新计算结果跟传入的值是否一致即可。

关于csrf_token的生成方式:QWeb端已由系统处理,即默认的页面会自己带着csrf_token。Json请求的话,使用require(web.core).csrf_token获取即可。

上下文协议

def __enter__(self):
    _request_stack.push(self)
    return self

def __exit__(self, exc_type, exc_value, traceback):
    _request_stack.pop()

    if self._cr:
        try:
            if exc_type is None and not self._failed:
                self._cr.commit()
                if self.registry:
                    self.registry.signal_changes()
            elif self.registry:
                self.registry.reset_changes()
        finally:
            self._cr.close()
    # just to be sure no one tries to re-use the request
    self.disable_db = True
    self.uid = None

我们在WebRequest的内部发现了上下文协议,恰巧的是在这个上下文协议中,我们看到了LocalStack的入栈和出栈操作。

我们在发起请求的时候数据格式一般可以分成两类:普通HTTP请求和Json请求,WebRequest为了处理这两类请求,派生出了两个子类HttpRequest和JsonRequest。

HttpRequest

HttpRequest 用来处理http类型的请求,查询参数和表单参数,文件等都通过关键字参数形式传递给处理函数。

HttRequest的返回内容可以是可以被当作false的值,这种情况下,返回的状态码将会是204,也可以是werkzeug的返回对象,这个对象将被渲染诚HTML显示在页面上。

在原生的httprequest对象中,Query参数是args,form参数在form参数中,文件参数在files中,而在HttpRequest中这些参数都集合到了参数params中,params是个有序字典。

HttpRequest的核心方法:

def dispatch(self):
    if self._is_cors_preflight(request.endpoint):
        headers = {
            'Access-Control-Max-Age': 60 * 60 * 24,
            'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization'
        }
        return Response(status=200, headers=headers)

    if request.httprequest.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE') \
            and request.endpoint.routing.get('csrf', True): # csrf checked by default
        token = self.params.pop('csrf_token', None)
        if not self.validate_csrf(token):
            if token is not None:
                _logger.warning("CSRF validation failed on path '%s'",
                                request.httprequest.path)
            else:
                _logger.warning("""No CSRF validation token provided for path '%s'
                    ......
                """, request.httprequest.path)

            raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)')
        r = self._call_function(**self.params)
        if not r:
            r = Response(status=204)  # no content
        return r

其内部同样调用了WebRequest中的_call_function方法。

HttpRequest还有另外一个两个方法:

  • make_response: 生产响应内容。
  • render: 渲染QWeb。

make_response

make_response方法用来产生HTML响应内容或者非HTML响应内容。可以通过此方法定制响应头和cookies。

def make_response(self,data,headers=None, Cookies=None):
    pass
  • data: 响应体内容
  • headers: HTTP响应头
  • cookies: Cookies(Mapping)

render

render方法用来渲染QWeb模板。

def render(self, template, qcontext=None, lazy=True, **kw):
    pass
  • template: 要渲染的模板
  • qcontext: 渲染模板需要的上下文变量context
  • lazy: 是否延迟到最后一刻进行渲染
  • kw: 转发给werkzeug的响应对象

JsonRequest

JsonRequest用来处理jsonrpc 2.0的请求,一个典型的jsonrpc请求如下:

{
    "jsonrpc": "2.0",
    "method": "call",
    "params": {"context": {},
                "arg1": "val1" },
    "id": null}

返回值:

{
    "jsonrpc": "2.0",
    "result": { "res1": "val1" },
    "id": null}

如请求中包含错误,那么返回值是:

{
    "jsonrpc": "2.0",
    "error": {"code": 1,
                "message": "End user error message.",
                "data": {"code": "codestring",
                        "debug": "traceback" } },
    "id": null}

那么JsonRPC是如何调用后台的方法的呢?

实际上不论是HTTP请求还是JSONRPC请求,其内部都是调用了WebRequest的 _call_function方法。

_call_function方法会将请求中的model、method匹配到对应的模型和方法,然后将调用的结果回传给前台。

EndPoint

EndPoint对象指的是Web中的访问入口,其主要包含如下几个属性:

  • method: 方法
  • original: 源方法
  • routing: 路由
  • arguments: 参数
class EndPoint(object):
    def __init__(self, method, routing):
        self.method = method
        self.original = getattr(method, 'original_func', method)
        self.routing = routing
        self.arguments = {}

    @property
    def first_arg_is_req(self):
        # Backward for 7.0
        return getattr(self.method, '_first_arg_is_req', False)

    def __call__(self, *args, **kw):
        return self.method(*args, **kw)

从代码中可以得出,EndPoint调用即调用自身的method方法。

Response

Response是Controller返回给调用者的响应结果,odoo的Response对象是在werkzeug的Response对象上继承而来,添加了额外的用来渲染QWeb的参数。

初始化参数列表:

  • template: 模板
  • qcontext: 渲染模板需要的上下文
  • uid: 用来请求ir.ui.view的用户ID,不填则使用请求的uid

Session

odoo的Session机制是在werkzeug的Session基础上拓展而来的,默认的Session存储方式是使用文件存储。存储的路径可以通过配置文件的data_dir节点设置,不同的系统默认的路径不同,比如Ubuntu默认的存储文件路径在当前用户的主目录下的.local文件中:

~/.local/share/Odoo

配置文件中的data_dir不但指session的存储文件夹,还包括附件和第三方模块拓展包。如果你看过data_dir文件夹下的内容你就会发现,sessions文件夹里存储的是session文件,filestore文件夹内存储的是附件,addons是第三方模块。

默认情况下,Odoo的Session过期时间是一周。当一个请求过来时,Odoo会检查其携带的session_sid参数,如果 session_sid存在则将其对应的session返回,否则创建一个新的session并返回。

Odoo判断Session过期的原理是判断session的存储文件的最后更新时间与当前的时间差,如果超过session定义的时间(默认一周)则会将session文件删除。

另外Session对象提供了一个用来验证用户账号的方法:authenticate

def authenticate(self, db, login=None, password=None, uid=None):
    """
    Authenticate the current user with the given db, login and
    password. If successful, store the authentication parameters in the
    current session and request.

    :param uid: If not None, that user id will be used instead the login
                to authenticate the user.
    """

    if uid is None:
        wsgienv = request.httprequest.environ
        env = dict(
            base_location=request.httprequest.url_root.rstrip('/'),
            HTTP_HOST=wsgienv['HTTP_HOST'],
            REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
        )
        uid = odoo.registry(db)['res.users'].authenticate(db, login, password, env)
    else:
        security.check(db, uid, password)
    self.rotate = True
    self.db = db
    self.uid = uid
    self.login = login
    self.session_token = uid and security.compute_session_token(self, request.env)
    request.uid = uid
    request.disable_db = False

    if uid: self.get_context()
    return uid

其内部用使用了res.users对象的authenticate方法完成对用户账号密码的认证,如果用户合法,那么系统将把uid等参数附加到session中。

HTTP与RPC

从逻辑上,可以把请求http请求和rpc请求,http请求又根据数据类型分为httprequest和jsonrequest,rpc又可以分成xml-rpc和json-rpc两种。那么,odoo又是如何分别对这些请求进行处理的呢?

在此之前,我们这里需要了解一下odoo是如何启动监听服务的:

简单地说,odoo服务启动后,网关程序会实例化http.py文件中的root类对象:

def application_unproxied(environ, start_response):
    """ WSGI entry point."""
    # cleanup db/uid trackers - they're set at HTTP dispatch in
    # web.session.OpenERPSession.send() and at RPC dispatch in
    # odoo.service.web_services.objects_proxy.dispatch().
    # /!\ The cleanup cannot be done at the end of this `application`
    # method because werkzeug still produces relevant logging afterwards
    if hasattr(threading.current_thread(), 'uid'):
        del threading.current_thread().uid
    if hasattr(threading.current_thread(), 'dbname'):
        del threading.current_thread().dbname
    if hasattr(threading.current_thread(), 'url'):
        del threading.current_thread().url

    result = odoo.http.root(environ, start_response)
    if result is not None:
        return result

    # We never returned from the loop.
    return werkzeug.exceptions.NotFound("No handler found.\n")(environ, start_response)

而root的核心是dispatch方法:

def __call__(self, environ, start_response):
    """ Handle a WSGI request
    """
    if not self._loaded:
        self._loaded = True
        self.load_addons()
    return self.dispatch(environ, start_response)

这也就是说,我们所有的请求都要从root的dispatch方法内进行分发。那么,root的dispatch方法又是怎样处理请求的呢?

def dispatch(self, environ, start_response):
    """
    Performs the actual WSGI dispatching for the application.
    """
    try:
        httprequest = werkzeug.wrappers.Request(environ)
        httprequest.user_agent_class = UserAgent  # use vendored userAgent since it will be removed in 2.1
        httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableOrderedMultiDict

        current_thread = threading.current_thread()
        current_thread.url = httprequest.url
        current_thread.query_count = 0
        current_thread.query_time = 0
        current_thread.perf_t0 = time.time()

        explicit_session = self.setup_session(httprequest)
        self.setup_db(httprequest)
        self.setup_lang(httprequest)

        request = self.get_request(httprequest)

        def _dispatch_nodb():
            try:
                func, arguments = self.nodb_routing_map.bind_to_environ(request.httprequest.environ).match()
            except werkzeug.exceptions.HTTPException as e:
                return request._handle_exception(e)
            request.set_handler(func, arguments, "none")
            try:
                result = request.dispatch()
            except Exception as e:
                return request._handle_exception(e)
            return result

        request_manager = request
        if request.session.profile_session:
            request_manager = self.get_profiler_context_manager(request)

        with request_manager:
            db = request.session.db
            if db:
                try:
                    odoo.registry(db).check_signaling()
                    with odoo.tools.mute_logger('odoo.sql_db'):
                        ir_http = request.registry['ir.http']
                except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError):
                    # psycopg2 error or attribute error while constructing
                    # the registry. That means either
                    # - the database probably does not exists anymore
                    # - the database is corrupted
                    # - the database version doesn't match the server version
                    # Log the user out and fall back to nodb
                    request.session.logout()
                    if request.httprequest.path == '/web':
                        # Internal Server Error
                        raise
                    else:
                        # If requesting /web this will loop
                        result = _dispatch_nodb()
                else:
                    result = ir_http._dispatch()
            else:
                result = _dispatch_nodb()

            response = self.get_response(httprequest, result, explicit_session)
        return response(environ, start_response)

    except werkzeug.exceptions.HTTPException as e:
        return e(environ, start_response)

这段代码中有两点需要明确:

  1. 正确的请求将会被转发到ir.http对象进行处理。
  2. 我们一开始提到的request对象入栈操作也是在这里进行的(上下文协议)。

关于ir.http的作用,我们会在相关的章节中进行详细地介绍,这里我们只需要知道,ir.http对象,将root对象传递过来的request对象,进行了解析,并找到了与之适配的路由,然后触发了相应的响应动作。

ir.http在解析过程中会根据request的数据格式,调用httprequest或jsonrequest的回调方法dispatch,下面我们就来详细解析一下这两个回调方法。

httprequest的dispatch

def dispatch(self):
    if self._is_cors_preflight(request.endpoint):
        headers = {
            'Access-Control-Max-Age': 60 * 60 * 24,
            'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization'
        }
        return Response(status=200, headers=headers)

    if request.httprequest.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE') \
            and request.endpoint.routing.get('csrf', True): # csrf checked by default
        token = self.params.pop('csrf_token', None)
        if not self.validate_csrf(token):
            if token is not None:
                _logger.warning("CSRF validation failed on path '%s'",
                                request.httprequest.path)
            else:
                _logger.warning("""No CSRF validation token provided for path '%s'

Odoo URLs are CSRF-protected by default (when accessed with unsafe
HTTP methods). See
https://www.odoo.com/documentation/15.0/developer/reference/addons/http.html#csrf for
more details.

* if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF
token in the form, Tokens are available via `request.csrf_token()`
can be provided through a hidden input and must be POST-ed named
`csrf_token` e.g. in your form add:

    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>

* if the form is generated or posted in javascript, the token value is
available as `csrf_token` on `web.core` and as the `csrf_token`
value in the default js-qweb execution context

* if the form is accessed by an external third party (e.g. REST API
endpoint, payment gateway callback) you will need to disable CSRF
protection (and implement your own protection if necessary) by
passing the `csrf=False` parameter to the `route` decorator.
                """, request.httprequest.path)

            raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)')

    r = self._call_function(**self.params)
    if not r:
        r = Response(status=204)  # no content
    return r

我们从代码中可以看出,dispatch方法对修改动作默认做了crsf令牌验证。然后,执行找到的路由相应的方法,将响应结果返回。

jsonrequest

我们再来看一下jsnorequest的逻辑:

def dispatch(self):
    rpc_request_flag = rpc_request.isEnabledFor(logging.DEBUG)
    rpc_response_flag = rpc_response.isEnabledFor(logging.DEBUG)
    if rpc_request_flag or rpc_response_flag:
        endpoint = self.endpoint.method.__name__
        model = self.params.get('model')
        method = self.params.get('method')
        args = self.params.get('args', [])

        start_time = time.time()
        start_memory = 0
        if psutil:
            start_memory = memory_info(psutil.Process(os.getpid()))
        if rpc_request and rpc_response_flag:
            rpc_request.debug('%s: %s %s, %s',
                endpoint, model, method, pprint.pformat(args))

    result = self._call_function(**self.params)

    if rpc_request_flag or rpc_response_flag:
        end_time = time.time()
        end_memory = 0
        if psutil:
            end_memory = memory_info(psutil.Process(os.getpid()))
        logline = '%s: %s %s: time:%.3fs mem: %sk -> %sk (diff: %sk)' % (
            endpoint, model, method, end_time - start_time, start_memory / 1024, end_memory / 1024, (end_memory - start_memory)/1024)
        if rpc_response_flag:
            rpc_response.debug('%s, %s', logline, pprint.pformat(result))
        else:
            rpc_request.debug(logline)

    return self._json_response(result)

jsonrequest的dispatch也是我们提到过的jsonrpc的核心,我们从方法的定义上可以看出来,jsonrpc的基本请求参数包含model,method, args。关于jsonrpc的更多内容,可以参考rpc一章。

我们在了解完了request,WebRequest,JsonRequest和HttpRequest之后,那么我们现在就可以大概勾勒出odoo处理一个Http请求的全景逻辑来了。

当一个HTTP请求过来以后,首先由Root对象的对request进行请求进行初步的分析,然后分配一个全局的request的对象,然后将请求交给ir.http对象进行处理。ir.http内部对请求的路由和请求的数据格式进行分析,负责完成匹配路由的工作,然后将请求根据数据格式的不同交由不同的类型的Request对象进行处理。

HttpRequest在接到请求以后,根据请求的路由,调用不同的方法,获取到相应的响应内容,然后返回。JsonRequest在接到请求以后,根据请求的参数,调用不同的对象模型的方法,然后将方法的执行结果格式化为json格式,然后再返回给前端。

results matching ""

    No results matching ""