Wiki

Clone wiki

bottle-simple-todo / HowThink

Simple-TODO(Bottle)设计和理解

好吧,应 Limodou 的意见,增补代码里实现过程中的思路和细节...

文件分布

https://bitbucket.org/ZoomQuiet/bottle-simple-todo/src
|-- bootle-simple.todo.leo  工程文件,用Leo~文学化编辑环境
|-- bottle.py               v0.95 Bottle 应用框架,就一个文件哪!真简洁的,有木有?!
|-- readme.txt              工程自述
|-- mydoto.db               数据库哪,就一个文件,随身的,好简洁哪有木有!
+-- views                   模板入口
|   |-- add.tpl             条目追加模板
|   |-- edit.tpl            条目编辑模板
|   |-- historic.tpl        TODO变更历史模板
|   |-- hist.tpl            条目编辑历史模板
|   |-- footer.tpl          页脚模板包含
|   |-- header.tpl          页头模板包含
|   \-- index.tpl           首页模板
\-- zqbtodo.py              Simple-todo 本尊,也很简洁的有木有!

注意

  • 推荐安装 Leo 查阅 bootle-simple.todo.leo,体验文学化编程!

URI 设计

作为Web 应用, URI(注意不是URL,那才是误解) 的设计,其实直接映射了最终的程序结构和数据流转关系!

  • 一定要事先想明白
  • 力图用最少的URI,涵盖最多的业务流程
    • 进一步的,隐藏的设计理念是:业务流程越少越好!
URI操作备注
/ or /index.html首页列表+追加表单
/add追加条目使用 POST 方式获得提交,并记录
/hist/:id条目编辑历史:id ~ 条目索引键
/revert/:id条目历史版本回滚:id ~ 同上(未完成)
/dele/:id删除条目:id ~ 同上
/edit/:id修订条目:id ~ 同上
/done/:id条目完成:id ~ 同上
/redo/:id重启条目:id ~ 同上
/fix/条目更新使用 POST 方式获得提交,并记录
/historic总历史所有条目的变化历史
/notify/:msgtype/:msgcnt提醒任意错误时,:msgtype类型 :msgcnt反馈内容
@404找不到所有不存在的元素
URI操作备注

提示

  1. Bottle 中页面和代码的关系再直接白不过:
    • 凡是有 @route('/index.html') 之类修饰的,就是用来处置对应页面的
      • 一般俺习惯将函式命名和URI 路由对应,同名
      • 另外,为了方便部属时可能将应用安装到任意路径之下,俺习惯设定个全局URI 前缀(zqbtodo.py 27):
        • urlprefix = ""
        • 所以,一般声明 route 时是这样的: @route('%s/'%ini.urlprefix)
    • 这样,如果应用在部属到生产环境时,要求用 http://ur.me/simpltodo/ 来发布,简单的将 urlprefix 修订成 simpltodo 就好!
    • 凡是没有类似修饰的,就是内部辅助/计算/解析等函式
  2. @error(404) 是Bottle 内置的一种 URI 映射支持,可以针对HTTP协议状态进行响应,而不仅仅是 URL 请求路径
    • 这样一来,可以非常方便,直观的定制各种意外请求的处置行为
    • 俺习惯对所有错误的路径配置一个简单的反馈字串(zqbtodo.py 283) return '404 !-( Nothing here, sorry' 确保:
      1. 所有无功能支持的路径都有反馈
      2. 浏览器总是神经的请求 /favicon.ico 神马的,就不用担心引发问题了
  3. 俺习惯给每个Bootle 应用一个通用的提示页面zqbtodo.py 278:
@route('%s/notify/:msgtype/:msgcnt'%ini.urlprefix)
def notify(msgtype,msgcnt):
    exp = base64.urlsafe_b64decode(msgcnt)
    return template('notify',alert=msgtype,allmsg=exp,urlprefix=ini.urlprefix)

配合 zqbtodo.py 268

def __redirect(msgtype,msgcnt):
    redirect("%s/notify/%s/%s"%(ini.urlprefix
        ,msgtype
        ,base64.urlsafe_b64encode(msgcnt))
        )

就可以在任意页面中,任意运行过程中跳出过程,反馈错误/失误/警告等等信息,经典的使用场景

try:
    #...
except OSError, e:
    __redirect("OSError",e)

一但出现意外就会将可能的问题类型和具体的 trace 信息丢到 http://ur.me/notify 固定的反馈页面进行说明,不用专门为各种提示配备不同的页面模板了 当然,可以嵌入友好的文字模板

#   如果有定义:
msgTPLtryerr    = "杯具! 脚本出状况了,请向管理员反馈;-(knoss@kingsoft.com 请包含错误信息:''' %s '''" 
#   则,容易出问题的地方就可以
# ...
except OSError, e:
    __redirect("OSError",msgTPLtryerr%e)

数据设计

K/V数据库,很潮的,也很容易用的!

  • 俺痛恨SQL!所以,相信 berkeley-db
  • 从TODO 应用来说,其实只有一类数据:
    • 条目 ~ 待办事宜的内容
  • 为了其它功能,得追加几个数据 注意,不是几类
    1. 待办条目序列
    2. 完成条目序列
    3. 删除条目序列
    4. 条目编辑版本序列
    5. 变更历史序列

这样便可形成如下的数据和状态变迁关系: snap/zqbtodo-data-flow.png 严格对应URI的操作:

  • 任务条目的所有操作,都对应记录到相关列表或是键值对中
  • 其中:
    1. 条目的id和内容形成 BDB 的标准键值对:
      • 为方便进行显示,和区分
      • 在值部分,使用文本DB的设计思路,用 @ 或是 > 特殊字串对不同的数据内容进行了间隔
    2. 而需要有顺序记录的同类条目,则指定特殊的键,将涉及的条目ID,充入值部分的列表对象中

BDB

其实,全部可以用 Py 的内部数据类型来完成

  • 幸福之处在 bdb 的内容可以是任意数据格式,同Py 字典数据类型的使用
  • 更加幸福在 Py 的所有数据对象都可以方便的序列化 所以,设计成以下的数据格式:
~条目:
    K:'(时间+内容)的hash后16进制字串'
    V:'时间戮@todo 内容'
        |
        +- "%.5f"%time.time() 类似'1309535832.60464'的16位字串
            time.strftime("%y%m%d %H:%M:%S",time.localtime(时间戮)) 来格式显示

~ 条目编辑历史序列
    K:'ver:条目最老键'
            |
            +- 56位字串
    V:['id0','id1','id2',,,] 修改历史上对应各个条目键组成的列表的序列化文本

~ TODO变更历史序列
    K:'BDBhistoric'
    V:['C>id0','D>id1','E>id2',,,] 按照时间顺序,逐一将各种变更记录在案
        |
        +- C|N|D|E|R 5种状态 创建|完成|删除|修订|重启

~ 待办条目序列
    K:'BDBdotolist'
    V:['id0','id1','id2',,,] 条目键组成的列表的序列化文本

~ 完成条目序列
    K:'BDBdonelist'
    V:['id0','id1','id2',,,] 条目键组成的列表的序列化文本

~ 删除条目序列
    K:'BDBdellist'
    V:['id0','id1','id2',,,] 条目键组成的列表的序列化文本

配合操作策略:

  1. 数据永远不删除,删除行为用标记为 del 代替
  2. 条目修订(编辑/完成/重启/删除)时,生成新的条目

实操

综上,进行BDB 数据库操作就非常明确了,简要的分析一下,当前这300行中,真实的数据操作行为:

初始化

zqbtodo.py 36 在配置部分,进行BDB的初始化:

db = bdb.btopen(dbtodo, 'c')
#...
# 初始化TODO变更历史列表
if db.has_key("BDBhistoric"):
    pass
else:
    db["BDBhistoric"] = pickle.dumps([])
# 初始化在待办列表
if db.has_key("BDBdotolist"):
    pass
else:
    db["BDBdotolist"] = pickle.dumps([])
# 初始化已完成列表
if db.has_key("BDBdonelist"):
    pass
else:
    db["BDBdonelist"] = pickle.dumps([])
# 初始化已删除列表
if db.has_key("BDBdellist"):
    pass
else:
    db["BDBdellist"] = pickle.dumps([])
  • 将约定的本地BDB文件打开为 bd 对象
  • 对四个特殊的键,先进行初始化:
    1. 首次空DB启动时(比如说,手工将 mytodo.db 删除),新增空列表值的键对
    2. 有历史数据在BDB 中时,略过

数据结构

    bdbkey = hashlib.sha224("%s+%s"%(int(time.time()),cnt)).hexdigest()
    # 由当前时间和条目内容组成的字串计算hash而得的16进制字串
    # 形如: 9fd8f3b409b26844dd36be0a177aaa8b391c0edef5b2c2cb36c7aa68
    verkey = "ver:%s"%bdbkey
    # 同时,给每个新增条目生成一个编辑历史值对,其键,就是条目本身的ID前缀"ver:"
    # 即: ver:9fd8f3b409b26844dd36be0a177aaa8b391c0edef5b2c2cb36c7aa68
  • 各种列表数据:
  • 前述设计的四种列表:
    1. 待办条目序列
    2. 完成条目序列
    3. 删除条目序列
    4. 条目编辑版本序列
def __flushBDB(sequkey,dotype,val):
    '''根据键和新增值,对BDB 进行更新:
        - dotype ~ add|pop
    '''
    # 检查键值对是否存在,若没有,就创建
    if ini.db.has_key(sequkey):
        crtlist = pickle.loads(ini.db[sequkey])
    else:
        crtlist = []
        ini.db[sequkey] = pickle.dumps(crtlist)
    # 根据操作类型对指定键的值进行操作
    if 'pop' == dotype:
        crtlist.pop(crtlist.index(val))
    else:
        crtlist.append(val)
    ini.db[sequkey] = pickle.dumps(crtlist)

以创建新任务条目为例,相关的列表数据变更就非常直观和简洁了:

__flushBDB('BDBdotolist','add',bdbkey)
__flushBDB(verkey,'add',bdbkey)
__flushBDB('BDBhistoric','add','C>%s'%bdbkey)

# 在 待办条目序列 BDBdotolist 中追加新条目id # 在 条目编辑版本序列 verkey,即 ver:条目id 中追加新条目id # 在 变更历史序列 BDBhistoric 中追加新条目的id 其它的各种操作类似 * 所以,可以见到有极其相似的响应函式: 任务完成 (zqbtodo.py 195)

@route('%s/done/:itemid'%ini.urlprefix)
def done(itemid):
    # ...
    __flushBDB('BDBdotolist','pop',itemid)
    __flushBDB('BDBdonelist','add',itemid)
    __flushBDB('BDBhistoric','add','N>%s'%itemid)

任务重启 (zqbtodo.py 214)

@route('%s/redo/:itemid'%ini.urlprefix)
def redo(itemid):
    # ...
    __flushBDB('BDBdotolist','add',itemid)
    __flushBDB('BDBdonelist','pop',itemid)
    __flushBDB('BDBhistoric','add','R>%s'%itemid)

任务删除 (zqbtodo.py 233)

@route('%s/dele/:itemid'%ini.urlprefix)
def dele(itemid):
    # ...
    __flushBDB('BDBdotolist','pop',itemid)
    __flushBDB('BDBdellist','add',itemid)
    __flushBDB('BDBhistoric','add','D>%s'%itemid)

* 这里,因为 del 是内建关键字不能用作参数,所以,取了 dele 作为删除的指令

附加信息

为了令页面信息更加友好,配合显示出各种效果, Simple-TODO 记录了两种附加信息:

时间戮

每次每个条目被操作时的UNIX 时间戳,被记录为一个 16位长,有5位小数的浮点数!

相关在 IPy-transparent-120-flush.png IPython中的试探:

In [7]: import time

In [8]: time.time()
Out[8]: 1309937915.323741

In [9]: "%.5f"%time.time()
Out[9]: '1309937937.68448'

In [13]: time.strftime("%y%m%d %H:%M:%S",time.time())
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)

/home/zoomq/workspace/3hg/bitbucket.org/bottle-simple-todo/src/<ipython console> in <module>()

TypeError: argument must be 9-item sequence, not float

In [14]: time.strftime("%y%m%d %H:%M:%S",time.localtime(time.time()))
Out[14]: '110706 15:40:42'
  • 所以,条目的值,取前16位,@ 字符前的字串,就可以随时转换输出成习惯的日期信息
条目状态

条目有创建/完成/重启/修订/删除,这几种状态,需要在条目编辑历史页面中加以区分和展示

  • 怎么办呢?要考虑到:
    • 如果以后要追加新操作状态?
    • 如果以后要随时修改甚至于国际化状态的页面展示?
  • 简单的通过状态对照表来就好咔!
STATE = {"C>":"创建"
    ,"N>":"完成"
    ,"D>":"删除"
    ,"E>":"修订"
    ,"R>":"重启"
    }

这也就解释了前述 条目编辑版本序列 数据追加时,为什么有:

    __flushBDB('BDBhistoric','add','N>%s'%itemid)

* 这种在条目id 前缀其它字串,来标识当时的操作 对应的,在展示时,就得进行翻译(zqbtodo.py 70):

def __loadhisdict(idlist):
    datalist = []
    for i in idlist:
        item = {}
        item['id'] = i[2:]
        item['scode'] = i[:1]
        item['state'] = ini.STATE[i[:2]]    # 就是这里,简单的使用字典的值提取语句而已
        item['stamp'] = time.strftime("%y%m%d %H:%M:%S",time.localtime(float(ini.db[i[2:]][:16])))
        item['txt'] = base64.urlsafe_b64decode(ini.db[i[2:]][17:])
        datalist.append(item)    
    return datalist[::-1]
    # 最后进行列表的反序输出,因为,要进行时序的倒排,将新修订放在前

以及模板中要对应进行CSS的聲明(historic.tpl 10)

<ol id="historic">
%for item in historiclist:
  <li class="state{{item['scode']}}"><sup>{{item['state']}}</sup>
  <sub>{{item['stamp']}}~</sub>
  {{item['txt']}}
%end
</ol>

配合CSS的效果(嵌入在头模板中:header.tpl 36)

li.stateC{ background-color:azure;    }
li.stateN{ background-color:moccasin;    }
li.stateR{ background-color:palegreen;    }
li.stateE{ background-color:pink;    }
li.stateD{ background-color:#ccc;}

* 获得以下效果 snap/snap-full-v1historic.png

模板技巧

Bottle内置简单模板的技巧

正如框架名称一样,Bottle 的文档也是非常的"子弹":

序列数据的循环展示

当然,首先是尝试如何可以将组织好的 HTML 数据输出到 {{变量名}} 所在的页面元素中

  • 尝试直接包含 HTML 代码,输出时,转义了,当成了内容,而不是 HTML 代码
  • 尝试先将 HTML 标签代码转义为 &lt;h1&gt; 类似的代码,输出后,原样显示了,同样失败

参考文档中的 Embedded python code:

%if name:
  Hi <b>{{name}}</b>
%else:
  <i>Hello stranger</i>
%end

联想:"是否,可以使用其它算子?只要,最后使用 %end 来结束嵌入区块就好?"

  • 先在模板中尝试:
{{"".join(["<li>%s</li>"%i['id'] for i in todolist])}}

发现可运行,但是, HTML 标签依然原样输出了

  • 再用 for 语句替换 if 语句 尝试:
<ol>
%for item in histlist:
  <li><sub>{{item['stamp']}}</sub>
  {{item['txt']}}
%end
</ol>

真的成!而且:

  1. 不用理会Py 的缩进,只是在 % 那行要遵守Py 的语法
  2. 实际上 {{}} 之内,是完全规范的Py 代码,可以使用各种内置操作

单实例类

这是一个 Geek 最爱的技巧:

  • 发源自星际旅行的故事
  • 传说中 Borg 族通过电子科技,可将任意智慧生物的个人经验同化成种族的集体知识,共享使用
  • 在Py 中,一直对.ini/.py 的外部配置很囧rz... 怎么使用也不舒服,有各种限制 所以zqbtodo.py 19:
class Borg():
    '''base http://blog.youxu.info/2010/04/29/borg
        - 单例配置收集类
    '''
    __collective_mind = {}
    def __init__(self):
        self.__dict__ = self.__collective_mind
    # ...
ini = Borg()

这样吼过后,在 Borg 类中的类变量,就成为全局变量,可以在任意场所中使用 ini.变量名 的形式引用

  • 妙在,不论你意外的实例化几次 Borg,得到的变量值本质上是同一个!
  • 不会发生,在其它实例引用中进行了修订,引发配置值不一致的问题...
  • 当然,这种配置管理的方式,可以作为外部文件独立存在,不影响引用效果

文档

Updated