Hadoop图书推荐系统课程设计:伪分布式搭建与MapReduce协同过滤实战

发布时间:2026/10/12 12:18:08
Hadoop图书推荐系统课程设计:伪分布式搭建与MapReduce协同过滤实战 简介这份资源是山东大学大数据课程设计的完整项目包围绕基于Hadoop实现的图书推荐系统展开适合大数据、计算机相关专业学生用于课程设计、期末大作业或毕业设计也适合想入门推荐算法与分布式计算的新手对照学习。压缩包共78个文件约20.11MB以50个class编译文件、17个java源码、4个xml配置、2个properties配置为主另含sql建表脚本、md说明文档、doc实验报告及工程配置文件覆盖从数据存储、算法实现到项目部署的完整链路。项目代码带有详细注释并附有实验报告与数据库脚本下载后简单部署即可运行便于快速理解Apriori频繁项集挖掘与图书推荐逻辑。目前已有506人学习下载可作为高分课程设计参考帮助读者掌握Hadoop环境下推荐系统的搭建思路、目录结构与排错方法。1. 从一份课程设计说起Hadoop 图书推荐系统到底在解决什么问题图书推荐系统这个词听起来像是电商大厂的专属但每年到了学期中后段高校里总有一批人要在两三周内交出一个能跑通、能演示、能写进实验报告的版本。山东大学大数据方向的这门课程设计核心就是基于 Hadoop 生态搭一套图书推荐系统配套源代码、实验报告和数据库脚本。它要解决的不是工业级千万级 QPS 的推荐问题而是在有限数据和有限机器上把「数据存储 → 离线计算 → 推荐结果生成 → 结果落库 → 前端展示」这条链路完整走一遍。适合谁看如果你正在做 Hadoop 课程设计、大数据实验或者想找一个能写进简历的离线推荐项目这套思路可以直接复用。它不要求你有服务器集群一台 16G 内存的笔记本跑伪分布式就够。真正难的不是算法本身而是环境配置、数据流转和各个组件之间的衔接——这些恰恰是课程设计里最容易翻车的地方。下面我按实际做项目的顺序把每个环节拆开讲清楚。2. 环境选型与伪分布式搭建为什么不用 Docker 一把梭2.1 伪分布式还是 Docker先看你的机器和答辩要求做 Hadoop 课程设计第一个决策就是环境形态。常见三种选择单机模式、伪分布式、Docker 容器。单机模式跑 MapReduce 本地模式不需要 HDFS适合只验证算法逻辑伪分布式是 NameNode、DataNode、ResourceManager、NodeManager 都在一台机器上用独立 JVM 进程模拟集群能完整走 HDFS 读写和 YARN 调度Docker 方案是把整套环境封进镜像启动快、隔离好但答辩时老师让你解释配置文件在哪、日志怎么看你可能会卡住。我的建议是如果这是你第一次接触 Hadoop用伪分布式。原因很直接——课程设计要交实验报告报告里需要体现你对 core-site.xml、hdfs-site.xml、mapred-site.xml、yarn-site.xml 这四个文件的理解。Docker 把这些细节藏起来了你写报告时只能抄网上的一问就露馅。伪分布式虽然配置麻烦但每一步你都知道自己在改什么。机器要求不高Ubuntu 20.04 或 CentOS 7内存 8G 以上16G 更稳JDK 8 或 11。Hadoop 版本选 3.x 系列别用 2.x3.x 对 Java 11 支持更好而且 YARN 的内存调度更合理。2.2 四个配置文件的最小改动清单伪分布式搭建的教程网上很多但大部分把配置写得太散。我按实际需要改动的项列一个最小清单你照着改就行。core-site.xml指定 HDFS 的默认文件系统地址和临时目录。configuration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/home/yourname/hadoop/tmp/value /property /configurationfs.defaultFS告诉客户端 NameNode 在哪端口 9000 是默认值改成别的也行但要和后面一致。hadoop.tmp.dir是 Hadoop 运行时存放临时文件的目录默认在 /tmp 下重启机器可能被清掉所以显式指定一个持久化路径。hdfs-site.xml设置副本数为 1因为伪分布式只有一个 DataNode。configuration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name value/home/yourname/hadoop/data/namenode/value /property property namedfs.datanode.data.dir/name value/home/yourname/hadoop/data/datanode/value /property /configuration副本数如果保持默认的 3HDFS 会一直报副本不足的警告虽然不影响使用但日志很乱。NameNode 和 DataNode 的数据目录分开存放方便出问题时单独清理。mapred-site.xml指定 MapReduce 跑在 YARN 上。configuration property namemapreduce.framework.name/name valueyarn/value /property /configurationyarn-site.xml配置 NodeManager 的辅助服务。configuration property nameyarn.nodemanager.aux-services/name valuemapreduce_shuffle/value /property property nameyarn.nodemanager.env-whitelist/name valueJAVA_HOME,HADOOP_COMMON_HOME,HADOOP_HDFS_HOME,HADOOP_CONF_DIR,CLASSPATH_PREPEND_DISTCACHE,HADOOP_YARN_HOME,HADOOP_MAPRED_HOME/value /property /configurationyarn.nodemanager.aux-services必须设为 mapreduce_shuffle否则 MapReduce 任务跑不起来。env-whitelist在 Hadoop 3.x 里需要显式加上不然 NodeManager 启动时可能报环境变量被过滤的错误。改完配置后格式化 HDFShdfs namenode -format。注意这个命令只能执行一次重复格式化会导致 NameNode 的 clusterID 和 DataNode 不一致DataNode 起不来。如果真格式化了两次把 namenode 和 datanode 的数据目录都清掉重新格式化。启动顺序先start-dfs.sh再start-yarn.sh。用jps检查进程应该看到 NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager 五个进程。少一个就去 logs 目录下看对应日志。提示如果 DataNode 启动后立刻消失九成是 clusterID 不匹配。去 namenode 的 current/VERSION 和 datanode 的 current/VERSION 里对比一下不一致就手动改成一样或者直接清空 datanode 目录重新启动。3. 数据准备与数据库设计图书、用户、评分三张表怎么建3.1 数据集从哪来Book-Crossing 与豆瓣爬虫的取舍推荐系统的原料是数据。课程设计里常用的公开数据集是 Book-Crossing包含约 27 万本书、9 万用户和 100 万条评分记录。它的优点是格式规整、字段清晰缺点是评分稀疏、书籍信息偏英文。如果你想让演示效果更贴近中文场景可以用 Python 爬豆瓣图书的部分公开信息但要注意控制频率和数量别给人家服务器添麻烦。Book-Crossing 有三个文件BX-Users.csv、BX-Books.csv、BX-Book-Ratings.csv。用户表包含 User-ID、Location、Age图书表包含 ISBN、Book-Title、Book-Author、Year-Of-Publication、Publisher评分表包含 User-ID、ISBN、Book-Rating。评分是 0 到 10 的整数0 表示显式评分缺失做推荐时通常把 0 过滤掉只保留 1 到 10 的记录。数据量对伪分布式来说刚好100 万条评分记录在 HDFS 上大约几十 MBMapReduce 跑一轮几分钟出结果不会让你等太久。如果数据再大单机伪分布式的磁盘和内存会成为瓶颈。3.2 MySQL 库表设计与 HDFS 的职责划分课程设计通常要求交一个数据库所以推荐结果和原始数据要落到 MySQL 里。但注意Hadoop 负责的是离线计算MySQL 负责的是结果存储和前端查询。不要把原始评分数据也塞进 MySQL 让 MapReduce 去读那样就失去了用 Hadoop 的意义。MySQL 里建三张表就够CREATE TABLE book ( isbn VARCHAR(20) PRIMARY KEY, title VARCHAR(255), author VARCHAR(255), publisher VARCHAR(255), pub_year INT ); CREATE TABLE user_info ( user_id INT PRIMARY KEY, location VARCHAR(255), age INT ); CREATE TABLE recommendation ( user_id INT, isbn VARCHAR(20), score DOUBLE, rank_no INT, PRIMARY KEY (user_id, isbn) );book 和 user_info 存原始信息recommendation 存 MapReduce 算出来的推荐结果。rank_no 表示这个推荐在该用户的推荐列表里排第几前端按这个字段排序展示。HDFS 上的目录结构建议这样组织/booksys/raw/books.csv /booksys/raw/users.csv /booksys/raw/ratings.csv /booksys/output/similarity/ /booksys/output/recommendation/raw 放原始数据output 放 MapReduce 的输出。每次重新跑任务前把 output 目录删掉否则 Hadoop 会报输出目录已存在的错误。3.3 用 Sqoop 还是手动导入课程设计的务实选择把 MySQL 数据导入 HDFS标准做法是用 Sqoop。但 Sqoop 的配置和版本兼容问题在课程设计里经常消耗大量时间。如果你的数据量不大几十 MB更务实的做法是用 Python 脚本从 MySQL 读出来写成 CSV再用hdfs dfs -put上传。这样你完全掌控数据格式出了问题也好排查。import pymysql import csv conn pymysql.connect(hostlocalhost, userroot, passwordyourpass, dbbooksys) cursor conn.cursor() cursor.execute(SELECT user_id, isbn, rating FROM ratings WHERE rating 0) with open(/tmp/ratings.csv, w, newline) as f: writer csv.writer(f) writer.writerow([user_id, isbn, rating]) for row in cursor.fetchall(): writer.writerow(row) conn.close()这段脚本把评分表导出成 CSV过滤掉评分为 0 的记录。字段顺序要和后面 MapReduce 程序里解析的顺序一致否则会解析出乱码。导出后用hdfs dfs -put /tmp/ratings.csv /booksys/raw/上传。注意CSV 里如果字段本身包含逗号需要用引号包裹。Book-Crossing 的书籍标题里经常有逗号导出时用 csv.writer 会自动处理手动拼字符串就会翻车。4. 推荐算法实现从物品相似度到用户推荐列表4.1 基于物品的协同过滤为什么比基于用户更适合图书场景推荐算法有很多种课程设计里最常用的是协同过滤。协同过滤分两类基于用户UserCF和基于物品ItemCF。图书场景下我建议用 ItemCF原因有两个。第一图书的数量远小于用户数量计算物品相似度矩阵的规模更可控第二物品相似度比用户相似度更稳定新用户进来时只要有几条评分就能匹配到相似物品不用等用户行为积累。ItemCF 的核心逻辑分两步先算物品之间的相似度再根据用户历史评分和物品相似度预测用户对未评分物品的偏好。相似度用余弦相似度sim(i, j) Σ(u∈N(i)∩N(j)) r(u,i) * r(u,j) / (sqrt(Σ r(u,i)²) * sqrt(Σ r(u,j)²))其中 N(i) 是对物品 i 有过评分的用户集合r(u,i) 是用户 u 对物品 i 的评分。这个公式在 MapReduce 里需要拆成多个 Job 来完成。4.2 MapReduce 三阶段实现共现矩阵、相似度、推荐列表整个计算拆成三个 MapReduce Job每个 Job 的输出是下一个 Job 的输入。Job 1生成物品共现矩阵。输入是评分数据输出是每对物品的共现评分乘积之和。// Mapper 输出keyuser_id, valueisbn:rating public class CoOccurrenceMapper extends MapperLongWritable, Text, Text, Text { Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] fields value.toString().split(,); if (fields.length 3) return; String userId fields[0].trim(); String isbn fields[1].trim(); String rating fields[2].trim(); context.write(new Text(userId), new Text(isbn : rating)); } }Mapper 把每条评分记录按用户 ID 分组输出 key 是用户 IDvalue 是 ISBN 和评分的组合。这样同一个用户的所有评分会进入同一个 Reducer。// Reducer 输出keyisbn_i:isbn_j, valuerating_i * rating_j public class CoOccurrenceReducer extends ReducerText, Text, Text, DoubleWritable { Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { ListString items new ArrayList(); for (Text val : values) { items.add(val.toString()); } for (int i 0; i items.size(); i) { for (int j i 1; j items.size(); j) { String[] itemA items.get(i).split(:); String[] itemB items.get(j).split(:); double product Double.parseDouble(itemA[1]) * Double.parseDouble(itemB[1]); String pairKey itemA[0] : itemB[0]; context.write(new Text(pairKey), new DoubleWritable(product)); } } } }Reducer 里对同一个用户的所有物品做两两组合计算评分乘积。注意这里输出的是单向对后续相似度计算时需要把 i:j 和 j:i 合并。Job 2计算物品相似度。输入是共现矩阵输出是每对物品的余弦相似度。// Mapper读取共现矩阵输出 keyisbn_i, valueisbn_j:product // Reducer对每个 isbn_i计算它与所有 isbn_j 的相似度 public class SimilarityReducer extends ReducerText, Text, Text, DoubleWritable { Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { MapString, Double coOccur new HashMap(); double normI 0.0; for (Text val : values) { String[] parts val.toString().split(:); String otherItem parts[0]; double product Double.parseDouble(parts[1]); coOccur.put(otherItem, coOccur.getOrDefault(otherItem, 0.0) product); normI product * product; } normI Math.sqrt(normI); for (Map.EntryString, Double entry : coOccur.entrySet()) { // 这里需要另一个 Job 或缓存来获取 normJ简化处理时可用近似值 double sim entry.getValue() / (normI * 1.0); context.write(new Text(key.toString() : entry.getKey()), new DoubleWritable(sim)); } } }实际实现时normJ 需要从另一个数据源获取通常的做法是在 Job 1 的输出里同时保留每个物品的评分平方和或者在 Job 2 之前加一个预处理步骤。课程设计里如果嫌麻烦可以用一个简化公式只算共现次数除以两个物品各自被评分次数的平方根效果差不太多但实现简单很多。Job 3生成推荐列表。输入是用户评分历史和物品相似度矩阵输出是每个用户的 Top-N 推荐。// Mapper读取用户评分输出 keyuser_id, valueisbn:rating // Reducer对每个用户根据相似度矩阵预测未评分物品的得分 public class RecommendationReducer extends ReducerText, Text, Text, Text { private MapString, ListString similarityMap new HashMap(); Override protected void setup(Context context) throws IOException { // 从分布式缓存加载相似度矩阵 URI[] cacheFiles context.getCacheFiles(); if (cacheFiles ! null) { for (URI uri : cacheFiles) { BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(uri.getPath()))); String line; while ((line reader.readLine()) ! null) { String[] parts line.split(\t); if (parts.length 2) { similarityMap.computeIfAbsent(parts[0], k - new ArrayList()) .add(parts[1]); } } reader.close(); } } } Override protected void reduce(Text key, IterableText values, Context context) throws IOException, InterruptedException { MapString, Double userRatings new HashMap(); for (Text val : values) { String[] parts val.toString().split(:); userRatings.put(parts[0], Double.parseDouble(parts[1])); } MapString, Double scores new HashMap(); for (Map.EntryString, Double rating : userRatings.entrySet()) { String item rating.getKey(); double r rating.getValue(); ListString similarItems similarityMap.get(item); if (similarItems null) continue; for (String simItem : similarItems) { if (userRatings.containsKey(simItem)) continue; // 解析相似度值并累加得分 double sim 0.1; // 实际应从相似度矩阵读取 scores.put(simItem, scores.getOrDefault(simItem, 0.0) sim * r); } } // 取 Top-N 输出 ListMap.EntryString, Double sorted new ArrayList(scores.entrySet()); sorted.sort((a, b) - Double.compare(b.getValue(), a.getValue())); int n Math.min(10, sorted.size()); for (int i 0; i n; i) { context.write(key, new Text(sorted.get(i).getKey() : sorted.get(i).getValue())); } } }Reducer 的 setup 方法从分布式缓存加载相似度矩阵这是 MapReduce 里做 map-side join 的常用手法。reduce 方法对用户看过的每本书找到与它相似的物品如果用户没看过就累加预测得分。最后按得分排序取前 10 个。提示分布式缓存的文件在 setup 里用 FileInputStream 读取时路径是本地路径而不是 HDFS 路径。用context.getCacheFiles()拿到 URI 后直接 new File(uri.getPath()) 可能找不到文件正确做法是用new File(uri)或者通过context.getLocalCacheFiles()获取。4.3 参数调优相似度阈值、推荐数量和评分归一化三个参数直接影响推荐质量。相似度阈值只保留相似度大于 0.1 的物品对太低的相似度是噪声。推荐数量 N课程设计演示用 10 就够太多前端展示不下。评分归一化不同用户的评分尺度不同有人习惯打高分有人习惯打低分可以对每个用户的评分减去其平均分让预测更准。这三个参数没有绝对最优值需要根据你的数据集试。Book-Crossing 的评分范围是 1 到 10用户平均分大约在 7 左右归一化后评分范围变成 -6 到 3。相似度阈值设 0.1 时每个物品平均有几十个相似物品推荐列表不会太短。5. 避坑与排查课程设计里最容易翻车的五个地方5.1 DataNode 启动后消失jps 里看不到进程现象执行 start-dfs.sh 后jps 只显示 NameNode 和 SecondaryNameNodeDataNode 进程不存在。查看 logs 目录下的 datanode 日志报错信息是java.io.IOException: Incompatible clusterIDs。原因NameNode 被格式化过多次每次格式化会生成新的 clusterID但 DataNode 的 clusterID 还是旧的两者不匹配导致 DataNode 拒绝启动。解决找到 namenode 数据目录下的 current/VERSION 文件记下 clusterID 的值。再找到 datanode 数据目录下的 current/VERSION 文件把 clusterID 改成和 namenode 一致。或者更干脆删掉 namenode 和 datanode 的数据目录重新执行一次hdfs namenode -format然后启动。注意格式化只能做一次做之前确保两个目录都是空的。5.2 MapReduce 任务卡在 map 0% reduce 0%现象提交 Job 后控制台一直显示 map 0% reduce 0%等几分钟也没进展最后超时失败。原因YARN 的 NodeManager 没有正常启动或者 NodeManager 的内存配置太小无法分配 Container。另一个常见原因是 mapred-site.xml 里mapreduce.framework.name没有设为 yarn任务跑在本地模式但资源不够。解决先用 jps 确认 NodeManager 进程存在。如果不存在看 yarn 的日志。如果存在检查 yarn-site.xml 里yarn.nodemanager.resource.memory-mb的值默认是 8192如果机器内存小就改成 4096 或 2048。同时检查yarn.scheduler.maximum-allocation-mb确保它不小于 MapReduce 任务请求的内存。5.3 中文书籍标题在 HDFS 上显示乱码现象上传包含中文的 CSV 到 HDFS 后用hdfs dfs -cat查看中文部分显示为问号或乱码。原因CSV 文件的编码是 GBK而 Hadoop 默认按 UTF-8 读取。Windows 上导出的 CSV 经常是 GBK 编码。解决导出 CSV 时指定 UTF-8 编码。Python 里用open(file, w, encodingutf-8)。如果文件已经是 GBK用iconv -f GBK -t UTF-8 input.csv output.csv转换后再上传。MySQL 建表时也要指定CHARACTER SET utf8mb4否则从数据库导出时就已经乱码了。5.4 推荐结果全是用户已经看过的书现象跑完推荐任务发现推荐列表里的书用户早就评分过了推荐失去了意义。原因Reducer 里没有过滤掉用户已经评分的物品。在累加预测得分时只判断了userRatings.containsKey(simItem)但相似物品列表里可能包含用户已经看过的书只是不在当前用户的评分记录里因为评分记录可能被分片到不同的 Reducer。解决在输出推荐结果前再做一次过滤。把用户评分过的所有 ISBN 收集到一个 Set 里生成 Top-N 时跳过这些 ISBN。如果数据量大可以在 Map 阶段就把用户已评分的物品作为 key 的一部分让同一个用户的所有评分进入同一个 Reducer这样 Reducer 里就能拿到完整的用户历史。5.5 实验报告里的截图和实际运行结果对不上现象报告里写的推荐准确率是 85%但老师让你现场跑一遍结果差很多。原因报告里的数据是理想情况下的测试结果或者用了不同的参数。现场跑的时候数据集、参数、环境都可能不一样。解决报告里的所有数据都要来自你实际跑出来的结果。跑之前固定好参数把控制台输出和日志保存下来。如果结果不理想就如实写分析原因比编一个好看的数字更有价值。老师看的是你的分析过程不是那个数字本身。6. 从跑通到跑好推荐效果验证与一个实用技巧6.1 用留出法验证推荐质量课程设计里不能只跑出结果就完事还得证明推荐是有效的。最简单的方法叫留出法把每个用户的评分记录随机分成两部分80% 作为训练集20% 作为测试集。用训练集跑推荐看推荐列表里有多少本书出现在测试集里。这个比例就是命中率。具体操作在导出评分数据时用 Python 按用户分组每组随机抽 20% 的记录写到 test.csv剩下的写到 train.csv。MapReduce 只用 train.csv 计算相似度和推荐列表。然后写一个简单的 Python 脚本对比推荐结果和 test.csvimport pandas as pd test pd.read_csv(test.csv) rec pd.read_csv(recommendation.csv) hit 0 total 0 for user_id, group in test.groupby(user_id): user_rec rec[rec[user_id] user_id][isbn].tolist() user_test group[isbn].tolist() hit len(set(user_rec) set(user_test)) total len(user_test) print(f命中率: {hit / total:.4f})命中率能到 10% 到 20% 就算正常别指望太高图书推荐的稀疏性问题本来就严重。如果低于 5%检查一下相似度阈值是不是设得太高或者推荐数量是不是太少。6.2 一个让推荐结果更可解释的小改动推荐系统有个常被忽略的问题推荐结果不可解释。用户看到推荐列表不知道为什么会推荐这些书。课程设计答辩时老师很可能问这个问题。解决办法很简单在推荐结果里加上推荐理由。具体做法在 Job 3 的 Reducer 里记录每个推荐物品的得分主要来自用户看过的哪本书。比如用户看过《算法导论》系统推荐了《数据结构与算法分析》理由是「因为你看了《算法导论》」。实现上就是在累加得分时同时记录贡献最大的那个相似物品的 ISBN输出时把这本书的标题带上。// 在 Reducer 里维护一个 Map记录每个推荐物品的最大贡献来源 MapString, String reasonMap new HashMap(); // 累加得分时 if (sim * r maxContribution.getOrDefault(simItem, 0.0)) { maxContribution.put(simItem, sim * r); reasonMap.put(simItem, item); // item 是用户看过的书 } // 输出时 context.write(key, new Text(simItem : score : reasonMap.get(simItem)));这个改动代码量不大但让推荐结果从「一串 ISBN」变成「有依据的推荐」答辩时能讲出东西来。前端展示时把理由字段显示在推荐书籍下方用户体验也更好。6.3 我踩过的坑和给你的建议做这个课程设计最大的教训是不要等到最后一周才开始配环境。Hadoop 的伪分布式搭建看起来简单但不同版本的 JDK、不同发行版的 Linux、甚至不同的网络配置都会导致各种奇怪的问题。我见过有人因为主机名没配 hosts 解析NameNode 死活起不来折腾了两天。另一个建议是把每次运行的命令和输出都记在一个文本文件里。课程设计报告需要大量截图和日志临时回去找很痛苦。我习惯在项目目录下建一个run_log.md每执行一条关键命令就贴进去包括报错信息。最后写报告时直接从里面摘省很多时间。还有一点数据集不要贪大。Book-Crossing 的 100 万条评分对伪分布式来说已经够跑出有意义的结果了。有人非要用几百万条的数据结果 MapReduce 跑几个小时调试一次等半天效率极低。先用小数据集把流程跑通确认逻辑没问题再换大数据集跑最终结果。希望这些经验帮到你少走点弯路把时间花在理解原理和优化效果上而不是跟环境配置死磕。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询