关于负载均衡:深入浅出LB手把手带你实现一个负载均衡器

49次阅读

共计 13080 个字符,预计需要花费 33 分钟才能阅读完成。

写作不易,未经作者容许禁止以任何模式转载!<br/> 如果感觉文章不错,欢送关注、点赞和分享!
继续分享技术博文,关注微信公众号 👉🏻 前端 LeBron
字节跳动校招进行中,校招内推码: 4FCV6BV 游戏部门前端团队可私聊直推
原文链接

简介

负载平衡,含意就是依据肯定算法将负载(工作工作)进行均衡,摊派到多个操作单元上运行、执行,常见的为 Web 服务器、企业外围应用服务器和其余次要工作服务器等,从而协同实现工作工作。负载平衡在原有的网络结构上提供了一种通明且无效的的办法扩大服务器和网络设备的带宽、增强网络数据处理能力、减少吞吐量、进步网络的可用性和灵活性,同时接受住更大的并发量级。

简略来说就是将大量的并发申请解决转发给多个后端节点解决,缩小工作响应工夫。

  • 防止资源节约
  • 防止服务不可用

一、分类

四层(传输层)

四层即 OSI 七层模型中的传输层,有 TCP、UDP 协定,这两种协定中蕴含源 IP、指标 IP 以外,还蕴含源端口号及指标端口号。四层负载平衡在接管到客户端申请后,通过批改报文的地址信息(IP + PORT)将流量转发到应用服务器。

七层(应用层)

代理负载平衡

七层即 OSI 七层模型中的应用层,应用层协定较多,罕用的为 HTTP/HTTPS。七层负载平衡能够给予这些协定来负载。这些应用层协定中会蕴含很多有意义的内容。比方同一个 Web 服务器的负载平衡,除了依据 IP + PORT 进行负载平衡,还能够依据七层的 URL、Cookie、浏览器类别、语言、申请类型来决定。

四层负载平衡的实质是转发,七层负载平衡的实质是内容替换和代理。

四层负载平衡 七层负载平衡
基于 IP + PORT URL 或 主机 IP
相似 路由器 代理服务器
复杂度
性能 高,无需解析内容 中,需算法辨认 URL Header、Cookie 等
安全性 低,无奈辨认 DDoS 攻打 高,可进攻 SYN Flood 攻打
扩大性能 内容缓存、图片防盗链等

二、常见算法

前置数据结构

interface urlObj{
  url:string,
  weight:number // 仅在权重轮询时失效
}
urlDesc: urlObj[]
​
interface urlCollectObj{
  count: number, // 连接数
  costTime: number, // 响应工夫
  connection: number, // 实时连接数
}
urlCollect: urlCollectObj[]

Random

随机

const Random = (urlDesc) => {let urlCollect = [];
​
  //  收集 url
  urlDesc.forEach((val) => {urlCollect.push(val.url);
  });
​
  
  return () => {
    //  生成随机数下标返回相应 URL
    const pos = parseInt(Math.random() * urlCollect.length);
    return urlCollect[pos];
  };
};
​
module.exports = Random;

Weighted Round Robin

权重轮询算法

const WeiRoundRobin = (urlDesc) => {
  let pos = 0,
    urlCollect = [],
    copyUrlDesc = JSON.parse(JSON.stringify(urlDesc));
​
  // 依据权重收集 url
  while (copyUrlDesc.length > 0) {for (let i = 0; i < copyUrlDesc.length; i++) {urlCollect.push(copyUrlDesc[i].url);
      copyUrlDesc[i].weight--;
      if (copyUrlDesc[i].weight === 0) {copyUrlDesc.splice(i, 1);
        i--;
      }
    }
  }
  // 轮询获取 URL 函数
  return () => {const res = urlCollect[pos++];
    if (pos === urlCollect.length) {pos = 0;}
    return res;
  };
};
​
module.exports = WeiRoundRobin;

IP Hash & URL Hash

源 IP / URL Hash

const {Hash} = require("../util");
​
const IpHash = (urlDesc) => {let urlCollect = [];
​
  for (const key in urlDesc) {
    // 收集 url
    urlCollect.push(urlDesc[key].url);
  }
​
  return (sourceInfo) => {
    // 生成 Hash 十进制数值
    const hashInfo = Hash(sourceInfo);
    // 取余为下标
    const urlPos = Math.abs(hashInfo) % urlCollect.length;
    // 返回
    return urlCollect[urlPos];
  };
};
​
module.exports = IpHash;

Consistent Hash

一致性 Hash

const {Hash} = require("../util");
​
const ConsistentHash = (urlDesc) => {let urlHashMap = {},
    hashCollect = [];
​
  for (const key in urlDesc) {
    // 收集 urlHash 进数组和生成 HashMap
    const {url} = urlDesc[key];
    const hash = Hash(url);
    urlHashMap[hash] = url;
    hashCollect.push(hash);
  }
  // 将 hash 数组从小到大排序
  hashCollect = hashCollect.sort((a, b) => a - b);
​
  return (sourceInfo) => {
    // 生成 Hash 十进制数值
    const hashInfo = Hash(sourceInfo);
    // 遍历 hash 数组找到第一个比源信息 hash 值大的,并通过 hashMap 返回 url
    hashCollect.forEach((val) => {if (val >= hashInfo) {return urlHashMap[val];
      }
    });
    // 没找大则返回最大的
    return urlHashMap[hashCollect[hashCollect.length - 1]];
  };
};
​
module.exports = ConsistentHash;

Least Connections

最小连接数

const leastConnections = () => {return (urlCollect) => {
    let min = Number.POSITIVE_INFINITY,
      url = "";
​
    // 遍历对象找到起码连接数的地址
    for (let key in urlCollect) {const val = urlCollect[key].connection;
      if (val < min) {
        min = val;
        url = key;
      }
    }
    // 返回
    return url;
  };
};
​
module.exports = leastConnections;

注:urlCollect 为负载均属数据统计对象,有以下属性

  • connection 实时连接数
  • count 解决申请次数
  • costTime 响应工夫。

FAIR

最小响应工夫

const Fair = () => {return (urlCollect) => {
    let min = Number.POSITIVE_INFINITY,
      url = "";
​
     // 找到耗时起码的 url 
    for (const key in urlCollect) {const urlObj = urlCollect[key];
      if (urlObj.costTime < min) {
        min = urlObj.costTime;
        url = key;
      }
    }
    // 返回
    return url;
  };
};
​
module.exports = Fair;

看到这里是不是感觉算法都挺简略的 🥱

期待一下模块五的实现吧😏

三、衰弱监测

衰弱监测即对应用服务器的衰弱监测,为避免把申请转发到异样的应用服务器上,应应用衰弱监测策略。应答不同的业务敏感水平,可相应调整策略和频率。

HTTP / HTTPS 衰弱监测步骤(七层)

  1. 负载平衡节点向利用服务器发送 HEAD 申请。
  2. 应用服务器接管到 HEAD 申请后依据状况返回相应状态码。
  3. 若在超时工夫内未收到返回的状态码,则判断为超时,健康检查失败。
  4. 若在超时工夫内收到返回的状态码,负载平衡节点进行比对,判断健康检查是否胜利。

TCP 健康检查步骤(四层)

  1. 负载平衡节点向内网应用服务器 IP + PORT 发 TCP SYN 申请数据包。
  2. 内网应用服务器收到申请后,若在失常监听,则返回 SYN + ACK 数据包。
  3. 若在超时工夫内未收到返回的数据包,则判断服务无响应、健康检查失败,并向内网利用服务器发送 RST 数据包中断 TCP 连贯。
  4. 若在超时工夫内收到返回的数据包,则断定服务衰弱运行,发动 RST 数据包中断 TCP 连贯。

UDP 健康检查步骤(四层)

  1. 负载平衡节点向内网应用服务器 IP + PORT 发送 UDP 报文。
  2. 若内网应用服务器未失常监听,则返回 PORT XX unreachable 的 ICMP 报错信息,反之为失常。
  3. 若在超时工夫内收到了报错信息,则判断服务异样,健康检查失败。
  4. 若在超时工夫内未收到报错信息,则判断服务衰弱运行。

四、VIP 技术

Vrtual IP

虚构 IP

  • 在 TCP / IP 架构下,所有想上网的电脑,不管以何种模式连上网络,都不须要有一个惟一的 IP 地址。事实上 IP 地址是主机硬件物理地址的一种形象。
  • 简略来说地址分为两种

    • MAC 物理地址
    • IP 逻辑地址
  • 虚构 IP 是一个未调配给实在主机的 IP,也就是说对外提供的服务器的主机除了有一个实在 IP 还有一个虚 IP,这两个 IP 中的任意一个都能够连贯到这台主机。

    • 通过虚构 IP 对应实在主机的 MAC 地址实现
  • 虚构 IP 个别用作达到高可用的目标,比方让所有我的项目中的数据库链接配置都是这个虚构 IP,当主服务器产生故障无奈对外提供服务时,动静将这个虚 IP 切换到备用服务器。

虚构 IP 原理

  1. ARP 是地址解析协定,作用为将一个 IP 地址转换为 MAC 地址。
  2. 每台主机都有 ARP 高速缓存,存储同一个网络内 IP 地址与 MAC 地址的映射关系,主机发送数据会先从这个缓存中查 3 指标 IP 对应 MAC 地址,向这个 MAC 地址发送数据。操作系统主动保护这个缓存。
  3. Linux 下可用 ARP 命令操作 ARP 高速缓存
  • 比方存在主机 A(192.168.1.6)和主机 B(192.168.1.8)。A 作为对外服务的主服务器,B 作为备份机器,两台服务器之间通过 HeartBeat 通信。
  • 即主服务器会定时给备份服务器发送数据包,告知主服务器失常,当备份服务器在规定工夫内没有收到主服务器的 HeartBeat,会认为主服务器宕机。
  • 此时备份服务器就降级为主服务器。

    • 服务器 B 将本人的 ARP 缓存发送进来,告知路由器批改路由表,告知虚构 IP 地址应该指向 192.168.1.8.
    • 这时外接再次拜访虚构 IP 的时候,机器 B 就会变成主服务器,而 A 降级为备份服务器。
    • 这样就实现了主从机器的切换,这所有对外都是无感知、通明的。

五、基于 NodeJS 实现一个简略的负载平衡

想手动实现一下负载均衡器 / 看看源码的同学都能够看看 👉🏻 代码仓库

预期成果

编辑 config.js 后 npm run start 即可启动均衡器和后端服务节点

  • urlDesc:后端服务节点配置对象,weight 仅在 WeightRoundRobin 算法时起作用
  • port:均衡器监听端口
  • algorithm:算法名称(模块二中的算法均已实现)
  • workerNum:后端服务端口开启过程数,提供并发能力。
  • balancerNum:均衡器端口开启过程数,提供并发能力。
  • workerFilePath:后端服务节点执行文件,举荐应用绝对路径。
const {ALGORITHM, BASE_URL} = require("./constant");
​
module.exports = {
    urlDesc: [
        {url: `${BASE_URL}:${16666}`,
            weight: 6,
        },
        {url: `${BASE_URL}:${16667}`,
            weight: 1,
        },
        {url: `${BASE_URL}:${16668}`,
            weight: 1,
        },
        {url: `${BASE_URL}:${16669}`,
            weight: 1,
        },
        {url: `${BASE_URL}:${16670}`,
            weight: 2,
        },
        {url: `${BASE_URL}:${16671}`,
            weight: 1,
        },
        {url: `${BASE_URL}:${16672}`,
            weight: 4,
        },
    ],
    port: 8080,
    algorithm: ALGORITHM.RANDOM,
    workerNum: 5,balancerNum: 5,workerFilePath:path.resolve(__dirname, "./worker.js")
}

架构设计图

先来看看主流程 main.js

  1. 初始化负载平衡统计对象 balanceDataBase

    • balanceDataBase 是一个 DataBase 类实例,用于统计负载平衡数据(后续会讲到).
  2. 运行均衡器

    • 多过程模型,提供并发能力。
  3. 运行后端服务节点

    • 多线程 + 多过程模型,运行多个服务节点并提供并发能力。
const {urlDesc, balancerNum} = require("./config")
const cluster = require("cluster");
const path = require("path");
const cpusLen = require("os").cpus().length;
const {DataBase} = require("./util");
const {Worker} = require('worker_threads');
​
const runWorker = () => {
    // 避免监听端口数 > CPU 核数
    const urlObjArr = urlDesc.slice(0, cpusLen);
    // 初始化创立子线程
    for (let i = 0; i < urlObjArr.length; i++) {createWorkerThread(urlObjArr[i].url);
    }
}
​
const runBalancer = () => {
    // 设置子过程执行文件
    cluster.setupMaster({exec: path.resolve(__dirname, "./balancer.js")});
    // 初始化创立子过程
    let max
    if (balancerNum) {max = balancerNum > cpusLen ? cpusLen : balancerNum} else {max = 1}
    for (let i = 0; i < max; i++) {createBalancer();
    }
}
​
// 初始化负载平衡数据统计对象
const balanceDataBase = new DataBase(urlDesc);
// 运行均衡器
runBalancer();
// 运行后端服务节点
runWorker();

创立均衡器(createBalancer 函数)

  1. 创立过程
  2. 监听过程通信音讯

    • 监听更新响应工夫事件并执行更新函数

      • 用于 FAIR 算法(最小响应工夫)。
    • 监听获取统计对象事件并返回
  3. 监听异样退出并从新创立,过程守护。
const createBalancer = () => {
    // 创立过程
    const worker = cluster.fork();
    worker.on("message", (msg) => {
        // 监听更新响应工夫事件
        if (msg.type === "updateCostTime") {balanceDataBase.updateCostTime(msg.URL, msg.costTime)
        }
        // 监听获取 url 统计对象事件并返回
        if (msg.type === "getUrlCollect") {worker.send({type: "getUrlCollect", urlCollect: balanceDataBase.urlCollect})
        }
    });
    // 监听异样退出事件并从新创立过程
    worker.on("exit", () => {createBalancer();
    });
}

创立后端服务节点(createWorkerThread 函数)

  1. 创立线程
  2. 解析须要监听的端口
  3. 向子线程通信,发送须要监听的端口
  4. 通过线程通信,监听子线程事件

    • 监听连贯事件,并触发处理函数。
    • 监听断开连接事件并触发处理函数。
    • 用于统计负载平衡散布和实时连接数。
  5. 监听异样退出并从新创立,线程守护。
const createWorkerThread = (listenUrl) => {
    // 创立线程
    const worker = new Worker(path.resolve(__dirname, "./workerThread.js"));
    // 获取监听端口
    const listenPort = listenUrl.split(":")[2];
    // 向子线程发送要监听的端口号
    worker.postMessage({type: "port", port: listenPort});
​
    // 接管子线程音讯统计过程被拜访次数
    worker.on("message", (msg) => {
        // 监听连贯事件并触发计数事件
        if (msg.type === "connect") {balanceDataBase.add(msg.port);
        }
        // 监听断开连接事件并触发计数事件
        else if (msg.type === "disconnect") {balanceDataBase.sub(msg.port);
        }
    });
    // 监听异样退出事件并从新创立过程
    worker.on("exit", () => {createWorkerThread(listenUrl);
    });
}

再来看看均衡器工作流程 balancer.js

  1. 获取 getURL 工具函数
  2. 监听申请并代理

    • 获取须要传入 getURL 工具函数的参数。
    • 通过 getURL 工具函数获取平衡代理目标地址 URL
    • 记录申请开始工夫
    • 解决跨域
    • 返回响应
    • 通过过程通信,触发响应工夫更新事件。

注 1:LoadBalance 函数即通过算法名称返回不同的 getURL 工具函数,各算法实现见模块二:常见算法

注 2:getSource 函数即解决参数并返回,getURL 为下面讲到的获取 URL 工具函数。

const cpusLen = require("os").cpus().length;
const LoadBalance = require("./algorithm");
const express = require("express");
const axios = require("axios");
const app = express();
const {urlFormat, ipFormat} = require("./util");
const {ALGORITHM, BASE_URL} = require("./constant");
const {urlDesc, algorithm, port} = require("./config");
​
const run = () => {
    // 获取转发 URL 工具函数
    const getURL = LoadBalance(urlDesc.slice(0, cpusLen), algorithm);
    // 监听申请并平衡代理
    app.get("/", async (req, res) => {
        // 获取须要传入的参数
        const source = await getSource(req);
        // 获取 URL
        const URL = getURL(source);
        // res.redirect(302, URL) 重定向负载平衡
        // 记录申请开始工夫
        const start = Date.now();
        // 代理申请
        axios.get(URL).then(async (response) => {
            // 获取负载平衡统计对象并返回
            const urlCollect = await getUrlCollect();
            // 解决跨域
            res.setHeader("Access-Control-Allow-Origin", "*");
            response.data.urlCollect = urlCollect;
            // 返回数据
            res.send(response.data);
            // 记录相应工夫并更新
            const costTime = Date.now() - start;
            process.send({type: "updateCostTime", costTime, URL})
        });
    });
    // 负载平衡服务器开始监听申请
    app.listen(port, () => {console.log(`Load Balance Server Running at ${BASE_URL}:${port}`);
    });
};
​
run();
​
​
const getSource = async (req) => {switch (algorithm) {
        case ALGORITHM.IP_HASH:
            return ipFormat(req);
        case ALGORITHM.URL_HASH:
            return urlFormat(req);
        case ALGORITHM.CONSISTENT_HASH:
            return urlFormat(req);
        case ALGORITHM.LEAST_CONNECTIONS:
            return await getUrlCollect();
        case ALGORITHM.FAIR:
            return await getUrlCollect();
        default:
            return null;
    }
};

如何在均衡器中获取负载平衡统计对象 getUrlCollect

  1. 通过过程通信,向父过程发送获取音讯。
  2. 同时开始监听父过程通信音讯,接管后应用 Promise resovle 返回。
// 获取负载平衡统计对象
const getUrlCollect = () => {return new Promise((resolve, reject) => {
        try {process.send({type: "getUrlCollect"})
            process.on("message", msg => {if (msg.type === "getUrlCollect") {resolve(msg.urlCollect)
                }
            })
        } catch (e) {reject(e)
        }
    })
}

如何实现服务节点并发 workerThread.js

应用多线程 + 多过程模型,为每个服务节点提供并发能力。

主过程流程

  1. 依据配置文件,创立相应数量服务节点。

    • 创立过程
    • 监听父线程音讯(服务节点监听端口),并转发给子过程。
    • 监听子过程音讯,并转发给父线程(建设连贯、断开连接事件)。
    • 监听异样退出并从新建设。
const cluster = require("cluster");
const cpusLen = require("os").cpus().length;
const {parentPort} = require('worker_threads');
const {workerNum, workerFilePath} = require("./config")
​
if (cluster.isMaster) {
    // 创立工作过程函数
    const createWorker = () => {
        // 创立过程
        const worker = cluster.fork();
        // 监听父线程音讯,并转发给子过程。parentPort.on("message", msg => {if (msg.type === "port") {worker.send({type: "port", port: msg.port})
            }
        })
        // 监听子过程音讯并转发给父线程
        worker.on("message", msg => {parentPort.postMessage(msg);
        })
        // 监听过程异样退出并从新创立
        worker.on("exit", () => {createWorker();
        })
    }
    // 按配置创立过程,但不可大于 CPU 核数
    let max
    if (workerNum) {max = workerNum > cpusLen ? cpusLen : workerNum} else {max = 1}
    for (let i = 0; i < max; i++) {createWorker();
    }
} else {
    // 后端服务执行文件
    require(workerFilePath)
}
​

子过程流程 worker.js(config.workerFilePath)

  1. 通过过程间通信,向父过程发送音讯,触发建设连贯事件。
  2. 返回相应。
  3. 通过过程间通信,向父过程发送音讯,触发断开连接事件。
var express = require("express");
var app = express();
let port = null;
​
app.get("/", (req, res) => {
    // 触发连贯事件
    process.send({type: "connect", port});
    // 打印信息
    console.log("HTTP Version:" + req.httpVersion);
    console.log("Connection PORT Is" + port);
​
    const msg = "Hello My PORT is" + port;
​
    // 返回响应
    res.send({msg});
    // 触发断开连接事件
    process.send({type: "disconnect", port});
});
​
// 接管主进通信音讯中的端口口并监听
process.on("message", (msg) => {if (msg.type === "port") {
        port = msg.port;
        app.listen(port, () => {console.log("Worker Listening" + port);
        });
    }
});

最初来看看 DataBase 类

  • 成员:
  1. status:工作队列状态
  2. urlCollect:数据统计对象(提供给各算法应用 / 展现数据)

    • count:解决申请数
    • costTime:响应工夫
    • connection:实时连接数
  3. add 办法

    • 减少连接数和实时连接数
  4. sub 办法

    • 缩小实时连接数
  5. updateCostTime 办法

    • 更新响应工夫
class DataBase {urlCollect = {};
​
    // 初始化
    constructor (urlObj) {urlObj.forEach((val) => {this.urlCollect[val.url] = {
                count: 0,
                costTime: 0,
                connection: 0,
            };
        });
    }
​
    // 减少连接数和实时连接数
    add (port) {const url = `${BASE_URL}:${port}`;
        this.urlCollect[url].count++;
        this.urlCollect[url].connection++;
    }
​
    // 缩小实时连接数
    sub (port) {const url = `${BASE_URL}:${port}`;
        this.urlCollect[url].connection--;
    }
​
    // 更新响应工夫
    updateCostTime (url, time) {this.urlCollect[url].costTime = time;
    }
}

最终成果

做了个可视化图表来看平衡成果(Random)✔️

看起来平衡成果还不错🧐

小作业

想手动实现一下负载均衡器 / 看看源码的同学都能够看看 👉🏻 代码仓库

六、常识扩大

cluster 多过程为什么能够监听一个端口?

  1. 通过 cluster.isMaster 判断是否为主过程,主过程不负责工作解决,只负责管理和调度工作子过程。
  2. master 主过程启动了一个 TCP 服务器,真正监听端口的只有这个 TCP 服务器。申请触发了这个 TCP 服务器的 connection 事件后,通过句柄转发(IPC)给工作过程解决。

    1. 句柄转发可转发 TCP 服务器、TCP 套接字、UDP 套接字、IPC 管道
    2. IPC 只反对传输字符串,不反对传输对象(可序列化)。
    3. 转发流程:父过程发送 -> stringfy && send(fd) -> IPC -> get(fd) && parse -> 子过程接管
    4. fd 为句柄文件描述符。
  3. 如何抉择工作过程?

    1. cluster 模块内置了 RoundRobin 算法,轮询抉择工作过程。
  4. 为什么不间接用 cluster 进行负载平衡?

    1. 手动实现可依据不同场景抉择不同的负载平衡算法。

Node 怎么实现过程间通信的?

  1. 常见的过程间通信形式

    • 管道通信

      • 匿名管道
      • 命名管道
    • 信号量
    • 共享内存
    • Socket
    • 音讯队列
  2. Node 中实现 IPC 通道是依赖于 libuv。Windows 下由命名管道实现,*nix 零碎则采纳 Domain Socket 实现。
  3. 体现在应用层上的过程间通信只有简略的 message 事件和 send()办法,接口非常简洁和音讯化。
  4. IPC 管道是如何建设的?

    • 父过程先通过环境变量告知子过程管道的文件描述符
    • 父过程创立子过程
    • 子过程启动,通过文件描述符连贯已存在的 IPC 管道,与父过程建设连贯。

多过程 VS 多线程

多过程

  1. 数据共享简单,须要 IPC。数据是离开的,同步简略。
  2. 占用内存多,CPU 利用率低。
  3. 创立销毁简单,速度慢
  4. 过程独立运行,不会相互影响
  5. 可用于多机多核分布式,易于扩大

多线程

  1. 共享过程数据,数据共享简略,同步简单。
  2. 占用内存少,CPU 利用率高。
  3. 创立销毁简略,速度快。
  4. 线程同呼吸共命运。
  5. 只能用于多核分布式。

七、由本次分享产生的一些想法

欢送留言探讨

  1. Node.js 非阻塞异步 I / O 速度快,前端扩大服务端业务?
  2. 企业实际,阐明 Node 还是牢靠的?

    • 阿里 Node 中台架构
    • 腾讯 CloudBase 云开发 Node
    • 大量 Node.js 全栈工程师岗位
  3. Node 计算密集型不敌对?

    • Serverless 流行,计算密集型用 C ++/Go/Java 编写,以 Faas / RPC 的形式调用。
  4. Node 生态不如其余成熟的语言

    • 阿里输入了 Java 生态
    • 是不是能够看准趋势,打造 Node 生态以加强团队影响力。
  5. 探讨

    • Node.js 做 Web 后端劣势为什么这么大?

八、参考资料

  1. 健康检查概述 – 负载平衡
  2. 《深入浅出 Node.js》
  3. Node.js (nodejs.cn)
  4. 深刻了解 Node.js 中的过程与线程
  • 原文链接
  • 掘金:前端 LeBron
  • 知乎:前端 LeBron
  • 继续分享技术博文,关注微信公众号 👉🏻 前端 LeBron

正文完
 0