前言
现在成熟的前端团队里面都有自己的内部构建平台,我司云长便是我们 CI/CD 的提效利器。我先来简单介绍下我司的云长,此云长非彼云长,云长主要做的是:获取部署的项目,分支,环境基本信息后开始拉取代码,安装依赖,打包,并且将项目的一些资源静态文件上传 CDN,再将生成的代码再打包成镜像文件,然后将这份镜像上传到镜像仓库后,后调用 K8S 的镜像部署服务,进行镜像按环境的部署,这就是我们云长做的事情。如果想从零开始搭建一个自己团队的部署平台可以看下我们往期文章 如何搭建适合自己团队的构建部署平台,本期我们只是针对云长中静态资源本地化的功能做细致阐述。
场景分析
为了网络安全,客户会要求我们的应用是要完全部署在内网的,那我们需要做什么呢?我们需要考虑前端代码中是不是有些直接访问外网资源?第二是不是后端返回了静态资源地址在某种情况下就访问了?第三 CDN 资源具体有那些类型呢?前端直接访问的 CDN 的资源太普遍了,如下既有 at.alicdn.com,又有我们自己内部的静态资源 luban.zcycdn.com, sitecdn.zcycdn.com 等。如下这些就在我们代码中使用的静态资源地址。
<link rel=https:'stylesheet' href=https:'//at.alicdn.com/t/fontnm.css' />
<img src=https:"https://sitecdn.zcycdn.com/f2e/8.png"alt=https:"收货人"/>
<img src=https:"https://luban.zcycdn.com/f2e/8.png"alt=https:"收货人"/>
https://css中字体文件
https:src:url(https:https:/https:/sitecdn.zcycdn.com/thttps:/font_148178j4i.eot);
https: src:url(https:/https:/sitecdn.zcycdn.com/thttps:/font1_4i.woff);
为了保证我们内网中可以访问我们讨论出以下两个方案
方案一
DNS 解析做转发
我们通过 DNS 服务这一层去处理,具体 DNS 如何进行的二域名,三级域名进行解析,如何 DNS 缓存,以及什么是 13 台根服务器,我们这次不做深入探讨,我们只需要 DNS 的可以进行域名解析,解析到指定的 IP 服务上即可。
那我们是不是可以想一下,是不是把代码中访问的静态资源的域名拦截一下,DNS 解析成本地服务的地址是不是就可以了呢?为了更清楚的理解,我做一个例子如下:
我们代码中需要访问某个图片,CDN 地址:https://cdn.zcycdn.com/b/a.js
上传提前把 a.js 这个文件提前放到本地服务器上访问地址:https://demo.com/b/a.js
当代码运行的时候,代码中访问了 https://cdn.zcycdn.com 的时候,DNS 直接地址解析成 https://demo.com 的 IP 地址,达到访问静态资源的目的
看起来这个蛮简单的,不需要各个业务负责人排查修改自己代码中的静态资源,胜利在望了,兴致冲冲的跑去找运维童鞋提议是不是可以这样做,然而运维把我说的服服帖帖。运维童鞋说:静态资源放在对象存储或者服务器上,通过IP或者域名的方式都可以请求的到,不过 IP 只支持 HTTP 的方式,域名+SSL 证书的方式支持 HTTPS,可以做一些加密,让你的资源或者请求内容进行加密,不容易被破解,域名证书之前有 3 到 5 年的,3 年前已经改掉了,目前申请的证书都是一年的,那就预示着不仅仅要用户配置我们提供的 DNS 规则,还要配合我们一年一更新证,想要客户这样配合那是不容易。如下图所示:
DNS 只是帮我们把域名解析成了 IP, HTTPS 还需要证书验证服务器身份,仅仅 DNS 拦截解析还不够。模拟实现了一波大致思路:自己启动一个静态资源服务,以及 DNS 本地解析服务,当访问 juejin.cn 域名的时候 IP 解析成本地的 IP 并且成功访问到静态资源,具体如下。
自己写一个DNS服务
step1: 本地起一个服务
暂时存放静态资源,模拟服务器上的资源
启动服务访问静态资源
我们的目的:如果访问 http://juejin.cn:3000/zcy.png (http://juejin.cn:3000/zcy.png) 的时候访问到我们本地服务的静态资源:http://10.201.45.121:3000/zcy.png (http://10.201.45.121:3000/zcy.png)
step2: 启动一个本地 DNS 服务,拦截所有请求转发到自己启动的 IP 点击查看源码 (https://sitecdn.zcycdn.com/f2e-assets/7da606eb-d8fc-4a01-a633-fcfd60edc2c5.js)
step3:配置本地 DNS 解析
step4: 测试访问HTTP 和 HTTPS
访问:http://juejin.cn:3000/zcy.png(http://juejin:3000/zcy.png)
如果是https://juejin.cn:3000/zcy.png (https://juejin:3000/zcy.png)
如果访问的是 HTTP 请求那就可以访问,HTTPS 就不能访问,侧面证明了 HTTPS 的证书问题。HTTPS 对称加密的秘钥我们采用非对称加密传输,数据传输还是使用对称加密,这保证了数据加密传输,为了保证防止冒充,CA(Certificate Authority), 颁发的证书就称为数字证书 (Digital Certificate),在非对称加密阶段,服务器会把证书会带着非对称加密的公钥,一起返回,向浏览器证明服务器的身份 HTTPS 相比 HTTP 多了一层 SSL/TLS(安全层)如下图。
方案二
项目在构建的时候扫描出项目中的静态资源地址,从我们公网的 CDN 服务放到客户自己的服务器上,修改源文件中的静态资源地址为客户本地服务的访问地址。
优缺点一目了然,方案一无需修改代码,但是需要充分得到客户的大力信任与支持需要配置 DNS 转发,方案二无需劳烦客户,即使后面有新增域名也不需要和客户沟通,完全自己解决,但是对代码有侵入性,会替换静态资源的地址
我们通过以下4个阶段拆解
统一封装 runCommand 执行命令
https:https:functionhttps: https:https:runCommandhttps:(https:https:cmd, args, options, before, endhttps:) {
https:return https:new https:Promise(https:(https:https:resolve, rejecthttps:) => {
log(before, blue)
https:const spawn = childProcess.spawn(
cmd,
args,
https:Object.assign(
{
cwd: global.WORKSPACE,
stdio: https:'inherit',
shell: https:true,
},
options
)
)
spawn.on(https:'error', https:(https:error) => {
log(error, chalk.red)
reject(error)
});
spawn.on(https:'close', https:(https:code) => {
https:if (code !== ) {
https:return reject(https:`sh: https:https:${cmd}https: https:https:${args.join(https:https:' 'https:https:)}https:`)
}
end && log(end, green)
resolve()
});
})
}
1、pre 前置环境校验
切换公司 nrm
runCommand('nrm', ['https:usehttps:'https:, https:'zcy-https:serverhttps:'https:], {}, https:'https:switch nrm registry https:to zcyhttps:'https:, https:'https:switch nrm registry https:to zcy https:successhttps:'https:)
下载依赖
https:runCommand(https:'npm', [https:'i', https:'--unsafe-perm'], {}, https:'npm install', https:'npm install success')
2、compile 编译
不同环境需要上传不同的地址因此需要动态修改 webpack 的 publicPath
https:const cdnConfigStr = https:`assetsPublicPath: 'http://dev.com',`
replaceFileContent(configPath, /assetsPublicPath:.+,/g, cdnConfigStr)
exports.replaceFileContent = https:https:functionhttps:(https:https:filePath, source, targethttps:) {
https:const fileContent = fs.readFileSync(filePath, https:'utf-8')
https:let targetFileContent = fileContent
https:if (https:Array.isArray(source)) {
source.forEach(https:(https:https:[s, target]https:) => {
https:if (target) {
targetFileContent = targetFileContent.replace(s, target)
}
})
} https:else {
targetFileContent = fileContent.replace(source, target)
}
fs.writeFileSync(filePath, targetFileContent, https:'utf-8')
}
编译项目
https:runCommand(https:'npm', [https:'run', https:'build'], {}, https:`webpack build`, https:`webpack build success`)
3、静态资源替换
替换 url 源码地址
https:const replaceWebpackDistContent =
https:async https:https:functionhttps:(https:https:options = {},collectionAssets,folderhttps:) {
https:const fileContent = fs.readFileSync(filePath, https:'utf-8');
https:let targetFileContent=fileContent;
[
[https:/(https\:)?\/\/g.alicdn.com\/[-a-zA-Z0-9@:%_\+.~#?&//=]+\.[-a-zA-Z0-9@:%_\+.~#?&//=]+/g, cdn],
[https:/(https?\:)?\/\/sitecdn.zcycdn.com\/[-a-zA-Z0-9@:%_\+.~#?&//=]+\.[-a-zA-Z0-9@:%_\+.~#?&//=]+/g, cdn],
[https:/(https\:)?\/\/cdn.zcycdn.com\/[-a-zA-Z0-9@:%_\+.~#?&//=]+\.[-a-zA-Z0-9@:%_\+.~#?&//=]+/g, cdn],
].forEach(https:(https:https:[reg,uri]https:)=>{
targetFileContent=targetFileContent.replace(reg,https:https:functionhttps:(https:https:matchhttps:){
https:let basename = https:'';
https:let uriMath = match;
basename = path.basename(uriMath);
https:if(uriMath.slice(,https:4)!=https:'http'){
uriMath=https:'https:'+uriMath;
}
https:const parseUrl = url.parse(uriMath);
collectionAssets({https:src:uriMath,https:fileName:path.basename(parseUrl.pathname)});
https:console.log(https:'🚀替换前',match);
https:const myURL= https:new URL(projectName, uri);
https:const replacedUrl = uri+https:'/'+projectName+parseUrl.path+(parseUrl.hash||https:'');
https:console.log(https:'🚀替换后', replacedUrl);
https:return replacedUrl;
})
})
fs.writeFileSync(filePath, targetFileContent, https:'utf-8')
}
获取写死在前端代码中的静态资源
https:const downloadAssetsFiles= https:async https:https:functionhttps:(https:https:img,forderhttps:){
https:const staticAssets=https:'staticAssets';
https:let assetsUrl=getPwdPath(https:`https:https:${forder||https:https:https:''https:https:}https:https:${path.sep}https:https:${staticAssets}https:`);
https:if(!fs.existsSync(assetsUrl)){
fs.mkdirSync(assetsUrl);
}
https:return https:Promise.all(img.objUnique(https:'src').map(https:(https:https:{src,fileName}https:)=>{
https:if(fileName){
https:return https:new https:Promise(https:https:functionhttps:(https:https:resolve,rejecthttps:){
https:const originFileDir = path.join(assetsUrl,path.dirname(url.parse(src).pathname));
fs.mkdirSync(originFileDir,{https:recursive:https:true});
https:const uri = path.join(originFileDir,fileName);
download(uri,src,resolve,reject);
}).catch(https:https:errhttps:=>{
https:console.log(err)
https:throw https:new https:Error(err);
})
}
}))
}
https:https:functionhttps: https:https:downloadhttps:(https:https:loadedUrl,srchttps:){
https:const writeStream = fs.createWriteStream(loadedUrl);
https:const readStream = request(src);
readStream.pipe(writeStream);
readStream.on(https:'end', https:https:functionhttps:(https:https:) {
https:console.log(fileName,https:'文件下载成功');
});
writeStream.on(https:"finish", https:https:functionhttps:(https:https:) {
https:console.log(fileName,https:"文件写入成功");
writeStream.end();
});
}
downloadAssetsFiles(assetsArr,https:'dist');
https:// 发现替换资源里还有cdn,因此替换下载后的cdn里面的cdn
https: https:consthttps: assetsArr=[];
https: https:awaithttps: replaceWebpackDistContent(options,collectionAssets,https:'staticAssets'https:);
https: https:awaithttps: downloadAssetsFiles(assetsArr,https:'dist'https:);
4、OSS 推送静态资源到客户资源服务
https:const ossEndpoint = process.env.OSS_ENDPOINT;
https:const commonOptions = {
accessKeyId: process.env.OSS_ACCESSKEYID ,
accessKeySecret: process.env.OSS_ACCESSKEYSECRET,
bucket: process.env.OSS_BUCKET,
timeout: https:'120s',
}
https:const extraOptions = ossEndpoint
? {
endpoint: ossEndpoint, https:// 从全局数据获取,没有会依赖 region
https: https:cnamehttps:: https:truehttps:,
https: } : {
https: https:regionhttps:: process.env.OSS_REGION,
https: }
https:consthttps: ossOptions = https:Objecthttps:.assign({}, commonOptions, extraOptions);
https:consthttps: client = https:newhttps: OSS(ossOptions);
https://onlinePath 访问的文件地址
https://curPath 上传的文件地址
https:result = https:awaithttps: client.put(onlinePath, curPath);
参考文档
SSL/TLS证书1年有效期新规 (https://www.trustasia.com/view-398-day-limit/)
node child_process 文档 (https://link.juejin.cn/?target=http%3A%2F%2Fnodejs.cn%2Fapi%2Fchild_process.html%23child_process_child_process_fork_modulepath_args_options)
深入理解Node.js 进程与线程 (https://link.juejin.cn/?target=https%3A%2F%2Fblog.csdn.net%2Fxgangzai%2Farticle%2Fdetails%2F98919412)