擼一個前端監控系統(React + Node + Mysql + Webpack plugin + Docker)—— (上)

前言

俗話說,不依賴業務場景的系統設計,叫耍流氓。
構想設計這個系統也是有着業務背景的,所在公司的產品既面向 C 端,也面向 B 端。在咱們開發落地業務時,常常遇到一類很是頭疼的問題:css

客戶:這個頁面怎麼報錯了?讓大家開發給看一下。

客戶:爲何這個模塊沒數據,控制檯還有報錯,怎麼回事?

苦逼coder: 請問您能具體描述一些出錯的場景和步驟嗎?最好能幫忙截圖一些頁面細節。

等客戶操做半天, 給來一堆有用沒用的信息......

感嘆!寫代碼爲何這麼難,業務怎麼這麼讓人頭疼...
複製代碼

因此我決定動手擼一個監控系統,用來解決錯誤收集和問題回溯。讓咱們在追溯以往出現的問題時,可以省時省力,而且可以統計到系統的漏洞。html

閱讀收益

「 我讀你寫的文章,對我有什麼用?」前端

  • 能夠掌握如何 收集前端錯誤
  • 學習如何設計一個 webpack 插件
  • 如何調試插件
  • 瞭解 Node服務端 的基本內容
  • 瞭解 數據庫 的基本操做
  • 瞭解 docker 的簡單使用
  • 起一個 nginx 服務器
  • 學會一種姿式本身 搭建一個生產環境
  • 學會 部署 服務

系統設計

系統設計的基本思路不一樣於解決業務邏輯,系統設計上,咱們應該 從大到小,由總體到局部,思考如下問題:node

步驟 問題 方案
1 咱們遇到了什麼問題 ? 前端錯誤難以追溯
2 解決問題的大體思路 收集錯誤,分析錯誤,展現錯誤
3 將解決方案轉換爲系統模型 咱們須要一整套可以作錯誤收集, 存儲, 分析, 展現的系統
4 拆分子系統 前端系統;後端系統;插件系統;
5 子系統模塊分割 根據各系統特性和解決的具體問題做劃分
6 模塊實現 擼代碼
7 系統串聯調試 系統間串聯調試
8 系統優化 思考已實現的內容能不能解決最初問題,哪裏還能更好 ?

這個系統咱們將從前端收集錯誤,上傳至服務器,經由服務器解析存儲並提供消費接口,並在必定程度上解析 source-map 來輸出源碼錯誤信息。mysql

基於以上,咱們搭建一個前端工程做爲實驗室,來生產錯誤數據, 經過 webpack plugin 來作 source-map 上傳,在服務端進行解析。服務端咱們以 node 做爲開發語言,並選用 mysql 數據庫來存儲錯誤信息,最終將收集到的錯誤展現到前端。react

前端實現

爲了可以快速的搭建一個前端工程,咱們選用 Create-React-App 做爲腳手架來初始化項目。webpack

1. 先安裝 Cra

npm i -g  create-react-app

2. 初始化項目

npx create-react-app react-repo

3. 啓動應用

cd react-repo
npm start

複製代碼

到這裏都很簡單,更多內容見官網ios

錯誤類型

對於前端出現的錯誤,咱們分爲兩類,一類是 頁面錯誤, 如一系列致使頁面異常,頁面白屏的錯誤;一類是 網絡錯誤,即因爲服務端異常所致使的錯誤,或者不符合既定先後端約束的錯誤。nginx

錯誤信息結構

錯誤信息消息體,包含如下信息:git

  • 用戶信息
  • 錯誤信息
  • 用戶設備信息

其中 用戶信息錯誤信息 咱們須要本身拼裝上傳,用戶設備信息咱們能夠在服務端獲取,無需上傳浪費資源。

/** */
interface ErrorInfo {
  /** * 用戶id */
  userId: string;
  /** * 帳戶名稱 */
  username: string;
  /** * 租戶 */
  tenant: string;
  /** * 請求源地址 */
  origin: string;  
  /** * 用戶設備 */
  userAgent: string;
  /** * 錯誤信息單元 */
  error: {
    /** * 錯誤信息 */
    message: string;
    /** * 錯誤棧,詳細信息 */
    stack: string;
    /** * 錯誤文件名稱 */
    filename: string;
    /** * 錯誤行 */
    line?: number;
    /** * 錯誤列 */
    column?: number;
    /** * 錯誤類型 */
    type: string;
  };
  // 發生錯誤的時間戳
  timestamp: number;
};
複製代碼

攔截全局錯誤

最早想到的是,處理全局錯誤,在瀏覽器環境中,咱們能夠監聽 onError 事件

window.addEventListener(
    "error",
    (msg, url, row, col, error) => {
      // 錯誤信息處理通道
      processErrorInfo(msg, url, row, col, error);
    },
  );
複製代碼

這裏使用 addEventListener, 能夠保證不影響其餘監聽 error 事件的事務執行。

使用 ErrorBoundary

在 React 中,有一個 componentDidCatch 的生命週期,它可以捕獲子層組件拋出的錯誤,在這裏咱們利用它來捕獲內層組件的錯誤,並增長友好性錯誤提示。

componentDidCatch(error, errorInfo) {
    processErrorInfo(error);
}
複製代碼

ErrorBoundary 僅能捕獲未被內層捕獲的錯誤,在一些邏輯清晰的組件中,咱們能夠經過邏輯判斷來主動上報錯誤,依然使用 processErrorInfo 的錯誤信息處理通道。

攔截網絡錯誤

在這個項目中,我使用的 axios 做爲咱們的 ajax 庫,它提供 interceptor 攔截器來預處理 request 和 response,因此咱們能夠在這裏進行統一的網絡錯誤攔截。

建議在咱們的項目中對於 ajax 都進行統一封裝,這樣在對於請求作一致化處理時很是方便。

import axios from "axios";

axios.interceptors.response.use(
  response => response,
  error => {
    // 對網絡錯誤進行攔截
    processErrorInfo(error);
    return Promise.reject(error);
  }
);
複製代碼

這裏選擇在錯誤攔截後,依然繼續拋出錯誤是爲了保證請求的連貫性,由於在具體的業務層面咱們有可能須要對錯誤信息進行一些處理。固然您也能夠根據具體的業務作相應的調整。

錯誤格式化

觀察以上的幾層攔截方式,能夠發現,咱們都使用了一個 processErrorInfo 的函數。因爲咱們收集到的錯誤類型衆多,所以須要進行格式化,而後再上傳到服務器。

// 生成 YYYY-MM-DD hh:mm:ss 格式的時間
function datetime() {
  const d = new Date();
  const time = d.toString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, "$1");
  const day = d.toJSON().slice(0, 10);
  return `${day} ${time}`;
}

// 生產最終的上報數據,包含了用戶信息和錯誤信息
const processErrorInfo = (info) => {
  let col;
  let line;
  let filename;
  let message;
  let stack;
  let type = "error";

  if (info instanceof ErrorEvent) {
    col = info.colno;
    line = info.lineno;
    filename = info.filename;
    message = info.message;
    stack = info.error.stack;
    type = info.type;
  } else if (info instanceof Error) {
    message = info.message;
    stack = info.stack;
  }
  
  // 僞造一份用戶信息
  // 這裏應該對接咱們實際業務中的用戶信息
  const userInfo = {
    user_id: "ein", // 用戶id
    user_name: "ein@mail.com",  // 用戶名稱
    tenant: "mail" // 租戶名稱
  }

  return {
    ...userInfo,
    col,
    line,
    filename,
    message,
    stack,
    type,
    timestamp: datetime()
  };
}
複製代碼

組裝完錯誤信息後,下面進行錯誤上報。

錯誤上傳

/** * @param {格式化後的錯誤信息} error */
export const uploadError = error => {
  axios
    .post("/errors/upload", JSON.stringify(data))
    .then(res => {
      console.log("upload result", res);
    })
    .catch(err => {
      console.log("upload error", err);
    });
};
複製代碼

咱們設定的後端路由 /errors/upload 來接收錯誤信息, 到這裏,前端收集,格式化,上傳錯誤的步驟就基本完成了。

在這些錯誤信息中,最重要的就是 stack 字段了,它包含了咱們出錯的具體信息,這一部分必定不能缺失。

細心的同窗可能發現,咱們上傳的錯誤信息中缺乏了col, line, filename 幾個字段,這幾個字段是錯誤的文件名和行列號,即出錯的具體位置。在有些場景下,咱們時沒法從回調事件參數中直接獲取這幾個字段的,但也不是沒有辦法解決,怎麼解決?咱們繼續往下看。

服務端實現

首先思考一個問題:爲何這個系統中咱們須要一個服務端 ? 能不能純前端完成這個系統 ?

「 瀏覽器不是有 localStorage, sessionStorage 這樣的 API 嗎? 也有 indexdb 這樣的瀏覽器數據庫。
  咱們能夠用它來存儲錯誤,而後進行集中展現。 」
複製代碼

可能有的同窗有以上的疑問 ? 在瀏覽器環境當中,咱們一直缺失一個數據持久層,所以在瀏覽器的不斷演進當中,添加了一些可以用來存儲數據的 API。

可是這些存儲方式自己有存儲量的限制,再者,用戶使用的瀏覽器豐富多樣,咱們如何同步這些數據 ? 如何保證接口的一致性 ?

咱們須要的是一個可以面向所有用戶的數據存儲設施,而且可以知足高的併發量,所以須要一個後端服務來完成這個工做。

綜上考慮,咱們選用 Node 做爲後端開發語言,第一它對於併發量有很好的支持,第二 Node 對於前端來講容易上手,數據庫咱們選用 mysql 來實現。

搭建基礎設施

要作後端服務,首先咱們須要搭建一個 node 工程。在這裏,我選用 Koa2 做爲後端框架,這是 Express 原團隊打造的 node 框架,支持 async 語法。固然你也能夠選擇 ExpressEgg

腳手架工具可使用 koa-generator 或其餘可選項快速生產一個 Node 項目骨架。

建議 不熟悉服務端開發或者不熟悉 Node 開發的同窗自行搭建一個工程,這裏咱們本身來搭建一個工程。

1. 建立一個工程目錄並初始化
mkdir error-monitor-node-server
cd error-monitor-node-server
npm init
git init

2. 安裝依賴項

// 咱們先安裝核心的幾個依賴
npm i koa koa-router mysql -S

3. 目錄結構

- config  // 系統相關設置
- controller  
- logs   // 日誌
- middleware  // 中間件 
- mysql  // 數據庫 
- routers  // 路由
- utils  // 工具類
index.js // 入口文件
複製代碼

具體的依賴見下圖,後續咱們都會用到,能夠提早安裝好或者用的時候再安裝均可。

在系統中,咱們用到了許多 ES 高版本語法,所以須要引入 babel 來作語法轉換。

Koa2

koa2 封裝了 Node 原生 API, 主要處理了 request 和 response 部分。提供了一個被稱爲 context 的運行時變量,將一些經常使用的操做掛載在了這個對象屬性上。並約定了中間件的組織方式,以著名的 洋蔥模型 順序來執行中間件。

有興趣的同窗能夠閱讀一下 源碼,比較簡短精煉,其中 koa-compose 包是中間件的實現。

錯誤獲取接口

const router = require("koa-router")();
const errorsController = require("../controller/c-error");

// 上傳錯誤信息
router.post("/errors/upload", errorsController.uploadErrors);
複製代碼
const uploadErrors = async ctx => {
  try {
    // request body
    const body = ctx.request.body;
    // 將錯誤信息寫入表中
    await ctx.mysql.whriteError(body);
    ctx.response.type = "json";
    ctx.body = "true";
  } catch (e) {
    ctx.statusCode = 500;
    ctx.response.type = "json";
    ctx.body = "false";
  }
};
複製代碼
const Koa = require("Koa");
const mysql = require("./mysql/index");

const app = new Koa();

app.context.mysql = mysql;
複製代碼

這裏咱們使用 koa-router 來處理路由,並將 router 和 controller 分開,這樣讓結構保持清晰。

uploadError 函數來處理具體的業務邏輯,這裏咱們接受請求傳來的參數,即咱們上面在前端經過接口傳過來的錯誤信息。

這裏的 ctx 就是咱們上面提到的 context,它會在中間件之間傳遞。咱們將 mysql 的一個實例也綁定在了 ctx 上,這樣就不須要每一個文件進行 require 操做。

mysql

下來咱們須要編寫數據庫部分的邏輯了。

安裝 mysql ---

npm i mysql -S

數據庫配置 ---

databaseConfig: {
    database: "error_monitor_ci",
    user: "root",
    password: "1234567890",
    host: "localhost"
}
  
操做數據庫 --- 

const mysql = require("mysql");
const { databaseConfig } = require("../config/default");
const sqls = require("./sqls");
const { logger } = require("../middleware/log");

const connection = mysql.createConnection(databaseConfig);

class MySQL {
  constructor() {
    this.table = "errors";
    this.init();
  }

  init = () => {
    // 初始化表
    connection.query(sqls.createTable(this.table), (err, res, fields) => {
      if (err) {
        logger.error("connect errors table failed...", err);
      }
    });
  };

  whriteError = error =>
    new Promise((r, j) => {
      connection.query(sqls.writeError(this.table), error, (err, res) => {
        if (err) {
          logger.error(err);
          j(err);
        } else {
          r(res);
        }
      });
    });
}
複製代碼

能夠看到,操做數據庫,咱們分爲了如下幾部:

  1. 鏈接數據庫
  2. 建表
  3. 執行插入語句

將 sql 語句,咱們單獨提了出來,若是沒有擴展的計劃,也能夠將 sql 語句和數據庫操做邏輯放在一塊兒。

/** * 注意: * 1. 表名帶引號爲小寫,不帶默認大寫 * 2. 列名帶引號爲小寫,不帶默認大寫 * 3. 字段類型標註須要大寫 * 4. 建表語句末尾不能加逗號 * 6. 默認取時間戳 current_timestamp * 7. 長文本不適合用char來存儲,能夠選擇使用txt類型 */
module.exports = {
  createTable: tb => `create table if not exists ${tb}( id int primary key auto_increment, user_id varchar(255) not null, user_name varchar(255) not null, tenant varchar(255) not null, timestamp datetime default now(), col int(1), line int(1), filename varchar(255) , message varchar(255) not null, stack text not null, type varchar(255) not null, sourcemap text ) engine=InnoDB auto_increment=0 default charset=utf8`,
  writeError: tb => `INSERT INTO ${tb} SET ?`,
};
複製代碼

實例化 MySQL 類的時候,咱們會先執行一個 init 方法,這個時候會進行建表操做。

當表不存在的時候,咱們會進行建表操做。

writeError 就是剛纔咱們在接受到 /errors/upload 請求時,執行的 ctx.mysql.whriteError 方法。

CORS

下面這張圖,有沒有很熟悉 ?

因爲咱們的前端和服務端在兩個端口運行,因此在調用接口的時候,會遇到跨域問題。

不用慌,咱們能夠用下面這個姿式解決。

const Koa = require("Koa");
const cors = require("koa2-cors");
const app = new Koa();

app.use(
  cors({
    origin: "*",
    credentials: true, //是否容許發送Cookie
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], //設置所容許的HTTP請求方法
    allowHeaders: ["Content-Type", "Authorization", "Accept"] //設置服務器支持的全部頭信息字段
  })
);
複製代碼

這裏咱們本地開發,能夠設置 cors origin 爲 「 * 」。可是 切忌 不能在生產環境這麼設置,必定要指定生產環境的前端域名,不然,你的服務將會很容易遭受到攻擊。

查詢錯誤列表

如今,咱們上傳錯誤的部分基本完成了。下來還須要一個查詢錯誤列表的接口,提供給前端來展現錯誤信息。下來讓咱們完成這一部分:

增長一條路由控制 ---

// 獲取錯誤信息
router.get("/errors/list", getErrors);

複製代碼
// 獲取錯誤列表
getErrors = async ctx => {
  const webErrors = await ctx.mysql.query();

  ctx.body = {
    code: 200,
    msg: "success",
    data: webErrors
  };
}
複製代碼
// mysql
query = () =>
    new Promise((r, j) => {
      connection.query(sqls.all(this.table), (err, res) => {
        if (err) {
          logger.error(err);
          j(err);
        } else {
          r(res);
        }
      });
    });
    
sqls.all = tb => `SELECT * from ${tb}`,
複製代碼

如今查詢錯誤列表的接口就完成了,前端咱們作一些簡單的展現組件來顯示這些信息。

getList = () =>
  axios
    .get("/errors/list")
    .then(res => res.data.data)
    .catch(err => []);
複製代碼
const request = async setList => {
  const list = await getList();
  setList(list);
};

function App() {
  const [list, setList] = useState([]);

  useEffect(() => {
    request(setList);
  }, []);

  return (
    <div className="App"> <header className="App-header"> <p>Error Monitor</p> <List list={list} /> </header> </div> ); } 複製代碼

能夠看到咱們上傳的信息,都經過接口獲取到了。

觀察仔細的同窗可能發現上面有一行 錯誤原始文件 的信息行,它是什麼呢?咱們繼續往下看。

source-map 插件

到這裏,咱們的前端,後端和數據庫已經完成了,整個錯誤信息上報的過程已經打通。可是你們能夠看到,錯誤棧信息,裏面是一堆 chunk.js 文件。

如今咱們前端開發大都會使用 React, Vue, Less, Sass 這些框架或庫,以及許多新版本的語法,以及一堆五花八門的三方依賴 SDK。

但在服務部署到線上時,都會對代碼進行分塊打包,壓縮合並。所以線上環境的代碼不可以知足咱們去分析錯誤緣由。

因此,咱們還須要對這些壓縮後的信息,進行還原,這樣纔可以準確的判定錯誤的具體位置。

「 若是可以像瀏覽器這樣,顯示具體的錯誤位置,那簡直太好了 」
複製代碼

問題分析

針對以上願景,咱們來作一下分析,肯定咱們的解決方案。

1. 咱們想要什麼 ?

咱們想要肯定錯誤的具體位置

2. 咱們有什麼 ?

咱們有壓縮後的錯誤信息

3. 咱們能夠作什麼 ?

嘗試經過壓縮後的信息,還原出來原始的錯誤信息

複製代碼

基於以上的思路, 在社區調研以後, 咱們決定經過解析 source-map 來獲得咱們的原始出錯信息。

步驟 操做
1 在打包時收集 map 文件
2 將 map 文件上傳到服務器
3 在接收到前端上報的錯誤時分析出原始文件信息
4 將原始錯誤信息入庫
5 在前端獲取錯誤列表時,一併返回原始錯誤信息

因爲咱們的 source-map 文件是在打包階段生產出來的,因此咱們不防設計一個插件來完成這個工做。

webpack plugin

咱們的前端項目經過 webpack 打包,因此咱們來設計一個 webpack 插件來完成 source-map 上傳的工做。

要設計 webpack 插件,讓我先簡單瞭解下 webpack 插件

webpack 插件用於在打包期間來擴展 webpack 打包行爲。

若是你在使用 webpack,可能對 html-webpack-plugin, HappyPack, DllReferencePlugin 這些已經比較熟悉了。
複製代碼

在設計層面上,咱們依然保持自上而下的構建思路,先描述咱們的接口,再編寫具體邏輯。

/* config-overrides.js */

一個 webpack plugin 應該長這個樣子:

1. 它是一個類,能夠被實例化
2. 能夠接收一些配置參數

const path = require("path");
const EmWebpackPlugin = require("error-monitor-webpack-plugin");

const pathResolve = p => path.join(process.cwd(), p);

module.exports = function override(config) {
  //do some stuff with the webpack config...
  config.plugins.push(
    new EmWebpackPlugin({
      url: "localhost:5000/sourcemap/upload", // 後端上傳 source-map 接口
      outputPath: config.output.path // 打包 output 路徑
    })
  );

  return config;
};
複製代碼

下面讓咱們來實現插件的內部邏輯。

const { uploadSourceMaps, readDir } = require("./utils");

/** * @param {插件配置桉樹} options */
function errorMonitorWebpackPlugin(options = {}) {
  this.options = options;
}

// 插件必須實現一個 apply 方法,這個會在 webpack 打包時被調用
errorMonitorWebpackPlugin.prototype = {
  /** * @param {編譯實例對象} compiler */
  apply(compiler) {
    const { url, outputPath } = this.options;
    /** * compiler hook: done * 在打包結束時執行 * 能夠獲取到訪問文件信息的入口 * https://webpack.js.org/api/compiler-hooks/#done */
    if (url && outputPath) {
      compiler.hooks.done.tap("upload-sourcemap-plugin", status => {
        // 讀入打包輸出目錄,提取 source-map 文件
        const sourceMapPaths = readDir(outputPath);
        sourceMapPaths.forEach(p =>
          uploadSourceMaps({
            url: `${url}?fileName=${p.replace(outputPath, "")}`,
            sourceMapFile: p
          })
        );
      });
    }
  }
};

module.exports = errorMonitorWebpackPlugin;

複製代碼

過濾並提取 source-map 文件:

const p = require('path');
const fs = require('fs');
// 咱們僅取出 .map 文件和 manifest.json 文件
const sourceMapFileIncludes = [/\.map$/, /asset-manifest\.json/];

/** * 遞歸讀取文件夾 * 輸出source-map文件目錄 */
readDir: path => {
const filesContent = [];

function readSingleFile(path) {
  const files = fs.readdirSync(path);
  files.forEach(filePath => {
    const wholeFilePath = p.resolve(path, filePath);
    const fileStat = fs.statSync(wholeFilePath);
    // 查看文件是目錄仍是單文件
    if (fileStat.isDirectory()) {
      readSingleFile(wholeFilePath);
    }

    // 只篩選出manifest和map文件
    if (
      fileStat.isFile() &&
      sourceMapFileIncludes.some(r => r.test(filePath))
    ) {
      filesContent.push(wholeFilePath);
    }
  });
}

readSingleFile(path);

return filesContent;
}
複製代碼

上傳文件到服務器, 這裏咱們選用 http 來完成文件上傳,也能夠選用其餘的 RPC 框架來完成這一步。

const { request } = require("http");

const uploadSourceMaps = options => {
const { url, sourceMapFile } = options;
if (!url || !sourceMapFile)
  throw new Error("params 'url' and 'sourceMapFile' is required!!");

const [host, o] = url.split(":");
const i = o.indexOf("/");
const port = o.slice(0, i);
const path = o.slice(i);

const req = request({
  host,
  path,
  port,
  method: "POST",
  headers: {
    "Content-Type": "application/octet-strean",
    // 因爲咱們的文件經過二進制流傳輸,因此須要保持長鏈接
    // 設置一下request header
    Connection: "keep-alive",
    "Transfer-Encoding": "chunked"
  }
});

fs.createReadStream(sourceMapFile)
  .on("data", chunk => {
    // 對request的寫入,會將數據流寫入到 request body
    req.write(chunk);
  })
  .on("end", () => {
    // 在文件讀取完成後,須要調用req.end來發送請求
    req.end();
  });
},
複製代碼

這樣,咱們的 webpack 上傳 source-map 的插件就完成了,下來處理一下服務端的邏輯。

本地調試 plugin

webpack plugin 實現以後,咱們怎麼鏈接到前端項目裏面進行使用和調試呢 ?

方法:

1. 經過相同路徑引用

這種方式很直接,也不須要額外操做,可是調試效果比較差

2. npm link

npm 提供了用於開發 npm 模塊時的調試方案

複製代碼

首先在 webpack plugin 工程中添加 link

下來在前端項目中 link 咱們開發好的 webpack plugin

能夠看到 node_modules 中已經有咱們的插件了。

而後在 webpack config 中引入,直接以絕對路徑引入並使用

const EmWebpackPlugin = require("error-monitor-webpack-plugin");
複製代碼

接收並解析 source-map

首先,咱們須要再後端新增一個路由,接收來自插件的請求

const router = require("koa-router")();
const sourcemapController = require("../controller/c-sourcemap");

// 上傳sourcemap文件
router.post("/sourcemap/upload", sourcemapController.uploadSourceMap);

module.exports = router;
複製代碼
const qs = require("querystring");
const path = require("path");
const { sourceMapConfig } = require("../config/default");
const { writeFile, delDir } = require("../utils/writeFile");

exports.uploadSourceMap = ctx => {
  ctx.req
    .on("data", data => {
      // 接收到的data會是一串二進制流
      // 咱們進行序列化
      const souremapContent = data.toString("utf8");
      const { querystring } = ctx.request;
      // 並從請求 url 中提取出 outputPath 參數
      const { fileName } = qs.parse(querystring);
      // 咱們將收集到的 source-map 以文件形式寫入
      writeFile(path.join(sourceMapConfig.dir, fileName), souremapContent);
    })
    .on("close", () => {})
    .on("error", () => {})
    .on("end", () => {});
};
複製代碼

存儲 source-map

咱們將 source-map 以本來的目錄層次存放在服務器中,這樣方便後續的 source-map 解析

const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

exports.writeFile = (fileName, content, options = {}) => {
  if (!content || !fileName) {
    throw new Error("'content', 'fileName' is required!!");
  }

  try {
    const { prefixDir = process.cwd() } = options;
    const pieces = fileName
      .replace(prefixDir, "")
      .split(/\//)
      .filter(p => !!p);
    let i = 0;
    if (pieces.length > 1) {
      let currentPath = prefixDir;
      // 自動建立空目錄
      while (i < pieces.length - 1) {
        const checkedPath = path.resolve(currentPath, pieces[i]);
        if (!fs.existsSync(checkedPath)) {
          fs.mkdirSync(checkedPath);
        }
        currentPath = checkedPath;
        i++;
      }
    }
    fs.writeFile(fileName, content, e => {
      if (e) throw e;
    });
  } catch (e) {
    throw new Error("write file failed, beacuse of these:", e);
  }
};
複製代碼

使用 map 文件

如今 map 文件存儲好了,下來能夠進行消費使用了。

如今考慮一個問題,咱們是在上報錯誤時解析仍是在前端獲取錯誤列表時解析 ?

設想一下具體的場景,系統上報錯誤時是一個個上報,在獲取列表時,是批量的獲取。顯而易見,咱們應該在上報時解析 source-map 並存儲到數據庫。

下來讓咱們實現具體邏輯:

// 擴展剛纔的uploadErrors
const uploadErrors = async ctx => {
  try {
    const body = ctx.request.body;
    const { stack } = body;
    // 解析 source-map
    const sourceInfo = findTheVeryFirstFileInErrorStack(stack);
    const sourceMapInfo = await soucemapParser(sourceInfo);
    // 將 source-map 信息插入表中
    await ctx.mysql.whriteError({ ...body, sourcemap: sourceMapInfo });
    ...
  } catch (e) {
    ...
  }
};
複製代碼

最開始,咱們提到了,不少時候咱們上報的錯誤信息,沒有行列號和錯誤文件名。這個時候,咱們能夠選擇從錯誤棧信息中提取。

咱們以錯誤棧頂第一個文件爲目標,由於這個文件通常就是咱們真正編碼的文件,對此進行錯誤文件和行列號提取。獲得三個核心參數中,再對其進行解析。

source-map 解析

解析 source-map 的工做,咱們選擇使用 source-map 這個 sdk 來完成, 這是 Mozilla 提供的一個 node 模塊。

固然若是你們有興趣,能夠自行實現一下 source-map 解析,會對這一塊有更深刻的認識,23333...

const fs = require("fs");
const path = require("path");
const sourceMapTool = require("source-map");
const { sourceMapConfig } = require("../config/default");

// 檢驗是否爲文件夾
const notStrictlyIsDir = p => !/\./.test(p);

// 檢測manifest文件
const isManifest = p => /manifest\.json/.test(p);

// 從sourcemap目錄中找到sourcemap文件
const findManifest = baseDir => {
  const files = fs.readdirSync(baseDir);

  if (files.some(f => isManifest(f))) {
    return path.join(baseDir, files.filter(f => isManifest(f))[0]);
  }

  files.forEach(f => {
    if (notStrictlyIsDir(f)) {
      findManifest(path.join(baseDir, f));
    }
  });
};

/** * * @param {sourcemap 文件} sourcemapFile * @param {行號} line * @param {列號} col * * 經過 sourec-map 來解析錯誤源碼 */
const parseJSError = (sourcemapFile, line, col) => {
  // 選擇拋出一個 promise 方便咱們使用 async 語法
  return new Promise(resolve => {
    fs.readFile(sourcemapFile, "utf8", function readContent( err, sourcemapcontent ) {
      // SourceMapConsumer.with 是該模塊提供的消費 source-map 的一種方式
      sourceMapTool.SourceMapConsumer.with(sourcemapcontent, null, consumer => {
        const parseData = consumer.originalPositionFor({
          line: parseInt(line),
          column: parseInt(col)
        });

        resolve(JSON.stringify(parseData));
      });
    });
  });
};

/** * 根據 sourcemap 文件解析錯誤源碼 * 1. 根據傳入的錯誤信息肯定sourcemap文件 * 2. 根據錯誤行列信息轉換錯誤源碼 * 3. 將轉換後的錯誤源碼片斷入庫 */

module.exports = (info = []) => {
  const [filename, line, col] = info;

  // 錯誤文件的 map 文件
  const sourcemapFileName = `${sourceMapConfig.dir}${filename}.map`;

  if (fs.existsSync(sourcemapFileName)) {
    return parseJSError(sourcemapFileName, line, col);
  }

  return Promise.resolve(JSON.stringify({}));
};

複製代碼

經過 source-map consumer 解析出的會是一個 js 對象,包含了 source, line, column, name 幾個信息,其中包含了源文件名,解析後的行號和列號。

這樣咱們就獲得了錯誤的原始文件及錯誤位置。

{ 
  source: 'http://example.com/www/js/two.js',
  line: 2,
  column: 10,
  name: 'n' 
}
複製代碼

咱們選擇將這個對象進行序列化,直接存儲到數據庫字段中,以後在前端進行展現。

能夠看到接口中的 sourcemap 字段就是咱們最終解析到的錯誤源文件信息。

最後再看一下咱們的前端效果。

到這裏,咱們就徹底還原了錯誤發生現場,能夠愉快的進行問題回溯了。

Congratulations ~~~~

本地搭建生產環境

在開發工程中,遇到了一個問題,就是在分析 source-map 時,發現須要的 map 文件找不到。最後發現,是由於咱們從 dev 模式生產的錯誤,可是 map 文件是 production 模式打出來的包。

Are you kidding me ?
複製代碼

因而咱們頗有必要搞一套生產環境出來,來模擬整個線上流程。

可是我只有一臺機器,怎麼辦?如今已經跑了一個前端,一個後端,一個數據庫,一個 webpack plugin。

不用慌,容器化幫你解決微服務,Docker獻上...

Dokcer

在此以前,其實已經有使用過一段時間的 docker 和 K8s 了,咱們本身的產品自己也是容器化自動化部署。它能夠極快極其方便的幫忙你起一個應用。

在一線大廠,容器化設施也是很是完備,許許多多互聯網產品都在其上運行。使用它那你能夠快速的建立一個 Ubuntu OS 運行環境,一個 Nginx 服務器,一個 mysql 數據庫,一個 Redis 存儲器。因此,若是你還不知道的話,還不快來了解一下。(我真的不是安利...)

在這裏,咱們選用 Nginx 做爲前端服務器,負載均衡,高性能的HTTP和反向代理web服務器。相信大家本身的產品大多數也運行在 Nginx 服務器中。

1. 首先咱們須要安裝 [docker](https://www.docker.com/)

2. 下來拉取 nginx 鏡像。

   docker pull nginx

3. 建立 nginx 相關目錄

mkdir -p /data/nginx/{conf, conf.d,logs}

這裏咱們在宿主機的 /data/nginx 目錄放置 nginx 相關的文件,這個目錄是可自定義的,但後續的目錄映射必定要保證和這個目錄相同。

4. 新建 nginx 配置文件

   touch /data/nginx/conf/nginx.conf
   vim /data/nginx/conf/nginx.conf

   ```conf user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; #gzip on; include /etc/nginx/conf.d/*.conf; } ``` 5. 新建 default.conf touch /data/nginx/conf.d/default.conf vim /data/nginx/conf.d/default.conf ```conf server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; autoindex on; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } ``` 到這裏,有關於 nginx 配置的處理就完成了,下來咱們要作的就是進行 docker 容器與宿主機的目錄映射 6. 將 nginx 內容掛載到宿主機 docker run -p 80:80 -d -v /Users/xxx/Documents/lab/error-monitor/react-repo/build:/usr/share/nginx/html -v /data/nginx/logs:/var/log/nginx -v /data/nginx/conf/nginx.conf:/etc/nginx/nginx.conf -v /data/nginx/conf.d:/etc/nginx/conf.d docker.io/nginx 這裏能夠看到咱們映射了兩個目錄和兩個配置文件,包括了前端 html 文件目錄,log 目錄以及兩個 nginx 配置文件。這裏我直接將咱們前端項目的打包目錄映射到了容器中的 html 目錄中,這樣會比較方便一些。 這裏咱們選擇宿主機的 80 端口映射 nginx 容器的 80 端口,咱們直接打開本機的瀏覽器訪問 localhost ,就能夠看到打包完後的前端項目運行起來了。若是 80 端口有其餘用途 ,能夠自行切換到其餘端口。 複製代碼

總結

到這裏,咱們的前端監控系統基本完成了。

給本身鼓個掌~~~

別急,其實目前這個版本還只是初版,初步走通了整個流程。

其中還有一些須要完善的地方,會在後續進行補充。在使用場景上,也還須要進一步的進行測試。

提早貼出來,但願一可以先階段性回顧總結一下整個項目,由於整個系統仍是比較複雜的,二來但願可以給你們分享一些可能也許有一丁點兒用處的內容。

文章中其實只貼了很小一部份內容,你們若是有興趣能夠進一步瞭解,其中 README 有寫一些知識點和構建思路以及學習經驗。

倉庫代碼

前端: error-monitor-frontend

後端:error-monitor-node-server

source-map插件: error-monitor-webpack-plugin

延續

細心的同窗可能發現,文章頭部貼的架構圖和文章內容有點出入,還有咱們標題有(上)的標註。

因此,後續在對這個系統近一步完善後,應該會有 (下)片補上,歡迎指正!!!歡迎點贊關注!!!(不是搞直播的)👏👏👏👏

相關文章
相關標籤/搜索