ARTICLE DETAIL

资讯详情

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

基于Hadoop的云盘系统实战:从HDFS存储到秒传断点续传

基于Hadoop的云盘系统实战:从HDFS存储到秒传断点续传 简介基于Hadoop的百度云盘项目面向计算机相关专业学生及大数据入门者可直接用作毕设、课程设计或项目演示。资源包含完整源代码与文档说明后端由Java、JSP及JAR依赖构成前端涵盖HTML、CSS、JavaScript以及大量PNG/GIF展示图片整体呈现出标准Web应用与Hadoop分布式存储相结合的项目形态。压缩包共2000个文件大小77.11MB内含页面、样式、脚本、配置、数据库脚本等模块目录结构清晰便于按需定位与二次开发。文档说明了环境部署、运行验证与使用要点能引导读者从HDFS操作到云盘功能实现建立整体认知。对于希望巩固分布式文件系统操作与Web服务搭建的学习者该项目提供了从数据存储到前端展示的完整参考。目前已有535人学习下载代码经测试运行成功答辩平均分达96分下载后即可展开学习或在其基础上扩展功能。1. 基于Hadoop的百度云盘一套能跑通的课程设计到底把云盘拆成了哪几块先说结论基于Hadoop的百度云盘不是要复刻百度网盘那种千亿文件级别的分布式系统而是把云盘最核心的“文件上传、下载、分享、秒传”这套业务用Hadoop生态里的HDFS当存储底座、Zookeeper管协调、MySQL或HBase管元数据逐层替换掉单机文件系统后做出来的一个可运行的大数据课程设计项目。你做完之后答辩时能说清楚“文件存在哪个节点、元数据存在哪张表、断点续传的块信息怎么恢复”——这套逻辑比单纯跑一个WordCount高一个量级。适合谁做如果你正在找大数据方向的课程设计、毕业设计或者想入门Hadoop生态但不想只跟着教程敲命令这个方向很合适。它足够大大到能覆盖HDFS、Zookeeper、MapReduce、HBase这些核心组件又足够小小到一台四核八G的笔记本用伪分布式就能跑起来。我把这套系统从架构到代码到踩坑完整拆给你你照着搭完再把源码和文档说明补齐就是一个能直接提交的工程。2. 把百度云盘的功能拆给Hadoop生态HDFS、Zookeeper、HBase各管哪一块2.1 为什么文件存储必须选HDFS而不是本地磁盘顺带回答hadoop面试题做基于Hadoop的云盘第一个要回答的问题是文件为什么不继续放在服务器本地磁盘非要折腾一套HDFS这是个很经典的hadoop面试题变体HDFS适合存储什么数据标准答案是“大文件、一次写入多次读取、批处理场景”。云盘的文件恰好符合——用户上传的视频、压缩包动辄几百MB到几个GB这些文件几乎不被修改只有读取和删除。HDFS把文件切成固定大小的块默认128MB每个块在多个DataNode上存副本伪分布式设1份这解决了单块磁盘容量上限的问题。你可以在HDFS上堆几十台机器文件总量轻松过PB而云盘业务的核心诉求就是“存得下、取得出”。另一个选型理由比较现实课程设计的评分很大程度取决于“技术含量”的可见性。文件存本地评委看到的是MySQL里的一个varchar路径字段跟在SSM课设里写一个文件上传没区别文件存HDFS系统架构图上就能画出DataNode、NameNode、副本、机架感知hadoop课程设计的味道一下就出来了。这不是让你去表演而是这个项目的本质就是“用分布式文件系统替换单机存储并让上层业务无感知”。注意HDFS的边界小文件多了会吃光NameNode内存每个block大概占150字节左右的元数据文件改起来麻烦追加写虽然支持但语义很弱。百度云盘的文件分享本质上是读操作避开了HDFS最大的短板。2.2 元数据为什么留在MySQL而不是全放HBase文件本体进HDFS那“哪个用户上传了什么文件、文件在HDFS上的路径、MD5是多少、分块列表在哪”这些信息放哪很多初学者一上来就把用户信息、文件信息、块信息全往HBase里塞理由是“用了HBase显得更大数据”。我建议反过来核心事务性元数据留在MySQLHBase只负责存文件块索引这类海量明细数据。原因是云盘这类系统的用户表、文件表有强事务要求注册、改密、删文件需要ACID。MySQL在这种量级下完全扛得住而且你在答辩时能说清楚“为什么用户量百万级时才需要拆库拆表”这种hadoop面试题衍生问题。HBase适合的是“同一行键下大量列版本”的写入模式比如文件分块记录rowkey设计成“file_md5chunk_index”每个块的存储路径、偏移量、大小、上传状态作为列族天然适合断点续传时反复查询“这个文件已经传了哪些块”。如果为了省事也可以全部走MySQL用一张chunk表存所有分块就够了。但源码里既然带了HBase整合的痕迹建议保留这个设计它让整个系统的技术栈更有层次感也方便你在文档说明里画一张“热数据走HBase、冷数据走MySQL”的读路径图。具体表设计我一般这样拆MySQLuser表用户、file_info表文件逻辑信息含file_md5、file_size、hdfs_path、upload_timeHBasechunk_tablerowkeyfile_md5_chunkIndex列包含chunk_index、chunk_size、chunk_hdfs_path、status2.3 Hadoop和Zookeeper整合实战NameNode高可用与分布式锁的位置整套系统里最容易被忽略、也最值得在文档说明里大讲特讲的就是Zookeeper。基于Hadoop的云盘需要在两个位置用到它。第一个是NameNode高可用。完整集群里NameNode是HDFS的唯一入口它挂了整个系统就全挂了。生产环境会把Active NameNode和Standby NameNode两节点用Zookeeper做故障自动切换Active节点在Zookeeper上创建临时节点Standby节点通过Watcher监听一旦Active宕机临时节点消失Standby立刻抢占成为新的Active。这在hadoop和zookeeper整合实战里是最标准的组合拳。做课程设计时可以不用真搭双NameNode但至少要在文档里画一张HA架构图并把zkfcZookeeper Failover Controller的配置列出来答辩能加分。第二个是分布式锁。云盘的秒传功能有个并发病多个用户同时上传同一个文件大家都算出同一个MD5如果同时往HDFS写同名文件可能互相覆盖或产生垃圾副本。常见做法是用Zookeeper创建一个临时顺序节点作为锁第一个拿到锁的线程去写文件后面的线程拿锁失败后直接读已存在的路径。回到伪分布式环境用Zookeeper的Curator Framework写一个InterProcessMutex代码量不大但能让系统在并发上有真实的协调逻辑而不是靠MySQL的唯一索引硬怼。下面这个片段是Curator获取锁的骨架如果你项目里已经集成了Zookeeper可以直接复用这段逻辑RetryPolicy retryPolicy new ExponentialBackoffRetry(1000, 3); CuratorFramework client CuratorFrameworkFactory.newClient( 127.0.0.1:2181, retryPolicy); client.start(); client.blockUntilConnected(); InterProcessMutex lock new InterProcessMutex( client, /file-lock/ fileMd5); try { if (lock.acquire(10, TimeUnit.SECONDS)) { // 检查HDFS上文件是否已存在如果存在则秒传 // 不存在则执行上传流程 copyLocalFileToHdfs(localPath, hdfsPath); } } finally { lock.release(); client.close(); }这里的核心参数是/file-lock/后面的路径代码里直接拼了文件的MD5这样不同文件走不同的锁节点互不阻塞同一个文件的所有上传请求会排队。acquire(10, TimeUnit.SECONDS)设置的是拿锁超时时间超过10秒直接放弃避免锁节点异常时客户端无限阻塞。这个逻辑在单机伪分布式上可能感受不到并发压力但放到完整集群上它是保证数据一致性的关键。3. Hadoop伪分布式搭建与最小验证照着复现、半小时看到文件进HDFS3.1 伪分布式搭建的最小配置从JDK到core-site.xml的四个参数做这个项目第一步不是写代码而是先把Hadoop跑起来。我建议直接在Linux虚拟机或云服务器上搭伪分布式别在Windows上硬折腾虽然Hadoop 3.x对Windows支持好多了但排错成本依然很高。核心版本选Apache Hadoop 3.x稳定线即可不要追最新版因为网上能搜到的hadoop安装与配置教程、HBase整合教程大部分基于3.x版本差太多你会在兼容性上翻车。搭建之前先把JDK装好Hadoop 3.x要求JDK 8或JDK 11我一般用JDK 8跟Zookeeper、HBase的兼容性最稳。安装完JDK后配置JAVA_HOME然后下载Hadoop压缩包解压到/opt/hadoop。下面这份是最小可用的配置伪分布式阶段核心就改三个文件。先看core-site.xml设置默认文件系统和临时目录configuration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property property namehadoop.tmp.dir/name value/opt/hadoop/data/tmp/value /property property namehadoop.http.staticuser.user/name valuehadoop/value /property /configurationfs.defaultFS指定了HDFS的入口地址以后所有相对路径的写操作都会自动落到这个HDFS上。hadoop.tmp.dir是NameNode和DataNode的默认数据目录的根这个参数非常关键后面格式化NameNode失败大概率就是它没配好或目录权限不对。第三行的hadoop.http.staticuser.user是让Web UI操作HDFS时默认以hadoop用户身份不然你通过页面删文件会报权限错误。再看hdfs-site.xml伪分布式必须把副本数改成1并指定NameNode的元数据目录configuration property namedfs.replication/name value1/value /property property namedfs.namenode.name.dir/name value/opt/hadoop/data/namenode/value /property property namedfs.datanode.data.dir/name value/opt/hadoop/data/datanode/value /property /configurationdfs.replication设置为1是因为伪分布式只有一台机器设3个副本会白白占两倍磁盘空间。dfs.namenode.name.dir是格式化时NameNode元数据落盘的位置这个目录和hadoop.tmp.dir不要混用最好单独建。如果后面想从伪分布式平滑过渡到集群只要把replication改回3、把name.dir改成共享存储路径就行。最后是yarn-site.xml伪分布式跑MapReduce或需要Yarn调度分块任务时用configuration property nameyarn.nodemanager.aux-services/name valuemapreduce_shuffle/value /property property nameyarn.nodemanager.resource.memory-mb/name value2048/value /property /configurationyarn.nodemanager.aux-services必须配置成mapreduce_shuffle否则跑MapReduce作业时容器起不来报错信息是Shuffle error很多新手在这里卡一晚上。内存参数按你机器实际大小调我本地虚拟机给的是2G作业规模不大就够用了。配置完记得执行source /etc/profile让环境变量生效然后格式化NameNode并启动hdfs namenode -format start-dfs.sh start-yarn.sh jps注意hdfs namenode -format只需要在第一次启动前执行一次以后每次重启集群千万不能顺手再格式化一次否则NameNode的namespace ID变了DataNode还是旧的启动时直接报Incompatible namespaceIDs这就是网上最常见的玄学问题之一。jps能看到NameNode、DataNode、SecondaryNameNode、ResourceManager、NodeManager五个进程就说明起来了。3.2 用HDFS Shell做第一次上传下载hadoop fs命令族集群起来后先不急着写代码用命令行验证一下存储路径是否通。HDFS的Shell命令和Linux命令长得差不多但前缀是hadoop fs。下面这组命令是云盘项目读写文件的最小闭环直接贴到终端跑hadoop fs -mkdir -p /user/upload echo hello hadoop cloud disk test.txt hadoop fs -put test.txt /user/upload/ hadoop fs -ls /user/upload/ hadoop fs -cat /user/upload/test.txt hadoop fs -get /user/upload/test.txt ./download_test.txt-put从本地拷贝到HDFS-get从HDFS拉回本地这两条命令对应云盘的上传和下载接口。-cat直接看文件内容方便你确认写入没有丢数据。如果-put报Permission denied通常是当前Linux用户不是HDFS超级用户可以先执行export HADOOP_USER_NAMEhadoop临时切换用户这个环境变量的作用范围只在当前Shell窗口不会污染系统配置。跑通了这一组命令说明你的HDFS是健康状态。接下来做云盘项目时所有代码里的路径都用hdfs://localhost:9000开头的全路径不要用相对路径避免因为当前用户和当前目录不一致导致文件写到了意外的地方。3.3 备选用Hadoop的Docker镜像跳过环境坑如果你的时间非常紧或者宿主机是一台Windows且不想装虚拟机我建议直接用现成的Hadoop的Docker镜像做环境。整个伪分布式环境被封装在容器里拉下来就能用省掉了配置免密SSH、调JVM参数这些容易翻车的步骤。常见做法是拉一个带Hadoop的官方镜像运行容器时把宿主机的某个目录挂载进容器作为云盘文件的上传中转目录。用Docker镜像有个好处是环境隔离就算你把集群搞坏了删掉容器重新创建一个就行不用重装系统这就是“后悔药”。但注意不要过度依赖镜像——你在容器里搭的环境和真实服务器环境还是有差别的尤其是端口映射和磁盘挂载这块代码里连接HDFS的地址要看容器的IP而不是localhost。我用镜像做演示时会先用docker exec -it 容器ID bash进入容器确认jps正常再在容器内执行一遍3.2节的Shell命令确认通了再开始跑项目代码。4. 核心代码与关键参数上传、秒传、分块断点续传怎么落在HDFS上4.1 文件上传与下载FileSystem API的骨架代码环境通了之后开始写云盘的业务代码。整个项目最核心的一段是“从本地文件系统到HDFS的搬运”Java里用HDFS自带的FileSystem API做接口很简洁但有几个参数值得你单独拿出来写进文档说明里。先看上传的主要代码这里用了IOUtils.copyBytes做流搬运避免手动开缓冲区import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.*; public class HdfsFileOperator { public static void upload(String localPath, String hdfsPath) throws Exception { Configuration conf new Configuration(); conf.set(fs.defaultFS, hdfs://localhost:9000); FileSystem fs FileSystem.get(conf); Path src new Path(localPath); Path dst new Path(hdfsPath); fs.copyFromLocalFile(false, true, src, dst); fs.close(); System.out.println(upload success: hdfsPath); } }copyFromLocalFile有四个重载常用的是copyFromLocalFile(boolean delSrc, boolean overwrite, Path src, Path dst)第一个参数delSrc表示上传完成后是否删除本地源文件云盘业务里用户传完文件还要保留本地副本所以传false第二个参数overwrite表示目标路径存在时是否覆盖这里传true是简化逻辑真正常见做法是先查文件是否已存在不存在才允许写入。下载方向对应copyToLocalFile伪分布式环境下这一步没什么坑但如果你之后把集群节点扩展到多台FileSystem.get(conf)会自动从配置的fs.defaultFS连接NameNode获取块位置信息数据在哪个DataNode是透明的你不需要关心具体节点地址这是HDFS对上层业务屏蔽细节的一个重要体现。4.2 秒传的实现MD5与文件块级别的去重百度网盘的秒传原理不是魔法就是文件指纹去重。用户上传文件时客户端先计算整个文件的MD5把这个值提交给服务端服务端去元数据库里查有没有相同MD5的文件记录。如果存在直接给用户挂一条指向该文件HDFS路径的记录跳过数据上传如果不存在才真正执行文件传输。这个设计极大节省了带宽和存储是云盘系统里能直接讲给人听的亮点。看下面这段秒传判断逻辑public boolean tryInstantUpload(String md5, long size, int userId) { // 先查元数据库 FileInfo existFile fileInfoMapper.findByMd5(md5); if (existFile ! null existFile.getFileSize() ! size) { // MD5相同但大小不同属于罕见碰撞走完整上传 return false; } if (existFile ! null) { // 秒传命中建立用户与文件关联不复制HDFS数据 userFileMapper.insert(userId, existFile.getFileId()); return true; } // 未命中返回false前端走分块上传流程 return false; }这里有个容易被忽略的边界单纯用MD5判断存在性。理论上有hash碰撞的可能概率极低但发生在重要文件上后果严重所以代码里加了一层文件大小校验MD5相同且size相同才允许秒传。生产环境会更稳妥地用MD5CRC64或SHA256做双重指纹但课程设计里加个size校验已经足够体现你的工程意识。秒传命中后文件在HDFS上只有一份物理副本但逻辑上多个用户都拥有它。这种“物理唯一、逻辑多引用”的模型正是对象存储里的去重思想。你在文档说明里把这个关系画成一张ER图答辩时非常加分。4.3 分块上传与断点续传为什么块大小不等于HDFS块大小大文件上传不能走简单的一把梭。假如用户传一个2GB的电影网络断了之后一切重来体验极差。所以真正的云盘都把文件切成固定大小的分块逐块上传HDFS侧再把这些块合并成一个完整文件。这里有一个很多初学者容易混的概念分块大小和HDFS块大小是两回事。HDFS块大小默认是128MB决定的是文件在HDFS内部的存储单元云盘的业务分块则通常在1MB到8MB之间决定的是网络传输粒度。业务分块跨过HDFS的块边界没有任何问题因为HDFS只负责底层的存储分布上层的file_md5chunk_index凭据是业务自己在元数据库里维护的。上传流程我拆成三步客户端计算文件总大小按固定分块大小比如4MB算出总块数向服务端申请一个uploadId客户端逐块上传每传完一块服务端把该块的MD5和HDFS临时路径写入chunk表状态置为UPLOADED所有块传完后服务端按chunk_index顺序将这些临时块合并或直接在合并时顺序写入最终路径分块上传的HDFS写入代码核心是按块追加或合并public boolean uploadChunk(String uploadId, int chunkIndex, InputStream in) { // 临时目录/tmp/chunks/{uploadId}/{chunkIndex} String chunkPath /tmp/chunks/ uploadId / chunkIndex; Path path new Path(chunkPath); FSDataOutputStream out fs.create(path, true); IOUtils.copyBytes(in, out, 4096, true); // 更新元数据库chunk表状态 chunkMapper.markUploaded(uploadId, chunkIndex); return true; }fs.create(path, true)想表达的语义是每次上传同一个块索引都覆盖重写这样网络重传时同一个chunk只需最新的那一份。但直接把所有分块都零散存在/tmp/chunks下最后一步合并会产生大量小文件HDFS处理小文件的性能会下降。更稳妥的做法是上传时先写入本地临时目录所有块齐了之后用一段流处理把它们顺序写入HDFS的最终路径——这就把一个“多次小块写入”变成“一次顺序大写入”。public void mergeChunks(String uploadId, String finalHdfsPath) { ListChunkInfo chunks chunkMapper.getChunksByUploadId(uploadId); chunks.sort(Comparator.comparingInt(c - c.getChunkIndex())); Path dst new Path(finalHdfsPath); FSDataOutputStream out fs.create(dst, true); for (ChunkInfo chunk : chunks) { // 从临时块路径读取写入最终文件 Path srcPath new Path(chunk.getChunkHdfsPath()); FSDataInputStream in fs.open(srcPath); IOUtils.copyBytes(in, out, 4096, false); fs.delete(srcPath, false); // 合并后删除临时块 } out.close(); }这里的4096是IOUtils.copyBytes的拷贝缓冲区大小按4KB走对本地磁盘和小文件足够但合并大块文件时我习惯提到1024 * 10241MB减少带宽切换次数合并速度能明显提升。还要注意IOUtils.copyBytes(in, out, buffSize, false)最后一个参数传false表示关闭流时不在方法内部自动执行close操作交给外层统一处理否则你在循环里每次都调用一次可能会出现“流先关闭、数据没flush完”的隐蔽bug这是我调分块合并时踩过的血泪经验。分块大小这个参数直接决定断点续传的粒度分块设1MB断点重传最多浪费1MB分块设8MB重传浪费8MB。但块设得越小元数据库里的chunk记录数越多chunk表的查询压力就越大。实际做课程设计4MB是一个比较平衡的值既能演示断点续传的效果又不会产生几十万条chunk记录。5. 避坑指南基于Hadoop的云盘最容易翻车的5个地方5.1 现象格式化两次后NameNode起不来日志报Incompatible namespaceIDs原因hdfs namenode -format每次会重新生成一个namespaceID而DataNode有自己的data目录和旧namespaceID。你格式化第二次后两边对不上就拒绝通信。这个坑几乎每个初学hadoop安装与配置的人都会踩一次。解决最简单的办法是格式化之前把hdfs-site.xml里配置的dfs.namenode.name.dir目录和dfs.datanode.data.dir目录整个删掉让DataNode重新生成和NameNode一致的ID。以后也不要动不动就重新格式化除非你确认要抛弃所有已存数据。5.2 现象MapReduce任务提交后一直停在ACCEPTED最后超时失败原因yarn-site.xml里的yarn.nodemanager.aux-services没配成mapreduce_shuffleNodeManager的Shuffle服务根本没启动Reduce阶段拿不到Map输出。解决补上配置重启Yarn。判断这个配置是否生效可以在Yarn的Web UI里看NodeManager状态如果显示RUNNING但没有任何容器分配优先怀疑这个参数。5.3 现象通过浏览器访问HDFS页面很流畅但云盘页面打不开文件列表原因云盘后端服务连HDFS时没有传用户身份HDFS默认权限模式会拒绝非超级用户操作。很多人用root启动的HDFS但代码里FileSystem.get(conf)拿到的用户是运行Tomcat的系统用户比如tomcat权限不足。解决在代码里这样指定访问用户FileSystem fs FileSystem.get(URI.create(hdfs://localhost:9000), conf, hadoop);第三个参数就是模拟的HDFS用户传成你启动HDFS的Linux用户名即可。同样的问题也会出现在WebHDFS上可以在请求头加user.name参数。5.4 现象分块上传后文件合并成功但下载下来的文件和原文件MD5不一致原因合并时读取分块的顺序错了。chunk索引是按字符串排序的chunk_10会排在chunk_2前面导致数据顺序错乱。解决合并前对分块列表做数值排序而不是字符串排序。代码里用Comparator.comparingInt(c - c.getChunkIndex())就是解决这个问题。顺带提一句判断上传正确性的标准是文件的MD5是否与分块前一致建议在合并完成后重新计算一次全文件MD5并和上传时提交的MD5做比对这是验证分块合并是否正确的硬指标。5.5 现象Zookeeper和Hadoop都启动了但Zookeeper那边的锁没生效原因伪分布式单节点上NameNode不需要ZK做HA如果项目里为了演示还是把Hadoop配成HA模式启动时两个NameNode互相争抢会导致集群假死。很多教程里的ha配置是给多节点用的硬搬到单机就翻了。解决课程设计层面Zookeeper只保留它的分布式锁功能别让Hadoop走HA模式。等以后真上了多节点再按hadoop和zookeeper整合实战的标准方案补HA配置。检查方法很简单看Zookeeper进程是否存在即可不需要把NameNode交给ZK管理。6. 验证与进阶从“能跑”到“像样”的3个检查点项目代码写完、环境搭好距离提交还有最后一步验证系统到底行不行。这里我习惯做三件事每一件都能在答辩时拿出来当数据支撑。第一是数据迁移与校验用distcp把伪分布式里积累的测试文件从HDFS的一个目录拷贝到另一个目录。很多人的认知里distcp只是跨集群拷贝工具但它最实用的场景是相同集群不同目录之间的数据校验与整理特别是合并小文件或者迁移废弃目录。常用这组参数hadoop distcp \ -m 4 \ -bandwidth 50 \ -update \ -skipcrccheck \ hdfs://localhost:9000/user/upload \ hdfs://localhost:9000/user/backup_2025-m 4指定同时运行的Map任务数控制并发度-bandwidth 50限制每个Map任务每秒最多50MB带宽避免大文件迁移时占满磁盘IO导致云盘Web服务卡顿-update让目标目录中比源目录旧的同名文件被覆盖更新-skipcrccheck跳过CRC校验适用于你对网络传输比较信任的场合或目标文件已经过校验的场景。这张命令不用背但你要能讲清楚每个参数含义。第二是上传性能摸底这是判断系统有没有隐藏问题的有效手段。我通常写一个循环脚本生成50个2MB左右的文件依次调用上传接口然后看总耗时和单文件平均耗时。如果50个文件上传明显越来越慢大概率是代码里每次上传都重新创建FileSystem实例没有复用连接池。正确答案是让FileSystem实例在整个Spring容器里保持单例因为FileSystem.get本身有缓存机制重复创建会积累大量空闲连接。第三是并发逻辑验证。开两个线程同时上传一个同名文件看系统是否保持只有一个成功、另一个被Zookeeper锁拦住走秒传分支。这个实验能直接体现你的Zookeeper代码是“写了”还是“真的跑起来了”。我在交付这类课程设计时有个习惯项目根目录放一份README第一页永远是环境版本表和启动顺序第二页是HDFS目录设计第三页才是功能说明。版本表里写清楚JDK版本、Hadoop版本、Zookeeper版本以及“必须先启动哪个服务、再启动哪个服务”这样换任何一台机器都能复现。很多人的代码没问题但文档说明一团乱麻导致别人根本不敢动他的项目。这个习惯帮我自己省了大量沟通成本也避免了很多次“为什么在我机器上跑不起来”的扯皮。希望帮到你。本文还有配套的精品资源点击获取
返回列表