透過 Spring 從其他 API 取得二進位檔案後回傳 - Ming Chang

透過 Spring 從其他 API 取得二進位檔案後回傳

問題描述

由於客戶資料庫的規格在既有的後端中不相容,我額外用 Rust 寫了 Axum w/ SQLx 框架的後端,以 Sidecar 的方式外掛在單體架構之外。

但這卻衍生了另一個問題:權限管理。

為了避免權限不足的使用者也能夠存取這些 API,勢必不能直接將 Axum API 掛到 NGINX Reverse Proxy 去,既有的權限管理機制也並不適合直接搬到 Axum 來。

我只好在 Spring 後端進行權限管理確認後,再由 Spring 請求 Axum 的 API。

系統架構分析

Axum 端

資料庫為 SQL Server,客戶想要利用姓名或身份證查詢其中的內容,之後製作成 Excel 檔讓他們可以下載查看。

Axum API 會將 SQL Server query 出來的結果製作成 Excel 檔(.xlsx,MIME Type:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)之後透過非同步的方式回傳檔案(下載)。

Spring 端

Spring API 端有兩種呼叫 API 的方式:Spring MVC 的 RestTemplate 與 Spring WebFlux 的 WebClient

由於前面有提到,Axum 端的下載是透過非同步的方式實現的,而根據 RestTemplate 的註釋說明,這個類是一個同步客戶端,而且在下方也有提到:

NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.

既然都直接說了WebClient 才支援非同步,而且已經不更新新功能了,那當然就直接使用 WebClient 囉。

實作

直接上Code

Axum(Rust)

pub async fn xlsx_download(filename: String) -> Result<(HeaderMap, StreamBody<ReaderStream<File>>), (StatusCode, String)> {
    match File::open(&filename).await {
        Ok(file) => {
            let stream = ReaderStream::new(file);
            let body = StreamBody::new(stream);

            let mut headers = HeaderMap::new();
            headers.append(
                header::CONTENT_TYPE,
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
                    .parse()
                    .unwrap(),
            );
            headers.append(
                header::CONTENT_DISPOSITION,
                format!("attachment; filename=\"{}\"", filename)
                    .parse()
                    .unwrap(),
            );
            remove_file(filename).unwrap_or(());
            Ok((headers, body))
        }
        Err(err) => Err((StatusCode::NOT_FOUND, format!("File not found: {}", err))),
    }
}

這邊可以注意到,程式中有指定 Content-TypeContent-Disposition 兩個 header,這兩個 header 分別指定了檔案的 MIME Type 跟檔名。

兩個 header 都需要在 Spring 端加上,並一起回傳給客戶端,下載的時候檔名才不會跑掉。

Spring (Java)

public @ResponseBody Mono<ResponseEntity<Resource>> getDataFromApi(@RequestBody OldDataRequest request) {
        return WebClient.builder()
                .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs().maxInMemorySize(50 * 1024 * 1024))
                .baseUrl("https://axum-base.url")
                .build()
                .get()
                .uri("/uri")
                .exchangeToMono(response -> response.bodyToMono(Resource.class)
                        .map(file -> ResponseEntity.ok()
                                .contentType(MediaType.valueOf("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
                                .header(HttpHeaders.CONTENT_DISPOSITION, response.headers().asHttpHeaders().getContentDisposition().toString())
                                .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION)
                                .body(file)
                        )
                        .switchIfEmpty(Mono.empty())
                );
    }

這邊透過 WebClient 從 Axum API 那邊將檔案下載回來後,利用 Resource interface 將檔案抽取出來。

由於檔案大小可能超出 WebClient 預設的 buffer 大小,所以利用 ClientCodecConfigurer 設定了較高的 maxInMemorySize

接著透過 ResponseEntity 製作要回傳給前端的 Response。

注意在製作 Response 的時候,我們除了有加上Content-TypeContent-Disposition 兩個 header,另外多加了一個 header Access-Control-Expose-Headers,這是為了讓前端可以取得 Content-Disposition header 的內容。

同場加映 - Angular(TypeScript)

downloadData() {
    this.http.post<HttpResponse<any>>(`https://spring-base.url/uri`, param, {
        responseType: 'blob' as 'json',
        observe: 'response'
    }).pipe()
        .subscribe(
            data => {
                const header = data.headers.get('content-disposition')
                saveAs(data.body, header.split('"')[1])
            },
            error => {
                alert('系統錯誤:\n' + error.message)
            }
        )
}

這邊用了 FileSaver.js 協助處理檔案下載功能。

由於要讀取 Content-Disposition header 的內容用於填入 FileSaver.js 的檔名參數,所以要將整個 response 保留下來,而非像預設值設定的僅保留 body。