
👋 大家好,欢迎来到我的技术博客!
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕YARN这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!
文章目录
YARN - YARN Web UI 核心功能:集群状态与任务监控 🚀
在当今的大数据生态系统中,YARN(Yet Another Resource Negotiator) 作为 Apache Hadoop 的核心组件之一,承担着资源管理和任务调度的关键职责。随着数据规模的不断增长和计算需求的多样化,如何高效地监控集群运行状态、追踪作业执行过程、诊断性能瓶颈,成为运维工程师和开发人员必须掌握的核心技能。
YARN Web UI 作为用户与集群交互的重要门户,提供了直观、实时的可视化界面,帮助我们全面了解集群的健康状况、资源使用情况以及正在运行或已完成的应用程序信息。本文将深入剖析 YARN Web UI 的核心功能,结合实际场景,讲解其在 集群状态监控 与 任务管理 中的作用,并通过 Java 编程接口实现对 YARN 集群的动态查询与控制。
我们将从 YARN 架构原理出发,逐步解析 Web UI 各个模块的功能,展示如何通过 REST API 获取关键指标,并编写可运行的 Java 示例代码来构建自定义监控工具。同时,文章中还将引入 Mermaid 图表以清晰呈现系统架构与数据流,确保内容既专业又易于理解。
🌐 YARN 架构概览:资源调度的基石
在进入 Web UI 功能详解之前,我们需要先理解 YARN 的基本架构。YARN 的设计目标是将资源管理与作业调度解耦,从而支持多种计算框架(如 MapReduce、Spark、Flink 等)共存于同一集群。
YARN 主要由以下几个核心组件构成:
- ResourceManager (RM):整个集群的“大脑”,负责全局资源调度和应用程序生命周期管理。
- NodeManager (NM):运行在每个工作节点上,负责本地资源(CPU、内存等)的监控与容器管理。
- ApplicationMaster (AM):每个应用程序启动一个 AM 实例,负责向 RM 申请资源并协调任务执行。
- Container:资源分配的基本单位,封装了 CPU、内存等资源,用于运行任务。
YARN Web UI 本质上是 ResourceManager 提供的一个 HTTP 服务端点,默认监听在 8088 端口(可通过 yarn-site.xml 中的 yarn.resourcemanager.webapp.address 配置)。它通过聚合来自 RM 和 NM 的状态信息,为用户提供了一个集中式的监控入口。
🔗 想了解更多关于 YARN 架构的设计理念?可以访问 Apache Hadoop 官方文档 查看权威说明。
🖥️ YARN Web UI 的核心页面与功能解析
当我们打开浏览器访问 http://<resourcemanager-host>:8088 时,首先看到的是 YARN Web UI 的首页 —— Cluster Overview 页面。这个页面是所有监控操作的起点,包含了集群最关键的运行指标。
1. Cluster Metrics(集群指标)
该区域展示了集群的整体资源概况,包括:
- Active Nodes / Lost Nodes / Unhealthy Nodes:活跃、丢失和不健康节点数量。
- Total Memory Available / Allocated / Reserved:总内存、已分配内存、预留内存(MB)。
- Total Virtual Cores Available / Allocated:虚拟核数使用情况。
- Running / Accepted / Finished Applications:应用状态统计。
这些数字直接反映了集群的负载能力和稳定性。例如,如果 “Lost Nodes” 数量持续增加,可能意味着网络问题或 NodeManager 进程异常;而 “Unhealthy Nodes” 则通常是因为磁盘空间不足或心跳超时。
2. Quick Links(快速链接)
提供通往关键子系统的快捷入口:
- Nodes:查看所有节点的详细状态,包括 IP 地址、健康状态、资源使用率等。
- Applications:列出当前所有提交的应用程序,支持按状态过滤(RUNNING、FINISHED、FAILED 等)。
- Scheduler:展示资源调度器(如 CapacityScheduler 或 FairScheduler)的队列结构与资源分配情况。
- About:显示 YARN 版本、编译信息及配置摘要。
点击 Applications 可进入应用列表页,这是最常使用的功能之一。每条记录包含以下字段:
| 字段 | 说明 |
|---|---|
| Application ID | 唯一标识符,格式如 application_1712345678901_0001 |
| Name | 应用名称(如 Spark 作业名) |
| User | 提交者用户名 |
| Queue | 所属调度队列 |
| State | 当前状态(NEW, SUBMITTED, RUNNING, FINISHED, FAILED, KILLED) |
| FinalStatus | 最终状态(SUCCEEDED, FAILED, KILLED) |
| Progress | 执行进度百分比 |
| Started Time | 启动时间戳 |
| Elapsed Time | 已运行时间 |
我们可以根据需要排序或搜索特定应用。点击任意 Application ID 可进入 Application Details 页面,获取更详细的执行信息。
3. Application Details(应用详情)
此页面分为多个标签页:
a. Overview
显示应用基本信息:AM 跟踪 URL、启动命令、资源请求(内存/CPU)、运行时长等。其中 Tracking URL 是非常重要的字段,指向 ApplicationMaster 自身提供的 Web UI(如 Spark History Server),可用于查看任务级日志和 DAG 图。
b. Executors / Containers
列出所有为该应用分配的 Container,包括:
- Container ID
- 所在 Node
- 资源使用(Memory, VCores)
- 启动时间
- 日志链接(Logs)
通过分析 Container 分布,可以判断是否存在数据倾斜或资源争抢问题。
c. Attempts
展示 ApplicationMaster 的尝试历史。若 AM 因崩溃重启,会生成新的 Attempt 记录。每个 Attempt 包含其生命周期日志,有助于故障排查。
d. Configuration
展示该应用运行时加载的完整 Hadoop 配置参数,便于验证配置是否生效。
🔍 使用 REST API 获取 YARN 状态信息
虽然 Web UI 提供了友好的图形化界面,但在自动化运维、告警系统或集成平台中,我们往往需要通过编程方式获取 YARN 数据。YARN ResourceManager 提供了一套完善的 RESTful API 接口,支持 JSON 格式响应。
以下是常用 API 端点及其用途:
| API Endpoint | 功能描述 |
|---|---|
/ws/v1/cluster/metrics | 获取集群整体指标 |
/ws/v1/cluster/nodes | 查询所有节点状态 |
/ws/v1/cluster/apps | 获取应用程序列表 |
/ws/v1/cluster/app/{appid} | 获取指定应用详情 |
/ws/v1/cluster/scheduler | 获取调度器信息 |
这些接口无需认证即可访问(生产环境建议启用 Kerberos 安全机制),返回标准 JSON 结构,非常适合 Java、Python 等语言调用。
🔗 更完整的 API 文档请参考 Hadoop YARN REST API 官方指南
下面我们通过 Java 编写几个实用的客户端工具类,演示如何利用这些 API 实现自动化监控。
💻 Java 实战:构建 YARN 监控客户端
我们将使用标准的 Java SE + java.net.http.HttpClient(JDK 11+)来发起 HTTP 请求,并结合 Jackson 解析 JSON 响应。项目无需额外依赖,保持轻量简洁。
环境准备
确保你的 JDK 版本 ≥ 11:
java -version
# openjdk version "17.0.9" 2023-10-17
创建 Maven 项目,在 pom.xml 中添加 Jackson 依赖:
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
</dependency>
工具类:YarnRestClient
封装通用的 HTTP 请求逻辑:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class YarnRestClient {
private final String rmHost;
private final int rmPort;
private final HttpClient client;
private final ObjectMapper mapper;
public YarnRestClient(String rmHost, int rmPort) {
this.rmHost = rmHost;
this.rmPort = rmPort;
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.mapper = new ObjectMapper();
}
private String sendGetRequest(String endpoint) throws Exception {
String url = "http://" + rmHost + ":" + rmPort + endpoint;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
}
return response.body();
}
public JsonNode getClusterMetrics() throws Exception {
String json = sendGetRequest("/ws/v1/cluster/metrics");
return mapper.readTree(json).get("clusterMetrics");
}
public JsonNode getNodes() throws Exception {
String json = sendGetRequest("/ws/v1/cluster/nodes");
return mapper.readTree(json).get("nodes").get("node");
}
public JsonNode getApplications(String state) throws Exception {
String endpoint = "/ws/v1/cluster/apps?state=" + state;
String json = sendGetRequest(endpoint);
JsonNode root = mapper.readTree(json);
return root.has("apps") && root.get("apps").has("app")
? root.get("apps").get("app") : null;
}
public JsonNode getApplication(String appId) throws Exception {
String json = sendGetRequest("/ws/v1/cluster/app/" + appId);
return mapper.readTree(json).get("app");
}
public JsonNode getSchedulerInfo() throws Exception {
String json = sendGetRequest("/ws/v1/cluster/scheduler");
return mapper.readTree(json).get("scheduler").get("schedulerInfo");
}
}
上述代码实现了对主要 API 的封装,返回原始 JsonNode 对象,便于后续提取字段。
示例 1:打印集群概览信息 📊
编写主程序,输出集群核心指标:
public class ClusterMonitor {
public static void main(String[] args) {
try {
// 替换为你的 ResourceManager 地址
YarnRestClient client = new YarnRestClient("your-resourcemanager-host", 8088);
System.out.println("📊 正在获取集群指标...");
JsonNode metrics = client.getClusterMetrics();
System.out.println("✅ 集群指标获取成功!");
System.out.printf("总节点数: %d%n", metrics.get("numNodeManagers").asInt());
System.out.printf("活跃节点: %d%n", metrics.get("numActiveNMs").asInt());
System.out.printf("丢失节点: %d%n", metrics.get("numLostNMs").asInt());
System.out.printf("不健康节点: %d%n", metrics.get("numUnhealthyNMs").asInt());
long totalMem = metrics.get("totalMB").asLong();
long usedMem = metrics.get("allocatedMB").asLong();
double memUsage = (double) usedMem / totalMem * 100;
System.out.printf("内存使用: %.2f%% (%d/%d MB)%n", memUsage, usedMem, totalMem);
long totalVCores = metrics.get("totalVirtualCores").asLong();
long usedVCores = metrics.get("allocatedVirtualCores").asLong();
double cpuUsage = (double) usedVCores / totalVCores * 100;
System.out.printf("CPU 使用: %.2f%% (%d/%d vCores)%n", cpuUsage, usedVCores, totalVCores);
} catch (Exception e) {
System.err.println("❌ 获取集群信息失败:" + e.getMessage());
e.printStackTrace();
}
}
}
输出示例:
📊 正在获取集群指标...
✅ 集群指标获取成功!
总节点数: 10
活跃节点: 10
丢失节点: 0
不健康节点: 1
内存使用: 68.34% (69824/102144 MB)
CPU 使用: 52.10% (52/100 vCores)
该工具可用于定时巡检脚本,当 “Lost Nodes” > 0 或内存使用率 > 90% 时触发告警。
示例 2:检查长时间运行的应用 🕒
某些 Spark 或 Hive 作业可能因数据倾斜导致运行时间过长,影响集群资源周转。我们可以编写监控程序自动识别这类“长尾”作业。
import java.time.Instant;
import java.time.temporal.ChronoUnit;
public class LongRunningAppDetector {
private static final long MAX_DURATION_HOURS = 24; // 最大允许运行时间
public static void main(String[] args) {
try {
YarnRestClient client = new YarnRestClient("your-resourcemanager-host", 8088);
JsonNode apps = client.getApplications("RUNNING");
if (apps == null || !apps.isArray()) {
System.out.println("📭 没有正在运行的应用程序。");
return;
}
System.out.printf("🔍 检测到 %d 个运行中的应用,正在分析...%n", apps.size());
Instant now = Instant.now();
boolean foundLongRunning = false;
for (JsonNode app : apps) {
String appId = app.get("id").asText();
String name = app.get("name").asText();
String user = app.get("user").asText();
long startedTimeMs = app.get("startedTime").asLong();
Instant startTime = Instant.ofEpochMilli(startedTimeMs);
long hoursElapsed = ChronoUnit.HOURS.between(startTime, now);
if (hoursElapsed >= MAX_DURATION_HOURS) {
foundLongRunning = true;
System.err.printf("🚨 发现长时间运行应用:%s (%s)%n", appId, name);
System.err.printf(" 用户: %s | 已运行: %d 小时 | 启动时间: %s%n",
user, hoursElapsed, startTime.toString());
}
}
if (!foundLongRunning) {
System.out.println("✅ 所有运行中应用均在正常时长范围内。");
}
} catch (Exception e) {
System.err.println("❌ 检查过程中发生错误:" + e.getMessage());
e.printStackTrace();
}
}
}
该程序可以帮助你及时发现潜在的问题作业,结合邮件或短信通知系统,实现主动式运维。
示例 3:分析调度队列资源占用 🧩
在多租户环境中,不同团队共享同一个 YARN 集群,通常通过 CapacityScheduler 划分多个队列(queue)进行资源隔离。了解各队列的资源使用情况对于容量规划至关重要。
public class QueueAnalyzer {
public static void main(String[] args) {
try {
YarnRestClient client = new YarnRestClient("your-resourcemanager-host", 8088);
JsonNode schedulerInfo = client.getSchedulerInfo();
System.out.println("🧩 正在分析调度队列资源分配...");
if ("capacityScheduler".equals(schedulerInfo.get("schedulerType").asText())) {
analyzeCapacitySchedulerQueues(schedulerInfo);
} else {
System.out.println("⚠️ 当前使用的是非 CapacityScheduler,暂不支持分析。");
}
} catch (Exception e) {
System.err.println("❌ 分析失败:" + e.getMessage());
e.printStackTrace();
}
}
private static void analyzeCapacitySchedulerQueues(JsonNode schedulerInfo) {
JsonNode queues = schedulerInfo.get("queues").get("queue");
traverseQueues(queues, "", 0);
}
private static void traverseQueues(JsonNode node, String prefix, int level) {
String indent = " ".repeat(level);
for (JsonNode q : node) {
String queueName = q.get("queueName").asText();
double capacity = q.get("capacity").asDouble();
double usedCapacity = q.get("usedCapacity").asDouble();
double maxCapacity = q.get("maxCapacity").asDouble();
System.out.printf("%s├─ 🏷️ 队列: %s%s%n", indent, prefix, queueName);
System.out.printf("%s│ 📦 容量: %.1f%% | 使用: %.1f%% | 最大: %.1f%%%n",
indent, capacity, usedCapacity, maxCapacity);
JsonNode childQueues = q.get("queues");
if (childQueues != null && childQueues.has("queue")) {
traverseQueues(childQueues.get("queue"), prefix + queueName + ".", level + 1);
}
}
}
}
输出示例:
🧩 正在分析调度队列资源分配...
├─ 🏷️ 队列: root
│ 📦 容量: 100.0% | 使用: 75.2% | 最大: 100.0%
├─ 🏷️ 队列: root.engineering
│ 📦 容量: 60.0% | 使用: 58.3% | 最大: 80.0%
├─ 🏷️ 队列: root.marketing
│ 📦 容量: 20.0% | 使用: 10.1% | 最大: 30.0%
├─ 🏷️ 队列: root.research
│ 📦 容量: 20.0% | 使用: 6.8% | 最大: 40.0%
通过该工具,管理员可以快速识别资源利用率过高或过低的队列,进而调整配额策略。
🛠️ 高级技巧:结合日志与事件诊断问题
尽管 Web UI 和 REST API 提供了丰富的状态信息,但真正深入的问题排查往往需要结合日志分析。YARN 支持将 Container 日志聚合到中央存储(如 HDFS),并通过 Web UI 提供访问入口。
获取 Container 日志 URL
每个 Container 在 Web UI 上都有一个 Logs 链接,其背后对应的是 NodeManager 的日志服务器地址。我们可以通过 API 获取该链接:
public class LogUrlFetcher {
public static void printContainerLogUrls(YarnRestClient client, String appId) throws Exception {
JsonNode app = client.getApplication(appId);
JsonNode attempts = app.get("currentAppAttempt").get("amContainers");
System.out.printf("📎 应用 %s 的 AM Container 日志链接:%n", appId);
for (JsonNode attempt : attempts) {
String nodeId = attempt.get("nodeId").asText();
String containerId = attempt.get("containerId").asText();
String logUrl = attempt.get("logUrl").asText();
System.out.printf(" 📂 Container: %s%n", containerId);
System.out.printf(" 📍 节点: %s%n", nodeId);
System.out.printf(" 🔗 日志: %s%n", logUrl);
}
}
}
logUrl 通常是类似这样的地址:http://node01:8042/node/containerlogs/container_e12_1712345678901_0001_01_000001/user1,打开后可以看到 stdout、stderr 和 syslog 文件。
⚠️ 注意:若未启用日志聚合(
yarn.log-aggregation-enable=true),日志仅保留在本地磁盘,重启后可能丢失。
📈 可视化增强:构建简易 Dashboard
虽然 YARN Web UI 功能强大,但对于企业级监控需求仍显不足。我们可以基于上述 Java 客户端,结合前端技术栈(如 Vue.js + ECharts)构建专属的 YARN 监控仪表盘。
下面是一个简化的数据模型设计思路:
public class ClusterStatus {
public int activeNodes;
public int lostNodes;
public int unhealthyNodes;
public long totalMemoryMB;
public long allocatedMemoryMB;
public long totalVCores;
public long allocatedVCores;
public List<AppSummary> runningApps;
public Map<String, QueueStats> queueUsage;
// Getter & Setter 省略
}
public class AppSummary {
public String appId;
public String name;
public String user;
public String queue;
public long durationMs;
public double progress;
}
public class QueueStats {
public String name;
public double capacity;
public double usedCapacity;
public double maxCapacity;
}
然后通过 Spring Boot 暴露 REST 接口:
@RestController
@RequestMapping("/api/yarn")
public class YarnMonitorController {
private final YarnRestClient client;
public YarnMonitorController() {
this.client = new YarnRestClient("rm-host", 8088);
}
@GetMapping("/status")
public ResponseEntity<ClusterStatus> getStatus() throws Exception {
ClusterStatus status = new ClusterStatus();
// 填充数据...
JsonNode metrics = client.getClusterMetrics();
status.activeNodes = metrics.get("numActiveNMs").asInt();
status.totalMemoryMB = metrics.get("totalMB").asLong();
status.allocatedMemoryMB = metrics.get("allocatedMB").asLong();
// 获取运行中应用
JsonNode apps = client.getApplications("RUNNING");
status.runningApps = new ArrayList<>();
if (apps != null && apps.isArray()) {
for (JsonNode a : apps) {
AppSummary app = new AppSummary();
app.appId = a.get("id").asText();
app.name = a.get("name").asText();
app.user = a.get("user").asText();
app.queue = a.get("queue").asText();
app.durationMs = System.currentTimeMillis() - a.get("startedTime").asLong();
app.progress = a.get("progress").asDouble();
status.runningApps.add(app);
}
}
return ResponseEntity.ok(status);
}
}
前端通过 AJAX 定期拉取 /api/yarn/status 并渲染图表,即可实现动态刷新的监控面板。
🔗 如果你想了解现代前端如何对接大数据后端,推荐阅读 Apache Superset 官方文档,它是一个优秀的开源数据可视化平台,支持接入多种数据源。
🔐 安全与权限控制
在生产环境中,暴露 YARN Web UI 和 REST API 存在安全风险。建议采取以下措施加强防护:
-
启用 Kerberos 认证
配置 Hadoop 安全模式,要求所有请求携带有效的票据(Ticket)。 -
使用 HTTPS 加密通信
配置 SSL/TLS,防止敏感信息被窃听。 -
限制 IP 访问范围
通过防火墙或反向代理(如 Nginx)只允许可信 IP 访问 8088 端口。 -
启用 CORS 控制
若需跨域调用 API,应明确设置Access-Control-Allow-Origin头部。 -
审计日志记录
开启 ResourceManager 的审计日志,追踪所有关键操作。
相关配置片段示例(yarn-site.xml):
<property>
<name>yarn.http.policy</name>
<value>HTTPS_ONLY</value>
</property>
<property>
<name>yarn.resourcemanager.webapp.https.address</name>
<value>0.0.0.0:8090</value>
</property>
<property>
<name>yarn.acl.enable</name>
<value>true</value>
</property>
🔄 性能优化建议
为了保证 YARN Web UI 的响应速度和稳定性,特别是在大规模集群中,应注意以下几点:
1. 控制应用保留数量
YARN 默认保留最近完成的应用记录。过多的历史数据会导致内存消耗上升。可通过以下参数调节:
<property>
<name>yarn.resourcemanager.max-completed-applications</name>
<value>1000</value>
</property>
2. 合理设置日志滚动策略
避免单个 Container 日志过大,影响下载和解析效率:
<property>
<name>yarn.nodemanager.log-aggregation.roll-monitoring-interval-seconds</name>
<value>3600</value>
</property>
3. 升级硬件资源配置
ResourceManager 本身也是一个 JVM 进程,建议为其分配充足内存(如 8GB+),并选择合适的 GC 策略(如 G1GC)。
🧪 故障模拟与恢复演练
定期进行故障演练是保障系统高可用的重要手段。以下是一些常见的测试场景:
场景 1:模拟 NodeManager 失联
手动停止某个节点上的 NodeManager 服务:
sudo systemctl stop hadoop-yarn-nodemanager
观察 Web UI 中该节点状态变为 LOST,并在几分钟后出现在 “Lost Nodes” 统计中。
场景 2:提交失败应用
编写一个故意抛出异常的 MapReduce 程序,提交后查看其状态变为 FAILED,并通过日志定位错误原因。
场景 3:资源耗尽测试
启动多个高内存需求的应用,使集群内存使用率达到 100%,验证新应用是否被正确排队或拒绝。
这些演练有助于提升团队应急响应能力,确保真实故障发生时能够快速定位和恢复。
🧭 未来展望:YARN 与云原生融合
随着 Kubernetes 在大数据领域的广泛应用,YARN 正面临新的挑战与机遇。社区已推出 YuniKorn —— 一个专为混合工作负载设计的新一代资源调度器,兼容 YARN API 并运行在 K8s 之上。
🔗 了解更多关于 YuniKorn 的信息,请访问 Apache YuniKorn 官网
此外,Hadoop 社区也在积极探索 Serverless 架构 下的弹性伸缩方案,使得 YARN 能够更好地适应云环境中的按需计费模式。
未来的 YARN Web UI 可能不再只是一个静态监控页面,而是集成了 AI 异常检测、自动调优建议、成本分析等功能的智能运维中心。
✅ 总结
YARN Web UI 不仅仅是一个简单的网页界面,它是连接开发者、运维人员与大数据集群之间的桥梁。通过对 集群状态 与 任务执行情况 的全方位监控,我们能够及时发现问题、优化资源配置、提升系统稳定性。
本文系统性地介绍了 YARN Web UI 的核心功能,涵盖:
- 集群概览、节点管理、应用跟踪等基础功能;
- REST API 的使用方法与 Java 客户端实现;
- 自定义监控工具开发实战;
- 安全加固与性能调优建议;
- 未来发展趋势展望。
希望这些内容能帮助你更深入地理解和运用 YARN 监控体系,打造更加健壮、高效的大数据平台。🌟
无论你是刚刚接触 Hadoop 的新手,还是经验丰富的架构师,掌握 YARN Web UI 的使用都将是通往大数据世界的一把钥匙。现在就打开浏览器,访问你的 ResourceManager 地址,开始探索吧!🚀
🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_41187124/article/details/157686038




