ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python+Spark汽车推荐系统实战:ALS模型与Web展示全攻略

Python+Spark汽车推荐系统实战:ALS模型与Web展示全攻略 简介这是一份面向计算机相关专业毕业设计的大数据汽车推荐系统项目资料基于Python与Spark实现覆盖数据采集、处理、推荐逻辑与可视化展示等环节适合做毕设、课设或入门大数据推荐场景的参考。资源共29个文件约7.99MB包含Python爬虫脚本、Spark/Scala示例代码、Java工具类、Markdown说明文档以及23张项目截图截图可直观呈现运行界面与可视化大屏便于对照代码看效果。已有127人学习下载。内容提供完整推荐系统实现思路、数据采集与处理过程、关键算法演示并包含可扩展的代码基础帮助读者理解Spark与推荐系统协同工作的整体流程项目已通过运行测试可直接用于毕业设计、课程设计、作业演示也可基于现有代码二次开发。适合需要完整项目参考、快速搭建演示或准备答辩的学生与开发者同时也适合希望从实际案例入门Spark的初学者。1. 这个选题到底解决了什么问题又一个被“大数据”三个字吓住的毕业季。每年这时候都有学生拿着“基于PythonSpark的汽车推荐系统”这个题目来找我第一句话基本是“老师Spark是不是特别难我是不是得搭集群” 实际上这个选题在本科毕设里是一个非常标准、且性价比极高的组合Python负责数据清洗和Web端逻辑Spark负责离线推荐计算推荐系统本身又是一套“能讲清楚原理、能看到代码、能演示效果”的闭环业务。比做纯数据爬虫有深度比做纯Web开发有区分度而且答辩时拿Spark的分布式计算说事评委通常不会深挖到底——前提是你真把模型跑通了。这个题目真正解决的是“怎么让用户在海量车型里快速找到想买的车”。常见做法是拉取汽车网站的车型信息、用户浏览和评分记录用协同过滤在Spark上训练推荐模型再通过Web页面把“猜你喜欢”的结果展示出来。适合的读者很明确Python基础尚可但没碰过Spark的学生或者想系统整理“离线推荐Web展示”技术栈的从业者。这篇笔记我尽量把每一步拆到能照着复现包括那些网上没人写的坑。2. 架构与技术选型为什么是Spark而不是纯Python推荐系统不是只有一种解法但你交上去的毕业设计必须能讲清楚“为什么这么选”。最核心的选择就是计算引擎因为汽车推荐场景的数据特征其实很典型用户对某个车型的评分或行为记录是稀疏的车辆属性价格、品牌、级别是结构化的而评论里的关键词又属于文本信息。这三类数据放在一起如果你只用Pandas和Scikit-learn数据量在几千条时毫无压力但答辩时“为什么不用Spark”这个问题就会很尴尬。2.1 Spark在项目里到底负责哪一块先给一个定位不是整条链路都要用Spark跑。常见做法是把项目拆成两条线离线推荐用Spark批处理在线推荐用Python直接查Redis或MySQL。离线部分处理用户历史评分、生成ALS模型、计算每个用户的Top-20推荐列表在线部分只做“从预先算好的推荐表里取数据再过滤已买车型”这种轻量操作。这么拆有三个好处一是Spark任务可以离线定时跑比如每天凌晨一次二是Web端响应速度不受Spark任务影响三是论文里能画出一张标准的Lambda架构图。Spark在里面的具体计算任务有三块第一是数据清洗聚合比如把用户对车型的点击、收藏、试驾记录汇总成“伪评分”表第二是ALS矩阵分解模型的训练这一步是Spark的核心优势因为矩阵分解在大数据量下开箱即用的分布式实现很少第三是生成每辆车的相似车型表用于做“看了这辆还看那辆”的关联推荐。2.2 选型对比表三套方案的取舍我把做这个题目常见的三套技术路线列个对比方便你写《可行性分析》那一节时直接用。技术路线数据处理能力推荐效果答辩亮点实现难度Python Pandas单机万级数据还行依赖手工特征工程几乎没有最简单Python Scikit-learn单机内存吃紧KNN或SVD勉强可用模型对比能写一点简单Python Spark (ALS)分布式百万级评分可扩展协同过滤效果好分布式架构大数据处理中等选Spark的另一个实际理由是ALS算法在Spark的MLlib里是高度封装好的你不需要自己推导矩阵分解的迭代过程调三个参数rank、iterations、lambda就能出结果。对毕设来说这意味着核心推荐算法部分不太容易翻车而你有大把时间把精力花在数据处理和前端展示上。2.3 集群还是本地模式别被“Spark集群搭建”吓退很多学生一搜“spark集群搭建”就头大觉得需要三台服务器。其实单机模式跑Spark完全够用原因是ALS训练在几万条评分记录的场景下Spark的Driver进程会把训练数据切分成Block分布式计算结果但即使全部在本地跑几分钟也能出模型。我建议的配置是一台16G内存的笔记本Spark Standalone单机模式跑本地文件系统上的数据。配置环境的时候直接用spark-env.sh里配SPARK_LOCAL_IP127.0.0.1避免虚拟机网络问题。集群模式反而会给毕设带来麻烦HDFS要额外开进程、YARN的资源管理会吃掉大量内存、Spark UI的端口配置容易和你本地的服务冲突。所以除非你的数据集有百万级评分否则单机足够。答辩时你说“我搭建了Spark环境以local模式运行”完全没有毛病。2.4 数据库选型一张表说清楚每种数据放哪推荐系统最忌讳的就是所有数据塞一个MySQL库里。车辆基础信息是低频写入的用户评分记录是高频写入的推荐结果表是每天全量更新的。三个写入频率完全不同的数据流落库方案应该分开数据类别存储方案理由车辆信息表车型、品牌、价格、级别MySQL事务性强Web端查询方便用户行为流点击、评分、试驾申请MySQL或MongoDB先落库Spark定时读走ALS训练好的推荐结果Redis在线推荐时O(1)读取相似车型表MySQL方便做详情页关联推荐展示Redis这里的作用很容易被忽视。我的做法是Spark任务跑完后把userId - List[carId]直接写进Redis的String结构里key是rec:user:{id}value是JSON数组。这样可以避免在线推荐时反复查MySQL。注意Redis里过期时间要设置成隔天凌晨3点过期保证和新一轮ALS产出的结果无缝衔接。3. 推荐引擎核心ALS模型训练与三参数调优推荐系统的核心不是写代码而是理解你打算怎么描述“用户和汽车的关系”。ALS交替最小二乘法是协同过滤里最成熟的矩阵分解算法基本思想是把用户对车型的评分矩阵分解成用户特征矩阵和车型特征矩阵让两个矩阵的乘积尽可能逼近原始评分矩阵。因为汽车数据的评分矩阵极度稀疏ALS的优势在于它能自动填充缺失值只依靠已有评分迭代求解。3.1 用PySpark实现ALS训练的完整代码这里给出一个可以直接跑通的最小训练代码。我默认你已经有处理好的ratings.csv包含三列userId, carId, rating评分范围1到5。from pyspark.sql import SparkSession from pyspark.ml.recommendation import ALS from pyspark.ml.evaluation import RegressionEvaluator # 创建Spark会话local模式设好内存参数防止OOM spark SparkSession.builder \ .appName(CarRecSysALS) \ .master(local[*]) \ .config(spark.driver.memory, 4g) \ .config(spark.sql.shuffle.partitions, 20) \ .getOrCreate() # 读取评分数据格式userId, carId, rating ratings spark.read.csv(data/ratings.csv, headerTrue, inferSchemaTrue) # 按7:3切分训练集和测试集seed固定方便复现 train_data, test_data ratings.randomSplit([0.7, 0.3], seed42) # ALS模型训练 als ALS( userColuserId, itemColcarId, ratingColrating, rank10, # 隐语义因子数 maxIter10, # 最大迭代次数 regParam0.01, # 正则化系数 coldStartStrategydrop # 测试集出现未知用户/物品时直接丢弃 ) als_model als.fit(train_data) # 模型评估用RMSE衡量预测评分和真实评分的误差 evaluator RegressionEvaluator( metricNamermse, labelColrating, predictionColprediction ) predictions als_model.transform(test_data) rmse evaluator.evaluate(predictions) print(fRoot Mean Squared Error {rmse}) print(fALS模型的隐因子矩阵大小为: {als_model.userFactors.count()} 行 * {als_model.itemFactors.count()} 行) spark.stop()代码逻辑拆开来randomSplit把原始评分表无放回分成训练和测试两部分种子固定为42可以保证多次运行结果一致写论文时实验数据可复现这点很重要。ALS类接受四列映射这里的userCol和itemCol必须和你CSV表头完全一致否则会在fit阶段直接报错。coldStartStrategydrop这个参数是常被忽略的不设的话测试集里只要出现训练集没见过的新用户预测结果就会是空值RMSE计算直接抛异常。3.2 三个必调参数rank、maxIter、regParam怎么设这三个参数我不建议你直接照抄默认值最好做一遍小范围网格搜索因为ALS对参数的敏感度非常高同一个数据集在rank10和rank30时推荐结果可能差出几个数量级。首先是rank它代表隐语义因子的维度通俗讲就是“用多少个隐藏特征来描述用户偏好和车辆属性”。rank太小比如2-5模型欠拟合推荐结果全是大路货rank太大比如50以上模型过拟合训练集上RMSE好看但推荐结果明显偏怪。常见做法是在10到30之间调。我建议跑一组对比实验固定maxIter和regParam让rank取[5, 10, 15, 20, 30]选出测试集RMSE最低的档位。这部分对比数据直接放论文里能当一个小节的实验分析。其次是maxIterALS迭代次数。超过10次之后收益急速衰减而且每轮迭代的耗时线性上涨。如果rank20且maxIter20三次网格搜索就能花掉一晚上。所以maxIter固定10就够。最后是regParam正则化系数对抗过拟合。范围0.001到0.1之间初始设0.01如果发现模型跑出来的Top推荐里频繁出现同品牌同价位的车说明过拟合了把regParam上调到0.05再试。注意很多网上的demo会把regParam写成lambda这是Spark旧版本接口里的别名新版本统一用regParam。3.3 生成Top-N推荐列表并落库模型训练完不是终点你需要给每个用户生成一个最终要展示的推荐列表。这一步有一个隐藏问题ALS会给出所有用户对车型的预测评分但大部分用户只想取Top-20再怎么高效过滤Spark里提供了recommendForAllUsers方法直接批量对所有用户生成推荐不需要自己写排序逻辑。但这个方法会把结果collect到Driver端如果用户量很大Driver内存又会炸。所以建议用recommendForUserSubset只对活跃用户生成推荐配合take(numItems)取前N个。from pyspark.sql.functions import col, explode, desc # 为所有用户生成Top-N推荐N取20 recs als_model.recommendForAllUsers(20) # 将结构化推荐列表展开成一行一条记录 recs_flat recs.select( col(userId), explode(col(recommendations)).alias(rec_item) ).select( col(userId), col(rec_item.carId).alias(carId), col(rec_item.rating).alias(pred_rating) ) # 写回MySQL需要先在MySQL建好表 recs_flat.write \ .mode(overwrite) \ .jdbc( urljdbc:mysql://127.0.0.1:3306/car_recommend, tableuser_rec_result, properties{ user: root, password: your_password, driver: com.mysql.jdbc.Driver } ) # 同时把每个用户的前5个推荐同步到Redis供Web端实时读取 import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) for row in recs_flat.collect(): key frec:user:{row.userId} val f{row.carId}:{round(row.pred_rating, 2)} r.rpush(key, val) r.expire(key, 86400) # 24小时过期防止脏数据累积这里explode是个高频函数很多人一开始会忽略。ALS返回的recommendations字段是一个数组如果不展开你拿到的是一整行嵌套结构。mode(overwrite)是全量覆盖写因为每天重新训练旧推荐直接冲掉这个模式下不需要写删除逻辑。注意Redis写入的时候我用了rpush而不是set因为一个用户有多个推荐列表结构天然合适expire是每天24小时过期对应每天凌晨Spark任务重跑后重新刷新。3.4 相似车型表给详情页做“看了又看”协同过滤能做用户对物品的推荐但汽车详情页更需要“这辆车的相似推荐”。ALS模型训练完后itemFactors矩阵里每辆车都有一个向量可以用余弦相似度计算车辆之间的相似度。注意这里别对全量车辆两两计算那是O(n²)的复杂度几千辆车也扛得住但几万辆开始吃力。常见做法是先用Spark的内积计算再按Top-K截断。from pyspark.ml.linalg import DenseMatrix import numpy as np # 获取车型因子矩阵转为numpy便于计算 item_factors als_model.itemFactors.toPandas() item_matrix np.vstack(item_factors[features].apply(lambda x: np.array(x)).values) # 归一化后计算余弦相似度矩阵 norm_matrix item_matrix / np.linalg.norm(item_matrix, axis1, keepdimsTrue) similarity_matrix norm_matrix norm_matrix.T car_ids item_factors[id].values sim_df [] for idx, car_id in enumerate(car_ids): # 取相似度最高的前5个去掉自身 top_sim np.argsort(-similarity_matrix[idx])[1:6] for sim_idx in top_sim: sim_df.append((car_id, car_ids[sim_idx], float(similarity_matrix[idx][sim_idx]))) similar_cars spark.createDataFrame(sim_df, schema[car_id, similar_car_id, similarity])这里把Spark里的分布式向量collect到本地计算相似度矩阵严格说不是最佳实践但对车辆这种几万规模的物品集是够用的。如果你想炫耀技术深度可以在Spark中用approxSimilarityJoin这类内置函数但写法复杂对毕设来说用NumPy直接算反而更好解释。4. 数据链路与交互界面从爬虫清洗到结果展示推荐模型只是项目的一半。一个完整的毕设必须有数据来源、预处理逻辑和可视化展示这条链路的完整度才是答辩老师真正关心的。很多人的推荐系统死在空中楼阁——你没有真实数据ALS再漂亮也是空转。4.1 汽车数据的获取与预处理汽车数据通常有两个来源一是公开数据集比如某汽车网站的用户评分二是自己爬。公开数据集省事但 Schema 经常很脏。自爬的话建议把车型、价格、品牌、级别、油耗这五个核心字段爬下来作为cars表同时模拟用户行为生成ratings表。模拟评分数据有一条血泪经验不要用纯随机数生成评分。均匀分布的评分会让ALS模型学不到任何偏好结构RMSE倒是很漂亮但推荐结果毫无意义。更现实的做法是设定每一类用户有品牌偏好比如“喜欢日系车”“只接受20万以下”然后按偏好分布生成评分这样矩阵里既有结构又能模拟真实噪声。代码大致是import random import pandas as pd # 生成模拟用户偏好每个用户对指定品牌的平均分更高 car_df pd.read_csv(data/cars.csv) users range(1, 501) # 假设500个用户 records [] for user_id in users: # 该用户偏好SUV级别 preferred_style random.choice([SUV, 轿车, MPV]) for car_id, row in car_df.sample(n80).iterrows(): base_score 3.0 if row[car_style] preferred_style: base_score 1.5 if random.random() 0.1: # 10%的噪声评分 base_score random.uniform(1, 2) base_score max(1, min(5, base_score random.gauss(0, 0.8))) records.append((user_id, car_id, round(base_score, 2))) ratings_df pd.DataFrame(records, columns[userId, carId, rating]) ratings_df.to_csv(data/ratings.csv, indexFalse)数据清洗的重点在于使用者会产生“重复评分”“同品牌汽车刷分”等脏数据直接用Spark做一轮去重和异常值过滤。清洗步骤放在数据加载进Spark之后用dropDuplicates([userId, carId])去掉同一个人对同一辆车的重复评分再用filter(col(rating) 1)过滤评分低于0或高于5的无效样本。4.2 离线推荐任务的Spark提交与调度模型的训练不能靠手动执行Python脚本。建议把整个流程打包成一个定时任务脚本用crontab调度每天凌晨2点自动跑。# 每30分钟检查一次防止Spark任务僵死 */30 * * * * /usr/bin/python3 /home/recsys/daily_train.py /home/recsys/logs/train_$(date \%Y\%m\%d).log 21crontab里有三个细节容易踩坑一是python3必须写绝对路径否则crontab环境里PATH不同找不到命令。二是日志文件按天分割否则日志膨胀速度会让你磁盘爆掉。三是Spark任务本身在local[*]模式下是串行的同一时刻只能跑一个假如上一个任务卡住了加了flock锁可以避免重复运行。我在生产里常用带flock的版本# 加锁防止任务重入 * 2 * * * flock -n /tmp/recsys_train.lock -c /usr/bin/python3 /home/recsys/daily_train.py /home/recsys/logs/train.log 21Spark任务的内存配置也在这里体现。壳脚本里设置spark-submit --driver-memory 4g --executor-memory 2g是标准配置但有时候你在IDE里能跑通到了服务器上却一直报Driver stacktrace多数是因为Spark读文件时默认block大小为128MB而你的小CSV被切了很多空partition通过spark.sql.files.maxPartitionBytes64m这个参数可以把任务切片控制在合理数目。4.3 Web端推荐结果展示与交互展示层我一般用Flask或FastAPI写一个极简Web服务前端模板直接渲染推荐列表。不要在这里花过多时间用Bootstrap模板加一个图展示推荐置信度就够。核心是后端接口怎么设计和推荐结果怎么取。from flask import Flask, render_template, jsonify import redis app Flask(__name__) cache redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) app.route(/) def index(): return render_template(index.html) app.route(/api/recommend/int:user_id) def get_recommend(user_id): # 直接从Redis里取该用户的推荐列表 rec_key frec:user:{user_id} recs cache.lrange(rec_key, 0, -1) car_list [] for rec in recs: car_id, score rec.split(:) # 关联车辆信息可以再查MySQL也可以直接内嵌到Redis car_list.append({carId: int(car_id), score: float(score)}) return jsonify({ userId: user_id, recommendations: car_list, total: len(car_list) }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)接口很直白用户访问页面时浏览器调用这个APIRedis里查出来推荐列表再用车辆ID去MySQL补上车辆名称、价格、图片链接。注意不要在prefetch阶段把所有车辆信息都塞进Redis那样查询很方便但每次数据更新会造成Redis和MySQL的数据不一致。用“ID存Redis详情查MySQL”的二级缓存策略是毕设里能体现工程思维的亮点。4.4 冷启动处理与热门兜底推荐系统里最常被问到的坑是“新用户没有评分记录怎么推荐”。如果你把ALS返回的推荐列表直接展示新用户会得到一个空列表。所以我通常会在模型结果之外加一套“热门车型兜底”逻辑作为冷启动策略。热门车型表可以单独维护一个SQL查询按近30天评分人数排序取前20SELECT carId, COUNT(userId) AS cnt FROM ratings WHERE create_time DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY carId ORDER BY cnt DESC LIMIT 20;在推荐服务里判断逻辑是先去Redis查rec:user:{id}如果返回为空或长度小于5就用热门车型表拼一个hot_rec_cache作为替代。这样既不会让前端展示空页面又能把这个作为论文里一个专门讨论的“冷启动解决策略”小节来写。5. 避坑合集ALS与Spark的5个常见翻车现场这一章是从我实际跑这个项目时踩过的坑里剔出来的高发问题按“现象 → 原因 → 解决”的顺序写。不管你是新手还是老手把这个部分过一遍能省下至少一个通宵调bug的时间。5.1 Spark任务卡死在Shuffle阶段现象ALSfit()时进度卡在某个stage超过15分钟Spark UI里看到大量Shuffle Read失败。原因spark.sql.shuffle.partitions默认是200但小数据集根本不需要200个分区大部分分区落空空任务一直重试。解决把分区数调小。我一般在SparkSession.builder里加上.config(spark.sql.shuffle.partitions, 20)同时spark.default.parallelism也设为CPU核心数的两倍例如本地4核8线程就设8。这样ALS迭代速度能快接近一个数量级。5.2 ALS预测结果大面积为空现象predictions表里很多行的prediction是nullRMSE没法计算。原因测试集里包含训练集没有出现过的用户或车型ALS无法为全新实体生成特征向量。解决coldStartStrategy参数必须设为drop这样预测阶段碰到未知实体时会自动丢弃该行。如果你论文里需要体现这个坑可以单独对比一下设与不设这个参数对RMSE的影响通常不设直接抛异常设了之后会有少量有效数据被丢弃。5.3 评分数据稀疏导致推荐结果全是热门车现象Top推荐列表里几千用户拿到的都是同一批车型看着非常“不个性”。原因评分矩阵太稀疏ALS学到的是全局平均偏好没有学到用户细分差异。解决一是增加评分数量把点击时长、收藏行为加权成伪评分扩大矩阵非空项比例二是调低rank隐因子少一点会让模型更关注大众偏好但这明显不是你想看到的。更有效的做法是把热门品牌/车辆的评分做时间衰减让较老的评分权重下降模型会更关注用户近期兴趣变化。5.4 MySQL写入中文车名乱码现象Spark写入MySQL后Web端查询到一堆???车辆名称全乱了。原因JDBC连接串里没指定字符编码Spark默认使用JVM的字符集和MySQL的utf8mb4不一致。解决在.jdbc()的properties里加上characterEncoding: utf8同时确认MySQL表本身是utf8mb4字符集。建库语句用CREATE DATABASE car_recommend DEFAULT CHARACTER SET utf8mb4;不要只改表字段的字符集因为库级别不对的话表字段改也白改。5.5 Redis数据堆积导致内存暴涨现象rec:user:*这个前缀的key越来越多Redis内存占用超过500MBWeb端响应开始变慢。原因每次ALS重跑后写入Redis用的key如果带用户ID而用户集合在增长旧key过期时间设置太长或根本没设。解决写入时必须加expire我建议TTL设24小时。为了更稳每天推荐任务跑完先清理一遍旧前缀的keyRedis命令scan加unlink异步删除别用keys *避免阻塞Redis主线程。6. 一个实用技巧为推荐结果增加AB测试验证逻辑模型训练完事才是真正的开始。很多学生的论文里只有RMSE这一个评估指标答辩时老师问“你的推荐到底有没有效果”就卡壳了。我更建议你搭一个简单到不能再简单的AB测试框架把推荐算法的效果用点击率或者加购率量化出来这部分写好之后论文的深度直接上一档而且代码量不大。服务端需要增加一个分桶逻辑第一步是给用户打上实验组标识A组用ALS推荐B组用热门兜底第二步是对每个推荐位上的点击事件埋点落到MySQL里第三步是每天用SQL对比两组点击率。from flask import request, make_response import hashlib BUCKET_A_RATIO 0.5 # A/B流量各半 app.route(/api/recommend/int:user_id) def recommend_with_ab(user_id): # 用用户ID哈希后取模固定分桶避免同用户来回跳组 hashed int(hashlib.md5(fuser_{user_id}.encode()).hexdigest(), 16) bucket A if (hashed % 100) (BUCKET_A_RATIO * 100) else B if bucket A: recs get_als_recs(user_id) else: recs get_hot_recs(user_id) resp make_response(jsonify({recommendations: recs, bucket: bucket})) resp.set_cookie(bucket, bucket, max_age86400) return resp这段代码的关键在于“哈希取模固定分桶”而不是随机数分配原因很简单用户第一次点开页面时是A组第二次随机变成了B组那么实验数据就全被污染了。在你写埋点逻辑时始终把userId或设备ID当作分桶标识这样同一个用户的多次访问始终落在同一组。埋点表可以设计成一个极简的recommendation_log表字段就四个user_id、car_id、bucket、clicked0或1。用户在页面上点击某个推荐车辆时前端发一个POST请求把这个日志写进MySQL。统计分析就是一条SQLSELECT bucket, SUM(clicked) / COUNT(*) AS click_rate FROM recommendation_log WHERE create_time DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY bucket;在实际跑的过程中你会很快发现一个比较反直觉的现象协同比模型推荐的点击率不一定高。为什么因为ALS只优化了“评分预测”不代表“用户一定会点”。想要提升点击率常见做法是在模型输出的Top-K结果里混入限定比例的热门车型或近期平台主推车型这也是业界讲到“混合推荐”时最常见的策略。你可以简单地在最终结果里替换掉末尾两条为热门车型。两组的点击率对比如果有3到5个百分点的差距这个数据在论文里就是一个非常硬的结果。最后一个习惯建议所有训练脚本和线上服务的日志都统一走logs/目录并且按日期分文件千万别一股脑写进一个文件。我遇到过日志涨到几个GB、服务器磁盘被塞满推荐接口直接无响应的情况。日志打了看不到比不打日志更痛苦。希望这篇笔记能让你的毕设少走几个弯路希望帮到你。本文还有配套的精品资源点击获取
返回列表