不好的程式設計習慣之檔案下載

JinCeon發表於2023-03-12

故事

系統裡有個Excel報表匯出,以前是匯出xls格式,沒問題。後來改成xlsx後,開啟就報錯了。
一開始同事還以為是用的Excel工具庫不支援xlsx,但我覺得不太可能,我把不能開啟的Excel檔案拿來一分析,果然有蹊蹺。


不能開啟的xlsx檔案末尾多了一個字串{reponseCode:"000000",reponseMsg:"成功"},之前xls格式能開啟可能是因為xls錯誤相容性比較好。
多這個字串的原因是匯出報表虛擬碼如下

@POST
@Path("/download")
public String download(){
      WorkBook wb = generateWorkBook();
      wb.save(response.getOutputStream());
      return {reponseCode:"000000",reponseMsg:"成功"};
}

最開始的同事之所以返回String我猜是想著出錯時返回一些錯誤提示。但其實真的出錯時直接往外拋異常即可。所以同事將程式碼改成

@POST
@Path("/download")
public void download(){
      WorkBook wb = generateWorkBook();
      wb.save(response.getOutputStream());
}

不要在操作outputStream時同時在方法返回字串就好。

問題

這個故事引出了本篇文章。
事實上網上大把檔案下載的示例程式碼都是這樣寫的。返回值為void,然後直接操作OutputStream。
但我認為這實在不是一個好的實踐。
我認為無論如何不應該去操作outputStream。
如果要返回檔案,就應該顯式地宣告返回值。

思考

函式的輸入輸出,非常直觀。Controller的介面本質也是一個函式。

// 例子1
public String sayHello(String uername){
     return "hello " + username;
}
// 顯而易見,假設輸入"張三",那麼函式的返回是"hello 張三",
// 如果是在controller裡,那客戶端收到的報文是"hello 張三"

非常直觀的輸入、輸出對吧,

常規的rest介面不會有人想著要直接入操作outputStream,對吧?

尬來一波,假如有人非要!

如果你在controller裡看到下面的程式碼,你認為這個函式的輸出是什麼?客戶端收到的返回又是什麼?

// 例子2
pubilc String sayHello(String username){
     response.getOutputStream().write("world".getBytes());
     return "hello " + username;
}
// 同樣輸入"張三",雖然這個函式的返回值是 "hello 張三",
// 但是對於http請求來說,客戶端收到的返回是 "world hello 張三"
// 看看本文開頭的例子,雖然函式本身的返回值僅僅是一個JSON字串,
// 但是客戶端收到的返回卻是【excel檔案+JSON字串】。

繼續往下看,加個 response.getOutputStream().close()你認為這個函式的輸出是什麼?客戶端收到的返回又是什麼?

// 例子3
pubilc String sayHello(String username){
     response.getOutputStream().write("world".getBytes());
     reponse.getOutputStream().close();
     return "hello " + username;
}
// 同樣輸入"張三",雖然這個函式的返回值是 "hello 張三",
// 但是對於http請求來說,客戶端收到的返回是 "world"
// 因為outputStream已經被關閉了,返回值不能再寫入到outputStream裡
// PS: springmvc表現不一樣,springmvc對request和response包了一層wrapper,
// 在springmvc下操作`outputStream.close()`實際上是執行了一個空方法,
// 所以springmvc下返回和例子2一樣

請問:這兩個例子,是不是讓你覺得很混亂,很不直觀?

再次回到本文的觀點,為什麼不要去操作outputStream?

沒有明顯的好處。
固然,常規 restful 的介面我們直接return資料比較簡單,所以不會有人自找麻煩去操作outputStream。但是對於檔案,用返回值的形式是給你帶了很大麻煩嗎?直接操作outputStream是給你帶來了很大的便利嗎?

與常規的思維邏輯相悖。
方法返回體宣告為void即是表明該方法無返回值,但是實際你又返回了一個檔案。

帶來混亂。
如上述的3個例子,當有新手開發在操作outputStream的同時還宣告瞭返回值,可能還呼叫了flush或close等方法時,會有一些意想不到的表現。

可能帶來未知的問題。
不同的框架有不同的實現,不是所有人都會去研究框架的原始碼。如上述例子3的程式碼在springmvc和jersey下表現就是不一致。
所以,為什麼就那麼執著地要去直接操作outputStream呢?

結論

無論如何不應該在Controller裡直接操作outputStream。

那以常見的檔案下載需求來說,應該怎樣寫檔案下載呢?

不推薦:直接操作outputStream,同時方法返回值宣告為void。

推薦:直接返回檔案流
// jersey的寫法

public Response downloadExcel(){
    StreamingOutput stream = 二進位制檔案內容;
    return Response
            .ok(stream, MediaType.APPLICATION_OCTET_STREAM)
            .header("content-disposition","attachment; filename=xx.xlsx")
            .build();
}

springmvc的示例

@Controller
public class DownloadController {
    @GetMapping
    public ResponseEntity<Resource> downloadPdf() {
        FileSystemResource resource = new FileSystemResource("/xx.xlsx");

        ContentDisposition disposition = ContentDisposition
                .inline() // or .attachment()
                .filename(resource.getFilename())
                .build();
        headers.setContentDisposition(disposition);
        return new ResponseEntity<>(resource, headers, HttpStatus.OK);
    }
}

相關文章