openresty-minifiers 是 OpenResty Inc. 開發的一款高效能私有 minifier 庫。它是一個 Nginx output filter 模組,能對 JS、CSS、HTML 這三類資源進行執行時的流式壓縮。特別適用於無法干預構建流程的場景——比如反向代理遺留系統、多租戶閘道器、邊緣節點透明最佳化等。在我們的測試環境,它單核吞吐可達 120+ MB/s

在你的 CI/CD pipeline 裡,minification 大概已經是個很無聊的環節了。

Webpack 配置裡的 TerserPlugin,Vite 預設開啟的 esbuild minify,cssnano 在 PostCSS 鏈條裡默默執行——這些工具已經成熟到你幾乎感覺不到它們的存在。你配置一次,它們就執行幾千次,從不出錯。這個問題,工程界早在十年前就解決了。

但我們今天要討論的,不是構建期的 minification,而是當你無法觸達構建期時,情況會變成甚麼樣。

約束改變,問題重開

想象三個場景:

場景一:反向代理遺留系統。 你接管了一個十年前的 Java 單體應用。它的前端資源沒有經過任何壓縮,但你沒有許可權改動它的構建流程,甚至沒有它的原始碼。你的 OpenResty 閘道器是唯一能介入的地方。

場景二:多租戶 SaaS 閘道器。 你的平臺為數百個租戶代理流量,每個租戶的應用由他們自己維護。你需要在閘道器層統一最佳化出口頻寬,但你無法要求每個租戶改造他們的構建工具鏈。

場景三:邊緣節點透明最佳化。 你在 CDN PoP 節點部署了 OpenResty,希望對所有流經的靜態資源做實時壓縮,不依賴任何上游配置變更。

這三個場景有一個共同特徵:minification 必須發生在執行時,在 Nginx filter 層,對響應體做流式處理

這個約束,讓一個本已解決的問題,需要我們從頭審視。

為甚麼這個問題比想象複雜

大多數工程師在第一次遇到這個需求時,會有一個直覺反應:“這不就是字串處理嗎?用正則替換掉註釋和多餘空格不就行了?”

這個直覺是錯的,而且錯得很有代表性。

Minification 是語法感知操作

以 JavaScript 為例,考慮這樣一段程式碼:

var result = a / b / c;
var regex = /pattern/g;
var str = "remove // this comment? no";
// this is a real comment

你需要移除真正的註釋(第四行),但不能動字串字面量裡的 // this comment?,不能動正則字面量 /pattern/g 裡的斜槓,也不能把除法運算 a / b / c 誤判為註釋起點。

這就是 JavaScript 詞法分析裡著名的斜槓二義性問題/ 這個字元,在不同上下文下可以是除法運算子、正則字面量的開始、或註釋的開始。只看區域性特徵根本沒法區分,必須維護完整的解析狀態才行。

CSS 有類似的問題:url() 函式內部的內容不能被當作普通文字處理,calc() 中的空格具有語義意義(calc(100% - 20px) 的空格不能刪除)。HTML 則更復雜——<script><style> 標籤就需要切換到完全不同的解析模式,而這兩種模式恰好就是 JS 和 CSS 的解析模式。

任何不考慮這些語法上下文的 minifier,都免不了在生產流量中引入難以排查的 bug。

流式處理讓問題難度倍增

構建期的 minifier 拿到的是完整檔案,可以先解析出完整 AST,再基於 AST 做變換輸出。整個流程乾淨又確定。

但在 Nginx filter 層,你拿到的是一個個資料流片段。一個 HTTP 響應體會被切成若干個 buffer chain 傳遞給 filter 模組,每個 buffer 的大小由 upstream 和核心決定,對 filter 來說是不可預測的。

也就是說,一個註釋可能橫跨兩個 buffer:前半個 buffer 以 /* 結尾,後半個 buffer 以 */ 開頭。一個字串字面量可能在某個 buffer 邊界處於"尚未閉合"的狀態。一個 </script> 標籤也可能被切割成兩個 chunk。

樸素的實現會在這些邊界上悄悄地出錯——它不報錯,但會生成一個偶爾在瀏覽器裡解析失敗的 JS 檔案。

O(1) 空間約束排除了所有傳統方案

最直接的應對思路是:把整個響應體 buffer 到記憶體,再用成熟的離線 minifier 處理它。

問題是,這破壞了 Nginx 的流式處理模型,並帶來嚴重的工程風險:

  • 記憶體不可控:一個 2MB 的 JS 檔案需要至少 2MB 的額外堆記憶體,乘以併發連線數就是數十 GB。
  • TTFB 延遲增加:必須接收完整個響應體才能開始處理,客戶端的第一位元組延遲直接拉長。
  • 單個大檔案可以打崩 worker:沒有上限的 buffer,本身就是一個 DoS 向量

一個可行的方案,必須能在固定大小的 buffer(比如 8KB)內完成增量處理,並且要保證處理結果的正確性——即便語法結構跨越了 buffer 邊界。

所以,你需要的其實不是一個 minifier,而是一個基於有限狀態機的流式詞法分析器,它得有能力在 O(1) 記憶體下,正確維護跨 buffer 的解析狀態。這已經是一個完全不同的工程問題了。

樸素方案會在哪裡出錯

在沒有專用工具的情況下,工程團隊通常會嘗試以下路徑:

路徑一:body_filter_by_lua + Lua 正則。 用 OpenResty 的 Lua API 接收完整響應體,然後做字串處理。這個方案在小檔案上能工作,但它隱含了對完整 buffer 的需求——一旦響應體超過幾十 KB,記憶體壓力和 GC 停頓就會變得可觀。更根本的問題是,Lua 的字串操作,本身就不是為語法感知的流式處理設計的。

路徑二:sub_filter 指令。 Nginx 原生的 ngx_http_sub_module 提供了字串替換功能,但它是字面量匹配,對語法結構完全沒有感知。用它刪註釋會誤刪程式碼中的同名字串,沒有實用價值。

路徑三:上游應用層處理。 在後端服務里加一箇中介軟體做壓縮。這把 minification 的職責推回了應用層,繞過了“無法修改上游”這個核心約束,同時還引入了額外的服務依賴和延遲。

這些方案不是壞的工程決策,它們是在約束條件下的合理嘗試。但它們都沒有真正解決“流式 + 語法感知 + O(1) 記憶體”這個三角約束。

如果你正在面對上述任一場景,或者已經在 body_filter_by_lua 的方向上踩過坑,可以先看看我們是怎麼解決這個問題的。

openresty-minifiers 如何解決這個問題

理解了問題的結構,就能明白甚麼樣的解法才算對症下藥。

這三個約束——語法正確性、流式處理、固定記憶體——並不是三個可以獨立最佳化的維度,它們之間存在著實實在在的張力:

  • 提高語法正確性,通常需要更多上下文狀態,這與 O(1) 記憶體矛盾
  • 流式處理要求增量輸出,但某些語法變換(如重寫相對路徑)需要向前看,這與純流式矛盾
  • 為每種語言維護完整的解析狀態,本身就需要投入專門的工程力量

真正滿足這三個約束的方案,需要為 JS、CSS、HTML 各自設計一個專用的流式有限狀態機,並將其編譯為 native 程式碼(Nginx filter module),這樣才能保證處理速度不會成為請求鏈路的瓶頸。

這不是一個可以在現有工具之上組合出來的解決方案,而是需要從頭開始設計。

工程驗證:數字說明甚麼

openresty-minifiers 是 OpenResty Inc. 為上述場景專門設計的私有庫,包含 JS、CSS、HTML 三個獨立的 minifier 模組,均作為 Nginx output filter 實現。

核心效能指標:

  • JS minifier 單核吞吐:120+ MB/s(測試平臺:Core i9-13900K)
  • 時間複雜度:O(n),n 為響應體長度
  • 空間複雜度:O(1),預設使用 8KB 固定 buffer

公開 benchmark 資料:https://openresty.org/misc/re/bench/

這些數字的工程意義是:

120 MB/s 的處理速度,換算一下就是單核約 960 Mbps 的 minification 能力。在典型的千兆出口場景下,單核處理能力已與出口頻寬相當,minification 不會成為請求鏈路的效能瓶頸。對於 10 Gbps 及以上的高頻寬場景,透過多核並行就能線性擴充套件處理能力。

O(1) 記憶體,意味著這個模組對任意大小的響應體都是安全的——你不需要為大檔案設定白名單,不需要擔心單個超大檔案打崩 worker。記憶體使用是確定性的,讓容量規劃變得非常簡單。

該庫底層使用了 OpenResty Inc. 自研的基於 DFA 最佳化演算法的正則編譯器(or-regex),這也是它能在保持 O(1) 記憶體的同時達到 120+ MB/s 吞吐的關鍵。如果你想在自己的環境裡驗證這些數字,或者評估它是否適合你的流量規模,可以聯絡我們的技術團隊

五分鐘接入:配置示例

該庫以私有包形式分發,需要有效訂閱 token。安裝依賴 replace-filter-plus 模組,兩者透過包管理器一併安裝:

# apt (Ubuntu/Debian)
sudo apt-get install -y openresty-minifiers replace-filter-plus-nginx-module-1.21.4

# yum (RHEL/CentOS)
sudo yum install -y openresty-minifiers replace-filter-plus-nginx-module-1.21.4

配置分為兩層:全域性預載入(在 http 塊)和按 location 啟用(在具體路由上)。

以 JS minifier 為例:

http {
    # 載入 filter 模組
    load_module /usr/local/openresty/nginx/modules/ngx_http_replace_filter_module.so;

    # 預編譯 minification 程式,init 階段完成,不影響請求延遲
    replace_filter_preload /usr/local/openresty-minifiers/lib/min-js.so
                           /usr/local/openresty-minifiers/tpls/min-js.tpl;

    init_by_lua_block { require "resty.replace" }

    server {
        location ~ \.js$ {
            replace_filter_types application/javascript;
            replace_filter_max_buffered_size 8k;

            access_by_lua_block {
                local ok, err = require "resty.replace".pick("min-js")
                if not ok then
                    error("failed to pick replace prog: " .. err)
                end
            }
        }
    }
}

CSS 和 HTML minifier 的配置結構完全相同,只需將 .so.tpl 路徑和 pick() 引數替換為對應的 min-css / min-html。三種 minifier 可以獨立載入,按需組合。

上線前需要知道的四件事

MIME type 匹配需要與上游對齊。 replace_filter_types 預設只處理 text/html。如果你的上游返回 text/javascript 而非標準的 application/javascript,需要在配置中顯式宣告:

replace_filter_types application/javascript text/javascript;

建議透過 curl -I 確認上游實際返回的 Content-Type,再配置對應的 MIME type。

replace_filter_max_buffered_size 通常無需調整。 預設 8k 對絕大多數場景已經足夠。這個引數控制的是 filter 模組在處理跨 buffer 語法結構時的最大緩衝量,不是響應體的最大處理大小——即便是 10MB 的 JS 檔案,也只會佔用 8KB 的常量記憶體。

Last-Modified 頭部預設會被清除。 壓縮後的響應體內容已經變了,所以 replace_filter_last_modified 預設為 clear。如果你的 CDN 快取策略依賴 Last-Modified 做 conditional request,需要評估這個行為對快取命中率的影響。如需保留:

replace_filter_last_modified keep;

建議先在低風險 location 灰度啟用。 即便是經過生產驗證的工具,任何新引入的 filter 模組都值得先在非關鍵路徑上跑幾天流量,確認與你的上游內容沒有特殊相容問題,再全量推廣。

結語

總結一下,將程式碼壓縮等任務放到 Nginx 執行時處理,能帶來極大的收益,但也極度考驗底層最佳化能力。

本文提到的高效壓縮方案,目前已封裝為 openresty-minifiers 模組,幷包含在 OpenResty XRay 的私有庫合集中。同系列還有針對 Redis、HTTP、Kafka 的高效能 C 實現客戶端庫,最佳化過的 LuaJIT 引擎,以及無鎖架構的動態指標統計模組——解決的都是同一類問題:在 OpenResty 執行時,把效能做到開源方案難以觸及的位置。完整列表見私有庫合集

如果您正面臨本文所述場景,可透過右下角"聯絡我們"與我們的工程師團隊取得聯絡,獲取部署方案與訂閱資訊。

關於作者

章亦春是開源 OpenResty® 專案創始人兼 OpenResty Inc. 公司 CEO 和創始人。

章亦春(Github ID: agentzh),生於中國江蘇,現定居美國灣區。他是中國早期開源技術和文化的倡導者和領軍人物,曾供職於多家國際知名的高科技企業,如 Cloudflare、雅虎、阿里巴巴, 是 “邊緣計算“、”動態追蹤 “和 “機器程式設計 “的先驅,擁有超過 22 年的程式設計及 16 年的開源經驗。作為擁有超過 4000 萬全球域名使用者的開源專案的領導者。他基於其 OpenResty® 開源專案打造的高科技企業 OpenResty Inc. 位於美國矽谷中心。其主打的兩個產品 OpenResty XRay(利用動態追蹤技術的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最適合微服務和分散式流量的全能型閘道器軟體),廣受全球眾多上市及大型企業青睞。在 OpenResty 以外,章亦春為多個開源專案貢獻了累計超過百萬行程式碼,其中包括,Linux 核心、Nginx、LuaJITGDBSystemTapLLVM、Perl 等,並編寫過 60 多個開源軟體庫。

關注我們

如果您喜歡本文,歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:

我們的微信公眾號

翻譯

我們提供了英文版原文和中譯版(本文) 。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!