简介:面向Java初、中级开发者,提供基于Socket编程的服务端与客户端文件传输示例,覆盖ServerSocket监听、accept建立连接、输入输出流读写、文件流传输等关键环节,可应用于分布式系统、网络应用中的数据交换场景,适合课程设计或自学练手。压缩包大小仅9KB,共包含9个文件,以3个Java源码和3个class文件为主,附带.project、.classpath等Eclipse工程配置;源码用于查看实现逻辑,class文件便于直接运行验证,配置文件则帮助快速导入开发环境。已有679人学习下载。包体虽小但内部结构完整,按src、bin等目录组织,服务端与客户端实现均包含在内,并涉及缓冲区读写、异常处理等细节;结合描述中的NIO、线程池优化提示,可帮助读者掌握从基础文件传输到并发性能提升的进阶路径。配合描述中的异常处理和性能优化思路,能帮助规避常见坑点,是入门Java网络编程与文件传输的良好参考。
1. 为什么我在Java里自己写Socket文件传输,而不是直接用现成框架
先说个真实场景:某个内部系统需要把生成的报表文件从一台服务器传输到另一台服务器,文件不大不小,单个在几十到几百MB之间。当时第一反应是直接用HTTP上传接口,或者干脆搭个FTP,但仔细一想都不太合适——HTTP接口要额外搭Web服务,FTP还要维护单独的账号体系,而且这两者传输过程遇到断网、超时,错误处理都挺麻烦的。
后来决定用Java原生的Socket自己实现一套简单的文件传输方案。这个选择有几个理由:
第一,Java的Socket编程是基础能力,网络传输这块几乎不依赖任何第三方库,部署环境只需要JDK,不需要额外装FTP服务器、Nginx之类的组件。对于内网环境下的文件同步场景,越少依赖越省心。
第二,自己能完全控制传输协议。比如可以自定义消息头,支持后续扩展——文件校验、断点续传、加密传输这些功能,都可以逐步加上去。用现成框架反而容易被框架的协议绑定住。
第三,面试这块也经常被问到。Java面试题里服务端和客户端通信、文件传输相关的问题出现频率很高,搞懂原理比背八股文有用得多。
当然,也不是说所有场景都适合手写。如果文件量巨大、要求高吞吐和可靠性,直接用成熟的传输框架(比如基于Netty实现,或者用JSch走SFTP协议)更稳妥。但如果是学习原理,或者传输场景比较单一、团队能Hold住代码维护,手写一套完全可行。
这篇文章我把完整的实现拆成几块来讲:传输协议怎么设计、服务端怎么写、客户端怎么写、大文件怎么优化、断点续传怎么实现,最后再分享一些实测中踩过的坑。
2. 传输协议设计:消息头和文件体的边界怎么划分
Socket传输本质上是流式传输,数据是一串字节流,没有天然的“分包”逻辑。如果直接把整个文件字节流扔进Socket,接收端没法知道文件名叫什么、文件多大、数据什么时候结束。所以必须要自定义一个协议格式。
我采用的协议结构分两部分:协议头 + 文件数据。
协议头用一个固定长度的字节数组(比如64字节)来承载文件的元信息,这样接收端只要先读取固定长度的头部,解析出元信息,再根据头部里记录的文件大小去读文件体,就能准确地把整个文件接收完整。
协议头设计成什么样?每个字段我用固定字节长度来分隔,避免用分隔符(分隔符存在转义问题,文件内容里如果恰好包含分隔符序列会出大问题):
- 魔数(4字节):固定值
0xCAFEBABE,用来校验是不是自己协议的数据包,防止收到无关数据。 - 消息类型(1字节):
0x01表示普通文件传输,0x02表示断点续传请求,0x03表示服务端应答。 - 文件名长度(4字节):文件名是UTF-8编码的字节数组,长度不定,所以先记录长度。
- 文件名(最大255字节):实际文件名字节。
- 文件大小(8字节):用long类型记录文件总字节数。
- 文件偏移量(8字节):用于断点续传,表示从文件的哪个位置开始发送。
- 数据校验码(4字节):对协议头本身做个简单的
CRC32校验,防止头部在传输过程中被破坏。
这样头部总长是:4 + 1 + 4 + 255 + 8 + 8 + 4 = 284字节。为了方便对齐,我把它固定为512字节,剩余部分用零填充。
提示:文件名长度限制255字节是UTF-8下的保守值,中文文件名一个汉字占3字节,255字节大约能存85个汉字,够用。如果业务上有更长的文件名需求,可以把这个字段扩大。
传输逻辑就是:客户端先写512字节的协议头,再写文件数据;服务端先读512字节的协议头,解析成功后再读文件数据。
代码实现协议头封装:
public class FilePacketHeader { // 魔数,用于快速识别协议 public static final int MAGIC_NUMBER = 0xCAFEBABE; // 固定协议头长度 public static final int HEADER_LENGTH = 512; // 最大文件名长度 public static final int MAX_FILE_NAME_LENGTH = 255; private byte messageType; private String fileName; private long fileSize; private long offset; // 将头部数据编码为字节数组 public byte[] toBytes() throws IOException { ByteBuffer buffer = ByteBuffer.allocate(HEADER_LENGTH); buffer.order(ByteOrder.BIG_ENDIAN); buffer.putInt(MAGIC_NUMBER); buffer.put(messageType); byte[] fileNameBytes = fileName.getBytes(StandardCharsets.UTF_8); if (fileNameBytes.length > MAX_FILE_NAME_LENGTH) { throw new IOException("文件名过长"); } buffer.putInt(fileNameBytes.length); buffer.put(fileNameBytes); buffer.putLong(fileSize); buffer.putLong(offset); // 对协议头做CRC32校验 byte[] headerWithReserved = buffer.array(); CRC32 crc32 = new CRC32(); crc32.update(headerWithReserved, 0, HEADER_LENGTH - 4); // 把校验值写入最后4字节 ByteBuffer result = ByteBuffer.allocate(HEADER_LENGTH); result.order(ByteOrder.BIG_ENDIAN); result.put(headerWithReserved, 0, HEADER_LENGTH - 4); result.putInt((int) crc32.getValue()); return result.array(); } // 从输入流中解析头部 public static FilePacketHeader fromStream(InputStream in) throws IOException { byte[] headerBytes = new byte[HEADER_LENGTH]; // 先完整读取头部 readFully(in, headerBytes); ByteBuffer buffer = ByteBuffer.wrap(headerBytes); buffer.order(ByteOrder.BIG_ENDIAN); int magic = buffer.getInt(); if (magic != MAGIC_NUMBER) { throw new IOException("非法协议头,魔数不匹配"); } FilePacketHeader header = new FilePacketHeader(); header.messageType = buffer.get(); int nameLength = buffer.getInt(); if (nameLength <= 0 || nameLength > MAX_FILE_NAME_LENGTH) { throw new IOException("非法的文件名长度"); } byte[] nameBytes = new byte[nameLength]; buffer.get(nameBytes); header.fileName = new String(nameBytes, StandardCharsets.UTF_8); header.fileSize = buffer.getLong(); header.offset = buffer.getLong(); // 校验头部CRC CRC32 crc32 = new CRC32(); crc32.update(headerBytes, 0, HEADER_LENGTH - 4); int expectedCrc = buffer.getInt(); if ((int) crc32.getValue() != expectedCrc) { throw new IOException("协议头校验失败,数据可能已损坏"); } return header; } // 从输入流中精确读取指定长度的字节 private static void readFully(InputStream in, byte[] buffer) throws IOException { int read = 0; while (read < buffer.length) { int result = in.read(buffer, read, buffer.length - read); if (result == -1) { throw new EOFException("流已结束,无法读取完整数据"); } read += result; } } // getter/setter 省略 }这个设计的核心思路是:固定长度头部 + 变长文件内容。接收方在读文件之前,先准确知道“接下来要读多少数据”,就不会出现读多或读少的问题。
3. 服务端实现:多线程接收、落盘和文件冲突处理
服务端的逻辑相对简单:启动ServerSocket,监听端口,每个客户端连接来了之后,读取协议头,再根据头部的元数据把文件写入指定目录。
3.1 服务端主流程
服务端代码分三层:
- 接收层:
ServerSocket.accept()循环接收连接。 - 处理层:每个连接分配一个线程,读取协议头和文件数据。
- 存储层:把文件写入磁盘,处理重名、路径安全等问题。
public class FileServer { private final int port; private final String storagePath; private volatile boolean running = true; private final ExecutorService executor = Executors.newCachedThreadPool(); public FileServer(int port, String storagePath) { this.port = port; this.storagePath = storagePath; } public void start() throws IOException { // 确保存储目录存在 Files.createDirectories(Paths.get(storagePath)); try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("文件服务端已启动,监听端口: " + port); while (running) { try { Socket socket = serverSocket.accept(); // 每个连接一个线程处理 executor.submit(() -> handleClient(socket)); } catch (IOException e) { if (running) { e.printStackTrace(); } } } } } private void handleClient(Socket socket) { // 设置超时时间,防止客户端连接后不发数据导致线程一直挂着 try (socket; DataInputStream dis = new DataInputStream(socket.getInputStream())) { socket.setSoTimeout(60000); // 读取协议头 FilePacketHeader header = FilePacketHeader.fromStream(dis); // 根据消息类型处理 switch (header.getMessageType()) { case 0x01: receiveFile(socket, dis, header, false); break; case 0x02: receiveFile(socket, dis, header, true); break; default: throw new IOException("未知的消息类型: " + header.getMessageType()); } } catch (Exception e) { e.printStackTrace(); } } private void receiveFile(Socket socket, DataInputStream dis, FilePacketHeader header, boolean append) throws IOException { String fileName = sanitizeFileName(header.getFileName()); Path targetPath = Paths.get(storagePath, fileName); // 如果是断点续传且文件已存在,校验已存在部分是否合法 long offset = 0; if (append && Files.exists(targetPath)) { offset = Files.size(targetPath); if (offset > header.getFileSize()) { throw new IOException("目标文件已大于待传输文件大小,续传终止"); } } try (FileOutputStream fos = new FileOutputStream(targetPath.toFile(), append)) { long remaining = header.getFileSize() - offset; byte[] buffer = new byte[8192]; int len; while (remaining > 0 && (len = dis.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { fos.write(buffer, 0, len); remaining -= len; } if (remaining > 0) { throw new IOException("文件接收不完整,剩余字节数: " + remaining); } } // 发送接收完成应答 sendResponse(socket, 0x03, "OK"); } // 防止文件名包含路径分隔符,造成目录穿越 private String sanitizeFileName(String fileName) { String cleanName = Paths.get(fileName).getFileName().toString(); if (cleanName.isEmpty()) { throw new IllegalArgumentException("非法文件名"); } return cleanName; } private void sendResponse(Socket socket, byte type, String message) throws IOException { DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8); dos.writeByte(type); dos.writeInt(msgBytes.length); dos.write(msgBytes); dos.flush(); } }3.2 服务端的三个关键细节
第一个细节:executor 用缓存线程池而不是固定大小线程池。文件传输是IO密集型操作,线程大部分时间在等网络/磁盘IO,固定线程池如果设置太小,并发多了之后文件会排队,影响体验。缓存线程池(CachedThreadPool)在空闲时会回收线程,在并发高时会自动增加线程,比较适合这种不稳定的连接场景。
第二个细节:socket 超时时间必须设置。我在handleClient里设置了60秒超时,这是防止某个客户端建立了TCP连接但迟迟不发送协议头,导致服务端线程白挂60秒。实际线上遇到过客户端程序崩溃、没有正常关闭连接的情况,如果不设超时,线程池会被耗尽。
第三个细节:文件名清洗必须做。恶意客户端可以在文件名里加入../之类的路径分隔符,比如文件名传../../etc/passwd,如果不处理就能把文件写到任意目录。Paths.get(fileName).getFileName()只取最后的文件名部分,能有效防止目录穿越。
4. 客户端实现:从文件读取到Socket写入的完整链路
客户端逻辑比服务端复杂一点,因为要考虑文件不存在、文件被占用等情况,还要处理大文件的内存问题。
4.1 客户端核心代码
public class FileClient { private final String host; private final int port; public FileClient(String host, int port) { this.host = host; this.port = port; } public void sendFile(String filePath) throws IOException { Path path = Paths.get(filePath); if (!Files.exists(path)) { throw new IOException("文件不存在: " + filePath); } long fileSize = Files.size(path); String fileName = path.getFileName().toString(); try (Socket socket = new Socket(host, port); DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); FileInputStream fis = new FileInputStream(path.toFile())) { // 构造并发送协议头 FilePacketHeader header = new FilePacketHeader(); header.setMessageType((byte) 0x01); header.setFileName(fileName); header.setFileSize(fileSize); header.setOffset(0); byte[] headerBytes = header.toBytes(); dos.write(headerBytes); dos.flush(); // 分段读取文件,写入Socket byte[] buffer = new byte[8192]; long totalSent = 0; int len; long startTime = System.currentTimeMillis(); while ((len = fis.read(buffer)) != -1) { dos.write(buffer, 0, len); totalSent += len; } dos.flush(); long endTime = System.currentTimeMillis(); System.out.printf("文件发送完成: %s, 大小: %d 字节, 耗时: %d ms%n", fileName, totalSent, (endTime - startTime)); // 读取服务端应答 readServerResponse(socket); } } private void readServerResponse(Socket socket) throws IOException { DataInputStream dis = new DataInputStream(socket.getInputStream()); byte type = dis.readByte(); int msgLength = dis.readInt(); byte[] msgBytes = new byte[msgLength]; dis.readFully(msgBytes); String message = new String(msgBytes, StandardCharsets.UTF_8); System.out.println("服务端应答: " + message); } }4.2 客户端设计中的一个隐形坑:文件大小和类型
刚开始写版本的时候,我用的是long fileSize = new File(filePath).length(),后来发现这个方法在文件是符号链接、或者正在被其他进程写入时,返回的大小可能不准确。更稳妥的做法是用Files.size(path),它可以正确处理符号链接,而且在文件系统层面获取真实大小。
另外如果传的是目录而不是文件,Files.exists返回true,但FileInputStream会直接抛异常。所以客户端最好先判断Files.isRegularFile(path)。
4.3 客户端发送大文件为什么不用一次全读进内存
很多人第一次写这种代码,会直接用Files.readAllBytes()把整个文件读进内存再一次性写入Socket。这种做法在小文件(几MB)时没问题,但文件超过500MB时,内存会直接被吃光,甚至触发GC停顿,传输效率反而下降。
正确的做法是像上面代码那样用缓冲区(buffer)循环读取和写入。这里有一个细节:buffer大小怎么选?
buffer太小(比如1KB),会导致系统调用频次太高,CPU开销大;buffer太大(比如64MB),占用内存多,GC压力大。实际测试下来,8KB到64KB之间是比较合理的范围。我默认用8KB,在千兆内网环境下实测没有明显瓶颈,如果追求极致性能可以调大到32KB或64KB。
注意:Java的
dos.write(byte[])底层调用的是原生方法,每次调用都涉及JNI边界,频繁调用会有开销。所以比起小buffer频繁flush,大buffer整块写入性能会明显好很多。
5. 大文件传输的性能优化:缓冲区和分片策略
实测中发现,用8KB buffer传100MB文件,在本地环回地址上大概耗时1.2秒;把buffer调到64KB后,耗时能降到0.7秒左右。这个差异主要来自系统调用次数减少了7倍。
除了调buffer,还有两个优化方向:
5.1 使用 BufferedOutputStream 包装
在 Socket 流外面套一层BufferedOutputStream,可以显著减少系统调用次数。原理很简单:BufferedOutputStream底层维护了一个字节数组,每次write()先把数据写入内存缓冲区,等缓冲区满了再一次性刷给 Socket。
// 客户端优化:套用缓冲输出流 Socket socket = new Socket(host, port); BufferedOutputStream bos = new BufferedOutputStream(socket.getOutputStream(), 65536); DataOutputStream dos = new DataOutputStream(bos);这样即使代码里每次只写1KB,实际刷到Socket的次数也会少很多。建议生产环境代码都用这种包装方式。
5.2 分片传输配合进度回显
大文件传输最怕用户等待时看不到进度,误以为程序卡死了。可以在客户端循环里每发送一个分片(比如4MB),就打印一次进度:
long totalSent = 0; int len; int progressInterval = 4 * 1024 * 1024; // 每4MB打印一次进度 long lastPrint = 0; while ((len = fis.read(buffer)) != -1) { dos.write(buffer, 0, len); totalSent += len; if (totalSent - lastPrint >= progressInterval) { System.out.printf("已发送: %d / %d (%.1f%%)%n", totalSent, fileSize, totalSent * 100.0 / fileSize); lastPrint = totalSent; } }分片逻辑同时也能让断点续传有“进度”维度可追踪。后面做断点续传时,这个分片的思想是基础。
5.3 滑动窗口与吞吐量的关系(产品级传输)
如果你的场景要求更高的传输效率,可以考虑把“发送完再等确认”改成“滑动窗口”模式。简单说,就是允许同时发送多个分片,不需要每个分片都等应答。窗口大小可以动态调整,类似TCP的拥塞控制。
这个方案实现复杂度高很多,我在实际项目中只在数据量达到GB级别且跨公网传输时才考虑。内网同机房传输,单线程socket配合大缓冲区,基本能跑满带宽。
6. 断点续传:从服务端查偏移量,客户端跳过已传部分
实际使用中网络断开、程序崩溃很常见。大文件传到一半断开了,如果从头开始传,体验很差。所以加上断点续传功能。
断点续传思路分三步:
- 客户端发送查询请求(
0x02类型),上报文件名和总大小。 - 服务端检查已存在的目标文件大小,返回一个偏移量(即已接收的字节数)。
- 客户端根据偏移量,从文件对应位置开始读取,发送剩余部分;服务端以追加模式打开文件写入。
6.1 偏移量查询接口
// 服务端:处理断点续传查询 private long queryOffset(String fileName) { Path targetPath = Paths.get(storagePath, sanitizeFileName(fileName)); if (Files.exists(targetPath)) { try { return Files.size(targetPath); } catch (IOException e) { return 0; } } return 0; }6.2 客户端断点续传逻辑
public void sendFileWithResume(String filePath) throws IOException { Path path = Paths.get(filePath); long fileSize = Files.size(path); String fileName = path.getFileName().toString(); try (Socket socket = new Socket(host, port); DataInputStream dis = new DataInputStream(socket.getInputStream()); DataOutputStream dos = new DataOutputStream(socket.getOutputStream())) { // 查询服务端已有的偏移量 FilePacketHeader queryHeader = new FilePacketHeader(); queryHeader.setMessageType((byte) 0x02); queryHeader.setFileName(fileName); queryHeader.setFileSize(fileSize); dos.write(queryHeader.toBytes()); dos.flush(); // 读取偏移量响应 byte type = dis.readByte(); long offset = dis.readLong(); System.out.println("服务端已有字节数: " + offset); if (offset >= fileSize) { System.out.println("文件已存在于服务端,无需重新发送"); return; } // 从指定偏移量开始发送文件数据 try (FileInputStream fis = new FileInputStream(path.toFile())) { fis.skipNBytes(offset); // Java 12+,旧版本用 skip FilePacketHeader dataHeader = new FilePacketHeader(); dataHeader.setMessageType((byte) 0x02); dataHeader.setFileName(fileName); dataHeader.setFileSize(fileSize); dataHeader.setOffset(offset); dos.write(dataHeader.toBytes()); byte[] buffer = new byte[8192]; long sent = 0; long remaining = fileSize - offset; int len; while (remaining > 0 && (len = fis.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { dos.write(buffer, 0, len); sent += len; remaining -= len; } dos.flush(); System.out.println("断点续传完成,本次发送: " + sent + " 字节"); } } }注意这里一个细节:查询偏移量用的连接和传输文件的连接是同一个,查询成功后就把文件数据直接发过去。服务端收到0x02类型头之后,先检查目标文件大小,再决定是追加还是覆盖,逻辑上需要把“查询偏移量”和“继续传输”拆成两个阶段,或者用同一条连接里连续两个头处理。我这里用的方式是同一条连接,先发查询头,服务端返回偏移量,再发数据头,服务端根据数据头里的offset字段追加写入。
6.3 断点续传的可靠性问题
断点续传有一个潜在bug:如果服务端上已存在的文件内容不完整但大小一致(比如上一次传输过程中数据损坏,但字节数相同),继续从偏移量追加的话,损坏部分无法被修复。解决办法是在协议里增加文件MD5校验——客户端计算整个文件的MD5,服务端接收完成后校验;不匹配就删除重传。这个逻辑目前是预留状态,有兴趣的可以自己加上。
7. 实测中遇到的问题与排查思路
理论讲完,说几个真实踩过的坑。
7.1 问题一:发送完成后服务端读不到完整数据
现象:客户端dos.flush()之后马上socket.close(),服务端却总是收到不完整文件。
原因:TCP是流式协议,flush()只保证数据从应用层送到内核缓冲区,不代表对端已经全部收到。客户端关闭Socket时,如果还有数据在内核缓冲区未发送完,正常关闭会先把缓冲数据发送完再发FIN包,理论上没问题。但问题是,如果客户端用socket.setSoLinger(true, 0)设置了强关闭,那关闭时缓冲区的数据会被直接丢弃,导致对端收不全。
排查路径:检查是否调用了setSoLinger,检查服务端的接收循环里是否用了read()返回-1作为结束条件——如果客户端没有正确半关闭(shutdownOutput()),而是直接close(),服务端的 read 依然能正常获得文件尾部数据,但如果在文件大小还没读够的情况下连接就关闭,那就是上面的问题。
解决方式:客户端发送完毕后调用socket.shutdownOutput()明确关闭输出方向,而不是直接close(),这样服务端可以正常感知流结束。
7.2 问题二:服务端用read(byte[])读文件体时出现半包
我最初的服务端接收代码是:
byte[] buffer = new byte[8192]; int len = dis.read(buffer); while (len != -1) { fos.write(buffer, 0, len); len = dis.read(buffer); }看起来没错,但坑在:如果客户端在发送完文件之后还发送了其他数据(比如应答消息),服务端的read可能一次性把文件数据和应答数据都读出来,导致写入了多余的数据。
排查路径:通过日志打印每次读到的字节长度,发现文件大小不一致。最后确定根因是“消息边界”问题。
解决方式:预先知道文件总大小,接收循环里只读到fileSize字节数就停下,不依赖流结束标记。这正是协议头里定义文件大小字段的意义。
7.3 问题三:大文件传输导致的内存抖动
现象:传输1GB文件过程中,JVM堆内存使用率曲线出现锯齿状,GC频繁。
原因:如果使用byte[] buffer = new byte[(int) fileSize]这种一次性分配大数组的写法,堆内存会被瞬间占满。即使后来改成大循环读写,但buffer太大(比如16MB)也会让Eden区频繁触发Minor GC。
解决方式:把buffer控制在合理范围(8KB ~ 64KB),避免在堆上每次分配大块数组。如果一定要优化性能,可以把buffer数组声明为类成员,避免每次都new一个新的数组。
7.4 问题四:多个客户端并发传输时服务端Socket阻塞
现象:两个客户端同时上传,第一个上传慢,第二个一直卡住。
原因:服务端如果用的是单线程处理所有连接,第一个连接就阻塞了后面的连接。所以多线程模型是必需的。
排查路径:可通过jstack查看线程栈,确认服务端是否只有一个线程在accept和read之间切换。
解决方式:使用线程池,每个连接独立线程。更细的优化是为文件传输单独维护连接池,控制最大并发上传数,防止线程数无限增长打满CPU。
8. 我对这套方案的后续扩展建议
我觉得这套原生实现的价值不在代码量,而在于它把Socket编程的核心要点全部串起来了:协议设计、流边界控制、并发模型、缓冲优化。把这些点吃透,之后学Netty、Mina之类的框架就轻松很多。
如果后续有更多需求,可以在已有协议的基础上扩展几个功能:
- 在协议头的保留字段里加入MD5校验值,接收完成后校验,保证数据完整性。
- 在消息类型里增加
0x04心跳包,实现连接保活、超时断开、自动重连。 - 用AES加密文件数据,密钥通过请求头传递,实现传输加密。
这套代码我实测在JDK 8和JDK 17上都运行正常。如果你也打算自己写一套,我建议先跑通基础传输,再逐步加功能,每一步都清楚为什么这样设计。最后记得设置合理的Socket超时时间——我一开始没设,结果客户端断了连接,服务端线程挂了整整10分钟才反应过来。
本文还有配套的精品资源,点击获取