国际化升级之路:构建支持多地域网站的架构实践

本文详述了 upGrad 网站国际化的技术实现,涉及 CMS 数据模型改造、Nuxt.js 前端路由国际化集成,以及利用 AWS Lambda@Edge 实现基于用户地理位置的自动重定向,涵盖系统设计、降级机制和具体代码实现。

将 upGrad.com 推向国际化

几个月前,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(URL 别名)。以前,只能有单层 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 是分配给每个国家的唯一两位代码。语言环境是国家的良好文本标识符,可以直接映射到 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=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 时,这必须通过一个转换函数来处理,该函数将当前的语言环境值附加到新的 URL 上。nuxt/i18n 为此公开了一个名为 localePath 的函数。我们发现这是一个很好的机会,可以清理我们现有的重定向逻辑,并通过编写一个包装函数将其集中到一个地方,该函数重定向到内部可选择使用 localePath 的 URL(如果 URL 是内部链接的话)。

当应用启动时,nuxt/i18n 会从 URL 中保存与当前语言环境相关的数据。例如,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 有一个 CloudFront 变体叫做 Lambda@Edge,它创建一个函数的副本并将其部署在 CloudFront 分发网络边缘。然后可以将其配置为在某些触发器上执行。

如上图所示,我们将 Lambda 函数放置在原始请求触发器上。当 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 应用)之前重定向到 /us。然后 nuxt/i18n 模块接管,将 Nuxt 上下文中的 locale 值设置为 us,这导致发起一个附带相应查询参数的 API 调用。API 服务器尝试在数据库中查找美国页面,并将最接近的匹配文档返回给客户端。收到此数据后,客户端成功渲染页面并将其返回给 CloudFront。然后 CloudFront 将此结果返回给查看者,并根据原始请求 URL 以及重定向后的 URL 进行缓存。就这样!

未来展望

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

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

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计