资讯动态

Spark地铁客流分析毕设:数据链路搭建与工程避坑实践

发布时间:2026/10/19 14:23:26 来源:尧图企业网站定制
简介一套完整的基于Spark的地铁大数据客流分析系统毕业设计源码面向计算机专业学生可用于完成大数据类课程设计或毕业设计课题。压缩包共一百九十四份文件大小约为四十二点六兆字节主要包含Java与Scala源代码、XML与YAML配置文件、SQL脚本、Python脚本以及多张PNG界面截图并附有HBase命令、Logstash采集配置和CSV客流数据基本覆盖数据采集、存储、分析到展示的完整链路。目前已有两百五十九人学习浏览。除了可运行的Spark作业代码还提供需求分析、系统设计文档、数据库脚本与测试数据完整呈现从数据采集、清洗、分析到可视化的链路并涉及Spark SQL、Spark Streaming与MLlib等子框架的使用能够帮助读者快速理解大数据项目的典型实现方式适合作为毕业设计或课程设计的直接参考。1. 拿到这份Spark地铁客流毕设我先翻的是配置文件而不是源码第一次展开这份基于Spark的地铁大数据客流分析系统毕设压缩包时我没有直奔src目录看算法代码而是先打开了根目录下那几个不起眼的文件。原因很直接这类系统能不能跑起来决定性的往往不是某个聚类函数写得漂不漂亮而是数据从哪进、落在哪个库、用什么口径统计。包里恰好摆着szmc.net-metro.csv、logstash-nginx.config、hbase.command和两个.http接口文件等于把整条链路直接摊在了桌面上。它覆盖了一个完整毕设需要回答的所有问题Spark从海量轨道刷卡数据里清洗有效记录算清楚分时进出站、OD矩阵和拥挤度把结果写进数据库供外部接口查询顺带做客流预测。适合正在找方向的计算机类学生也适合想快速摸清Spark批处理链路怎么组织的开发者。2. 数据链路先立住从Logstash与csv输入到HDFS和HBase的存储分工拆开压缩包后Graduation Design目录下的文件排布其实已经暴露了作者的链路设计意图。szmc.net-metro.csv是主数据文件logstash-nginx.config负责近实时日志采集hbase.command则是结果查询库的建表与操作脚本。建议拿到资源的第一天别急着跑代码先把这三份文件按“输入→存储→查询”的顺序读一遍理解它们之间的依赖关系。后端接口文件里所有的查询参数、路由和期望返回格式都是围绕这三份配置展开的链路理解错了后面所有改动都会连带跑偏。2.1 两个输入源静态csv承担离线统计Nginx日志做近实时补充szmc.net-metro.csv从命名和内容特征看属于某个城市轨道网络的模拟客流记录字段大概率覆盖卡号、站点编号、进出站动作、事件时间戳这一类基础项。用这份csv跑离线指标是绰绰有余的它对应Spark批处理作业的主输入。而logstash-nginx.config的存在说明作者额外搭了一条近实时输入链Nginx下挂着票务查询或进出站闸机的HTTP服务每来一个请求就会留一条access logLogstash把日志采集后落进HDFS再由Spark Streaming按小批次消费。这套设计在毕设里很常见既能体现离线分析的完整流程又能演示流式处理的衔接逻辑。# logstash-nginx.config 片段 input { file { path /var/log/nginx/access.log # 按实际部署位置改 start_position beginning # 第一次启动从头读 sincedb_path /dev/null # 每次重启重新读调试好用 } } filter { grok { match { message %{IPORHOST:client_ip} - - \[%{HTTPDATE:ts}\] \%{WORD:method} %{NOTSPACE:request} HTTP/%{NUMBER:http_version}\ %{INT:status} %{INT:body_bytes} } } date { match [ts, dd/MMM/yyyy:HH:mm:ss Z] target timestamp } mutate { remove_field [message, path, host] } } output { hdfs { path hdfs://localhost:9000/data/nginx/%{YYYY-MM-dd} file_type text } }这段配置看着长其实只干三件事从文件读一行日志用grok正则拆出客户端IP、请求串、状态码再用date插件把HTTP日志时间转成标准时间戳。grok里的%{NOTSPACE:request}会把/station?idstation_01这样的完整请求串捕获后续Spark解析站点号时直接用这个字段即可。start_position beginning只在第一次启动时有效sincedb_path /dev/null会让Logstash每次重启都重新读整个文件。这两行配置在本地调试时非常省事但如果这链路要真正接进采集流程必须删掉否则文件里的日志会被反复送进HDFSSpark Streaming那边就会重复统计这一点在第5章的避坑部分会展开讲。2.2 存储层不要混HDFS放原始与中间层HBase放结果处理这类项目时最怕的就是“一张表存到底”。HDFS适合存原始csv和清洗后的Parquet文件写起来便宜但单条点查效率很低而分析完的客流结果是要给HTTP接口查询的走HDFS每次都要起Spark作业重算完全不现实。所以常见的做法是把结果指标写进HBase靠rowkey直接点查和范围扫描。hbase.command这个文件名看起来像shell脚本实际就是一连串HBase操作命令负责建表、写入和查询验证。# hbase.command 片段建表、写入样例数据、范围扫描 create metro_flow_daily, cf alter metro_flow_daily, { NAME cf, TTL 5184000 } put metro_flow_daily, 20250315|station_01|08, cf:in_num, 1520 put metro_flow_daily, 20250315|station_01|08, cf:out_num, 1473 scan metro_flow_daily, { STARTROW 20250315|station_01|07, ENDROW 20250315|station_01|09 }rowkey按字典序排列日期放最前面可以让“某天某站某个时段”的范围扫描天然命中连续存储区域这是这个设计里最关键的点。因为scan的STARTROW和ENDROW都得按同一个拼接规则来写如果数据写入时站点编号放在日期前面那scan条件也得保持站点在前提前想好这个顺序后面写查询服务时会省很多事。TTL 5184000是60秒乘以86400等于60天过期客流结果表设个TTL能防止长时间运行后数据无限膨胀同时不影响最近一段时间内接口的正常查询。列族只设一个cf读写简单直接符合毕设场景。2.3 建议按四层数据模型组织数据别偷懒跳层很多初学者会直接把csv读进来算完就展示跳过了中间的数据分层结果就是指标口径越调越乱。稳妥的做法是把整条链路拆成四层ODS原始数据层、DWD清洗明细层、DWS汇总指标层、ADS应用查询层。分层的收益在于每层只干一件事出问题时顺着层往里查很快就能定位到是清洗逻辑错了还是聚合逻辑错了。层级存储载体内容与职责ODS原始层HDFS csv目录存放原始刷卡记录和Nginx日志不进任何处理DWD清洗层HDFS Parquet分区表去重、纠正站名、统一时间格式保留明细不做聚合DWS指标层HDFS Parquet分时进出站、OD矩阵、断面客流等轻聚合结果ADS应用层HBase按rowkey组织的查询结果供HTTP接口直接读取实际开发时最容易被跳过的就是DWD清洗层因为写着不产生任何“看得见的成果”。但原始csv里经常混着重复刷卡、站名写法不一致、时间格式五花八门等问题如果跳过清洗直接聚合算出来的数字就是错的更麻烦的是这种错误不会报错只会表现为接口数据不对劲排查起来非常痛苦。DWS层的轻聚合还有一个隐藏作用把Spark作业的重复计算收敛到固定时间窗口内夜里跑一次批处理白天接口只查HBase整个系统负担会轻很多。3. 客流分析的主战场Spark SQL算指标、Streaming补实时、MLlib做预测这一章对应压缩包源码里最核心的三段逻辑。虽然文件列表里没有直接展开代码但从摘要描述能确认Spark SQL负责结构化数据统计Spark Streaming负责实时或近实时流处理MLlib负责客流预测模型训练。三者共享同一套清洗后的明细数据只是计算时机和输出目标不同。建议把这三个模块看作彼此独立的作业先各自跑通再考虑要不要串成一条完整流程。3.1 分时进出站与OD统计Spark SQL批处理的核心代码分时进出站指标是整个客流系统最容易出效果的一部分。从DWD层读清洗后的Parquet数据按站点和小时分组统计进出站次数。这里有一个减少shuffle的小技巧用count(when(...))而不是先filter再groupBy因为后者会多一次全量数据的shuffle。from pyspark.sql import SparkSession from pyspark.sql.functions import col, count, when, date_format spark SparkSession.builder.master(yarn) \ .appName(metro_hourly_flow) \ .getOrCreate() flow spark.read.parquet(/data/dwd/metro_flow) hourly flow.filter(col(is_valid) 1) \ .withColumn(dt, date_format(col(event_time), yyyy-MM-dd)) \ .withColumn(hour, date_format(col(event_time), HH)) \ .groupBy(station_id, dt, hour) \ .agg( count(when(col(action_type) in, 1)).alias(in_num), count(when(col(action_type) out, 1)).alias(out_num) ) hourly.write.mode(overwrite) \ .partitionBy(dt, hour) \ .parquet(/data/dws/hourly_flow)这段先过滤is_valid 1把清洗层标记的脏数据去掉再分别抽取日期和小时做分组聚合。date_format(col(event_time), HH)取出的是两位小时字符串所以groupBy里的hour字段也是字符串后续做小时排序时记得转成整数否则“10”会排在“2”前面。写入时按dt和hour双层分区这样查询特定时段能直接走分区裁剪不用扫全表。如果发现某个站点某小时的进出站数明显异常先回头查DWD层该站点的原始明细不要直接怀疑Spark算错了。OD矩阵的写法和分时统计类似但关键点在于对一次完整行程的去重。一条原始记录只能体现“进”或“出”其中一个动作而OD分析需要把同一次出行中“进站点”和“出站点”配对起来。常见做法是先按卡号和出行时间唯一键去重再做站点对的groupBy计数。od_matrix flow.filter(col(is_valid) 1) \ .select(card_id, origin_station, dest_station, trip_time) \ .dropDuplicates([card_id, trip_time]) \ .groupBy(origin_station, dest_station) \ .count() od_matrix.write.mode(overwrite) \ .parquet(/data/dws/od_matrix)这里dropDuplicates用的是“卡号出行时间”作为一次出行的唯一标识前提是数据源里上下行记录共享同一个trip_time字段如果你的csv里没有这个字段就退而求其次用“卡号进出站动作最近配对”的方式手工构造但那样会引入时间窗参数复杂度和出错概率都会明显上升。3.2 Streaming近实时客流DStream批处理与窗口设计的取舍Nginx日志经过Logstash落进HDFS后Spark Streaming可以按固定批处理间隔消费。压缩包里的链路走的是文件源毕设演示足够但要注意文件源对文件可见性很敏感相关坑记录在第5章。这里先看标准的DStream窗口统计写法。from pyspark.streaming import StreamingContext ssc StreamingContext(spark.sparkContext, 10) lines ssc.textFileStream(hdfs://localhost:9000/data/nginx/) def parse_station(record): # 从 /station?idstation_01 里截出站点编号 try: request record.split(\)[1] station request.split(id)[1].split( )[0] return (station, 1) except Exception: return None events lines.map(parse_station).filter(lambda x: x is not None) windowed events.window(300, 60).reduceByKey(lambda a, b: a b) windowed.foreachRDD(lambda rdd: save_to_hbase(rdd)) ssc.start() ssc.awaitTermination()window(300, 60)表示窗口长度300秒每60秒滑动一次输出的是最近5分钟内的客流计数。批量间隔设成10秒意味着每10秒从文件源读一次新数据窗口每滑一次会合并最近若干个批次的RDD。这里有个常见理解误区窗口长度不是批处理间隔的倍数也没关系但滑动步长最好与批处理间隔成整数倍关系否则会产生部分窗口内数据量不均匀的现象。foreachRDD里每次都要新建HBase连接最稳的做法是维护一个连接池否则频繁创建连接会导致节点端出现大量短连接任务跑得越久越明显。save_to_hbase这个函数在源码里应该有对应的实现如果压缩包里没找到自己补一个即可逻辑就是遍历每条记录把站点编号和统计值拼成HBase put对象。3.3 客流预测MLlib线性回归比深度学习更务实很多人拿到客流数据第一反应就是上LSTM但毕设阶段数据量摆在那里把MLlib线性回归调通、能解释清楚特征含义答辩效果比一个黑匣子模型强得多。预测目标可以是“未来一小时某站的进站量”特征从历史统计表里构造。from pyspark.ml.feature import VectorAssembler from pyspark.ml.regression import LinearRegression from pyspark.ml.evaluation import RegressionEvaluator train, test data.randomSplit([0.8, 0.2], seed42) assembler VectorAssembler( inputCols[hour, dayofweek, is_holiday, lag1, lag7], outputColfeatures ) train_feat assembler.transform(train) lr LinearRegression(featuresColfeatures, labelColflow, maxIter50) model lr.fit(train_feat) pred model.transform(assembler.transform(test)) evaluator RegressionEvaluator( labelColflow, predictionColprediction, metricNamermse ) rmse evaluator.evaluate(pred)这里的特征只有五个小时、星期几、是否节假日、前一日同时段客流、前一周同时段客流。lag1和lag7在Spark里用窗口函数按站点分组生成具体做法是Window.partitionBy(station_id).orderBy(dt)然后取lag列。randomSplit([0.8, 0.2], seed42)是随机切分而非按时间切分如果讲究严谨建议先按日期排序取前80%的天数做训练、后20%做验证避免未来信息泄漏。RMSE是绝对误差数值大小跟客流数量级有关真正有参考价值的是算出来的相对误差也就是RMSE除以测试集均值。如果这个比值控制在20%以内这个预测模块在毕业设计里已经够用了如果明显更大先别急着换模型回头检查lag特征有没有正确生成。4. 把工程跑起来版本组合、spark-submit拆解与最小验证路径很多人拿到压缩包后卡在第一步代码在本地能编译一上集群就各种报错。这一章把“能不能跑起来”拆成三个问题版本选型、资源参数、验证顺序。每一个都是毕设阶段反复踩坑的高发区。4.1 版本组合是第一道坎Spark、Scala、Hadoop与HBase怎么配对版本匹配这种事玄学成分很大但至少把下面这张组合表记清楚能避开八成以上的老掉牙错误。压缩包里如果自带pom.xml或build.sbt以里面的版本定义为第一优先级表里的组合是“没有自带定义时我一般会采用的起步配置”。组件建议组合说明Spark3.x选Scala 2.12编译版本2.x也有能跑的但3.x的Spark SQL更稳定Scala2.12编译打包时scala版本必须和Spark构建一致Hadoop3.x注意YARN相关jar与Spark自带的版本冲突HBase2.x与Hadoop 2.x也能连但3.x更省心JDK1.8用JDK11也能跑但JDK8最不会出幺蛾子常见的翻车现场是代码在本地跑得好好的打包提交到集群报ClassNotFound。多数原因是本地lib目录里带了集群自带的Spark或Hadoop某个依赖两边的版本不一致。建议提交前先执行一遍mvn clean package -DskipTests看有没有依赖冲突警告别等到yarn logs里看Error再回头查。如果你的机器上只有一套JDK那就在pom里显式指定maven.compiler.source和target为1.8避免编译出更高版本的class字节码导致JVM拒绝加载。4.2 spark-submit参数逐条拆解资源给太多反而是负担毕设里最省事的提交方式是IDE里直接跑main方法但那不能算真正把工程跑起来。一份能说明白参数的提交命令远比你守着本地调试界面有价值。下面这条命令是按集群规模较小、数据量在一两个月csv以内的场景写的。spark-submit \ --master yarn \ --deploy-mode cluster \ --name metro-daily-flow \ --driver-memory 2g \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 6 \ --queue offline \ --conf spark.sql.shuffle.partitions48 \ --conf spark.dynamicAllocation.enabledtrue \ --class com.metro.analysis.DailyFlowJob \ metro-flow-1.0.jar \ /data/dwd/metro_flow /data/dws/hourly_flow--driver-memory 2g是对应driver节点的内存如果代码里没有大规模collect回驱动2g一般是够的一旦出现OOM先看是不是自己写了collect。--executor-memory 4g别贪大配得过高会导致节点上启动不了几个executor资源利用率反而下降。--executor-cores 2是每个executor占用的核数建议2到3之间堆到8个core会导致内部线程竞争和GC开销。--num-executors 6乘以单executor内存就是整个作业申请的总内存小集群上要看队列配额够不够。--conf spark.sql.shuffle.partitions48给了个小数值因为csv规模不大默认200个reduce分区会产生大量小文件反而拖累HBase批量写入。spark.dynamicAllocation.enabledtrue让集群在任务空闲时自动回收多余的executor对共享集群很友好。本机做逻辑验证时把--master换成本地模式即可。常见做法是--master local[4]这样能在单机上模拟4个并行任务数据量小的时候速度甚至比提交到集群还快。等本地跑通了再切回yarn集中时间去处理权限、队列、HDFS路径这类环境问题。4.3 建议先在本机把链路走通最小验证五步拿到代码后一口气提交集群是最没必要的试错方式。推荐先按下面五步在单机上把整条链路走一遍全程大概一个多小时能跑通基本就稳了。启动本地HDFS与HBase服务确认hdfs dfs -ls /可以正常列目录。在HDFS上建/data/ods和/data/nginx目录把szmc.net-metro.csv上传到/data/ods下。修改作业配置中的输入路径为HDFS路径不要用本地文件系统路径Spark作业在local模式下也能读HDFS这样环境更接近集群行为。运行Spark SQL统计模块跑完后用hadoop fs -ls /data/dws/hourly_flow确认分区目录生成。去HBase shell里执行count metro_flow_daily或scan检查结果表有没有数据。如果第五步scan出来空白先回到第三步检查输入路径下的文件是不是真的上传成功再确认清洗逻辑里的时间格式和csv里的原始格式是否完全一致这两个是最大概率出错的地方。5. 避坑自查这个客流系统最容易翻车的五个地方下面五条是从这类Spark客流项目中最高频出现的坑里挑出来的。每一条按“现象、原因、解决”的顺序展开方便你对号入座也方便照着排查自己的日志。5.1 作业刚跑两分钟就OOM日志里却查不到真正的Error现象spark-submit提交后控制台刷出一堆WARN然后进程直接挂掉日志里只看到“Job aborted”之类的通用信息找不到具体堆栈。原因最常见的是主程序里写了collect()把整年或整月的csv全部拉到driver进程汇总。毕设的数据量看似不大但原始csv可能包含几千万条记录全部堆在单机内存里谁也扛不住。另一个隐蔽原因是在local模式里把所有数据load到内存后反复cache()缓存层层累积导致driver内存耗尽。解决先看作业UI里的Storage页确认cache的RDD有没有被多次创建然后全局搜代码里的collect原则上只允许在样本数据上使用例如df.limit(1000).collect()做展示。写结果用save系列方法落到HDFS或HBase不要为了图方便把结果集拉回驱动再处理。5.2 CSV读进来全是字符串时间比较全部失效现象用spark.read.option(header, true).csv(path)读文件后对event_time字段做范围过滤结果一条数据都查不出来打印schema发现时间字段全是string。原因Spark的schema推断是抽样做的如果碰到一行脏值比如空串、或“2025/03/15”这种非标准格式整列就会被推断成StringType。后续所有时间比较都变成字符串比较自然得不到正确结果。解决显式定义schema再读不要依赖推断。时间字段用TimestampType定义同时用timestampFormat参数指明csv里的时间格式。from pyspark.sql.types import StructType, StructField, StringType, TimestampType schema StructType([ StructField(card_id, StringType(), True), StructField(station_id, StringType(), True), StructField(action_type, StringType(), True), StructField(event_time, TimestampType(), True), ]) df spark.read.option(header, true) \ .option(timestampFormat, yyyy-MM-dd HH:mm:ss) \ .schema(schema) \ .csv(/data/ods/szmc_metro.csv)这里的timestampFormat必须和csv文件里的时间格式完全一致比如csv里是“2025-03-15 08:12:33”那就按上面写如果是“2025/03/15”就改写yyyy/MM/dd HH:mm:ss。格式对不上时Spark不会报错而是整列读成null这个现象比全String更隐蔽。5.3 Streaming窗口统计数字偏小像丢了一批数据现象Spark Streaming计算出的某站某时段进站人数和Spark SQL批处理结果差了一截好像有一批日志没有被消费。原因Logstash的hdfs插件写入文件时文件会先带临时后缀再rename到正式路径而textFileStream只识别完整可见的文件。如果Logstash落地的文件在Spark扫描时刻还没rename完成这批数据就永远不会再被读取。另一个相关原因是sincedb_path配置不当导致重复读取重复则计数偏大、漏读则计数偏小两个问题方向相反但同样常见。解决落地目录改成“先写临时目录再原子rename”的方式保证文件一出现就对Spark可见。调试阶段可以调大spark.streaming.fileStream.minRememberDuration比如设为5m避免文件延迟可见时元数据被提前清理。如果生产环境只是演示最简单的替代方案是改用Kafka作为中间管道让Logstash直接吐Kafka topicSpark Streaming消费topic绕开文件源这些刁钻要求。5.4 HBase写入量不大但某台节点CPU持续飙高scan变慢现象数据量并不大但某个RegionServer的CPU居高不下HBase查询越来越慢查看UI发现某个Region的storefile数量特别多。原因rowkey设计把所有最近写入的数据压到了同一个Region。如果rowkey以当前日期开头那么当前日期的数据在字典序上是相邻的Hash会全部路由到同一个Region写热点就出现了。这种现象在集群上有多个RegionServer时会特别明显因为只有一台机器在扛写入。解决把rowkey改成“倒序时间戳站点编号”的结构让新数据散列到不同Region。倒序时间戳仍然保持时间上连续有序范围扫描能力不受影响只是起始rowkey不再是日期本身。关键在于拼接规则一旦定了就不要再改写入和scan用的是同一个规则。如果发现scan时指定STARTROW和ENDROW查不到数据先检查是不是站点编号和日期拼反了位置。5.5 本地跑得好好的打包提交集群就ClassNotFound现象同一个任务在IDE里用local模式执行非常流畅打包成jar提交到YARN后日志报NoClassDefFoundError而且指向的类看起来跟项目A毫无关系。原因默认打包方式没有把第三方依赖打进去或者打进去的依赖版本与集群自带的Spark相关jar冲突。最常见的冲突对象是guava、protobuf这类被多个组件共用的基础库。解决用maven-shade或sbt-assembly打fat jar同时把Spark和Hadoop自带依赖排除掉。shade插件里对这些依赖声明scopeprovided/scope即可打包后jar体积会明显增大这是正常的。如果启动后还报错别急着改代码重打包先执行yarn logs -applicationId appid拉完整日志看具体是哪个类冲突再去pom里加一次exclusion。不看日志就重打包等于闭着眼闯红灯。6. 验证整套链路的捷径从search.http和szt-api.http反查数据流6.1 这两个.http文件是最快的接口说明书压缩包里藏着search.http和szt-api.http两个文件很多人会忽略它们觉得只是IDE的请求记录。实际上这是整个系统最好用的“接口说明书”。这类文件是VSCode REST Client插件能直接识别的请求描述里面保存了请求方法、URL、查询参数和期望返回。你不必先看懂后端全部代码照着文件里的路由和参数反推HBase的rowkey结构就能快速判断“后端从哪张表查、按什么条件过滤”。比如其中一条请求可能是这样的GET http://127.0.0.1:8080/api/flow/daily?stationstation_01date2025-03-15 Accept: application/json拿到这个请求你的排查思路就不一样了接口大概率是拿station和date去拼接HBase rowkey前缀然后scan出对应的进出站统计。对照hbase.command里rowkey的拼接规则就能验证两者是否一致。很多毕设项目后端代码和数据库设计之间存在手写差异这两个.http文件就是用来抓这种差异的。6.2 最小闭环验证顺序最快验证整条链路的方式不是重新跑一遍Spark任务而是走下面三步十分钟内就能判断“是数据的锅还是代码的锅”。第一步从hbase.command或hbase shell里scan一条已知数据把rowkey的完整内容记下来比如20250315|station_01|08。第二步用这条rowkey去对照接口URL里的查询参数日期对不对得上、站点编号规则对不对得上。如果接口里传的日期格式是“2025-03-15”而HBase里存的是“20250315”那问题多半在接口参数拼接层。第三步启动查询服务用curl照接口文件发一次请求检查返回值与HBase里的列名是否一一对应。curl -s http://127.0.0.1:8080/api/flow/daily?stationstation_01date2025-03-15如果返回的JSON字段和HBase列名对得上整条链路就是通的问题只出在Spark作业是否按预期调度上如果对不上先查是后端没有读到HBase还是HBase里压根没有这条数据别一上来怀疑Spark任务没跑完。从那以后我接手任何含数据链路的项目都会强制先走一遍“原始样本→数据库表→HTTP返回字段”的最小闭环再谈调优和美化。这个习惯帮我在好几个项目里省下了一整天的排查时间希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑