手刃前端监控系统

我们为什么要做前端系统呢,可以明显地从下表看出来,前端的性能对于产品的价值提升还是蛮有帮助的,但是这些信息如果我们能实时的采集到,并且实施以监控和报警,让整个产品在产品线上一直保持高效的运作,这是我们的目标,做前端监控只是为了达到这个目标的手段。
性能收益
Google 延迟 400ms搜索量下降 0.59%
Bing 延迟 2s收入下降 4.3%
Yahoo 延迟 400ms流量下降 5-9%
Mozilla 页面打开减少 2.2s下载量提升 15.4%
Netflix 开启 Gzip性能提升 13.25% 带宽减少50%其次,前端监控能让我们即使发现问题(页面加载过慢等)或者错误(js错误,资源加载失败等),我们总不可能等待用户的反馈投诉,到那个时候花儿都谢了。也能够在我们改进前端代码性能或者相关措施后,对于性能的提升有多少,有一个清晰地数据前后对比,这样子也比较好写报告(KPI)。
于是撸起袖子,说干就干,自己参照了市面上的各钟前端监控系统,搞一个贴合公司需求的前端监控系统。并把其接入了内部系统做了进行测试。参与了从产品设计,前后端开发,SDK开发的过程,学习到了很多东西,下面开始分享。
技术选型
  • 前端: React, echarts, axios, webpack,antd, typescript等;
  • 后端:egg, typescript等;
  • 数据库: mysql, opentsdb;
  • 消息队列:kafka;
本来公司内部使用的全都是vue,为什么在这里我用了react,一是因为自己一直对react就持有兴趣,二来则是vue实在用的有点多了。总的感觉来说就是react通过jsx和render函数可以做到高自由度的封装,而vue则需要需要花更多的精力在封装上;但是react对于状态的管理比较花精力,一个不注意就会无限循环触发render函数,vue则相对简单些。
系统简介

监控了什么东西通过埋下SDK,上报数据,监控了以下两大类型数据:
1.页面加载性能数据性能数据的上报使用了opentsdb时序数据库(时序数据库非常适合监控类的数据),先看一下上报的具体数据,是一个数组,如下图所示:
有关opentsdb时序数据库的介绍可以看一下 这篇文章


来看看每个字段的具体含义:
字段含义
endpoint项目ID
metricview(视图).service(服务).topic(主题)_uri(标识符)
tasg记录一些非数值类型的值,类似打上一些标签
timestamp时间戳
step数据上报周期
counterType数据类型,默认是GAUGE类型(瞬时值),还有COUNTER类型(累加值)
value在metric条件下,这条数据的具体数值我这里的metric填的其中一条是frontMonitor.perf.time_dns指的是:前端监控系统-性能-时间-dns。
我们可以从metric中提取出性能数值类型的指标:
指标含义
load页面完全加载时间
readyHTML 加载完成时间,DOM ready时间
fpt首次渲染时间,白屏时间
tti首次可交互时间
domDOM解析耗时
dnsDNS解析耗时
tcpTCP解析耗时
sslSSL安全连接耗时,只在HTTPS存在
ttfb首字节(time to first byte)
trans数据传输耗时
res页面同步资源加载耗时还记录了些字符串类型指标: 如操作系统类型,浏览器类型,分辨率,页面path,域名,sdk版本等,都可以在tags里面找到。
根据以上指标可以做成如下页面:
性能总览:

页面性能:

2.资源加载数据同样也是用opentsdb,为了节省空间,这里我只展示其中数组的一条数据,如下图:


我这里的metric填的其中一条是frontMonitor.perf.resource_size指的是:前端监控系统-性能-资源-资源大小。
资源加载的数据我们可以用performance.getEntriesByType('resource')获得:

同样地,我们可以从metric中提取出性能数值类型的指标:
指标含义
size资源大小(decodedBodySize)
parseSize压缩后资源大小(transferSize)
request请求时间(responseStart - requestStart)
response响应时间(responseEnd - responseStart)还记录了些字符串类型指标: 如资源名字,资源类型,域名,协议等,都可以在tags里面找到。
根据以上指标可以做成资源加载页:


3.错误数据前端错误主要分为三类:
3.1脚本错误import BaseError from './base'import EventUtil from '../../utils/event'export default class ScriptError extends BaseError {  constructor () {    super('script')  }  start () {    this.attachEvent()  }  attachEvent () {    // 普通脚本你错误    EventUtil.add(window, 'error', (e) => {      this.handleError(e)    }, false)    // promise之类的错误    EventUtil.add(window, 'unhandledrejection', (e) => {      this.handleError(e)    }, false)  }  handleError (e) {    const {      message,      filename,      lineno,      colno,      reason,      type,      error    } = e    if (!message) {      this.send({        type,        message: reason.message,        stack: reason.stack      })    } else {      const lowMsg = message.toLowerCase()      if (lowMsg.includes('script error')) {        this.send({          message        })      } else {        this.send({          message,          filename,          lineno,          colno,          type,          stack: error.stack        })      }    }  }}复制代码
如果引用的脚本跨域,则需要另行设置:
  • <script type="rexr/javascript" src="https://crossorigin.com/app.js" crossorigin="anonymous"></script>要在引用的script标签中加上crossorigin="anonymous"
  • 服务器要返回的头信息包括:Access-Control-Allow-Origin: *
3.2资源加载错误可以捕获资源访问失败的错误,如img,script,style等。
import BaseError from './base'import EventUtil from '../../utils/event'import DOMReady from '../../utils/ready' // 兼容IE8export default class DocumentError extends BaseError {  constructor () {    super('document')  }  start () {    this.attachEvent()  }  attachEvent () {    DOMReady(() => {      EventUtil.add(document, 'error', (e) => {        const el = EventUtil.getTarget(e)        const tag = el.tagName.toLowerCase()        const src = el.src        this.send({          el,          tag,          src        })      }, true)    })  }}复制代码
对于此类型错误的捕获,需要满足一下两个条件:
  • 事件需要设置在捕获阶段
  • 资源必须在dom树上
3.3 ajax请求错误这里需要对原生xhr进行打补丁,从而拦截ajax请求
import BaseError from "./base";// 过滤自身服务器上报时发生错误const urlWhiteList = [  '//api.b1anker.com/msg',  '//api.b1anker.com/d.gif/',  '//api.b1anker.com/form/push']export default class AjaxError extends BaseError {  constructor () {    super('ajax')  }  start () {    this.patch()  }  patch () {    if (!XMLHttpRequest && !window.ActiveXObject) {      return    }    // patch    const XHR = XMLHttpRequest || window.ActiveXObject    const open = XHR.prototype.open    let METHOD = ''    let URL = ''    try {      XHR.prototype.open = function (method, url) {        // 保存请求方法和请求链接        METHOD = method        URL = url        open.call(this, method, url, true)      }    } catch (err) {      console.log(err)    }      const send = XHR.prototype.send    const self = this    XHR.prototype.send = function (data = null) {      // 获取刚刚暂存的请求链接      let CURRENT_URL = URL      try {        this.addEventListener('readystatechange', () => {          if (this.readyState === 4) {            if (this.status !== 200 && this.status !== 304) {              // 不上报自身的报错,如上报服务器出错等              if (urlWhiteList.some((url) => CURRENT_URL.includes(url))) {                return              }              const name = this.statusText              const reponse = this.responseText              const url = this.responseURL              const status = this.status              const withCredentials = this.withCredentials              self.send({                name,                reponse,                url,                status,                withCredentials,                data,                method: METHOD              })            }          }        }, false)        send.call(this, data)      } catch (err) {        console.log(err)      }    }  }}复制代码3.4 fetch错误这里也对原生fetch进行了hook:
import BaseError from './base'export default class FetchError extends BaseError {  constructor() {    super('fetch')  }  start () {    this.patch()  }  patch() {    if (!window.fetch) {      return null    }    let _fetch = fetch    const self = this    window.fetch = function() {      const params = self.parseArgs(arguments)      return _fetch        .apply(this, arguments)        .then(self.checkStatus)        .catch(async (err) => {          const { response } = err          if (response) {            const data = await response.text()            self.send({              name: response.statusText,              type: response.type,              data,              status: response.status,              url: response.url,              redirected: response.redirected,              method: params.method,              credentials: params.credentials,              mode: params.mode            })          } else {            self.send({              name: err.message,              method: params.method,              credentials: params.credentials,              mode: params.mode,              url: params.url            })          }          return err        })    }  }  checkStatus (response) {    if (response.status >= 200 && response.status < 300) {      return response    } else {      var error = new Error(response.statusText)      error.response = response      throw error    }  }  parseArgs (args) {    const parms = {      method: 'GET',      type: 'fetch',      mode: 'cors',      credentials: 'same-origin'    }    args = Array.prototype.slice.apply(args)    if (!args || !args.length) {      return parms    }    try {      if (args.length === 1) {        if (typeof args[0] === 'string') {          parms.url = args[0]        } else if (typeof args[0] === 'object') {          this.setParams(parms, args[0])        }      } else {        parms.url = args[0]        this.setParams(parms, args[1])      }    } catch (err) {      throw err    } finally {      return parms    }  }  setParams (params, newParams) {    params.url = newParams.url || params.url    params.method = newParams.method    params.credentials = newParams.credentials || params.credentials    params.mode = newParams.mode || params.mode    return params  }}复制代码4.自定义数据上报有时候用户需要监控自己页面上的一些数据,比如说直播视频中,监控启动这个播放器的时间,又或者是播放器的播放帧率等。基于此需求,我们简单地来扩展一波sdk:
// customReport.jsimport BaseReport from './baseReport'import throttle from 'lodash/throttle'import isEmpty from 'lodash/isEmpty'// 暂时只支持数值类型的上报const defaultOptions = {  type: 'number'}export default class CustomReport extends BaseReport {  constructor (options = {    delay: 5000  }) {    super('custom');    this.skynetQuque = [];    // 用户上报有可能是多次上报,所以做了个防抖,把数据缓存起来然后再统一上报    this.sendToSkynetThrottled = throttle(this.sendToSkynet.bind(this), options.delay, {      leading: false,      trailing: true    })  }  upload (options = defaultOptions, data) {    const { type } = options;    if (type === 'number') {      // 数值类型的上报      this.uploadToSkynet(data);    }  }  uploadToSkynet (data) {    this.skynetLoop(data);  }    // 把数据缓存到队列里,等时间到了,统一上报  skynetLoop (data) {    this.skynetQuque.push(this.formatSkynetData(data));    this.sendToSkynetThrottled(this.skynetQuque)  }  // 把数据格式化成opentsdb的上报格式  formatSkynetData (data) {    const { module, metric, tags, value } = data;    const result = {      metric: `frontMonitor.custom.${module}_${metric}`,      endpoint: `${window.__HBI.id}`,      counterType: "GAUGE",      step: 1,      value,      timestamp: parseInt((new Date()).getTime() / 1000)    };    if (!isEmpty(tags)) {      // 如果tags不是空,则需要做一些转换处理,处理成k1=v1,k2=v2形式的字符串      result.tags = Object.entries(tags).map(([key, value]) => `${key}=${value}`).join(',')    }    return result  }  // 上报数据,并把队列清空  sendToSkynet (data) {    this.sender.doSendToSkynet(data)    this.skynetQuque = []  }}复制代码这样子,开发者就可以用如下代码进行上报:
if (window.__CUSTOM_REPORT__) {  const data = {    module: 'player',    metric: 'openTime',    value: 100,    tags: {      browser: 'Chrome69',      op: 'mac'    }  }  c.upload({    type: 'number'  }, data)}复制代码遇到了什么问题1.上报跨域问题由于每个网站引用sdk的时候,sdk上报的地址是固定的(专门用来做上报数据处理,跟目标网站非同源),就会发生跨域问题,可以利用form表单和iframe结合解决跨越问题:
class FormPost {  postData (url, data) {    let formId = this.getId('form');    let iframeId = this.getId('iframe');    let form = this.initForm(formId, iframeId, url, data);    let ifr = this.initIframe(iframeId);    return this.doPost(ifr, form);  }  doPost (ifr, form) {    return new Promise(resolve => {      let target = document.head || document.getElementsByTagName('head')[0];      !target && (target = document.body);      target.appendChild(form);      target.appendChild(ifr);      ifr.onload = () => {        // iframe加载完成后卸载form和iframe        form.parentNode.removeChild(form);        ifr.parentNode.removeChild(ifr);        resolve();      }      form.submit();    });  }  getId (prefix) {    !prefix && (prefix = '');    return `${prefix}${new Date().getTime()}${parseInt(Math.random() * 10000)}`;  }  initForm (id, ifrId, url, data) {    let fo = document.createElement('form');    fo.setAttribute('method', 'post');    fo.setAttribute('action', url);    fo.setAttribute('id', id);    fo.setAttribute('target', ifrId);// 在iframe中加载    fo.style.display = 'none';    for (let k in data) {      let d = data[k];      let inTag = document.createElement('input');      inTag.setAttribute('name', k);      inTag.setAttribute('value', d);      fo.appendChild(inTag);    }    return fo;  }  initIframe (id) {    let ifr = (/MSIE (6|7|8)/).test(navigator.userAgent) ?      document.createElement(`<iframe name="${id}">`) :      document.createElement('iframe')    ifr.setAttribute('id', id);    ifr.setAttribute('name', id);    ifr.style.display = 'none';    return ifr;  }}export default new FormPost();复制代码2.数据采集维度指标爆炸由于使用的是opentsdb时序数据库,一开始设计上报资源加载数据的时候,想着把uri设为资源名字,然后把request,response, size, parseSize等信息放到tags里,value则随便填个数字就好,一条资源只用上报一条数据即可。这样子上报是可以正常上报的,但是由于在tags里存数值类型的值(数值的具体指太多了),导致数据组合爆炸,数据根本就查不出来。
优化前上报数据格式:
{    "metric": "frontMonitor.perf.resource_app.js",    "value": 0,    "endpoint": "3",    "timestamp": 1539068028,    "tags": "size=177062,parseSize=300,request=200,response=300,type=script,origin=huya.com,protocol=h2",    "counterType": "GAUGE",    "step": 1}复制代码所以只好把uri设为request,response, size, parseSize等,把资源名字存到tags里,这样子每条资源就要上报多条数据。虽然会增加上报内容体积,但是这样可以有效地降低维度,使得数据可以快速查出来。
优化后上报数据格式:
{    "metric": "frontMonitor.perf.resource_size",    "value": 177062,    "endpoint": "3",    "timestamp": 1539068028,    "tags": "name=app.js,type=script,origin=huya.com,protocol=h2",    "counterType": "GAUGE",    "step": 1}复制代码3.上报并发量大考虑如果把系统接入用户量大的网站中,就会遇到同一秒收到多条数据的情况。当遇到这种情况,opentsdb就会出现一个覆盖问题,具体原因就是上报的数据中除了value字段,其他字段都一样的话,opentsdb就会把这一秒内的最后一条数据覆盖掉前面的数据。当时一个解决办法就是给tags字段里添加unique字段,并通过一些简单的算法让它去到唯一值,这样就可以解决覆盖问题。
但是这样并不完美,主要有两个原因,第一个原因是在画出来的图表中会出现在x轴上的同一个点上会出现多个y值,所以只能对图表做些适应,在前端聚合这些数据(在服务端做会增加服务端压力);第二个原因是数据量太大,会对服务器造成压力的同时也让查询效率变慢,于是利用kafak做了队列处理,对这些数据做分钟维度的归并,再上报到opentsdb,这样一箭双雕,即解决了覆盖问题,也能减少服务器压力并提高查询效率。
4.部署的坑4.1前端构建因为项目的发布是要通过公司的统一发布系统进行发布,并且后端用的是egg框架,所以需要先把前端项目构建到后端项目的app/public文件夹下 :


即需要修改前端的构建项目为后端项目中的app/public下:


4.2后端构建由于使用了egg + typescript,所以使用生产环境代码的时候需要多一个用tsc编译成js的步骤,不然会报错,以下是构建脚本命令:
"scripts": {    "start": "egg-scripts start --daemon --title=egg-server-monitor-backend --port=8088",    "stop": "egg-scripts stop --title=egg-server-monitor-backend --port=8088",    "dev": "egg-bin dev -r egg-ts-helper/register --port=8088",    "debug": "egg-bin debug -r egg-ts-helper/register",    "test-local": "egg-bin test -r egg-ts-helper/register",    "test": "npm run lint -- --fix && npm run test-local",    "cov": "egg-bin cov -r egg-ts-helper/register",    "tsc": "ets && tsc -p tsconfig.json",    "ci": "npm run lint && npm run cov && npm run tsc",    "autod": "autod",    "lint": "tslint --project . -c tslint.json",    "clean": "ets clean",    "pack": "npm run tsc && rm -rf ./node_modules && npm i --production && tar -zcvf ../ROOT.tgz ./ && npm run reDevEnv && npm run clean",    "reDevEnv": "rm -rf ./node_modules && npm i",    "zip": "node ./zip.js"}复制代码我们构建的时候,用的是pack指令,即使用npm run pack或者yarn run pack即可,其实就是执行npm run tsc && rm -rf ./node_modules && npm i --production && tar -zcvf ../ROOT.tgz ./ && npm run reDevEnv && npm run clean。执行这条指令发生了如下几个步骤:
  • 先用tsc编译成js代码;
  • 删掉node_modules代码;
  • 安装生产环境的node_modules代码;
  • 把项目压缩成.tgz格式;
  • 删掉node_modules代码;
  • 重新安装开发环境的node_modules代码;
  • 删掉tsc编译成的js代码;
4.3后端使用前端静态资源由于是前后端分离项目,并没有用到egg提供的模板功能,所以需要写一个中间件,因为egg是基于koa来写的,所以koa的一些中间件是也是可以用的,来指定访问路由时引用的页面:
// kstatic.tsimport * as KoaStatic from 'koa-static';import * as path from 'path';export default (options) => {  // 使用koa-static中间件  return KoaStatic(path.join(__dirname, '../public'), options);};复制代码然后再config/config.default.ts中添加代码config.middleware = ['kstatic']即可
4.4修复路由指向由于前端页面使用react-router-dom,并且使用的是history模式,当访问根页面时是可以正常加载页面和js等文件的,但是当我们需要访问二级甚至三级路由或者刷新页面时,如xxx.huya.com/test/100时,就可能会出现js加载失败的情况,从而导致页面渲染失败。
所以我们需要修复这些本地静态资源的访问路径,当访问的时候,让他们从根目录上去找,因此我们再添加一个中间件:
// historyApiFaalback.tsimport * as url from 'url';export default (options) => {  return function historyApiFallback(ctx, next) {    options.logger = ctx.logger;    const logger = getLogger(options);    logger.info(ctx.url);    // 如果不是get请求或者非html则跳过    if (ctx.method !== 'GET' || !ctx.accepts(options.accepts || 'html')) {      return next();    }    const parsedUrl = url.parse(ctx.url);    let rewriteTarget;    options.rewrites = options.rewrites || [];    // 根据规则进行url跳转处理    for (let i = 0; i < options.rewrites.length; i++) {      const rewrite = options.rewrites;      let match;      if (parsedUrl && parsedUrl.pathname) {        match = parsedUrl.pathname.match(rewrite.from);      } else {        match = '';      }      if (match !== null) {        rewriteTarget = evaluateRewriteRule(parsedUrl, match, rewrite.to, ctx);        ctx.url = rewriteTarget;        return next();      }    }    const pathname = parsedUrl.pathname;    if (      pathname &&      pathname.lastIndexOf('.') > pathname.lastIndexOf('/') &&      options.disableDotRule !== true    ) {      return next();    }    rewriteTarget = options.index || '/index.html';    logger('Rewriting', ctx.method, ctx.url, 'to', rewriteTarget);    ctx.url = rewriteTarget;    return next();  };};function evaluateRewriteRule(parsedUrl, match, rule, ctx) {  if (typeof rule === 'string') {    return rule;  } else if (typeof rule !== 'function') {    throw new Error('Rewrite rule can only be of type string or function.');  }  return rule({ parsedUrl, match, ctx });}function getLogger(_options) {  if (_options && _options.verbose) {    return console.log.bind(console);  } else if (_options && _options.logger) {    return _options.logger;  }}复制代码然后在config/config.default.ts里之前的中间件代码中添加:config.middleware = ['historyApiFallback', 'kstatic'];,注意要按顺序。
并且再添加选项代码:
config.historyApiFallback = {  ignore: [/.*\..+$/, /api.*/],  rewrites: [{ from: /.*/, to: '/' }]};复制代码5sdk版本发布管理一开始为了方便,就把编译后的sdk直接丢到cdn上,然后各个系统直接引用这个脚本即可。但是这个的风险比较大,主要有两点原因,第一点是当sdk没有做好充分测试就上传到cdn上的话,sdk如果出现bug,则所有系统都会受到影响。第二点就是对于不同的系统对于sdk的功能需求是不一样的,所以用同一个sdk的话,维护起来就比较困难。考虑这两点,于是做了sdk版本发布管理的功能,以下是具体流程;


5.1 sdk编译:向服务获取当前最新版本号,并更新一个版本号;构建多入口,根据功能模块将sdk切割成多个文件,如:sdk.perf.js和sdk.error.js(分别是性能监控,错误监控)。然后将几个文件合并成一个文件,并加上各模块之间加上切割符,以备后续分离sdk;
const axios = require('axios')const webpack = require('webpack')const webpackConfig = require('../webpack.config.prod.js')const fs = require('fs')const path = require('path')const OUTPUT_DIR = '../dist/'const resolve = (dir) => path.join(__dirname, OUTPUT_DIR, dir)const combineFiles = (bases, error, target) => {  // 合并sdk  let data = ''  // 合并公共模块  bases.forEach((file) => {    data += fs.readFileSync(resolve(file))    fs.unlinkSync(resolve(file))  })  // 添加错误监控切割符,合并错误监控代码  data += '/*HBI-SDK-ERROR-MONITOR*/'  data += fs.readFileSync(resolve(error))  fs.unlinkSync(resolve(error))  fs.writeFileSync(resolve(target), data)}async function build () {  // 获取sdk最新版本号,新更新版本号  const version = await axios.get('https://api.b1anker.com/api/v0/systemVariable/list?name=SDK_VERSION')    .then(({data: { data }}) => {      return data[0].value;    });    webpack(webpackConfig({      version    }), (err, stats) => {      if (err || stats.hasErrors()) {        console.error('构建失败')        throw err      } else {        // 合并sdk模块        combineFiles([          'hbi.vendor.js',          'hbi.commons.js',          'hbi.performance.js'        ], 'hbi.error.js', 'hbi.js')        console.error('构建成功: v' + version);      }    });}build()复制代码5.2 sdk上传:sdk上传到服务器本地,当发布的时候获取相应版本进行后续操作中。其中sdk的上传操作应该由人手动操作,这样可以记录相应的信息,以便出问题或有需求的时候回滚:



并且多系统发布:
在进行发布的时候,后端从本地找出对应版本的sdk,并且查出系统对应的sdk配置,从而决定给sdk配置什么功能,也就是切割sdk;在生成相应sdk的时候,给sdk以项目的flag(创建的时候设置)来命名sdk的名字(如b1anker.sdk.js),这样子就可以做到sdk的发布只会作用到使用了这个flag的系统;
export default class SDK extends Service {    // 发布sdk    public async pulishSDK (projects: string[], version: string) {        const success: any[] = [];        const error: any[] = [];        for (let i = 0; i < projects.length; i++) {          const id: number = Number(projects);          try {            // 获取项目相应信息            const { flag } = await this.service.project.getProject(id);            // 根据项目flag和sdk版本生成对应的sdk            await this.uploadSDKToCDN(flag, version);            // 上传至cdn            await this.service.sdk.updateSdkInfo(id, version);            success.push(id);          } catch (err) {            error.push(id);            this.logger.error(err);          }        }        return {          success,          error        };   }      public async uploadSDKToCDN (flag: string, version: string) {    // 从数据库中查找出项目的错误配置信息    const error = await this.app.mysql.query(`select error from project a inner join project_sdk_setting b where a.id = b.pid and a.flag = '${flag}';`)    // 默认关闭错误监控    let enableError = false;    // 处理错误配置    try {      if (JSON.parse(error[0].error).length) {        enableError = true;      }    } catch (err) {      throw err;    }    const sdkPath = path.join(os.homedir(), 'sdk', `b1anker-${version}.js`);    const cdnPath = `b1anker/${flag}.sdk.js`;    // 根据项目的sdk配置来生成最终sdk    if (enableError) {      // 没有开启错误监控则修改下名字就可以直接上传到cdn      await this.service.util.uploadFileToCdn(sdkPath, cdnPath);    } else {      const sdkData = fs.readFileSync(sdkPath).toString();      // 根据切割符切割sdk,然后生成新的sdk      const withoutErrorMonitor = sdkData.split('/*HBI-SDK-ERROR-MONITOR*/')[0];      // 上传到cdn      await this.service.util.uploadBufferToCdn(cdnPath, new Buffer(withoutErrorMonitor));    }  }}复制代码总结通过这个项目,个人接触到了很多前端以外的知识,系统构思,原型设计,后端逻辑处理,mysql关系型数据库,opentsdb时序数据库,kafak消息队列等,也让自己对一个完整的系统有了较为清晰的认识,也能更好理解不同技术上的瓶颈,尤其是前端和后端的关注方向。也扩展了自己的前端技术栈,对react有了一定的认识。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值