1. 程式人生 > >手刃前端監控系統

手刃前端監控系統

為什麼要做前端監控

我們為什麼要做前端系統呢,可以明顯地從下表看出來,前端的效能對於產品的價值提升還是蠻有幫助的,但是這些資訊如果我們能實時的採集到,並且實施以監控和報警,讓整個產品在產品線上一直保持高效的運作,這是我們的目標,做前端監控只是為了達到這個目標的手段。

效能 收益
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開發的過程,學習到了很多東西,下面開始分享。

技術選型

  • 前端: Reactecharts, axioswebpackantd, typescript等;
  • 後端:egg, typescript等;
  • 資料庫: mysql, opentsdb
  • 訊息佇列:kafka

本來公司內部使用的全都是vue,為什麼在這裡我用了react,一是因為自己一直對react就持有興趣,二來則是vue實在用的有點多了。總的感覺來說就是react通過jsxrender函式可以做到高自由度的封裝,而vue則需要需要花更多的精力在封裝上;但是react對於狀態的管理比較花精力,一個不注意就會無限迴圈觸發render函式,vue

則相對簡單些。

系統簡介

監控了什麼東西

通過埋下SDK,上報資料,監控了以下兩大型別資料:

1.頁面載入效能資料

效能資料的上報使用了opentsdb時序資料庫(時序資料庫非常適合監控類的資料),先看一下上報的具體資料,是一個數組,如下圖所示:

有關opentsdb時序資料庫的介紹可以看一下這篇文章

來看看每個欄位的具體含義:

欄位 含義
endpoint 專案ID
metric view(檢視).service(服務).topic(主題)_uri(識別符號)
tasg 記錄一些非數值型別的值,類似打上一些標籤
timestamp 時間戳
step 資料上報週期
counterType 資料型別,預設是GAUGE型別(瞬時值),還有COUNTER型別(累加值)
value 在metric條件下,這條資料的具體數值

我這裡的metric填的其中一條是frontMonitor.perf.time_dns指的是:前端監控系統-效能-時間-dns。

我們可以從metric中提取出效能數值型別的指標:

指標 含義
load 頁面完全載入時間
ready HTML 載入完成時間,DOM ready時間
fpt 首次渲染時間,白屏時間
tti 首次可互動時間
dom DOM解析耗時
dns DNS解析耗時
tcp TCP解析耗時
ssl SSL安全連線耗時,只在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' // 相容IE8

export 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.js
import 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設為資源名字,然後把requestresponse, 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設為requestresponse, 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.ts
import * 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.ts
import * 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[i];
      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.jssdk.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[i]);
          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有了一定的認識。