ASP.NET MVC的壓縮打包能有效縮小CSS與JS檔案體積,減少HTTP往返次數,進而提升網站效能。JavaScript經壓縮可讀性雖然已大幅下降,但"保護程式邏輯不外洩"的效果仍然有限,不必過度期望。只是壓縮對我還有另一層重大意義: "JavaScript中的註解會被一併移除!"

我很愛在程式裡寫故事註解,把程式邏輯修正的來龍去脈交待清楚,例如:
//2012-04-01 Bug Fix: VIP級使用者呼叫MehtodA前需呼叫MethodB以校正狀態
//2013-04-01 應客服部要求,錯誤次數上限調整為5
//2014-04-01 TODO: 不支援資料量大於1MB,未來如有超過上限需求,此段需修改

手上維護的系統一多,修改動機甚至更動本身很容易迷失在時間的洪流裡,等某天射茶包查出該處修改是禍源,要不是靠註解喚醒記憶,恐怕會有不小心問候到自己爹娘的風險;或者,急著把Bug修掉還原修改,卻忽略當初調整的理由,就會上演"修好A問題,以前改過的B問題又他X的冒出來"悲劇,少不了挨刮。腦海有幾段鮮明記憶: 某段修改在很久之後造成死傷慘重的大爆炸,靠著程式註解詳載使用者堅持調整的始末,第一時間列為呈堂證供,程式人員才沒被當祭品~ 註解能協助程式碼理解、防止邏輯被誤改,緊急時刻還能用來驅邪防身,是程序員的好朋友! 如果講成這樣還不能讓你寫註解,試試這個: 老闆請了個脾氣暴躁的程序員接手維護你的程式,有流言說那傢伙有重傷害前科...

總之,被註解救過小命,就更愛在程式碼加上滿滿註解,救人又救已,連JavaScript也不例外。但這類註解並不適合外流,其中可能涉及能當成八卦的敏感人事時地物,也可能透露開發者真性情,尤其JavaScript註解會原封不動送到Client端,要上到正式環境前,隱藏或移除才是上策。

ASP.NET MVC的打包壓縮功能啟用後,Client將出現<script src="/bundles/script_pack_name?v=XGaE…" type="text/javascript">及壓縮過內容,理論上使用者無從得知原始JavaScript路徑取得原始碼。但我的資安偏執容不下"看起來夠安全",畢竟原始檔案還是放在網站上,萬一有人得知原始路徑或壓縮功能被意外關閉,JavaScript的原始檔含註解就會經由/scripts/blah.js公諸於世,我決心要社絕這一層風險。

當初為專案客製的Script已集中在/scripts/mycode目錄方便管理,因此針對該目錄統一處理即可。一開始想到的做法是用WebGrease工具在每次部署時將JS檔壓成min.js,再將.js的內容也換成壓縮版,當同檔名的.js及min.js同時存在,ASP.NET MVC打包壓縮機制會直接取用min.js版打包,不致重複壓縮;而.js檔已被換成壓縮版,即使用/scripts/mycode/boo.js也看不到原始碼及註解。但這有個缺點 -- 當迫不得已必須在正式台偵錯時,看到的永遠是壓縮後的版本,無法用原始碼偵錯。

最後,我找到一個更彈性的解法: 使用HttpHandler攔截所有/scripts/mycode/*.js請求,讀取JS檔後即時壓縮再傳到Client端;而少數允許偵錯的內部機器,可事先在web.config中列舉IP,針對這些偵錯用IP,則提供未壓縮版本,魚與熊掌兼得。

在ASP.NET MVC網站加入AppScriptHandler.cs:

using Microsoft.Ajax.Utilities;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Hosting;
 
namespace MyWeb.Models
{
    public class AppScriptsHandler : IHttpHandler
    {
        public bool IsReusable
        {
            get { return false; }
        }
 
        static string[] debugClients = null;
        static string[] DebugClients
        {
            get
            {
                if (debugClients == null)
                {
                    var list = new List<string>();
                    string config = 
ConfigurationManager.AppSettings["AppScriptsDebugClients"];
                    if (!string.IsNullOrEmpty(config))
                    {
                        foreach (string ip in config.Split(',', ';'))
                        {
                            //Replace to regular expression pattern
                            list.Add(ip.Replace("*", "[0-9]+").Replace(".", "\\."));
                        }
                    }
                    debugClients = list.ToArray();
                }
                return debugClients;
            }
        }
        //Check if the ip address matches any pattern in DebugClients
        static bool IsDebugClient(string ip)
        {
            foreach (string ipPattern in DebugClients)
            {
                if (Regex.IsMatch(ip, ipPattern))
                    return true;
            }
            return false;
        }
        public void ProcessRequest(HttpContext context)
        {
            string jsFile = Path.GetFileName(context.Request.Url.AbsolutePath).ToLower();
            string jsPath = Path.Combine(
                   HostingEnvironment.MapPath("~/Scripts/mycode"), jsFile);
            var resp = context.Response;
            if (!jsFile.EndsWith(".js") || !File.Exists(jsPath))
            {
                resp.Write("//JavaScript not found: " + jsFile);
            }
            else
            {
                string js = File.ReadAllText(jsPath);
                resp.ContentType = "text/javascript";
                if (jsFile.EndsWith(".min.js") || 
                    IsDebugClient(context.Request.UserHostAddress))
                {
                    //if already minified or from debug clients
                    resp.Write(js);
                }
                else 
                {
                    //return minified result
                    Minifier minifier = new Minifier();
                    resp.Write(minifier.MinifyJavaScript(js));
                }
            }
        }
    }
}

在web.config中,加入appSetting指定可偵錯的來源IP: (支援萬用符號"*",酷吧?)
<add key="AppScriptsDebugClients" value="::1;127.0.0.1;192.168.1.*;172.28.*.*"/>

接著,在web.config要指定將script/mycode/*的存取請求交給AppScriptsHandler處理:
<system.webserver>
    <handlers>
    <add name="scripts" path="scripts/mycode/*" verb="GET" type="MyWeb.Models.AppScriptsHandler" preCondition="integratedMode,runtimeVersionv4.0"/>
    </handlers>
</system.webserver>

就這樣,使用偵錯機器可以看到原始版,其他IP只能看到壓縮版,成功對外隱藏開發者不為人知的一面,又能兼顧線上偵錯的便利性,很棒吧!


【延伸閱讀】


Comments

# by demo

類似的需求我會直接在VS內設定原始的JS在編譯時不輸出來解決^^

# by 挖賽

酷ㄟ~

# by metavige

是說現在如果是用 Angularjs,但是一堆 Html Template 是否也可以用類似的方式,移除註解以及 minify 呢?

# by Jeffrey

to metavige, WebGrease的壓縮針對JavaScript與CSS,如果您所指的Html Template內容主要都為HTML Tag,恐需另覓壓縮元件,但原理相同,應該可行。

# by metavige

瞭解,我去看了 WebGrease 的 Source ,的確只提供 js/css 的壓縮 因為我想 HTML 壓縮可能要考慮的東西比較多吧~~~ 另外我實際應用了一下這段程式碼,我發現如果指定的目錄下,有子目錄,子目錄的 js 檔案會無法顯示 所以我改了一下程式碼,讓子目錄的檔案也可以正常顯示 主要改的是 ProcessRequest 這個方法的前幾行 改的程式如下: string jsFile = Path.GetFileName(context.Request.Url.AbsolutePath); // 這邊跟原始檔案不同,是為了如果有子目錄的話,可以遞迴下去處理,避免把目錄寫死 var scriptRoot = "~" + context.Request.Url.AbsolutePath.Replace(jsFile, ""); string jsPath = Path.Combine( HostingEnvironment.MapPath(scriptRoot), jsFile); 提拱你參考!

# by Jeffrey

to metavige, 原程式的確未考慮到有子目錄的情境,謝謝你的補充。

Post a comment