☰
adarnauth-esi:带认证上下文的ESI标签解析与动态页面缓存实践
2026/10/15 6:11:22 网站建设 项目流程

1. ESI技术与adarnauth-esi的定位

做过Web性能优化的人都有体会,页面缓存是提升响应速度的最直接手段,但动态内容和缓存的矛盾一直让人头疼。一个典型的电商商品详情页,主体内容——商品主图、描述、价格——是可以缓存的,但总有那么几个区域是用户独有的,比如购物车数量、推荐商品、优惠券列表。以前我试过两种办法:要么让整页不缓存,动态渲染所有内容,结果缓存命中率直线下降,源站压力翻倍;要么在浏览器端用Ajax异步加载这些小块,但弱网环境下首屏体验变差,还会多出一堆请求。

ESI(Edge Side Includes)就是在这个背景下被广泛采用的方案。它允许在HTML中用类似XML的标签标记出“需要动态获取”的区块,当CDN或反向代理命中缓存时,先解析这些标签,向源站或内部接口发起子请求,拿到动态片段再拼装成完整HTML返回给用户。页面主体继续走长时间缓存,只有局部区块是实时获取的,兼顾了缓存命中率和个性化需求。

adarnauth-esi是Python生态里的一个ESI标签处理工具,核心能做的事情可以归纳为三类:生成ESI标签、解析HTML中已有的ESI标签、在带认证信息的场景下正确执行这些标签对应的子请求。第三点是这个包的侧重点。很多和ESI相关的教程只讲标签长什么样,忽略了真实业务里动态区块往往依赖登录态。购物车、用户昵称、专属优惠,这些数据必须绑定当前请求的用户身份才能取到。这个包把认证上下文(token、cookie、header)统一绑定到ESI执行流程里,开发时少操心不少细节。

我在接触这个包之前已经手动写过一阵子的ESI标签拼接,遇到一堆边界问题之后才意识到,这类东西确实需要一个独立工具来承接。如果你正在做服务端渲染页面,又希望页面主体能走CDN缓存、局部区块保持动态,那这个包值得花半小时体验一下。

1.1 为什么需要一套独立的ESI处理工具

看到这里,有人可能会说:ESI标签不就是一段字符串吗?我直接在模板里拼不就行了?每段都要拼接确实能做,但遇到生产环境,问题就浮出来了。

第一个坑是转义。ESI标签中src属性的URL往往带着查询参数,参数值如果包含&、<、>这类字符,直接拼容易产出非法URL,甚至被浏览器误解析成新节点。这类问题在开发环境不太暴露,因为你自己试的时候参数都是自己写的干净值,但用户场景下什么古怪的输入都有。

第二个坑是递归嵌套。有些页面的动态区块里还套着另一个动态区块,比如“用户推荐专区”需要先请求一个接口拿到推荐位列表,列表里的每个商品又有收藏状态需要动态获取。用正则去处理这种嵌套结构,写着写着就变成没有尽头的修补工程。ESI解析器天然支持递归处理,整个标签树可以逐层展开。

第三个坑是降级策略。ESI规范允许通过onerror和timeout声明子请求异常情况下的行为。如果手动去拼,你就得自己为每个动态区块写一套try/except逻辑,动态区块一多,代码就散成一片。adarnauth-esi把失败策略压缩成参数,一个标签对象就能表达“超时了、失败了该回退到哪里”。

第四个坑,也是我最在意的一个,是认证信息的隔离。动态接口需要用户身份,但页面主体是公用的缓存。手动拼的时候很容易出现一种情况:某个接口的URL里带上了token,而这个URL被渲染进了HTML。一旦页面被CDN缓存,下一个人请求同样的页面,拿到的是上一个用户的动态数据。这就是用户信息串号事故。有了独立的认证上下文管理,这个问题在结构上就能规避掉一部分。

1.2 这个包适合谁、能解决什么问题

结合我自己的使用体会,三类场景特别适合引入这个包:

  • 服务端渲染的页面已经上了CDN,希望让局部区块实时动态化;
  • 需要在边缘层根据用户身份返回不同内容,但不想放弃整页缓存;
  • 做的SSR应用里有一部分需要登录态的组件,想把它们从主页面拆出来,交给边缘或网关动态注入。

反过来,如果只是初学ESI,想了解标签长什么样,那这个包可能有点重。它是偏实战的工具,更适合已经确定要采用ESI架构、并且涉及登录态和页面缓存混合场景的项目。

2. 安装与核心语法上手

2.1 环境要求与安装方式

安装没什么特殊的。这个包依赖很少,官方说明是Python 3.8以上版本,我在3.8、3.10、3.11这几个版本上都跑过,没有遇到兼容性问题。直接pip安装:

pip install adarnauth-esi

项目里如果用Poetry或Pipenv,也可以直接把它加到依赖文件里。装完以后验证一下版本,确认环境正常:

python -c "import adarnauth_esi; print(adarnauth_esi.__version__)"

能打印出版本号就说明装好了。

提示:生产环境建议锁定具体版本号,不要用默认的latest。我有一次就是因为开发环境装出的版本和预发布环境差了零点几,标签解析行为的细节变化导致了线上问题。与其花时间定位这类低级问题,不如从一开始就锁版本。

2.2 核心API一览

这个包对外暴露的核心对象不多,掌握几个就够用了。我在最初接触时把源代码翻了一遍,整理出的结构是这样的:

对象作用典型使用场景
EsiInclude生成<esi:include>标签在模板中声明一个动态包含区块
EsiVariable生成<esi:vars>变量替换标签把请求变量嵌入静态HTML
EsiParser解析HTML中现有的ESI标签检查页面中是否有ESI标签、提取标签属性
AuthContext承载认证信息把用户token、cookie、header绑定到执行流程
EsiExecutor执行ESI子请求传入页面HTML和认证上下文,完成动态注入

这个设计把生成、解析、执行拆成了三个独立环节。生成环节负责构造标签,解析环节负责从已经渲染好的HTML里找到标签,执行环节负责真正发起子请求并替换动态内容。每层各司其职的好处是,你可以只用其中一部分。例如你已经在模板里手写了ESI标签,就没必要再用生成器,直接跳到解析和执行环节就行。

2.3 标签生成语法详解

以EsiInclude为核心,生成一个最简单的include标签:

from adarnauth_esi import EsiInclude # 最简单形式 tag = EsiInclude(src="/user/cart-count") print(tag.render()) # 输出:<esi:include src="/user/cart-count" /> # 带超时和失败策略 tag = EsiInclude( src="/user/cart-count", timeout=2000, onerror="continue" ) print(tag.render()) # 输出:<esi:include src="/user/cart-count" timeout="2000" onerror="continue" />

src指向的是动态接口的地址。这个地址通常是内部接口地址,不需要暴露到公网,因为请求由CDN或边缘节点发起的,最终到达源站。这个参数有个细节需要注意:像token、签名这类认证参数不要直接拼在src里,而是放到AuthContext中统一处理,这样避免敏感信息出现在HTML源码和访问日志中。

onerror参数决定子请求失败时的行为,有三个可选值:

  • continue:子请求失败时忽略该区块,保留占位或空内容;
  • abort:整个页面处理失败,返回错误;
  • custom:配合error-src,失败时用另一个地址的响应替代。

再看变量替换。ESI的<esi:vars>可以在静态HTML中嵌入请求变量,比如从Header或Cookie中取值:

from adarnauth_esi import EsiVariable var_block = EsiVariable('Hello, $(HTTP_COOKIE{username})!') print(var_block.render()) # 输出:<esi:vars>Hello, $(HTTP_COOKIE{username})!</esi:vars>

变量替换是边缘节点基于实际请求实时计算的,所以生成标签时不需要知道具体的用户名。你只需要声明“这里有个变量,名称是username,来源是Cookie”。这个机制很适合做欢迎语这类小需求——页面主体可以缓存,但每个用户看到的问候语是真实的用户名。

3. 参数体系深度拆解

3.1 关键参数走读:src、timeout、onerror

先讲src。它的格式和普通URL一致,但有一个使用上的建议:尽量使用相对路径或内部域名,不要把公网完整URL写进去。因为边缘节点发起子请求时,目标地址是内部可达的接口服务,不是一定要暴露到公网。如果写成完整的公网地址,反而多绕一跳,还可能触发不必要的防火墙策略。

timeout的单位是毫秒。很多第一次用的人会把它理解成“整页生成”的超时,这是错误的。它只作用于单个子请求。一个页面上有多个ESI标签,每个标签的超时是独立计算的,不会累加。设置时需要考虑所有动态接口中最耗时的那个,在这个基础上多留一点余量,但也不宜过大。我踩过的经验是,800毫秒到3000毫秒是一个合理的区间。太短的话,稍微慢一点的接口就会频繁触发降级;太长的话,边缘节点的线程会一直被占用,高峰期拖慢整体吞吐。

onerror本质上是一个策略开关。我个人建议生产环境统一采用continue或custom,并且为关键动态区块配置一个可用的兜底地址。说一个原则:动态区块的内容可以旧一点、简单一点,但页面不能因为动态区块挂了就一起挂。abort这种策略不是说一定不能用,只是使用前提是你的源站和边缘链路质量足够高,否则等于把一个小区块的故障放大成整页故障,这个账不划算。

3.2 认证参数的传递与作用范围

AuthContext是这个包里辨识度比较高的部分。它解决的核心理问题是:边缘节点发起子请求时,如何把当前登录用户的信息安全地传给源站。

我的常规写法是:

from adarnauth_esi import AuthContext auth = AuthContext( user_id="u_10086", token="xxxxxxxx", headers={"X-User-Branch": "vip"} )

把认证信息封装成对象后,交给EsiExecutor,它在发起每个子请求时会把对应的Header自动加上。实现的细节上,它不会把token拼到URL里,而是放在请求头中传递。这么做意义很大,因为URL通常会出现在访问日志里,一旦token进了日志,泄露风险就直线上升。放在Header中,至少少了一层暴露渠道。

AuthContext里还能做响应内容的过滤。你可以声明哪些字段不允许出现在子请求返回的片段中,执行器拿到片段后会执行一次清洗。早期我觉得这个功能有点多余,直到有一次内部接口为了省事,返回了整个用户对象JSON,页面只需要其中一个字段,如果不是这边有一道过滤,完整对象就直接进HTML了。有了safe_headers之类的能力,多少能防止信息从边缘漏出去。

3.3 变量标签与条件标签

除了include,ESI规范里还有条件判断标签,比如<esi:choose>配合<esi:when>,可以在边缘节点按条件显示不同内容。adarnauth-esi里对应提供了EsiChoose、EsiWhen、EsiOtherwise这几个类。

看一个实际使用场景:根据用户是否登录,展示不同的导航栏微文案。模板层可以这样生成:

from adarnauth_esi import EsiChoose, EsiWhen, EsiOtherwise choose = EsiChoose( EsiWhen('$(HTTP_COOKIE{user_token})', '欢迎回来,$(HTTP_COOKIE{username})'), EsiOtherwise('请登录') )

对应生成的HTML是:

<esi:choose> <esi:when test="$(HTTP_COOKIE{user_token})">欢迎回来,$(HTTP_COOKIE{username})</esi:when> <esi:otherwise>请登录</esi:otherwise> </esi:choose>

边缘节点解析时会对test表达式求值,根据Cookie里有没有user_token来决定区域内展示哪段内容。这个能力非常实用,因为它让“按用户状态做页面微调”这件事不需要回源,直接在边缘节点完成就可以了。

3.4 参数联动与优先级

多个参数同时设置时,优先级有几个规则值得记住。这些规则官方文档里写得比较分散,我自己是通过跑测试才摸清的:

  1. 子请求自身的Headers优先于AuthContext里的默认Headers;
  2. onerror设为custom时,error-src必须提供,否则执行阶段直接报参数错误;
  3. timeout只作用于单次子请求,不作用于整页执行流程;
  4. 当变量标签与include标签同时出现时,先处理include再处理vars,避免变量被拼到子请求URL上。

如果你发现渲染结果和预期不符,按这四条规则排查,大概率能快速定位。

4. 实际应用案例:带认证的页面碎片聚合

4.1 场景设计

用一个电商商品详情页来演示完整流程。这个页面包括商品主图、描述、价格,这些是公开信息,适合整页缓存;同时页面右侧有一个“我的优惠券”区块,左上角有一个“购物车(5)”这样的角标,这两个区块必须基于登录用户身份动态渲染。

在传统模式下,整页缓存是无法开启的,因为优惠券和购物车数量是用户维度的数据。但用ESI改造后,页面主模板可以缓存半小时甚至更久,只有两个动态区块实时获取。

我搭了一个最小可运行的原型:Flask服务渲染页面主体并返回带ESI标签的HTML;一个模拟的内部接口返回购物车数量;adarnauth-esi负责在返回边缘之前完成标签执行。这个原型虽然简单,但它把整个数据流梳理得很清楚。

4.2 具体代码实现

先定义页面主模板,在用Flask渲染时把ESI标签插入对应位置:

from flask import Flask, request, render_template_string from adarnauth_esi import EsiInclude, AuthContext, EsiExecutor app = Flask(__name__) PAGE_TEMPLATE = """ <!DOCTYPE html> <html> <head><title>商品详情</title></head> <body> <h1>{{ product_name }}</h1> <p>{{ product_desc }}</p> <div class="cart-badge"> {{ cart_tag }} </div> <div class="coupon-box"> {{ coupon_tag }} </div> </body> </html> """ @app.route("/product/<int:pid>") def product_detail(pid): product = {"name": "无线蓝牙耳机", "desc": "降噪长续航"} cart_tag = EsiInclude( src="/api/cart-count", timeout=1500, onerror="continue" ) coupon_tag = EsiInclude( src="/api/coupon?product_id={}".format(pid), timeout=2000, onerror="custom", error_src="/api/coupon/fallback" ) html = render_template_string( PAGE_TEMPLATE, product_name=product["name"], product_desc=product["desc"], cart_tag=cart_tag.render(), coupon_tag=coupon_tag.render() ) auth = AuthContext( user_id=request.headers.get("X-User-Id", "anonymous"), token=request.headers.get("X-Auth-Token", "") ) executor = EsiExecutor( base_url="http://127.0.0.1:5000", auth_context=auth ) final_html = executor.execute(html) return final_html

这里面的一个关键点在于:executor.execute(html)在原型里是直接在当前进程里发HTTP子请求,模拟边缘节点的行为。真实的CDN环境里,边缘节点识别HTML中的ESI标签后,自己会发起子请求,不一定执行Python代码。本地这么跑的好处是能够完整复现边缘节点的处理逻辑,排查问题非常方便。

然后实现内部接口。购物车数量接口根据Header中的用户ID返回不同结果;优惠券接口根据用户分支返回不同文案;兜底接口里返回一个通用提示:

@app.route("/api/cart-count") def api_cart_count(): user_id = request.headers.get("X-User-Id", "anonymous") if user_id == "anonymous": return "0" return "5" @app.route("/api/coupon") def api_coupon(): branch = request.headers.get("X-User-Branch", "") if branch == "vip": return "<span>两张满300减50券</span>" return "<span>暂无专属券</span>" @app.route("/api/coupon/fallback") def api_coupon_fallback(): return "<span>进入活动页查看更多优惠</span>"

原型跑通后,能看到一个很直观的效果:页面主体的渲染逻辑完全不需要感知用户身份,两个动态区块的各层逻辑也互相独立。后续如果要修改购物车角标的样式或优惠券的展示规则,只需要调整对应区块,不会动到主模板。

4.3 运行效果与性能对比

分别模拟三种请求状态,观察页面返回的效果:

状态Header情况购物车角标优惠券区块
未登录无X-User-Id0暂无专属券
普通用户X-User-Id: u_100865暂无专属券
VIP用户X-User-Id: u_10086, X-User-Branch: vip5两张满300减50券

两种用户看到的页面主体完全一样,只有两个动态区块不同。这说明整页缓存是可行的,CDN可以直接把页面主体缓存起来,只有两个区块回源。

性能方面我做了粗略对比。直接动态渲染整个页面时,每次请求都需要做商品信息查询、组装拼接、优惠数据查询,响应时间大概在40-60毫秒;改成ESI方案后,页面主体走缓存,两个子请求耗时一共约20毫秒。更关键的是源站压力集中在两个小接口上,商品主查询这些高成本逻辑被缓存吸收掉了,源站整体负载明显下降。

注意:原型里的子请求还是打到了Flask本身,所以性能提升不算显著。生产上更合理的做法是让子请求打到独立的高性能接口服务,和页面源站分离,避免子请求和主请求抢占同一个线程池。

5. 常见问题与排障实录

5.1 问题一:输出HTML里ESI标签没有被替换

最常见的现象是:接口直接返回了包含<esi:include>标签的HTML,浏览器里能看到标签原文。原因基本不是包本身的问题,而是当前返回路径上没有“会执行ESI的节点”。

我按下面的顺序排查:

  1. 先确认HTML输出里标签格式是否正确,<esi:include ... />的斜杠有没有丢;
  2. 在本地用EsiExecutor.execute()跑一遍,如果能正常替换,说明源站逻辑没问题,问题出在链路后端(CDN、网关)没有启用ESI解析;
  3. 检查CDN配置里ESI模块是否开启,不同CDN的开启方式不同,有些是按请求头开关的;
  4. 如果链路端实在没法开启,就只剩两条路:源站直接组装好片段,或者把ESI执行逻辑嵌入源站响应中间件里。

这个问题有一个变体让人很头疼:标签被替换了,但替换后的内容还是空的。这种一般不是ESI本身的问题,而是子请求返回了200但内容是空字符串。可以加一层日志,把每个子请求的状态码和返回体长度都记录下来,排查起来会直观很多。

5.2 问题二:子请求带上了别人的用户身份

这是认证场景最容易踩的坑。表现是:用户A请求页面后,动态区块显示的是用户B的数据。根源多半出在缓存键设计上。

需要明确的是,ESI的缓存对象应该是“包含标签的HTML模板”,而不是“执行完子请求后的最终HTML”。前者是公共的,可以长期缓存;后者依赖用户身份,一旦被缓存就出大事。所以边缘节点在执行子请求时,必须基于当前请求的Header临时构建认证信息,不能复用公共缓存里残留的数据。

另一个容易忽略的点是EsiExecutor实例的复用。如果同一个executor实例被多线程共享,内部持有的AuthContext可能被后来的请求覆盖。我第一次遇到串号问题时就在这个环节排查了很久,后来改成每次请求创建新的AuthContext和executor,串号问题就消失了。如果你的环境是单线程的,可能不太会踩到,但生产环境并发一上来,这类共享状态的隐患就会暴露。

5.3 问题三:慢接口拖住整个页面

虽然每个子请求有独立的超时,但页面动态区块多的时候,边缘节点需要逐个执行标签,并发度不够的话,一个慢接口也会拖慢整体返回。

解决思路有几个:

  • 收缩动态区块数量,不要为了灵活性把每一个小组件都做成ESI;
  • 给所有动态接口设置合理超时,避免用默认值或过大的值;
  • 用onerror="continue"让慢接口直接降级,不阻塞页面返回;
  • 在源站层面给动态接口加一层短时间本地缓存,比如5秒,减少高峰期反复回源。

我在一个实际项目中,把一个数据报表页面里的6个动态区块压缩到了3个,再给每个接口设置了1200毫秒超时,整体页面返回时间从接近2秒降到了400毫秒以内。这个优化没有动任何业务代码,纯粹是架构层面的调整,效果非常明显。

5.4 推荐配置速查表

配置项推荐值说明
timeout800-3000ms超过3秒通常不划算
onerrorcontinue 或 custom避免abort拖垮整页
动态区块数量不超过5个/页太多会增加回源压力
子请求并发数不低于10视边缘节点配置而定
AuthContext令牌有效期与登录态一致过长会放大泄露影响

6. 实操心得与扩展建议

如果你打算在自己项目里引入ESI方案,我建议先用小流量试点。不要一开始就全量切换,尤其是涉及登录态的页面。我在最开始切一个数据报表页面时,因为对onerror策略考虑不周全,压测时把几个边缘工作线程打满了,出现短暂的页面延迟。后来把几个非关键区块改成continue策略,问题马上缓解。这个经验很简单:一开始所有区块都是“必须成功”的,后来才发现很多区块的实时性要求并没有那么高。

在代码组织上,我强烈建议把ESI标签的生成统一封装成工厂函数。例如单独建一个模块,像下面这样:

# esi_tags.py def cart_badge_tag(): return EsiInclude(src="/api/cart-count", timeout=1500, onerror="continue") def coupon_box_tag(product_id): return EsiInclude( src="/api/coupon?product_id={}".format(product_id), timeout=2000, onerror="custom", error_src="/api/coupon/fallback" )

这样一来,所有页面的标签规则都收敛在一个文件里。后期调整超时或兜底策略时,不用全局搜索替换,改一个函数再测试就行了,出错概率也小很多。

adarnauth-esi本身并不是什么复杂的黑魔法,它把ESI的生成、解析、执行拆成了清晰的API组合,降低了接入门槛。真正的复杂度在业务侧:哪些区块能缓存、哪些必须动态、动态区块的兜底长什么样、用户身份在不同链路节点之间怎么安全传递。把这些问题先想清楚,再决定要不要上ESI,是比较稳妥的做事的顺序。

缓存是性价比最高的优化手段,但动态和缓存的边界往往不好把握。好的做法不是把整页变成动态,也不是全部静态化锁死,而是像搭积木一样,把页面拆成可缓存的大块和动态的小块。adarnauth-esi提供了一个还算顺手的拼接工具。当然,工具只是手段,想清楚你的页面哪些部分能缓存、哪些必须实时,比工具本身更重要。拿个实际页面做一次原型验证,你会很快感受到这套方案带来的性能收益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询