本文,我們學習一個叫birdseye的庫,看看它是怎么實現Python代碼debug可視化的。
先簡單看看它的效果。
我用遞歸,寫了一段生成斐波那契數列的函數,然后我用birdseye中的eye對函數進行裝飾
from birdseye.server import main
from birdseye import eye
@eye
def fibonacci(n):
if n <= 0:
return 0
elif n == 1:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(10)
main()
運行后,birdseye會啟動一個web服務,通過這個web,我們可以很直觀地調試與觀察fibonacci函數中的各種數值。
效果如下:
這里,就不多討論,怎么用它了,你可以直接看項目的github:https://github.com/alexmojaki/birdseye
本文主要要學習一下,人家是怎么做到這種效果的。
birdseye原理
還是老話,閱讀任何一個項目的源碼,都要有一個目的,因為代碼里全是細節,如果沒有目的,容易在細節里迷失。
我們閱讀birdseye項目源碼的目的主要就是:可視化debug是怎么實現的?
基于一開始的例子,我們很自然的,可以從eye裝飾器入手,代碼如下:
# birdseye/__init__.py
class _SimpleProxy(object):
def __init__(self, val):
# 設置了新的屬性
object.__setattr__(self, '_SimpleProxy__val', val)
def __call__(self, *args, **kwargs):
return self.__val()(*args, **kwargs)
def __getattr__(self, item):
return getattr(self.__val(), item)
def __setattr__(self, key, value):
setattr(self.__val(), key, value)
eye = _SimpleProxy(lambda: import_module('birdseye.bird').eye)
eye其實是類裝飾器,通過Python的動態import機制(import_module)將birdseye.bird中的eye引入。
在一開始,我不太理解,為啥要特意定義_SimpleProxy類將birdseye.bird.eye包裹多一層,其實就是通過_SimpleProxy類將其變為裝飾器,birdseye.bird.eye則是具體的邏輯。
當項目運行時,eye裝飾器會讓_SimpleProxy類的__call__函數被調用,從而執行birdseye的業務邏輯。
為了避免迷茫,這里先概述一下birdseye的原理,經過學習,birdseye做到可視化debug主要通過下面幾步:
1.通過eye裝飾器獲得被裝飾函數的AST(抽象語法樹)
2.修改AST,實現對函數中的每個語句(stmt)與表達式(expr)的監控,從而獲得運行時的各種信息
3.基于Flask構建了web服務,數據從database來
要比較好地了解birdseye,需要對Python ast庫有基本的了解,這里給出一個基本的例子:
import ast
code_str = 'a = 1; b = 2; print(a + b);'
# 解析代碼,生成ast
node = ast.parse(code_str)
# 將ast轉成string
ast_str = ast.dump(node)
# 打印
print('ast_str: ', ast_str)
print('a object value: ', node.body[0].value.n)
# 編譯,獲得可直接執行的code
code = compile(node, filename='<string>', mode='exec')
# 執行
exec(code)
# 基于ast修改a變量的值
node.body[0].value.n = 6
# 編譯修改后的ast
code = compile(node, filename='<string>', mode='exec')
# 執行
exec(code)
# ast.NodeTransformer用于遍歷抽象語法樹,并允許修改節點
# MyVisitor繼承了st.NodeTransformer
class MyVisitor(ast.NodeTransformer):
# 修改Num類型節點
def visit_Num(self, _node):
# 將ast中Num節點都改成100,即a與b的值都變為的100,那么結果就為200
return ast.Num(n=100)
# 遍歷處理ast
MyVisitor().visit(node)
ast.fix_missing_locations(node)
# 編譯
code = compile(node, filename='<string>', mode='exec')
# 執行
exec(code)
從上述代碼可知:
ast.parse函數可以將字符串代碼轉成AST
compile函數可以將AST編譯成可執行代碼
ast.NodeTransformer類可以遍歷AST,重寫ast.NodeTransformer類中的函數可以批量修改AST節點
當eye裝飾器執行時,其調用鏈路為:
@eye
-> _SimpleProxy __call__
-> TreeTracerBase __call__
-> TreeTracerBase trace_function
trace_function函數做了所有的主要操作,這里拆分著討論一下其原理。
首先,trace_function中通過inspect庫獲得fibonacci函數所在python文件的代碼,inspect庫是Python的自省庫,可以比較方便地獲得Python對象的各種信息
這里給個inspect庫的例子:
import ast
import inspect
class A:
def __init__(self):
super().__init__()
print('success!')
print('co_name:', A.__init__.__code__.co_name)
print('lineno:', A.__init__.__code__.co_firstlineno)
# 獲得所在python路徑
filename = inspect.getsourcefile(A.__init__)
# 讀取python文件中的python代碼
source = open(filename, encoding='utf-8').read()
# 獲得ast
node = ast.parse(source)
# 編譯文件
code = compile(node, filename=filename, mode='exec')
通過 inspect.getsourcefile 函數,獲得A.__init__所在的python文件的路徑,然后獲得文件中的內容并通過ast.parse函數將其轉為AST,最后通過compile函數進行編譯,這樣就獲得了這個文件編譯后的對象code。
birdseye也是這樣做的。
這里有個細節,就是birdseye為啥不直接編譯被裝飾的函數,而是要編譯該函數所在的整個py文件呢?其實inspect庫支持只獲取部分代碼的效果。
還是用我的例子,如果只想獲得A.__init__的代碼,可以這樣寫:
import ast
import inspect
class A:
def __init__(self):
super().__init__()
print('success!')
# 獲得 A.__init__ 代碼
source = inspect.getsource(A.__init__).strip()
# 獲得ast
node = ast.parse(source)
# 編譯文件
code = compile(node, filename=__file__, mode='exec')
# 執行
exec(code)
print('__init__: ', __init__)
A.__init__ = __init__
A()
這段代碼是可以執行,但當我們使用編譯后的__init__時會報【RuntimeError: super(): __class__ cell not found】。
為什么會發生這個錯誤?
這與super函數有關,在Python3中,當編譯器發現你使用了super函數,編譯器會幫你插入一個對當前類的隱藏引用,這表示報錯中提到的__class__。因為我們單獨抽離出A.__init__進行編譯,就缺失了上下文,從而導致這個RuntimeError。
birdseye為了避免這個問題,就直接對整個Python文件進行編譯,因為編譯的是整個文件,而我們需要的是Python文件中具體的某個函數,所以編譯完后,還需要從中找到對應的函數,通過這種形式,繞過了缺失上下文導致的RuntimeError。
這段邏輯依舊在trace_function函數中。
最后,基于new_func_code生成新的函數:
# 生成新函數
new_func = FunctionType(new_func_code, func.__globals__, func.__name__, func.__defaults__, func.__closure__)
# update_wrapper將舊函數(func)中所有屬性復制到新函數(new_func)中
update_wrapper(new_func, func) # type: FunctionType
if PY3:
new_func.__kwdefaults__ = getattr(func, '__kwdefaults__', None)
new_func.traced_file = traced_file
return new_func
你可能會有個疑惑,trace_function函數的邏輯看下來似乎就是編譯Python文件,然后通過find_code函數找到相關函數的code,然后基于這個code重新生效一個函數,那修改函數AST的邏輯在哪里?
在trace_function函數中,我們調用了self.compile函數進行編譯,這個函數會完成被裝飾函數的修改,調用鏈如下:
TreeTracerBase trace_function
-> TracedFile __init__
-> _NodeVisitor visit
-> _NodeVisitor generic_visit
在TracedFile的__init__函數中,真正調用Python內置的compile函數對Python文件進行編譯,然后調用_NodeVisitor的visit函數對AST節點進行遍歷。
_NodeVisitor類繼承了ast.NodeTransformer類,可以遍歷AST節點,birdseye作者重寫了generic_visit函數,讓遍歷的過程走他自己的邏輯,最終實現對expr(expression)與stmt(Statement)的處理,兩者差異如下圖:
如果節點類型為expr,走visit_expr函數,該函數會為使用before_expr與after_expr將expr包裹。
def visit_expr(self, node):
# type: (ast.expr) -> ast.Call
"""
each expression e gets wrapped like this:
_treetrace_hidden_after_expr(_treetrace_hidden_before_expr(_tree_index), e)
where the _treetrace_* functions are the corresponding methods with the
TreeTracerBase and traced_file arguments already filled in (see _trace_methods_dict)
"""
before_marker = self._create_simple_marker_call(node, TreeTracerBase._treetrace_hidden_before_expr)
# 將before相關邏輯設置在node前
ast.copy_location(before_marker, node)
after_marker = ast.Call(
func=ast.Name(
# after相關邏輯設置在node后
id=self.traced_file.trace_methods[
TreeTracerBase._treetrace_hidden_after_expr
],
ctx=ast.Load(),
),
# 參數為 before 和 node
args=[
before_marker,
super(_NodeVisitor, self).generic_visit(node),
],
keywords=[],
)
ast.copy_location(after_marker, node)
ast.fix_missing_locations(after_marker)
return
上述代碼效果如下:
修改前: expr
修改后: after(before(...), expr)
這樣,正常邏輯執行前,會先執行before_expr,執行后,會執行after_expr。
如果節點類型為stmt,走visit_stmt函數,該函數會使用上下文管理器(with關鍵字)將stmt包裹。
def visit_stmt(self, node):
# type: (ast.stmt) -> ast.With
"""
Every statement in the original code becomes:
with _treetrace_hidden_with_stmt(_tree_index):
<statement>
where the _treetrace_hidden_with_stmt function is the the corresponding method with the
TreeTracerBase and traced_file arguments already filled in (see _trace_methods_dict)
"""
context_expr = self._create_simple_marker_call(
super(_NodeVisitor, self).generic_visit(node),
TreeTracerBase._treetrace_hidden_with_stmt)
if PY3:
wrapped = ast.With(
items=[ast.withitem(context_expr=context_expr)],
body=[node],
)
else:
wrapped = ast.With(
context_expr=context_expr,
body=[node],
)
ast.copy_location(wrapped, node)
ast.fix_missing_locations(wrapped)
return wrapped
上述代碼效果如下:
修改前: stmt
修改后: with context(...):
stmt
當用戶執行stmt時,就會經過上下文管理器的__enter__和__exit__函數。
從visit_expr函數與visit_stmt函數的注釋也可以其大意(嗯,其實我也是看注釋,我對ast庫也沒那么熟悉)。
至此,被裝飾函數的修改就完成了,當這個函數執行時,它的任何信息都會被獲取,無論是靜態信息還是運行時信息。
當fibonacci函數真正執行時,如果是stmt,就會進入到_StmtContext類(_treetrace_hidden_with_stmt函數設置的)的__enter__函數中,如果是expr,就會進入到_treetrace_hidden_before_expr函數中,從而做到監控fibonacci函數運行時所有信息的效果。
這些信息會被記錄起來,用戶等待fibonacci函數執行完后,便可以通過web去查看fibonacci函數執行過程中的所有信息,從而做到可視化debug的效果。
結尾
本文只是簡要的分析了birdseye的原理,birdseye還有很多細節我們沒有深入研究,就本文目前的原理介紹,只能大概知道birdseye這樣做了,但如果你想自己開發類似birdseye的庫,需要對ast庫、inspect庫、調用棧等有深入的掌握才行。
擴展講講,birdseye的思路可以運用到任意動態類型的語言上,比如JavaScript,我們可以獲得JavaScript的AST,并對AST進行修改,從而做到監控任意語句執行的效果,做個JavaScript的birdseye對網站JavaScript的逆向會有降維打擊的效果,你再怎么混淆,我可以直接獲得執行過程中的任意值,也可以直接可視化調試。
以上就是“Python教程—如何實現debug可視化”的詳細內容,想要了解更多Python教程歡迎持續關注編程學習網。
掃碼二維碼 獲取免費視頻學習資料
- 本文固定鏈接: http://www.stbrigidsathleticclub.com/post/11301/
- 轉載請注明:轉載必須在正文中標注并保留原文鏈接
- 掃碼: 掃上方二維碼獲取免費視頻資料
查 看2022高級編程視頻教程免費獲取