跳至主要內容
技術

PMTiles 取代傳統 tile server:HTTP Range Request 的單檔魔術

PMTiles 取代傳統 tile server:HTTP Range Request 的單檔魔術
打造 TaxMap-TW:用 Astro 做台灣所得地圖 第 4 / 10 篇

沒有底圖、tile、vector tile 的基本概念? 建議先看 Web 地圖底圖是什麼?vector vs raster、tile pyramid、style spec 一次搞懂

上一篇底圖比較 提到「自架需要 100GB+ 儲存」,我把自架直接排除了。但其實有第三條路:PMTiles

PMTiles 是 Protomaps 出品的新型 tile 格式。它的核心想法很瘋狂:

把上千萬個 tile 打包成 1 個檔案,丟到 S3 / R2 / Cloudflare Pages,瀏覽器靠 HTTP Range Request 只讀需要的位元組。

沒有 tile server、沒有 Docker、沒有資料庫。你可以把它想成「靜態檔案上的 tile 服務」。

TaxMap-TW 的 7,747 個村里 polygon 就是這樣上線的:一個 5.7 MB 的 .pmtiles 檔放在 Cloudflare Pages,瀏覽器只讀當前視窗需要的 chunks。整個地圖服務沒有任何 server。

這篇拆解 PMTiles 的設計,以及實戰怎麼產一個。

為什麼 PMTiles 想取代傳統 tile server

傳統做法是這樣的:

  1. tippecanoe 把 GeoJSON 切成 vector tiles,輸出成 MBTiles(一個 SQLite 檔,內含上萬條 tile blob)
  2. 跑一個 tile server(tileserver-glmartin)讀 MBTiles
  3. tile server 暴露 /{z}/{x}/{y}.pbf HTTP endpoint
  4. 瀏覽器透過 MapLibre 請求 tile

這個架構的痛點:

  • 需要 server:得有一台機器跑 24/7
  • 需要 SQLite 隨機讀:tile server 要把 MBTiles 開著、不停讀
  • 冷啟動慢:Cloud Run / Lambda 第一次請求 tile 要載入整個 SQLite
  • 延展性與成本:流量大時 server 變瓶頸

對小型 / 靜態網站來說,光是「為了底圖而養一台 server」就讓很多人卻步。

不過先把話說公道:tile server 的這些「麻煩」反過來也是它的能力。它能即時更新——資料一改、下一次請求就拿到新 tile,不用重切;能在伺服器端依參數過濾,回傳不同子集;能依登入身分或權限產出不同的 tile。這些都是把資料封進一個靜態檔的 PMTiles 做不到的。所以它不是「過時」,而是換了一組取捨。下面講的痛點,是針對「資料不常變、不需要動態查詢」的場景。

PMTiles 的核心想法

PMTiles 直接把問題拆掉:先把所有 tile(z/x/y 索引到 pbf binary)排成一個檔案、前面附上一份目錄(directory),然後把這個檔案丟到任何支援 HTTP Range Request 的地方(S3、R2、Cloudflare Pages、GitHub Pages 都行)。之後瀏覽器需要某個 (z, x, y) 的 tile 時,會先讀目錄查到那塊 tile 的 byte offset 和 length,再發一個像 Range: bytes=12345-67890 的請求,CDN 就只回那段 binary,MapLibre 直接吃。

整個流程沒有任何 server。.pmtiles 就是個靜態檔,CDN 加速、邊緣節點、cache 全都能用——前提是 host 真的支援 Range Request。它得在回應帶 Accept-Ranges: bytes、對 Range 請求回 206 Partial Content,跨網域用時還要設好 CORS。S3、R2、Cloudflare Pages、GitHub Pages 都 OK,但自架 nginx 或某些 CDN 設定不見得預設開。上線前用 curl -I -H "Range: bytes=0-99" 看一眼回的是不是 206,最省事。

HTTP Range Request 是什麼?

HTTP/1.1 早就有的功能(規格出自 1997 年的 RFC 2068,後來併入 1999 年的 RFC 2616),但很多人沒用過。

瀏覽器送出:

GET /villages.pmtiles HTTP/1.1
Host: tiles.example.com
Range: bytes=12345-67890

伺服器回應:

HTTP/1.1 206 Partial Content
Content-Range: bytes 12345-67890/5998619
Content-Length: 55546

<那段 binary>

關鍵點:伺服器只回那個範圍,不會把整個 6 MB 檔案傳給你。

這個能力一直存在,PMTiles 只是聰明地用它來實作「單檔 tile 服務」。

PMTiles vs MBTiles 比較

項目MBTilesPMTiles
格式SQLite(gzip 壓縮的 tile blob)自訂二進位 (header + directory + tile blobs)
需要 server是(tile-server-gl / martin)
部署server + SQLite 檔一個 .pmtiles 檔 + 靜態 host
客戶端讀取透過 server HTTP API瀏覽器直接 HTTP Range
Cloud-native不友善(要 server 跑 SQLite)完美(純靜態檔)
工具支援老牌、生態廣較新但 tippecanoe 直接支援
適合場景自架 tile 服務、企業內部靜態網站、無 server、低成本

對 Cloudflare Pages、Vercel、GitHub Pages 這類只能放靜態檔、不能跑 server 的託管,PMTiles 幾乎是唯一不用另外架服務就能上 vector tile 的選項。

PMTiles 的限制:什麼時候不該用

寫到這裡好像 PMTiles 全面碾壓,但它換來的好處是有代價的。Protomaps 官方自己就有一篇〈You Might Not Want PMTiles〉,我自己用下來,下面幾個情況我不會選它:

  • 資料常變動:PMTiles 是不可變的單一檔。改一個村里的幾何,就得整包重切、重傳、讓 CDN 重新快取。TaxMap 的村里界一年才更新一次,這成本可以接受;如果是每天甚至每小時更新的資料,傳統 tile server 的「改了就生效」反而省事。
  • 需要動態查詢或權限控管:PMTiles 出去的是固定的一份檔,沒辦法依使用者身分、登入狀態回不同內容。要「付費才看高解析、依角色給不同圖層」這種需求,還是得有一層 server 把關。
  • 巨型資料集:檔案越大、目錄越深,一次 tile 請求可能要先讀好幾跳目錄才找到 byte offset,Range Request 的來回次數跟頻寬都會上去。幾 MB 到幾百 MB 很舒服,但到了幾十 GB 的全球底圖等級,就要認真評估 CDN 命中率與請求數,不是無腦丟上去就好。

簡單說:資料靜態、規模中小、不需要伺服器端邏輯時,PMTiles 很香;反過來就回去用 tile server。

3 步驟產一個 PMTiles

完整實戰:把一份 GeoJSON 變成可以丟 CDN 的 .pmtiles

1. 裝 tippecanoe

tippecanoe 是把 GeoJSON 切成 vector tile 的 CLI。原來是 Mapbox 出的,但他們從 2020 停更,現在用社群維護的 felt fork:

brew install tippecanoe
# 或從 source build
git clone https://github.com/felt/tippecanoe.git
cd tippecanoe && make -j && sudo make install

注意 Mapbox 原版 tippecanoe 已 2020 年停更,felt fork 是現役版本。它的 --output 看副檔名自動決定格式,.mbtiles.pmtiles 兩種都能直接吐——所以同一份 GeoJSON 想換格式,只要改輸出檔名就好。

2. 從 GeoJSON 產 PMTiles

最簡單的命令:

tippecanoe \
  --output=villages.pmtiles \
  --layer=villages \
  --minimum-zoom=6 \
  --maximum-zoom=13 \
  --detect-shared-borders \
  --coalesce-densest-as-needed \
  --extend-zooms-if-still-dropping \
  --force \
  villages.geojson

幾個關鍵 flag:

  • --minimum-zoom / --maximum-zoom:要切哪些 zoom level。z=6 看全國,z=13 看里街道
  • --detect-shared-borders:相鄰 polygon 共用邊界,省 50% 大小
  • --coalesce-densest-as-needed:低 zoom 自動合併小 polygon 避免破圖
  • --extend-zooms-if-still-dropping:高 zoom 還有特徵會自動延伸

跑 30 秒,吐出 5.7 MB 的 villages.pmtiles

3. 放到 CDN + 在 MapLibre 接

villages.pmtiles 丟到 public/data/geometry/villages.pmtiles,部署到 Cloudflare Pages。

前端裝 pmtiles npm

pnpm add pmtiles

接到 MapLibre:

import maplibregl from 'maplibre-gl';
import { Protocol } from 'pmtiles';

// 註冊 pmtiles:// protocol handler
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.openfreemap.org/styles/positron',
  center: [121.0, 23.7],
  zoom: 7,
});

map.on('load', () => {
  map.addSource('villages', {
    type: 'vector',
    url: 'pmtiles:///data/geometry/villages.pmtiles',
    promoteId: 'VILLCODE',  // 把 properties.VILLCODE 提升為 feature.id
  });

  map.addLayer({
    id: 'villages-fill',
    type: 'fill',
    source: 'villages',
    'source-layer': 'villages',
    paint: {
      'fill-color': '#41b6c4',
      'fill-opacity': 0.7,
    },
  });
});

關鍵在 pmtiles:// 這個自訂 protocol。MapLibre 看到 pmtiles://... 開頭的 URL 會交給 Protocol.tile handler,handler 用 HTTP Range Request 讀取 PMTiles。

上面 fill-color 我先寫死成 YlGnBu 的青色 #41b6c4 只是為了把幾何畫出來。所得地圖真正的色階後來改成了 OrRd,原因見 為什麼我把所得地圖色階從 viridis 換成 OrRd

對 MapLibre 來說,這就像是個正常的 vector tile source。但實際上瀏覽器只下載當前視窗需要的幾個 chunks(首屏約 200 KB),不是整個 6 MB。

PMTiles 在 TaxMap-TW 的實戰

TaxMap-TW 是個剛上線的台灣所得稅地圖,全台 7,747 個村里 polygon 都用 PMTiles 上線。

實際整合的踩雷:

1. 從 MOI 下載的 Shapefile 是 Big5 編碼

內政部國土測繪中心 的村里界 shapefile zip 內含中文檔名,macOS 預設 unzip 會崩潰。改用 macOS native ditto

ditto -x -k villages-1130928.zip villages-shp/

2. tippecanoe 的 --read-parallel 會把 5.6 MB 的 GeoJSON 切碎解析錯誤

我的 GeoJSON 一行一個 feature(mapshaper 輸出格式)。--read-parallel 想平行讀但會在某些行切錯。單線程讀更穩,30 秒也不算慢。

3. promoteId 是必須的

預設情況下 tippecanoe 產出的 tile 不會把 properties 的 ID 欄位提升到 feature.id。這會導致 MapLibre 的 setFeatureState(用來做 hover 反白)失效。在 source 設定加 promoteId: 'VILLCODE' 即可。

4. Mapshaper 簡化要用 keep-shapes

把 NLSC 50 MB 的 shapefile 簡化到 5.6 MB GeoJSON,預設會「丟掉太小的 polygon」。但每個村里都要保留,所以要加 -simplify 5% keep-shapes,太小的村里也會被保留。

反思

讓我印象最深的是 Range Request 這個東西本身。它出自 1997 年的 RFC 2068、躺在 HTTP 規格裡快三十年,PMTiles 在 2021 年初發表、那年 FOSS4G 上正式亮相後,GIS 圈才比較多人這樣用它來做單檔 tile 服務。倒不是「沒人想到」,而是要等 R2 這類便宜物件儲存、CDN 對 Range 的普遍支援、瀏覽器端讀 PMTiles 的函式庫這幾個條件同時成熟,這套玩法才划算。我那天接完 pmtiles:// 看到地圖跑起來、整個專案連一台 server 都沒有,確實愣了一下——原來繞了一圈,答案是 HTTP 本來就會的事。

架構上 PMTiles 是「移除中間層」:傳統 tile 架構有 4 層(origin DB → tile server → CDN → browser),它直接拿掉 tile server 那層(origin file → CDN → browser)。少一層確實少一個故障點,但代價是原本集中在 server 那層的快取策略、可觀測性、錯誤處理也一起沒了,這些複雜度其實是轉嫁到了客戶端和 CDN 設定上,只是不再是「我要顧的一台機器」而已。

成本面,PMTiles + Cloudflare 的零成本全端組合對個人專案、低中流量、用量落在免費額度內的情況,幾乎可以做到月付 $0:TaxMap-TW 的 OpenFreeMap 底圖、PMTiles polygon、SSG 靜態頁、CDN 流量加起來就是 $0。但 $0 不是物理定律——Range Request 一樣算請求數和頻寬,真的爆紅、流量衝破免費額度,帳單照樣會來。

後記:這篇講的「TaxMap 放在 Cloudflare Pages」其實是當時的狀態。後來「一頁一檔」的 SSG 頁面撞到 Cloudflare Pages 的 2 萬檔上限,我把站搬到了 Netlify,過程寫在 Cloudflare Pages 的 20,000 檔案上限。PMTiles 這套單檔做法本身沒變,變的是放它的平台。

如果你有「想做地圖但被 server 嚇退」的點子,週末花一兩個小時,從 GeoJSON 到丟上 CDN 是真的跑得完的——只是記得先確認你的資料是不是適合走靜態這條路。

系列其他文章

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →