基于 s3 的单页应用的一种纯前端灰度发布实现


为什么要做这个功能:

  1. 不用借助外部(native) 来修改入口 url, 整个灰度控制完全前端控制
  2. 控制线上发布风险

如何实现

去掉打包输出文件 index.html 上面的包含当前打包代码的静态 script 和 style 标签,改成动态读取远程配置文件,然后根据配置结果具体选择加载哪个版本.

具体实现细节

网页灰度整个过程分成三个步骤: 打包阶段,部署阶段和运行阶段.

打包阶段

为了给外部留有足够的迁移时间,项目同时保留了支持灰度发布的运行在 s3 上的新编译方式,以及传统的基于 k8s+ngnix 反向代理的老编译方式.

原来项目的打包输出目录如下:

├── abtest.json
├── asset-manifest.json
├── favicon.ico
├── index.html
├── manifest.json
├── static
│ ├── css
│ ├── js
│ └── media
└── version.txt

新的打包输出目录

├── abtest.json
├── asset-manifest.json
├── dba6651
│ ├── css
│ ├── js
│ ├── media
│ └── meta.json
├── favicon.ico
├── index.html
├── manifest.json
├── version.json
└── version.txt

主要区别包含如下几点:

  1. 原来的 static 目录名称变更为根据当前 git 的 commit id 决定.
  2. 新增 version.json 字段,其内容如下:
[
{
"version": "1.4.0", //当前版本
"desc": "1", // 版本描述
"rate": 0, //此版本触发概率
"comment": "dba6651", //此版本的 commit id
"timestamp": "2020-03-13T07:01:23.018Z" //编译时间
}
]

其中最重要的两个字段是ratecomment, 这两个字段的用处将在后面的’运行阶段’详细说明.

  1. 原来 index.html 的 body 包含当前编译的打包代码:
<script>
...太长省略
</script>
<script src="/static/js/10.e05dc50a.chunk.js"></script>
<script src="/static/js/main.3b6d8fc7.chunk.js"></script>

这部分代码在新的 index.html 将不复存在,取而代之的是多出了一段 运行代码(后面运行阶段说明),以及在 commit id 文件夹下面多出来的 meta.json 文件. meta.json 文件如下:

[
{
"tagName": "script",
"innerHTML": "...太长省略",
"closeTag": true
},
{
"tagName": "script",
"voidTag": false,
"attributes": { "src": "./dba6651/js/10.e05dc50a.chunk.js" }
},
{
"tagName": "script",
"voidTag": false,
"attributes": { "src": "./dba6651/js/main.3b6d8fc7.chunk.js" }
}
]

也就是说原来内嵌在 index.html 里面的内容被移到了 meta.json 里面.

以上新编译的所有配置,均是通过修改 webpack 配置实现的. 由于当前项目使用了 create-react-app 模板,并且不建议 eject 项目避免后期维护困难.所以项目借助了 customize-cra项目拓展 webpack 配置.

以下代码实现了修改 webpack 配置:

/**
* 以下代码请确保在理解 customize-cra 插件后运行
*/
//当前 commitid
const prefix = gitRevisionPlugin.commithash();
const HtmlWebpackPlugin = require('html-webpack-plugin');
//这个插件的作用是替换 index.html 模板文件里面的特定变量(这里特指模板里面 的
//if ('%DEPLOY_TYPE%' === 's3') loadAbTestFromRemote();)
//运行后 DEPLOY_TYPE 会被替换成 's3' 或者 'k8s', 从而实现只有新的编译方式才使灰度发布生效
class InterpolateHtmlPlugin {
constructor(htmlWebpackPlugin, replacements) {
this.htmlWebpackPlugin = htmlWebpackPlugin;
this.replacements = replacements;
}
apply(compiler) {
compiler.hooks.compilation.tap('InterpolateHtmlPlugin', (compilation) => {
this.htmlWebpackPlugin
.getHooks(compilation)
.afterTemplateExecution.tap('InterpolateHtmlPlugin', (data) => {
// Run HTML through a series of user-specified string replacements.
Object.keys(this.replacements).forEach((key) => {
const value = this.replacements[key];
data.html = data.html.replace(
new RegExp('%' + key + '%', 'g'),
value
);
});
});
});
}
}
//将 static 目录替换成 git commit id 目录
const modifyOutputPath = (config) => {
config.output.filename = config.output.filename.replace(
'static/js',
`${prefix}/js`
);
config.output.chunkFilename = config.output.chunkFilename.replace(
'static/js',
`${prefix}/js`
);
const loaders = config.module.rules.find((rule) => Array.isArray(rule.oneOf))
.oneOf;
loaders
.filter(({ options }) => options && options.name)
.forEach(({ options }) => {
options.name = options.name.replace('static/media', `${prefix}/media`);
});
config.plugins
.filter(
({ options }) =>
(options && options.filename) ||
(options && options.chunkFilename) ||
(options && options.inject)
)
.forEach(({ options }) => {
if (options.filename)
options.filename = options.filename.replace(
'static/css',
`${prefix}/css`
);
if (options.chunkFilename)
options.chunkFilename = options.chunkFilename.replace(
'static/css',
`${prefix}/css`
);
//防止 html 模板注入脚本
if (options.inject) options.inject = false;
});
return config;
};
//这个插件的作用是在拦截 `html-webpack-plugin`插件,将其输出的内容输出到 meta.json 上面,并且把版本信息写入 version.json
class MyPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync(
'MyPlugin', // <-- Set a meaningful name here for stacktraces
(data, cb) => {
fs.mkdirpSync(path.join(__dirname, 'build', prefix));
fs.writeFileSync(
path.join(__dirname, 'build', prefix, 'meta.json'),
JSON.stringify(data.bodyTags)
);
fs.writeFileSync(
path.join(__dirname, 'build', 'version.json'),
JSON.stringify([
{
version: packageConfig.version,
desc: '1',
rate: 0,
comment: prefix,
timestamp: new Date(),
},
])
);
cb(null, data);
}
);
});
}
}
//修改配置开始
config.plugins.push(
new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
DEPLOY_TYPE: process.env.DEPLOY_TYPE,
})
);
if (process.env.DEPLOY_TYPE !== 'k8s')
config.plugins.push(new MyPlugin({ options: '' }));
if (process.env.DEPLOY_TYPE !== 'k8s') config = modifyOutputPath(config);

这里的 DEPLOY_TYPE 为 k8s 时表示老模式,s3 时表示新模式. 到这里说明了如何修改 webpack 配置输出新的编译目录. 接下来说明如何部署

部署阶段

因为当前项目是静态单页应用,所以部署到亚马逊 s3 相比如之前的 k8s,省去了 ngnix 反向代理. 而且用 s3 部署比 k8s 更加节约成本. 大致做法就是利用 aws-sdk 上传代码.

利用 s3 的存储方式后,网页的访问入口发生了变化. 而且由于缺少反向代理,前端必须使用 hash 路由

下面说明具体步骤:

  1. 下载远程的 version.json 内容并合并到本地的 version.json
  2. 为了防止灰度选择版本失败,线上部署始终会保留一份 latest 文件夹.方便当取不到 git comit 时能够安全回退
  3. 覆盖上传
const cp = require('child_process');
const path = require('path');
const fs = require('fs-extra');
const AWS = require('aws-sdk');
const mime = require('mime');
const downloadVersion = async (s3, s3BucketName, folder) => {
try {
const content = await s3
.getObject({
Bucket: s3BucketName,
Key: `${folder}/version.json`,
})
.promise();
return JSON.parse(content.Body.toString());
} catch (e) {
if (e.statusCode === 404) {
console.warn('远程历史版本文件不存在,创建新的远程文件!!');
return [];
} else {
throw e;
}
}
};
const upload = async (s3, localFolder, s3RootDir, s3BucketName) => {
const filesPaths = await walkSync(localFolder);
for (let i = 0; i < filesPaths.length; i++) {
const statistics = `(${i + 1}/${filesPaths.length}, ${Math.round(
((i + 1) / filesPaths.length) * 100
)}%)`;
const filePath = filesPaths[i];
const fileContent = fs.readFileSync(filePath);
// If the slash is like this "/" s3 will create a new folder, otherwise will not work properly.
const relativeToBaseFilePath = path.normalize(
path.relative(localFolder, filePath)
);
s3RootDir = s3RootDir || '';
const relativeToBaseFilePathForS3 = path
.join(s3RootDir, relativeToBaseFilePath)
.split(path.sep)
.join('/');
const mimeType = mime.getType(filePath);
console.log(`Uploading`, statistics, relativeToBaseFilePathForS3);
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property
await s3
.putObject({
ACL: `public-read`,
Bucket: s3BucketName,
Key: relativeToBaseFilePathForS3,
Body: fileContent,
ContentType: mimeType,
})
.promise();
console.log(`Uploaded `, statistics, relativeToBaseFilePathForS3);
}
};
async function walkSync(dir) {
const files = fs.readdirSync(dir);
const output = [];
for (const file of files) {
const pathToFile = path.join(dir, file);
const isDirectory = fs.statSync(pathToFile).isDirectory();
if (isDirectory) {
output.push(...(await walkSync(pathToFile)));
} else {
output.push(await pathToFile);
}
}
return output;
}
module.exports = context => {
return {
fn: async () => {
console.log('is ci %s', process.env['CI']);
const config = {
s3BucketName: '',//远程桶名称
localFolder: context.outputRoot//本地编译输出目录,
accessKeyId: '',//s3 accessKeyId
secretAccessKeyId: '', //s3 secretAccessKeyId
folder: '',//桶下面的一级目录
region: '',//s3 地区
endPoint: 'https://{region}.amazonaws.com.cn',//s3 endpoint
};
AWS.config.setPromisesDependency(Promise);
const s3 = new AWS.S3({
signatureVersion: 'v4',
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKeyId,
region: config.region,
endpoint: config.endPoint,
});
//更新本地 version 文件
const versionJson = await downloadVersion(
s3,
config.s3BucketName,
config.folder
);
console.log('远程 json 文件 %o', versionJson);
const currentVersion = JSON.parse(
fs.readFileSync(path.join(context.outputRoot, 'version.json'))
)[0];
//todo 修改概率
versionJson.unshift(currentVersion);
fs.writeFileSync(
path.join(context.outputRoot, 'version.json'),
JSON.stringify(versionJson)
);
//复制一份 latest
const GitRevisionPlugin = require('git-revision-webpack-plugin');
const gitRevisionPlugin = new GitRevisionPlugin({
commithashCommand: 'rev-parse --short HEAD',
});
//需要确保必须是这次 build 出来的,否则 commit 对不上
const prefix = gitRevisionPlugin.commithash();
fs.copySync(
path.join(context.outputRoot, prefix),
path.join(context.outputRoot, 'latest')
);
//将 latest 里面指向原来 comment 的 hash 指向 latest
const command = process.env.CI
? `find ${path.join(
context.outputRoot,
'latest'
)} -type f -exec sed -i -e "s/${prefix}/latest/g" {} \\;`
: `find ${path.join(
context.outputRoot,
'latest'
)} -type f -exec sed -i '' "s/${prefix}/latest/g" {} \\;`;
console.log('exec replace with command %s', command);
cp.execSync(command);
//上传本地文件到 s3
await upload(s3, context.outputRoot, config.folder, config.s3BucketName);
console.log('发布完毕');
},
};
};

运行阶段

运行代码的主要作用是读取远程的 version.json 文件,然后根据里面的 rate 字段在 [0-1]区间分配比例,之后通过 random 函数取一个随机值,看这个值最终落在哪个区间,之后读取选定的 version 里面的 meta.json 信息,动态拼接到 index.html 上面即可实现动态选择版本.

  1. 默认情况下灰度发布不生效,全部使用 latest 版本,这也是第一个步骤生成的 version.json 里面 rate 是 0 的原因(主要懒得配置..)

以下代码是直接写在项目 public 的 index.html 模板文件里面的,之后 html-webpack-plugin 插件会把里面的 PUBLIC_URLDEPLOY_TYPE替换为真实值.

<script>
//https://babeljs.io/repl
const getJSON = function (url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'json';
xhr.onload = function () {
var status = xhr.status;
if (status === 200) {
callback(null, xhr.response);
} else {
callback(status, xhr.response);
}
};
xhr.send();
};
const choiceVersion = (versions) => {
const validVersions = versions.filter((version) => version.comment);
const total = validVersions.reduce((acc, cur) => acc + cur.rate, 0);
if (total < 1) {
validVersions.push({
version: 'latest',
rate: 1 - total,
});
}
const sorted = validVersions.sort((a, b) => a.rate - b.rate);
let pre = 0;
const ranged = sorted.map((v, i) => {
const now = pre + v.rate;
const result = { ...v, range: [pre, now] };
pre = now;
return result;
});
const rate = Math.random();
const target = ranged.find(
(item) => rate > item.range[0] && rate <= item.range[1]
);
console.log('rate %s,target %o', rate, target);
return target;
};
const applyVersion = (version, data) => {
data.forEach(function (item) {
const node = document.createElement(item.tagName, {});
if (item.innerHTML) node.innerHTML = item.innerHTML;
Object.keys(item.attributes || {})
.filter(function (attributeName) {
return item.attributes[attributeName] !== false;
})
.forEach((attributeName) => {
node.setAttribute(attributeName, item.attributes[attributeName]);
});
document.body.appendChild(node);
localStorage.setItem(
'oral:html:cache',
JSON.stringify({
version,
meta: data,
timestamp: Date.now(),
})
);
});
};
const loadTargetVersion = (version) => {
getJSON(
`%PUBLIC_URL%/${
version.comment ? version.comment : 'latest'
}/meta.json?t=${Date.now()}`,
(err, data) => {
if (err !== null) {
loadTargetVersion({
version: 'latest',
});
console.error('failed to load version %o,error %o', version, err);
} else {
applyVersion(version, data);
}
}
);
};
const loadAbTestFromRemote = () => {
getJSON(`%PUBLIC_URL%/version.json?t=${Date.now()}`, (err, data) => {
if (err !== null) {
console.error('Something went wrong: ' + err);
} else {
const version = choiceVersion(data);
loadTargetVersion(version);
}
});
};
if ('%DEPLOY_TYPE%' === 's3') loadAbTestFromRemote();
</script>

好了,一篇流水账完成..