1. ESI技术与adarnauth-esi的定位做过Web性能优化的人都有体会页面缓存是提升响应速度的最直接手段但动态内容和缓存的矛盾一直让人头疼。一个典型的电商商品详情页主体内容——商品主图、描述、价格——是可以缓存的但总有那么几个区域是用户独有的比如购物车数量、推荐商品、优惠券列表。以前我试过两种办法要么让整页不缓存动态渲染所有内容结果缓存命中率直线下降源站压力翻倍要么在浏览器端用Ajax异步加载这些小块但弱网环境下首屏体验变差还会多出一堆请求。ESIEdge 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变量替换标签把请求变量嵌入静态HTMLEsiParser解析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, timeout2000, onerrorcontinue ) print(tag.render()) # 输出esi:include src/user/cart-count timeout2000 onerrorcontinue /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:varsHello, $(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_idu_10086, tokenxxxxxxxx, headers{X-User-Branch: vip} )把认证信息封装成对象后交给EsiExecutor它在发起每个子请求时会把对应的Header自动加上。实现的细节上它不会把token拼到URL里而是放在请求头中传递。这么做意义很大因为URL通常会出现在访问日志里一旦token进了日志泄露风险就直线上升。放在Header中至少少了一层暴露渠道。AuthContext里还能做响应内容的过滤。你可以声明哪些字段不允许出现在子请求返回的片段中执行器拿到片段后会执行一次清洗。早期我觉得这个功能有点多余直到有一次内部接口为了省事返回了整个用户对象JSON页面只需要其中一个字段如果不是这边有一道过滤完整对象就直接进HTML了。有了safe_headers之类的能力多少能防止信息从边缘漏出去。3.3 变量标签与条件标签除了includeESI规范里还有条件判断标签比如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 参数联动与优先级多个参数同时设置时优先级有几个规则值得记住。这些规则官方文档里写得比较分散我自己是通过跑测试才摸清的子请求自身的Headers优先于AuthContext里的默认Headersonerror设为custom时error-src必须提供否则执行阶段直接报参数错误timeout只作用于单次子请求不作用于整页执行流程当变量标签与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 headtitle商品详情/title/head body h1{{ product_name }}/h1 p{{ product_desc }}/p div classcart-badge {{ cart_tag }} /div div classcoupon-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, timeout1500, onerrorcontinue ) coupon_tag EsiInclude( src/api/coupon?product_id{}.format(pid), timeout2000, onerrorcustom, error_src/api/coupon/fallback ) html render_template_string( PAGE_TEMPLATE, product_nameproduct[name], product_descproduct[desc], cart_tagcart_tag.render(), coupon_tagcoupon_tag.render() ) auth AuthContext( user_idrequest.headers.get(X-User-Id, anonymous), tokenrequest.headers.get(X-Auth-Token, ) ) executor EsiExecutor( base_urlhttp://127.0.0.1:5000, auth_contextauth ) 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的节点”。我按下面的顺序排查先确认HTML输出里标签格式是否正确esi:include ... /的斜杠有没有丢在本地用EsiExecutor.execute()跑一遍如果能正常替换说明源站逻辑没问题问题出在链路后端CDN、网关没有启用ESI解析检查CDN配置里ESI模块是否开启不同CDN的开启方式不同有些是按请求头开关的如果链路端实在没法开启就只剩两条路源站直接组装好片段或者把ESI执行逻辑嵌入源站响应中间件里。这个问题有一个变体让人很头疼标签被替换了但替换后的内容还是空的。这种一般不是ESI本身的问题而是子请求返回了200但内容是空字符串。可以加一层日志把每个子请求的状态码和返回体长度都记录下来排查起来会直观很多。5.2 问题二子请求带上了别人的用户身份这是认证场景最容易踩的坑。表现是用户A请求页面后动态区块显示的是用户B的数据。根源多半出在缓存键设计上。需要明确的是ESI的缓存对象应该是“包含标签的HTML模板”而不是“执行完子请求后的最终HTML”。前者是公共的可以长期缓存后者依赖用户身份一旦被缓存就出大事。所以边缘节点在执行子请求时必须基于当前请求的Header临时构建认证信息不能复用公共缓存里残留的数据。另一个容易忽略的点是EsiExecutor实例的复用。如果同一个executor实例被多线程共享内部持有的AuthContext可能被后来的请求覆盖。我第一次遇到串号问题时就在这个环节排查了很久后来改成每次请求创建新的AuthContext和executor串号问题就消失了。如果你的环境是单线程的可能不太会踩到但生产环境并发一上来这类共享状态的隐患就会暴露。5.3 问题三慢接口拖住整个页面虽然每个子请求有独立的超时但页面动态区块多的时候边缘节点需要逐个执行标签并发度不够的话一个慢接口也会拖慢整体返回。解决思路有几个收缩动态区块数量不要为了灵活性把每一个小组件都做成ESI给所有动态接口设置合理超时避免用默认值或过大的值用onerrorcontinue让慢接口直接降级不阻塞页面返回在源站层面给动态接口加一层短时间本地缓存比如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, timeout1500, onerrorcontinue) def coupon_box_tag(product_id): return EsiInclude( src/api/coupon?product_id{}.format(product_id), timeout2000, onerrorcustom, error_src/api/coupon/fallback )这样一来所有页面的标签规则都收敛在一个文件里。后期调整超时或兜底策略时不用全局搜索替换改一个函数再测试就行了出错概率也小很多。adarnauth-esi本身并不是什么复杂的黑魔法它把ESI的生成、解析、执行拆成了清晰的API组合降低了接入门槛。真正的复杂度在业务侧哪些区块能缓存、哪些必须动态、动态区块的兜底长什么样、用户身份在不同链路节点之间怎么安全传递。把这些问题先想清楚再决定要不要上ESI是比较稳妥的做事的顺序。缓存是性价比最高的优化手段但动态和缓存的边界往往不好把握。好的做法不是把整页变成动态也不是全部静态化锁死而是像搭积木一样把页面拆成可缓存的大块和动态的小块。adarnauth-esi提供了一个还算顺手的拼接工具。当然工具只是手段想清楚你的页面哪些部分能缓存、哪些必须实时比工具本身更重要。拿个实际页面做一次原型验证你会很快感受到这套方案带来的性能收益。
阅读完成 · 觉得有帮助?