2016年2月28日 星期日

如何在 GAE 上佈署 jQuery EasyUI 專案 (十一) : EasyUI CMS on GAE 之 6

原以為 EasyUI  on GAE 可以告一段落了, 想整理檔案上傳雲端保存, 卻發現我漏掉一個功能沒有實作, 就是使用者管理. 有前面其他功能當範本, 要實作也是很快, 今天斷斷續續修改測試一下就搞定了, 以下是測試紀錄.

首先是去 model.py 修改之前做登入測試時就建立的 Members 資料模型, 添加了 email (預備發 email 用), 與 mobile (預備發簡訊用) 兩個欄位如下 :

class Members(db.Model):
    account=db.StringProperty(required=True)
    password=db.StringProperty(required=True)
    theme=db.StringProperty(required=True,default="default")
    is_admin=db.BooleanProperty(required=True,default=False)
    email=db.EmailProperty(required=False)
    mobile=db.StringProperty(required=False)

member=Members(key_name="admin",account="admin",password="admin",
    theme="default",is_admin=True,email="foo@bar.com",mobile="0933")
member.put()
member=Members(key_name="guest",account="guest",password="guest",
    theme="black",is_admin=False,email="foo@bar.com",mobile="0932")
member.put()

其次新增一個 Systabs 資料模型的實體, 以便增加一個 "使用者" 頁籤, 超連結指向 /list_members 這個路徑 :

systab=Systabs(key_name="list_members",tab_name="list_members",
    tab_label=u"使用者",tab_link="/list_members",tab_order=3,tab_admin=True)
systab.put()

這樣就搞定資料儲存了, 接下來去 main.py 新增 /list_members 路徑的處理類別, 主要是用來顯示目前的使用者列表 :

class list_members(webapp2.RequestHandler):
    def get(self):
        #query Themes from datastore
        themes=m.Themes.all()
        info={}
        theme_list=[]
        for t in themes:
            theme_list.append(t.theme)
        info["themes"]=theme_list
        url="templates/list_members.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

可見它會渲染 templates 下的 list_members.htm 這個網頁模板, 並且將全部主題布景選項放在字典物件中傳遞給它, 以便在新增使用者頁面中產生下拉式選單. 此模板 list_members.htm 內容如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <!--使用者 Members 列表-->
  <table id="members" title="使用者列表" style="width:auto" data-options="tools:'#members_tools'"></table>
  <div id="members_tools">  
    <a href="#" id="add_member" class="icon-add" title="新增"></a>
    <a href="#" id="edit_member" class="icon-edit" title="編輯"></a>
    <a href="#" id="remove_member" class="icon-remove" title="刪除"></a>
    <a href="#" id="reload_members" class="icon-reload" title="重新載入"></a>
  </div>
  <!--新增&編輯 Members 表單對話框-->
  <div id="member_dialog" class="easyui-dialog" title="新增連結" style="width:360px;height:270px;" data-options="closed:'true',buttons:'#member_buttons'">
    <form id="member_form" method="post" style="padding:10px">
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">帳號 : </label>
        <input id="member_account" name="account" type="text" class="easyui-textbox" data-options="missingMessage:'此欄位必須為英數字組合',required:true,readonly:false" style="width:230px">
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">密碼 : </label>
        <input name="password" type="text" class="easyui-textbox" data-options="missingMessage:'此欄位為必填',required:true" style="width:230px">
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">主題布景 : </label>
        <select name="theme" id="user_theme" class="easyui-combobox" data-options="required:true,panelHeight:'auto'">
{% for t in info.themes %}
              <option value="{{t}}">{{t}}</option>
{% endfor %}
        </select>
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">管理員 : </label>
        <select name="is_admin" id="is_admin" class="easyui-combobox" data-options="required:true,panelHeight:'auto'">
          <option value="True">True</option>
          <option value="False">False</option>
        </select>
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">Email : </label>
        <input name="email" id="email" type="text" class="easyui-textbox" data-options="required:true"  style="width:230px">
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">行動電話 : </label>
        <input name="mobile" type="text" class="easyui-textbox" style="width:230px">
        <input type="hidden" id="member_op" value="">
      </div>
    </form>
  </div>
  <div id="member_buttons" style="padding-right:15px;">
    <a href="#" id="clear_member" class="easyui-linkbutton" iconCls="icon-clear" style="width:90px">重設</a>
    <a href="#" id="save_member" class="easyui-linkbutton" iconCls="icon-ok" style="width:90px">確定</a>
  </div>
  <script>
    $(function(){
      //標頭連結 members
      $('#members').datagrid({
        columns:[[
          {field:'account',title:'帳號',sortable:true},
          {field:'password',title:'密碼',sortable:true},
          {field:'theme',title:'主題布景',sortable:true},
          {field:'is_admin',title:'管理員',sortable:true},
          {field:'email',title:'Email',sortable:true},
          {field:'mobile',title:'行動電話',sortable:true}
          ]],
        url:"/get_members",
        method:"post",
        singleSelect:true,
        rownumbers:true
        });
      $("#clear_member").bind("click",function(){
        $("#member_form")[0].reset();
        });
      $("#save_member").bind("click",function(){      
        var op=$("#member_op").val();  //判斷是新增或修改
        if (op=="update") {var url="/update_member";}
        else {var url="/add_member";}
        $("#member_form").form("submit",{
          url:url,
          method:"post",
          success:function(data){
            var data=eval('(' + data + ')');
            $("#member_dialog").dialog("close");
            if (data.status==="success") {
              $("#members").datagrid("reload");
              }
            else {
              $.messager.alert("訊息",data.reason,"error");  
              }        
            }
          });
        });
      $("#add_member").bind("click",function(){
        $("#member_dialog").dialog("open").dialog("setTitle","新增使用者");
        $("#member_account").textbox({"readonly":false}); //for adding
        $("#member_form").form("clear");
        $("#member_op").val("add");
        $('#user_theme').combobox();
        $('#user_theme').combobox('setValue','default');
        $('#is_admin').combobox();
        $('#is_admin').combobox('setValue','False');
        $('#email').textbox('setValue','foo@bar.com');
        });
      $("#edit_member").bind("click",function(){
        var row=$("#members").datagrid("getSelected");
        if (row) {
          $("#member_dialog").dialog("open").dialog("setTitle","編輯使用者");
          $("#member_form").form("load",row);
          $("#member_account").textbox({"readonly":true});
          $("#member_op").val("update");
          }
        else {$.messager.alert("訊息","請先選取要編輯的使用者!","error");}
        });
      $("#remove_member").bind("click",function(){
        var row=$("#members").datagrid("getSelected");
        if (row) {
          var params={account:row.account};
          $.messager.confirm("確認","確定要刪除這個使用者嗎?",function(btn){
            if (btn){
              if (row.account=="admin") {
                $.messager.alert("訊息","此為系統管理者不可刪除!","error");
                return;
                }
              var callback=function(data){
                if (data.status==="success"){
                  $("#members").datagrid("reload");
                  }
                else {$.messager.alert("訊息",data.reason,"error");}          
                };              
              $.post("/remove_member",params,callback,"json");
              }
            })
          }
        });
      $("#reload_members").bind("click",function(){
        $("#members").datagrid("load");
        });
      });
  </script>
{% endblock%}

這裡黃顏色的部分是今天花比較多時間磨合的地方. email 這個欄位很奇怪, 我明明在 model.py 中已經用 required=False 指名此欄位不必須填值, 但測試新增使用者時卻還是報錯, 說 email 不可 empty. 沒辦法, 乾脆在 data-options 中將 email 設為必填. 既然如此, 為了用 Easyui 的 setValue 設定初始值, 就幫此欄位多設一個 id 屬性.

另外, 要幫 theme 與 is_admin 這兩個下拉式選單設定初始值時發現, 直接用 combobox 的 setValue 方法是不行的, 會說找不到 options 物件, 必須先呼叫 combobox() 方法初始化物件, 再呼叫 setValue 方法才會生效.

值得一提的是, 我利用一個隱藏欄位 member_op 來儲存是要 add 還是 update 使用者資料, 如果是編輯動作, 就將 account 欄位設為唯讀 (帳號一經建立不能修改, 只能刪除), 並將 member_op 設為 update; 若為新增動作, 就清除 account 欄位的唯讀屬性, 並將 member_op 設為 add, 這樣就可以共用一張表單了.

此 datagrid 是從 /get_members 路徑取得資料來源, 其路徑處理類別如下 :

class get_members(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="account"
        if not len(order):
            order="asc"
        query=m.Members.gql("ORDER BY %s %s" % (sort, order))
        count=query.count()
        mbs=query.fetch(rows, (page-1)*rows)
        rows=[]  #for storing objects
        for mb in mbs:
            member={"account":mb.account,
                    "password":mb.password,
                    "theme":mb.theme,
                    "is_admin":mb.is_admin,
                    "email":mb.email,
                    "mobile":mb.mobile}
            rows.append(member)
        obj={"total":count,"rows":rows}  #Easyui datagrid json format
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

就是讀取全部 Members 資料實體, 將各欄位值編成 json 傳回前端而已. 

新增使用者 /add_member 的路徑處理類別如下 :

class add_member(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account")
        password=self.request.get("password")
        theme=self.request.get("theme")
        is_admin=self.request.get("is_admin")
        email=self.request.get("email", default_value="foo@bar.com")
        mobile=self.request.get("mobile", default_value="0933123456")
        #trans string to boolean
        if is_admin=="True":
            is_admin=True
        else:
            is_admin=False
        #check entity if exist
        mb=m.Members.get_by_key_name(account)
        if mb: #already exist
            result='{"status":"failure","reason":"帳號已存在!"}'  
        else:  #new member
            member=m.Members(key_name=account,
                account=account,
                password=password,
                theme=theme,
                is_admin=is_admin,
                email=email,
                mobile=mobile
                )
            member.put()
            result='{"status":"success"}'         
        self.response.out.write(result) 

這裡要注意的是資料類型為 boolean 的 is_admin 欄位, 因為前端傳出的 True 與 False 都是字串, 必須轉換為 boolean 欄位才行, 否則會出現資料不符錯誤. 

更新 /update_member 與移除 /remove_member 的處理類別如下 : 

class update_member(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account")
        password=self.request.get("password")
        theme=self.request.get("theme")
        is_admin=self.request.get("is_admin")
        email=self.request.get("email")
        mobile=self.request.get("mobile", default_value="")
        #trans string to boolean
        if is_admin=="True":
            is_admin=True
        else:
            is_admin=False
        #get entity from store
        mb=m.Members.get_by_key_name(account)
        if mb: #entity exist
            mb.account=account
            mb.password=password
            mb.theme=theme
            mb.is_admin=is_admin
            mb.email=email
            mb.mobile=mobile
            mb.put()
            result='{"status":"success"}'
        else:  #member not existed
            result='{"status":"failure","reason":"使用者不存在!"}'         
        self.response.out.write(result)

class remove_member(webapp2.RequestHandler):
    def post(self):
        account=self.request.get("account")
        #get entity from store
        mb=m.Members.get_by_key_name(account)
        if mb: #entity exist
            db.delete(mb)            
            result='{"status":"success"}'
        else:  #member not existed
            result='{"status":"failure","reason":"使用者不存在!"}'         
        self.response.out.write(result) 

以上便是整個使用者管理的完整 CRUD 實作紀錄, 實際測試範例如下 (登入帳密 admin, admin 或 guest, guest) :






OK, 終於完成 EasyuiCMS on GAE 的全部的系統基本管理功能了, 包括登入登出, 使用者管理, 導覽列管理, 標頭超連結管理, 訪客紀錄器. 其實還有一個 App 管理, 需要檔案上傳功能, 但是 GAE 與一般 PHP 應用不同, 我不太確定 GAE 能照搬 PHP 版本過來, 直覺是必須跟系統寫在一起 (擴增功能), 可能很難獨立開來, 但或許也有辦法, 只是還沒想出來而已.


2016年2月27日 星期六

旅展

今天很忙, 早上 GAE 程式改完一段落後, 十點半拎了背包牽了小摺去大地游泳池游泳. 其實我昨天晚上就想去游了, 去河堤還書時就順路去買裝備, 泳鏡 390+泳褲290+泳帽100=780, 想說把書帶回家再回來游, 但突然想起姊姊今天去陳建智畫室畫圖, 得去接她, 所以就改今天了.

到了泳池發現今天人不多 (連假都出遊去了嗎?), 換好衣服後沖了熱水澡出來, 做完暖身操下池時發現, 哇咧, 不是溫水嗎? 怎麼有點冷? 原來剛剛沖的熱水澡太熱了, 池水反而變成冷的. 下池後迫不及待開展我最擅長的蛙式, 但才閉氣滑三次水, 就感覺上氣不接下氣, 氣喘如牛, 只好停下來. 哇咧, 七年沒下水竟然肺活量與心肺能力退步這麼多! 以前我可以閉一口氣滑 4~5 次水再換氣, 還可以從這頭游到那頭再折返, 現在變肉腳了, 所以持續運動是必要的.

休息一下後再開始慢慢嘗試, 總算可以連續游完 25 公尺, 總共來回游了 10 趟左右, 戰力逐漸恢復了. 我看下周開始有時間要每周去游兩次, 因為下午從旅展回來時, 我又彎進去再買 95 張 (買 50 送 45, 共 5000 元), 現在總共有 190 張了, 若每周游 1 次, 要近 4 年才用得完哩!

因舅媽在東南的朋友一直沒給我回覆, 所以下午跑去高雄展覽館看旅展, 上週六去東南明誠分店詢問時, 林妹妹拿了兩張入場券給我, 說旅展還有優惠價. 大概三點進場發現, 天哪, 長長的人龍在排隊入場. 進去後裡面鬧哄哄, 入口第一攤就是東南的, 正想這麼多人還要逛嗎? 就看到明誠分店的林妹妹在我前面, 她幫我找了 4/15 日出發的立春雪壁黑部立山之旅, 一口價 38400, 她說還有機位, 我覺得跨假日可以少請假, 就交了 16000 訂金 (每人 8000), 寫好契約書後我就去逛吃的了. 但過一會兒就 CALL 我說搞錯了, 那一團是人家公司團, 所以只好改為 4/17 那一團, 原價 41900, 2/29 前訂少 1000, 旅展再便宜 1000, 所以是 39900, 兩個人就是 79800. 下周要帶護照回高雄給她.

從展覽館出來順便去總圖看看, 但今天沒找到令我驚豔的書, 所以沒借半本回來. 我現在手上借的夠多了, 應該先消化掉才對. 不過倒是看到陳列台上一本 "第二次機器時代", 隨手就翻到第 14 章首頁, 上面寫著伏爾泰的話 :

"工作能解救人們免於三大惡 : 無聊, 墮落, 和欲求."

對於我這個無法容忍無聊的射手座而言, 我只剩下兩大惡必須去面對, 這應該是個好消息哩.


2016年2月26日 星期五

如何在 GAE 上佈署 jQuery EasyUI 專案 (十) : EasyUI CMS on GAE 之 5

本來想說要開始玩 App Inventor, 但想想還是先把系統設定處理掉好了, 反正應該不會花很多時間. 我參考了之前的 PHP 版本, 但不包括密碼安全設定部分, 那是為了公司資安要求加進去的, 而且也還沒有實作.

用 jQuery EasyUI 打造輕量級 CMS (七)

首先修改 model.py 中的 Settings 模型以及其資料實體 settings 如下 :

class Settings(db.Model):
    site_title=db.StringProperty()
    site_theme=db.StringProperty()
    site_state=db.StringProperty()
    site_created_time=db.DateTimeProperty(auto_now_add=True)

settings=Settings(key_name="settings",site_title="EasyUI-based CMS on GAE",
    site_theme="default",site_state="on")
settings.put()

同時也在 Systab 資料模型新增一個系統設定 settings 頁籤 :

systab=Systabs(key_name="settings",tab_name="settings",
    tab_label=u"系統設定",tab_link="/settings",tab_order=6,tab_admin=True)
systab.put()

然後在 main.py 主程式中新增 settings 與 update_settings 路徑的處理類別 :

class settings(webapp2.RequestHandler):
    def get(self):
        info={}  #for storing parameters
        #query settings from datastore
        settings=m.Settings.get_by_key_name("settings")
        if settings: #entity exist
            info["site_title"]=settings.site_title
            info["site_theme"]=settings.site_theme
            info["site_state"]=settings.site_state
        else:
            info["site_title"]="EasyuiCMS on GAE"
            info["site_theme"]="default"
            info["site_state"]="on"
        #query Themes from datastore
        themes=m.Themes.all()
        theme_list=[]
        for t in themes:
            theme_list.append(t.theme)
        info["themes"]=theme_list
        url="templates/settings.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

class update_settings(webapp2.RequestHandler):
    def post(self):
        #get entity from store
        settings=m.Settings.get_by_key_name("settings")
        if settings: #entity exist
            settings.site_title=self.request.get("site_title")
            settings.site_theme=self.request.get("site_theme")
            settings.site_state=self.request.get("site_state")
            settings.put()
            result='{"status":"success"}'
        else:  #entity not existed
            result='{"status":"failure"}'      
        self.response.out.write(result)

這裡 settings 類別主要是從資料儲存區擷取 Settings 與 Themes 實體, 取得系統設定值與主題布景名稱後存入 info 字典中傳給要渲染的網頁 settings.htm 如下 :

<!--系統設定表單-->
<div id="settings" class="easyui-panel" title="系統設定" style="width:auto;padding:5px;" data-options="tools:'#settings_tools'">
  <form id="settings_form" style="padding:5px">
    <table style="border-width:0px;width:100%;border-spacing:2px;">
      <tr>
        <td style="width:50%;padding:3px;">
          <label style="width:100px;display:inline-block;">網站標題 : </label>
          <input name="site_title" type="text" class="easyui-textbox" data-options="missingMessage:'此欄位為必填',required:true" style="min-width:300px;" value="{{info.site_title}}">
        </td>
        <td style="width:50%;padding:3px;">
          <label style="width:100px;display:inline-block;">系統主題布景 : </label>
          <select name="site_theme" class="easyui-combobox" style="width:120px;" data-options="panelHeight:'auto'">
            <option value="default">主題布景</option>
{% for t in info.themes %}
              <option value="{{t}}"{% ifequal t info.site_theme %} selected{% endifequal %}>{{t}}</option>
{% endfor %}
          </select>
        </td>
      </tr>
      <tr>
        <td style="width:50%;padding:3px;">
          <label style="width:100px;display:inline-block;">系統運行狀態 : </label>
            <input type='radio' name='site_state' value='on'{% ifequal info.site_state 'on' %} checked{% endifequal %}>運轉中
            <input type='radio' name='site_state' value='off'{% ifequal info.site_state 'off' %} checked{% endifequal %}>維護中
        </td>
        <td style="width:50%;padding:3px;">
        </td>
      </tr>
    </table>
  </form>
</div>
<div id="settings_tools">
  <a href="#" id="reload_settings" class="icon-reload" title="重新載入"></a>
  <a href="#" id="save_settings" class="icon-save" title="儲存"></a>
</div>
<script>
  $(document).ready(function(){
    //系統設定 settings
    $('#reload_settings').bind('click',function(){
      $('#settings').panel('open').panel('refresh');
      });
    $('#save_settings').bind('click',function(){
      var params=$('#settings_form').serialize();
      var callback=function(data,textStatus){
        if (data.status==='success'){
          $.messager.alert('訊息','系統設定更新成功!','info');
          }
        else {$.messager.alert('訊息','系統設定更新失敗!','error');}
        }
      $.post('/update_settings',params,callback,'json');
      });
    });
</script>

這裡利用傳入的 info.themes 串列以迴圈來產生下拉式選單的選項. 當按下右上角的儲存鈕時, 會向後端的 /update_settings 路徑提出 post 要求, 更改資料儲存中的設定. 實際測試範例如下 (登入帳號 admin, 密碼 aaa) :

測試 6 : http://jqueryeasyui.appspot.com/main_5 (下載原始碼(備用下載點)


以上就是系統設定的管理, 雖然只有三個基本欄位, 但我主要是把架構與方法勾勒出來, 以後要加欄位就很方便了. OK, 自去年底突然轉向到 GAE 架站系統的研究到此告一段落, 總算將很久以前的構想實作出來的, 以後要在這個基礎上寫應用就能順水推舟快速進行了. 我花時間把過程記錄下來, 為的是將來要用時可以很快恢復功力. 接下來要把 Python 整理一下, 再回頭玩 PHP, 打算今年能一鼓作氣把新版工作日誌寫完.


2016年2月24日 星期三

從 IP 查來源國家 (二)

上一篇文章中使用了檔案查詢的方式從訪客 IP 找出其國名, 其中存在兩個問題, 一是資料似乎有點舊, 有些 IP 找不到所屬國家 (特別是香港); 其二是檔案處理要使用迴圈, 查詢速度似乎較慢.

我找到 ip2nation 這個網站, 不但可以線上查詢 IP 所屬國家, 還慷慨地提供資料庫讓我們下載 (點左方導覽列的 download), 方便整合到自己的應用服務之中. 還可以在底下的框框輸入 email, 當資料庫有更新時會通知我們下載 :

http://www.ip2nation.com/


解壓縮所下載的 ip2nation.zip 會得到一個 ip2nation.sql 資料庫檔, 裡面建立了 ip2nation 與 ip2nationCountries 這兩個資料表, 前者儲存 IP 的上限與國碼簡碼, 如下所示 :

DROP TABLE IF EXISTS ip2nation;

CREATE TABLE ip2nation (
  ip int(11) unsigned NOT NULL default '0',
  country char(2) NOT NULL default '',
  KEY ip (ip)
);

DROP TABLE IF EXISTS countries;
   
CREATE TABLE countries (
  code varchar(4) NOT NULL default '',
  iso_code_2 varchar(2) NOT NULL default '',
  iso_code_3 varchar(3) default '',
  iso_country varchar(255) NOT NULL default '',
  country varchar(255) NOT NULL default '',
  country_zhtw varchar(255) NOT NULL default '',
  lat float NOT NULL default '0',
  lon float NOT NULL default '0',
  PRIMARY KEY  (code),
  KEY code (code)
);
INSERT INTO ip2nation (ip, country) VALUES(0, 'us');
INSERT INTO ip2nation (ip, country) VALUES(687865856, 'za');
INSERT INTO ip2nation (ip, country) VALUES(689963008, 'eg');
INSERT INTO ip2nation (ip, country) VALUES(691011584, 'za');
INSERT INTO ip2nation (ip, country) VALUES(691617792, 'zw');
INSERT INTO ip2nation (ip, country) VALUES(691621888, 'lr');
INSERT INTO ip2nation (ip, country) VALUES(691625984, 'ke');
INSERT INTO ip2nation (ip, country) VALUES(691630080, 'za');
INSERT INTO ip2nation (ip, country) VALUES(691631104, 'gh');
INSERT INTO ip2nation (ip, country) VALUES(691632128, 'ng');
INSERT INTO ip2nation (ip, country) VALUES(691633152, 'zw');
INSERT INTO ip2nation (ip, country) VALUES(691634176, 'za');
INSERT INTO ip2nation (ip, country) VALUES(691650560, 'gh');
INSERT INTO ip2nation (ip, country) VALUES(691666944, 'ng');
INSERT INTO ip2nation (ip, country) VALUES(691732480, 'tz');
INSERT INTO ip2nation (ip, country) VALUES(691798016, 'zm');
INSERT INTO ip2nation (ip, country) VALUES(691863552, 'za');
INSERT INTO ip2nation (ip, country) VALUES(691994624, 'zm');
INSERT INTO ip2nation (ip, country) VALUES(692011008, 'za');
.....

後者儲存國碼簡碼, 英文全名, 經緯度等資訊 :

INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ad', 'AD', 'AND', 'Andorra', 'Andorra', '', 42.3, 1.3);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ae', 'AE', 'ARE', 'United Arab Emirates', 'United Arab Emirates', 24, 54);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('af', 'AF', 'AFG', 'Afghanistan', 'Afghanistan', 33, 65);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ag', 'AG', 'ATG', 'Antigua and Barbuda', 'Antigua and Barbuda', 17.03, -61.48);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ai', 'AI', 'AIA', 'Anguilla', 'Anguilla', 18.15, -63.1);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('al', 'AL', 'ALB', 'Albania', 'Albania', 41, 20);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('am', 'AM', 'ARM', 'Armenia', 'Armenia', 40, 45);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('an', 'AN', 'ANT', 'Netherlands Antilles', 'Netherlands Antilles', 12.15, -68.45);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ao', 'AO', 'AGO', 'Angola', 'Angola', -12.3, 18.3);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('aq', 'AQ', 'ATA', 'Antarctica', 'Antarctica', -90, 0);
INSERT INTO countries (code, iso_code_2, iso_code_3, iso_country, country, country_zhtw,  lat, lon) VALUES('ar', 'AR', 'ARG', 'Argentina', 'Argentina', -34, -64);
.....

在 phpmyadmin 的輸入上傳這個 ip2nation.sql 就會在系統產生 ip2nation 與 ip2nationCountries 這兩個資料表 :


但是這樣只能查詢英文國名, 為了要查中文國名, 我另外準備了一個 nation 資料表, 它只有 code 與 name 兩個欄位, code 就是上面 ip2nationCountries 資料表的國名簡碼, 而 name 是其繁體中文國名, 此表的可由下列連結下載 :

下載國名簡碼與中文國名對照表 nation.sql
下載國名簡碼與中文國名對照表 nation.txt

然後參考 ip2nation.com 網站在 Sample scripts 所提供的範例程式碼, 修改 sys.php 中的 visitors 與 list_visitors 這兩個模組, 在 visitors 模組中我將中英國名分在兩欄呈現 :

    $('#sys_visitors').datagrid({
      columns:[[
        {field:'id',title:'id',sortable:true},
        {field:'visit_time',title:'到訪時間',sortable:true},
        {field:'remote_addr',title:'遠端位址',sortable:true},
        {field:'remote_port',title:'遠端埠號',sortable:true},
        {field:'country',title:'Country',sortable:false},
        {field:'name',title:'國家',sortable:false},
        {field:'user_agent',title:'使用者代理',sortable:true}
        ]],
      url:"sys.php",
      queryParams:{op:"list_visitors"},
      fitColumns:true,
      singleSelect:true,
      pagination:true,
      pageSize:10,
      rownumbers:true
      });

注意, 因為 country 與 name 並非 visitors 資料表內的欄位, 所以這裡 sortable 要設為 false.

而 list_visitors 模組則改為如下 :

  case "list_visitors" : {
    $page=isset($_REQUEST['page']) ? intval($_REQUEST['page']) : 1;
    $rows=isset($_REQUEST['rows']) ? intval($_REQUEST['rows']) : 10;
    $sort=isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'id';
    $order=isset($_REQUEST['order']) ? $_REQUEST['order'] : 'desc';
    if (isset($_REQUEST['search_field'])) { //有 search
      $where="WHERE ".$_REQUEST['search_field']." LIKE '%".
             $_REQUEST['search_what']."%'";
      }
    else {$where="";} //無 search
    $start=($page-1) * $rows;  //本頁第一個列索引 (0 起始)
    $SQL="SELECT COUNT(*) FROM `sys_visitors`";
    $RS=run_sql($SQL);
    $total=$RS[0][0]; //紀錄總筆數
    $SQL="SELECT * FROM sys_visitors ".$where." ORDER BY ".
         $sort." ".$order." LIMIT ".$start.",".$rows;
    $RS=run_sql($SQL);
    $visitors=Array();
    if (is_array($RS)) {
      for ($i=0; $i<count($RS); $i++) {
        //查詢 IP 來源國名
        $SQL='SELECT c.country,c.code FROM ip2nationCountries c,ip2nation i '.
             'WHERE i.ip < INET_ATON("'.$RS[$i]["remote_addr"].'") AND '.
             'c.code=i.country ORDER BY i.ip DESC LIMIT 0,1';
        $RS1=run_sql($SQL);
        if (is_array($RS1)) { //
          $code=trim($RS1[0]["code"]); //Country code
          $country=$RS1[0]["country"]; //Country name
          $RS1=search("nation", "code", $code); //根據 code 查中文國名
          if (is_array($RS1)) {$name=$RS1[0]["name"];}
          else {$name="";}
          }
        else {
          $country="";
          $name="";
          }
        $visitors[$i]=Array("id" => $RS[$i]["id"],
                            "visit_time" => $RS[$i]["visit_time"],
                            "remote_addr" => $RS[$i]["remote_addr"],
                            "remote_port" => $RS[$i]["remote_port"],
                            "country" => $country,
                            "name" => $name,
                            "user_agent" => $RS[$i]["user_agent"]
                            );
        }
      }
    $arr=array("total" => $total, "rows" => $visitors);
    echo json_encode($arr);
    break;
    }

主要就是利用從 ip2nationCountries 資料表查得的國名簡碼 code, 再去 nation 這張表去查中文國名而已. 這樣就能順利顯示訪客來自哪個國家了 :


以後若發現有些 IP 沒顯示國名, 表示資料需要更新了, 只要再去 ip2nation.com 下載最新的 sql 檔匯入資料庫即可, 而 nation 資料表除非有新的獨立國家, 否則幾乎都不用更新. 有了這三張資料表, 上一篇文章所用的檔案可以丟掉了, 而且 file.php 函式庫中新增的 get_country_by_ip() 也可以拿掉了.

如果能顯示 IP 的 DNS 更好, 可以大致知道訪客來自哪個公司或機關, 但可惜沒有這樣的資料庫可用, 有的話相信也很龐大, 會佔據 MySQL 很大份量. 這可以到下列網站查詢 :

http://ping.eu/nslookup/

最後我修改了 EasyuiCMS 的系統安裝檔 install.php, 加入上面三個資料表的建立語法 :

    //建立 ip2nation 資料表 (訪客紀錄用:DUMMY:避免錯誤)
    $data_array["ip"]="int(11)";         //IP 上限
    $data_array["country"]="char(2)";    //國名簡碼
    $result=create_table("ip2nation",$data_array);
    if ($result) {$msg .= "建立資料表 ip2nation ... 完成!<br>";}
    $data_array=NULL;

    //建立 countries 資料表 (訪客紀錄用:DUMMY:避免錯誤)
    $data_array["code"]="varchar(4)";           //國名簡碼
    $data_array["iso_code_2"]="varchar(2)";     //國名簡碼
    $data_array["iso_code_3"]="varchar(3)";     //國名簡碼
    $data_array["iso_country"]="varchar(255)";
    $data_array["country"]="varchar(255)";
    $data_array["lat"]="float";
    $data_array["lon"]="float";
    $result=create_table("countries",$data_array);
    if ($result) {$msg .= "建立資料表 countries ... 完成!<br>";}
    $data_array=NULL;

    //建立 nation 資料表 (訪客紀錄)
    $data_array["code"]="varchar(4) PRIMARY KEY";
    $data_array["name"]="varchar(255)";       //中文國名
    $result=create_table("nation",$data_array);
    if ($result) {$msg .= "建立資料表 nation ... 完成!<br>";}
    $data_array=NULL;
    //從 data 下讀取 nation.txt 寫入 nation 資料表
    $file=read_file("./data/nation.txt");
    $lines=explode("\n", $file);
    foreach($lines as $line) {
      if (strlen($line)) {
        $arr=explode(',', trim($line));
        //插入 nation 資料表
        $data_array["code"]=$arr[0];
        $data_array["name"]=$arr[1];
        $result=insert("nation", $data_array);
        $data_array=NULL;
        }
      }

這裡主要是建立 nation 這張資料表, 並從 /data 下讀取 nation.txt 填入資料表中, 而 ip2nation 與 countries 這裡只是建立空資料表, 避免尚未匯入從 ip2nation.com 下載的 ip2nation.sql 時, 程式讀取它們可能產生的錯誤.

參考 :

INET_ATON() and INET_NTOA() in PHP?


2016年2月23日 星期二

從 IP 查來源國家 (一)

上週六去市圖還書時看到架上這本以前借過, 但沒時間看的 "PHP 最強外掛 100 選 (碁峰翻譯書, 已絕版)", 一時技癢拿下來看, 翻到最後面第 91 個 Pug-in : "利用 IP 取得所在位置", 覺得挺好玩的, 就又再借回來, 想幫 EasyuiCMS 上面的訪客紀錄器添加顯示來源國家的功能.


此書範例的原始碼可在下面連結下載 :

# Plug-in PHP: 100 Power Solutions

下載後解開第 12 章底下的 plugin91.php 與 ips.txt 這兩個檔案, 這個 ips.txt 就是各國的 ip 分布區間, 格式如下 :

984809472,984875007,Australia
984875008,984940543,South Korea
984940544,984956927,South Korea
984956928,984965119,Australia
984965120,984969215,Pakistan
984973312,985006079,Thailand
985006080,985071615,Singapore
985071616,985104383,Japan
985104384,985137151,Japan
985137152,985202687,South Korea
985202688,985235455,Singapore
985235456,985268223,Singapore
985268224,985399295,Viet Nam
985399296,985661439,Japan
985661440,985792511,China
985792512,985923583,China
985923584,986054655,China

其中第一個數字是 IP 下限, 第二個數字是上限, 最後面為所屬國名. 我有改其中的國名, 例如 Russian Federation 改為 Russia, Korea republic of 改為 South Korea 等等, 然後把這個 ipx.txt 放在根目錄的 data 目錄下面. 此檔可從下列連結下載 :

# 下載修改後的 ips.txt

接著參考 plugin91.php 這個範例程式, 修改 /lib 下的 file.php 函式庫, 加入下面的函式 get_country_by_ip() :

/*-----------------------------------------------------------------------------
get_country_by_ip($ip)
功能 :
  此函數依據傳入的 ip 到 /data/ips.txt 查詢其所屬國家英文名稱.
參數 :
  $ip : IP (192.168.1.1)
傳回值 :
  成功傳回英文國名, 失敗傳回 FALSE.
範例 :
  $result=get_country_by_ip("10.11.223.12");
-----------------------------------------------------------------------------*/
function get_country_by_ip($ip) {
  $iptemp=explode('.', $ip);
  $ipdec=$iptemp[0] * 256 * 256 * 256 + $iptemp[1] * 256 * 256 +
         $iptemp[2] * 256 + $iptemp[3];
  $file=read_file("./data/ips.txt");
  if (!strlen($file)) return FALSE;
  $lines=explode("\n", $file);
  foreach($lines as $line) {
    if (strlen($line)) {
      $parts=explode(',', trim($line));
      if ($ipdec >= $parts[0] && $ipdec <= $parts[1]) {return $parts[2];}
      }
    }
  return FALSE;
  }

其原理就是將 IP 以小數點拆分成四個數字, 然後換算成一個十進位整數, 然後讀取 ips.txt 檔, 比對看看落在哪一個區間, 有的話傳回國名, 否則傳回 False.

最後修改 sys.php 中關於訪客紀錄的兩個 case 模組 visitors 與 list_visitors (sys.php 一開頭就已匯入 file.php 了), 在 visitors 模組中, 修改 datagrid 的設定, 加入 country 欄位 :

    $('#sys_visitors').datagrid({
      columns:[[
        {field:'id',title:'id',sortable:true},
        {field:'visit_time',title:'到訪時間',sortable:true},
        {field:'remote_addr',title:'遠端位址',sortable:true},
        {field:'country',title:'國家',sortable:true},
        {field:'remote_port',title:'遠端埠號',align:'right',sortable:true},
        {field:'user_agent',title:'使用者代理',sortable:true}
        ]],
      url:"sys.php",
      queryParams:{op:"list_visitors"},
      fitColumns:true,
      singleSelect:true,
      pagination:true,
      pageSize:10,
      rownumbers:true
      });

然後在 list_visitors 模組中加入呼叫 get_country_by_ip() 的程式碼 :

  case "list_visitors" : {
    $page=isset($_REQUEST['page']) ? intval($_REQUEST['page']) : 1;
    $rows=isset($_REQUEST['rows']) ? intval($_REQUEST['rows']) : 10;
    $sort=isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'visit_time';
    $order=isset($_REQUEST['order']) ? $_REQUEST['order'] : 'desc';
    if (isset($_REQUEST['search_field'])) { //有 search
      $where="WHERE ".$_REQUEST['search_field']." LIKE '%".
             $_REQUEST['search_what']."%'";
      }
    else {$where="";} //無 search
    $start=($page-1) * $rows;  //本頁第一個列索引 (0 起始)
    $SQL="SELECT COUNT(*) FROM `sys_visitors`";
    $RS=run_sql($SQL);
    $total=$RS[0][0]; //紀錄總筆數
    $SQL="SELECT * FROM sys_visitors ".$where." ORDER BY ".
         $sort." ".$order." LIMIT ".$start.",".$rows;
    $RS=run_sql($SQL);
    $visitors=Array();
    if (is_array($RS)) {
      for ($i=0; $i<count($RS); $i++) {
        $country=get_country_by_ip($RS[$i]["remote_addr"]);
        if (!$country) {$country="";}  //傳回 false 設為空字串
        $visitors[$i]=Array("id" => $RS[$i]["id"],
                            "visit_time" => $RS[$i]["visit_time"],
                            "remote_addr" => $RS[$i]["remote_addr"],
                            "country" => $country,
                            "remote_port" => $RS[$i]["remote_port"],
                            "user_agent" => $RS[$i]["user_agent"]
                            );
        }
      }
    $arr=array("total" => $total, "rows" => $visitors);
    echo json_encode($arr);
    break;
    }

這樣就會增加國家這個欄位了 :



但顯然這個 ips.txt 不是很完整, 某些 IP 查不到所屬國家. 如果要加註中文國名, 那麼需要準備一張中英國名對照表. 這張表在目前還在用的舊工作日誌的 ACCESS 資料庫中就有, 我先將其匯出後, 剔除不需要的欄位如時區等, 並將英文國名與上面的 ips.txt 一致化, 此檔可從下列連結下載 :

下載中英國名對照表 country.txt

在整理這張表時遇到排序的問題, 我在 ACCESS 2003 上將其按英文國名排序後, 按 "檔案/匯出" 輸出為 country.txt, 但它卻不是按照英文字母排序 :

ACCESS 中已排序好

以逗號分隔欄位

去除雙引號

沒有按照字母排序

解決之道就是把這 .txt 檔改成 .csv 副檔名, 然後用 EXCEL 開啟後排序, 再回存為 country.txt 檔即可 :


參考 :

跑出來的順序不是照我ACCESS上所看到的順序

接下來去修改 file.php 裡的 get_country_by_ip() 函式, 在查到英文國名後, 再去開啟 country.txt, 用同樣的邏輯查詢其中文國名 :

/*-----------------------------------------------------------------------------
get_country_by_ip($ip)
功能 : 
  此函數依據傳入的 ip 到 /data/ips.txt 查詢其所屬國家英文名稱.
參數 :  
  $ip : IP (192.168.1.1) 
傳回值 :
  成功傳回英文國名, 失敗傳回 FALSE. 
範例 :
  $result=get_country_by_ip("10.11.223.12"); 
-----------------------------------------------------------------------------*/  
function get_country_by_ip($ip) {
  $iptemp=explode('.', $ip);
  $ipdec=$iptemp[0] * 256 * 256 * 256 + $iptemp[1] * 256 * 256 +
         $iptemp[2] * 256 + $iptemp[3];
  $file=read_file("./data/ips.txt");
  if (!strlen($file)) return FALSE; //沒檔案
  $lines=explode("\n", $file);  //拆出各列
  foreach($lines as $line) {  //拜訪每一列
    if (strlen($line)) {  //非空白列
      $parts=explode(',', trim($line));  //以逗號拆分
      if ($ipdec >= $parts[0] && $ipdec <= $parts[1]) {  //比對 ip
        //return $parts[2];
        $country=trim($parts[2]);  //英文國名
        $file2=read_file("./data/country.txt");
        if (!strlen($file2)) return FALSE;  //沒檔案
        $lines2=explode("\n", $file2);  //拆出各列
        foreach($lines2 as $line2) {  //拜訪每一列
          if (strlen($line2)) {  //非空白列
            $parts2=explode(',', trim($line2));  //以逗號拆分
            if ($parts2[0]==$country) { //比對英文國名
              return $country." (".$parts2[1].")";
              }  
            }
          }
        }
      }
    }
  return FALSE;
  }


這樣就會顯示中英國名了.


TinyMCE 在 IE 無法顯示 icon 問題

上回企圖將公司工作日誌系統的 HTML 編輯器改為 TinyMCE, 改換之後卻出現了一個奇怪的問題, 就是某台電腦的 IE11 無法顯示 TinyMCE 的按鈕 icon, 其他台電腦都沒問題, 比較 IE 版本都一樣啊! 而且 Chrome 與 Firefox 都無問題 (可見 IE 真是無可救藥的爛), 卡在一台沒辦法用, 無奈只好改回原來的 CKEditor 編輯器.

昨天正想要修改 EasyuiCMS 目錄結構, 發現 TinyMCE 已經版為 4.3.4, 我就想說該不會已經解決 icon 問題了吧? 於是下載最新版, 替換工作日誌 plug-ins 下的舊版, 測試結果還是不行. 我又再去網搜這個問題, 這回找到新線索 :

# TinyMCE : Toolbar icons not appearing

在這篇文章中提到兩個解法, 一是要加上 <!DOCTYPE html>, 二是要把 TinyMCE 的 textarea 元素上的 !important 樣式標記去掉, 因為它可能會破壞掉樣式設定. 而在下面這篇文章中則有不同解法 :

# tinymce 4.0 : toolbar icons not showing up in IE

它是建議在 head 裡面添加下列針對 IE 支援的 meta 元素 :

<meta http-equiv="X-UA-Compatible" content="IE=edge">

上面三個建議我全部都採用, 在 admin.asp 中加上 meta 元素, 結果真的有效! 但糟糕的是現在不知道哪一個才是真正的解決關鍵, 我應該一個一個去改才對. 因為每次改都要進電腦機房去改, 一堆門禁密碼很麻煩, 先擱著吧, 下一次要改的時候再來試試看. 總之, 就是暫時解決了一個困擾我很久的問題.

其他參考 :

# How to add an Access-Control-Allow-Origin header


2016年2月21日 星期日

2016 年第 8 周記事

昨晚在 Yahoo 新聞看到有小行星將掠過地球, 結果晚上睡覺時就做了一個天文學之夢, 我夢見金木水火土五星連珠, 排成一列向地球衝過來, 我是透過電腦軟體即時監看, 眼看著螢幕上顯示他們就要撞上地球了, 我連忙驚呼, 而且感到似乎天搖地動, 狂風拔地而起 ... 好險! 它們只是從地球旁邊呼嘯而過啦!

結果今天早上姊姊說我昨天說夢話, 而且驚叫得很大聲, 好像是講英文 "Warship!", 我倒覺得可能是看到五星要撞上地球了, 情急之下喊出 "Bullshit" 吧! 還真奇怪, 人的夢境有很多種, 最常見的是夢到被鬼追, 但夢到彗星撞地球之類的就少見了.

十二月種植的十株玉米因為冬雨的關係長得不好, 加上日照與肥料施予不足, 矮矮的就抽穗開花結果, 傍晚把五支穗鬚已經變黑的摘下來煮, 嫩則嫩矣, 果粒還不夠飽滿. 此次得到一個經驗, 以後 11 月後就不再種玉米, 收成可能不佳.

上週過年後所鋤的一塊地又長出密密麻麻的雜草來, 真是可惱啊! 本想下午再鋤一遍, 爸說這是俗稱 "冬馬其" 的雜草, 下面有塊莖, 非常難根除. 我挖開泥土一看, 果然它們在地下形成綿密的塊狀莖網路, 宛如人類的腦神經軸突結構 :


若用鋤頭將其切碎會更糟, 就像把地瓜切塊後, 每一塊都會長出新芽, 不鋤還好, 越鋤越糟. 回來查一下這種雜草名稱, 原來叫做香附子, 參考 :

# 單元 24 香附子
# 小麥的幼苗與雜草,你分得出來嗎?

看來只有農藥能對付此種難纏的雜草了.

2016-02-23 補充 :

爸說用塑膠布將地面蓋住一陣子, 香附子的塊莖就會慢慢腐爛掉.


2016年2月20日 星期六

松屋拉麵

今天是很奇特的一天, 小狐狸們都要上學, 而我們卻不用上班, 似乎從來沒這樣過, 以前都是相反. 雖然不用上班, 但還是得如常早起準備早餐, 然後載姊姊去捷運站.

早上完成了幾件事, 一是終於將買來已多時的安麗濾水器濾心更換完畢, 一年換一次 (濾心很貴, 要 4600 元), 程序有點陌生, 總覺得要花很多時間, 所以就一直拖, 我看也有兩個月了吧! 然後去市圖還書, 再去對面不遠的大地游泳池詢問票價, 打算要恢復游泳了, 因為到了 50 歲似乎不宜再慢跑, 對膝蓋與髖關節傷害較大. 思來想去還是去游泳好了. 剛好他們在 2/29 前有特價專案, 95 張無限期只要 5000 元, 平均一張 53 元, 之前一張要 100 元哩!


右邊那個 15 個月 11000 的 VIP 會員似乎也不錯, 但換算一下每周至少要去游 4 天才划算 (才會勝過左邊的全票券), 這樣又太頻繁了, 我沒把握 :

11000/(15*4)=183 元/周
183/53=3.5 次

如果有一周沒去游那就糟了, 變成下周要每天去游才能追平, 所以還是保守一點, 料敵從嚴, 千萬別忘了, 我就是自己最大的敵人啊!

不過我沒有當下衝動刷卡買券, 還是回來想看看再說, 反正 2/29 才截止. 如果要去游泳, 蛙鏡, 泳衣, 泳帽都得買過新的, 因為太多年沒游了, 泳衣穿不下了 (肥了), 泳鏡鬆緊帶也鬆掉了.

上一次游泳好像是姊姊四年級時 (七年前!), 當時教育政策認為台灣是海洋國家, 游泳是基本技巧, 學校有所謂小海豚計畫, 要求小學畢業前要通過小海豚考試. 三隻小狐狸都有去大地報名游泳班, 姊姊有通過小海豚, 二哥只差一點, 菁菁則是去玩水而已.

回家途中繞到明誠路東南旅行社, 詢問黑部立山行程, 現在匯率較不划算, 團費比三年前媽跟小舅他們那次要貴, 約 42000 左右. DM 上寫 2/5 前訂的話可省 2000, 過年前應該來問才對. 不過櫃台妹妹說下周展覽館有旅展, 她給我兩張票, 說現場也會有此優惠.

中午跟水某去忠貞路的松屋拉麵, 想跟板前口味比較看看. 這家位於高雄水利會大樓對面巷子裡, 價位約板前的一半左右, 比較平價, 我點了 90 元的特濃豚骨, 而水某點 160 元的一般豚骨套餐 (+溫泉蛋+豬排), 我覺得特濃的滋味與板前差不多, 只是料稍少而已, 而一般豚骨湯頭當然就較淡, 與特濃差了 20 塊耶!



下午睡了一個舒服的覺, 四點起來頓時精神百倍, 雖然只是一個尋常的禮拜六, 但是因為有一大票人還要上班上課, 這種反差反而讓我覺得今天才是真正在休假呢! 果然, 愛因斯坦的相對論是正確的, 沒有黑的黑哪能突顯白的白呢?


2016年2月19日 星期五

申請新的免費 PHP 虛擬主機

除了恢復 Hostinger 虛擬主機運作外, 我又在下列網頁發現了五個網頁空間 :

# 5個在2014年還值得使用之網頁空間

我首先中意不會亂砍帳號的 247ZILLA, 興沖沖去申請 FREE HOSTING PLAN, 它一開始會進行所謂的 Anti-fraud 掃描, 結果不管是在哪一台電腦申請 (IP 不同), 都被判定是 Fraud (網路詐騙?), 我原以為這是技術性干擾, 希望你用他們的付費服務, 結果也是一樣, 這是怎麼回事?


我已寫了一封信要求他們給個說法 (Ticket Created #311115).

其次試試 2freehosting, 這家位於美國的免費主機商其實與我的 PHP 測試主機商 1freehosting 是同一家, 但免費額度較高, 提供 20G 容量, 每月 150G 流量, 以及免費的網域名稱, 我測試的結果非常滿意, 優點是速度非常快, 介面簡單又漂亮, 是我用過最美觀的. 免費方案提供 10 個 MySQL 資料庫, 5 個子網域, 10 個郵件信箱, 有 Cron Job 與 cURL 功能 :

Free Hosting

Disk Space
Bandwidth
E-mail Addreses
Max Databases
MySQL Connection Hourly Limit
Max Daily Unique Visitors
Mailbox Size Limit
Max Subdomains
Max Parked Domains

它的付費方案也不貴, 每個月 2 美金 (約台幣 66 元), 參考 : 

# https://www.2freehosting.com/unlimited-hosting.html

它們在主要社群也有帳號 :

On Forum
On Facebook
On Google+
On Twitter
On Tumblr
On YouTube
On VK.COM

我把改版為 mysqli 的 EasyuiCMS 上傳測試也沒問題, 會不會限制資料表數量要等 Cron Job 開始跑之後才知道.

其他兩個 Byethost000webhost 這兩家都是我以前使用過的, 兩者介面都較簡單, 限制較多, 久沒用或無流量也會被砍帳號, 只適合做測試用.


註冊

今天中午跑去高雄銀行繳小狐狸們新學期註冊費, 今年我不想再用註冊平台繳費, 網路雖然方便, 但一來註冊單上要填授權碼, 感覺不踏實, 二來申請學費補助需要單據, 網路繳費還要等幾天才能下載, 感覺很麻煩, 還不如趁中午飯後出去逛逛順便繳費, 下午回來馬上就能搞定公司的學費補助. 

這學期姊姊 15860, 二哥 5223, 菁菁 5398, 合計 26481 元, 還真是不小的數目, 其實裡面部分是午餐費. 我想以前爸媽繳我們學費時更吃力吧! 務農可不是每個月都有金流匯進戶頭呀! 記得以前讀書時常聽他們說要起穀會之類的, 就是用下期稻作收入來籌措註冊費, 我印象比較深的是媽勤於養母豬, 因為價格好時一窩小豬可以賣上萬塊, 當時一萬塊可是相當大一筆錢哪! 都說要自己當了父母才會知道甚麼是孝, 這是沒錯的.


板前拉麵

昨天下班後去家樂福採買, 然後去接水某下班, 回來時經過自由路的板前拉麵, 今天還好有空位, 就停下來吃吃看. 這家開業約有半年了, 每個禮拜天晚上從鄉下回高雄經過時, 都已經快十點了還有排隊的人龍, 到底有何魔力? 一直想找時間來吃, 今天就擇期不如撞期吧!

我點了 160 元的豚骨拉麵, 水某是味噌拉麵, 感覺湯頭都很濃郁, 特別是味噌不會太鹹. 我查了一下網路, 發現下面這篇介紹得很詳盡, 參考 :

板前拉麵-最強深夜拉麵上路


原來這家店是是所謂的 "深夜食堂", 營業時間是 18:00~02:00, 到凌晨兩點! 難怪晚上十點還那麼多人在排隊啊! 二哥說明誠路寶雅新店巷子裡也開了一家拉麵店, 比較平價, 改天也來去光顧看看唄!

EasyuiCMS 改版為 v2 (mysqli)

由於 Appfog 升版的關係, 讓我又重新找尋免費的 PHP 主機. 想起之前有申請過英國 Hostinger 的虛擬主機, 但是一直都沒使用, 試著將 EasyuiCMS 上傳, 結果竟然在登入畫面就出現如下錯誤回應 :

<br />
<b>Deprecated</b>:  Function mysql_numrows() is deprecated in <b>/home/u220421318/public_html/lib/mysql.php</b> on line <b>313</b><br />
<br />
<b>Deprecated</b>:  Function mysql_numrows() is deprecated in <b>/home/u220421318/public_html/lib/mysql.php</b> on line <b>313</b><br />
{"status":"success","msg":""}

意思是 MySQL 的 mysql_numrows() 已經被廢棄了, 網搜結果發現, 整個 mysql 函式庫在 PHP 2.5.5 版就被廢棄不能用了, 應該改用 mysqli 函式庫 (i 是 improved 的意思). 這個 Hostinger 主機使用的正是 PHP 2.5.5, 我在本機測試使用的是 Appserv 的 2.5.4 版, 難怪我的 EasyuiCMS 在其他主機都沒問題, 在 Hostinger 卻出問題. 參考 :

mysql_num_rows replacment to recomend?
mysql_ deprecated

PHP 的資料庫存取從最早的 mysql 函式庫, 因為安全性緣故進階到物件化的 mysqli, 甚至抽象化的 PDO, 其差異詳見下面這篇 :

# 淺談 PHP-MySQL, PHP-MySQLi, PDO 的差異

為了讓 EasyuiCMS 能在 Hostinger 上跑, 我參考了下列網站的說明文件, 把 /lib 下的 mysql.php 複製一份到 mysqli.php 進行修改 :

# mysqli 函式
'MySQLi' for Beginners
# PHP 5 MySQLi Functions

修改的部分主要是將 mysql_ 字頭的函式名稱改成 mysqli_ 開頭, 因為 mysql 函式全部在 mysqli 都有實作, 除了 mysqli_select_db() 與 mysqli_query() 這兩個參數先後顛倒, mysql_numrows() 改為 mysql_num_rows()外, 主要差別就是 mysqli 字頭而已. 修改的部分如下 :

舊 : $conn=mysql_connect($address, $username, $password)
新 : $conn=mysqli_connect($address, $username, $password)

舊 : $result=mysql_select_db($SQL, $conn)
新 : $result=mysqli_select_db($conn, $database)

舊 : $result=mysql_query($SQL, $conn)
新 : $result=mysqli_query($conn, $SQL)

舊 : mysql_numrows($result)
新 : mysqli_num_rows($result)

舊 : mysql_fetch_array($result)
新 : mysqli_fetch_array($result)

舊 : mysql_error($conn)
新 : mysqli_error($conn)

舊 :  mysql_free_result($result)
新 :  mysqli_free_result($result)

舊 : mysql_close($conn)
新 :  mysqli_close($conn)

舊 : mysql_fetch_array($result)
新 : mysqli_fetch_array($result)

舊 : mysql_num_rows($result)
新 : mysqli_num_rows($result)

舊 : mysql_fetch_row($result)
新 : mysqli_fetch_row($result)

舊 : mysql_num_fields($result)
新 : mysqli_num_fields($result)

舊 : mysql_fetch_field($result, $i)
新 : mysqli_fetch_field($result)

新增 mysqli.php 函式庫後, 架站系統其他檔案如 index.php, main.php, sys.php 與應用程式也都要修改匯入的資料庫函式庫為 mysqli.php, 經過這樣改版後再次上傳 Hostinger 已可正常運作矣. 從去年 5 月寫好 EasyuiCMS 後, 已過了九個月才改為第二版, 最近還要將前陣子移植到 GAE 所發現的缺失再進行細部修改, 增進安全性考慮以便今年完成工作日誌改版.

至於 PDO, 利用抽象層來介接各種資料庫似乎是不錯的做法, 但效能會不會較差一些呢? 等了解用法做個充分測試再來決定要不要改版到 PDO 版.

2016-02-24 補充 :

記一下 Hosting.com 後台管理入口的撇步, 因為我常迷路. 登入後會先進入後台首頁 (Home) 如下 :


你要點第一個 Hosting 才會進入主機帳號管理畫面 :


點選要進入帳號的 Status 欄位中的 "Active", 底下就會出現四個按鈕, 按第一個 "Manage" 即可進入此帳號的後台管理畫面.


2016年2月16日 星期二

Appfog v1 退場了

去年底便收到 Appfog 通知, 說服務即將升版為 Appfog v2, 因為沒時間細看, 所以就一直擱著. 這幾天又收到來信, 說即將在 2016-02-15 將 v1 汰換, 哇咧不就要到期了嗎! 昨天晚上就在忙著備份程式與資料庫. 我在 Appfog 有兩個帳號, 上面有股市資料自動擷取與分析的雲端應用服務在跑, 我擔心上面的 MySQL 資料庫內容受到影響, 所以昨晚趕緊將信件細讀, 內容如下 :

AppFog v1 Service Retirement
on March 15, 2016


You are receiving this email because you have at least one MySQL instance on AppFog v1. As such, you were not required to migrate off of AppFog v1 by December 16, 2015 until AppFog v2's MySQL entered general availability.
We delayed the transition to ensure MySQL performance requirements were met and to ensure enough time to migrate applications onto the new and improved platform - AppFog v2.
The new MySQL service offered as an add-on in AppFog v2 is currently in beta, but is expected to exit beta on February 1, 2016. Once the new MySQL service exits beta, pricing will begin at $.017/hour for 1vCPU, 1GB RAM, and 1GB allocated storage.

Given the upcoming release of MySQL as a service, we have extended the deadline for the transition of AppFog v1 to AppFog v2 to March 15, 2016 at midnight US Pacific Time (UTC - 8:00). Applications will no longer be available on AppFog v1 after this time.  
Stay tuned for additional migration guides to help with the transition. Existing migration guides are available below for review.
It is the responsibility of application owners to make sure applications are migrated off of AppFog v1 by the above date. AppFog is not responsible for any loss of data or loss of access to data when AppFog v1 is no longer available.

We apologize for the wait and thank you for your patience! If you have any additional questions, please don’t hesitate to reach out at support@appfog.com.

Sincerely,

The AppFog Team
support@appfog.com
Access our privacy policy. © 2015 CenturyLink, Inc. All Rights Reserved.
This communication is the property of CenturyLink and may contain confidential or privileged information. Unauthorized use of this communication is strictly prohibited and may be unlawful. If you have received this communication in error, please immediately notify the sender by reply e-mail and destroy all copies of the communication and any attachments.
The CenturyLink mark, logo and certain CenturyLink product names are the property of CenturyLink, Inc. All other marks are the property of their respective owners.


總之一句話, 跟 GAE 改版為 Google Cloud Platform 一樣, 藉著升版把以前用免費號召進來的使用者改為付錢客戶, 否則掃地出門. 我去 Appfog v2 稍微看了一下, 一樣是註冊時要填信用卡號碼, 按小時付費, 每個應用服務每 GB 價格是每小時 0.04 美金, 即 1GB 跑一天是 0.04*24=0.96 美金, 折台幣約 32 元, 一年要 11563 元, 比外面一般 PHP 虛擬主機還貴很多 (一般一年大約台幣 1000~2000 左右), 參考 :

https://www.ctl.io/appfog/

而 GAE 的價格更複雜, 我實在看不懂, 參考 :

# App Engine Pricing

雲端服務價格還真的是在雲端哪! 2014 年 Appfog 開始改成收費服務時, 我還慶幸註冊了兩個免費帳號可以繼續使用, 懊悔沒多註冊幾個, 結果兩年後結果是一樣的. 天下還真的沒有白吃的午餐耶!

Appfog 要求 v1 的使用者在美國時間 2016-02-15 前將應用服務移到 v2 上, 否則到時 v1 關掉造成資料損失概不負責. 昨天花了一整個晚上備份應用服務, 程式部分沒問題, 可以用 af pull 指令下載, 也可以在後台管理網頁中下載, 不過 v2 上來後, v1 的後台管理登入已經移到這裡 :

https://console.appfog.com/login

但 MySQL 資料庫卻一波三折, 使用應宏筆電的 Ruby 2.0.0 始終無法下載, 改用 ACER D260 小筆電安裝 Ruby 1.9.3 以及 OpenSSL 1.0.3f 雖然可以下載了, 但下載的 SQL 檔內容並不完整, 只有資料表架構與部分紀錄. 可能我的資料表太多了, 自 2015 年 4~5 月運轉至今的 cron job 擷取紀錄超過 45 萬筆 (mysnowball) 與 31 萬筆 (twstockbot), 今早看系統還在運作中, 可能晚上就會被關掉了, 雖然累積快一年的資料無法備份有點可惜, 但無法下載也沒辦法.

Dump 資料庫程序如下 :

Microsoft Windows XP [版本 5.1.2600]
(C) Copyright 1985-2001 Microsoft Corp.

C:\Documents and Settings\tony1966>d:

D:\>cd mysnowball

D:\mysnowball>af login
Attempting login to [https://api.appfog.com]
Email:
Password: ********
Successfully logged into [https://api.appfog.com]


D:\mysnowball>af tunnel
1: mysnowball
Which service to tunnel to?: 1
Getting tunnel connection info: OK

Service connection info:
  username : uniCIZYyTGzkK
  password : pou3Jtzg8NTMM
  name     : da2e07e20fb1d431c8a85e71ed423b747
  infra    : aws

Starting tunnel to mysnowball on port 10001.
1: none
2: mysql
3: mysqldump
Which client would you like to start?: 3
Waiting for local tunnel to become available..
Output file: ccc.sql
Launching 'mysqldump --protocol=TCP --host=localhost --port=10001 --user=uniCIZY
yTGzkK --password=pou3Jtzg8NTMM da2e07e20fb1d431c8a85e71ed423b747 > ccc.sql'

mysqldump: Couldn't execute 'SHOW TRIGGERS LIKE 'report'': Lost connection to My
SQL server during query (2013)
Error: 'mysqldump' execution failed; is it in your $PATH?


D:\mysnowball>





參考 :

http://info.ctl.io/webmail/86222/20086859/b742303bc5954c6269d7a533871f95b5
http://github.com/oneclick/rubyinstaller/wiki/Development-Kit
# Backup database on appfog
https://www.ctl.io/knowledge-base/afv1/mysql/
# Error while installing caldecott