From cbf8d3b3047c94a2ff7fb197bbae92a402e0b58b Mon Sep 17 00:00:00 2001 From: Jeremy Kersten Date: Thu, 20 Aug 2020 12:30:44 +0000 Subject: [PATCH] [IMP] website: perf - cache public page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes odoo/odoo#56331 Signed-off-by: Jérémy Kersten (jke) --- addons/website/models/ir_http.py | 64 ++++++++++++++++++++-- addons/website/models/website_page.py | 13 +++++ addons/website/tests/test_performance.py | 2 + addons/website/views/website_views.xml | 2 + addons/website_sale/models/__init__.py | 1 + addons/website_sale/models/website_page.py | 14 +++++ 6 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 addons/website_sale/models/website_page.py diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index d29d367e5c2..53762ac85c3 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -4,6 +4,7 @@ import logging from lxml import etree import os import unittest +import time import pytz import werkzeug @@ -18,6 +19,7 @@ from odoo import registry, SUPERUSER_ID from odoo.http import request from odoo.tools.safe_eval import safe_eval from odoo.osv.expression import FALSE_DOMAIN +from odoo.tools.lru import LRU from odoo.addons.http_routing.models.ir_http import ModelConverter, _guess_mimetype from odoo.addons.portal.controllers.portal import _build_url_w_params @@ -68,6 +70,7 @@ class Http(models.AbstractModel): @classmethod def clear_caches(cls): super(Http, cls)._clear_routing_map() + cls.clear_cached_pages() return super(Http, cls).clear_caches() @classmethod @@ -139,15 +142,23 @@ class Http(models.AbstractModel): @classmethod def _register_website_track(cls, response): - if getattr(response, 'status_code', 0) != 200 or not hasattr(response, 'qcontext'): + if getattr(response, 'status_code', 0) != 200: return False - main_object = response.qcontext.get('main_object') - website_page = getattr(main_object, '_name', False) == 'website.page' and main_object - template = response.qcontext.get('response_template') + + template = False + if hasattr(response, 'qcontext'): # classic response + main_object = response.qcontext.get('main_object') + website_page = getattr(main_object, '_name', False) == 'website.page' and main_object + template = response.qcontext.get('response_template') + elif hasattr(response, '_cached_page'): + website_page, template = response._cached_page, response._cached_template + view = template and request.env['website'].get_template(template) if view and view.track: request.env['website.visitor']._handle_webpage_dispatch(response, website_page) + return False + @classmethod def _dispatch(cls): """ @@ -232,6 +243,10 @@ class Http(models.AbstractModel): installed = request.registry._init_modules | set(odoo.conf.server_wide_modules) return mods + [mod for mod in installed if mod.startswith('website')] + @classmethod + def clear_cached_pages(cls): + cls._cached_pages = LRU(1024) + @classmethod def _serve_page(cls): req_page = request.httprequest.path @@ -240,15 +255,54 @@ class Http(models.AbstractModel): published_domain = page_domain # specific page first page = request.env['website.page'].sudo().search(published_domain, order='website_id asc', limit=1) + if page: # prefetch all menus (it will prefetch website.page too) request.website.menu_id + if page and (request.website.is_publisher() or page.is_visible): + need_to_cache = False + cache_key = page._get_cache_key(request) + if ( + page.cache_time # cache > 0 + and request.httprequest.method == "GET" + and request.env.user._is_public() # only cache for unlogged user + and 'nocache' not in request.params # allow bypass cache / debug + and len(cache_key) and cache_key[-1] is not None # nocache via expr + ): + need_to_cache = True + if not hasattr(cls, '_cached_pages'): + cls.clear_cached_pages() # init LRU + + # use cached version + try: + r = cls._cached_pages[cache_key] + if r['time'] + page.cache_time > time.time(): + response = werkzeug.Response(r['content'], mimetype=r['contenttype']) + response._cached_template = r['template'] + response._cached_page = page + return response + else: + cls._cached_pages[cache_key] + except KeyError: + pass + _, ext = os.path.splitext(req_page) - return request.render(page.view_id.id, { + response = request.render(page.view_id.id, { 'deletable': True, 'main_object': page, }, mimetype=_guess_mimetype(ext)) + + if need_to_cache and response.status_code == 200: + r = response.render() + cls._cached_pages[cache_key] = { + 'content': r, + 'contenttype': response.headers['Content-Type'], + 'time': time.time(), + 'template': getattr(response, 'qcontext', {}).get('response_template') + } + + return response return False @classmethod diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index 32c0cfa249f..96eed5a06ee 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -3,6 +3,7 @@ from odoo.addons.http_routing.models.ir_http import slugify from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval class Page(models.Model): @@ -21,6 +22,9 @@ class Page(models.Model): is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage') is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible') + cache_time = fields.Integer(default=3600, help='Time to cache the page. (0 = no cache)') + cache_key_expr = fields.Char(help='Expression (tuple) to evaluate the cached key. \nE.g.: "(request.params.get("currency"), )"') + # Page options header_overlay = fields.Boolean() header_color = fields.Char() @@ -202,3 +206,12 @@ class Page(models.Model): def get_website_meta(self): self.ensure_one() return self.view_id.get_website_meta() + + def _get_cache_key(self, req): + # Always call me with super() AT THE END to have cache_key_expr appended as last element + # It is the only way for end user to not use cache via expr. + # E.g (None if 'token' in request.params else 1,) will bypass cache_time + cache_key = (req.website.id, req.lang, req.httprequest.path) + if self.cache_key_expr: # e.g. (request.session.geoip.get('country_code'),) + cache_key += safe_eval(self.cache_key_expr, {'request': req}) + return cache_key diff --git a/addons/website/tests/test_performance.py b/addons/website/tests/test_performance.py index c6701f8aaf8..466167b436f 100644 --- a/addons/website/tests/test_performance.py +++ b/addons/website/tests/test_performance.py @@ -19,6 +19,8 @@ EXTRA_REQUEST = 5 class UtilPerf(HttpCase): def _get_url_hot_query(self, url): + url += ('?' not in url and '?' or '') + '&nocache' + # ensure worker is in hot state self.url_open(url) self.url_open(url) diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index e6ad38088ae..b9e9de95c71 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -127,6 +127,8 @@ + +