即便前端發展已十分成熟,幾乎無所不能,但仍有些必須依賴 ASP.NET WebForm 的場合 - 例如,在前端專案中整合既有系統的 WebForm 網頁。如果該系統成熟且穩定,年資還比你多三倍,嚷著把它換掉改用前端重寫,通常老闆想換掉的會是你。另外,有些技術目前只有 WebForm 解決方案,ReportViewer 便是一例,此時直接在前端網頁整合 WebForm 是省時省力的做法。

這篇就來談談從前端整合 WebForm 網頁的幾種做法。

我做了張指定日期區間及排序方式的 RDLC 查詢報表為範例,藉此示範如何用純前端製作查詢介面,整合 WebForm ReportViewer 顯示報表,整合時需傳遞參數控制報表查詢結果也較符合實務應用情境。

查詢前端我採用在 HTML 引用 vue.js 的輕前端寫法,做了一個簡單的條件輸入查詢介面 - report.html,包含兩個 <input type="date" > 輸入查詢區間起迄日期,一個 <select> 選取排序欄位,<div class="loading" v-show="Loading"> 則是前幾天介紹的手作版載入中動畫

<div class="content" id="app">
    <div id="op">
        日期區間:
        <input type="date" v-model="StDate" />
        ~
        <input type="date" v-model="EdDate" />
        排序:
        <select v-model="SortBy">
            <option v-for="opt in SortOptions" v-bind:value="opt">{{opt}}</option>
        </select>
        <br />
        <button v-on:click="ShowQ()">
            查詢 (QueryString)
        </button>
        <button v-on:click="ShowX()">
            查詢 (XSS)
        </button>
        <button v-on:click="ShowP()">
            查詢 (POST Form)
        </button>
        <button v-on:click="ShowR()">
            查詢 (Param Trans)
        </button>
    </div>
    <iframe id="rptViewer" name="rptViewer" v-bind:src="IFrameSrc"></iframe>
    <div class="loading" v-show="Loading">
        <div class="mask"></div>
        <div class="lds-spinner"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
    </div>        
</div>

報表部分為求簡單,就不連資料庫了,在記憶體用色彩名稱模擬一份玩家清單,其中以亂數產生註冊日期,當成查詢日期區間標的:

public class SimulateData
{
    public static DataTable DataTable = null;
    static SimulateData()
    {
        var t = new DataTable();
        t.Columns.Add(new DataColumn("PlayerId", typeof(string)));
        t.Columns.Add(new DataColumn("Name", typeof(string)));
        t.Columns.Add(new DataColumn("RegDate", typeof(DateTime)));
        t.Columns.Add(new DataColumn("Score", typeof(int)));
        var rnd = new Random(9527);
        int i = 1;
        typeof(Color).GetProperties(BindingFlags.Static | BindingFlags.DeclaredOnly | BindingFlags.Public)
                    .Select(c => (Color)c.GetValue(null, null))
                    .ToList()
                    .ForEach(c =>
                    {
                        t.Rows.Add(
                            $"P{i++:000}",
                            c,
                            new DateTime(1990, 1, 1).AddDays(rnd.Next(10000)),
                            rnd.Next(32767));
                    });

        DataTable = t;
    }
}

RDLC 報表設計及呈現效果如下,最上方傳入三個 ReportParameter:@StDate、@EdData、@SortBy 以顯示起迄日期及排序依據:

顯示 ReportViewer 的 WebForm DemoReport.aspx,所有邏輯在 Page_Load() 寫完,它同時接受 POST Form 或 QueryString 傳入的 st (註冊區間起日)、ed (註冊區間迄日) 及 s (排序欄位) 參數作為查詢條件及排序依據。未輸入有效參數時則不顯示 ReportViewer。另外,程式支援經由 MemoryCache 傳遞查詢參數的做法,運作原理後面會介紹。

public partial class DemoReport : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        //https://blog.darkthread.net/blog/report-viewer-infinite-loop/
        //防止無窮迴圈
        if (ScriptManager1.IsInAsyncPostBack) return;

        var st = Request.Form["st"] ?? Request.QueryString["st"] ?? string.Empty;
        var ed = Request.Form["ed"] ?? Request.QueryString["ed"] ?? string.Empty;
        var s = Request.Form["s"] ?? Request.QueryString["s"] ?? string.Empty;
        var p = Request["p"] ?? "NA";
        var paramDict = (MemoryCache.Default.Get(p)) as Dictionary<string, string>;
        if (paramDict != null)
        {
            st = paramDict["st"];
            ed = paramDict["ed"];
            s = paramDict["s"];
        }
        if (string.IsNullOrEmpty(st) || string.IsNullOrEmpty(ed) || string.IsNullOrEmpty(s))
        {
            //未傳參數時顯示空白
            ReportViewer1.Visible = false;
        }
        else
        {
            ReportViewer1.Visible = true;
            ReportViewer1.ProcessingMode = ProcessingMode.Local;
            ReportViewer1.LocalReport.ReportPath = Server.MapPath("~/Reports/PlayerReport.rdlc");

            //此處以 DataView 模擬查詢資料庫
            DataView view = SimulateData.DataTable.DefaultView;
            //指定排序依據
            view.Sort = s;
            //指定查詢區間
            view.RowFilter = 
                $"RegDate >= '{DateTime.Parse(st):yyyy-MM-dd}' AND RegDate <= '{DateTime.Parse(ed):yyyy-MM-dd}'";

            ReportDataSource ds = new ReportDataSource("DataSet1", view.ToTable());
            ReportViewer1.LocalReport.DataSources.Clear();
            ReportViewer1.LocalReport.DataSources.Add(ds);
            ReportViewer1.LocalReport.SetParameters(new ReportParameter("StDate", st));
            ReportViewer1.LocalReport.SetParameters(new ReportParameter("EdDate", ed));
            ReportViewer1.LocalReport.SetParameters(new ReportParameter("SortBy", s));
        }
    }
}

從前端整合 WebForm 方法很多,核心概念是用 IFrame 嵌入 WebForm 頁面,至於觸發查詢及參數溝通有多種做法,可以走純 HTML 協定,也可利用 JavaScript XHR,這裡介紹四種不同做法:

  1. 透用 QueryString
    最簡單直覺的做法,修改 IFrame src="DemoReport.aspx?st=...&ed=...&s=..." 代入參數顯示結果
    這裡用 Vue 3 示範,將 <input> <select> 等輸入值繫結到 View Model 變數,按鈕時,將其組成 URL 指定給 IFrame 即完成所有動作。
    var vm = Vue.createApp({
        data: function () {
            return {
                StDate: '2000-01-01',
                EdDate: new Date().toJSON().substr(0, 10),
                SortBy: 'PlayerId',
                SortOptions: [
                    'PlayerId', 'Name', 'RegDate', 'Score'
                ],
                IFrameSrc: '../Reports/DemoReport.aspx',
                Loading: false
            };
        },
        methods: {
            ShowQ: function () {
                this.Loading = true;
                this.IFrameSrc = '../Reports/DemoReport.aspx?st=' + this.StDate + '&ed=' + this.EdDate + '&s=' + this.SortBy + '&_=' + new Date().getTime();
            }
        }
    }).mount('#app');
    function hideLoadingAnimation() {
        vm.Loading = false;
    }
    
  2. QueryString GET 方法有參數外露及易被 XSS 攻擊的缺點,可改從前端 POST Form 到 DemoReport.aspx
    賦與 IFrame name="rptViewer",在前端宣告一個 <form target="rptViewer" action="DemoReport.aspx">,其中加入 <input name="st">、<input name="ed">、<input name="s">,送出表單時 DemoReport.aspx 進入 IsPostBack == true 流程,可由 Request.Form["st"]... 等取得參數,並由於指定了 target="rptViewer",DemoReport.aspx 會顯示在 IFrame。report.html 的無形表單如下:
     <form action="../Reports/DemoReport.aspx" method="post" target="rptViewer"
           enctype="application/x-www-form-urlencoded" id="rptForm">
         <input type="hidden" name="st" v-model="StDate" />
         <input type="hidden" name="ed" v-model="EdDate" />
         <input type="hidden" name="s" v-model="SortBy" />
     </form>   
    
    由於 st、ed、s 等 hidden 欄位已用 v-model 連動,故最上方的 <input type="date" type="date"> 及 <select> 輸入值將即時更新,而 form 設定 target="rptViewer",只需呼叫 .submit() 即可模擬 PostBack 並顯示在 IFrame 中:
    ShowP: function () {
         this.Loading = true;
         document.getElementById('rptForm').submit();
     }
    
    這麼做的好處是 DemoReport.aspx 可限定只接受 POST 請求,停用 GET 請求可避免惡意人士捏造 URL 發動 Cross-Site Scripting 攻擊。延伸閱讀:隱含殺機的 GET 式 AJAX 資料更新
  3. Cross-Frame Scripting,若前端網頁與 WebForm 同屬一個站台,基於同源原則,前端的 JavaScript 可伸手進 IFrame 操作網頁元素
    在 DemoReport.aspx HTML 中加入隱藏的參數 input,由外部操作填入參數按鈕送出可模擬自身的 PostBack 行為。這種設計方式的好處是可在 DemoReport.aspx 埋入防止跨站請求偽造(Cross-Site Request Forgery,CSRF)的欄位、Cookie,或是使用 ASP.NET 內建的 ViewStateUserKey 防止請求偽造攻擊,提高網站安全性。
    實際做法要在 DemoReport.aspx HTML 加入參數欄位,為了與方法 2 共用參數接收邏輯,我是用純 HTML input 欄位,若無此考量可改用 WebForm 控制項 <asp:TextBox> 等標準 WebForm 控制項,口味更道地。最下方的 Script 則於網頁載入後隱藏父層網頁的載入中動畫。
     <form id="form1" runat="server">
         <div style="display: none">
             <input type="text" id="st" name="st" readonly />
             <input type="text" id="ed" name="ed" readonly />
             <input type="text" id="s" name="s" readonly />
         </div>
         <asp:ScriptManager ID="ScriptManager1" runat="server"></asp:ScriptManager>
         <div id="divReport">
             <rsweb:ReportViewer ID="ReportViewer1" runat="server" Height="100%" Width="100%">
             </rsweb:ReportViewer>
         </div>
         <script>
             if (parent && parent.hideLoadingAnimation) parent.hideLoadingAnimation();
         </script>
     </form>
    
    前端寫法如下:
     ShowX: function () {
         var doc = document.getElementById('rptViewer').contentWindow.document;
         doc.getElementById('st').value = this.StDate;
         doc.getElementById('ed').value = this.EdDate;
         doc.getElementById('s').value = this.SortBy;
         this.Loading = true;
         doc.getElementById('form1').submit();
     }
    
  4. 最後一種做法稍稍複雜,但客製彈性最大,能支援加密或更複雜的安全管控,做法是前端透過 WebAPI 將查詢參數存入伺服器端(例如:MemoryCache、DB)後取得 Token,將 Token 當作參數傳給 DemoReport.aspx,DemoReport.aspx 再以 Token 提取真正參數內容當查詢條件。
    為此我寫了一簡單的 WebAPI 示範:
     public class MvcApiController : Controller
     {
         public ActionResult SaveParam(string st, string ed, string s)
         {
             var key = Guid.NewGuid().ToString();
             MemoryCache.Default.Add(key, new Dictionary<string, string>
             {
                 ["st"] = st,
                 ["ed"] = ed,
                 ["s"] = s
             }, DateTime.Now.AddSeconds(30));
             return Content(key);
         }
     }
    
    而前端則寫成:
     ShowR: function () {
         var self = this;
         this.Loading = true;
         $.post("../MvcApi/SaveParam", {
             st: this.StDate, ed: this.EdDate, s: this.SortBy
         }).done(function (paramKey) {
             self.IFrameSrc = '../Reports/DemoReport.aspx?p=' + paramKey + '&_=' + new Date().getTime();
         });
     }
    

完整操作展示如下:

範例專案我已放上 Github,有興趣的同學可抓回去玩。

Example of embedding and communicating with WebForm from frond-end.


Comments

Be the first to post a comment

Post a comment