2014年3月10日 星期一

如何在 GAE 中處理 HTTP 要求

網頁專案主要就是處理瀏覽器 (Client) 向伺服器 (Server) 提出的 HTTP 要求, 瀏覽器部分就是在網頁中配置表單 (Form) 與前端驗證 (Validation) 功能, 伺服器的應用程式則負責取出 HTTP 要求中所帶的參數, 據此進行後端商業邏輯處理後, 向瀏覽器回應輸出訊息.

以下要測試在 GAE 中如何來處理 HTTP 要求, 前端 Javascript 框架使用 ExtJS 4, 後端則使用 GAE 內建的 Django 開發框架裡的 webapp 模板. 關於在 GAE 中如何使用 ExtJS 4, 請參考下列文章 :

# 如何在 GAE 上佈署 jQuery 與 ExtJS 專案

首先來製作一個前端登入頁面, 讓使用者輸入帳號密碼後, 按確定提交給伺服器驗證. 登入畫面可以使用 ExtJS 4 的 Form Panel 作輸入表單. 我們直接繼承上文中使用的 extjs.htm 模板, 寫一個登入網頁 login1.htm :

{% extends "extjs.htm" %}
{% block body %}
  <div id="loginform"></div>
  <script language="JavaScript">
    Ext.onReady(function(){
      var form1=Ext.create("Ext.form.Panel", {
                frame: true,
                width: 340,
                bodyPadding: 5,
                buttonAlign: "right",
                fieldDefaults: {
                  labelAlign: "left",
                  labelWidth: 50,
                  anchor: "100%"
                  },
                items: [
                  {xtype: "textfield",
                   name: "account",
                   fieldLabel: "帳號 ",
                   allowBlank: false},
                  {xtype: "textfield",
                   name: "password",
                   inputType: "password",
                   fieldLabel: "密碼 ",
                   allowBlank: false}
                  ],
                buttons: [{
                  text: "登入",
                  formBind: true,
                  disabled: true,
                  handler: function(){
                    var form=this.up("form").getForm();
                    var values=form.getValues();
                    var msg="帳號:" + values["account"] + "<br>" +
                            "密碼:" + values["password"];
                    Ext.Msg.alert("訊息",msg);
                    }
                  }]
                });
      Ext.create("Ext.window.Window", {
                 title: "登入",
                 height: 150,
                 width: 300,
                 layout: "fit",
                 items: [form1]
                 }).show();    
      });
  </script>
{% endblock %}

測試範例 1 : http://mygaetestweb.appspot.com/loginform1 [下載 zip 檔]



在範例 1 中, 我們使用 Form Panel 容器來擺放登入所需元件. 在 items 屬性中, 用 xtype 來設定兩個 textfield 欄位, 其中密碼欄位必須用 inputType 來設定. 此二欄位均利用 allowBlank:false 來驗證使用者是否有輸入內容. 注意, 確定按鈕不使用 xtype, 而是使用 buttons 屬性來定義, 因為這樣才能用 buttonAlign 屬性將按鈕向右對齊. 同時按鈕的 formBind 與 disabled 都設為 true, 使按鈕一開始為禁能, 等帳號密碼均有輸入, 驗證過關後, 此按鈕才致能可以按. 在按鈕的事件處理函式中, 用 this.up("form") 指向 DOM 的上一層表單, 呼叫 getForm() 傳回此表單之包裹物件, 再呼叫其 getValues() 方法會傳回表單內各元件輸入值組成之關聯式陣列, 這樣就能取得元件值了. 

上面兩個文字欄位都分別指定 xtype, 這在欄位多時就很煩, 可以利用 defaultType 屬性指定預設元件類型為 textfield 來簡化, 這樣沒有指定 xtype 的元件就是文字欄位 :

               defaultType: 'textfield',
               ...
               items: [
                  {name: "account",
                   fieldLabel: "帳號 ",
                   allowBlank: false},
                  {name: "password",
                   inputType: "password",
                   fieldLabel: "密碼 ",
                   allowBlank: false}
                  ],

當然這要看表單中哪一種元件用最多而定, 不過通常是 textfield. 最後再建立一個 Window 容器, 把 Form Panel 放進去, 並將 Window 的布局設為 fit, 這樣登入表單就會螢幕置中了. 如果直接把 Form Panel 丟到 Body 去描繪, 則登入表單會在螢幕左上角, 使用 Fit Layout 的 Window 容器就可以輕易將其置中, 不須自行用 CSS 排版, 這就是 ExtJS 獲得企業採用的一個小小原因.

以上我們已經利用 ExtJS 做好表單, 下一步是要如何把它送出去給 GAE 伺服器呢?  最簡單的方式是呼叫表單包裹物件的 submit() 方法, 把後端伺服器負責處理此要求的 url 當參數傳進去即可, 我們修改範例 1 的 loginform1.htm 為 loginform2.htm, 僅僅把登入按鈕的事件處理函式 handler 改成下列 :
                   handler: function(){
                     form1.getForm().submit({
                       url:"/form2handler",
                       method:"post",
                       success:function(form,action){
                         Ext.Msg.alert("訊息","登入成功");
                         },
                       failure:function(form,action){
                         Ext.Msg.alert("訊息","登入失敗" + action.failureType);
                         }
                       });
                     }

注意, submit() 方法的參數是一個物件實體, 此處傳入 method, url, success, failure 等屬性, 以進行 Ajax 非同步呼叫, 若收到伺服端傳回的 JSON 屬性 success 的值為 true, 表示後端操作成功, flase 表示失敗.

其次要修改主程式 main.py, 添加 loginform2.htm 與 form2handler 的 URL, 並撰寫其 handler, 擷取前端傳送的帳密參數 比對是否符合, 是的話傳回 {success:true} 的 JSON 字串, 否則傳回 {success:false} ::

# -*- coding: utf-8 -*-
import os
from google.appengine.ext.webapp import template
import webapp2

class jquery_datatable(webapp2.RequestHandler):
    def get(self):
        url="jquery_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class extjs_datatable(webapp2.RequestHandler):
    def get(self):
        url="extjs_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform1(webapp2.RequestHandler):
    def get(self):
        url="loginform1.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform2(webapp2.RequestHandler):
    def get(self):
        url="loginform2.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class form2handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        if (account=="admin" and password=="aaa"): 
   content="{success:true}"
else:
   content="{success:false,errors:{account:'帳號或密碼錯誤'}}"
self.response.out.write(content)

class MainHandler(webapp2.RequestHandler):
    def get(self):
        self.response.write('Hello world!')

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/jquery_datatable', jquery_datatable),
    ('/extjs_datatable', extjs_datatable),
    ('/loginform1', loginform1),
    ('/loginform2', loginform2),
    ('/form2handler', form2handler)
], debug=True)

如下列範例 2 所示 :

測試範例 2 : http://mygaetestweb.appspot.com/loginform2 [下載 zip 檔]

此例中我們檢查帳號是否為 admin, 密碼是否為 aaa, 符合的話就傳回 {success:true}, 這樣 ExtJS 就會知道比對成功. 如果失敗, 傳回 {success:false}, 但此處還傳回 errors 屬性, 其值是一個物件, 用來指定要在前端哪些欄位顯示欄位檢查錯誤提示. 





在上面範例 2 中 我們是在 Python 程式 main.py 中, 固定判斷前端是否傳送 account 參數為 "admin" 以及密碼為 "aaa", 但這樣做就沒有彈性, 我們應該將使用者輸入的帳密與資料庫比對, 符合才准許進入系統. 這在 PHP+MySQL 可以很簡單地利用 MySQL 資料表達成, 但在 GAE 該怎麼做呢? 在 GAE 我們用的是 Datastore (資料儲存).

首先我們製作一個 members.py 程式來定義資料儲存, 如下所示 :

# -*- coding: utf-8 -*-
from google.appengine.ext import db

class Members(db.Model):
    account=db.StringProperty()
    password=db.StringProperty()

member=Members(account="admin",password="aaa")
member.put()

首先匯入 google.appengine.ext.db 這個函式庫, 然後繼承 db.Model 類別建立一個 Members 類別, 其中定義了 account 與 password 這兩個文字欄位, 最後建立一個 member 物件, 設定 account 欄位值為 admin, 而 password 欄位值為 aaa, 呼叫 put() 將此物件實體寫入資料儲存即可. 這樣一筆紀錄在 Datastore 中稱為 entity (實體), 相當於關聯式資料庫裡, 資料表 Members 中的一筆紀錄.

搞定資料庫後就可以進行資料庫比對測試了, 我們將上面的 loginform2.htm 改為 loginform3.htm, 但只是把登入按鈕的事件處理函式 handler 的 url 改成 form3handler, 如下列所示 :

                   handler: function(){
                     form1.getForm().submit({
                       url:"/form3handler",
                       method:"post",
                       success:function(form,action){
                         Ext.Msg.alert("訊息","登入成功");
                         },
                       failure:function(form,action){
                         Ext.Msg.alert("訊息","登入失敗" + action.result.msg);
                         }
                       });
                     }

然後改寫 main.py, 加入 form3handler 來處理 loginform3.htm 傳來的參數, 並與 Datastore 中的資料比對是否符合. 因為資料儲存是由 members.py 程式負責, 因此在 main.py 程式中, 必須匯入 members 才行, 注意, 是匯入小寫的程式主檔名 members, 不是首字大寫的類別名稱 Members, 同時, 因為要在 main.py 中使用 db 套件查詢資料儲存, 所以也要匯入 db, 修改後的 main.py 如下 :

# -*- coding: utf-8 -*-
import os
from google.appengine.ext.webapp import template
import webapp2
import members
from google.appengine.ext import db

class jquery_datatable(webapp2.RequestHandler):
    def get(self):
        url="jquery_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class extjs_datatable(webapp2.RequestHandler):
    def get(self):
        url="extjs_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform1(webapp2.RequestHandler):
    def get(self):
        url="loginform1.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform2(webapp2.RequestHandler):
    def get(self):
        url="loginform2.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform3(webapp2.RequestHandler):
    def get(self):
        url="loginform3.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class form2handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        if (account=="admin" and password=="aaa"):
            content="{success:true}"
        else:
            content="{success:false,errors:{account:'帳號或密碼錯誤'}}"
        self.response.out.write(content)

class form3handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        query=db.GqlQuery("""SELECT * FROM Members 
                             WHERE account= :1 
     AND password= :2""",
             account,password)
        result=query.get()
        if result is None
    content="{success:false,errors:{account:'帳號或密碼錯誤'}}"
        else:
            content="{success:true}"
        self.response.out.write(content)

class MainHandler(webapp2.RequestHandler):
    def get(self):
        url="default.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/jquery_datatable', jquery_datatable),
    ('/extjs_datatable', extjs_datatable),
    ('/loginform1', loginform1),
    ('/loginform2', loginform2),
    ('/form2handler', form2handler),
    ('/loginform3', loginform3),
    ('/form3handler', form3handler)
], debug=True)

如下範例 3 所示 :
 .
測試範例 3 : http://mygaetestweb.appspot.com/loginform3 [下載 zip 檔]


此例中我們利用 Datastore 來驗證使用者所輸入的帳密是否符合, 使用了 db 類別的 GqlQuery() 方法來查詢 Datastore, 它會傳回代表查詢結果的 Query 物件, 呼叫此物件的 get() 方法會傳回第一個實體或者 None, 因此我們可用 is None 來判斷無此帳密. 當然也可以反過來這麼寫 :

        result=query.get()
        if result:
       content="{success:true}"
        else:
            content="{success:false,errors:{account:'帳號或密碼錯誤'}}"


上面使用 GQL 語法來查詢 Datastore, GQL 是與 SQL 類似的資料庫查詢語法, 這裡使用 :1 與 :2 分別傳入使用者參數 account 與 password, 而類別 Members 就相當於資料表.

除了使用 db 類別的 GqlQuery() 來查詢外, 也可以呼叫 Datastore 資料儲存類別 (即本利中的 Members 類別) 的 gql() 方法來查詢, 但是不同的是不可使用完整的 GQL 語句, 只用 WHERE 以下的部分, 所以本例也可以改寫為如下 :

        query=Members.gql("""WHERE account= :1 
    AND password= :2""",
            account,password)
        result=query.get()

以上都是以 ExtJS4 為例說明, 下面以範例 3 為藍本, 改用 jQuery UI 來測試, 首先繼承 jquery.htm 模板, 寫一個 loginform4.htm 如下 :

{% extends "jquery.htm" %}
{% block style %}
  body {font: 62.5% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <div id="loginbox" style="margin:20px;">
    <label for="account">帳號 :</label>
    <input id="account" type="text"><br><br>
    <label for="password">密碼 :</label>
    <input id="password" type="password">

  </div>
  <div id='login_msgbox' title='訊息'>
   <p></p>
  </div>

  <script type="text/javascript">
    $(document).ready(function(){
      $('#login_msgbox').dialog({modal:false,
                                 autoOpen:false,
                                 buttons:{'確定':function(){
                                   $(this).dialog('close');}
                                   }
                                 }); //訊息框初始化
      var login=function() {
                  $(this).dialog("close");
                  $.ajax({
                    type:"POST",
                    url:"/form4handler",
                    cache:false,
                    data:{"account":$("#account").val(),
                          "password":$("#password").val()},

                    dataType:"json",
                    success:function(data) {
                              if (data.result==="success") {
                                  var msg='<p>登入成功</p>';
                                  } //end of if
                              else {
                                  var msg='<p>' + data.reason + '</p>';
                                  } //end of else
                              $('#loginbox p').remove(); //避免 p 元件一直疊上去
                              $('#loginbox').prepend(msg);
                              $('#loginbox').dialog("open");
                              },
                    error:function(xhr, thrownError) { //ajax 發生錯誤
                            var msg='<p>Ajax 發生錯誤! 無法登入系統.<br>' +
                                    'Error : ' + xhr.status + " - " +
                                    thrownError + '</p>';
                            $('#login_msgbox').html(msg);
                            $('#login_msgbox').dialog("open");                      
                            } //end of function
                    }); //end of ajax
                  } //end of function
      var opt={modal:false,
               title:"系統登入",
               buttons:{"登入":login}
               };
      $("#loginbox").dialog(opt);
      });
  </script>
{% endblock%}

在這個 loginform4.htm 網頁檔中, 我們設置了兩個訊息框 : loginbox 用來一開啟時顯示登入對話框, 讓使用者輸入帳密, login_msgbox 用來在 Ajax 失敗時 (例如網路連線或伺服器主機故障) 顯示錯誤訊息, 預設為不顯示 (autoOpen:false). jQuery UI 訊息框用法參考舊作 :

# jQuery UI widget 之訊息盒與輸入盒應用範例
# jQuery UI 訊息盒 (Msgbox) 語法

其次, 我們預期 Ajax 會傳回一個 JSON 字串, 其中需有 result 與 reason 兩個屬性, 傳回 "success" 表示帳密驗證成功, 否則為失敗, 並將失敗原因放在 reason 屬性中, 亦即成功時傳回 :

{"result":"success"}

失敗時傳回 :

{"result":"failure","reason":"帳號或密碼錯誤"}

按下登入鈕後, 會向 form4handler 這個 url 發出 Ajax 要求, 並利用 data 屬性傳送 account 與 password 兩個參數給 form4handler, 因此我們需要修改 main.py, 加入 loginform4 與 form4handler 這兩個路徑, 並修改傳回值 :

# -*- coding: utf-8 -*-
import os
from google.appengine.ext.webapp import template
import webapp2
import members
from google.appengine.ext import db

class jquery_datatable(webapp2.RequestHandler):
    def get(self):
        url="jquery_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class extjs_datatable(webapp2.RequestHandler):
    def get(self):
        url="extjs_datatable.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform1(webapp2.RequestHandler):
    def get(self):
        url="loginform1.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class loginform2(webapp2.RequestHandler):
    def get(self):
        url="loginform2.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class form2handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        if (account=="admin" and password=="aaa"):
            content="{success:true}"
        else:
            content="{success:false,errors:{account:'帳號或密碼錯誤'}}"
        self.response.out.write(content)

class loginform3(webapp2.RequestHandler):
    def get(self):
        url="loginform3.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

class form3handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        query=db.GqlQuery("""SELECT * FROM Members
                             WHERE account= :1
                             AND password= :2""",
                             account,password)
        result=query.get()
        if result is None:
            content="{success:false,errors:{account:'帳號或密碼錯誤'}}"
        else:
            content="{success:true}"
        self.response.out.write(content)

class loginform4(webapp2.RequestHandler):
    def get(self):
        url="loginform4.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)


class form4handler(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        query=db.GqlQuery("""SELECT * FROM Members
                             WHERE account= :1
                             AND password= :2""",
                             account,password)
        result=query.get()
        if result:
            content='{"result":"success"}'
        else:
            content='{"result":"failure","reason":"帳號或密碼錯誤"}'
        self.response.out.write(content)


class MainHandler(webapp2.RequestHandler):
    def get(self):
        url="default.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/jquery_datatable', jquery_datatable),
    ('/extjs_datatable', extjs_datatable),
    ('/loginform1', loginform1),
    ('/loginform2', loginform2),
    ('/form2handler', form2handler),
    ('/loginform3', loginform3),
    ('/form3handler', form3handler),
    ('/loginform4', loginform4),
    ('/form4handler', form4handler)

], debug=True)

基本上我們只是修改 form4handler 中的傳回值 content 的格式而已, 資料儲存驗證部分都跟上面範例 3 一樣. 特別注意, 在 jQuery 中使用 JSON 時, 屬性名稱與其值都必須用雙引號括起來, 否則會出現 202 parse error, 用單引號也不行.

經過這樣處理後就可以了, 如下列範例 4 所示 :

測試範例 4http://mygaetestweb.appspot.com/loginform4 [下載 zip 檔]

 


[2014.8.5 下午] 終於把這篇文章補齊了, 這是媽 7/21 住院後, 我往返公司, 家裡, 以及在病房陪媽的空檔寫完的, 因為沒啥心思, 所以斷斷續續寫, 只簡單測試一下功能就好了.


沒有留言:

張貼留言