REP-06:真正的 ranged read 契約設計
狀態:已通過設計審閱(3 輪,2026-09-30)與程式碼審閱(2 輪,2026-09-30);已實作。實作細節見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(第 6 點 Range delivery 的驗收要求)、報表產生開發計畫 REP-06、REP-05 契約(保存後以
FileResponseBuilder下載)、檔案儲存設計、RFC 9110 §13(條件式請求)與 §14(Range) 日期:2026-09-30
範圍
| 納入 | 不納入(所屬工作包) |
|---|---|
FileStorage 的 ranged-read 能力宣告與讀取方法;Local、S3(含 MinIO)、Azure Blob、SFTP 的原生實作;TransactionAwareFileStorage 轉送 | 參考實作的 DatabaseFileStorage(沿用預設「不支援」;見「backend」) |
FileResponseBuilder 的 Range 語意:單一 range、206、416、If-Range、If-None-Match、Accept-Ranges | multipart/byteranges(多 range 回應) |
Range 解析的共用實作,ZipFileResponseBuilder 改用同一語意 | ZipStreamResponseBuilder(邊壓縮邊傳,本來就不支援 Range) |
| PDF.js 需要的跨來源 exposed headers:框架常數、文件與 CORS 測試 | 參考伺服器的 CORS 設定調整(併入 FU-103) |
| backend contract test:mock client 驗證請求帶 range,Testcontainers(MinIO、Azurite)與內嵌 SFTP 驗證真實讀取 | 前端按需預覽(REP-07) |
已確定的方向
以下四項已由框架維護者於 2026-09-30 決定,本文據此設計:
- 介面預設方法+能力宣告。
FileStorage新增rangeSupport()與openRange(...)兩個 default 方法;預設為「不支援」, 框架的四個 backend 覆寫為原生 ranged read。下游自訂 backend 不必改碼即相容,但會被識別為不支援。 - 不支援時忽略 Range。 backend 不支援有效率的 ranged read 時,
FileResponseBuilder忽略Range、回200全檔並宣告Accept-Ranges: none(RFC 9110 允許伺服器忽略 Range)。不以skip()模擬:PDF.js 對大檔發出多個 range 請求時, 模擬會讓每個請求都從 byte 0 讀起,成本是請求數的倍數。 - 兩層 contract test。 一般測試以 mock
S3Client/BlobClient驗證送出的請求帶 range(不需 Docker);另以 Testcontainers 的 MinIO 與 Azurite 做真實 ranged read,沒有 Docker 時明確跳過。SFTP 以既有的內嵌 SSHD 伺服器驗證。 - CORS 由框架提供常數與文件。 框架公開 range 回應需要 expose 的 header 清單,並以 CORS 測試驗證;參考伺服器的
WebConfig/CorsConfigurationSource調整隨 FU-103 報表功能一起處理。
設計原則
- 能力是事實,不是猜測。 是否有效率由 backend 自己宣告;
FileResponseBuilder依宣告決定是否提供 Range,不以型別或名稱推測。 - 宣告與行為一致。
Accept-Ranges: bytes只在 backend 原生支援時出現;宣告none時任何 Range 請求都得到完整內容。 - 不讀不需要的位元組。 原生實作必須讓儲存服務只回傳所請求的區段(S3/Azure 的 range 請求、檔案通道定位、SFTP 帶 offset 的讀取),
不得開啟完整串流後
skip()。 - 條件式請求先於 Range。 依 RFC 9110 的評估順序:
If-None-Match命中即304;If-Range不符即忽略 Range;最後才處理 Range。 - 格式錯誤就當沒有 Range,無法滿足才 416。 語法不合法的 Range 一律忽略(
200);語法合法但落在檔案範圍外才回416。
公開 API
儲存能力(io.leandev.appfuse.file)
public interface FileStorage {
// ...既有方法不變
/// 此 backend 是否能只讀取物件的一個區段(不讀取前段、不讀取完整物件)
default RangeSupport rangeSupport() {
return RangeSupport.NONE;
}
/// 開啟 [offset, offset + length) 區段的串流;呼叫端負責關閉
///
/// @throws FileNotFoundException 檔案不存在
/// @throws IllegalArgumentException offset < 0、length <= 0
/// @throws UnsupportedOperationException rangeSupport() 為 NONE
default InputStream openRange(String partition, String fileId, long offset, long length)
throws FileNotFoundException {
throw new UnsupportedOperationException(getStorageType() + " storage does not support ranged reads");
}
}
public enum RangeSupport {
NATIVE, // 儲存服務只傳回所請求的區段
NONE // 不支援;FileResponseBuilder 忽略 Range
}
| 規則 | 內容 |
|---|---|
| 區段 | 串流恰好產生 min(length, size - offset) 個位元組;offset >= size 時產生 0 個位元組(呼叫端依 metadata 大小計算,FileResponseBuilder 不會這樣呼叫)。skip 的負值回 0,不倒退到區段之前 |
| 一致性 | fileId 為 write-once,同一 fileId 的大小與內容不變;區段讀取不需再比對版本 |
| 錯誤 | 不存在為 FileNotFoundException(與 getInputStream 一致);backend 錯誤沿用各自既有的例外型別 |
| 串流 | 呼叫端負責關閉;關閉即釋放底層連線或 handle |
| 為何是列舉 | 保留日後加入其他等級(例如只支援從某個 offset 起讀到結尾)的空間,而不改變方法簽章 |
Range 評估(RangeUtils)
public sealed interface RangeRequest {
record Absent() implements RangeRequest { } // 沒有 Range,或語法不合法、多個 range → 回 200
record Satisfiable(ByteRange range) implements RangeRequest { } // 回 206
record Unsatisfiable(long size) implements RangeRequest { } // 回 416,Content-Range: bytes */{size}
}
RangeRequest RangeUtils.evaluate(String rangeHeader, long size);
評估分兩步,先語法後可滿足性(RFC 9110 §14.1.1:last-pos 小於 first-pos 的 range 無效;數字不轉成 long 就比較,避免溢位):
- 語法:單位、形式與數字都在十進位字串上檢查。
first-last形式以字串比較數值大小(去掉前導零後先比位數、再比字典序),last < first即無效。任何無效 →Absent。 - 可滿足性:只有語法合法的單一 range 才進入此步,數值超出
long時才以「超過結尾」處理。
| Range 值 | 結果 |
|---|---|
| 缺少、空白 | Absent |
單位不是 bytes(不分大小寫)、語法錯誤(bytes=、bytes=-、bytes=a-b、last < first,含超出 long 的反向區間如 bytes=9223372036854775808-1) | Absent |
多個 range(bytes=0-1,5-9) | Absent(見「已裁決的決策」第 2 項) |
bytes=first-last、bytes=first- 且 first < size | Satisfiable,last 超過結尾時截到 size - 1 |
bytes=-n 且 n > 0、size > 0 | Satisfiable,最後 min(n, size) 個位元組 |
first >= size、bytes=-0、size == 0 時的任何合法 range | Unsatisfiable |
語法合法但數字超出 long | first 超出 → 超過結尾(Unsatisfiable,例如 bytes=9223372036854775808-、bytes=9223372036854775808-9999999999999999999);last 或 suffix 長度超出 → 到結尾(例如 bytes=0-99999999999999999999 為整個檔案) |
既有 RangeUtils.parseRange(...) 把「無法滿足」與「格式錯誤」都回 Optional.empty(),無法區分 416 與 200。
它保留原行為並標為 @Deprecated,框架內部改用 evaluate(見「已裁決的決策」第 3 項)。
FileResponseBuilder
return FileResponseBuilder.from(fileStorage, partition, fileId)
.request(request) // 新增:讀取方法、Range、If-Range、If-None-Match(見已裁決的決策第 1 項)
.filename(stored.filename())
.cacheControl(CacheControl.noCache().cachePrivate())
.build();
// 既有 setter 保留;新增 ifRange(String) 與 method(HttpMethod)
FileResponseBuilder.from(fileStorage, partition, fileId)
.method(HttpMethod.valueOf(request.getMethod()))
.range(rangeHeader).ifRange(ifRangeHeader).ifNoneMatch(ifNoneMatch)
.build();
builder 只有在得知方法時才能對 HEAD 不讀內容:request(...) 或 method(...) 未設定時視為 GET(維持既有行為,
HEAD 仍會開啟串流、由 Spring 丟棄 body)。指南與範例一律使用 request(...)。
兩個 builder 都是下載回應,只服務 GET 與 HEAD。方法為其他值(例如 POST)時,build() 在任何條件式或 Range
評估之前丟出 IllegalArgumentException(程式錯誤,與 REP-05 ReportResponses 一致),不讀取 metadata、不開啟內容、
ZipFileResponseBuilder 不建立暫存檔。框架因此不必定義其他方法下 If-None-Match 應回的 412 與 Range 的忽略規則;
下載端點以 GET 對應(Spring MVC 會把 HEAD 交給同一 handler)。
HTTP 語意
評估順序(每一步命中即回應):
| # | 條件 | 回應 |
|---|---|---|
| 0 | 方法不是 GET/HEAD | IllegalArgumentException,不做後續任何評估 |
| 1 | metadata 不存在 | 404 |
| 2 | If-None-Match 與 ETag("{fileId}")相符 | 304,帶 ETag、Cache-Control,不開串流 |
| 3 | 方法為 HEAD | 200,headers 由 metadata 建構(與 GET 的 200 相同,Content-Length 為檔案大小),body 為空;不呼叫 getInputStream/openRange,不處理 Range |
| 3′ | 沒有 Range、backend 為 NONE,或 If-Range 不符 | 200 完整內容 |
| 4 | RangeUtils.evaluate 為 Absent | 200 完整內容 |
| 5 | Unsatisfiable | 416,Content-Range: bytes */{size},無 body |
| 6 | Satisfiable | 206,openRange(offset, length) 的內容 |
| 項目 | 規則 |
|---|---|
If-Range | 只接受與目前 ETag 完全相同的強 ETag;弱 ETag(W/"...")與 HTTP-date 形式一律視為不符(框架不送 Last-Modified,日期無從比較),改回 200 完整內容 |
Accept-Ranges | 200、206、416 都送;backend 為 NATIVE 時為 bytes,NONE 時為 none |
206 headers | Content-Range: bytes {first}-{last}/{size}、Content-Length(區段長度)、Content-Type、ETag、Cache-Control、Content-Disposition、Accept-Ranges |
416 headers | Content-Range: bytes */{size}、ETag、Cache-Control、Accept-Ranges |
HEAD | 不處理 Range(RFC 9110:Range 只定義於 GET),也不開啟內容串流:交給 Spring 去除 body 並不代表不讀取——ResourceHttpMessageConverter 的寫出路徑仍會開啟並傳輸 InputStreamResource,遠端 backend 會白白傳完整物件。回應與 GET 的 200 相同 headers、body 為空 |
| 快取 | 沿用 builder 的 cacheControl;REP-05 規範的私有快取不變 |
行為變更(寫入 CHANGELOG Changed):
- 無法滿足的 range 由
200完整內容改為416。 - backend 為
NONE時由「skip()模擬的206」改為200完整內容與Accept-Ranges: none;參考實作的DatabaseFileStorage受影響。 ZipFileResponseBuilder採用相同評估與HEAD規則,差異見下節。
ZipFileResponseBuilder
ZIP 每次請求都重新產生暫存檔,沒有 write-once 的識別;即使輸入相同,entry 時間戳也使兩次產生的位元組不同。
| 項目 | 規則 |
|---|---|
| 方法 | 同 FileResponseBuilder:新增 request(HttpServletRequest) 與 method(HttpMethod);只接受 GET/HEAD,其他方法在產生暫存檔前丟 IllegalArgumentException |
| 驗證器 | 沒有:不送 ETag,不處理 If-None-Match |
If-Range | 出現任何形式一律視為不符,回 200 完整內容——續傳的用戶端因此不會把兩次不同產生的位元組接在一起 |
沒有 If-Range 的 Range | 對本次產生的暫存檔評估:206(檔案通道定位)或 416。跨請求拼接的一致性不保證,於 javadoc 與指南註明需要安全續傳時應使用 FileStorage 保存後再以 FileResponseBuilder 下載 |
Accept-Ranges | bytes |
HEAD | 仍須產生暫存檔才知道 Content-Length;headers 送出後立即刪除暫存檔,body 為空 |
暫存檔清理責任:
| 路徑 | 刪除時機 |
|---|---|
200/206(有 body) | 交給 response body 的串流在關閉時刪除(沿用 DeleteOnCloseFileInputStream;Spring 的 converter 寫出後關閉串流) |
HEAD、416 | build() 返回前刪除 |
產生 ZIP、Files.size 或建構回應時的 I/O 失敗 | build() 內刪除後回 500;已建立但寫到一半的暫存檔也刪除 |
| entry 來源丟出的非 I/O 例外(程式錯誤) | 刪除暫存檔後原樣傳出,不以 500 掩蓋(程式碼審閱裁決) |
| 沒有任何 entry | 不建立暫存檔(204,既有行為) |
回應物件已建立、但 Spring 從未寫出 body(例如後續 filter 中止)時,串流不會被開啟也不會被關閉,暫存檔留在系統暫存目錄,
由 tmpdir 清理處理;這是既有的限制,本工作包不改(ZipFileResponseBuilder 不在報表交付路徑上)。
Backend
| backend | rangeSupport() | 實作 |
|---|---|---|
| Local | NATIVE | FileChannel.open(path).position(offset),以限長串流包裝;不使用 InputStream.skip |
| S3/MinIO | NATIVE | GetObjectRequest.range("bytes={offset}-{offset+length-1}");回傳 ResponseInputStream |
| Azure Blob | NATIVE | BlobClient.openInputStream(new BlobInputStreamOptions().setRange(new BlobRange(offset, length))) |
| SFTP | NATIVE | 開啟 handle 後以 SftpClient.read(handle, fileOffset, ...) 逐塊讀取,第一個讀取請求即從 offset 開始 |
TransactionAwareFileStorage | 轉送被裝飾者 | 兩個方法都轉送;不轉送會把原生能力遮蔽為 NONE(契約測試涵蓋) |
參考實作 DatabaseFileStorage(app-*) | NONE(預設) | 不修改:內容本來就整筆載入記憶體;依「已確定的方向」第 2 項回完整內容 |
rangeSupport() 為 NONE 的 backend 第一次收到 Range 請求時,FileResponseBuilder 以 INFO 記錄一次(每個 storage 類別一次):
file.range.unsupported: {storageType} storage does not support ranged reads; serving full content。
CORS
PDF.js 以 Accept-Ranges: bytes 與 Content-Length 判斷能否使用 range 請求,並讀取 Content-Range。跨來源時,
非 safelisted 的 response header 必須由伺服器 expose,否則前端讀不到而退回下載全檔。
/// range 回應需要讓跨來源前端讀取的 headers
public static final List<String> RANGE_EXPOSED_HEADERS =
List.of(HttpHeaders.ACCEPT_RANGES, HttpHeaders.CONTENT_RANGE, HttpHeaders.CONTENT_LENGTH, HttpHeaders.ETAG);
常數放在 FileResponseBuilder(見「已裁決的決策」第 4 項)。應用的 CORS 設定把它加入 exposedHeaders;Range、If-Range
是請求 header,需加入 allowedHeaders(Range 為 CORS safelisted request header 僅限單一 range 形式,列入較穩妥)。
框架以 MockMvc 的 CORS 設定測試 preflight 與實際回應的 Access-Control-Expose-Headers。
實作後補充(2026-09-30):CORS 只在前端與 API 跨來源時需要。參考實作為同來源部署(開發由 Vite 代理 API; SIT/UAT 的 server 與前端 host 共用
siteUrl、以 context path 區分),因此參考伺服器不需調整 CORS;FU-103 已改為 「跨來源部署時才設定」。前端直接讀取預簽 URL 時,CORS 設定在儲存服務。見檔案儲存設計指南 §4.10。
Diagnostic 與錯誤
| 情況 | 行為 |
|---|---|
| backend 不支援 | Accept-Ranges: none、200 完整內容;INFO 記錄一次 |
openRange 開啟失敗(檔案在 metadata 之後被刪除) | 404(與完整下載一致) |
| backend I/O 錯誤 | 500(與完整下載一致) |
直接呼叫 openRange 於 NONE backend | UnsupportedOperationException |
驗收方式(對應計畫的完成條件)
| 完成條件 | 驗證方式 |
|---|---|
| ranged request 不必讀取完整物件或從 byte 0 線性略過 | mock S3Client:GetObjectRequest.range() 等於所請求的區段;mock BlobClient:BlobInputStreamOptions 的 range;SFTP:伺服器端 SftpEventListener 記錄的第一個讀取 offset 等於請求的 offset;Local:以 sparse 大檔(數 GiB)讀取尾端區段並檢查內容;Testcontainers MinIO/Azurite:真實讀取的內容與長度 |
| 同一組契約適用每個 backend | 抽象的 FileStorageRangeContract 測試,Local、SFTP、S3(MinIO)、Azure(Azurite)、TransactionAwareFileStorage 各自繼承:開頭、中段、結尾、跨結尾截斷、長度 1、offset >= size、不存在、非法參數 |
| 合法、超界、格式錯誤與條件式 range 的整合測試 | RangeUtils.evaluate 的語法表(含超出 long 的反向區間回 Absent、超出 long 的 first 回 Unsatisfiable、超出 long 的 last 截到結尾);FileResponseBuilder(MockMvc):206 headers 與內容、416、格式錯誤與多 range 回 200、If-Range 相符/不符/弱 ETag/日期、If-None-Match 優先於 Range |
HEAD 不讀內容 | 以計數的 storage 經 MockMvc 送 HEAD(含帶 Range 者):headers 與 GET 的 200 相同、body 為空、getInputStream 與 openRange 呼叫次數為 0;未設定方法時維持既有行為(測試鎖定) |
只接受 GET/HEAD | POST(含帶合法 Range、帶命中的 If-None-Match)經 request(...) 與 method(...) 兩種設定:丟 IllegalArgumentException,getMetadata/getInputStream/openRange 呼叫次數為 0;ZipFileResponseBuilder 同樣丟例外且暫存目錄為空、entry 內容從未讀取 |
| 不支援 efficient range 的 backend 被清楚識別 | NONE 的 fake storage:Accept-Ranges: none、200 完整內容、openRange 從未被呼叫、INFO 只記錄一次 |
| 前端跨來源可讀取 PDF.js 所需 headers | MockMvc CORS:Access-Control-Expose-Headers 含 RANGE_EXPOSED_HEADERS |
ZipFileResponseBuilder 一致 | 206、416、帶 If-Range 回 200、不送 ETag;暫存目錄在 HEAD、416、產生失敗(entry 串流丟例外之外的 I/O 失敗注入)後為空,200/206 讀完關閉後為空 |
已裁決的決策
以下 4 項於設計審閱(2026-09-30,3 輪)由審閱者裁決,皆採作者方案。
| # | 議題 | 裁決 | 捨棄的方案 |
|---|---|---|---|
| 1 | builder 如何得知請求方法與條件 headers | 新增 request(HttpServletRequest),一次讀取方法、Range、If-Range、If-None-Match;另新增 method(HttpMethod) 與 ifRange(String),既有 setter 保留。避免 controller 漏傳 If-Range,也讓 HEAD 不讀內容 | 只新增 ifRange(String) 與 method(HttpMethod),由 controller 自行傳入 |
| 2 | 多個 range | 忽略,回 200 完整內容(RFC 9110 允許;PDF.js 只送單一 range) | 416;或實作 multipart/byteranges |
| 3 | 既有 RangeUtils.parseRange | 保留原行為並 @Deprecated,新增 evaluate | 就地改語意(已隨 alpha 發布,下游呼叫端會靜默改變) |
| 4 | expose header 常數的位置 | FileResponseBuilder.RANGE_EXPOSED_HEADERS | 放在 RangeUtils;或只寫在文件 |
設計審閱中補充的規則
| 輪次 | 議題 | 結果 |
|---|---|---|
| R1-01 | HEAD 仍會讀取內容 | HEAD 由 metadata 建構 headers、body 為空,不呼叫 getInputStream/openRange;新增 method(HttpMethod) |
| R1-02 | ZIP 沒有驗證器、暫存清理未定義 | ZIP 不送 ETag、If-Range 一律不符;HEAD/416/產生失敗於 build() 內刪除暫存檔 |
| R1-03 | 超大數字與反向區間衝突 | 先在字串上檢查語法(反向區間為無效),再判斷可滿足性 |
| R2-01 | 其他方法落入 Range 評估 | 兩個 builder 只接受 GET/HEAD,其他方法在任何評估前丟 IllegalArgumentException |
程式碼審閱裁決
程式碼審閱(2026-09-30,2 輪)的意見與裁決:
| # | 議題 | 裁決 |
|---|---|---|
| R1-01 | 限長串流的負值 skip 會倒退越出區段(Local 以 FileInputStream 實作時可讀出區段前的內容) | 修正:所有限長串流對負值 skip 回 0;Azure SDK 串流包一層;契約測試由五個 backend 共用 |
| R1-02 | Azure 存在檢查之後被刪除,開啟時的服務端 404 未轉為 FileNotFoundException | 修正:404 → FileNotFoundException(下載回 404) |
| R1-03 | 空白 If-Range 被當成沒有 If-Range | 修正:只有缺少 header 才視為沒有;空值為不符(File)/出現即回完整內容(ZIP) |
| 1 | S3/Azure 服務端 416 回空串流 | 維持:符合 openRange 契約;builder 先以 metadata 大小評估、fileId 為 write-once,正常不可達 |
| 2 | ZIP 的非 I/O 例外 | 刪除暫存檔後原樣傳出,不以 500 掩蓋;已補入清理責任表 |
| 3 | S3 容器測試 | 接受以 S3Mock 取代 MinIO |
| 4 | Azure SDK 版本範圍解析到 beta | 接受 compileOnly 與測試 prefer("12.29.0") |
實作註記
| 項目 | 內容 |
|---|---|
| S3 容器測試 | 契約寫 MinIO;實作時 minio/minio 已無法從 Docker Hub 取得(repository 不存在),quay.io/minio/minio 回 401。改用 Adobe S3Mock(adobe/s3mock:4.7.0,實作 S3 的 Range GET)以 GenericContainer 啟動;不引入 testcontainers-minio。Azurite 依原設計 |
| Azure 既有缺陷 | Azurite 契約測試發現 AzureBlobFileStorage 的 store/persist 以 original-filename 作 blob metadata 名稱,Azure 要求合法識別字而回 400 InvalidMetadata(過去只以 mock 測試)。經框架維護者同意於本工作包修正:blob metadata 名稱改為 original_filename,staging .meta sidecar 的文字格式不變;舊名稱寫入從未成功,不需遷移。CHANGELOG 記 Fixed |
| Azure SDK 版本 | azure-storage-blob 的版本範圍 [12.25.0,13.0.0) 會解析到 12.36.0-beta.1;compileOnly 與測試相依加上 prefer("12.29.0"),以穩定版編譯與測試。下游仍自行選擇版本 |
| Local 的驗收 | 8 GiB sparse 檔的尾端讀取驗證 long offset 與內容正確;「不讀前段」由實作以 FileChannel.position 保證,此測試不作為效能證明 |
| SFTP 的驗收 | 伺服器端 SftpEventListener#reading 記錄所有讀取請求的 offset:第一個等於請求的 offset,全部落在區段內 |
| ZIP 產生失敗 | 暫存檔 I/O 失敗回 500 且不留暫存檔;entry 來源丟出的非 I/O 例外(程式錯誤)刪除暫存檔後原樣傳出,不轉成 500(已補入上方清理責任表) |
負值 skip | Local、兩個 builder 的限長串流對負值 skip 回 0(FileInputStream 會倒退越出區段);Azure SDK 的串流對負值丟 IndexOutOfBoundsException,包一層改為回 0;S3、SFTP 本來就回 0 |
| If-Range 空值 | 缺少 header 才視為沒有 If-Range;空字串或空白視為不符(File)/出現即回完整內容(ZIP) |
ImageResponseBuilder | 原圖委派 FileResponseBuilder 時本來就不傳 Range,本工作包不改 |
| 測試 | RangeUtilsTest(語法表 39 項)、FileResponseBuilderTest、FileRangeMvcTest(MockMvc:206/416/If-Range/HEAD/POST/CORS/不支援時只記錄一次)、ZipFileResponseBuilderTest、FileStorageRangeContract 與 Local、SFTP、TransactionAware、S3Mock、Azurite 五個實作;S3/Azure 另有 mock client 測試 |
相關文檔
- REP-05 ReportArtifact 與交付契約設計:保存後以
FileResponseBuilder下載、私有快取與 fileId ETag - 檔案儲存設計:backend、partition 與 write-once fileId
- 報表產生開發計畫:REP-06 工作內容與完成條件