目录

如何流式导出xlsx/zip文件

应用场景

在后端系统中,可能需要支持数据导出为xlsx文件的功能,当数据量很大时,如果先创建文件,然后通过HTTP接口进行下载,则在文件生成前,浏览器端会出现下载已经开始,但是没有任何数据传输的情况,用户体验很差,而且在服务器端要浪费磁盘空间和CPU时间来进行文件打包,下载完成/中断后还需要删除临时文件。

类似的情况还有数据打包操作,比如在网盘应用中,用户希望一次性下载多个文件,而浏览器可能会拦截除第一个外的其他文件下载,也会导致用户体验变差。在百度网盘和google drive中,当需要下载一个目录时,会将这个目录打包为一个zip文件后再下载,从而规避浏览器的这个限制。

xlsx文件格式

要想生成xlsx文件,需要了解其文件格式,根据Ecma Office Open XML Part 2可知,xlsx是一个zip文件,所以我们先来看看zip文件格式

zip文件格式简介

/img/ZIP-64_Internal_Layout.png

从上图可以看出,zip文件格式分为多个文件记录和central directory两部分。文件记录包括local file header和文件内容(还可以包含可选的data descriptor和encryption header)。文件记录可以分布在zip中的任意位置,文件记录之间可以有空隙。在zip文件最后有一个central directory来指明文件记录在zip中所在位置。

之所以采用这种布局,是为了支持:

  • 生成流式zip文件
  • 添加新文件不需要重新打包旧的文件,只需要在文件结尾处添加新文件并重新生成新的central directory
  • 在zip文件头添加其他数据,如自解压头
  • 在其他文件中嵌入zip文件

上面的图中还有一个重要的部分没有绘制出来,就是data descriptor,根据zip format SPEC

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[local file header 1]
[encryption header 1]
[file data 1]
[data descriptor 1]
.
.
.
[local file header n]
[encryption header n]
[file data n]
[data descriptor n]
[archive decryption header]
[archive extra data record]
[central directory header 1]
.
.
.
[central directory header n]
[zip64 end of central directory record]
[zip64 end of central directory locator]
[end of central directory record]

我们可以看到一个文件记录最开始是一个local file header,后面是可选的encryption header,接下来是文件内容,最后是一个可选的data descriptor。data descriptor就是用于支持流式zip文件生成而必须的部分。

文件记录

下面我们详细说明一下文件记录中的各个组成部分:

  • local file header
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    local file header signature     4 bytes  (0x04034b50)
    version needed to extract       2 bytes
    general purpose bit flag        2 bytes
    compression method              2 bytes
    last mod file time              2 bytes
    last mod file date              2 bytes
    crc-32                          4 bytes
    compressed size                 4 bytes
    uncompressed size               4 bytes
    file name length                2 bytes
    extra field length              2 bytes
    
    file name (variable size)
    extra field (variable size)
    
  • data descriptor
    1
    2
    3
    
    crc-32                          4 bytes
    compressed size                 4 bytes
    uncompressed size               4 bytes
    

可以看到,在local file header和data descriptor中都包含了crc-32、compressed size和uncompressed size。 为什么要重复记录呢?当文件内容是实时生成的时候,这三个值都是无法预先知道的,只有在写入完成后才能得到。因此在生成流式文件的时候:

  1. 在写入的local file header中设置general purpose bit flag的bit 3为1,指明这三个值需要从data descriptor中读取,并将其值都设置为0;
  2. 写入数据,同时计算crc-32,记录compressed size和uncompressed size;
  3. 将正确的crc-32、compressed size和uncompressed size写入data descriptor中;
  4. 将所有写入文件的偏移写入到central directory中,完成文件传输。

当需要写入的文件已经存在时,我们甚至可以预先计算好文件对应的crc-32值,当生成流式zip文件时,compression method字段采用store(0)模式,这种模式下文件数据不压缩,compressed size和uncompressed size大小一致。此时,zip文件中所有数据的位置和值都是可以预先确定的,从而支持断点续传和多线程下载流式zip文件,具体实现可以参考nginx zip module。这种方式可以用于在网盘应用中下载包含多个文件的zip包,

xlsx文件内容

简单来说,xlsx就是一个包含多个xml文件的zip包,其中大部分是固定内容的xml文件。我们可以新建一个空的xlsx文件,unzip后就能看到其中的文件:

  • _rels/.rels
  • docProps/app.xml
  • docProps/core.xml
  • xl/_rels/workbook.xml.rels
  • xl/theme/theme1.xml
  • xl/styles.xml
  • [Content_Types].xml
  • xl/workbook.xml
  • xl/worksheets/sheet1.xml

当只有一个工作簿的时候,工作簿数据一般存放在xl/worksheets/sheet1.xml文件。如果需要支持多个工作簿和其他高级功能,需要参考Standard ECMA-376 Office Open XML File Formats

xl/worksheets/sheet1.xml文件格式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="x14ac xr xr2 xr3"
  xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac"
  xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision"
  xmlns:xr2="http://schemas.microsoft.com/office/spreadsheetml/2015/revision2"
  xmlns:xr3="http://schemas.microsoft.com/office/spreadsheetml/2016/revision3" xr:uid="{A45FA7EE-319C-4F2A-BADD-CBFF345C308D}">
  <cols>
    <col min="1" max="1" width="20" />
    <col min="2" max="2" width="20" />
    <col min="3" max="3" width="20" />
  </cols>
  <sheetData>
    <row r="1" spans="1:3" x14ac:dyDescent="0.25">
      <c r="A1" t="inlineStr">
        <is>
          <t>f1</t>
        </is>
      </c>
      <c r="B1" t="inlineStr">
        <is>
          <t>f2</t>
        </is>
      </c>
      <c r="C1" t="inlineStr">
        <is>
          <t>f3</t>
        </is>
      </c>
    </row>
    <row r="2" spans="1:3" x14ac:dyDescent="0.25">
      <c r="A2" t="inlineStr">
        <is>
          <t>2 1</t>
        </is>
      </c>
      <c r="B2" t="inlineStr">
        <is>
          <t>2 2</t>
        </is>
      </c>
      <c r="C2" t="inlineStr">
        <is>
          <t>2 3</t>
        </is>
      </c>
    </row>
    <row r="3" spans="1:3" x14ac:dyDescent="0.25">
      <c r="A3" t="inlineStr">
        <is>
          <t>3 1</t>
        </is>
      </c>
      <c r="B3" t="inlineStr">
        <is>
          <t>3 2</t>
        </is>
      </c>
      <c r="C3" t="inlineStr">
        <is>
          <t>3 3</t>
        </is>
      </c>
    </row>
  </sheetData>
</worksheet>

其中,用于说明工作簿有多少列,以及每列的名称;由多个组成,每个代表工作簿的一行,每行由多个cell组成,用表示,为了简单起见,我们使用inlineStr类型来保存数据,数据放在标签中。

根据数据生成流式xlsx文件

要生成流式xlsx文件,首先需要有能够支持流式生成zip文件的库,go和java的标准库都能支持,python的标准库不支持,需要使用第三方库python-zipstream或者ZipStream,C/C++需要自己实现,可以参考nginx zip module

我实现了一个流式生成xlsx的go版本,供参考。

流式HTTP文件下载

当需要传输文件流时,HTTP的header不发送Content-Length,在发送完headers后,将生成的流式xlsx数据写入socket即可。go的http handler的参数ResponseWriter是实现了io.Writer接口,可以直接写入生成的xlsx文件数据,比较简单,就不写例子了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

type ResponseWriter interface {
    // Header returns the header map that will be sent by
    // WriteHeader. The Header map also is the mechanism with which
    // Handlers can set HTTP trailers.
    //
    // Changing the header map after a call to WriteHeader (or
    // Write) has no effect unless the modified headers are
    // trailers.
    //
    // There are two ways to set Trailers. The preferred way is to
    // predeclare in the headers which trailers you will later
    // send by setting the "Trailer" header to the names of the
    // trailer keys which will come later. In this case, those
    // keys of the Header map are treated as if they were
    // trailers. See the example. The second way, for trailer
    // keys not known to the Handler until after the first Write,
    // is to prefix the Header map keys with the TrailerPrefix
    // constant value. See TrailerPrefix.
    //
    // To suppress automatic response headers (such as "Date"), set
    // their value to nil.
    Header() Header

    // Write writes the data to the connection as part of an HTTP reply.
    //
    // If WriteHeader has not yet been called, Write calls
    // WriteHeader(http.StatusOK) before writing the data. If the Header
    // does not contain a Content-Type line, Write adds a Content-Type set
    // to the result of passing the initial 512 bytes of written data to
    // DetectContentType. Additionally, if the total size of all written
    // data is under a few KB and there are no Flush calls, the
    // Content-Length header is added automatically.
    //
    // Depending on the HTTP protocol version and the client, calling
    // Write or WriteHeader may prevent future reads on the
    // Request.Body. For HTTP/1.x requests, handlers should read any
    // needed request body data before writing the response. Once the
    // headers have been flushed (due to either an explicit Flusher.Flush
    // call or writing enough data to trigger a flush), the request body
    // may be unavailable. For HTTP/2 requests, the Go HTTP server permits
    // handlers to continue to read the request body while concurrently
    // writing the response. However, such behavior may not be supported
    // by all HTTP/2 clients. Handlers should read before writing if
    // possible to maximize compatibility.
    Write([]byte) (int, error)

    // WriteHeader sends an HTTP response header with the provided
    // status code.
    //
    // If WriteHeader is not called explicitly, the first call to Write
    // will trigger an implicit WriteHeader(http.StatusOK).
    // Thus explicit calls to WriteHeader are mainly used to
    // send error codes.
    //
    // The provided code must be a valid HTTP 1xx-5xx status code.
    // Only one header may be written. Go does not currently
    // support sending user-defined 1xx informational headers,
    // with the exception of 100-continue response header that the
    // Server sends automatically when the Request.Body is read.
    WriteHeader(statusCode int)
}