僞裝優雅地實現定時緩存裝飾器

參考資料

  1. Python 工匠:使用裝飾器的技巧
  2. 一日一技:實現有過時時間的LRU緩存

此次的參考資料寫在前面,由於寫得真不錯!開始閱讀本篇分享前,建議先閱讀參考資料,若是還不能實現定時緩存裝飾器,再繼續從這裏開始讀。python

實現思路

功能拆分:git

  1. 緩存上次函數運行的結果一段時間。
  2. 把它封裝成裝飾器。

定時緩存

衆所周知,python的functools庫中有lru_cache用於構建緩存,而函數參數就是緩存的key,所以,只要把緩存空間設置爲1,用時間值做爲key,便可實現定時執行函數。細節就去看參考資料2吧,我這裏就不贅述了。
具體實現以下:github

""" 定時執行delay_cache """
import time
from functools import lru_cache

def test_func():
    print('running test_func')
    return time.time()

@lru_cache(maxsize=1)
def delay_cache(_):
    return test_func()


if __name__ == "__main__":
    for _ in range(10):
        print(delay_cache(time.time()//1))    # 1s
        time.sleep(0.2)

程序輸出:緩存

running test_func
1582128027.6396878
1582128027.6396878
running test_func
1582128028.0404685
1582128028.0404685
1582128028.0404685
1582128028.0404685
1582128028.0404685
running test_func
1582128029.0425367
1582128029.0425367
1582128029.0425367

能夠看到,test_func在距上次調用1s內直接輸出緩存結果,調用間隔超過1stest_func纔會被真正執行。
手動實現緩存須要用字典,這裏用lru_cache裝飾器代替了複雜的字典實現,就很優雅;-)app

裝飾器

裝飾器的做用呢,就是給函數戴頂帽子,而後函數該幹嗎幹嗎去,然而別人看見的已經不是原來的函數,而是戴帽子的函數了。哈哈。函數

@delay_cache(time.time()//1)    # (midori)帽子
def test_func():
    print('running test_func')
    return time.time()

一個錯誤的示範

實現這個delay_cache:測試

...
import wrapt
...
def delay_cache(t):
    @wrapt.decorator
    def wrapper(func, isinstance, args, kwargs):
        # 給func加緩存
        @lru_cache(maxsize=1)
        def lru_wrapper(t):
            return func()
        return lru_wrapper(t)
    return wrapper
...

運行這段程序,就會獲得錯誤的結果……(嘿嘿)code

test 1582129926.0
running test_func
1582129926.4459314
test 1582129926.0
running test_func
1582129926.6466658
test 1582129926.0
...

能夠看到,定時緩存好像消失了同樣。緣由是裝飾器返回的是wrapper函數,而參數twrapper函數排除在外了。print打印t,就會發現t一直沒有變。
等等,若是t不變,那不該該是一直取緩存結果嗎?ip

  • 現實老是殘酷的,wrapper函數返回的是lru_wrapper(t),是一個結果,而不是lru_wrapper函數,因而可憐的lru_cache跟着執行完的lru_wrapper,被扔進了垃圾桶,今後被永遠遺忘。等到下一次執行到這裏,儘管新的t相同,可是lru_cache也是新的,它根本不記得本身曾經與t還有過一段美好的姻緣過往……
    證據呢?若是你也和我同樣八卦的話,就去搞個全局變量,在lru_wrapper首次運行的時候把它存下來,後面的調用就全靠這個全局變量,而後輸出結果就不變了。(要記得只須要在lru_wrapper首次運行的時候把函數賦值給全局變量!)
  • 現實老是殘酷的×2,就算證實了lru_cachet隔世的姻緣,咱們的需求也不會實現,由於以前說過,參數twrapper函數排除在外了。

若是不把t做爲裝飾器的參數,而做爲被裝飾函數的參數呢?功能卻是實現了,但是裝飾器失去了它的價值,並且每一個用戶函數,好比這裏的test_func,都要加上時間計算,變成test_func(time.time()//1, ...):,到時候time模塊滿天飛,難以直視,慘不忍睹。get

正解

用類來作裝飾器,類實例化之後就能夠一直相伴lru_cache左右,爲它保駕護航。有關類裝飾器的內容看參考資料1

class DelayCache(object):
    def __init__(self, delay_s):
        self.delay_s = delay_s
    
    @wrapt.decorator
    def __call__(self, func, isinstance, args, kwargs):
        self.func = func
        self.args, self.kwargs = args, kwargs
        hashable_arg = pickle.dumps((time.time()//self.delay_s, args, kwargs))
        return self.delay_cache(hashable_arg)

    @lru_cache(maxsize=1)
    def delay_cache(self, _):
        return self.func(*self.args, **self.kwargs)

新的帽子作好了,給函數戴上試試看:

...
@DelayCache(1)      # 緩存 1s
def test_func(_):
    print('running test_func')
    return time.time()

測試下效果:

if __name__ == "__main__":
    for _ in range(10):
        print(test_func(1))     # 只取定時緩存
        time.sleep(0.2)
# 測試結果:  
# running test_func     # 首次運行定時不是設定的1s,下面給出解決方案
# 1582132259.4029999
# 1582132259.4029999
# 1582132259.4029999
# running test_func
# 1582132260.0045283
# 1582132260.0045283
# 1582132260.0045283
# 1582132260.0045283
# 1582132260.0045283
# running test_func
# 1582132261.0072334
# 1582132261.0072334
if __name__ == "__main__":
    for i in range(10):
        print(test_func(i))     # 每次都執行函數
        time.sleep(0.2)
# 測試結果:  
# running test_func
# 1582132434.0865102
# running test_func
# 1582132434.2869732
# running test_func
# 1582132434.4875488
# ...

哈哈,這下終於搞定了。不過又冒出來2個問題:

  1. 首次運行的定時值並非1s
    函數每次開始計時的時間點都是隨機的,而緩存更新卻依靠秒進位,因此首次運行的緩存時間多是0~1s內任意一個時間點到1s,因此不許。要解決這個問題,就要讓時間從0開始計時。個人作法是用一個self.start_time屬性記錄函數首次運行的時間,而後計算實際間隔的時候,用取到的時間減去這個記錄值,這樣起始時間就必定從0開始了。

  2. 參數改變的時候計時沒有復位。
    須要復位的地方就是執行delay_cache的地方,因此在delay_cache函數裏復位計時值便可。
    另外,每次復位後,(time.time() - self.start_time)都從新從0開始累加,(time.time() - self.start_time) // self.delay_s的輸出會變成...0,1,0,0,0,0,1,0,0,0,0,1,0,0...,這樣就不能做爲lru_cachekey來斷定了,因此添加一個self.tick屬性,把狀態鎖住,變成...0,0,1,1,1,1,1,0,0,0,0,0,1,1...

改動的地方直接看最終代碼吧。

最終代碼

import time
import pickle
import wrapt
from functools import lru_cache

class DelayCache(object):
    def __init__(self, delay_s):
        self.delay_s = delay_s
        self.start_time = 0
        self.tick = 0
    
    @wrapt.decorator
    def __call__(self, func, instance, args, kwargs):
        self.func = func
        self.args, self.kwargs = args, kwargs
        if time.time() - self.start_time > self.delay_s:
            self.tick ^= 1          # 狀態切換,至關於自鎖開關
        hashable_arg = pickle.dumps((self.tick, args, kwargs))
        return self.delay_cache(hashable_arg)

    @lru_cache(maxsize=1)
    def delay_cache(self, _):
        self.start_time = time.time()       # 計時復位
        return self.func(*self.args, **self.kwargs)

@DelayCache(delay_s=1)  # 緩存1秒
def test_func(arg):
    print('running test_func')
    return arg

if __name__ == "__main__":
    for i in [1, 1, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1]:
        print(test_func(i))
        time.sleep(0.4)

@wrapt.decorator抵制套娃,用@lru_cache幹掉字典,代碼變得異常清爽啊……

測試結果

running test_func
1
1
running test_func
2
running test_func
3
running test_func
1
1
1
running test_func
1
1
1
running test_func
1
1
相關文章
相關標籤/搜索