跳至主要内容

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-Rangesmultipart/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 決定,本文據此設計:

  1. 介面預設方法+能力宣告。 FileStorage 新增 rangeSupport() 與 openRange(...) 兩個 default 方法;預設為「不支援」, 框架的四個 backend 覆寫為原生 ranged read。下游自訂 backend 不必改碼即相容,但會被識別為不支援。
  2. 不支援時忽略 Range。 backend 不支援有效率的 ranged read 時,FileResponseBuilder 忽略 Range、回 200 全檔並宣告 Accept-Ranges: none(RFC 9110 允許伺服器忽略 Range)。不以 skip() 模擬:PDF.js 對大檔發出多個 range 請求時, 模擬會讓每個請求都從 byte 0 讀起,成本是請求數的倍數。
  3. 兩層 contract test。 一般測試以 mock S3Client/BlobClient 驗證送出的請求帶 range(不需 Docker);另以 Testcontainers 的 MinIO 與 Azurite 做真實 ranged read,沒有 Docker 時明確跳過。SFTP 以既有的內嵌 SSHD 伺服器驗證。
  4. CORS 由框架提供常數與文件。 框架公開 range 回應需要 expose 的 header 清單,並以 CORS 測試驗證;參考伺服器的 WebConfig/CorsConfigurationSource 調整隨 FU-103 報表功能一起處理。

設計原則​

  1. 能力是事實,不是猜測。 是否有效率由 backend 自己宣告;FileResponseBuilder 依宣告決定是否提供 Range,不以型別或名稱推測。
  2. 宣告與行為一致。 Accept-Ranges: bytes 只在 backend 原生支援時出現;宣告 none 時任何 Range 請求都得到完整內容。
  3. 不讀不需要的位元組。 原生實作必須讓儲存服務只回傳所請求的區段(S3/Azure 的 range 請求、檔案通道定位、SFTP 帶 offset 的讀取), 不得開啟完整串流後 skip()。
  4. 條件式請求先於 Range。 依 RFC 9110 的評估順序:If-None-Match 命中即 304;If-Range 不符即忽略 Range;最後才處理 Range。
  5. 格式錯誤就當沒有 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 就比較,避免溢位):

  1. 語法:單位、形式與數字都在十進位字串上檢查。first-last 形式以字串比較數值大小(去掉前導零後先比位數、再比字典序), last < first 即無效。任何無效 → Absent。
  2. 可滿足性:只有語法合法的單一 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 < sizeSatisfiable,last 超過結尾時截到 size - 1
bytes=-n 且 n > 0、size > 0Satisfiable,最後 min(n, size) 個位元組
first >= size、bytes=-0、size == 0 時的任何合法 rangeUnsatisfiable
語法合法但數字超出 longfirst 超出 → 超過結尾(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/HEADIllegalArgumentException,不做後續任何評估
1metadata 不存在404
2If-None-Match 與 ETag("{fileId}")相符304,帶 ETag、Cache-Control,不開串流
3方法為 HEAD200,headers 由 metadata 建構(與 GET 的 200 相同,Content-Length 為檔案大小),body 為空;不呼叫 getInputStream/openRange,不處理 Range
3′沒有 Range、backend 為 NONE,或 If-Range 不符200 完整內容
4RangeUtils.evaluate 為 Absent200 完整內容
5Unsatisfiable416,Content-Range: bytes */{size},無 body
6Satisfiable206,openRange(offset, length) 的內容
項目規則
If-Range只接受與目前 ETag 完全相同的強 ETag;弱 ETag(W/"...")與 HTTP-date 形式一律視為不符(框架不送 Last-Modified,日期無從比較),改回 200 完整內容
Accept-Ranges200、206、416 都送;backend 為 NATIVE 時為 bytes,NONE 時為 none
206 headersContent-Range: bytes {first}-{last}/{size}、Content-Length(區段長度)、Content-Type、ETag、Cache-Control、Content-Disposition、Accept-Ranges
416 headersContent-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-Rangesbytes
HEAD仍須產生暫存檔才知道 Content-Length;headers 送出後立即刪除暫存檔,body 為空

暫存檔清理責任:

路徑刪除時機
200/206(有 body)交給 response body 的串流在關閉時刪除(沿用 DeleteOnCloseFileInputStream;Spring 的 converter 寫出後關閉串流)
HEAD、416build() 返回前刪除
產生 ZIP、Files.size 或建構回應時的 I/O 失敗build() 內刪除後回 500;已建立但寫到一半的暫存檔也刪除
entry 來源丟出的非 I/O 例外(程式錯誤)刪除暫存檔後原樣傳出,不以 500 掩蓋(程式碼審閱裁決)
沒有任何 entry不建立暫存檔(204,既有行為)

回應物件已建立、但 Spring 從未寫出 body(例如後續 filter 中止)時,串流不會被開啟也不會被關閉,暫存檔留在系統暫存目錄, 由 tmpdir 清理處理;這是既有的限制,本工作包不改(ZipFileResponseBuilder 不在報表交付路徑上)。

Backend​

backendrangeSupport()實作
LocalNATIVEFileChannel.open(path).position(offset),以限長串流包裝;不使用 InputStream.skip
S3/MinIONATIVEGetObjectRequest.range("bytes={offset}-{offset+length-1}");回傳 ResponseInputStream
Azure BlobNATIVEBlobClient.openInputStream(new BlobInputStreamOptions().setRange(new BlobRange(offset, length)))
SFTPNATIVE開啟 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 backendUnsupportedOperationException

驗收方式(對應計畫的完成條件)​

完成條件驗證方式
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/HEADPOST(含帶合法 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 所需 headersMockMvc 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 輪)由審閱者裁決,皆採作者方案。

#議題裁決捨棄的方案
1builder 如何得知請求方法與條件 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 發布,下游呼叫端會靜默改變)
4expose header 常數的位置FileResponseBuilder.RANGE_EXPOSED_HEADERS放在 RangeUtils;或只寫在文件

設計審閱中補充的規則​

輪次議題結果
R1-01HEAD 仍會讀取內容HEAD 由 metadata 建構 headers、body 為空,不呼叫 getInputStream/openRange;新增 method(HttpMethod)
R1-02ZIP 沒有驗證器、暫存清理未定義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-02Azure 存在檢查之後被刪除,開啟時的服務端 404 未轉為 FileNotFoundException修正:404 → FileNotFoundException(下載回 404)
R1-03空白 If-Range 被當成沒有 If-Range修正:只有缺少 header 才視為沒有;空值為不符(File)/出現即回完整內容(ZIP)
1S3/Azure 服務端 416 回空串流維持:符合 openRange 契約;builder 先以 metadata 大小評估、fileId 為 write-once,正常不可達
2ZIP 的非 I/O 例外刪除暫存檔後原樣傳出,不以 500 掩蓋;已補入清理責任表
3S3 容器測試接受以 S3Mock 取代 MinIO
4Azure 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(已補入上方清理責任表)
負值 skipLocal、兩個 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 測試

相關文檔​