深度解析悟空系统多机房部署共线改造
作者:vivo 互联网前端团队- Fang Liangliang 多地区销量持续增长、业务运营诉求与日俱增,悟空作为一站式h5搭建平台,需要先发完成多地区化能力改造,基于复用、提效的思路,探索多地区系统方案,实现多地区一体化运作。 1分钟看图掌握核心观点👇 图 1 VS 图 2,您更倾向于哪张图来辅助理解全文呢?欢迎在评论区留言 悟空系统多地区化共线改造,用一套代码、一套架构实现多地区部署,后续的增量功能一次开发,全量复用,已有机房实现100%复用,新增机房节约90%开发成本; 开发者开发组件的方式不需要做任何改变,公共npm依赖包无需迁移,开发者低成本完成组件迁移; 本文将深度解析悟空系统多地区共线改造的架构设计,从页面多语言、站点的编译、npm私服、开发者等环节进行解析,让读者能够有所收获。 开发之前,我们进行了整体的梳理,涉及的范围如下图所示: 业务分层图 从用户层、服务层、调度层进行拆解,可以进一步分析出需要实施的要点,如图所示: 整体功能点梳理图 进行整体的分析之后,我们可以从平台侧开始一层层的进行拆解,从用户能直观看到的web层,到用户感知比较弱的编译服务层,再到私服和底层库的处理上,核心主要分为三个模块进行改造:平台改造、编译服务、npm私服\&底层库。 这部分介绍平台web侧的改造,主要分为三个方向:中英文改造、平台登录改造、国家码存储改造。 平台中英文国际化改造 背景 :平台本身是用的vue进行开发,所以这里我们采用Vue.js + vue-i18n的国际化解决方案,支持中文(zh)和英文(en)双语切换。 我们来看一段核心代码示例: 使用vue-i18n有以下几点优势: 平台登录改造 悟空平台会存在多个机房场景,如何使用同一个域名做为入口,简化多地区登录场景链路。 我们设计了一个多地区统一域名入口,进入之后运营可以根据需求切换不同地区,不需要在单独保存各个地区的独立链接,登录链路也会整合到入口域名,整体链路如下图所示: 代码示例如下: 通过上述的方案,我们将机房的匹配集成在系统内部进行,减少用户感知,降低用户使用成本,这样可以做到统一域名入口,运营不需要本地记录多个地址,同时平台还提供便捷的地区切换能力,极大的提升跨地区运营的便利程度。 国家码存储方案 用户选择国家之后,悟空需要存储当前地区的地区码、语言码、时区信息,并且对应地区的站点语言和生效时间都要和地区时区匹配,而平台本身除了新开tab需要携带地区信息外还有开发者组件、iframe嵌套需要获取地区信息的场景,针对这些场景,悟空平台采用三层级的国家码存储策略: ① 新开tab时,地区信息通过URL参数携带 ② Vuex Store存储,地区信息在应用状态中持久化存储,开发者可以在组件内通过store读取国家码信息。 ③ LocalStorage缓存,用户地区选择持久化到本地存储,适用于iframe嵌套场景。 如果父子iframe是同源策略可以直接读取LocalStorage,如果非同源策略可以通过postMessage获取地区信息。 三级存储有以下几点优势: 介绍完平台web侧的改造内容后,接下来会详细介绍悟空系统编译服务多地区化改造方案。该方案通过统一的配置管理、多机房部署策略、差异化构建流程等技术手段,实现了01地区、02地区、03地区 的全面支持。 整体架构设计 整体架构图 该架构图展示了悟空互动平台多地区改造的整体设计思路: 核心技术方案 整体方案设计完毕之后,我们接下来一层层解析每个环节的改造。 ① 统一环境配置管理 通过对context.ts文件进行改造,实现不同机房部署的统一入口处理。 通过上述代码,我们可以发现环境配置读取从只有一级的环境区分改造为机房信息+环境的二级目录结构,这样修改后服务启动时,代码内部通过env方法可以获取当前机房信息下对应环境的全部配置信息,方便全局调用。 核心特性: 统一配置入口改造完毕之后,接下来我们介绍下入口文件往下一层去查询每个机房各个环境具体配置信息的改造。 配置文件信息按地区和环境进行分层管理改造后,整体目录结构如下所示: 通过上述目录结构可以看出,不同机房的配置信息一目了然,相互独立,方便维护和定位问题,后续新增机房信息时,只需要按照当前规则添加即可,不需要额外关心内部业务逻辑。 整体流程分为以下四个步骤: ② ZooKeeper服务发现与调度改造 由于测试环境和预发环境都部署在01和02机房,通过模拟的方式支持01、02地区,而线上环境才是真正的物理隔离机房,因此在ZooKeeper服务发现中需要特殊处理(01、02为示例地区信息): 核心调度逻辑改造如下: 通过上述代码,我们可以发现服务注册和发现都需要按照机房信息+环境信息作改造,这样可以有效避免测试环境和预发环境,站点编译时调度的机房出现异常,线上环境由于物理机房隔离,服务注册和发现可以不做调整。 介绍完环境隔离策略后,接下来我们介绍下不同环境的zk配置信息的改造。 测试环境(模拟多地区): 预发环境(模拟多地区): 生产环境(真实隔离机房): 通过上述两个地方改造,服务发现分组策略如下所示: ③ 多机房构建策略 编译服务在生成站点时,还会对每个站点的主js文件做dll拆包处理,将公共依赖打包成独立的dll基座文件,降低页面的主资源体积,提升加载速度,那么针对多地区改造场景,我们会做哪些处理呢? 针对不同地区我们需要使用不同的API包,实现差异化的DLL构建: 01地区DLL配置: 02、03地区DLL配置: 在基座dll文件构建时,由于多地区的登录、分享、埋点合规等存在较大差异,我们对多地区场景做了单独的底层库封装,dll文件生成需要根据不同地区分别构建并输出到不同目录。 修改dll配置文件时,同时也需要在 package.json 中配置不同地区的构建命令(01、02由于保密,均为地区示例信息): 通过上述指令的改造,我们可以很清晰的看到不同地区的dll构建指令都根据region信息做了区分,方便后续的机房扩充和维护。 ④ Webpack多机房配置改造 这里主要介绍webpack打包时如何实现不同地区的dll动态引入,以及将国家码等信息编译到站点内。 不同地区的dll文件构建完毕之后,我们需要在 webpack.pkg.config.js 中实现基于地区的动态DLL引用: 通过上述示例代码,我们需要将不同机房构建dll文件时生成的manifest.json进行不同动态引入改造,避免站点运行时,相同依赖通过chunkid进行匹配时,出现错乱导致页面异常。 通过编译服务能够拿到平台web用户在哪个地区编译发布的站点,但是这些信息如何编译到用户可访问的每个站点里呢? 我们通过wk_siteInfo将地区信息注入到前端: 通过修改webpack.config.js,将站点的地区信息、时区、语言码等信息注入到wk_siteInfo,然后通过htmlWebpackPlugin将打包后的地区码信息注入到html中,这样就能实现页面对地区信息读取。 ⑤多地区部署流程 构建流程图 该流程图详细展示了多地区构建部署的完整流程: 环境变量配置: 编译服务改造技术亮点 ① 统一入口设计 ② 差异化API包管理 ③ 服务发现机制 ④ 智能构建策略 ⑤ 前端代码隔离 通过上述的整套共线方案设计,后续新增国家机房时,新增地区只需配置相应环境文件,无需修改核心代码,配置结构清晰,各地区配置完全隔离,节约90%重复建设成本,提升了系统的灵活性和可扩展性。 通过统一的技术架构和清晰的配置管理,成功实现了"一套代码,多地部署"的目标,为悟空互动平台的多地区化业务发展提供了坚实的技术保障。 介绍完编译服务后,接下来介绍私服的代理策略和公共底层库的外销化改造。 npm私服 悟空除了服务业务运营,还有开发者部分,基于目前开发者整体的开发习惯,我们需要做到开发者零感知,实现02地区npm包的部署。 基于此我们有以下三点目标: 为了实现上述目标,我们做了一套完整的02地区私服方案设计,具体如下图所示: 开发者开发组件上传,维持01机房npm私服开发上传习惯,无需新增02机房源,npm物料仍然托管在01npm私服。 同时在02机房建设代理npm私服,通过ip白名单与悟空通信,私服本身通过verdaccioss服务配置代理: 通过02机房代理私服的方式,既能减少了悟空开发需要额外多维护02机房源,同时也降低了开发者组件包维护成本,实现本地不需要新增任何npm源,即可实现02机房包的开发上线流程。 底层库外销改造 wk-api是悟空平台封装的底层npm库,为了实现多机房场景下,业务组件多地区场景请求接口域名不变,我们通过fetch统一拦截器+header国家码信息,直接将业务组件的请求转发到不同国家的接口服务。 具体实现代码如下: 统一拦截器策略有以下优点: 悟空系统的整体改造从上到下可以分为用户能直观看到的平台改造,然后到用户感知不到的编译服务改造,最后是开发者也无需感知的外销私服部署,从前期梳理到后续一个个模块的拆解,出方案,进行开发落地,最终实现了以下目标:

一、目标
二、整体方案设计


三、模块拆解
3.1 平台改造
// i18n.js 核心配置
// 语言包配置,方便扩展
const messages = {
zh: { ...zh, ...zhLocale }, // 中文语言包 + Element UI中文
en: { ...en, ...enLocale } // 英文语言包 + Element UI英文
}
// 基于域名的地区检测 // 不同地区自动读取对应语言包
const domainConfigMap = new Map([
['****.vivo.com.cn', { region: '01', local: 'zh' }], // 01地区 读取zhLocale语言包
['****.vivo.com', { region: '02', local: 'en'}], // 示例:02地区 读取enLocale语言包
['in-****.vivo.com', { region: '03', local: 'en' }] // 示例:03地区 读取enLocale语言包
])
getUucLogin (key, region) => {
...
const locationUrl = getLocationUrl(region, env) // 回跳的链接根据地区信息来区分
return `${originMap[region][env]}/#/login?orgfrom=${locationUrl}/project${key}` // uuc登录地址融合地区信息和环境信息
}// 项目跳转时携带地区参数
goList(projectId) {
const params = { projectId: projectId }
const wkCountryInfo = Utils.tools.getCountryInfoParams() // 获取地区参数
const query = {...params, ...wkCountryInfo} // 合并参数
this.$router.push({ path: '/main', query: query })
}
// 例如 getCountryInfoParams 返回格式:{ loc: 'AA', lan: 'th_AA', tz: 'REGION/aa' }// Store中的地区信息存储
state: {
siteConfig: {
wkCountryInfo: {
loc: 'AA', // 地区信息
lan: 'th_AA', // 语言码
tz: 'REGION/aa' // 时区
}
}
}
// 获取Store中的地区信息
const storeCountryInfo = store.getters['edit/snapInfo'].wkCountryInfo || store.getters['interactive/snapInfo'].wkCountryInfo// 地区切换时的存储逻辑
changeRegion(value) {
const item = this.headerRegion.list.find(v => v.countryCode === value)
const wkCountryInfo = {
loc: item.countryCode, // 如:'AA', 'BB', 'CC'
lan: item.languageCode, // 如:'th_AA', 'en_BB', 'zh_CC'
tz: item.timezone// 如:'REGION/aa', 'REGION/bb'
}
localStorage.setItem('__wk_platform_region_info_', JSON.stringify(wkCountryInfo))
}3.2 编译服务改造

// 示例代码
// server/src/app/extend/context.ts
get env(): Ienv {
return env[process.env.REGION || 'AA'][(this as any).app.config.env]
}
// 目录结构:
server/src/app/util/env/
├── index.ts # 配置入口
├── 01 # 01地区配置
│ ├── index.ts
│ ├── local.ts
│ ├── test.ts
│ ├── prod.ts
│ └── ...
├── 02 # 02地区配置
│ ├── index.ts
│ ├── test.ts
│ ├── prod.ts
│ └── ...
└── 03 # 03地区配置
├── index.ts
├── test.ts
├── prod.ts
└── ...
// server/src/app.js
const isTestOrPreEnv = process.env.EGG_SERVER_ENV.includes('test') || process.env.EGG_SERVER_ENV.includes('pre');
// 添加机房信息
let group = isTestOrPreEnv ? `${process.env.REGION || 'AA'}-${process.env.EGG_SERVER_ENV}`: process.env.EGG_SERVER_ENV
const serviceClient = new BeehiveService({
zkhost: ctx.env.zkHost,
pong: true,
services: {
siteService: ctx.service.site,
dspService: ctx.service.genDsp
},
config: c.Config(c.group(group), c.maxTimeout(3 * 60 * 1000))
})// 所有地区测试环境都使用同一个ZK集群
zkHost: 'zookeeper-*****.vivo.xyz:2183'
// 但通过group区分:01-test, 02-test, 03-test// 所有地区预发环境使用同一个ZK集群
zkHost: 'common-zk-****.vivo.lan:2181'
// 通过group区分:01-prev, 02-prev, 03-prev// 机房1(示例名称)
zkHost: 'common-*****-zk.vivo.lan:2181'
// 机房2(示例名称)
zkHost: 'in-common-*****-zk.vivo.lan:2181'
// 机房3(示例名称)
zkHost: 'app.*****.zk.prd.****.vivo.lan:2181'// webpack.dll.config.js
const vendors = [
....
'@vivo/wk-api', // 01地区专用API
'vue-lazyload',
]
module.exports = {
output: {
path: path.join(__dirname, './dll'), // 输出到dll目录
filename: '[name].[hash].js',
},
// ...
}// webpack.dll.02.config.js //webpack.dll.03.config.js
const vendors = [
....
'@vivo/asia-wk-api', // 02、03地区专用API
'vue-lazyload',
]
module.exports = {
output: {
path: path.join(__dirname, './dll-02'), // 输出到dll-02目录或者dll-03
filename: '[name].[hash].js',
},
// ...
}//代码示例
{
"scripts": {
// DLL构建
"dll": "npx webpack --config webpack.dll.config.js",
"dll:01": "npx webpack --config webpack.dll.01.config.js",
"dll:02": "npx webpack --config webpack.dll.02.config.js",
// 服务启动
"start_test_01": "EGG_SERVER_ENV=test REGION=01 yarn dock_start",
"start_prev_01": "EGG_SERVER_ENV=prev REGION=01 yarn dock_start",
"start_prod_01": "EGG_SERVER_ENV=prod_wk REGION=01 yarn dock_start",
"start_test_02": "EGG_SERVER_ENV=test REGION=02 yarn dock_start",
"start_prev_02": "EGG_SERVER_ENV=prev REGION=02 yarn dock_start",
"start_prod_02": "EGG_SERVER_ENV=prod_wk REGION=02 yarn dock_start"
}
}// 代码示例
// webpack.pkg.config.js
const dllReferencePlugin = config.plugins.find(plugin => {
const name = plugin.constructor.name
if (['DllReferencePlugin'].includes(name)) {
returntrue
}
})
if (dllReferencePlugin && dllReferencePlugin.options) {
dllReferencePlugin.options.context = pageTempPath
// 动态dll文件引用改造,根据wukong.region:01,02,03 来设置manifest路径
const DLL_DIR = wukong.region === '02' ? 'dll-02' :
wukong.region === '03' ? 'dll-03' : 'dll'
dllReferencePlugin.options.manifest = require(`./${DLL_DIR}/manifest.json`)
....
}// webpack.pkg.config.js
const site_option = {
host: ip.address(),
port: 8080,
stPath: '****',
loginPath: '****',
wk_siteInfo: {
siteId,
....
wkCountryInfo, // 地区信息、时区、语言码信息
region, // 地区信息
},
...wukong
}
// 完成地区信息的注入
// index.html
<script>
// 示例代码 :window.wk_siteInfo = JSON.stringify(htmlWebpackPlugin.options.wk_siteInfo)
</script>

3.3 npm私服\&底层库

uplinks:
zhan-npm:
url: http://****.vivo.lan:8080
packages:
'**':
...
# allow all known users to publish packages
# (anyone can register by default, remember?)
# if package is not available locally, proxy requests to 'npmjs' registry
proxy: zhan-npm
// 请求拦截器 - 动态URL路由
axios.interceptors.request.use(config => {
const { region } = app;
// 根据region动态获取prodUrl
if (region === '01') {
config.baseURL = 'https://****.vivo.com.cn';
} elseif (region === '02'||region === '03') {
config.baseURL = 'https://****.vivo.com';
}
// 请求头注入
if (wkCountryInfo.loc; wkCountryInfo.lan; wkCountryInfo.tz) {
code = `loc=${wkCountryInfo.loc};lan=${wkCountryInfo.lan};tz=${wkCountryInfo.tz}`;
loc = wkCountryInfo.loc;
}
config.headers = Object.assign(config.headers, {
'X-I8n-Code': code,
'X-Wukong-Loc': loc
});
return config;
});四、总结