You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

通过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

问题分析与解决方案

核心错误点

  1. 内存流清空操作破坏Zip结构
    发送端在PerformExport中每次发送后调用stream.Clear(),直接清空了ZipArchive绑定的内存流缓冲区。而ZipArchive在创建模式下会持续向该流写入条目内容和后续的中心目录,清空操作会删除之前写入的所有条目数据,导致最终发送的只有最后一次分块加EOCD,缺失了完整的Zip结构。

  2. 二进制转字符串导致字节损坏
    Zip是二进制格式,发送端用StreamReader读取二进制流并转成UTF8字符串,接收端再转回字节数组,这个过程会破坏二进制数据的完整性(部分字节无法被UTF8正确编码/解码),直接导致Zip结构损坏。

  3. 分块逻辑与EOCD发送时机混乱
    ZipArchive只有在Dispose时才会写入中心目录和EOCD到内存流,但之前的分块发送已经清空了流内容,最后发送的EOCD没有对应的中心目录和条目数据,自然会出现条目数不匹配的错误。

修复方案

发送端修改

  1. 移除内存流清空操作,让ZipArchive完整管理整个Zip文件的写入流程,所有CSV数据写入完成后,再一次性分块发送完整的Zip二进制数据。
  2. 直接传输二进制数据,改用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();
}

接收端修改

  1. 直接接收二进制数据,通过ByteString获取原始字节数组,避免编码转换。
  2. 确保合并后的内存流指针重置到起始位置。

修改后的代码:

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.05 00:03:18