目前市面上有许多成熟的前端监控系统,但我们没有选择成品,而是自己动手研发。这里面包括多个原因:

  • 填补H5日志的空白
  • 节约公司费用支出
  • 可灵活地根据业务自定义监控
  • 回溯时间能更长久
  • 反哺运营和产品,从而优化产品质量
  • 一次难得的练兵机会

  前端监控地基本目的:了解当前项目实际使用的情况,有哪些异常,在追踪到后,对其进行分析,并提供合适的解决方案。

  前端监控地终极目标: 1 分钟感知、5 分钟定位、10 分钟恢复。目前是初版,离该目标还比较遥远。

  SDK(采用ES5语法)取名为 shin.js,其作用就是将数据通过 JavaScript 采集起来,统一发送到后台,采集的方式包括监听或劫持原始方法,获取需要上报的数据,并通过 gif 传递数据。

  整个系统大致的运行流程如下:

  

一、异常捕获

  异常包括运行时错误、Promise错误、框架错误等。

1)error事件

  为 window 注册 error 事件,捕获全局错误,过滤掉与业务无关的错误,例如“Script error.”、JSBridge告警等,还需统一资源载入和运行时错误的数据格式。

  1. // 定义的错误类型码
  2. var ERROR_RUNTIME = "runtime";
  3. var ERROR_SCRIPT = "script";
  4. var ERROR_STYLE = "style";
  5. var ERROR_IMAGE = "image";
  6. var ERROR_AUDIO = "audio";
  7. var ERROR_VIDEO = "video";
  8. var ERROR_PROMISE = "promise";
  9. var ERROR_VUE = "vue";
  10. var ERROR_REACT = "react";
  11. var LOAD_ERROR_TYPE = {
  12. SCRIPT: ERROR_SCRIPT,
  13. LINK: ERROR_STYLE,
  14. IMG: ERROR_IMAGE,
  15. AUDIO: ERROR_AUDIO,
  16. VIDEO: ERROR_VIDEO
  17. };
  18. /**
  19. * 监控异常
  20. */
  21. window.addEventListener(
  22. "error",
  23. function (event) {
  24. var errorTarget = event.target;
  25. // 过滤掉与业务无关的错误
  26. if (event.message === "Script error." || !event.filename) {
  27. return;
  28. }
  29. if (
  30. errorTarget !== window &&
  31. errorTarget.nodeName &&
  32. LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()]
  33. ) {
  34. handleError(formatLoadError(errorTarget));
  35. } else {
  36. handleError(
  37. formatRuntimerError(
  38. event.message,
  39. event.filename,
  40. event.lineno,
  41. event.colno,
  42. event.error
  43. )
  44. );
  45. }
  46. },
  47. true //捕获
  48. );
  49. /**
  50. * 生成 laod 错误日志
  51. * 需要加载资源的元素
  52. */
  53. function formatLoadError(errorTarget) {
  54. return {
  55. type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
  56. desc: errorTarget.baseURI + "@" + (errorTarget.src || errorTarget.href),
  57. stack: "no stack"
  58. };
  59. }

2)unhandledrejection事件

  为 window 注册 unhandledrejection 事件,捕获未处理的 Promise 错误,当 Promise 被 reject 且没有 reject 处理器时触发。

  1. window.addEventListener(
  2. "unhandledrejection",
  3. function (event) {
  4. //处理响应数据,只抽取重要信息
  5. var response = event.reason.response;
  6. //若无响应,则不监控
  7. if (!response) {
  8. return;
  9. }
  10. var desc = response.request.ajax;
  11. desc.status = event.reason.status;
  12. handleError({
  13. type: ERROR_PROMISE,
  14. desc: desc
  15. });
  16. },
  17. true
  18. );

  Promise 常用于异步通信,例如axios库,当响应异常通信时,就能借助该事件将其捕获,得到的结果如下。

  1. {
  2. "type": "promise",
  3. "desc": {
  4. "response": {
  5. "data": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic",
  6. "status": 504,
  7. "statusText": "Gateway Timeout",
  8. "headers": {
  9. "connection": "keep-alive",
  10. "date": "Wed, 24 Mar 2021 07:53:25 GMT",
  11. "transfer-encoding": "chunked",
  12. "x-powered-by": "Express"
  13. },
  14. "config": {
  15. "transformRequest": {},
  16. "transformResponse": {},
  17. "timeout": 0,
  18. "xsrfCookieName": "XSRF-TOKEN",
  19. "xsrfHeaderName": "X-XSRF-TOKEN",
  20. "maxContentLength": -1,
  21. "headers": {
  22. "Accept": "application/json, text/plain, */*",
  23. },
  24. "method": "get",
  25. "url": "/api/monitor/performance/statistic"
  26. },
  27. "request": {
  28. "ajax": {
  29. "type": "GET",
  30. "url": "/api/monitor/performance/statistic",
  31. "status": 504,
  32. "endBytes": 0,
  33. "interval": "13.15ms",
  34. "network": {
  35. "bandwidth": 0,
  36. "type": "4G"
  37. },
  38. "response": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic"
  39. }
  40. }
  41. },
  42. "status": 504
  43. },
  44. "stack": "Error: Gateway Timeout
  45. at handleError (http://localhost:8000/umi.js:18813:15)"
  46. }

  这样就能分析出 500、502、504 等响应码所占通信的比例,当高于日常数量时,就得引起注意,查看是否在哪块逻辑出现了问题。

  有一点需要注意,上面的结构中包含响应信息,这是需要对 Error 做些额外扩展的,如下所示。

  1. import fetch from 'axios';
  2. function handleError(errorObj) {
  3. const { response } = errorObj;
  4. if (!response) {
  5. const error = new Error('你的网络有点问题');
  6. error.response = errorObj;
  7. error.status = 504;
  8. throw error;
  9. }
  10. const error = new Error(response.statusText);
  11. error.response = response;
  12. error.status = response.status;
  13. throw error;
  14. }
  15. export default function request(url, options) {
  16. return fetch(url, options)
  17. .catch(handleError)
  18. .then((response) => {
  19. return { data: response.data };
  20. });
  21. }

  公司中有一套项目依赖的是 jQuery 库,因此要监控此处的异常通信,需要做点改造。

  好在所有的通信都会请求一个通用函数,那么只要修改此函数的逻辑,就能覆盖到项目中的所有页面。

  搜索了API资料,以及研读了 jQuery 中通信的源码后,得出需要声明一个 xhr() 函数,在函数中初始化 XMLHttpRequest 对象,从而才能监控它的实例。

  并且在 error 方法中需要手动触发 unhandledrejection 事件。

  1. $.ajax({
  2. url,
  3. method,
  4. data,
  5. success: (res) => {
  6. success(res);
  7. },
  8. xhr: function () {
  9. this.current = new XMLHttpRequest();
  10. return this.current;
  11. },
  12. error: function (res) {
  13. error(res);
  14. Promise.reject({
  15. status: res.status,
  16. response: {
  17. request: {
  18. ajax: this.current.ajax
  19. }
  20. }
  21. }).catch((error) => {
  22. throw error;
  23. });
  24. }
  25. });

3)框架错误

  框架是指目前流行的React、Vue等,我只对公司目前使用的这两个框架做了监控。

  React 需要在项目中创建一个 ErrorBoundary 类,捕获错误。

  1. import React from 'react';
  2. export default class ErrorBoundary extends React.Component {
  3. constructor(props) {
  4. super(props);
  5. this.state = { hasError: false };
  6. }
  7. componentDidCatch(error, info) {
  8. this.setState({ hasError: true });
  9. // 将component中的报错发送到后台
  10. shin && shin.reactError(error, info);
  11. }
  12. render() {
  13. if (this.state.hasError) {
  14. return null
  15. // 也可以在出错的component处展示出错信息
  16. // return <h1>出错了!</h1>;
  17. }
  18. return this.props.children;
  19. }
  20. }

  其中 reactError() 方法在组装错误信息。

  1. /**
  2. * 处理 React 错误(对外)
  3. */
  4. shin.reactError = function (err, info) {
  5. handleError({
  6. type: ERROR_REACT,
  7. desc: err.toString(),
  8. stack: info.componentStack
  9. });
  10. };

  如果要对 Vue 进行错误捕获,那么就得重写 Vue.config.errorHandler(),其参数就是 Vue 对象。

  1. /**
  2. * Vue.js 错误劫持(对外)
  3. */
  4. shin.vueError = function (vue) {
  5. var _vueConfigErrorHandler = vue.config.errorHandler;
  6. vue.config.errorHandler = function (err, vm, info) {
  7. handleError({
  8. type: ERROR_VUE,
  9. desc: err.toString(),   //描述
  10. stack: err.stack      //堆栈
  11. });
  12. // 控制台打印错误
  13. if (
  14. typeof console !== "undefined" &&
  15. typeof console.error !== "undefined"
  16. ) {
  17. console.error(err);
  18. }
  19. // 执行原始的错误处理程序
  20. if (typeof _vueConfigErrorHandler === "function") {
  21. _vueConfigErrorHandler.call(err, vm, info);
  22. }
  23. };
  24. };

  如果 Vue 是被模块化引入的,那么就得在模块的某个位置调用该方法,因为此时 Vue 不会绑定到 window 中,即不是全局变量。

4)难点

  虽然把错误都搜集起来了,但是现代化的前端开发,都会做一次代码合并压缩混淆,也就是说,无法定位错误的真正位置。

  为了能转换成源码,就需要引入自动堆栈映射(SourceMap),webpack 默认就带了此功能,只要声明相应地关键字开启即可。

  我选择了 devtool: "hidden-source-map",生成完成的原始代码,并在脚本中隐藏Source Map路径。

  1. //# sourceMappingURL=index.bundle.js.map

  在生成映射文件后,就需要让运维配合,编写一个脚本(在发完代码后触发),将这些文件按年月日小时分钟的格式命名(例如 202103041826.js.map),并迁移到指定目录中,用于后期的映射。

  之所以没有到秒是因为没必要,在执行发代码的操作时,发布按钮会被锁定,其他人无法再发。

  映射的逻辑是用 Node.js 实现的,会在后文中详细讲解。注意,必须要有列号,才能完成代码还原。

二、行为搜集

  将行为分成:用户行为、浏览器行为、控制台打印行为。监控这些主要是为了在排查错误时,能还原用户当时的各个动作,从而能更好的找出问题出错的原因。

1)用户行为

  目前试验阶段,就监听了点击事件,并且只会对 button 和 a 元素上注册的点击事件做监控。

  1. /**
  2. * 全局监听事件
  3. */
  4. var eventHandle = function (eventType, detect) {
  5. return function (e) {
  6. if (!detect(e)) {
  7. return;
  8. }
  9. handleAction(ACTION_EVENT, {
  10. type: eventType,
  11. desc: e.target.outerHTML
  12. });
  13. };
  14. };
  15. // 监听点击事件
  16. window.addEventListener(
  17. "click",
  18. eventHandle("click", function (e) {
  19. var nodeName = e.target.nodeName.toLowerCase();
  20. // 白名单
  21. if (nodeName !== "a" && nodeName !== "button") {
  22. return false;
  23. }
  24. // 过滤 a 元素
  25. if (nodeName === "a") {
  26. var href = e.target.getAttribute("href");
  27. if (
  28. !href ||
  29. href !== "#" ||
  30. href.toLowerCase() !== "javascript:void(0)"
  31. ) {
  32. return false;
  33. }
  34. }
  35. return true;
  36. }),
  37. false
  38. );

2)浏览器行为

  监控异步通信,重写 XMLHttpRequest 对象,并通过 Navigator.connection 读取当前的网络环境,例如4G、3G等。

  其实还想获取当前用户环境的网速,不过还没有较准确的获取方式,因此并没有添加进来。

  1. var _XMLHttpRequest = window.XMLHttpRequest;   //保存原生的XMLHttpRequest
  2. //覆盖XMLHttpRequest
  3. window.XMLHttpRequest = function (flags) {
  4. var req;
  5. req = new _XMLHttpRequest(flags);        //调用原生的XMLHttpRequest
  6. monitorXHR(req);     //埋入我们的“间谍”
  7. return req;
  8. };
  9. var monitorXHR = function (req) {
  10. req.ajax = {};
  11. req.addEventListener(
  12. "readystatechange",
  13. function () {
  14. if (this.readyState == 4) {
  15. var end = shin.now();          //结束时间
  16. req.ajax.status = req.status;     //状态码
  17. if ((req.status >= 200 && req.status < 300) || req.status == 304) {
  18. //请求成功
  19. req.ajax.endBytes = _kb(req.responseText.length * 2) + "KB";   //KB
  20. } else {
  21. //请求失败
  22. req.ajax.endBytes = 0;
  23. }
  24. req.ajax.interval = _rounded(end - start, 2) + "ms";   //单位毫秒
  25. req.ajax.network = shin.network();
  26. //只记录300个字符以内的响应
  27. req.responseText.length <= 300 &&
  28. (req.ajax.response = req.responseText);
  29. handleAction(ACTION_AJAX, req.ajax);
  30. }
  31. },
  32. false
  33. );
  34.  
  35. // “间谍”又对open方法埋入了间谍
  36. var _open = req.open;
  37. req.open = function (type, url, async) {
  38. req.ajax.type = type;    //埋点
  39. req.ajax.url = url;     //埋点
  40. return _open.apply(req, arguments);
  41. };
  42.  
  43. var _send = req.send;
  44. var start;     //请求开始时间
  45. req.send = function (data) {
  46. start = shin.now();      //埋点
  47. if (data) {
  48. req.ajax.startBytes = _kb(JSON.stringify(data).length * 2) + "KB";
  49. req.ajax.data = data;   //传递的参数
  50. }
  51. return _send.apply(req, arguments);
  52. };
  53. };
  54. /**
  55. * 计算KB值
  56. */
  57. function _kb(bytes) {
  58. return _rounded(bytes / 1024, 2);   //四舍五入2位小数
  59. }
  60. /**
  61. * 四舍五入
  62. */
  63. function _rounded(number, decimal) {
  64. return parseFloat(number.toFixed(decimal));
  65. }
  66. /**
  67. * 网络状态
  68. */
  69. shin.network = function () {
  70. var connection =
  71. window.navigator.connection ||
  72. window.navigator.mozConnection ||
  73. window.navigator.webkitConnection;
  74. var effectiveType = connection && connection.effectiveType;
  75. if (effectiveType) {
  76. return { bandwidth: 0, type: effectiveType.toUpperCase() };
  77. }
  78. var types = "Unknown Ethernet WIFI 2G 3G 4G".split(" ");
  79. var info = { bandwidth: 0, type: "" };
  80. if (connection && connection.type) {
  81. info.type = types[connection.type];
  82. }
  83. return info;
  84. };

  在所有的日志中,通信占的比例是最高的,大概在 90% 以上。

  浏览器的行为还包括跳转,当前非常流行 SPA,所以在记录跳转地址时,只需监听 onpopstate 事件即可,其中上一页地址也会被记录。

  1. /**
  2. * 全局监听跳转
  3. */
  4. var _onPopState = window.onpopstate;
  5. window.onpopstate = function (args) {
  6. var href = location.href;
  7. handleAction(ACTION_REDIRECT, {
  8. refer: shin.refer,
  9. current: href
  10. });
  11. shin.refer = href;
  12. _onPopState && _onPopState.apply(this, args);
  13. };

3)控制台打印行为

  其实就是重写 console 中的方法,目前只对 log() 做了处理。在实际使用中发现了两个问题。

  第一个是在项目调试阶段,将数据打印在控制台时,显示的文件和行数都是 SDK 的名称和位置,无法得知真正的位置,很是别扭。

  并且在 SDK 的某些位置调用 console.log() 会形成死循环。后面就加了个 isDebug 开关,在调试时就关闭监控,省心。

  1. function injectConsole(isDebug) {
  2. !isDebug &&
  3. ["log"].forEach(function (level) {
  4. var _oldConsole = console[level];
  5. console[level] = function () {
  6. var params = [].slice.call(arguments);   // 参数转换成数组
  7. _oldConsole.apply(this, params);       // 执行原先的 console 方法
  8. var seen = [];
  9. handleAction(ACTION_PRINT, {
  10. level: level,
  11. // 避免循环引用
  12. desc: JSON.stringify(params, function (key, value) {
  13. if (typeof value === "object" && value !== null) {
  14. if (seen.indexOf(value) >= 0) {
  15. return;
  16. }
  17. seen.push(value);
  18. }
  19. return value;
  20. })
  21. });
  22. };
  23. });
  24. }

  第二个就是某些要打印的变量包含循环引用,这样在调用 JSON.stringify() 时就会报错。

三、其他

1)环境信息

  通过解析请求中的 UA 信息,可以得到操作系统、浏览器名称版本、CPU等信息。

  1. {
  2. "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36",
  3. "browser": {
  4. "name": "Chrome",
  5. "version": "89.0.4389.82",
  6. "major": "89"
  7. },
  8. "engine": {
  9. "name": "Blink",
  10. "version": "89.0.4389.82"
  11. },
  12. "os": {
  13. "name": "Mac OS",
  14. "version": "10.14.6"
  15. },
  16. "device": {},
  17. "cpu": {}
  18. }

  图省事,就用了一个开源库,叫做 UAParser.js,在 Node.js 中引用了此库。

2)上报

  上报选择了 Gif 的方式,即把参数拼接到一张 Gif 地址后,传送到后台。

  1. /**
  2. * 组装监控变量
  3. */
  4. function _paramify(obj) {
  5. obj.token = shin.param.token;
  6. obj.subdir = shin.param.subdir;
  7. obj.identity = getIdentity();
  8. return encodeURIComponent(JSON.stringify(obj));
  9. }
  10. /**
  11. * 推送监控信息
  12. */
  13. shin.send = function (data) {
  14. var ts = new Date().getTime().toString();
  15. var img = new Image(0, 0);
  16. img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
  17. };

  用这种方式有几个优势:

  • 兼容性高,所有的浏览器都支持。
  • 不存在跨域问题。
  • 不会携带当前域名中的 cookie。
  • 不会阻塞页面加载。
  • 相比于其他类型的图片格式(BMP、PNG等),能节约更多的网络资源。

  不过这种方式也有一个问题,那就是采用 GET 的请求后,浏览器会限制 URL 的长度,也就是不能携带太多的数据。

  在之前记录 Ajax 响应数据时就有一个判断,只记录300个字符以内的响应数据,其实就是为了规避此限制而加了这段代码。

3)身份标识

  每次进入页面都会生成一个唯一的标识,存储在 sessionStorage 中。在查询日志时,可通过该标识过滤出此用户的上下文日志,消除与他不相干的日志。

  1. function getIdentity() {
  2. var key = "shin-monitor-identity";
  3. //页面级的缓存而非全站缓存
  4. var identity = sessionStorage.getItem(key);
  5. if (!identity) {
  6. //生成标识
  7. identity = Number(
  8. Math.random().toString().substr(3, 3) + Date.now()
  9. ).toString(36);
  10. sessionStorage.setItem(key, identity);
  11. }
  12. return identity;
  13. }

从零开始搞监控系统(1)——SDK的更多相关文章

  1. 从零开始搭建前端监控系统(三)——实现控制iframe前进后退

    前言 本系列文章旨在讲解如何从零开始搭建前端监控系统. 项目已经开源 项目地址: https://github.com/bombayjs/bombayjs (web sdk) https://gith ...

  2. Qt编写安防视频监控系统9-自动隐藏光标

    一.前言 这个效果的灵感来自于大屏电子看板系统,在很多系统中尤其是上了大屏的时候,其实在用户不在操作的时候,是很不希望看到那个鼠标箭头指针的,只有当用户操作的时候才显示出来,这个就需要开个定时器定时计 ...

  3. 用python 10min手写一个简易的实时内存监控系统

    简易的内存监控系统 本文需要有一定的python和前端基础,如果没基础的,请关注我后续的基础教程系列博客 文章github源地址,还可以看到具体的代码,喜欢请在原链接右上角加个star 腾讯视频链接 ...

  4. 互联网级监控系统必备-时序数据库之Influxdb

    时间序列数据库,简称时序数据库,Time Series Database,一个全新的领域,最大的特点就是每个条数据都带有Time列. 时序数据库到底能用到什么业务场景,答案是:监控系统. Baidu一 ...

  5. 前端性能监控系统 & 前端数据分析系统

    前端监控系统 目前已经上线,欢迎使用! 背景:应工作要求,需要整理出前端项目的报错信息,尝试过很多统计工具,如: 腾讯bugly.听云.OneApm.还有一个忘记名字的工具. 因为各种原因,如: 统计 ...

  6. [转]用python 10min手写一个简易的实时内存监控系统

    简易的内存监控系统 本文需要有一定的python和前端基础,如果没基础的,请关注我后续的基础教程系列博客 文章github源地址,还可以看到具体的代码,喜欢请在原链接右上角加个star 腾讯视频链接 ...

  7. Lepus搭建企业级数据库全方位监控系统

    前言 Lepus(天兔)数据库企业监控系统是一套由专业DBA针对互联网企业开发的一款专业.强大的企业数据库监控管理系统,企业通过Lepus可以对数据库的实时健康和各种性能指标进行全方位的监控.目前已经 ...

  8. OneAPM大讲堂 | 基于图像质量分析的摄像头监控系统的实现

    今天咱们要介绍的技术很简单,请看场景: 你在家里安装了几个摄像头想监视你家喵星人的一举一动,然而,就在喵星人准备对你的新包发动攻击的时候,图像突然模糊了.毕竟图像模糊了以后你就没法截图回家和喵当面对质 ...

  9. 互联网级监控系统必备-时序数据库之Influxdb技术

    时间序列数据库,简称时序数据库,Time Series Database,一个全新的领域,最大的特点就是每个条数据都带有Time列. 时序数据库到底能用到什么业务场景,答案是:监控系统. Baidu一 ...

随机推荐

  1. HDU_6693 Valentine's Day 【概率问题】

    一.题目 Valentine's Day 二.分析 假设$ s_0 $代表不开心的概率,$ s_1 $代表开心一次的概率. 那么随便取一个物品,那么它的开心概率为$ p _i $,可以推导加入之后使女 ...

  2. python stats画正态分布、指数分布、对数正态分布的QQ图

    stats.probplot(grade, dist=stats.norm, plot=plt) #正态分布 # stats.probplot(grade, dist=stats.expon, plo ...

  3. java重写toString()方法

    toString()方法是Object类的方法,调用toString()会返回对象的描述信息. 1)为什么重写toString()方法呢? 如果不重写,直接调用Object类的toString()方法 ...

  4. swing实现QQ登录界面1.0( 实现了同一张图片只加载一次)、(以及实现简单的布局面板添加背景图片控件的标签控件和添加一个关闭按钮控件)

    swing实现QQ登录界面1.0( 实现了同一张图片只加载一次).(以及实现简单的布局面板添加背景图片控件的标签控件和添加一个关闭按钮控件) 代码思路分析: 1.(同一张图片仅仅需要加载一次就够了,下 ...

  5. python3表格数据处理

    技术背景 数据处理是一个当下非常热门的研究方向,通过对于大型实际场景中的数据进行建模,可以用于预测下一阶段可能出现的情况.比如我们有过去的2002年-2018年的黄金价格的数据: 该数据来源于Gite ...

  6. frp穿透内网使用vsftpd服务

    本篇文章将会介绍如何使用frp穿透内网以及如何在centos8环境下安装和使用vsftpd,最后在公网通过frp穿透内网使用ftp. 一.内网穿透神器frp frp 是一个专注于内网穿透的高性能的反向 ...

  7. CVPR2021| 继SE,CBAM后的一种新的注意力机制Coordinate Attention

    前言: 最近几年,注意力机制用来提升模型性能有比较好的表现,大家都用得很舒服.本文将介绍一种新提出的坐标注意力机制,这种机制解决了SE,CBAM上存在的一些问题,产生了更好的效果,而使用与SE,CBA ...

  8. wap视频广告遇到的问题

    最近在做一个wap端的视频广告,耗了很多心力在上面,仍旧做不好.没想到wap浏览器对video标签这么不友好.广告需要在原编辑视频播完后插入并自动播放. ios浏览器点击播放按钮后喜欢自动全屏播放,希 ...

  9. 001 - 使用鸿蒙WebView创建简单浏览器 step 1

    打开官网,找到WebView的文档(模拟器不支持) 鸿蒙webview的开发指南(原始链接,方便大家识别并点击):https://developer.harmonyos.com/cn/docs/doc ...

  10. 自动化kolla-ansible部署ubuntu20.04+openstack-victoria之ceph部署-07

    自动化kolla-ansible部署ubuntu20.04+openstack-victoria之ceph部署-07 欢迎加QQ群:1026880196 进行交流学习 近期我发现网上有人转载或者复制原 ...