2016年1月8日 星期五

如何在 GAE 上佈署 jQuery EasyUI 專案 (五) : 紀錄到訪者

測試完 Google 帳號驗證後, 我回頭繼續 GAE 資料儲存區 (Datastore) 的測試. 之前使用 PHP 開發 EasyUICMS 時, 裡面有一個功能是紀錄來過網站首頁的使用者, 將訪客的 IP 位址, 拜訪時間, 以及使用之客戶端軟體 (瀏覽器) 存入資料表中, 藉以了解訪客來自何方. 這在 GAE 上要如何實作此功能呢?

以下測試是在上一篇文章的基礎上加以修改而得, 參考 :

# 如何在 GAE 上佈署 jQuery EasyUI 專案 (四) : Google 登入

首先要在資料庫定義檔 model.py 中新增一個 Visitors 資料表來記錄瀏覽網站首頁 (根目錄) 的訪客資訊, 包含 ip (位址), visit_time(到訪時間), 以及 user_agent (瀏覽器) 三個欄位 :

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

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

class Visitors(db.Model):
    ip=db.StringProperty()
    visit_time=db.DateTimeProperty()
    user_agent=db.StringProperty()

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

注意, 這裡一般來說我們會在到訪時間欄位傳入 auto_now_add 這個參數 :

visit_time=DateTimeProperty(auto_now_add=True)

這樣當新增到訪紀錄時, 此欄不須給予值就會自動填入時間. 但是因為 GAE 預設是 UTC 時區, 因此自動填入的是 UTC 的時間, 而不是台北時間. 解決此問題的辦法就是不要自動填入, 而是經過調整後自行填入.

要在訪客瀏覽首頁時紀錄其資料, 必須修改主程式 main.py 裡應用程式首頁之路徑處理類別 MainHandler, 加入記錄訪客資訊之程式碼, 修改為如下 :

class MainHandler(webapp2.RequestHandler):
    def get(self):
        ip=self.request.remote_addr
        user_agent=os.environ.get("HTTP_USER_AGENT")
        #user_agent=self.request.headers.get("User-Agent")
        visitor=m.Visitors()
        visitor.ip=ip
        visitor.visit_time=datetime.now() + timedelta(hours=+8)
        visitor.user_agent=user_agent
        visitor.put()
        url="templates/default.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

上面藍色部分是新增的程式碼, 當訪客瀏覽首頁時, 便將其 IP, 到訪時間與使用之瀏覽器, 存入資料儲存區, 然後再渲染首頁. 此處 User agent 可以用 os 模組的 environ 物件的 get() 方法取得 (需匯入 os 模組), 也可以用 request.headers 的 get() 方法取得. 而到訪時間則使用 datetime 物件的 now() 方 法取得, 再用 timedelta 物件將 UTC 時間往前挪 8 小時, 因此需先匯入 os, datetime, 以及 timedelta 這 3 個模組 :

import model as m
import os
from datetime import timedelta, datetime

關於時區調整問題, 參考 :

# GAE 的時區問題

然後在 main.py 中添加一個 list_visitors_1 的路徑以及其處理類別如下 :

class list_visitors_1(webapp2.RequestHandler):
    def get(self):
        query=m.Visitors.all()
        query.order("-visit_time")
        url="templates/list_visitors_1.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"visitors":query})
        self.response.out.write(content)

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1)
], debug=True)

此處 list_visitors_1 類別中的 m 為資料模型物件 model 的別名, 我們在上一篇中有將 model.py 匯入 main.py, 並為了簡單起見用 as 將其改命名為 m 物件 :

import model as m

如果沒有用 as 改名, 就要用原名 model. 這裡用 m.Visitors.all() 可以取得 Visitors 類別的所有 Querry 物件實體, 也就是瀏覽首頁者在 MainHandler 類別中所建立之拜訪資料. 然後呼叫 order() 方法將這些實體排序, 傳入參數 "-visit_time" 表示根據 visit_time 進行反向排序 (沒有負號表示正向排序, 有負號為反向排序), 這樣就會把最近的訪客排在最前面. 然後在渲染 list_visitors_1.htm 這個模板檔案時將這些 querry 實體集合當作 template 參數傳入 render() 方法中.

最後就是在 templates 目錄下繼承 jqueryeasyui.htm 這個模板寫一個 list_visitors_1.htm 來倒序顯示訪客列表如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table class="easyui-datagrid" title="訪客列表" style="width:960px;height:400px" data-options="singleSelect:true,collapsible:true,rownumbers:true">
    <thead>
      <tr>
        <th data-options="field:'ip',width:100">IP 位址</th>
        <th data-options="field:'visit_time',width:150">到訪時間</th>
        <th data-options="field:'user_agent'">使用瀏覽器</th>
      </tr>
    </thead>
    <tbody>
{% for visitor in visitors %}
      <tr>
        <td>{{visitor.ip}}</td>
        <td>{{visitor.visit_time|date:"Y-m-d H:i:s"}}</td>
        <td>{{visitor.user_agent}}</td>
      </tr>
{% endfor %}
    </tbody>
  </table>
{% endblock%}

這裡使用 EasyUI 的 Datagrid 來顯示訪客列表, 並以 template 引擎之 for 迴圈語法遍歷傳入之 visitors 參數以產生表格內容之三個欄位. 注意第二欄位 (到訪時間) 我用了管線過濾器 "|" 來調整日期的顯示樣式為較簡潔的 "2016-01-06 10:20:11" 格式, 原始格式為 "Jan. 6, 2016, 10:20:11 am".

測試 1 : http://jqueryeasyui.appspot.com/list_visitors_1 


關於 EasyUI 的 Datagrid 用法, 參考 :

# jQuery EasyUI 測試 : Datagrid (一)

上面測試 1 就採用了其中的範例 4 做法, 直接將表格內容輸出於頁面上. jQuery 最迷人的地方之一是提供了簡單好用的 Ajax 功能, 下面測試 2 就是改用 Ajax 方式來實作同樣功能, 以 HTTPXML 方式向後端取得 Datagrid 所需的資料, 參考 jQuery EasyUI 測試 : Datagrid (一) 中的範例 2.

首先在 main.py 中添加 list_visitors_2 與 get_visitors_2 這兩個路徑, 分別用來渲染 HTML 檔與取得後端資料 :

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1),
    ('/list_visitors_2', list_visitors_2),
    ('/get_visitors_2', get_visitors_2)
], debug=True)

然後撰寫 list_visitors_2 的路徑處理類別 :

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

很簡單, 就是渲染 templates 下的模板檔案 list_visitors_2.htm 而已, 此檔內容如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:960px;height:400px"></table>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors_2',
        method:'get',
        columns:[[
          {field:'ip',title:'IP 位址',width:150},
          {field:'visit_time',title:'到訪時間',width:150},
          {field:'user_agent',title:'瀏覽器'}
          ]],
        singleSelect:true,
        collapsible:true,
        rownumbers:true
        });
      });
  </script>
{% endblock%}

這裡很簡單, 就是繼承 jqueryeasyui.htm 模板, 放一個有 id 的 table 元件, 然後在 Javascript 程式中呼叫此 table 元件之包裹物件的 datagrid() 方法來起始 EasyUI Datagrid 物件. 其中與 Ajax 相關的兩個最重要參數是 url 與 method, 前者表示要向後端 get_visitors_2 這個路徑要求提供 JSON 資料; 後者為要求的方法.

最後只要搞定供檔程式 get_visitos_2 這個類別即可, 由於此類別要傳回 json 檔給前端, 需要用到 json 模組, 因此要先匯入 :

import json

Ajax 供檔程式如下, 注意配合上面 list_visitors_2.htm 中指定 Ajax 使用 get 方法提出要求, 這裡必須定義 get 函式才行 (反之, 若為 post 則下面就要定義 post) :

class get_visitors_2(webapp2.RequestHandler):
    def get(self):
        self.response.headers["Content-Type"]="application/json"
        query=m.Visitors.all()
        query.order("-visit_time")
        rows=[]
        count=0
        for v in query:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
            count=count + 1
        obj={"total":count,"rows":rows}
        self.response.out.write(json.dumps(obj))

首先是設定 response 物件的 headers[] 串列設定 http 回應訊息的內容為 json 型態, 然後呼叫 model 的 Visitors 類別的 all() 方法查詢全部 Visitors 實體 (及訪客資料物件), 依到訪時間倒序排列. 再設定一個串列 rows 來儲存訪客物件實體, 以及一個計數器 count 來累計實體總數, 因為 EasyUI 的 Datagrid 需要此數據. 接下來用一個 for 迴圈來遍歷從資料儲存中查詢到的 Query 物件, 將其內容製作成 dictionary 物件, 一一放進串列中. 最後組合成 Datagrid 所需要格式的物件, 利用 json 類別的 dumps() 方法進行編碼後輸出即可. 參考 :

How to properly output JSON with app engine Python webapp2?
Convert a python dict to a string and back
Json概述以及python對json的相關操作
Python处理JSON

另外, 到訪時間 visit_time 原始格式無法順利通過編碼 (無法序列化, serialization), 必須經過 strftime() 函式轉為例如 "2016-01-07 01:02:03" 的字串格式才行. 參考 :

# Python datetime to string without microsecond component

此供檔程式產出的資料節錄如下 :

{"rows": [{"ip": "66.249.79.16", "visit_time": "2016-01-07 15:18:23", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12F70 Safari/600.1.4 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"}, ... ,
{"ip": "59.127.170.38", "visit_time": "2016-01-06 22:40:27", "user_agent": "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0"}],  "total": 28}

完整 json 檔可由下面超連結下載 :

http://jqueryeasyui.appspot.com/get_visitors_2

Ajax 產生的訪客列表如下面測試 2 所示 :

測試 2 : http://jqueryeasyui.appspot.com/list_visitors_2 

上面兩個測試 "理論上" 是顯示全部訪客紀錄, 如果訪客不斷增加, 列表長度就會增長, 解決之道是以分頁 (paging) 方式處理. 參考 jQuery EasyUI 測試 : Datagrid (一) 中的範例 7, 改寫為 GAE 版的分頁表格.

先在 templates 目錄下複製上面的 list_visitors_2.htm 為 list_visitors_3.htm, 然後加入 pagination:true 與 pageSize:10 這兩個分頁相關屬性如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:960px;"></table>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors_3',
        method:"post",
        columns:[[
          {field:'ip',title:'IP 位址',width:150},
          {field:'visit_time',title:'到訪時間',width:150},
          {field:'user_agent',title:'瀏覽器'}
          ]],
        singleSelect:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        pageSize:10
        });
      });
  </script>
{% endblock%}

這個 pageSize 就是一個分頁顯示的紀錄筆數, 這個值預設是 10 筆, EasyUI 的 Datagrid 有下拉式選單可以讓使用者更改每頁筆數. 當變換顯示頁時, Datagrid 元件會向 url 所指的後端路徑程式 /get_visitors_3 傳送兩個參數 page 與 rows, 分別表示欲查詢的頁次與每頁筆數. 注意, 這裡我拿掉了 table 元件的 height 屬性, 而是由每頁筆數來決定 Datagrid 的高度. 另外,  提交 Ajax 要求的方式也改成 post, 因此

接著修改主控程式 main.py, 加入 list_visitors_3 與 get_visitors_3 這兩個路徑與其處理類別, 分別用來渲染 list_visitors_3.htm 網頁與透過 Ajax 取得分頁紀錄 :

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

class get_visitors_3(webapp2.RequestHandler):
    def post(self):
        page=self.request.get("page")
        rows=self.request.get("rows")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        query=m.Visitors.all()
        query.order("-visit_time")
        visitors=query.fetch(rows, (page-1)*rows)
        rows=[]
        for v in visitors:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
        count=query.count()
        obj={"total":count,"rows":rows}
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1),
    ('/list_visitors_2', list_visitors_2),
    ('/get_visitors_2', get_visitors_2),
    ('/list_visitors_3', list_visitors_3),
    ('/get_visitors_3', get_visitors_3)
], debug=True)

上面主要的變化是在負責 Ajax 供檔的 get_visitors_3 類別, 由於前端改用了 post 方法提交 Ajax, 因此處理類別也需定義 post 函式才行.

首先用 request 物件的 get() 方法取得前端 Datagrid 元件傳來的 page (頁次) 與 rows (每頁筆數) 參數, 然後用 len() 長度函數偵測否有傳出這兩個參數, 若有傳出參數就用 int() 函式轉成整數, 否則就設定預設值為第 1 頁與 10 筆. 然後查詢全部 Visitors 物件再以到訪日期倒序排序, 再用 fetch() 方法擷取指定頁之 Visitors 實體, 其第一參數是要擷取之筆數, 故傳入 rows; 第二參數是資料實體的索引, 即頁次減 1 後乘以每頁筆數即得. 例如每頁 10 筆, 則第 2 頁是從索引 (2-1)*10=10 開始 (第 1 頁是 0~9). 接下來就跟上面測試 2 一樣, 將每筆到訪記錄組成 dict 後存入串列.

最重要的是, 由於不是遍歷所有 Visitors 實體, 無法透過迴圈計算顯示的總筆數, 改由呼叫 Query 物件的 count() 方法來取得總筆數. 據書上寫說此方法效能不好, 但至少能用, 實際範例如下 :

測試 3 : http://jqueryeasyui.appspot.com/list_visitors_3 


可見取消 table 的 height 樣式後, 右方捲軸就消失了.

EasyUI 的 Datagrid 欄位標題可以透過將欄位設定為 sortable:true 而具有排序功能, 當點擊欄位標題時, 也會啟動 Ajax 查詢, 並向後端增加傳送 sort (要排序之欄位名稱) 以及 order (順序/倒序) 這兩個參數, 參考 jQuery EasyUI 測試 : Datagrid (一) 中的範例 9. 下面測試 4 就來實作欄位排序功能.

首先複製測試 3 的模板 list_visitors_3.htm, 修改三個欄位設定, 加入 sortable:true 屬性, 存成 list_visitos_4.htm 如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:960px;"></table>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors_4',
        method:"post",
        columns:[[
          {field:'ip',title:'IP 位址',width:150,sortable:true},
          {field:'visit_time',title:'到訪時間',width:150,sortable:true},
          {field:'user_agent',title:'瀏覽器',sortable:true}
          ]],
        singleSelect:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        pageSize:10
        });
      });
  </script>
{% endblock%}

然後在 main.py 中增加 list_visitors_4 與 get_visitors_4 的路徑與處理類別 :

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

class get_visitors_4(webapp2.RequestHandler):
    def post(self):
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="visit_time"
        if not len(order):
            order="desc"
        query=m.Visitors.gql("ORDER BY %s %s" % (sort, order))
        visitors=query.fetch(rows, (page-1)*rows)
        rows=[]
        for v in visitors:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
        count=query.count()
        obj={"total":count,"rows":rows}
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1),
    ('/list_visitors_2', list_visitors_2),
    ('/get_visitors_2', get_visitors_2),
    ('/list_visitors_3', list_visitors_3),
    ('/get_visitors_3', get_visitors_3),
    ('/list_visitors_4', list_visitors_4),
    ('/get_visitors_4', get_visitors_4)
], debug=True)

這裡改變較多的是在 get_visitors_4 路徑處理類別, 取得 sort 與 order 參數後利用 len() 函式偵測有無傳出值, 若無就分別預設為 "visit_time"  與 "DESC". 其次我們得改用資料類別的 gql() 方法或 db 模組的 GqlQuerry() 方法來查詢資料儲存區, 因為欄位排序必須用到 GQL 語言才能達成目的. 這裡我們用 % 格式化字串來將 sort 與 order 參數填入 "ORDER BY ..." 語句中, 這樣 "理論上" 就得到經排序後的全部訪客資料實體了, 再使用 Query 物件的 fetch() 方法即可得到指定頁次的資料了, 其餘與上面測試 3 一樣. 實際範例如下 :

測試 4 : http://jqueryeasyui.appspot.com/list_visitors_4 


可見欄位標題右邊多了三角形圖案, 表示為可排序欄位, 點欄位標題即可針對該欄位進行順向或逆向排序. 其實上面的 gql() 查詢也可以用 order() 函式來做, 將 gql() 那行用下列取代 :

        query=m.Visitors.all()
        if order=="desc":
            query.order("-" + sort)
        else:
            query.order(sort)

效果是一樣, 這裡是判斷排序方向, 若為倒序就在排序欄位前面加個負號.

接下來要實作搜尋功能, 參考 jQuery EasyUI 測試 : Datagrid (一) 中的範例 10, 只要在 table 中增加 toolbar 屬性, 就可以在 Datagrid 上方添加搜尋工具列, 如下面 list_visitors_5.htm 所示 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:960px;" toolbar="#search_bar"></table>
  <div id="search_bar" style="padding:3px">
    <select id="search_field">
      <option value="ip">IP 位址</option>
      <option value="visit_time">到訪時間</option>
      <option value="user_agent">瀏覽器</option>
    </select>
    <input id="search_what" style="line-height:15px;border:1px solid #ccc">
    <a href="#" class="easyui-linkbutton" iconCls="icon-search" onclick="doSearch()">搜尋</a>
  </div>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors_5',
        method:"post",
        columns:[[
          {field:'ip',title:'IP 位址',width:150,sortable:true},
          {field:'visit_time',title:'到訪時間',width:150,sortable:true},
          {field:'user_agent',title:'瀏覽器',sortable:true}
          ]],
        singleSelect:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        pageSize:10
        });
      });
    function doSearch(){
      $('#visitors').datagrid('load',{
        search_field: $('#search_field').val(),
        search_what: $('#search_what').val()
        });
      }
  </script>
{% endblock%}

此處使用 div 製作了一個包含下拉式選單, 文字欄位, 以及按鈕組成的搜尋框, 以 Datagrid 的 toolbar 屬性掛到其工具列去, 當按下搜尋鈕時, 執行 doSearch() 函式, 取得 search_field 與 search_what 之值, 呼叫 Datagrid 的 load() 方法以 Ajax 方式重新載入表格內容, 這會將 search_field 與 search_what 當作參數傳給後端去做搜尋. 但很可惜的, GAE 的 GQL 不提供 SQL 中的 LIKE 語法, 這使得 LIKE 'text%' 或 LIKE '%text' 或 LIKE '%ext%' 等模糊比對功能無法達成, 不過可以透過字串排序方式做到 LIKE 'text%' , 參考 :

在GAE中模拟like查询进行模糊搜索
Google App Engine: Is it possible to do a Gql LIKE query?

修改 main.py, 增添 list_visitors_5 與 get_visitors_5 路徑與處理類別如下 :

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

class get_visitors_5(webapp2.RequestHandler):
    def post(self):
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        search_field=self.request.get("search_field")
        search_what=self.request.get("search_what")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="visit_time"
        if not len(order):
            order="desc"
        if len(search_field):
            query=m.Visitors.all()
            query.filter(search_field + " >= ", search_what)
            query.filter(search_field + " < ", search_what + u'\ufffd'
        else:
            query=m.Visitors.gql("ORDER BY %s %s" % (sort, order))
        visitors=query.fetch(rows, (page-1)*rows)
        rows=[]
        for v in visitors:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
        count=query.count()
        obj={"total":count,"rows":rows}
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1),
    ('/list_visitors_2', list_visitors_2),
    ('/get_visitors_2', get_visitors_2),
    ('/list_visitors_3', list_visitors_3),
    ('/get_visitors_3', get_visitors_3),
    ('/list_visitors_4', list_visitors_4),
    ('/get_visitors_4', get_visitors_4),
    ('/list_visitors_5', list_visitors_5),
    ('/get_visitors_5', get_visitors_5)
], debug=True)

這裡我們新增擷取搜尋框傳出的 search_field 與 search_what, 然後判斷前端是否有傳出 search_field 參數, 沒有的話就如同上面測試 4 依指定方式排序; 若有, 就先取得該資料表全部紀錄, 再用 filter() 函數去找搜尋欄位之值介於 search_what 字串與 search_what + u'\ufffd' 字串間的紀錄, 這裡 u'\ufffd" 是最後一個 unicode 字元, 因此就能搜尋到以 search_what 開頭的紀錄.

測試 5 : http://jqueryeasyui.appspot.com/list_visitors_5 

其實上面用 >= 與 < 配合 unicode 模擬 LIKE "search_what%" 後面模糊搜尋之前採用 gql() 查詢都失敗, 例如用下列語法 :

            query=m.Visitors.gql(
                "WHERE %s >= %s AND %s < %s ORDER BY %s %s" %
                (search_field, search_what, search_field,
                search_what + u'\ufffd', sort, order))

執行時出現 "Internal Server Error", 錯誤訊息如下 :

"BadQueryError: Parse Error: Expected no additional symbols at symbol \ufffd"

怎麼看都看不出問題在哪. 就算不跳行, 把整個 GQL 語句放在一行也是一樣的錯誤. 換另外一種字串代換法也無濟於事 :

            query=m.Visitors.gql(
                'WHERE :1 >= :2 AND :3 < :4 ORDER BY :5 :6',
                search_field, search_what, search_field,
                search_what + u'\ufffd', sort, order)

此語句出現的錯誤是 :

"BadQueryError: Parse Error: Invalid WHERE Identifier at symbol :1"

最後只剩下 filter() 函式這一招, 竟然就成功了. 總之,  可能是我的 Python 還不到家, 要好好地來學學, 畢竟 Raspberry Pi 也要用. 不過上面這個測試 5 還有一個問題, 就是欄位排序功能不能用了, 我試過先用 gql() 排序, 再用 filter() 過濾 :

            query=m.Visitors.gql("ORDER BY %s %s" % (sort, order))
            query.filter(search_field + " >= ", search_what)
            query.filter(search_field + " < ", search_what + u'\ufffd')

但這樣是不行的, 出現如下伺服器錯誤 :

"'GqlQuery' object has no attribute 'filter'"

原來 gql() 傳回的 Query 物件與用 all() 傳回者不同, 它沒有 filter() 函式. 但如果改成用 all() 搜尋後再呼叫 sort() 排序, 這樣雖然不會有錯誤, 但實際上按欄位標題卻沒有動作 :

            query=m.Visitors.all()
            query.filter(search_field + " >= ", search_what)
            query.filter(search_field + " < ", search_what + u'\ufffd')
            if order=="desc":
                query.order("-" + search_field)
            else:
                query.order(search_field)

總之, 測試到目前為止, 結論是有搜尋功能就沒有欄位排序功能, 反之亦然.

我覺得很奇怪, SQL 的 LIKE 語法在 MySQL, SQL Server, 甚至微軟的 ACCESS 都能很方便地被用做對特定欄位的全文檢索, 不知道為何 Google 的 Big Table 就是不能提供? 只能以很彆扭的方式勉強做到 LIKE "search_what%"?

2016-01-11 補充 :

後來我在下面這篇看到, StringList 欄位如果用 "=" 去搜尋, 事實上是 LIKE "%search_what%" 的功能, 亦即可以做全文檢索 :

在GAE中模拟like查询进行模糊搜索

所以我又增加了下面測試 6. 在此測試中, 我想對 ip 與 user_agent 兩個欄位做全文檢索, 因此在 model.py 中增加了 ip_list 與 user_agent_list 這兩個欄位 :

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

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

class Visitors(db.Model):
    ip=db.StringProperty()
    ip_list=db.StringListProperty()
    visit_time=db.DateTimeProperty()
    user_agent=db.StringProperty()
    user_agent_list=db.StringListProperty()

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

然後在 templates 下複製 list_visitors_5.htm 為 list_visitors_6.htm, 但僅修改搜尋用的下拉式選單 search_field 元件的值, 將原來的 ip 改為搜尋 ip_list 欄位, 原來的 user_agent 改為搜尋 user_agent_list 欄位 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:960px;" toolbar="#search_bar"></table>
  <div id="search_bar" style="padding:3px">
    <select id="search_field">
      <option value="ip_list">IP 位址</option>
      <option value="visit_time">到訪時間</option>
      <option value="user_agent_list">瀏覽器</option>
    </select>
    <input id="search_what" style="line-height:15px;border:1px solid #ccc">
    <a href="#" class="easyui-linkbutton" iconCls="icon-search" onclick="doSearch()">搜尋</a>
  </div>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors_6',
        method:"post",
        columns:[[
          {field:'ip',title:'IP 位址',width:150,sortable:true},
          {field:'visit_time',title:'到訪時間',width:150,sortable:true},
          {field:'user_agent',title:'瀏覽器',sortable:true}
          ]],
        singleSelect:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        pageSize:10
        });
      });
    function doSearch(){
      $('#visitors').datagrid('load',{
        search_field: $('#search_field').val(),
        search_what: $('#search_what').val()
        });
      }
  </script>
{% endblock%}

最後在 main.py 中增加 list_visitors_6 與 get_visitors_6 這兩個路徑與其處理類別如下 :

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

class get_visitors_6(webapp2.RequestHandler):
    def post(self):
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        search_field=self.request.get("search_field")
        search_what=self.request.get("search_what")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="visit_time"
        if not len(order):
            order="desc"
        if len(search_field):
            if search_field=="ip_list":
                query=m.Visitors.gql("WHERE ip_list = :1", list(search_what))
            elif search_field=="user_agent_list":
                query=m.Visitors.gql("WHERE user_agent_list = :1", list(search_what))
            else:
                query=m.Visitors.all()
                query.filter(search_field + " >= ", search_what)
                query.filter(search_field + " < ", search_what + u'\ufffd') 
        else:
            query=m.Visitors.gql("ORDER BY %s %s" % (sort, order))
        visitors=query.fetch(rows, (page-1)*rows)
        rows=[]
        for v in visitors:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
        count=query.count()
        obj={"total":count,"rows":rows}
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj)) 

class MainHandler(webapp2.RequestHandler):
    def get(self):
        url="templates/default.htm"
        ip=self.request.remote_addr
        user_agent=os.environ.get("HTTP_USER_AGENT")
        #user_agent=self.request.headers.get("User-Agent")
        visitor=m.Visitors()
        visitor.ip=ip
        visitor.visit_time=datetime.now() + timedelta(hours=+8)
        visitor.user_agent=user_agent
        visitor.ip_list=list(ip)
        visitor.user_agent_list=list(user_agent)
        visitor.put()
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ('/easyui_5', easyui_5),
    ('/login_5', login_5),
    ('/easyui_5_1', easyui_5_1),
    ('/home', home),
    ('/google_user_login_1', google_user_login_1),
    ('/google_user_login_2', google_user_login_2),
    ('/google_user_login_3', google_user_login_3),
    ('/google_user_login_4', google_user_login_4),
    ('/google_user_login_5', google_user_login_5),
    ('/list_visitors_1', list_visitors_1),
    ('/list_visitors_2', list_visitors_2),
    ('/get_visitors_2', get_visitors_2),
    ('/list_visitors_3', list_visitors_3),
    ('/get_visitors_3', get_visitors_3),
    ('/list_visitors_4', list_visitors_4),
    ('/get_visitors_4', get_visitors_4),
    ('/list_visitors_5', list_visitors_5),
    ('/get_visitors_5', get_visitors_5),
    ('/list_visitors_6', list_visitors_6),
    ('/get_visitors_6', get_visitors_6)
], debug=True)

首先我們須修改根目錄處理類別, 當使用者要求根目錄時, 把 ip 與 user_agent 這兩個變數用 list() 函式轉成串列型態後存入 ip_list 與 user_agnet_list 欄位中去, 這樣才能對此二欄位進行全文檢索. 

其次是在 get_visitors_6 類別中, 我們須判斷 search_field 之值, 當其為 "ip_list" 或 "user_agent_list" 時, 就用 "=" 判斷去搜尋這兩個欄位. 注意, 因為此兩個欄位儲存的是 list (串列) 資料, 因此 search_what 也必須用 list() 函式轉成串列型態才行. 實際測試範例如下 :


結果真的可以對 ip_list 與 user_agent_list 欄位進行全文檢索, 不過代價資料儲存要花兩倍的空間. 我連線到 GAE 的 Datastore 管理頁面去檢視資料實體, 發現用 list() 函式將字串轉成串列時, 它其實是把字串的每個字元拆開來存, 例如 ip 欄位值為 223.141.225.199, 它的 ip_list 欄位值如下 :

["2","2","3",".","1","4","1",".","2","2","5",".","1","9","9"]

而 user_agent 欄位字串較長, 例如 :

Mozilla/5.0 (Windows NT 6.3; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0

其相對的 user_agent_list 欄位值就很長了 :
["M","o","z","i","l","l","a","/","5",".","0"," ","(","W","i","n","d","o","w","s"," ","N","T"," ","6",".","3",";"," ","W","O","W","6","4",";"," ","r","v",":","4","3",".","0",")"," ","G","e","c","k","o","/","2","0","1","0","0","1","0","1"," ","F","i","r","e","f","o","x","/","4","3",".","0"]

這樣拆成單一字元儲存, 然後用 "=" 去匹配就能做全文檢索, 其機制為何? 我也只知其然不知其所以然. 不過實際測試後發現, 它應該是把要搜尋的字元拆開後, 去 StringList 欄位中搜尋是否含有這些字元, 所以嚴格來講不是真正的 LIKE "%search_what%" 模糊搜尋, 因為會找到比預期還多的東西, 例如我搜尋 IP 位址含有 21 者找到 4 筆, 其中的 223.114.225.199 並沒有 21 字串, 但還是被找出來, 我想就是因為裡面含有 2 與 1 的緣故 :


若改為搜尋 217, 就只剩 3 筆, 223.114.225.199 就被刷掉了, 因為它沒有 7 :


所以這種解決方式只是以化整為零的方式將字串打散為字元串列儲存, 搜尋時也是把欲搜尋的字串打散, 然後去儲存的串列中找看看是否都含有這些字元罷了. 雖然要找的都有找到, 但這不是肯德基!

好了, 關於 GAE 上 EasyUI Datagrid 的前後端互動大概如此, GAE 在資料庫方面不如 PHP+MySQL 那樣方便好用, 像欄位全文檢索這麼稀鬆平常的功能在 GAE 竟然如此彆扭. 或許真的是有一好沒兩好, Google 雲端穩定快速可擴充性佳的代價就是, 程式師要辛苦點. 

以上測試的全部原始碼可在下列網址下載 :

沒有留言 :