ServletOutputStream在nginx轉發下輸出檔案下載的一種方法

myskies發表於2021-11-11

以前接觸到的檔案下載基本上都是實時讀取的,比如我們直接將一些伺服器端的檔案做輸出,或是匯出一些計算量不太大的excel,所以沒有太留意檔案下載的細節。

昨天坐了一個資料匯出,由於匯出的資料量還不算小,然後在匯出的過程中還需要做少許的執行,導致下載的時候大約需要5分鐘左右。這時候以前沒有注意到的細節便浮出了水面。

本次的問題主要出現在瀏覽器端未及時彈出檔案下載框,這給了使用者一種下載頁面沒法開啟的假象。

檔案流

HTTP在進行連線時,會接收到響應頭與響應主體。根據HTTP協議的描述,響應頭與主體間使用空行來分隔。當響應的內容比較大時,伺服器先把響應的內容由上至下的傳送給客戶端。這更像資料結構中的佇列,header頭資訊發入隊,body的主體的資訊後入隊,然後由於網速的限制,沒有辦法一次性將佇列中的內容全部傳送完畢,所以在傳送時便使用了先進先出的原則,將位於隊頭的header的資訊先傳送給客戶端。

瀏覽器做為客戶端,接收到http的header頭資訊後,便可以得知後臺將傳送一個大的檔案給我們,然後彈出儲存檔案操作的對話方塊。

彈出檔案下載框

所以下載大的檔案時,如果想讓瀏覽器及時的彈出下載對話方塊,最關鍵的就是讓瀏覽器及時的收到相關的header資訊。

故以下的程式碼是錯誤的:

    ServletOutputStream outputStream = httpServletResponse.getOutputStream();
   // 設定響應頭 httpServletResponse.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;");
    httpServletResponse.setHeader("Content-Disposition", "attachment;filename=text.xlsx");

    // 模擬耗時的下載
    Thread.sleep(10000);

    // 傳送資料並關閉連結
    outputStream.flush(); ➊
    outputStream.close();

上述程式碼將導致執行到➊時,瀏覽器端才能夠接收到響應的header資訊。也就說是:只有後臺的程式碼執行到➊時,才會觸發瀏覽器彈出儲存檔案的對話方塊。

正確的方法如下:

    ServletOutputStream outputStream = httpServletResponse.getOutputStream();
   // 設定響應頭 httpServletResponse.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;");
    httpServletResponse.setHeader("Content-Disposition", "attachment;filename=text.xlsx"
    outputStream.flush(); ➋

    // 耗時的下載
    Thread.sleep(10000);

    // 傳送資料並關閉連結
    outputStream.flush();
    outputStream.close();

此時程式碼執行到➋時,瀏覽器便接收到了必要的header頭資訊,進而觸發其彈出對話方塊。

本以為已經萬事大吉,但是用很多新手的話說:瀏覽器就是不彈出對話方塊。其實,瀏覽器不彈出對話方塊必然是我們還沒有弄明白的,不存在就是一說。我們在提問時,如果加入了就是,往往說明自己的心態已經崩潰了。

NGINX

排除這種就是的問題,往往還需要簡化環境,一層層的把一些環境扔掉,看看是否仍然報錯。通過嘗試我發現原來是自己使用nginx反向代理的原因導致header的資訊沒有被瀏覽器及時的接收,所以我大膽的猜測應該是nginx做了資料快取。

由於傳送的header的資料量比較小,然後NGINX出於某些效率的原因,並沒有選擇實時地將資料轉發給瀏覽器,這導致了雖然後臺將HEADER頭資訊傳送了出來,但卻沒有被瀏覽器接收到,所以瀏覽器便沒有實時的彈出下載的對話方塊。

有了大概的方向後,通過google查詢發現nginx的確預設有快取功能。然後找到了相應的官方文件中關於proxy_buffer的一節。內容如下:


Syntax:    proxy_buffering on | off;
Default:    
proxy_buffering on;
Context:    http, server, location

Enables or disables buffering of responses from the proxied server.

When buffering is enabled, nginx receives a response from the proxied server as soon as possible, saving it into the buffers set by the proxy_buffer_size and proxy_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the proxy_max_temp_file_size and proxy_temp_file_write_size directives.

When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the proxied server. The maximum size of the data that nginx can receive from the server at a time is set by the proxy_buffer_size directive.

Buffering can also be enabled or disabled by passing “yes” or “no” in the “X-Accel-Buffering” response header field. This capability can be disabled using the proxy_ignore_headers directive.


通過閱讀官方文件我們發現有兩種禁用該快取的方法:

  1. 設定proxy_buffering的值為off
  2. 在返回header時,增加一項X-Accel-Buffering,設定值為no

通過測試發現兩種情況均可以正常工作。考慮到nginx的快取機制必然有它自己的道理,所以在這裡我們採用第二種:在返回header時,增加一項X-Accel-Buffering,設定值為no

    ServletOutputStream outputStream = httpServletResponse.getOutputStream();
    httpServletResponse.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
    httpServletResponse.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));
    httpServletResponse.setHeader("X-Accel-Buffering", "no");
    outputStream.flush();

至此,便可以愉快的收工了。

總結

下載大檔案未及時彈出對話方塊的問題,使我認識到了flush()方法的作用,也好像明白了為什麼response中只有set方法,而沒有clear方法。同時這還使我大膽地猜測:一旦呼叫了response中的write()方法後再呼叫setHeader方法,則應該會報一個錯誤或是異常。同時瞭解了nginx為了某些不知道的原因,自動啟用了快取,這應該是一種優秀的機制,所以在解決方法中我們並沒有直接將其關閉。而是選擇了另一種自定義header值的方法,該方法來告之nginx:這裡的資料不需要快取,請直接傳送給客戶端。從而達到了在nginx轉發的前提下,瀏覽器實時的彈出儲存檔案對話方塊的目的。

希望能對你有所幫助。

相關文章