iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 12

Day 12:一個 Content-Type 的 bug——sitemap.xml 為什麼 Google 解析不了

  • 分享至 

  • xImage
  •  

前言:sitemap 產生了、路由也對了,為什麼 Google 說讀不到?

昨天講完 sitemap:generate 這支指令怎麼把 sitemap.xml 寫進儲存空間,今天要講一個真實發生過的後續:sitemap.xml 產生出來了,路由也能正常回應,Google Search Console 卻回報讀不到這個檔案。

檔案存在、路由也通,問題出在哪?答案跟檔案內容一點關係都沒有——是回應本身的 Content-Type 不對,還牽連到一個完全不相干的 middleware。

今日目標

  • 理解「檔案存在」「路由能回應」不代表「回應格式是對的」
  • 看一個真實案例:response()->stream() 怎麼正確帶出 XML 的 Content-Type
  • 認識這個 bug 背後真正的根因——一個不相干的 middleware 插手了 XML 路由
  • 建立「輸出格式的正確性,要單獨驗證,不能靠『能回應』來擔保」的習慣

本文主體

症狀:檔案是對的,回應卻不對

sitemap.xml 這個路由,一開始的寫法接近這樣:直接把儲存空間裡的檔案讀出來回傳。看起來合理——檔案內容確實是合法的 XML,路由也真的回應了 200。但 Google Search Console 的爬蟲讀到這個回應時,判定它「不是有效的 sitemap」,直接跳過。

追下去才發現:這個路由回應的 Content-Type header 帶的不是 application/xml,而是別的東西。瀏覽器打開這個網址看起來完全正常——瀏覽器對 Content-Type 沒那麼敏感,就算標成 text/plain 也會照樣把 XML 內容畫出來。但 Google 的爬蟲不是瀏覽器,它嚴格照 Content-Type 判斷這個回應該怎麼解讀,標錯了就直接不當一回事。

這是這個 bug 最陰險的地方:用瀏覽器手動驗證完全看不出問題,因為瀏覽器會幫你把錯的地方藏起來。

根因:一個不相干的 middleware 插手了 XML 路由

修這個 bug 花的時間比預期久,因為第一輪只把 Content-Type 補上去還是沒解決。後來才發現,sitemap.xml 這個路由當時掛在系統裡一個全站共用的 middleware 底下,這個 middleware 的職責跟 sitemap 完全無關——它是用來處理「依照使用者的設定,切換要載入哪一套畫面樣板」,過程中會寫入 session。

問題就出在這裡:session 的寫入動作,會在回應裡加上跟 session 有關的 cookie/header,這些額外的動作,加上 middleware 本身處理請求的方式,讓最終送出的回應不再是一個乾淨的 XML 串流。sitemap.xml 這種路由,理論上應該是全站最單純、最不該被任何跟「使用者狀態」有關的邏輯碰到的路由,卻意外被牽連進去。

修法分兩步:

  1. 把 sitemap.xml 路由移出那個全站共用的 middleware——它不需要知道使用者的畫面設定,也不該觸發 session 寫入
  2. 改用 response()->stream() 明確指定 Content-Type,而不是依賴框架的預設行為
public function xml()
{
    abort_unless(Storage::exists('sitemap.xml'), 404);

    return response()->stream(function () {
        fpassthru(Storage::readStream('sitemap.xml'));
    }, 200, [
        'Content-Type' => 'application/xml; charset=UTF-8',
    ]);
}

fpassthru() 搭配 Storage::readStream(),讓大檔案不用整個載進記憶體就能串流輸出,這也是為什麼選 response()->stream() 而不是直接 return Storage::get(...) 的原因——後者要先把整個檔案內容讀進字串,前者邊讀邊吐。

對照:兩種寫法的差異

回應「能動」但格式沒把關

public function xml()
{
    return Storage::response('sitemap.xml');
    // 檔案內容沒問題,但 Content-Type 交給框架猜,
    // 又掛在一個會寫 session 的全站 middleware 底下
}

明確控制格式,路由職責單純化

public function xml()
{
    abort_unless(Storage::exists('sitemap.xml'), 404);

    return response()->stream(function () {
        fpassthru(Storage::readStream('sitemap.xml'));
    }, 200, [
        'Content-Type' => 'application/xml; charset=UTF-8',
    ]);
}
// 路由移出全站共用 middleware,不再意外觸發 session 寫入

這個 bug 教會我的事

「檔案是對的」「路由能回應」是兩件容易被誤判成「這功能沒問題」的假訊號。真正該驗證的是回應本身——用 curl -I 去看實際送出去的 header,而不是用瀏覽器眼睛看內容對不對。瀏覽器的容錯能力,恰恰是這類 bug 最容易被隱藏的地方。

另外一個更值得記住的教訓:一個全站共用的 middleware,會被哪些路由用到,往往比你以為的更廣。sitemap.xml 這種「看起來跟使用者狀態完全無關」的路由,也可能因為掛在錯誤的中介層底下,被拖進不該有的副作用裡。這個 middleware 後面還會在別的情境裡再次出現——它不只造成過這次 sitemap 的問題。

今日思考題

你的專案裡,有沒有一個全站共用的 middleware,掛在了所有路由底下?回想一下,有沒有哪個路由其實不需要那個 middleware 提供的功能,只是因為「全站共用」而被動掛上去,卻因此承接了不該有的副作用?

今日重點回顧

  • 「檔案存在、路由能回應」不代表輸出格式是對的,Content-Type 錯誤在瀏覽器上完全看不出來
  • 真實案例:sitemap.xml 被錯誤地標成不對的 Content-Type,導致 Google Search Console 判定為無效的 sitemap
  • 根因不在 sitemap 本身,是一個跟畫面樣板切換有關的全站 middleware,意外觸發了 session 寫入
  • 修法:路由移出不相關的 middleware + 用 response()->stream() 明確指定 Content-Type
  • 驗證回應格式要用 curl -I 看實際 header,不能只靠瀏覽器手動測試

明日預告

明天要換一個主題:一支 Queue Job 怎麼用 Generator 安全地迭代一段日期區間,去外部系統抓資料,還要處理「同一筆資料抓兩次」這種真實會發生的情況。


上一篇
Day 11:排程 sitemap 自動產生——為什麼「產生」跟「回應請求」要刻意拆開
下一篇
Day 13:Queue Job 基礎——用 Generator 安全迭代日期區間去抓外部資料
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言