国际化网站的架构实践:从CMS改造到Lambda@Edge的全球部署

本文详细介绍了 upGrad 公司为支持其网站 upgrad.com 的国际化所进行的技术改造。内容涵盖了内容管理系统(CMS)的改造、基于Nuxt和i18n模块的客户端路由处理,以及利用AWS Lambda@Edge实现基于用户位置的自动重定向,构建了一套完整的、可扩展的国际化技术栈。

几个月前,upGrad 决定向国际市场扩张,因此需要在其各个面向客户的技术产品中支持这一扩张。首个需要做出重大改变的是课程列表网站——upgrad.com。在这篇文章中,我将讨论我们如何规划并实施这项改造,以及我们在这个过程中学到的东西。

对于国际化的第一阶段,平台需要达成的目标是:内部团队应该能够在不同国家独立地推广 upGrad 的课程,即每个国家显示的内容可以不同,并且应该能够单独维护。除此之外,当访问者尝试打开 upgrad.com 网站上的任何页面时,应自动重定向到他们所在国家/地区的特定 URL。这是许多网站上的常见行为,它们通过附加在基础 URL 后的特定代码来区分内容。

一个可能不那么明显但额外的要求是:在整个访问者会话期间都要保持这个附加的代码。如果有人访问 upgrad.com/us,那么所有后续的路由处理都应在初始位置代码的上下文中进行。例如,如果查看者点击一个超链接或按钮,导致路由到 /data-science-pgd-iiitb,那么最终的 URL 应该是 upgrad.com/us/data-science-pgd-iiitb。

总而言之,我们需要解决三个问题:

  1. 支持在数据库中保存特定地区的页面并允许分别编辑。
  2. 处理客户端路由。
  3. 根据用户位置自动重定向到这些特定页面。

更新 CMS 以处理国际化页面

我们有一个内部的内容管理系统(CMS),名为 Apollo,用于管理 upgrad.com 上的内容。“页面"可以描述为数据库中的文档,它们映射到一个唯一的 slug。以前,只可能使用单级 slug,它们直接映射到 upgrad.com 上的 URL。例如,data-science-pgd-iiitb 映射到 upgrad.com/data-science-pgd-iiitb。为了允许页面拥有像 upgrad.com/us/data-science-pgd-iiitb 这样的两级 URL,我们在页面文档中引入了一个名为 i18n 的新属性。这是一个对象,它保存 locale 的值,locale 是一个分配给每个国家的唯一两位数代码。locale 是良好的国家文本标识符,可以直接映射到 URL。

在创建页面时,编辑者可以指定他们希望为哪个国家创建页面,创建后该值将附加到页面文档中。我们将以前用于页面的 slug 标识符分解为两部分,即 namei18n.locale

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 旧
{
  "slug": "data-science-pgd-iiitb",
  ...
}

// 新
{
  "slug": "us/data-science-pgd-iiitb",
  "name": "data-science-pgd-iiitb",
  "i18n": {
    "locale": "us"
  }
}

name 属性包含页面所属项目更具逻辑性的值。这可以用于后续将同一名称下的所有页面分组。如上面的代码片段所述,slug 属性现在包含了文档所代表的完整 URL,即,如果请求美国地区的 /data-science-pgd-iiitb 页面,我可以期望从上述文档中获取数据。然而,页面数据加载逻辑并不那么直接,我稍后会谈到这一点。

你可能会问,为什么我们要在 nameslugi18n.locale 中维护重复的信息。答案很简单——为了简化各种获取场景。在这个项目中,我们希望将破坏性更改保持在最低限度,并在可能的情况下提供回退机制。我们内部称为 Apollo 的 CMS 以前仅由 Web 客户端使用。但在引入 upGrad 的移动应用程序后,它扩展到了 Android 和 iOS 移动客户端。因此,保持 CMS API 端点和响应模式不变至关重要。

回退机制

为了能够覆盖尽可能多的地区,同时保持较低的数据足迹,国际化营销页面采用了按区域回退的机制。如果简单地考虑,为每个项目的每个国家创建一个页面将意味着我们的数据呈 m * n 倍增长。这也意味着内容维护人员必须更新多个页面才能对特定项目的所有国家页面进行更改。这不是一个理想的方案。

为了解决这个问题,思路是维护最少数量的国际化页面,并用这少数页面上的相关内容服务于全球所有地区。这意味着我们只需要维护几个页面,并且可以动态更新哪个页面应该服务于特定地区。

概括来说,我们在 API 中引入了一个配置对象。这是一个 JSON 结构,用于保存按区域分组的地区。逻辑很简单:这棵树叶子节点上的值表示可能拥有关联页面的各个国家/地区。如果没有,页面获取 API 将向上一级查找,并递归地尝试获取与该 locale 关联的页面。

例如,如果客户端请求 locale=ae 的页面,我们会检查 ae 页面是否存在,如果不存在,则检查 asia 页面是否存在,如果仍然不存在,则返回请求实体的全局页面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 配置
{
  "global": {
    "asia": {
      "sg": {},
      "ae": {}
    },
    "europe": {
      "gb": {}
    }
  }
}

引入这个配置使我们能够将国家关联到特定区域,以便我们可以在属于该区域的每个国家提供区域数据。为了避免这个配置变得过于庞大,我们在自身层面确保每个页面都存在一个全局页面变体。这确保了如果请求一个在我们配置中不存在的国家的页面,它总是会回退到全局页面。

页面端点现在接受一个 locale 参数,客户端可以通过它指定需要哪个国家的页面。这主要涵盖了大部分的兼容性问题,特别是对于逐渐更新的移动客户端,因为端点保持不变,即使没有发送 locale 查询参数,API 也默认假设是 in(印度),这是国际化之前的情况。

处理客户端路由

我们使用通用模式下的 Nuxt 来提供我们的营销页面。幸运的是,Nuxt(以及整个 Vue 社区)为常见用例(包括国际化)提供了一套官方模块。得益于 nuxt/i18n 模块,我们的很多工作都得到了简化。它提供了启动时自动生成静态路由、客户端路由,以及多种配置方式。

在许多地方,我们都在将用户重定向到应用内或应用外的 URL。使用 i18n 时,这必须通过一个转换器函数来推送,该函数将当前的 locale 值附加到新的 URL 上。nuxt/i18n 为此公开了一个名为 localePath 的函数。我们发现这是一个清理现有重定向逻辑并将其集中到单一位置的好机会,即通过编写一个包装函数来重定向到 URL,内部可选地使用 localePath(如果 URL 是内部的)。

当应用启动时,nuxt/i18n 会从 URL 中保存与当前 locale 相关的数据。例如,upgrad.com/us/data-science-pgd-iiitb 会将 localeus 存储到 store 中,并附带其他相关信息。利用这个值,Nuxt 应用会向 CMS API 发起调用,附带由该模块设置的 locale 查询参数。之后,API 负责返回精确匹配的数据,或应用某种回退机制来检索最接近的匹配项。一旦数据返回,Nuxt 应用就会根据数据中指定的布局进行渲染。

自动重定向到特定国家/地区的 URL

这是整个项目中最具美感的功能。无论背后进行了多少数据库和 API 的改动,用户只会看到 URL 神奇地变成了他们所在地区的代码并感到惊叹。但我们作为开发者知道,为了实现这一点并确保平台其余部分像以前一样正常工作,付出了多少努力。

我们现有的营销网站通过 CloudFront 提供服务,通过启用边缘缓存来实现低延迟。我们决定利用此设置来增加一步指向特定地区 URL 的路由。重申一下当前的问题: 当访问者(例如从美国)打开 upgrad.com/data-science-pgd-iiitb 时,请求应被重定向到 upgrad.com/us/data-science-pgd-iiitb。

这是一个完美的按需执行函数的用例——AWS Lambda。AWS Lambda 有一个名为 Lambda@Edge 的 CloudFront 变体,它会在 CloudFront 分发网络中的多个位置创建函数的副本并部署它们。然后可以将其配置为在某些触发器上执行。

如上图所述,我们将 Lambda 函数放置在 origin request 触发器上。当 CloudFront 向源服务器(即原始服务器)请求内容时,此事件会触发——该函数位于 CloudFront 和源服务器之间。Lambda 的职责是创建重定向或将 URL 传递给源服务器。为了检测用户位置,CloudFront 的国家头信息非常有用。除此之外,我们还使用了内部由 MaxMind IP 数据库提供支持的位置解析 API。

要访问 CloudFront 的国家头信息,需要将它们添加到行为配置的白名单头信息中。你可以通过名称 CloudFront-Viewer-Country 找到它。

用 Node.js 实现的 Lambda 函数大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
exports.handler = async (event, context, callback) => {
  let request = event.Records[0].cf.request;
  const uri = request.uri;
  const querystring = request.querystring;
  const countryCode = request.headers['cloudfront-viewer-country'][0].value;

  // 跳过一些你可能不想重定向的 URL
  if(shouldSkipRedirection(uri)) return callback(null, request);

  // 从头信息或 IP 解析位置
  let { locale } = await resolveLocation(countryCode, request.clientIp);

  // 创建 302 重定向
  let response = {
    status: 302,
    statusDescription: 'Found',
    headers: {
      location: [{
        key: 'Location',
        value: '/' + locale + uri + (querystring ? `?${querystring}` : '')
      }]
    }
  }
  return callback(null, response);
}

一旦这个 Lambda 发布到边缘位置,对源服务器的请求就开始流经这个 lambda 函数,全球各地的用户看到的内容就不同了。这是整个流程中的最后一步(在用户旅程中却是第一步),如果我们一步一步回溯,所有环节就都清晰了。

当用户从美国打开 upgrad.com 网站时,lambda 检测到位置并分配 locale 值为 us,并在到达源服务器(Nuxt 应用)之前重定向到 /usnuxt/i18n 模块接管,并在 Nuxt 上下文中将 locale 值设置为 us,从而触发附带相应查询参数的 API 调用。API 服务器尝试在数据库中查找美国页面,并将最接近的匹配文档返回给客户端。收到此数据后,客户端成功渲染页面并将其返回给 CloudFront。然后 CloudFront 将此结果返回给查看者,并将其针对原始请求 URL 以及重定向后的 URL 进行缓存。就这样完成了!

未来展望

这是营销网站国际化的第一阶段,现在已经能够让我们在多个国家推广我们的课程,并且我们能够在各地独立地驱动内容。接下来的步骤将包括添加本地语言支持和允许手动更改地区。除此之外,我们认为 Lambda 方法还有改进的空间。目前,跳过某些不符合此标准的 URL 的逻辑有些分散。我们正在考虑如何使其更加集中,以便可以通过单一事实来源进行配置。如果你在应用中实现了类似的功能,请在评论中告诉我们,我们很乐意听取不同的观点。

如果没有整个团队的成功合作,这个项目是不可能完成的。感谢 Maitrey、Jitesh、Shahir、Abhishek、Harshita、Omkar、Akshay、Vishal、Yuvaraj 及团队,以及所有其他做出贡献的人。欢迎访问 upGrad.com 查看我们完全在线的项目!如果你希望与我们充满热情的团队一起工作,请查看我们的招聘页面。我们一直在寻找有抱负、有才华的人!

参考资料与延伸阅读

  • Handling Redirects@Edge Part 1 & 2
  • Personalize Content by Country or Device Type Headers — Examples
  • I18n
  • Lambda Edge
  • Cloudfront
  • Nuxtjs
  • Upgrad
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计