☰
基于 Netty4.1 的文件分片发送与断点续传实战(CodeGuide Netty 中级拓展篇四)
2026/9/25 1:31:42 网站建设 项目流程
  • 文档
  • 教程
  • 后端

【免费下载链接】CodeGuide

:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!

项目地址:https://gitcode.com/gh_mirrors/code/CodeGuide
点击查看免费下载

本文以 CodeGuide 开源仓库中《Netty 传输文件、分片发送、断点续传》专题文档为核心,完整讲解如何用 Netty4.1 +RandomAccessFile+ protostuff 实现大文件的高效分片传输与断点续传。读完你将掌握一套可直接运行的三段式文件传输协议(请求 / 指令 / 数据),理解分片读取时尾部空字节的处理原理,以及服务端如何记录与恢复传输位点。本文基于 docs/md/netty/expand/2019-08-19-netty案例,netty4.1中级拓展篇四《Netty传输文件、分片发送、断点续传》.md 展开,配套工程为itstack-demo-netty-2-04。

一、为什么需要分片发送与断点续传

在实际应用中,我们经常使用网盘服务,它们可以高效地上传下载较大文件。这些高性能文件传输服务,都需要实现两个基础能力:

  1. 分片发送:把大文件按固定大小切成若干数据块逐片传输,避免一次性把整个文件塞进内存或单帧报文,降低内存占用和单次传输失败的影响面;
  2. 断点续传:传输过程中连接意外中断后,再次传输时无需从头开始,而是从上次记录的读取位点继续传输剩余分片。

在 Java 文件操作中,RandomAccessFile类可以支持文件的定位读取和写入(seek移动文件记录指针),这就满足了我们对文件分片的最基础需求——按任意偏移量读取与写入。

本篇案例的整体协作模式是:

  • Netty 服务端启动后,可以向客户端发送文件传输指令:允许接收文件、控制读取位点、记录传输标记、文件接收完成;
  • 为了保证传输性能,采用protostuff 二进制流进行传输(与直接使用 protobuf 不同,不需要定义 proto 文件,而是对 POJO 直接序列化);
  • 读取文件时设定byte[1024]为默认读取范围,但当读取到最后时可能不足 1024 个字节,就会出现空字节。此时必须去掉空字节,否则文件写入会带上额外信息,导致文件不能打开(zip、war、exe、jar 等二进制格式对多余字节极其敏感)。

二、开发环境与工程结构

开发环境

环境说明
JDKjdk1.8(jdk1.7 以下只能部分支持 netty)
NettyNetty4.1.36.Final(netty3.x、4.x、5.x 每次的变化较大,接口类名也随之变化)

工程结构(itstack-demo-netty-2-04):

itstack-demo-netty-2-04 └── src ├── main │ └── java │ └── org.itstack.demo.netty │ ├── client │ │ ├── MyChannelInitializer.java │ │ ├── MyClientHandler.java │ │ └── NettyClient.java │ ├── codec │ │ ├── ObjDecoder.java │ │ └── ObjEncoder.java │ ├── domain │ │ ├── Constants.java │ │ ├── FileBurstData.java │ │ ├── FileBurstInstruct.java │ │ ├── FileDescInfo.java │ │ └── FileTransferProtocol.java │ ├── server │ │ ├── MyChannelInitializer.java │ │ ├── MyServerHandler.java │ │ └── NettyServer.java │ └── util │ ├── CacheUtil.java │ ├── FileUtil.java │ ├── MsgUtil.java │ └── SerializationUtil.java │ └── test └── java └── org.itstack.demo.test ├── ApiTest.java ├── NettyClientTest.java └── NettyServerTest.java

结构职责划分清晰:domain定义传输协议对象,codec负责二进制编解码,util提供文件读写、消息构建与序列化工具,client/server分别承载客户端与服务端通道逻辑。其中MyChannelInitializer、NettyClient、NettyServer、ObjDecoder、ObjEncoder、SerializationUtil的实现方式与本系列《Netty 传输 Java 对象》(2019-08-18 中级拓展篇三)一致,服务端 / 客户端启动器的基础写法可参考 基础入门篇一《嗨!NettyServer》。

三、传输协议设计:三种消息类型与四个领域对象

文件传输是一个"你来我往"的交互过程,需要一套明确的协议约定。本案例用FileTransferProtocol作为统一消息载体,通过transferType区分三种消息:

transferType含义transferObj 承载对象
0传输文件"请求"(客户端 → 服务端)FileDescInfo(文件描述信息)
1文件传输"指令"(服务端 → 客户端)FileBurstInstruct(分片指令)
2文件传输"数据"(客户端 → 服务端)FileBurstData(分片数据块)

该枚举语义在Constants.TransferType中定义,从MsgUtil中Constants.TransferType.INSTRUCT、Constants.TransferType.DATA的调用可以推断其常量取值即 0、1、2。

FileTransferProtocol.java —— 文件传输协议(统一消息外壳)

public class FileTransferProtocol { private Integer transferType; //0请求传输文件、1文件传输指令、2文件传输数据 private Object transferObj; //数据对象;(0)FileDescInfo、(1)FileBurstInstruct、(2)FileBurstData ... get/set }

FileDescInfo.java —— 文件传输信息(请求阶段)

public class FileDescInfo { private String fileUrl; private String fileName; private Long fileSize; ... get/set }

FileBurstInstruct.java —— 文件分片指令(服务端下发)

public class FileBurstInstruct { private Integer status; //Constants.FileStatus {0开始、1中间、2结尾、3完成} private String clientFileUrl; //客户端文件URL private Integer readPosition; //读取位置 ... get/set }

FileBurstData.java —— 文件分片数据块(客户端回传)

public class FileBurstData { private String fileUrl; //客户端文件地址 private String fileName; //文件名称 private Integer beginPos; //开始位置 private Integer endPos; //结束位置 private byte[] bytes; //文件字节;再实际应用中可以使用非对称加密,以保证传输信息安全 private Integer status; //Constants.FileStatus {0开始、1中间、2结尾、3完成} ... get/set }

分片状态由Constants.FileStatus定义:0 开始(BEGIN)、1 中间(CENTER)、2 结尾(END)、3 完成(COMPLETE)。从客户端与服务端 handler 中的Constants.FileStatus.BEGIN、CENTER、END、COMPLETE调用可以确认这组状态常量。其中FileBurstData的bytes字段承载真实分片内容,原文档注释也特别提醒:实际生产应用中可以使用非对称加密,以保证传输信息安全。

四、基于 protostuff 的二进制编解码

为了传输性能,本案例没有使用 Java 原生序列化,而是采用protostuff 二进制流。protostuff 基于 Google protobuf,但提供了更简易的用法——protostuff-runtime无需预编译、无需定义 proto 文件,即可对 Java Bean 直接进行 protobuf 序列化 / 反序列化(详见本系列 中级拓展篇三《Netty 传输 Java 对象》)。

工程中的codec/ObjDecoder.java与codec/ObjEncoder.java就是这一机制的落地:4 字节长度头 + 序列化数据,解码侧先读取int长度,不足则等待,满足后按长度切出完整字节流再反序列化为目标对象,天然解决了半包 / 粘包问题:

public class ObjDecoder extends ByteToMessageDecoder { private Class<?> genericClass; public ObjDecoder(Class<?> genericClass) { this.genericClass = genericClass; } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { if (in.readableBytes() < 4) { return; } in.markReaderIndex(); int dataLength = in.readInt(); if (in.readableBytes() < dataLength) { in.resetReaderIndex(); return; } byte[] data = new byte[dataLength]; in.readBytes(data); out.add(SerializationUtil.deserialize(data, genericClass)); } }
public class ObjEncoder extends MessageToByteEncoder { private Class<?> genericClass; public ObjEncoder(Class<?> genericClass) { this.genericClass = genericClass; } @Override protected void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) { if (genericClass.isInstance(in)) { byte[] data = SerializationUtil.serialize(in); out.writeInt(data.length); out.writeBytes(data); } } }

SerializationUtil的核心在于:用ConcurrentHashMap缓存每个类的RuntimeSchema,通过Objenesis无参构造创建反序列化实例,再调用ProtostuffIOUtil.toByteArray / mergeFrom完成转换。注意:protostuff-runtime 反序列化不负责对象的创建只负责复制,因此目标类必须提供默认构造函数;序列化前需预先传入 schema。

编解码器接入管道的位置在MyChannelInitializer(客户端与服务端各自一份),与业务 handler 串联:

protected void initChannel(SocketChannel channel) throws Exception { //对象传输处理 channel.pipeline().addLast(new ObjDecoder(FileTransferProtocol.class)); channel.pipeline().addLast(new ObjEncoder(FileTransferProtocol.class)); // 在管道中添加我们自己的接收数据实现方法 channel.pipeline().addLast(new MyClientHandler()); }

五、客户端:请求发起、分片读取与模拟断点

5.1 客户端启动器 NettyClientTest

客户端启动后连接本机7397端口,构造一个大于 1024KB的文件请求(文件过小无法体现出多分片与断点续传的效果,原文注释即为"文件大于 1024kb 方便测试断点续传"):

public class NettyClientTest { public static void main(String[] args) { //启动客户端 ChannelFuture channelFuture = new NettyClient().connect("127.0.0.1", 7397); //文件信息{文件大于1024kb方便测试断点续传} File file = new File("C:\\Users\\fuzhengwei\\Desktop\\测试传输文件.rar"); FileTransferProtocol fileTransferProtocol = MsgUtil.buildRequestTransferFile(file.getAbsolutePath(), file.getName(), file.length()); //发送信息;请求传输文件 channelFuture.channel().writeAndFlush(fileTransferProtocol); } }

5.2 客户端消息处理 MyClientHandler

MyClientHandler.channelRead是客户端核心逻辑:收到服务端下发的**文件传输指令(类型 1)**后,解析出FileBurstInstruct,按指令中的readPosition调用FileUtil.readFile读取对应分片,再通过MsgUtil.buildTransferData包装成数据消息发回服务端。

@Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //数据格式验证 if (!(msg instanceof FileTransferProtocol)) return; FileTransferProtocol fileTransferProtocol = (FileTransferProtocol) msg; //0传输文件'请求'、1文件传输'指令'、2文件传输'数据' switch (fileTransferProtocol.getTransferType()) { case 1: FileBurstInstruct fileBurstInstruct = (FileBurstInstruct) fileTransferProtocol.getTransferObj(); //Constants.FileStatus {0开始、1中间、2结尾、3完成} if (Constants.FileStatus.COMPLETE == fileBurstInstruct.getStatus()) { ctx.flush(); ctx.close(); System.exit(-1); return; } FileBurstData fileBurstData = FileUtil.readFile(fileBurstInstruct.getClientFileUrl(), fileBurstInstruct.getReadPosition()); ctx.writeAndFlush(MsgUtil.buildTransferData(fileBurstData)); System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " bugstack虫洞栈客户端传输文件信息。 FILE:" + fileBurstData.getFileName() + " SIZE(byte):" + (fileBurstData.getEndPos() - fileBurstData.getBeginPos())); break; default: break; } /**模拟传输过程中断,场景测试可以注释掉 * */ System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " bugstack虫洞栈客户端传输文件信息[主动断开链接,模拟断点续传]"); ctx.flush(); ctx.close(); System.exit(-1); }

这里有三个关键细节:

  1. 状态判断前置:当指令状态为COMPLETE(3)时,意味着文件已经传完,客户端刷新并关闭通道、结束进程;
  2. 指令驱动读取:客户端本身不决定"读哪一段",而是完全听从服务端指令中的readPosition,这为断点续传提供了基础——中断后服务端只需把记录的位点再次下发;
  3. 模拟断点:代码末尾的ctx.close()与System.exit(-1)是"主动断开链接,模拟断点续传"的演示代码,场景测试时可以注释掉,即可正常跑完整个传输流程。正是这段代码,让读者可以直观看到"传一半断开、重连后从断点继续"的效果。

六、服务端:断点记录、指令下发与分片落盘

6.1 服务端启动器 NettyServerTest

public class NettyServerTest { public static void main(String[] args) { //启动服务 new NettyServer().bing(7397); } }

服务端监听7397端口,默认接收地址为E 盘根目录(写入基地址"E://"硬编码在MyServerHandler中,可自行修改)。

6.2 服务端消息处理 MyServerHandler

服务端channelRead处理两类消息:文件传输请求(0)和文件传输数据(2),并在过程中维护断点续传信息:

@Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //数据格式验证 if (!(msg instanceof FileTransferProtocol)) return; FileTransferProtocol fileTransferProtocol = (FileTransferProtocol) msg; //0传输文件'请求'、1文件传输'指令'、2文件传输'数据' switch (fileTransferProtocol.getTransferType()) { case 0: FileDescInfo fileDescInfo = (FileDescInfo) fileTransferProtocol.getTransferObj(); //断点续传信息,实际应用中需要将断点续传信息保存到数据库中 FileBurstInstruct fileBurstInstructOld = CacheUtil.burstDataMap.get(fileDescInfo.getFileName()); if (null != fileBurstInstructOld) { if (fileBurstInstructOld.getStatus() == Constants.FileStatus.COMPLETE) { CacheUtil.burstDataMap.remove(fileDescInfo.getFileName()); } //传输完成删除断点信息 System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " bugstack虫洞栈服务端,接收客户端传输文件请求[断点续传]。" + JSON.toJSONString(fileBurstInstructOld)); ctx.writeAndFlush(MsgUtil.buildTransferInstruct(fileBurstInstructOld)); return; } //发送信息 FileTransferProtocol sendFileTransferProtocol = MsgUtil.buildTransferInstruct(Constants.FileStatus.BEGIN, fileDescInfo.getFileUrl(), 0); ctx.writeAndFlush(sendFileTransferProtocol); System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " bugstack虫洞栈服务端,接收客户端传输文件请求。" + JSON.toJSONString(fileDescInfo)); break; case 2: FileBurstData fileBurstData = (FileBurstData) fileTransferProtocol.getTransferObj(); FileBurstInstruct fileBurstInstruct = FileUtil.writeFile("E://", fileBurstData); //保存断点续传信息 CacheUtil.burstDataMap.put(fileBurstData.getFileName(), fileBurstInstruct); ctx.writeAndFlush(MsgUtil.buildTransferInstruct(fileBurstInstruct)); System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " bugstack虫洞栈服务端,接收客户端传输文件数据。" + JSON.toJSONString(fileBurstData)); //传输完成删除断点信息 if (fileBurstInstruct.getStatus() == Constants.FileStatus.COMPLETE) { CacheUtil.burstDataMap.remove(fileBurstData.getFileName()); } break; default: break; } }

请求阶段(case 0)的断点判断逻辑:

  1. 以fileName为 key 从CacheUtil.burstDataMap中查找历史断点指令;
  2. 若命中:若状态是COMPLETE说明上次已传完,直接移除断点记录;否则把上次保存的FileBurstInstruct原样回传给客户端,客户端会从记录的readPosition继续读取——这就是断点续传的入口;
  3. 若未命中:说明是全新传输,下发BEGIN(0)指令,readPosition置为 0,让客户端从文件头开始。

数据阶段(case 2)的落盘与应答逻辑:

  1. 调用FileUtil.writeFile("E://", fileBurstData)把分片写入本地文件,并得到一条新的传输指令(携带下一个读取位点);
  2. 将指令存入CacheUtil.burstDataMap作为断点记录(原文档注释明确说明:实际应用中需要将断点续传信息保存到数据库中,本案例用内存 Map 演示);
  3. 回传指令给客户端,驱动它读取下一片;
  4. 若返回指令状态为COMPLETE,说明服务端已收到结尾分片并完成文件拼接,随即删除断点信息。

七、FileUtil:RandomAccessFile 分片读写与空字节处理

FileUtil是整个文件传输的"搬运工",两个静态方法分别负责分片读取与分片写入,是理解本篇技术点的重中之重。

7.1 readFile:按位点分片读取

public class FileUtil { public static FileBurstData readFile(String fileUrl, Integer readPosition) throws IOException { File file = new File(fileUrl); RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");//r: 只读模式 rw:读写模式 randomAccessFile.seek(readPosition); byte[] bytes = new byte[1024]; int readSize = randomAccessFile.read(bytes); if (readSize <= 0) { randomAccessFile.close(); return new FileBurstData(Constants.FileStatus.COMPLETE);//Constants.FileStatus {0开始、1中间、2结尾、3完成} } FileBurstData fileInfo = new FileBurstData(); fileInfo.setFileUrl(fileUrl); fileInfo.setFileName(file.getName()); fileInfo.setBeginPos(readPosition); fileInfo.setEndPos(readPosition + readSize); //不足1024需要拷贝去掉空字节 if (readSize < 1024) { byte[] copy = new byte[readSize]; System.arraycopy(bytes, 0, copy, 0, readSize); fileInfo.setBytes(copy); fileInfo.setStatus(Constants.FileStatus.END); } else { fileInfo.setBytes(bytes); fileInfo.setStatus(Constants.FileStatus.CENTER); } randomAccessFile.close(); return fileInfo; } public static FileBurstInstruct writeFile(String baseUrl, FileBurstData fileBurstData) throws IOException { if (Constants.FileStatus.COMPLETE == fileBurstData.getStatus()) { return new FileBurstInstruct(Constants.FileStatus.COMPLETE); //Constants.FileStatus {0开始、1中间、2结尾、3完成} } File file = new File(baseUrl + "/" + fileBurstData.getFileName()); RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");//r: 只读模式 rw:读写模式 randomAccessFile.seek(fileBurstData.getBeginPos()); //移动文件记录指针的位置, randomAccessFile.write(fileBurstData.getBytes()); //调用了seek(start)方法,是指把文件的记录指针定位到start字节的位置。也就是说程序将从start字节开始写数据 randomAccessFile.close(); if (Constants.FileStatus.END == fileBurstData.getStatus()) { return new FileBurstInstruct(Constants.FileStatus.COMPLETE); //Constants.FileStatus {0开始、1中间、2结尾、3完成} } //文件分片传输指令 FileBurstInstruct fileBurstInstruct = new FileBurstInstruct(); fileBurstInstruct.setStatus(Constants.FileStatus.CENTER); //Constants.FileStatus {0开始、1中间、2结尾、3完成} fileBurstInstruct.setClientFileUrl(fileBurstData.getFileUrl()); //客户端文件URL fileBurstInstruct.setReadPosition(fileBurstData.getEndPos() + 1); //读取位置 return fileBurstInstruct; } }

读取侧核心逻辑:

  • RandomAccessFile(file, "r")以只读模式打开文件,seek(readPosition)把文件指针定位到指定偏移;
  • 每次最多读 1024 字节,readSize为实际读到的字节数;
  • readSize <= 0表示已读到文件末尾,返回COMPLETE状态的分片(服务端据此判定传输完成);
  • readSize < 1024时是最后一片:new byte[1024]的缓冲区中后段是无效空字节,必须用System.arraycopy拷贝出恰好readSize长度的新数组再放入分片,并将状态置为END(2);
  • 恰好读满 1024 字节则属于中间分片,状态为CENTER(1);
  • beginPos是本次读取起点,endPos = readPosition + readSize是本次读取终点,二者共同构成分片在源文件中的精确区间。

写入侧核心逻辑:

  • RandomAccessFile(file, "rw")以读写模式打开目标文件(不存在会自动创建),seek(beginPos)定位到本次分片应在的偏移,write(bytes)将分片内容写入——写入位置与读取位置一一对应,多片数据互不覆盖,这正是分片拼接的底层保证;
  • 收到END状态的分片后返回COMPLETE指令,通知客户端整个文件传输结束;
  • 中间分片则返回CENTER指令,且readPosition = endPos + 1,即告诉客户端"下一片从这里开始读"。

注意endPos + 1这个细节:readFile中endPos = readPosition + readSize描述的是"已读区间的终点",而writeFile回传的readPosition表示"下一片读取起点",二者相差 1 个字节的游标语义。这是 RandomAccessFile 字节定位方式下最容易混淆的地方,也是断点续传能否精确衔接的关键。

空字节问题再强调:如果不做"去掉空字节"处理,最后一片会把byte[1024]中未填充的(1024 - readSize)个\0一并写入文件。对于 zip、war、exe、jar 等有严格结构约束的二进制格式,这些多余字节会导致文件无法打开。这正是原文档开篇专门提醒的坑。

八、MsgUtil:传输消息构建工具

MsgUtil负责把领域对象包装成FileTransferProtocol,四个静态方法对应协议的三类消息:

public class MsgUtil { /** * 构建对象;请求传输文件(客户端) * * @param fileUrl 客户端文件地址 * @param fileName 文件名称 * @param fileSize 文件大小 * @return 传输协议 */ public static FileTransferProtocol buildRequestTransferFile(String fileUrl, String fileName, Long fileSize) { FileDescInfo fileDescInfo = new FileDescInfo(); fileDescInfo.setFileUrl(fileUrl); fileDescInfo.setFileName(fileName); fileDescInfo.setFileSize(fileSize); FileTransferProtocol fileTransferProtocol = new FileTransferProtocol(); fileTransferProtocol.setTransferType(0);//0请求传输文件、1文件传输指令、2文件传输数据 fileTransferProtocol.setTransferObj(fileDescInfo); return fileTransferProtocol; } /** * 构建对象;文件传输指令(服务端) * @param status 0请求传输文件、1文件传输指令、2文件传输数据 * @param clientFileUrl 客户端文件地址 * @param readPosition 读取位置 * @return 传输协议 */ public static FileTransferProtocol buildTransferInstruct(Integer status, String clientFileUrl, Integer readPosition) { FileBurstInstruct fileBurstInstruct = new FileBurstInstruct(); fileBurstInstruct.setStatus(status); fileBurstInstruct.setClientFileUrl(clientFileUrl); fileBurstInstruct.setReadPosition(readPosition); FileTransferProtocol fileTransferProtocol = new FileTransferProtocol(); fileTransferProtocol.setTransferType(Constants.TransferType.INSTRUCT); //0传输文件'请求'、1文件传输'指令'、2文件传输'数据' fileTransferProtocol.setTransferObj(fileBurstInstruct); return fileTransferProtocol; } /** * 构建对象;文件传输指令(服务端) * * @return 传输协议 */ public static FileTransferProtocol buildTransferInstruct(FileBurstInstruct fileBurstInstruct) { FileTransferProtocol fileTransferProtocol = new FileTransferProtocol(); fileTransferProtocol.setTransferType(Constants.TransferType.INSTRUCT); //0传输文件'请求'、1文件传输'指令'、2文件传输'数据' fileTransferProtocol.setTransferObj(fileBurstInstruct); return fileTransferProtocol; } /** * 构建对象;文件传输数据(客户端) * * @return 传输协议 */ public static FileTransferProtocol buildTransferData(FileBurstData fileBurstData) { FileTransferProtocol fileTransferProtocol = new FileTransferProtocol(); fileTransferProtocol.setTransferType(Constants.TransferType.DATA); //0传输文件'请求'、1文件传输'指令'、2文件传输'数据' fileTransferProtocol.setTransferObj(fileBurstData); return fileTransferProtocol; } }

方法对照表:

方法消息类型使用方场景
buildRequestTransferFile0 请求客户端告知服务端要传哪个文件(地址、名称、大小)
buildTransferInstruct(status, url, pos)1 指令服务端首次传输下发BEGIN+ 位点 0
buildTransferInstruct(FileBurstInstruct)1 指令服务端断点续传时回传历史指令、传输中回传下一片位点
buildTransferData2 数据客户端回传读到的分片数据块

九、完整传输时序与断点续传流程

将上述各环节串联起来,一次完整传输的时序如下:

  1. 请求:客户端NettyClientTest构造FileDescInfo(类型 0)发送给服务端;
  2. 首指令:服务端case 0查询CacheUtil.burstDataMap,无断点记录则回传BEGIN(0)+readPosition=0指令(类型 1);
  3. 读片:客户端收到指令,FileUtil.readFile(url, 0)读取第一片(1024 字节,状态CENTER),buildTransferData包装后回传(类型 2);
  4. 落盘:服务端case 2调用FileUtil.writeFile("E://", data)按beginPos写入,保存断点指令(readPosition = endPos + 1),回传新指令;
  5. 循环:重复 3、4 步,直到客户端读到不足 1024 字节的最后一片(状态END),服务端写入后返回COMPLETE(3)指令;
  6. 收尾:客户端收到COMPLETE指令后刷新、关闭通道并退出;服务端从断点 Map 中移除该文件的记录。

断点续传流程(中断场景):

  1. 传输进行到某一片后,客户端执行模拟断点代码(ctx.close()+System.exit(-1)),连接断开;
  2. 客户端重启、重新发起类型 0 请求,文件信息不变;
  3. 服务端case 0命中burstDataMap中残留的断点指令(例如记录到第 2 片、readPosition=1025),直接把该指令原样回传;
  4. 客户端按readPosition从断点处继续读取剩余分片,无需从头传输;
  5. 全部完成后,服务端删除断点记录。

十、测试结果验证

服务端执行结果(默认接收地址 E 盘根目录):

itstack-demo-netty server start done. 链接报告开始 链接报告信息:有一客户端链接到本服务端。channelId:3a1df8c1 链接报告IP:127.0.0.1 链接报告Port:7397 链接报告完毕 2019-08-04 19:46:46 bugstack虫洞栈服务端,接收客户端传输文件请求。{"fileName":"测试传输文件.rar","fileSize":1400,"fileUrl":"C:\\Users\\fuzhengwei1\\Desktop\\测试传输文件.rar"} 2019-08-04 19:46:46 bugstack虫洞栈服务端,接收客户端传输文件数据。{"beginPos":0,"bytes":"UmFyIRoHAM+QcwAADQAAAAAAAAC4C3SgkkkAFAUAAIjDEQACJRsHe0WECE8dMyQAIAAAALLiytS0q8rkzsS8/i50eHQAbWpL1YsgT5OPoIdl9k4udAB4dACwS2heCZgVEQzPzUEXfAnhs2R75rhNbCQhNE3uMY4EBkBqQJ45izS4lFGujEk08xLGuhp4sSUSbzEscRICakGyOdARhIE6GEPucySJpY5kQ/Cq28ur4XdfH/j1V8UVoo5X5V+B3dl2f8qvvoxd3t6GPv8HZ7dXs+98XT6uJ0Oj8GZ4c6tvV6vzV865ka375utod+9i+pX/O1Uu1tT76tT38TE+Hq+tzud6Of9Xo9T0/S/xytLm12v4NWztfhnda3lbevs7dnXsWL1vT3Kte91triqYuHW/9bf3WPnjq5r5savbHd67V8Nu6r5+lmZtrP0eO63Ba+upVuWtf7bvByg2/0w+5hz8ru14ND5ex/Odw4F7uRWrYedwU2tXmw5m1u+S7lWdjvTq5e/Kv+1apZxrdjT2dHizdHDrlrH1cvkbrWe5k97u6WRnXdviw6zkvc3cD9TOt7+7W9z2/Ys+Sx9VTPwGeYLmrz+h8fQt5u5v8/3fZ5sXKnc4MOT1+n0upicmOVDT86GfY4bPf5vN7XSxMT5Mnsdry8e84///+quHp9l19fRz8vkds+O7qb+9pWe1WvXdb7NWza3vNO3V3cZZ2rPDr1svHwO3Nq14sBuZu3P1zOvuWP++s8ex9O95e5U/vW9/F7Jtb+p+PGeRtzlg8VfG5t5TyAAAAAAAAAAAAAAAARu1PHU9QX3wAAAAAAAAAAAAAAAiiXyXwAAAAAAAAAAAAAAAiiXyXwAAAAAAAAAAAAAAAiiXyXwAAAAAAAAAAAAAAAiiXyXwAEbDTyAAAAAAAAAAAAAEctTx1PUF98AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAABHx08gAAAAAAAAAAj108dT1BffAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAgankAAAAAAAEZGnjqeoL74AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAEYinkAAAAEH08dT1BffAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAA==","endPos":1024,"fileName":"测试传输文件.rar","fileUrl":"C:\\Users\\fuzhengwei1\\Desktop\\测试传输文件.rar","status":1} 2019-08-04 19:46:46 bugstack虫洞栈服务端,接收客户端传输文件数据。{"beginPos":1025,"bytes":"AAI8VPIAEfZTx1PUF98AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8AAAAAAAAAAAAAAAIol8l8R+dPIAAAAAAAAAAAAAAAjt08dT1BffAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAAAAAAAAAAAAACKJfJfAAAIWp5AAAAAAAAAAAAEYGnjqeoL74AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAAAAAAAAAAAARRL5L4AAAAABGYp5AAAAAAAAAEc/Tx1PUF98AAAAAAAAIyFPIAAAAAACB6eOp6gvvgAAAAAAAAAARiKeQAAAAQfTx1PUF98AAAAAAAAAAAAAjAU8gACF6eOp6gvvgAAAAAAAAAAAAAABFH9IDEPXsAQAcA","endPos":1400,"fileName":"测试传输文件.rar","fileUrl":"C:\\Users\\fuzhengwei1\\Desktop\\测试传输文件.rar","status":2} 客户端断开链接/127.0.0.1:7397 Process finished with exit code -1

客户端执行结果:

itstack-demo-netty client start done. 链接报告开始 链接报告信息:本客户端链接到服务端。channelId:71399d8c 链接报告IP:127.0.0.1 链接报告Port:54974 链接报告完毕 2019-08-04 19:46:46 bugstack虫洞栈客户端传输文件信息。 FILE:测试传输文件.rar SIZE(byte):1024 2019-08-04 19:46:46 bugstack虫洞栈客户端传输文件信息。 FILE:测试传输文件.rar SIZE(byte):375 Process finished with exit code -1

从日志可以清晰验证分片逻辑:一个 1400 字节的文件被切成2 片——第一片beginPos=0、endPos=1024、状态 1(中间分片);第二片beginPos=1025、endPos=1400、状态 2(结尾分片,实际只有 375 字节)。服务端先收到请求(类型 0),随后按序收到两片数据(类型 2),说明客户端严格遵循了"指令 → 读片 → 回传"的驱动模式。末尾的exit code -1与"客户端断开链接"日志,正是模拟断点代码触发的结果。

十一、易错点与工程化建议

综合本篇实现,梳理出以下几个值得注意的要点:

  1. 空字节处理是硬性要求:最后一片必须System.arraycopy去掉byte[1024]缓冲区的空字节,否则 zip、war、exe、jar 等二进制文件会因多余字节而损坏无法打开;
  2. 位点语义要理清:readFile的endPos是"已读终点",writeFile回传的readPosition = endPos + 1是"下一片起点",两个字段差 1,切勿混淆;
  3. 默认构造函数是前提:protostuff-runtime 反序列化只负责复制不负责创建对象,所有参与传输的 POJO(FileDescInfo、FileBurstInstruct、FileBurstData、FileTransferProtocol)必须提供无参构造;
  4. 断点信息应持久化:本案例用CacheUtil.burstDataMap内存 Map 演示断点记录,进程重启即丢失;原文档明确指出实际应用需要将断点续传信息保存到数据库;
  5. 传输安全:FileBurstData.bytes明文传输,原文档建议在真实业务中可对分片做非对称加密,保证传输信息安全;
  6. 数据完整性校验:本案例未涉及分片校验,工程落地时建议在协议中补充每片 MD5/SHA 摘要或整体文件校验值,防止传输损坏。

本篇文章对应的完整文档与代码讲解位于 docs/md/netty/expand/2019-08-19-netty案例,netty4.1中级拓展篇四《Netty传输文件、分片发送、断点续传》.md,其在 CodeGuide 仓库 Netty 专题(见 README)中的位置为中级拓展篇四。前置知识可继续阅读本系列 中级拓展篇二《Netty 使用 Protobuf 传输数据》 与 中级拓展篇三《Netty 传输 Java 对象》,后续的 中级拓展篇十一《Netty 基于 ChunkedStream 数据流切块传输》 则提供了另一种流式切块方案,可作为对比学习。

  • 文档
  • 教程
  • 后端

【免费下载链接】CodeGuide

:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!

项目地址:https://gitcode.com/gh_mirrors/code/CodeGuide
点击查看免费下载

相关推荐

上一篇:智能决策×效率提升:OpCore-Simplify的黑苹果配置自动化方案
下一篇:终极指南:如何用OpenCode插件系统打造你的专属AI编程助手

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询