项目需求
项目上为了更好地掌握产品的使用数据,综合对比各种埋点框架之后决定基于自由度更高的日志服务(以下简称“SLS”)实现数据埋点。
SLS 官方提供的 SDK 非常丰富,包含十几种语言,而 JavaScript 更是受到了优厚待遇,相关 SDK 已经达到了 3 种:浏览器 JavaScript SDK、小程序 JavaScript SDK、Node.js SDK。
目前我们项目没有小程序,只有 Node.js 的桌面端以及 JavaScript 的浏览器端,所以不考虑小程序 JavaScript SDK。
浏览器 JavaScript SDK
浏览器 JavaScript SDK,可以通过 npm 方便地引入,在 web 页面上进行数据上报。
importSlsTrackerfrom'@aliyun-sls/web-track-browser'; constopts= { host: '${host}', // 所在地域的服务入口。例如cn-hangzhou.log.aliyuncs.comproject: '${project}', // Project名称。logstore: '${logstore}', // Logstore名称。}
API 也相当简单,4个函数实现了同步和异步、单条和多条的数据上报。
符合项目需求,很顺利地就被引用了。
Node.js SDK
而 Node.js SDK 和浏览器 JavaScript SDK 提供的 API 相比,则提供了更为丰富的功能,不仅有日志数据上报,还加入了对日志库的管理操作。
不仅如此,使用的时候增加必传参数 AccessKeyID 和 AccessKeySecret。
constALY=require('aliyun-sdk') varsls=newALY.SLS({ accessKeyId: "11****ut", //阿里云访问密钥AccessKey ID。更多信息,请参见访问密钥。阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维。 secretAccessKey: "TS****7Y", //阿里云访问密钥AccessKey Secret。 endpoint: 'http://cn-hangzhou.log.aliyuncs.com', //日志服务的域名。更多信息,请参见服务入口。此处以杭州为例,其它地域请根据实际情况填写。apiVersion: '2015-06-01'//SDK版本号,固定值。 }) constprojectName="you_project_name"// 必选,Project名称。constlogstoreName="your_logstore_name"// 必选,Project描述。// 创建Project。functioncreateProject () { constparam= { projectDetail: { projectName, description: "description about project" } } sls.createProject(param, function(err, data) { if (err) { console.error('error:', err) } else { console.log('创建project', data) } }) } // 运行function。createProject()
这两个特点就和桌面端的使用场景有些相悖了。
一方面桌面端也只需要进行数据上报,并不需要对日志库管理,丰富的功能有些冗余。另一方面作为支持公共写的日志库实际上并不需要这些配置信息,而且这些信息放在端上也存在安全风险。
那在桌面端上该怎么方便地上报日志数据呢?
解决思路
借用浏览器 JavaScript SDK
既然桌面端和浏览器一样都是用 JavaScript 写的,那是否可以直接在桌面端用浏览器 JavaScript SDK 呢?
很遗憾并不行,由于浏览器 JavaScript SDK 依赖了浏览器一些全局方法,会导致在 Node.js 中直接报错。
如果要强行在 Node.js 中运行,对其进行改造也是可以。
但是改造流程可能会比较长:
- 拉取代码仓库。
- 修改代码。
- 提交 PR 合并后等新版本发布或者自行再建一个仓库发布到 (T)NPM。
这一番操作下来已是远水救不了近火,所以换个思路自行封装一个轻量的用于客户端的 Node.js SDK。
基于官方 API 进行封装
首先考虑的是基于官方的 API 进行封装。
关于日志数据上报,官方提供了2个的方法: PostLogStoreLogs 和 PutLogs 。这两个方法各有一些限制:
- PostLogStoreLogs 只支持 PB格式的日志压缩数据。
- PutLogs 需要传入 LogGroup、LogTag 这些参数。
除此之外,它们都有一个共同点:需要 AK 授权。
这和浏览器 JavaScript SDK 相比明显麻烦很多,既然这样,我们只能往更底层去挖掘可能性了~
基于官方 SDK 逆向研发
既然我们想要在桌面端实现一个功能和浏览器 JavaScript SDK 一样的 SDK,那么不妨先来了解它做了什么。
当我们调用 send 函数的时候,实际上并没有立即发送日志,而是在随后的轮询间隔中,以数组的形式将日志数据进行上报。
所以这里推测采用了队列来缓存日志数据,减少网络请求次数。
同时仔细观察还可以发现为了不占用网络线程,这里采用了浏览器 sendBeacon 方法而不是 XMLHTTPRequest 或者 fetch 的方式,如果浏览器不支持这个方法则降级为 POST 请求。但由于这个 API 是浏览器提供的,所以桌面端只考虑采用 POST 请求方式。
通过浏览器调试工具观察请求网址可以发现其规律
https://{project}.{host}/logstores/{logstore}/track?APIVersion=0.6.0
总结一下:
- 1.建立日志队列,延迟、定时发送。
- 2.按照固定格式发送 POST 请求。
- 3.支持立即发送,对应 SDK 的 sendImmediate 方法。(这一点是后面开发中才发现的需求,比如页面离开时需要立即发送日志,避免缓存的日志数据丢失)
具体实现
一般而言,对于无状态的封装可以考虑用(纯)函数。
而 SDK 不仅要传入 project、host、logstore 这些配置数据,还需要建立内部的缓存队列,所以对于这种有状态的场景考虑使用类 Class。
对应代码如下:
classSlsTracker { _timeout=null; // 定时器实现延迟发送__logs__= []; // 缓存队列constructor({ host, project, logstore }) { // 配置信息this._host=host; this._project=project; this._logstore=logstore } }
至于延迟、定时发送的实现可以依赖 setTimeout 或 setInterval,由于发送应该是队列有值的时候才进行,所以 setInterval 轮询这种性能消耗较大的方式并不适用。
下面来考虑核心函数 send 的实现。
send 接收一个对象参数作为日志数据。所以现将它推送到队列,然后创立一个延迟的 POST 请求,但很可能之前已经创建了一个 POST 请求,所以通过 this._timeout 来判断,如果存在则不再创建。定到定时器触发时将队列所有数据发送。
这里需要注意的是,上报的数据值需要转换为字符串。
考虑到程序的鲁棒性,可以考虑限制队列长度和字符长度。
classSlsTracker { send(info){ this.__logs__.push(this._transString(info)); constoptions= { hostname: `${this._project}.${this._host}`, port: 443, path: `/logstores/${this._logstore}/track?APIVersion=0.6.0`, method: 'POST', headers: { 'Content-Type': 'application/json', } } if (this._timeout) return; this._timeout=setTimeout((o) => { constpayload=JSON.stringify({ __logs__: this.__logs__ }); o.headers['Content-Length'] =Buffer.byteLength(payload) constreq=https.request(o) req.write(payload); req.end(); this.__logs__= []; this._timeout=null; }, this._time, options); } . }
至于立即发送日志数据实现就相对容易了,取消之前定时器直接发送请求即可。
classSlsTracker { sendImmediate() { constpayload=JSON.stringify({ __logs__: this.__logs__ }); consto= { hostname: `${this._project}.${this._host}`, port: 443, path: `/logstores/${this._logstore}/track?APIVersion=0.6.0`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } } constreq=https.request(o) req.write(payload); req.end(); this.__logs__= []; clearTimeout(this._timeout); this._timeout=null; } }
总结
在依赖的代码库或平台不能满足要求时,首先考虑基于已有的信息自行进行研发,实在不行逆向研发也未尝不可。