简介:面向Java后端开发者的源码包,聚焦大数据平台后端实现,适合正在学习Spring Boot、MyBatis以及Hadoop、Spark等技术的开发者。资源共521个文件,包含462个Java源文件、XML与YAML配置、Properties配置、SQL脚本、PNG图表及运行脚本,整体仅11.99MB,结构清晰,便于快速阅读。目前已有189人学习,可参照源码分析Controller-Service-Mapper分层、RESTful接口、作业调度与HDFS操作等核心模块。通过该项目还能了解Maven构建、单元测试及日志配置等工程化细节,对于毕业设计、课程实训或二次开发都具有复用价值。
1. 解压这个 java 大数据平台后端.zip:先别急着找页面,先看 YARN 作业链路
解压 zip 之后,第一眼看到的是两个 bat 脚本和一堆纯 Java 类,说实话,这个 java 大数据平台后端项目包装得很朴素。但把 JobManagerService、JobOperationService、JobMonitorService、YarnUtil、HdfsUtil 这几个类名连起来读一遍,整个平台的骨架就浮出来了——这是一个围绕 YARN 作业全生命周期管理来设计的 Java 后端工程,核心不是页面,而是“把作业交到集群、盯住状态、处理失败”这条链路。
这份资源适合两类人:一类是想把 Java 后端开发和大数据调度结合起来准备面试或找工作的人,另一类是手头缺一份可复现的 YARN 客户端封装、想直接借鉴提交和轮询写法的工程师。它不是一个完整到可以一键部署的商业平台,而是一个能帮你把 Hadoop、YARN、HDFS、Spring Boot 串起来的实战样本,值得照着拆一遍。
2. 把项目先跑起来:run.bat 启动链路、Maven 依赖与 JVM 参数
拿到压缩包后最容易被忽略的,恰恰是那四个看起来没什么技术含量的文件:ag-admin.bat、run.bat。很多人第一件事就是开 IDE 编译 Java 代码,结果项目根本跑不起来,然后开始怀疑源码缺东西。实际上,这类大数据平台后端在本地启动失败,九成问题出在环境装配而不是代码逻辑。
2.1 先看懂 run.bat 和 ag-admin.bat 各自干什么
run.bat 是作业管理后端的入口脚本,它的核心工作就是把 classpath 拼好、把 JVM 参数设好,然后启动主类。一个典型的 Windows 启动脚本长这样:
@echo off set JAVA_HOME=C:\Program Files\Java\jdk1.8.0_202 set APP_HOME=%~dp0 set CP=%APP_HOME%\lib\*;%APP_HOME%\conf java -Xms512m -Xmx2g -Dlog4j.configurationFile=%APP_HOME%\conf\log4j2.xml -cp "%CP%" com.ag.dataplatform.jobmanager.JobManagerApplication这里有几个决定成败的细节。JAVA_HOME 必须指向 JDK 根目录而不是 JRE,否则后续排查问题时 jps、jstat 这些工具全不可用;-Xmx2g是把堆内存上限设成 2G,如果本地机器只有 8G 内存,而 YARN 客户端和 HDFS 客户端都要占用连接资源,建议先改成 1g,跑通再往上加;-Dlog4j.configurationFile显式指定日志配置文件,说明日志配置与代码分离,这在排查作业提交失败时非常有价值,你不需要去代码里翻日志级别。
ag-admin.bat 的路数不太一样。从命名惯例看,admin 通常指管理后台模块,它的启动内容一般是在 run.bat 的基础上加载另一套 Spring Boot 配置,或者指定 admin 模块的 main class,单独监听一个端口,用于查看作业列表、触发重跑、查看 YARN 日志。如果你只是想验证项目能跑,不需要同时启动两个脚本,先跑 run.bat,确认作业管理服务起来之后,再决定要不要拉 admin。
2.2 工程补全:把十个 Java 文件放回一个能编译的 Spring Boot 骨架
压缩包里只有源码文件,没有完整的 pom.xml、application.yml 和目录结构。这意味着你拿到的是一份“源码精选”,不是开箱即用的工程包。常见做法是新建一个 Maven 工程,把这些类按包名放回去:com.ag.dataplatform.common放 JacksonUtil、DateUtils、ErrorCode,com.ag.dataplatform.job放三个 Service,com.ag.dataplatform.hadoop放 YarnUtil 和 HdfsUtil。依赖上,既然要操作 YARN 和 HDFS,就必须引入 Hadoop 客户端依赖:
<dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>3.3.4</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.18</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency>这里有两个隐藏的坑。第一,hadoop-client 会把 protobuf、netty、guava、jackson 一大串传递依赖带进来,和 Spring Boot 自带的版本几乎必然冲突,引入后要第一时间跑mvn dependency:tree,把冲突项用 exclusion 排除。第二,不要同时引入 hadoop-hdfs-client 和 hadoop-client,这两个包存在类的重复加载,实际开发中只要保留 hadoop-client,通过 DistributedFileSystem 就能完成 HDFS 操作。
工程补全后还要自己写一个带@SpringBootApplication的入口类。入口类的位置决定了@ComponentScan的扫描范围,假设它放在com.ag.dataplatform包下,所有 Service 和 Util 都必须落在 com.ag 的子包内,否则启动时会出现NoSuchBeanDefinitionException,报错信息还特别容易误导人去查配置。
2.3 配置文件与 JVM 参数:第一次启动前必须检查的四件套
本地跑这个项目,本质上是在用 Java 客户端连接一个远程 Hadoop/YARN 集群,所以 classpath 里必须有三个核心配置文件:core-site.xml、hdfs-site.xml、yarn-site.xml。它们决定了客户端认识哪一个集群,配置放错位置,代码写得再对也会挂在连接阶段。
| 配置文件 | 作用 | 关键参数 |
|---|---|---|
| core-site.xml | 提供 fs.defaultFS,决定 HDFS NameNode 地址 | fs.defaultFS=hdfs://namenode:8020 |
| yarn-site.xml | 决定 YARN ResourceManager 地址与 HA 配置 | yarn.resourcemanager.address |
| hdfs-site.xml | 决定副本数、块大小、权限策略 | dfs.replication=2 |
| log4j2.xml | 指定日志输出文件与级别 | level=INFO |
我实际联调时踩过最花时间的坑,是 yarn-site.xml 里只配了yarn.resourcemanager.ha.enabled=true,却没有把 RM 的地址列表写全,YarnClient 初始化后直接报 UnknownHostException。本地测试环境不要一上来就搞 HA,先用最简单的单点 RM 地址跑通链路。另外,Windows 上还需要设置环境变量HADOOP_HOME指向一个包含 winutils.exe 的目录,否则 Hadoop 在执行本地 Shell 调用时会抛 NativeIO 异常,表现为日志刚打印完配置加载就静默退出。
注意:run.bat 和 ag-admin.bat 不要同时跑。先启动作业管理服务,确认端口起来后,再按需启动 admin。同时启动两个进程会争抢本地资源,第一次联调时不好定位问题出在哪个进程里。
3. 作业管理三兄弟:JobOperationService、JobManagerService 与 JobMonitorService 的分工
这个项目的核心业务都集中在三个 Service 上。从命名就能看出它们各管一段:JobOperationService 负责“动手”——提交、停止、重跑;JobManagerService 负责“记账”——维护作业状态和元数据;JobMonitorService 负责“盯梢”——定时去 YARN 上拉状态并更新记录。三者合起来,就是一套完整的作业生命周期管理闭环。
3.1 从一次作业提交说起:JobOperationService 的接口思维
JobOperationService 是你调用 YARN 的第一站。它本身不直接碰 YarnClient,而是做三件事:参数校验、资源检查、状态落库。我一般会把它写成这样:
@Service public class JobOperationService { private final YarnUtil yarnUtil; private final JobManagerService jobManagerService; private final HdfsUtil hdfsUtil; public String submitJob(JobSubmitRequest request) { // 1. 参数校验:作业名、队列、jar 路径不能为空 if (StringUtils.isBlank(request.getJarPath())) { throw new BizException(ErrorCode.PARAM_ERROR); } // 2. 检查 HDFS 上的 jar 是否真实存在 if (!hdfsUtil.exists(request.getJarPath())) { throw new BizException(ErrorCode.FILE_NOT_FOUND); } // 3. 调用 YarnUtil 提交,拿到 applicationId String applicationId = yarnUtil.submitApplication( request.getName(), request.getQueue(), request.getJarPath(), request.getMemoryMb(), request.getVCores()); // 4. 初始化作业状态,后续交给 JobMonitorService 更新 jobManagerService.recordJob(applicationId, request); return applicationId; } }注意这里的两个工程习惯。参数校验放在 Service 层而不是 Controller 层,因为作业提交可能来自 REST 接口,也可能来自定时重跑任务,入口不同但校验逻辑必须共用。错误处理统一走BizException加ErrorCode,而不是返回 false 或者 null,这样前端才能拿到结构化错误信息。
另一点值得学习的是 HDFS 资源检查。很多新手直接调 YarnClient 提交,提交后才发现 jar 路径不存在,任务在 RM 那边直接 FAILED,诊断信息只有一行Main class not found。提前用 HdfsUtil 检查一下,能把这类问题挡在提交之前,减少 YARN 集群上的无效作业。
3.2 YarnUtil:用 YarnClient 提交 Application 的封装
YarnUtil 是这份源码里含金量最高的一个类。它把 YARN 客户端操作封装成 submitApplication、getReport、killApplication 几个方法,上层 Service 完全不需要感知 YarnClient 的细节。核心的提交逻辑大致是:
public String submitApplication(String name, String queue, String jarPath, int memoryMb, int vCores) throws Exception { // 创建并启动 YarnClient,conf 来自 classpath 下的 yarn-site.xml YarnClient yarnClient = YarnClient.createYarnClient(); yarnClient.init(conf); yarnClient.start(); // 第一步:向 RM 申请一个新的 ApplicationId ApplicationId appId = yarnClient.createApplication().getApplicationId(); // 第二步:构造提交上下文 ApplicationSubmissionContext context = yarnClient.createApplication() .getApplicationSubmissionContext(); context.setApplicationName(name); context.setQueue(queue); // 第三步:指定 AppMaster 容器里执行的命令 ContainerLaunchContext amContainer = ContainerLaunchContext.newInstance( Collections.emptyMap(), ImmutableMap.of("CLASSPATH", "/etc/hadoop/conf:" + jarPath), Lists.newArrayList("java", "-jar", jarPath), null, null, null); context.setAMContainerSpec(amContainer); // 第四步:指定容器资源并提交 Resource capability = Resource.newInstance(memoryMb, vCores); context.setResource(capability); return yarnClient.submitApplication(context).toString(); }几个参数必须说清楚。queue必须是当前用户有权限的队列名,很多测试环境默认队列叫 default,生产环境则区分 dev、prod、batch,传错会被 RM 拒绝,错误信息藏在 diagnostics 里。memoryMb的单位是 MB,不是字节,我见过有人把 1024 当成 1G 的字节数传入,结果 RM 提示资源超限,这类问题排查起来非常费劲。vCores是虚拟核数,不代表物理 CPU,实际申请时要对照集群的调度策略来设置。
CLASSPATH 这一行很容易被忽略。AppMaster 容器里如果找不到 Hadoop 的配置和依赖,作业会在启动阶段失败,现象是状态从 SUBMITTED 变 FAILED,耗时不到十秒。如果你提交的是 Spark 或 Flink 作业,这里的启动命令和 CLASSPATH 还要和对应的客户端版本匹配,否则会报各种 NoClassDefFoundError。
3.3 JobManagerService 与 JobMonitorService:状态机与轮询
JobManagerService 是作业状态的中枢。它维护一张作业表,记录 applicationId、作业名、提交时间、结束时间、当前状态。这张表不依赖 YARN 实时状态,而是由 JobMonitorService 周期性写入,所以 JobManagerService 更像一个状态机加元数据存储的整合体。
JobMonitorService 的轮询逻辑是这个项目最有参考价值的部分。它通过 Spring 的@Scheduled注解定时执行,拉取所有运行中作业的 ApplicationReport,然后比对状态并更新本地库:
@Scheduled(fixedDelay = 15000) public void monitorRunningJobs() { List<String> runningJobIds = jobManagerService.getRunningJobIds(); if (runningJobIds.isEmpty()) { return; } Map<String, ApplicationReport> reports = yarnUtil.getReports(runningJobIds); for (ApplicationReport report : reports.values()) { YarnApplicationState state = report.getYarnApplicationState(); if (state == YarnApplicationState.FINISHED) { jobManagerService.markSuccess(report.getApplicationId().toString()); } else if (state == YarnApplicationState.FAILED || state == YarnApplicationState.KILLED) { // 诊断信息一定要入库,否则下次排查只能去 RM UI 翻日志 jobManagerService.markFailed(report.getApplicationId().toString(), report.getDiagnostics()); } } }fixedDelay = 15000的含义是上一次执行完成后再等 15 秒执行下一次。如果轮询逻辑本身耗时 5 秒,那么实际周期是 20 秒。这个写法的好处是任务执行期间不会并发触发,状态更新不会互相覆盖。如果换用fixedRate,每 15 秒无条件触发一次,遇到 YARN 响应慢,上一次还没跑完下一次就进来了,容易出现同一作业的状态被旧数据回退——这在状态机设计里是最忌讳的。
提示:
report.getDiagnostics()这个字段是失败排查的关键。作业被 RM 拒绝、AM 启动失败、资源超限等错误信息都在里面。生产环境建议把 diagnostics 完整写入数据库,只存状态不存原因,等于把排障通道堵死了一半。
4. 工具层与错误码:HdfsUtil、JacksonUtil、DateUtils、ErrorCode 的细节价值
四个工具类看起来不起眼,但在这类大数据后端里,它们的价值比 Service 层更值得读。因为 Service 决定业务怎么走,工具类决定边界怎么兜。HdfsUtil 管文件,JacksonUtil 管序列化,DateUtils 管时间,ErrorCode 管契约,任何一环出问题,都会让上层业务变得不可靠。
4.1 HdfsUtil:文件操作工具的边界处理
HdfsUtil 在这个项目里服务于两件事:检查作业 jar 包在 HDFS 上是否存在,以及上传本地文件到指定目录。这两件事看着简单,实际坑很多。文件路径拼接是第一个坑,HDFS 路径必须以/开头,但用户传参可能是相对路径,所以代码里通常会做一次路径规整:
public boolean exists(String path) throws IOException { Path p = new Path(path.trim()); return fileSystem.exists(p); } public void upload(String localPath, String remotePath, boolean overwrite) throws IOException { Path src = new Path(localPath); Path dst = new Path(remotePath); // delSrc 传 false 表示保留本地源文件,overwrite 由调用方决定 fileSystem.copyFromLocalFile(false, overwrite, src, dst); log.info("upload completed: {} -> {}", localPath, remotePath); }copyFromLocalFile的参数看起来简单,但第一个布尔值 delSrc 很容易传错。改成 true 的话,本地 jar 会被删掉,第二次提交同一个作业就会报本地文件不存在。我一般坚持用 false,并把这个值耦合成常量,写进代码注释里。
另一个值得注意的细节是 FileSystem 实例的获取方式。常见做法是用FileSystem.get(conf),它返回的是缓存实例,不要在每个方法里反复创建,否则会撑爆连接数。生产环境里更稳妥的做法是将 FileSystem 作为 HdfsUtil 的成员变量,在 @PostConstruct 里初始化一次,服务关闭时统一释放。
4.2 JacksonUtil 与 DateUtils:最容易翻车的两个基础类
Jackson 在大数据工程里是个敏感角色。Hadoop 客户端会带入自己的 jackson 版本,如果代码里直接new ObjectMapper(),大概率会和 Spring Boot 默认的序列化行为不一致,典型症状是 LocalDateTime 被序列化成数组,前端拿到后没法直接展示。我一般会在工具类里统一注册 JavaTimeModule 并固定日期格式:
public static String toJson(Object obj) throws IOException { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); return mapper.writeValueAsString(obj); }DateUtils 的核心问题永远是时区。大数据集群通常跑在 UTC 或服务器默认时区,而业务表里往往存的是东八区时间。如果工具类里写死了TimeZone.getDefault(),本地调试和服务器运行会得到完全不同的结果。我习惯的做法是:所有解析统一按 UTC 处理,最后展示前再转北京时间,并且把时区常量暴露为配置项而不是硬编码。
注意:SimpleDateFormat 是线程不安全的。如果你的 DateUtils 把 formatter 定义为 static 成员变量,在高并发调用时会出现日期错乱甚至抛 NumberFormatException。改用线程安全的 DateTimeFormatter,或者每次调用时在方法内部创建实例,两条路随便选一条,但不要用 static SimpleDateFormat。
4.3 ErrorCode:错误码设计
ErrorCode 这个类决定了整个项目前端和后端怎么对话。最忌讳的是每个模块各自定义错误码,最后前端维护一张巨大的对照表,还总对不上。合理做法是按段位划分错误域:
| 错误码段 | 含义 | 示例 |
|---|---|---|
| 1xxxx | 参数校验失败 | 10001:作业名不能为空 |
| 2xxxx | 作业状态异常 | 20001:作业不存在或已结束 |
| 3xxxx | 集群通信失败 | 30001:YARN 连接超时 |
| 4xxxx | 权限或资源不足 | 40001:HDFS 写入权限被拒绝 |
这样设计的好处是前端只需要根据段位决定 UI 提示类型:1 开头弹表单校验错误,2 开头弹业务提示,3 开头提示稍后重试,4 开头引导用户联系管理员。后端加新错误码时,也不用跨模块商量,只要保证段位含义不变即可。
5. 避坑:让 Windows 下的 YARN 客户端稳定运行的五个常见问题
这些坑是我在真机联调里一个个踩出来的。项目本身不复杂,但本地 Windows 环境连远程大数据集群时,会出现各种和代码逻辑无关的环境问题。下面五条按“现象 → 原因 → 解决”写清楚,给你当作排查手册用。
5.1 现象:run.bat 双击后窗口一闪而过
双击 bat 后没有任何输出,窗口直接消失。最直接的原因是脚本执行失败,常见的失败点有两个:一是 JAVA_HOME 没有正确指向 JDK 根目录,二是 classpath 里引用的 lib 或 conf 目录不存在。
原因:bat 脚本默认在语句出错时不会暂停,窗口关闭后错误信息全部丢失。解决:第一步先给脚本末尾加一行pause,再双击执行,让错误停在屏幕上。第二步检查 JAVA_HOME 是否包含空格,比如C:\Program Files\Java\jdk1.8.0_202必须用引号包住,否则 java 命令会被拆错。第三步检查set CP=%APP_HOME%\lib\*;%APP_HOME%\conf中的目录是否存在,Maven 构建后没有执行mvn package,lib 目录可能根本不存在。
5.2 现象:YarnClient 连接 RM 时报 Connection refused
日志里出现类似Connection refused to localhost:8032,或者UnknownHostException: hadoop-cluster-01。这种报错会让很多第一次接触 YARN 的人误以为是集群挂了,其实多半是客户端配置问题。
原因:YarnClient 启动时从 classpath 加载 yarn-site.xml,如果本地工程 resources 目录下没有这个文件,Hadoop 客户端会默认连 localhost:8032。就算有 yarn-site.xml,如果里面只配了 HA 标志而没有写具体 RM 地址,也会出现解析失败。解决:确认 yarn-site.xml 里yarn.resourcemanager.address指向远程 RM 的 IP 和端口,同时检查本地 hosts 文件是否配置了 RM 主机名的映射,最后把 log4j 级别调到 DEBUG,观察 YarnClient 初始化阶段到底连了哪个地址。
5.3 现象:提交作业时提示 Permission denied: user=Administrator
在 Windows 上跑这个项目,HDFS 操作经常报权限不足。比如Permission denied: user=Administrator, access=WRITE, inode="/user":hdfs:supergroup:drwxr-xr-x。
原因:Windows 登录用户名被 Hadoop 客户端直接当作 HDFS 用户,Administrator 在 HDFS 上没有写权限。解决:不用去改 HDFS 的真实权限,更安全的做法是在代码里指定认证用户。常见做法是先初始化 UserGroupInformation,再执行文件操作:
UserGroupInformation ugi = UserGroupInformation.createRemoteUser("hdfs"); ugi.doAs((PrivilegedExceptionAction<Object>) () -> { upload(localPath, remotePath, true); return null; });如果集群开了 Kerberos,则需要走 loginUserFromKeytab,但本地联调阶段一般不需要,createRemoteUser 足够。
5.4 现象:作业提交后一直 ACCEPTED,就是不进入 RUNNING
作业提交成功,RM UI 上能看到 Application,但状态停在 ACCEPTED 很长时间,既不失败也不运行。
原因:ACCEPTED 状态表示 RM 已经把作业交给调度器,但调度器无法分配容器。常见原因是当前队列没有可用资源,或者你申请的 memory 和 vCores 超过了队列配额上限。解决:先看 RM UI 的 Active Queue 页面,确认队列剩余资源。如果队列没资源,要么调小 memory 和 vCores 重提,要么换一个有配额的队列。也要检查yarn.scheduler.maximum-allocation-mb是否小于你申请的容器内存,超过这个阈值时作业永远等不到资源。
5.5 现象:Jackson 解析作业配置时 LocalDateTime 报 InvalidFormatException
提交作业时,前端传过来的时间字段是2025-01-01 12:00:00,后端用 JacksonUtil 解析直接抛异常。
原因:Hadoop 传递依赖把 jackson 版本覆盖了,JavaTimeModule 没有被自动注册,ObjectMapper 不认识 LocalDateTime。解决:按 4.2 节的方式,在 ObjectMapper 上显式注册 JavaTimeModule,同时确认pom.xml里 jackson-datatype-jsr310 没有被 exclusion 掉。另一种常见做法是在application.yml里配置spring.jackson.date-format和spring.jackson.time-zone,但这类配置只对 Spring MVC 自动注入的 ObjectMapper 生效,对工具类里的手动创建不生效,所以工具类里必须自己做注册。
6. 把这份源码变成简历亮点:改一个实时监控推送就够了
这个项目最容易被面试官追问的点,就是 JobMonitorService 的轮询机制。如果你只是说“我定时去查 YARN 状态”,那和 CRUD 没什么区别。我建议你做一个简单但完整的改造:把轮询结果通过 WebSocket 实时推给前端,让作业状态从“到时候去查”变成“主动告诉用户”。这一改,整个项目的架构感就出来了。
改造思路很直接:保留 @Scheduled 轮询 YARN 的逻辑,但在轮询结束后把状态变更发给一个 WebSocket 广播器。前端订阅作业状态主题,收到消息就刷新页面。代码大致是这样:
@Scheduled(fixedDelay = 10000) public void pushJobStatus() { List<String> runningJobIds = jobManagerService.getRunningJobIds(); if (runningJobIds.isEmpty()) { return; } Map<String, Object> statusMap = new HashMap<>(); for (String appId : runningJobIds) { ApplicationReport report = yarnUtil.getReport(appId); statusMap.put(appId, report.getYarnApplicationState().toString()); } // 将状态快照广播给所有订阅者,前端据此刷新 UI webSocketSessionManager.broadcast(JacksonUtil.toJson(statusMap)); }这个改造的关键点有两个:一是状态快照必须是完整的 map 而不是单条记录,否则多个应用并发变更时前端要做复杂的合并逻辑;二是 broadcast 要处理 session 关闭的异常,否则用户刷新页面后,服务端推送时会抛 IllegalStateException。第一个关键点体现设计意识,第二个关键点体现工程经验,面试官对这两点都很敏感。
除了实时推送,还有两个切入点建议你顺手做掉。第一个是失败自动重试:在 markFailed 分支里判断重试次数小于阈值时,重新调用 JobOperationService.submitJob,这是大数据平台的基本容错能力。第二个是作业历史入库:把每次提交的配置、状态变更时间、diagnostics 完整落到 MySQL,用 MyBatis 查历史,这正好把 spring boot + mybatis 这条技术栈串起来,回答“你的项目里 MyBatis 起什么作用”这类问题时会非常扎实。
从那以后,我每次拿到一个大数据后端项目,都会强制走一遍“先看启动脚本、再补配置、最后观察轮询日志”的流程,不急着读业务代码。环境跑通了,业务逻辑才有讨论的前提。这个项目虽然小,但该有的链路一条不少,静下心拆一遍,收获会超出你的预期。希望帮到你。
本文还有配套的精品资源,点击获取