編程學習網 > 編程語言 > Python > Python教程—如何實現debug可視化
2023
08-21

Python教程—如何實現debug可視化


本文,我們學習一個叫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教程歡迎持續關注編程學習網。

掃碼二維碼 獲取免費視頻學習資料

Python編程學習

查 看2022高級編程視頻教程免費獲取