:::

JSONP跨網域傳送檔案:以POST方法實作

image

網頁應用設計中的JSONP技巧可以做到跨網域資料傳送的功能,但它卻只能傳送純文字資料,而我希望能在跨網域的環境下開發AJAX上傳檔案的功能,那麼就需要一些小技巧才能進行。

雖然我想應該也有人提出這個作法了,不過實際上在之前我並沒有找到過。因此我把這個在論文中實作的技巧在此記錄、分享。

這篇用到大量的網頁設計專有名詞,主要是以AJAX設計網頁應用的人為主要對象撰寫的。


範例

image

這個範例網頁中,你可以上傳一個檔案,並填寫下面的敘述Description,再按下UPLOAD按鈕上傳。伺服器會回傳給你檔案的上傳結果、檔案名稱、檔案大小。檔案上傳最大是1MB,超過1MB的檔案,伺服器會回傳錯誤結果。

原本我是將HTML網頁放在Dropbox空間(http://dl.dropbox.com/),而伺服器則是放在You Hosting的空間(http://pulipuli.co.cc/),想用兩個不同網域來呈現透過POST方式實作JSONP跨網域檔案上傳的範例。但是因為You Hosting放著一陣子就被強制關閉了,所以很遺憾的,在此無法看到線上即時的成果。(2011/9/19更新)

此範例中用了三個檔案,請直接下載研究、並自行配置使用吧:

應用背景

使用AJAX開發網頁應用的程式設計師,大多時候都是在同個網域之下處理client(使用者端電腦)與server(伺服器)之間的訊息傳遞。更進一步的,利用jQuery框架,以JSON物件、GET方式傳遞資料的jQuery.getJSON()函數,讓處理AJAX資料傳遞更為簡單。

但是我的論文中需要使用的AJAX跟上述常見情況有個很大的差別。第一個是跨網域,client端呼叫server端資料,這兩者是位於不同的網域,因此普通的GET或POST方式都無法讓client端取得server端的資料,必須仰賴JSONP(JavaScript Object Notation with Padding)技巧;其二,這次要傳送的資料並非純文字或可以用JSON來組織的資料,而是需要上傳二進位的檔案,或是超過GET資料傳送上限的資料量。在這種情況下是不能單純使用JSONP,必須使用POST方式與form表單傳送資料才行。

因此,我將POST的傳送優勢與JSONP的跨網域AJAX特色融合,寫成以POST方式實作的JSONP跨網域檔案傳送。

角色

在此應用中是以前端的瀏覽器與遠端的伺服器進行溝通合作,因此我先簡單敘述這兩種角色。

「C」 client

在這篇文章中,我以「C」表示「client」(客戶端)瀏覽器中執行的程式語言,主要是HTML跟JavaScript,這也是AJAX的基礎。多虧了jQuery框架克服了相容性問題,因此在Chorme、Firefox或IE都可以使用。

在此範例中,「C」是擺在Dropbox的空間,他並不具備伺服器端的功能,不能判斷檔案大小。此外,我將「C」的HTML外觀寫在client.html當中,而JavaScript的邏輯運作寫在client.js當中。稍候會再敘述他們的功用。

「S」 server

另外「S」代表「server」(伺服器),在此範例中我以PHP寫成,擺在You Hosting空間。伺服器端的程式語言具備了處理檔案的能力,可以判斷檔案大小。在此利用AJAX架構,讓「C」能夠與「S」進行溝通。

為了利用POST傳送檔案,並能以JSONP進行跨網域溝通,「S」必須判斷請求方式(request method)是POST還是GET。並以session來暫存狀態。

稍微有點概念之後,接下來就是實際看看這方法要怎麼運作了。


Step1. C1:資料準備

 image

為了讓大家好理解,此範例中的檔案上傳做得跟傳統網頁一樣。這是一個<form>表單,裡面有個可以選擇檔案上傳的input,並限制只能上傳1MB大小的檔案,還有一個可以撰寫「Description」(敘述)的input,最後則是一個submit(遞交)的「UPLOAD」按鈕。

在這個表單前面,引用了大家的好朋友jQuery以及我另外寫的client.js;表單後面則有個<div>容器,負責顯示待會跟伺服器溝通的結果。

原始碼很簡單,寫在client.html中,摘錄如下:

<script src='http://www.google.com/jsapi' type='text/javascript'></script>
<script type='text/javascript'>google.load("jquery","1.2.6");</script>
<script src="client.js" type="text/javascript"></script>
<form id="form" method="post" action="http://pulipuli.co.cc/20110517-jsonp-post/server.php" enctype="multipart/form-data">
    <input type="hidden" name="max_file_size" value="1048576">
    <label>File: <input type="file" name="file" /> * Max File Size: 1MB</label>
    <br />
    <label>Description: <input type="text" name="description" value="The description of this file."></label>
    <br />    
    <button type="submit">UPLOAD</button>
</form>
<div id="output"></div>

另外,為了讓這個form表單能具備AJAX跨網域上傳的功能,我利用JavaScript幫他做了些初始化的調整,讓form的遞交會以AJAX方式進行,並設定回傳資料時所要執行的回呼函數(callback)。

這部份寫在client.js開頭,程式碼如下:

/**
 * Initialize Form
 */
$(function () {
    
    $("#form").submit(function () {
        
        if ($(this).attr("jsonp_by_post") == null)
        {
            $.jsonp_by_post(this, function (_result) {
                //...Step7時再講解... 
}); return false; } }); });

Step2. C2:AJAX式的POST遞交

當form的UPLOAD按鈕被按下時,就會開始一連串的AJAX式的POST遞交作業。

在此步驟中,JavaScript會做以下事情:

  1. 以timestamp作為辨識每次檔案上傳作業的代號。
  2. 讀取form的資料,並將timestamp設定到action的網址中,提供伺服器辨識每次檔案上傳作業的代號。
  3. 建立一個接收資料的iframe,隱藏之,並設定name。
  4. 調整form的action跟name,讓form的遞交會傳送到iframe去,而不是把整個網頁都替換掉。
  5. 給form做個記號(此範例用jsonp_by_post屬性),以免跟Step1初始化form的submit事件相衝突。
  6. 設定iframe遞交完成之後的後續動作。
  7. form執行遞交作業。

程式碼寫在client.js,摘錄如下,其中6.設定iframe遞交完成之後的動作,我會在下面的Step4詳細敘述,這邊先以略過。

$.jsonp_by_post = function (_form_ele, _callback) {
    
    var _timestamp = (new Date()).getTime();
    
    var _form_obj = $(_form_ele);
    
    var _action = _form_obj.attr("action");
    var _action_post = _action + "?timestamp=" + _timestamp;
    
    //建立接收資料的iframe
    var _iframe_name = _action + _timestamp;
    
    var _iframe_obj = $('<iframe></iframe>')
        .attr('name', _iframe_name)
        .appendTo($('body'));
        
    //隱藏iframe
    _iframe_obj.css('width', '0').css('height', '0')
        .css('position', 'absolute').css('left', '-1000px').css('top', '-1000px');
    
    //調整傳送資料的form
    _form_obj.attr('action', _action_post)
        .attr('target', _iframe_name)
        .attr('method', 'post')
        .attr('enctype', 'multipart/form-data');
    
    //防止重複觸發初始化的事件
    _form_obj.attr('jsonp_by_post', 'true');
        
    //設定iframe讀取完畢之後的動作
    
    _iframe_obj.load(function () {
        
        //...Step4時再講解...
        
    });
    
    //執行遞交
    _form_obj.submit();
};

Step3. S1:伺服器接收POST資料

伺服器端的程式會依照請求方式的不同,決定此程式是要接收檔案上傳,還是要回報檔案上傳的結果。

在Step3中,form以POST方式呼叫了位於另一個伺服器的server.php,並執行以下動作:

  1. 接收$timestamp,作為判斷這次作業的辨識代號,並與一個特定的$header組成session使用的$index。
  2. 透過透過PHP的$_SERVER['REQUEST_METHOD']來判斷請求方式。
  3. 如果是POST,則將檔案的檔名、大小、以及從POST過來的description資料儲存在session當中。
  4. 判斷檔案上傳的狀態,失敗就是「error」,成功則是「sussesful」。
  5. 顯示一些資料,作為簡單偵錯。但其實可以省略。

以下程式碼寫在server.php中,摘錄如下。請求方式是GET的情況,我會在下面的Step6再來講解。

<?php
$header = "data";
$timestamp = $_GET["timestamp"];
$index = $header . $timestamp;

session_start();

if ($_SERVER['REQUEST_METHOD'] == "POST")
{
    $description = $_POST["description"];
    
    $_SESSION[$index]['name'] = $_FILES['file']['name'];
    $_SESSION[$index]['size'] = $_FILES['file']['size'];
    $_SESSION[$index]['description'] = $description;
    
    if ($_FILES['file']['size'] == 0)
    {
        $_SESSION[$index]['state'] = 'error';
        echo "false";
    }
    else
    {
        $_SESSION[$index]['state'] = "sussesful";
        echo "true";
    }
}
else
{
    // ...Step5時再講解...
}

Step4. C3:以JSONP取得檔案上傳結果

當iframe讀取完畢之後,「C」就可以知道檔案上傳動作已經結束。但是因為POST在跨網域的狀況下是無法取得iframe裡面的資料,所以必須要改用JSONP的方式來跟「S」取得結果。

這些動作寫在Step2中省略的iframe onload事件中,大概動作如下:

  1. 設定JSONP的網址,加入jQuery.getJSON()特有的callback=?參數。
  2. 執行jQuery.getJSON(),並設定執行完後的回呼函數。

這些程式碼寫在client.js中,摘錄如下。執行完getJSON()的回呼函數會在Step6講解。

    //設定iframe讀取完畢之後的動作
    
    _iframe_obj.load(function () {
        
        //設定get方法JSONP的網址,要注意加入了JSONP特有的callback參數
        var _action_get = _action + "?timestamp=" + _timestamp 
            + "&callback=?";
          
        $.getJSON(_action_get, function (_result) {
            // ...Step6時再講解...
        });
        
    });

Step5. S2:伺服器回傳檔案上傳的結果

以JSONP方式呼叫伺服器時,「S」是以GET方式運作,他會做以下動作:

  1. 接收$timestamp,並與$header組成$index。這跟Step3做的事情一樣。
  2. 判斷請求方法為GET。
  3. 取得回呼函數的代號$callback,這是搭配jQuery.getJSON的作法。
  4. 從$index取得session資料,將檔案上傳結果存進$output字串。
  5. 刪除session資料,避免後來有心人再利用同樣的timestamp取得資料。
  6. 以JSONP方式輸出。

以下程式碼寫在server.php中,摘錄如下:

<?php
$header = "data";
$timestamp = $_GET["timestamp"];
$index = $header . $timestamp;

session_start();

if ($_SERVER['REQUEST_METHOD'] == "POST")
{
    // ...Step3講解...
}
else
{
    $callback = $_GET['callback'];

    $output = '';
    if (isset($_SESSION[$index]))
    {
        $output = $_SESSION[$index]['state'].'; ' 
            .$_SESSION[$index]['name'].'; '
            .$_SESSION[$index]['size'].'; '
            .$_SESSION[$index]['description'];
            
        unset($_SESSION[$index]);
    }

    if (is_null($callback))
        echo $output;
    else
        echo $callback."('".$output."');";    
    
}


Step6. C4:接收資料,復原form,呼叫回呼函數

以jQuery的getJSON取得資料之後,接下來就是復原form、刪除臨時建立的iframe,然後呼叫回呼函數。

這段程式碼也很簡單,寫在client.js中,摘錄如下:

        $.getJSON(_action_get, function (_result) {
            
            //復原form
            _form_obj.attr('action', _action)
                .removeAttr('target')
                .removeAttr('jsonp_by_post');
            
            //移除iframe
            _iframe_obj.remove();
            
            _callback(_result);
        });

Step7. C5:結果輸出

在Step1初始化時設定好的回乎函數會在最後接收資料,然後將結果輸出到div容器中。程式碼寫在client.js的上方,摘錄如下:

            $.jsonp_by_post(this, function (_result) {
                $("#output").html("Message: " + _result);
            });

如果檔案正常上傳,你會看到以下畫面:

image

如果你上傳超過1MB的檔案,則伺服器會記錄錯誤狀態,於是會看到以下畫面:

image

這樣就大功告成啦!


變化與應用

範例中的講解很簡單,身為能夠舉一反三的程式設計師,腦袋裡面應該已經呈現出很多不同的應用方式吧。這邊我大略提一下幾個變化的重點,還有最後應該注意的安全性問題。

資料的輸入

在此範例中,我所有資料都是來自於撰寫好的form表單。實作時,我們的原始資料不一定是真的來自form表單,通常會以JSON、陣列、變數形式存在記憶體中,以方便JavaScript進行處理。

如果是以JSON等非form的資料型態,想要進行跨網域的AJAX傳輸,那麼你還是得要自己將這些資料寫成form,這樣才能以POST進行遞交。雖然多了點步驟,但相信對於熟悉jQuery的你來說應該不是什麼問題。

安全性問題

網頁之間有很多跨網域的限制,而本篇旨在於打破這個跨網域限制來做到更多功能,但相對的就會負擔起更多安全性問題。

提供JSONP的服務,很容易就會受到跨網域指令碼(Cross-site Script)的攻擊,而被找漏洞、挖出使用者的資料。我並不是資安專家,對這方面研究還不夠深入,只能提供幾個簡單的安全性加強方向。大致上,我建議搭配身分認證的session、來源IP或網址,來判斷這個C是否有跟S溝通的資格,S再決定是否接受C的POST或GET請求。這樣應該會安全許多吧。

範例程式中為了講解方便,所以並沒有加上這些措施。如果你要將這個方法放到自己的應用程式中,請務必做好安全性的防範措施。


結語

這是我論文系統中實作的一個技巧,但因為論文無法寫出這些細節,所以我以寫blog的方式,記錄這個技巧的細節,供有需要的人參考。實際上這篇從想寫、擬完心智圖、寫了一半覺得不滿意、又改了好多次,到現在才完成。

這只是一個很基礎的AJAX技巧,可以應用到很多地方,可以進一步寫成更漂亮的framework或library。也許已經有人發表過這種方法了也說不定,不過,我另一個目的也只是給未來的自己留下一個參考而已,就作為一篇記錄吧。