撸一个前端监控系统(React + Node + Mysql + Webpack plugin + Docker)—— (上)

前言

俗话说,不依赖业务场景的系统设计,叫耍流氓。
构想设计这个系统也是有着业务背景的,所在公司的产品既面向 C 端,也面向 B 端。在我们开发落地业务时,经常遇到一类非常头疼的问题:

客户:这个页面怎么报错了?让你们开发给看一下。  客户:为什么这个模块没数据,控制台还有报错,怎么回事?  苦逼coder: 请问您能具体描述一些出错的场景和步骤吗?最好能帮忙截图一些页面细节。  等客户操作半天, 给来一堆有用没用的信息......  感叹!写代码为什么这么难,业务怎么这么让人头疼...

所以我决定动手撸一个监控系统,用来解决错误收集和问题回溯。让我们在追溯以往出现的问题时,能够省时省力,并且能够统计到系统的漏洞。

阅读收益

“ 我读你写的文章,对我有什么用?”

  • 可以掌握如何 收集前端错误
  • 学习如何设计一个 webpack 插件
  • 如何调试插件
  • 了解 Node服务端 的基本内容
  • 了解 数据库 的基本操作
  • 了解 docker 的简单使用
  • 起一个 nginx 服务器
  • 学会一种姿势自己 搭建一个生产环境
  • 学会 部署 服务

系统设计

系统设计的基本思路不同于解决业务逻辑,系统设计上,我们应该 从大到小,由整体到局部,思考以下问题:

步骤 问题 方案
1 我们遇到了什么问题 ? 前端错误难以追溯
2 解决问题的大致思路 收集错误,分析错误,展示错误
3 将解决方案转换为系统模型 我们需要一整套能够做错误收集, 存储, 分析, 展示的系统
4 拆分子系统 前端系统;后端系统;插件系统;
5 子系统模块分割 根据各系统特性和解决的具体问题作划分
6 模块实现 撸代码
7 系统串联调试 系统间串联调试
8 系统优化 思考已实现的内容能不能解决最初问题,哪里还能更好 ?

这个系统我们将从前端收集错误,上传至服务器,经由服务器解析存储并提供消费接口,并在一定程度上解析 source-map 来输出源码错误信息。

基于以上,我们搭建一个前端工程作为实验室,来生产错误数据, 通过 webpack plugin 来做 source-map 上传,在服务端进行解析。服务端我们以 node 作为开发语言,并选用 mysql 数据库来存储错误信息,最终将收集到的错误展示到前端。

<img src="https://user-gold-cdn.xitu.io/2020/1/29/16ff0913ddce5108?w=1492&h=642&f=jpeg&s=137230"/>

前端实现

为了能够快速的搭建一个前端工程,我们选用 Create-React-App 作为脚手架来初始化项目。

1. 先安装 Cra  npm i -g  create-react-app  2. 初始化项目  npx create-react-app react-repo  3. 启动应用  cd react-repo npm start 

到这里都很简单,更多内容见官网

错误类型

对于前端出现的错误,我们分为两类,一类是 页面错误, 如一系列导致页面异常,页面白屏的错误;一类是 网络错误,即由于服务端异常所导致的错误,或者不符合既定前后端约束的错误。

错误信息结构

错误信息消息体,包含以下信息:

  • 用户信息
  • 错误信息
  • 用户设备信息

其中 用户信息错误信息 我们需要自己拼装上传,用户设备信息我们可以在服务端获取,无需上传浪费资源。

/**   */ 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 来接收错误信息, 到这里,前端收集,格式化,上传错误的步骤就基本完成了。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff5bc2605ac57b?w=1492&h=1085&f=png&s=416004'/>

在这些错误信息中,最重要的就是 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 // 入口文件

具体的依赖见下图,后续我们都会用到,可以提前安装好或者用的时候再安装都可。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff5d96c92d5ebb?w=1492&h=933&f=png&s=288170'/>

在系统中,我们用到了许多 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

下面这张图,有没有很熟悉 ?

由于我们的前端和服务端在两个端口运行,所以在调用接口的时候,会遇到跨域问题。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6a7b8f7980ea?w=1492&h=1085&f=png&s=161225'/>

不用慌,我们可以用下面这个姿势解决。

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>   ); }

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6a152a74c471?w=1492&h=826&f=png&s=363691'/>

可以看到我们上传的信息,都通过接口获取到了。

观察仔细的同学可能发现上面有一行 错误原始文件 的信息行,它是什么呢?我们继续往下看。

source-map 插件

到这里,我们的前端,后端和数据库已经完成了,整个错误信息上报的过程已经打通。但是大家可以看到,错误栈信息,里面是一堆 chunk.js 文件。

现在我们前端开发大都会使用 React, Vue, Less, Sass 这些框架或库,以及许多新版本的语法,以及一堆五花八门的三方依赖 SDK。

但在服务部署到线上时,都会对代码进行分块打包,压缩合并。因此线上环境的代码不能够满足我们去分析错误原因。

所以,我们还需要对这些压缩后的信息,进行还原,这样才能够准确的断定错误的具体位置。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6b54c2c802a0?w=1492&h=1085&f=png&s=630896'/>

“ 如果能够像浏览器这样,显示具体的错误位置,那简直太好了 ” 

问题分析

针对以上愿景,我们来做一下分析,确定我们的解决方案。

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

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6f5f7106da6b?w=1148&h=332&f=png&s=159988'/>

下来在前端项目中 link 我们开发好的 webpack plugin

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6f7dff282653?w=908&h=310&f=png&s=74896'/>

可以看到 node_modules 中已经有我们的插件了。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff6fb16829c6a0?w=900&h=948&f=png&s=99235'/>

然后在 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'  }

我们选择将这个对象进行序列化,直接存储到数据库字段中,之后在前端进行展示。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff7141b301c666?w=1492&h=1085&f=png&s=341072'/>

可以看到接口中的 sourcemap 字段就是我们最终解析到的错误源文件信息。

最后再看一下我们的前端效果。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff71604dc93f19?w=1492&h=618&f=png&s=168589'/>

到这里,我们就完全还原了错误发生现场,可以愉快的进行问题回溯了。

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 

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 
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 端口有其他用途 ,可以自行切换到其他端口。

<img src='https://user-gold-cdn.xitu.io/2020/1/30/16ff728c95358f54?w=1492&h=834&f=png&s=180520'/>

总结

到这里,我们的前端监控系统基本完成了。

给自己鼓个掌~~~

别急,其实目前这个版本还只是第一版,初步走通了整个流程。

其中还有一些需要完善的地方,会在后续进行补充。在使用场景上,也还需要进一步的进行测试。

提前贴出来,希望一能够先阶段性回顾总结一下整个项目,因为整个系统还是比较复杂的,二来希望能够给大家分享一些可能也许有一丁点儿用处的内容。

文章中其实只贴了很小一部分内容,大家如果有兴趣可以进一步了解,其中 README 有写一些知识点和构建思路以及学习经验。

仓库代码

前端: error-monitor-frontend

后端:error-monitor-node-server

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

延续

细心的同学可能发现,文章头部贴的架构图和文章内容有点出入,还有我们标题有(上)的标注。

所以,后续在对这个系统近一步完善后,应该会有 (下)片补上,欢迎指正!!!欢迎点赞关注!!!(不是搞直播的)

脚本宝典为你提供优质服务
脚本宝典 » 撸一个前端监控系统(React + Node + Mysql + Webpack plugin + Docker)—— (上)

发表评论

提供最优质的资源集合

立即查看 了解详情