俗話說,不依賴業務場景的系統設計,叫耍流氓。
構想設計這個系統也是有着業務背景的,所在公司的產品既面向 C 端,也面向 B 端。在咱們開發落地業務時,常常遇到一類很是頭疼的問題:css
客戶:這個頁面怎麼報錯了?讓大家開發給看一下。
客戶:爲何這個模塊沒數據,控制檯還有報錯,怎麼回事?
苦逼coder: 請問您能具體描述一些出錯的場景和步驟嗎?最好能幫忙截圖一些頁面細節。
等客戶操做半天, 給來一堆有用沒用的信息......
感嘆!寫代碼爲何這麼難,業務怎麼這麼讓人頭疼...
複製代碼
因此我決定動手擼一個監控系統,用來解決錯誤收集和問題回溯。讓咱們在追溯以往出現的問題時,可以省時省力,而且可以統計到系統的漏洞。html
「 我讀你寫的文章,對我有什麼用?」前端
系統設計的基本思路不一樣於解決業務邏輯,系統設計上,咱們應該 從大到小,由總體到局部,思考如下問題: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 事件的事務執行。
在 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 語法。固然你也能夠選擇 Express 或 Egg。
腳手架工具可使用 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 封裝了 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 ---
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);
}
});
});
}
複製代碼
能夠看到,操做數據庫,咱們分爲了如下幾部:
將 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 方法。
下面這張圖,有沒有很熟悉 ?
因爲咱們的前端和服務端在兩個端口運行,因此在調用接口的時候,會遇到跨域問題。
不用慌,咱們能夠用下面這個姿式解決。
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> ); } 複製代碼
能夠看到咱們上傳的信息,都經過接口獲取到了。
觀察仔細的同窗可能發現上面有一行 錯誤原始文件 的信息行,它是什麼呢?咱們繼續往下看。
到這裏,咱們的前端,後端和數據庫已經完成了,整個錯誤信息上報的過程已經打通。可是你們能夠看到,錯誤棧信息,裏面是一堆 chunk.js 文件。
如今咱們前端開發大都會使用 React, Vue, Less, Sass 這些框架或庫,以及許多新版本的語法,以及一堆五花八門的三方依賴 SDK。
但在服務部署到線上時,都會對代碼進行分塊打包,壓縮合並。所以線上環境的代碼不可以知足咱們去分析錯誤緣由。
因此,咱們還須要對這些壓縮後的信息,進行還原,這樣纔可以準確的判定錯誤的具體位置。
「 若是可以像瀏覽器這樣,顯示具體的錯誤位置,那簡直太好了 」
複製代碼
針對以上願景,咱們來作一下分析,肯定咱們的解決方案。
1. 咱們想要什麼 ?
咱們想要肯定錯誤的具體位置
2. 咱們有什麼 ?
咱們有壓縮後的錯誤信息
3. 咱們能夠作什麼 ?
嘗試經過壓縮後的信息,還原出來原始的錯誤信息
複製代碼
基於以上的思路, 在社區調研以後, 咱們決定經過解析 source-map 來獲得咱們的原始出錯信息。
步驟 | 操做 |
---|---|
1 | 在打包時收集 map 文件 |
2 | 將 map 文件上傳到服務器 |
3 | 在接收到前端上報的錯誤時分析出原始文件信息 |
4 | 將原始錯誤信息入庫 |
5 | 在前端獲取錯誤列表時,一併返回原始錯誤信息 |
因爲咱們的 source-map 文件是在打包階段生產出來的,因此咱們不防設計一個插件來完成這個工做。
咱們的前端項目經過 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 的插件就完成了,下來處理一下服務端的邏輯。
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");
複製代碼
首先,咱們須要再後端新增一個路由,接收來自插件的請求
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 解析
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 文件存儲好了,下來能夠進行消費使用了。
如今考慮一個問題,咱們是在上報錯誤時解析仍是在前端獲取錯誤列表時解析 ?
設想一下具體的場景,系統上報錯誤時是一個個上報,在獲取列表時,是批量的獲取。顯而易見,咱們應該在上報時解析 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 這個 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獻上...
在此以前,其實已經有使用過一段時間的 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 有寫一些知識點和構建思路以及學習經驗。
source-map插件: error-monitor-webpack-plugin
細心的同窗可能發現,文章頭部貼的架構圖和文章內容有點出入,還有咱們標題有(上)的標註。
因此,後續在對這個系統近一步完善後,應該會有 (下)片補上,歡迎指正!!!歡迎點贊關注!!!(不是搞直播的)👏👏👏👏