通过gRPC传输后重建ZipArchive无条目,报错条目数不符
问题
通过gRPC连接重建ZipArchive时出现异常:重建ZipArchive成功但无条目,报错Error executing Write request: Number of entries expected in End Of Central Directory does not correspond to number of entries in Central Directory。排查确认:
- 中心目录预期1个文件但实际为0
- 收发两端字节数一致,Zip头符合规范
流程说明:发送端从第三方服务获取数据,生成CSV文件放入ZipArchive,基于内存流分块发送(应对大数据量),关闭Zip后发送EOCD字节;接收端将收到的字符串转为字节数组合并后重建ZipArchive。
发送端代码
// just call for destination using var call = await _destinationService.InitCall(); using (var stream = new MemoryStream()) { using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true)) { var entry = zip.CreateEntry($"{Model.FileName}.{Model.Format}"); using var writer = new StreamWriter(entry.Open(), Model.Encoding); var csvConfiguration = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = Model.Delimiter }; using var csv = new CsvWriter(writer, csvConfiguration); var columns = await _exportService.GetAndWriteColumns(csv, firstChunk, Model, cancellationToken); // initalChunk to memoryStream await _exportService.PerformExport(); while (_substreamData.TryDequeue(out var data) || !_isSubstreamEof) { // next chunks await _exportService.PerformExport(); } } // sending eocd bytes after closing zip await _writeDataService.WriteDataPartAsync(call, stream, cancellationToken); await call.RequestStream.CompleteAsync(); } // PerformingExport public async Task PerformExport(CsvWriter csv, StreamWriter writer) { // generating csv, getting data from third party services await csv.FlushAsync(); await writer.FlushAsync(); await WriteDataPartAsync(call, stream, cancellationToken); stream.Clear(); } public async Task WriteDataPartAsync( AsyncDuplexStreamingCall<WriteData, WriteResponse> call, Stream stream, CancellationToken cancellationToken) { stream.Seek(0, SeekOrigin.Begin); using (var streamReader = new StreamReader(stream, leaveOpen:true)) { var streamString = await streamReader.ReadToEndAsync(cancellationToken); await call.RequestStream.WriteAsync( new WriteData { DataPart = new WriteChunk { Data = streamString } }, cancellationToken); } } public static void Clear(this MemoryStream source) { var buffer = source.GetBuffer(); Array.Clear(buffer, 0, buffer.Length); source.Position = 0; source.SetLength(0); }
接收端代码
var bytes = new List<byte[]>(); while (await requestStream.MoveNext(context.CancellationToken)) { try { var data = Encoding.UTF8.GetBytes(requestStream.Current.DataPart.Data); bytes.Add(data); } catch (Exception ex) { _logger.LogError($"Error executing Write request: {ex.Message}"); errors.Add(ex.Message); } } var stream1 = new MemoryStream(bytes.SelectMany(x => x).ToArray()); using var zipArchiveSubexport = new ZipArchive(stream1); // success var t = zipArchiveSubexport.Entries.First(); // error because of number if entries
问题分析与解决方案
核心错误点
内存流清空操作破坏Zip结构
发送端在PerformExport中每次发送后调用stream.Clear(),直接清空了ZipArchive绑定的内存流缓冲区。而ZipArchive在创建模式下会持续向该流写入条目内容和后续的中心目录,清空操作会删除之前写入的所有条目数据,导致最终发送的只有最后一次分块加EOCD,缺失了完整的Zip结构。二进制转字符串导致字节损坏
Zip是二进制格式,发送端用StreamReader读取二进制流并转成UTF8字符串,接收端再转回字节数组,这个过程会破坏二进制数据的完整性(部分字节无法被UTF8正确编码/解码),直接导致Zip结构损坏。分块逻辑与EOCD发送时机混乱
ZipArchive只有在Dispose时才会写入中心目录和EOCD到内存流,但之前的分块发送已经清空了流内容,最后发送的EOCD没有对应的中心目录和条目数据,自然会出现条目数不匹配的错误。
修复方案
发送端修改
- 移除内存流清空操作,让
ZipArchive完整管理整个Zip文件的写入流程,所有CSV数据写入完成后,再一次性分块发送完整的Zip二进制数据。 - 直接传输二进制数据,改用Protobuf的
ByteString类型传递字节数组,避免UTF8编码的损坏问题。
修改后的关键代码:
using var call = await _destinationService.InitCall(); using (var stream = new MemoryStream()) { using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true)) { var entry = zip.CreateEntry($"{Model.FileName}.{Model.Format}"); using var writer = new StreamWriter(entry.Open(), Model.Encoding); var csvConfiguration = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = Model.Delimiter }; using var csv = new CsvWriter(writer, csvConfiguration); var columns = await _exportService.GetAndWriteColumns(csv, firstChunk, Model, cancellationToken); await _exportService.PerformExport(csv, writer); while (_substreamData.TryDequeue(out var data) || !_isSubstreamEof) { await _exportService.PerformExport(csv, writer); } // 确保所有CSV数据写入Zip条目 await csv.FlushAsync(); await writer.FlushAsync(); } // ZipArchive已Dispose,此时stream包含完整Zip文件(条目+中心目录+EOCD) stream.Seek(0, SeekOrigin.Begin); // 分块发送二进制数据 byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) { await call.RequestStream.WriteAsync( new WriteData { DataPart = new WriteChunk { Data = ByteString.CopyFrom(buffer, 0, bytesRead) } }, cancellationToken); } await call.RequestStream.CompleteAsync(); } // 修改PerformExport,仅负责写入CSV public async Task PerformExport(CsvWriter csv, StreamWriter writer) { // 生成CSV数据并写入 await csv.FlushAsync(); await writer.FlushAsync(); }
接收端修改
- 直接接收二进制数据,通过
ByteString获取原始字节数组,避免编码转换。 - 确保合并后的内存流指针重置到起始位置。
修改后的代码:
var bytes = new List<byte>(); while (await requestStream.MoveNext(context.CancellationToken)) { try { // 直接获取ByteString的原始字节数组 var data = requestStream.Current.DataPart.Data.ToByteArray(); bytes.AddRange(data); } catch (Exception ex) { _logger.LogError($"Error executing Write request: {ex.Message}"); errors.Add(ex.Message); } } var stream1 = new MemoryStream(bytes.ToArray()); stream1.Seek(0, SeekOrigin.Begin); // 重置流指针到开头 using var zipArchiveSubexport = new ZipArchive(stream1); var t = zipArchiveSubexport.Entries.First();
额外建议
- 对于超大文件,不要先将整个Zip写入内存流,可直接通过gRPC流分块写入Zip条目,避免内存溢出。
- 所有二进制格式文件(Zip、图片、音频等)禁止转成UTF8字符串传输,优先用Protobuf的
ByteString或Base64编码传输。
内容的提问来源于stack exchange,提问作者Stanislav Muryndin

