T
traeai
登录
返回首页
InfoQ

Presentation: Theme Systems at Scale: How To Build Highly Customizable Software

8.5Score
Presentation: Theme Systems at Scale: How To Build Highly Customizable Software

TL;DR · AI 摘要

Shopify通过Liquid主题系统实现了高度可定制化与高并发性能的平衡,采用安全DSL、原生代码扩展和健壮开发者工具,在BFCM高峰期支撑近600万请求/分钟。

核心要点

  • Liquid主题系统支持商户自定义商店外观,同时通过安全DSL防止恶意代码注入。
  • Shopify在BFCM高峰期处理近600万请求/分钟,依赖原生代码扩展提升渲染性能。
  • 开发者工具链(如实时预览、语法检查)显著降低定制化开发门槛,提升商户体验。

结构提纲

按章节快速跳转。

  1. 现代软件如操作系统和手机应用普遍具备可定制性,Shopify平台允许商户自定义商店前端,但面临高并发与安全性的双重挑战。

  2. ·Liquid主题系统架构

    Liquid是Shopify的核心模板语言,支持动态内容渲染和设计灵活性,同时通过安全机制保障执行环境。

  3. BFCM高峰期,Shopify每分钟处理近600万请求,通过原生代码扩展和缓存优化实现低延迟响应。

  4. Liquid作为安全DSL,限制危险操作并提供沙箱执行环境,防止用户注入恶意代码影响平台稳定性。

  5. Shopify提供实时预览、语法检查和调试工具,降低商户定制化开发门槛,提升开发效率。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Theme Systems at Scale
    • 核心挑战
      • 高度可定制化
      • 高并发性能
    • 技术方案
      • 安全DSL (Liquid)
      • 原生代码扩展
      • 开发者工具链
    • 实际案例
      • BFCM高峰期 6M req/min
      • 商户自定义商店

金句 / Highlights

值得收藏与分享的关键句。

  • 在BFCM高峰期,Shopify每分钟处理近600万请求,要求系统在高度定制化的同时保持极低延迟。

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Liquid主题系统通过安全DSL机制,限制危险操作,确保用户自定义内容不会破坏平台安全性。

    第 4 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 原生代码扩展被用于加速Liquid模板渲染,显著提升性能以应对大规模流量冲击。

    第 5 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#Shopify#Liquid#主题系统#DSL#可定制化平台
打开原文

[InfoQ 主页](https://www.infoq.com/ "InfoQ Homepage")[演示文稿](https://www.infoq.com/presentations "Presentations")大规模主题系统:如何构建高度可定制的软件

观看演示文稿

速度:

50:20

Image 1/presentations/liquid-theme-system-dsl/en/slides/Gui-1780312954156.jpg)

摘要

Shopify 高级工程师 Guilherme Carreiro 探讨了构建和扩展高度可定制平台的方法。以 Shopify 的 Liquid 主题系统为例,他解释了如何在极端设计灵活性与海量流量下的低延迟性能之间取得平衡。他还分享了实现安全领域特定语言、原生代码扩展以及健壮开发者工具的经验。

个人简介

Guilherme Carreiro 是 Shopify 的高级开发人员,致力于推动 Liquid 的演进。拥有超过 14 年的软件开发经验,他曾帮助构建奠定 Liquid 广泛采用基础的核心工具。加入 Shopify 之前,Guilherme 在 Red Hat 领导 DMN 工具团队,交付开源解决方案,使用户能够通过无代码方式构建决策模型。

关于会议

InfoQ Dev Summit 慕尼黑软件开发大会聚焦当今资深开发团队面临的重大软件挑战。您将从 20 多位资深软件开发者那里获得宝贵的实战技术洞察,与演讲嘉宾和同行建立联系,并享受社交活动。

INFOQ 活动

  • Image 2/filters:no_upscale()/sponsorship/eventsnotice/34b23b6d-548d-473c-9ac7-ec2f8ca684b1/resources/1GuardsquareWebinarJune11-Transcripts-1777545596301.png)2026年6月11日,上午10点(EDT)

#### 重新思考应用安全:为什么编译器级别的安全改变了架构对话

演讲者:Anton Baranenko - Guardsquare 产品经理

  • Image 3/filters:no_upscale()/sponsorship/eventsnotice/7dd71c7c-4b0e-4760-b97d-232ac1816637/resources/1NeuBirdWebinarJune25-Transcripts-1777458459989.png)2026年6月25日,下午1点(EDT)

#### 为自主可靠性架构:将 AI 嵌入可观测性栈中

演讲者:Justin Griffin - NeuBird AI 产品负责人

  • Image 4/filters:no_upscale()/sponsorship/eventsnotice/0b46c1f1-7263-457d-82d9-12be6fa07fbd/resources/1DatadogWebinarJuly9-Transcripts-1779204853394.png)2026年7月9日,中午12点(EDT)

#### 在 AI 分析时代重新思考日志

演讲者:Nicolas Jung - Datadog 日志产品经理

文稿

Guilherme Carreiro: 我非常兴奋今天能和大家探讨如何让我们的软件变得更加可定制。我们日常使用的很多软件其实已经具备高度可定制性,只是我们往往没有察觉,比如操作系统、手机、各类应用程序,其中都包含大量可定制功能。Shopify 也不例外。Shopify 是一个平台,我们的用户——商家,可以在上面管理和创建自己的商店。Shopify 负责处理一切,包括库存、物流,以及构建商店所需的所有功能,甚至包括店面展示。这正是一个 Shopify 商店的前端界面,也是我们今天要重点讨论的内容。您所看到的这个店面中的所有元素都是可定制的,它们基于我们统一的 Liquid 主题系统。正因为其高度可定制,每次打开不同的 Shopify 商店时,它们看起来都截然不同。然而,高可定制性并非唯一的挑战,第二个挑战是这些商店会承受海量请求。

在被称为 BFCM(黑色星期五和网络星期一)的销售高峰期,我们每分钟几乎收到 600 万次请求。商家们会进行最后一刻的更新,买家们则不断刷新页面以获取最佳优惠,平台必须对此做出快速响应。这基本概括了我们面临的挑战:我们需要构建一种在最终用户渲染时呈现完全不同的软件,同时它还必须具备良好的性能表现。这就是我们今天要学习的内容——如何实现这一点。此外,为了展示商家后台的工作原理,我将在这里做一个演示。这里是一个店面界面。中间这个条形区域看起来有点奇怪,所以我打算修改一下。我可以打开管理后台,点击这个部分,更改背景颜色。我还可以将图片向左移动,稍微放大一点尺寸。

也许我会在这里添加一个小小的评论组件。我只需在界面上点击一下,搜索评论,这里就有了一个评论应用,现在我们有了评论组件。我可以稍微简化一下,并增加星星数量,因为这是一件四星的T恤。我对这个自定义功能很满意。我只需保存它,然后刷新我的商店前端,它就已经显示在那里了。

除了允许开发者构建高度可定制的商店前端之外,这些功能也允许非技术人员进行定制。这就是我们将要探讨的内容——所有将一切连接在一起的架构组件。所有内容都基于 Liquid。Liquid 是我们的模板语言,但围绕 Liquid 还有许多其他内容,才使得这种体验成为可能。我们将逐一介绍这些架构元素。我本可以从多个角度来讲解,但我选择以时间顺序的方式展开。

首先,我们将了解这门语言是如何构建的,以及后来如何使其对非技术人员也可定制。接着,我们将了解如何在生产环境中高效运行它。最后,如何为这类语言构建工具。

个人简介

我叫 Guilherme Carreiro,是 Shopify 的高级工程师。我在 Liquid 开发工具团队工作,主要负责两个方向。一方面,我为编写 Liquid 模板的开发者构建工具,例如运行在他们本地机器上的 CLI,以及运行在 VS Code 扩展中的语言服务器。我开发的是那些更偏向本地运行的工具。另一方面,我也致力于推动语言本身的演进,让开发者能用更少的代码实现更多功能。这两个方向分别是:运行在开发者机器上的代码,以及运行在平台上的代码,用于支持新功能的实现。我的工作主要聚焦于主题(themes)。

主题,在 Shopify 中

如今,当我们谈论“主题”时,可以将其理解为一个包含两部分内容的包。第一部分是关于某些软件(在此例中是商店前端,但也可以是你的软件)的图形外观信息;第二部分是它所能实现的功能。主题不仅仅是“看起来怎么样”,还包括“能做什么”。当我们讨论主题系统时,实际上是在讨论一组基础构件,它们使不同角色的人能够协作,共同构建某种用户体验。这听起来有些主观,因此让我们深入探讨一下这个主题系统。

我们有一个角色是商家(merchant)。商家希望打造一个独特的店铺,我们需要帮助他们实现这一点。另一个角色是买家(buyer),他们只关心看到店铺,而不会参与构建过程。这是整个流程中涉及的两个非技术人员。

假设商家希望参与到店铺的构建过程中。中间则有主题开发者(theme developers),他们开发主题,并在主题商店中出售。然而,这些主题通常无法完全满足商家的需求。比如在之前的例子中,我们在构建商店前端时,需要一个评论组件,而这个功能并不包含在现有主题中。这就引出了另一类开发者——应用开发者(app developers),他们开发主题扩展(theme extensions)。这是参与该流程的另一类开发者。最后一类开发者是靠近商家的主题开发者,他们在代码的最后阶段进行微调,确保店铺最终呈现的效果符合商家期望。因此,我们需要记住三类开发者和两类非技术人员。这基本框定了我们面临的挑战。

接下来我们将大量讨论主题。为了澄清 Shopify 中“主题”的含义,它本质上就是一个包含若干文件的目录。其中一些文件是 Liquid 模板,另一些是 JSON 文件。今天我们将理解这些文件的重要性及其意义,因为当你开发自己的主题系统时,就能明白为什么这些抽象概念如此重要。就我们今天的讨论而言,它们只是目录中的普通文件。

这些文件会渲染出页面。为了分解页面的概念,大家都知道什么是 HTML 页面。这里我想引入几个关键概念。首先是布局(layout),即图中的绿色部分,也就是框架。它包含了页眉、头部元素中的脚本等。其次是区块(sections),这些是横向排列的元素,占据页面的全部宽度。我们还有称为“块”(blocks)的元素,它们是存在于区块内部的更细粒度的组件。这就是我们对主题中不同元素的命名方式。这真正框定了我们的挑战:我们需要构建一个平台,能够接受包含上述目录结构的 ZIP 文件,这些文件代表了主题的布局,并且该主题必须能被商家自定义。

语言

现在我们开始旅程的第一部分:语言。为什么我们需要一门语言来表达我们的主题?为什么还需要另一种 DSL(领域特定语言)?因为大多数编程语言本身已经自带模板语言。那 Liquid 是为何而生呢?下面就是简要总结。

我们希望允许开发者在页面的任意位置展示商品价格。我们不希望将价格展示限制在某个固定的、可自定义区域,而是希望提供更高的灵活性。当 Shopify 最初构建时,它是一个 Ruby 应用程序,当时使用的模板语言与其它语言中的模板语言非常相似,即 ERB 模板。这类模板语言的问题在于它们过于宽松,允许你在视图中嵌入任意代码。例如像下面这样的场景,在 ERB 模板中很难防止发生。

在这里,我们正在遍历数据库中的一些商品。然后,对于每个商品(例如一件T恤),我们会加载其变体(例如蓝色T恤、红色T恤)。当我们这样做时,就会遇到经典的“N+1查询”问题,这是不可取的。如果你在自己的公司开发自己的应用,这还好办——你可以制定一些开发规范,提醒开发者注意这个问题,从而避免它。但在我们的场景中,我们接受来自第三方开发者的模板,这就要求我们必须非常严格和聪明地进行防范。

此外,为了说明ERB模板有多么宽松,即使像下面这样的代码也能正常运行。这充分说明了ERB模板并不是解决方案。正因如此,Liquid才被创建出来,这就是它的初衷。

我们需要一种安全的方式来表达模板。Liquid的工作方式几乎就像一个白名单:模板只能做某些被允许的事情。你可以使用条件语句,可以执行循环,可以输出值,也可以像这里一样对数据进行转换——比如获取价格,再通过我们这里的money过滤器将其转换。它也非常安全,因为我们完全清楚向开发者暴露了哪些内容。关键在于,Liquid无法执行任何Ruby代码,无法访问现有资源,也无法直接访问数据库。它不能完成我们在ERB模板中能做的所有事情。最终,Liquid模板看起来是这样的,非常人性化。它最初设计时就希望既能被开发者更新,也能被没有深厚技术背景的设计人员轻松使用。这是一种非常友好的人类语言。

除了语言本身,Liquid还强制用户编写Liquid模板时遵循某些模式。如果你打算定义自己的DSL来表达主题,这一点也值得你牢记。其中最核心的部分就是Liquid的“drop”机制。这是什么意思呢?这里展示的是整个Liquid的公共API。顶部我们接收一个字符串(可以是一个文件),然后解析这个字符串并生成模板,这个模板就是我们所说的抽象语法树(AST)。接着,我们可以多次渲染这个AST,同时传入数据进行解析,最后输出结果。在解析数据时,我们通常解析一个哈希表(hashmap)。但除了哈希表,我们也可以解析一个实例,也就是我们所说的“drop”。

这个实例,比如这里的product drop,实际上是这段代码的一个实例。可能现场很多人没有Ruby背景,但从直观上讲,我们在这一层所做的其实就是缓存变量。例如,调用product.title可能代价很高,在这一层我们就可以提前保存这个值。这基本上就是drop层的作用。另一个作用是,我们通过drop层来定义希望向第三方开发者暴露的方法列表。如果某个商品方法计算量很大,我们就不对外暴露它。这就是drop的作用:它们保护我们所暴露的内容,同时也通过缓存层节省了资源。在DSL层面,我们还确保为用户提供一个安全的沙箱环境,用于渲染他们的模板。

我们是如何做到这一点的呢?通过限制模板在平台后端渲染时所能执行的操作数量。让我们验证一下它是如何工作的。这里有一个简单的Liquid模板,包含一个for循环,打印一些数字。在后端,我们设置了一个render_length_limit参数,这里设置为50,意味着最多允许渲染50个元素。在这个例子中,我解析的是一个从1到10的数字数组。如果尝试渲染这个模板,它会成功运行。但如果尝试渲染一个过大的模板,就会出现内存错误,因为我们明确设定了允许的模板大小上限。这是一种资源限制,我们在构建自己的DSL并接受外部模板时也需要考虑这一点。我们必须非常小心地控制提供给用户的计算能力,这样才能保证每个人都能在一个安全的环境中渲染模板,而不会影响其他人的性能。

另一种我们设置的资源限制是变量创建的数量。例如,这里Liquid模板中创建了两个变量。我们设定的变量分配上限是3。如果你渲染这个模板,它会正常工作。但如果你将变量分配上限降低到2,那么同样会触发内存错误。这对Liquid来说是有意义的。如果你正在创建一个不同的DSL来更好地表达你的模板语言意图,也许你的需求会有所不同。但对于Liquid,由于我们允许变量定义,这种资源限制非常合理。

还有一个需要牢记的重要点是:当你为主题构建自己的DSL时,必须考虑向后兼容性。向后兼容性是不可妥协的,因为如果我们构建一个平台,并打破了对开发者的承诺,那么今天他们构建的主题明天就可能无法运行。这会让开发者感到沮丧,进而停止为你的平台开发主题。此外,如果破坏了向后兼容性,商家今天安装的主题可能在黑色星期五期间突然失效。因此,向后兼容性实际上是一种契约,我们必须签署它。一旦签署,它就会影响所有人。这是我们在构建自己的DSL时必须牢记的另一个重要概念。接下来,我想分享一些实际案例。

这里我们有一个模板。我们正在将这个 temptemplate 进行比较,并打印该值。大多数人看到这个模板时可能会想,如果有人这样构建模板,页面上实际上会打印出 false。然而,默认情况下,Liquid 并不支持这种表达式,也不支持在打印值时进行比较。默认情况下,Liquid 会忽略它不支持的内容。因此,它不会打印 false,而是打印 temp——即解析器在处理模板时最后理解的那个值。让我们放大来看这个例子。这里我们有另一个类似的例子:我们取一个 Liquid 模板,也就是这里的简单字符串,然后对其进行解析和渲染。它之所以渲染出 hello,原因和渲染 temp 一样,因为 hello 是那里索引变量的值。

这听起来可能是个不错的主意。例如,我们不支持比较操作。也许我们可以这样想:与其像第二个例子那样打印错误,不如采用更严格的解析方式重新解析同一个模板,从而直接报错。在构建 DSL(领域特定语言)时,我们经常会面临这样的困境:是应该对错误宽容一些,直接渲染某些内容(因为在生产环境中,渲染内容可能比报错更好),还是从一开始就保持严格?请考虑向后兼容性。在生产环境中,最好遵循第二个例子中的方法,即在解析和解释你的 DSL 时保持严格,因为你总可以逐步扩展错误处理空间。

在第二个例子中,你可以随时决定开始支持模板中的比较操作,例如,然后你就可以逐步演进语言。而在第一个例子中,我们采取了更宽容的方法,实际上等于与开发者签订了一份合同:如果他们编写了一个无效的模板,那么合同规定你会打印比较左侧的值。这并不好,因为它极大地限制了语言未来的演进空间。当你构建自己的 DSL 时,请务必尽可能严格,因为你总可以扩展错误处理空间。一旦你与所有人“签定”了一份宽容的合同,日后要修改语言语法就会困难得多。

这正是多年来我们在 Liquid 上所面临的挑战:思考如何通过资源限制来保证 Playground 的正常运行,以及如何演进语言的语法。当你设计自己的主题系统时,也许你会想:“我真的需要发明一种 DSL 吗?我真的需要构建自己的编程语言吗?”你很可能会遇到类似的问题。如果你确实需要从头构建自己的模板语言,而且不确定从何入手,我强烈推荐这本书——《Crafting Interpreters》。它非常友好、有趣且富有启发性,阅读和实践都非常值得。这是开始编写自己语言的一个很好的方式,但并非总是我推荐的做法,不过它确实是一个不错的选择。

扩展

使用 Liquid,开发者可以编写自己的模板,他们可以将价格放在页面的任何位置。现在我们面临第二个挑战:如何让这个系统具有可扩展性,因为这必须能够被商家自定义。商家可能拥有这样的店面,并希望实现类似的效果。如今,如果商家想要实现这一点,他们需要打开一个 Liquid 模板并更新其中的代码。虽然代码本身是友好的,但对于非技术人员来说仍然不够友好。我们该如何让 Liquid 模板对非技术用户也具备可扩展性呢?

首先,我们需要让开发者、商家和平台之间使用相同的语言进行沟通。如果我们所有人都使用相同的术语来指代页面中的元素,那么大家就可以协作并共同构建东西。

这就是我们所做的。我们创建了“区块(section)”的概念。在这个模板中,我们有一个头部区块,一个产品详情区块,还有另一个区块。通过这种方式,无论是否具备技术背景,每个人都知道什么是“区块”。让我们放大查看这个区块,并检查一下如何构建、如何编写一些 Liquid 代码来定义当前所见的可定制内容。我们再仔细看看如何编写这个模板。

在图片顶部,我们有关于这个模板的详细信息。左侧我们渲染了一个代表图片的片段,右侧则是代表产品描述的内容。下方是 schema 标签,这是最重要的部分。我认为这是今天关于扩展代码时最重要的幻灯片。

在这个 schema 标签中,我们做了一些特别的事情:我们声明了一个名为 image_position 的属性,并说明该属性的名称是 image_position,它可以有两个值:leftright。我们以 JSON 格式声明了这些内容。为什么这样做?稍后我们会看到原因。关键在于,我们在这里定义了这个属性,并在其他地方使用它。我们知道 CSS 类可以根据 leftright 切换,从而改变元素在屏幕上的位置。因为我们使用 JSON 格式定义了这个 schema,所以我们的可视化编辑器能够理解 JSON。于是,当商家点击该位置时,可视化编辑器就能直接展示 rightleft 两个选项,让商家可以轻松地与该组件进行交互。

这是开发者与非技术人员之间建立桥梁的方式。开发者可以选择一组希望暴露的设置,通过 schema 标签进行定义。然后,可视化编辑器能够读取 JSON 并将这些属性呈现出来。现在,非技术人员可以使用这些属性来调整页面中元素的位置。通过这种方式,我们对页面每个元素是如何构建的有了更清晰的认识,并且这种构建方式是可定制的。

接下来,让我们回顾整个请求生命周期,以便理解主题的其他部分如何影响页面状态。这里有一张时间线图。当请求到达 Shopify 时,我们首先查看的是 URL。例如,这是一个名为“fun-food”的站点。由于我们知道 URL,因此可以确定访问的是哪个店铺以及应该渲染哪个主题。根据店铺和主题的信息,我们可以将该主题加载到内存中。

接下来,我们加载以 JSON 文件形式保存的全局设置。全局设置包括页面背景等信息。开发者可以像通过 schema 暴露特定 Liquid 区块的属性一样,利用这些 JSON 文件存储整个页面的全局状态。然而,这些 JSON 文件之所以能被暴露,是因为它们将背景等信息写入其中,从而让商家可以通过编辑器直观地更新这些设置,并自动保存到主题中。这是我们做的第一步:加载全局属性。在全局属性加载完成后,页面的基本框架就已设定好。

接着,我们会判断当前是否为产品页面。根据这个 URL,我们知道这是一个产品页面,而不是首页或其他类型页面。因此,我们知道将使用这个 JSON 文件来渲染页面。

该 JSON 文件包含了所有区块(section)的信息——那些占据页面全宽的元素都存储在这个文件中。然后我们利用这些信息将其渲染到页面中。你可以清楚地看到各个区块在页面中的渲染顺序。随后,我们开始渲染这些区块。在这个 JSON 文件中,我们还定义了页面中的各个模块(block)。此时,我们会将之前示例中看到的 image_position 替换为商家在编辑器中设置的实际值。最后,我们加载所有区块和模块,并赋予正确的值,页面也就完全加载完成。这就是当你打开一个 Shopify 店铺时,页面的完整生命周期。

回顾所有角色,我们似乎已经覆盖了所有人:买家可以看到页面,商家可以自定义页面的部分内容,开发者可以构建可定制的主题。但还有一个角色我们尚未提及,那就是应用开发者。他们如何参与到主题页面的构建过程中呢?再次提醒一下:应用开发者开发的是这类组件。我们可以像添加其他元素一样,将它添加到页面中。你只需搜索你的组件,它就会出现在页面上。这又是如何实现的?

也许你已经猜到了,这个组件本质上只是一个带有 schema 的 Liquid 模板。比如,如果你正在开发一个折扣组件,你会编写一段 HTML 来显示折扣金额,同时暴露出“折扣额度”这一属性,供非技术人员自定义,并在模板中使用该属性。最终,应用开发者所做的就是编写这段 Liquid 代码片段,然后上传至应用商店,供商家购买并安装到他们的主题之上。这样,整个请求生命周期就基本完成了。

在生产环境中运行

我们拥有让开发者可以随意放置价格的语言支持,也具备让非技术人员通过编辑器自定义组件的技术。那么,如何在生产环境中快速运行这些功能呢?因为黑色星期五即将到来,届时将面临海量请求,我们该如何扩展系统?简单介绍一下 Shopify 的运行时基础设施:Shopify 基于 Google Cloud 运行,并采用多租户架构。这意味着我们拥有一组具有独立数据库的应用程序,并使用店铺 ID 作为分片键(sharding key)。这正是我们扩展数据库的方式,而数据库扩展通常是应用扩展中最困难的部分。对于数据库以外的其他组件,由于它们是无状态的,因此更容易扩展。我们使用 Kubernetes 自动伸缩器,根据流量动态扩展应用程序实例。以上是对整体工作原理的高度概括。

由于数据库是最难扩展的部分,我们采取了一种主动数据复制(active data replication)策略来保护主数据库。这里有两个关键流程:商家进入后台管理界面,进行店铺管理操作,例如保存新的 Liquid 模板或更改图片位置(从左到右),并将这些变更持久化。这些操作会经过核心服务(core),由核心服务执行读写操作,核心服务连接的是我们的主数据库。而在买家侧(即最难以扩展的部分),我们依赖主动数据复制机制,并依靠这些复制数据库确保页面渲染速度极快。我们有一个名为“storefront renderer”的组件,其主要职责就是快速渲染 Liquid 模板。为了实现这一点,我们采用了专门处理数据复制的策略。

另一种我们采用的方法是使用原生扩展(native extensions),以确保模板渲染速度非常快。如果你自己构建 DSL,或者使用他人编写的 DSL,你很可能也会采用原生扩展。这是什么?我们都是程序员,经常编写代码。由于频繁编程,多年来我们采用了各种工具来帮助自己。例如,当你创建一个字符串时,不需要在内存中手动清理它——因此我们采用了这些高级语言,它们拥有虚拟机(VM),这些 VM 在内存中有特定位置,并且具备垃圾回收机制。我们享受着高级语言带来的所有优势:它们非常高效、生产力高,这很好,因为它让我们能够编写大量代码并获得运行时环境的全面支持。

然而,某些挑战并不适合这些高级语言处理,这时我们就引入原生扩展。这类代码用低级语言编写,比如 C++、C 或 Rust,然后我们用低级语言解决部分问题。之后,我们可以使用高级语言调用这些原生扩展。这种模式在很多语言中都存在。在 Shopify,我们通常使用 Ruby 作为高级语言,Rust 作为低级语言;许多 Java 应用程序采用 Java 原生扩展,Python 则有 C 扩展。这是一种常见的做法,用于解决那些相对独立且计算密集型的问题。例如,很多语言之所以用原生扩展实现 JSON 解析器,正是出于这个原因。如果你自己编写 DSL,在生产环境中渲染 DSL 时,为了确保其快速渲染,这也是值得考虑的做法。

原生扩展有哪些好处?由于它是低级别的,不会影响垃圾回收器(GC)。有些算法在运行时会创建大量对象实例,从而导致垃圾回收器出现异常行为——换句话说,应用程序需要暂停以清理引用,这显然不是好事。如果你有一个算法会生成大量实例,并且在性能测试中发现它对垃圾回收器造成了显著负面影响,那么这很可能是一个很好的候选,将其逻辑提取到原生扩展中,并由你自己管理内存。当然,这既是优势也是风险,因为你可能会造成内存泄漏,因此非常危险。这一点必须牢记。垃圾回收器是一方面,另一方面,原生扩展还带来了性能提升。原生代码运行更快,尽管编写起来更困难,但执行速度更快。

使用原生扩展的另一个原因是可重用性。例如,如果你已经有一个用 C 编写的优秀 JSON 解析器,为什么不能直接复用它,而不是重新用高级语言重写一遍呢?可重用性是一个合理的选择。为了更清楚地说明实际工作原理,这里做一个简要概述。当我们创建高级应用(如 Ruby 应用)时,Ruby 虚拟机会在那里运行。它执行我们的 Ruby 脚本,运行垃圾回收器,完成大量工作以确保运行时环境健康稳定。这就是常规流程。然而,当我们使用原生扩展时,一旦启动程序并创建进程,我们会将某种 .so 文件链接到进程中,实现动态链接。

什么是 .so 文件?这是一个你编译生成的文件。也许你用 C 或 Rust 编写了原生代码,然后将其编译成 .so 文件(即共享对象文件)。当你启动 Ruby 程序时,会动态链接这个文件。由于该文件是按照特定规范(通常是 C 规范)编译的,其中定义的函数可以从高级语言中访问,因此我们可以在同一进程中从高级语言调用原生代码。这里的调用成本非常低,因为所有操作都在同一个进程中进行。不过,仍然存在一点开销:当你在高级语言(如 Ruby、Python 或其他语言)中创建字符串时,该字符串会带有某些内置特性。

我再谈谈垃圾回收器。该字符串由 GC 管理,因此你知道它的内存最终会被释放。但在你的原生代码中创建字符串时,你需要自己管理内存,且该字符串仅存在于原生扩展的上下文中。由于数据类型之间存在这种差异,每次从高级语言调用低级语言时,都会产生序列化和反序列化的开销。通常情况下,这种开销会被性能提升所抵消,但有时并不会。因此,当我谈论原生扩展时,总是喜欢展示下面这个例子。

这里我们有三段代码:一段完全用原生语言编写,一段完全用高级语言(Ruby)编写,另一段则在做某种计算操作。假设这部分计算是业务的核心,那么我会将其提取为原生扩展,因为这样运行得更快,应用性能也会更好。假设我已经将这部分提取到低级语言(此处为 Rust)中。接着,我可能过于兴奋,于是把整个逻辑都迁移到了 Rust 中。现在我们有了同一段代码的两个版本,分别使用不同层次的原生扩展。观察这三个版本,很明显,完全用原生语言编写的版本最快,因为所有操作都在原生语言中执行,因此速度最快。

然而,有趣的是,第二种选项实际上是速度最慢的。尽管我们使用了原生扩展,但由于我们没有聪明地处理这个问题,导致高级代码和低级代码之间频繁来回通信,序列化和反序列化的开销变得显著,最终性能反而比直接使用高级语言还要差。每次当你考虑“我想将这部分代码提取出来,使用原生扩展”时,非常重要的一点是,要牢记你将执行这些调用的频率。而基准测试(benchmark)是确保这种投入物有所值的最佳方式。所有你所付出的努力,都是为了采用原生代码。

工具链

再次强调,我们有语言,可以将价格放在任何位置;我们有模式(schemas),可以让商家自定义代码。现在我们对原生扩展有了更多了解,也知道如何让主题中的 DSL 在生产环境中快速渲染。假设开发者开始为你的应用构建主题,但他们的主题质量不佳——这时工具链就至关重要了。我们开发的工具必须激励开发者写出尽可能优秀的代码。这正是 Shopify 需要解决的问题。

我们构建的第一个工具之一就是性能分析器(profiler)。我们在后端渲染模板,如果不对开发者提供分析工具,他们就无法优化首次字节时间(time to first byte),因为他们根本不知道后端发生了什么。我们替他们渲染模板,那他们怎么知道哪些 Liquid 模板部分最慢呢?于是,我创建了这个命令 shopify theme profile,开发者可以在本地运行它,获取每个模板部分渲染所需时间的性能数据。这激励开发者编写能更快响应的代码,因为他们现在拥有了可见性,清楚知道哪些部分可以优化。

另一个激励开发者编写更好代码的工具是 theme check,即我们的代码检查器(linter)。theme check 能帮助识别一些有趣的区域,比如这里有一个解析块脚本(parsing block script),当开发者编写模板时,我们会在这里显示波浪线,并提示:“此处存在解析块脚本,你可以使用 defer 或 async 来优化模板。”

这是我们与开发者建立的一个沟通渠道,鼓励他们编写更优质的 Liquid 模板。我们在 linter 中还引入了一些检查项,例如:在模板中使用了一个名为 market 的对象,但该对象并不存在。此时工具可以提醒开发者:“此对象不存在。”否则,开发者只能上传模板到平台后才发现问题,这样效率太低。另外,还需要记住一点:必须让开发者更容易编写模板。如今,人们在编写模板时几乎都期望获得代码补全支持。因此,我们也需要构建一个语言服务器(language server),这也是你在设计 DSL 时需要考虑的重要因素。

当你发明一门语言时,不仅仅是发明语言本身,还需要围绕它构建配套工具。当人们使用你的 DSL 编写代码时,他们应该享受这个过程。这是我们的语言服务器演示。如你所见,我们知道这些属性是可用的,开发者可以轻松查阅相关文档。过去,他们必须猜测属性名或查阅文档,这对编写模板来说体验非常糟糕。

为了确保你能围绕自己的 DSL 构建工具链,我们需要另一个关键组件:容错解析器(tolerant parser)。什么是容错解析器?这里是一个 Liquid 模板,如你所见,其中存在错误:有一个向前循环(forward loop)未闭合。尽管模板中有错误,我们仍能成功解析它,推断出产品类型,并知道哪些属性可用。再次强调,当你发明一门语言时,不仅需要一个用于后端的优秀解析器,还需要一个专门用于工具链、能够容忍错误模板的解析器。因为当开发者在 VS Code 中编写代码时,他们的代码始终处于未完成状态,总是会出错,而工具链必须能够解析这些不完整的模板并理解其结构。

有些语言只使用一个解析器,同时用于后端、DSL 处理和工具链。这种解析器速度快且具备错误容忍能力。对于 Liquid 这种模板语言,我们有一个专为生产环境优化、渲染速度极快的解析器,但它仅了解 Liquid 语法。而对于工具链,我们则使用另一个解析器实现,它不仅理解 Liquid,还能解析 HTML。因此,当我们发现用户遗漏关闭 HTML 标签时,我们也能向开发者提供提示:“请关闭该标签。”

最后,我给你的一个建议是:如果你需要发明一门新语言,Ohm 这个库是一个绝佳选择,用于构建工具链。你无需手动实现解析器,它通过语法文件(grammar file)来表达语言规则,并自动生成解析器。它的速度很快,最近版本甚至更快了。这是一个快速构建解析器的优秀工具,之后你就可以基于它开发各种工具。这是一个 TypeScript 库,意味着你可以用 TypeScript 来构建工具链,非常适合开发 VS Code 扩展。

我们这里还有一个上下文:这是一个 Liquid 模板,作为提醒,我们有一个包含 JSON 内容的 schema 标签。正如你可能猜到的那样,我们还需要一个健壮的 JSON 解析器,以便在用户编写 JSON 逻辑时提供有用的建议。我们并没有从零开始实现一个 JSON 解析器来为用户提供这些建议,因为微软已经实现了 JSON 语言服务器。

我们只需要实例化这个语言服务器,就能确保我们的扩展为 JSON 文件提供代码补全功能。在实例化时,我们使用 JSON Schema 来表达预期的 JSON 文件结构和规则。借助 JSON 语言服务器和 JSON Schema(例如定义什么是有效的 JSON),我们已经免费获得了自动建议功能。这对我来说也是一个经验教训:因为我们发明了 Liquid,所以我们需要自己构建一套完整的工具链;但 JSON 是已有的标准,我们可以直接复用已为 JSON 构建好的工具。

总结

这是对创建自定义主题系统所需考虑的所有要素的一个简要概述。也许你需要考虑一种语言,但必须始终将非技术人员纳入考量范围。它需要渲染得非常快。如果你正在发明一种新语言,那么你就需要一些工具来帮助开发者以你期望的方式编写主题,从而保证平台上主题的质量。这就是四个关键点。

如果我必须在这四个要点中选出最喜爱的一个,如果今天我要从头实现一个主题系统,我相信这一项最重要——因为它能邀请非技术人员表达他们对主题的期望。我会审视我的应用程序,并思考:“如何才能让非技术人员轻松更新应用的这部分?”然后从那里开始演进。也许你需要一种语言,接着就需要完成我提到的所有工作,以确保所发明的语言能在生产环境中快速渲染。你还需要实现一些工具支持。如果可以采用一种已存在的语言,那么你可以在该决策上节省大量成本。

问答环节

参与者1: 你提到希望选择一种能在客户端和服务器端运行的语言。显而易见的选择应该是 JavaScript。为什么没有选它,反而选择了 Ruby?

Guilherme Carreiro: 你的问题是为什么我们选择了 Ruby 作为编写应用的语言?

参与者1: 是指编写运行时。我认为模板是通过某些 Ruby 代码定义的。Ruby 可以在服务器端和客户端运行。我原本以为会选择 JavaScript,这样模板就可以在服务器和客户端都运行。

Guilherme Carreiro: Shopify 过去出于多种原因被构建为一个 Ruby 应用。我们之所以用 TypeScript 实现工具链,是因为通常 VS Code 扩展都是用 TypeScript 编写的,因此我们在解析器方面做出了不同的技术决策。

参与者2: 关于主题性能分析,你提到作为开发者,我们已经知道哪些代码运行最快、哪些最慢。那为什么我们不把这些信息暴露给非技术人员?为什么他们必须本地运行才能自行检查?

Guilherme Carreiro: 目前,我们只向开发者暴露这些信息,因为他们更有能力采取行动进行修复。通常,后端渲染缓慢或首字节时间较长的原因是存在嵌套的 for 循环。有时这对我们来说很明显,比如看到嵌套循环,就知道应该移除它。有时则是因为模板嵌套(如一个文件渲染另一个文件),这种嵌套关系被隐藏起来,导致问题不易察觉。这类问题开发者有责任去解决。而对于非技术人员的商家侧,他们无法采取任何修复措施。我们目前会透明地向非技术人员分享 Core Web Vitals 数据,让他们知道自己的模板渲染速度,但他们无法了解具体哪一部分耗时更长,因为他们对此无能为力。目前我们并未暴露这部分细节。

参与者2: 他们不需要深入到细节层面,但整体上,我们能否共享整个主题的信息,比如哪个主题最慢、哪个加载最快?

Guilherme Carreiro: 正是如此,他们确实知道这些信息,因为我们已经公开了 Core Web Vitals 数据。例如,当商家将某个主题安装到店铺时,如果渲染较慢,他们会知道“我的首字节时间很差”,但他们不知道具体是哪个部分导致的慢。他们需要开发者的帮助,运行命令并调试才能获取更多信息。我完全同意,在某些情境下,根据你采用的主题系统和语言,也许有必要将这些信息暴露给非技术人员。但在我们当前的场景中,这样做并不合适,而且这也取决于未来几年的发展方向。目前我们尚未提供此类信息。

参与者3: 我看到了你展示的图表,其中显示了从商家数据库到店铺数据库的数据复制,渲染后的文本被复制过去。你说的是“部分复制”。你是说主数据复制到他们的数据库,第一个操作是同步的,其余复制是异步的?还是别的意思?

Guilherme Carreiro: 是的,复制过程确实是异步发生的,但写入操作本身是同步的。

参与者3: 在这个商户规模的数据库中,但所有的复制都是异步进行的,对吗?

Guilherme Carreiro: 是的。因为我们采用了这种多租户应用架构,所以我们需要记住,同步操作只发生在特定数据库的独立隔离单元内部。由于商店是根据商店ID重新分片到独立实例上的,因此这一点需要特别留意。

查看更多[带字幕的演讲](https://www.infoq.com/transcripts/presentations/)

录制于:

Image 5

2026年6月1日

AI 可能会生成不准确的信息,请核实重要内容