Python 探针的实现原理
探针的实现主要涉及以下几个知识点:
sys.meta_path
sitecustomize.py
sys.meta_path
sys.meta_path这个简单的来说就是可以实现importhook的功能,
当执行import相关的操作时,会触发sys.meta_path列表中定义的对象。
关于sys.meta_path更详细的资料请查阅python文档中sys.meta_path相关内容以及
PEP0302。
sys.meta_path中的对象需要实现一个find_module方法,
这个find_module方法返回None或一个实现了load_module方法的对象
(代码可以从github上下载part1):
importsys
classMetaPathFinder:
deffind_module(self,fullname,path=None):
print('find_module{}'.format(fullname))
returnMetaPathLoader()
classMetaPathLoader:
defload_module(self,fullname):
print('load_module{}'.format(fullname))
sys.modules[fullname]=sys
returnsys
sys.meta_path.insert(0,MetaPathFinder())
if__name__=='__main__':
importhttp
print(http)
print(http.version_info)
load_module方法返回一个module对象,这个对象就是import的module对象了。
比如我上面那样就把http替换为sys这个module了。
$pythonmeta_path1.py
find_modulehttp
load_modulehttp
sys.version_info(major=3,minor=5,micro=1,releaselevel='final',serial=0)
通过sys.meta_path我们就可以实现importhook的功能:
当import预定的module时,对这个module里的对象来个狸猫换太子,
从而实现获取函数或方法的执行时间等探测信息。
上面说到了狸猫换太子,那么怎么对一个对象进行狸猫换太子的操作呢?
对于函数对象,我们可以使用装饰器的方式来替换函数对象(代码可以从github上下载part2):
importfunctools
importtime
deffunc_wrapper(func):
@functools.wraps(func)
defwrapper(*args,**kwargs):
print('startfunc')
start=time.time()
result=func(*args,**kwargs)
end=time.time()
print('spent{}s'.format(end-start))
returnresult
returnwrapper
defsleep(n):
time.sleep(n)
returnn
if__name__=='__main__':
func=func_wrapper(sleep)
print(func(3))
执行结果:
$pythonfunc_wrapper.py startfunc spent3.004966974258423s 3
下面我们来实现一个计算指定模块的指定函数的执行时间的功能(代码可以从github上下载part3)。
假设我们的模块文件是hello.py:
importtime defsleep(n): time.sleep(n) returnn
我们的importhook是hook.py:
importfunctools
importimportlib
importsys
importtime
_hook_modules={'hello'}
classMetaPathFinder:
deffind_module(self,fullname,path=None):
print('find_module{}'.format(fullname))
iffullnamein_hook_modules:
returnMetaPathLoader()
classMetaPathLoader:
defload_module(self,fullname):
print('load_module{}'.format(fullname))
#``sys.modules``中保存的是已经导入过的module
iffullnameinsys.modules:
returnsys.modules[fullname]
#先从sys.meta_path中删除自定义的finder
#防止下面执行import_module的时候再次触发此finder
#从而出现递归调用的问题
finder=sys.meta_path.pop(0)
#导入module
module=importlib.import_module(fullname)
module_hook(fullname,module)
sys.meta_path.insert(0,finder)
returnmodule
sys.meta_path.insert(0,MetaPathFinder())
defmodule_hook(fullname,module):
iffullname=='hello':
module.sleep=func_wrapper(module.sleep)
deffunc_wrapper(func):
@functools.wraps(func)
defwrapper(*args,**kwargs):
print('startfunc')
start=time.time()
result=func(*args,**kwargs)
end=time.time()
print('spent{}s'.format(end-start))
returnresult
returnwrapper
测试代码:
>>>importhook >>>importhello find_modulehello load_modulehello >>> >>>hello.sleep(3) startfunc spent3.0029919147491455s 3 >>>
其实上面的代码已经实现了探针的基本功能。不过有一个问题就是上面的代码需要显示的
执行importhook操作才会注册上我们定义的hook。
那么有没有办法在启动python解释器的时候自动执行importhook的操作呢?
答案就是可以通过定义sitecustomize.py的方式来实现这个功能。
sitecustomize.py
简单的说就是,python解释器初始化的时候会自动importPYTHONPATH下存在的sitecustomize和usercustomize模块:
实验项目的目录结构如下(代码可以从github上下载part4)
$tree
.
├──sitecustomize.py
└──usercustomize.py
sitecustomize.py:
$catsitecustomize.py
print('thisissitecustomize')
usercustomize.py:
$catusercustomize.py
print('thisisusercustomize')
把当前目录加到PYTHONPATH中,然后看看效果:
$exportPYTHONPATH=. $python thisissitecustomize<---- thisisusercustomize<---- Python3.5.1(default,Dec242015,17:20:27) [GCC4.2.1CompatibleAppleLLVM7.0.2(clang-700.1.81)]ondarwin Type"help","copyright","credits"or"license"formoreinformation. >>>
可以看到确实自动导入了。所以我们可以把之前的探测程序改为支持自动执行importhook(代码可以从github上下载part5)。
目录结构:
$tree
.
├──hello.py
├──hook.py
├──sitecustomize.py
sitecustomize.py:
$catsitecustomize.py importhook
结果:
$exportPYTHONPATH=. $python find_moduleusercustomize Python3.5.1(default,Dec242015,17:20:27) [GCC4.2.1CompatibleAppleLLVM7.0.2(clang-700.1.81)]ondarwin Type"help","copyright","credits"or"license"formoreinformation. find_modulereadline find_moduleatexit find_modulerlcompleter >>> >>>importhello find_modulehello load_modulehello >>> >>>hello.sleep(3) startfunc spent3.005002021789551s 3
不过上面的探测程序其实还有一个问题,那就是需要手动修改PYTHONPATH。用过探针程序的朋友应该会记得,使用newrelic之类的探针只需要执行一条命令就可以了:newrelic-adminrun-programpythonhello.py实际上修改PYTHONPATH的操作是在newrelic-admin这个程序里完成的。
下面我们也要来实现一个类似的命令行程序,就叫agent.py吧。
agent
还是在上一个程序的基础上修改。先调整一个目录结构,把hook操作放到一个单独的目录下,方便设置PYTHONPATH后不会有其他的干扰(代码可以从github上下载part6)。
$mkdirbootstrap $mvhook.pybootstrap/_hook.py $touchbootstrap/__init__.py $touchagent.py $tree . ├──bootstrap │├──__init__.py │├──_hook.py │└──sitecustomize.py ├──hello.py ├──test.py ├──agent.py
bootstrap/sitecustomize.py的内容修改为:
$catbootstrap/sitecustomize.py
import_hook
agent.py的内容如下:
<spanclass="kn">import</span><spanclass="nn">os</span> <spanclass="kn">import</span><spanclass="nn">sys</span> <spanclass="n">current_dir</span><spanclass="o">=</span><spanclass="n">os</span><spanclass="o">.</span><spanclass="n">path</span><spanclass="o">.</span><spanclass="n">dirname</span><spanclass="p">(</span><spanclass="n">os</span><spanclass="o">.</span><spanclass="n">path</span><spanclass="o">.</span><spanclass="n">realpath</span><spanclass="p">(</span><spanclass="n">__file__</span><spanclass="p">))</span> <spanclass="n">boot_dir</span><spanclass="o">=</span><spanclass="n">os</span><spanclass="o">.</span><spanclass="n">path</span><spanclass="o">.</span><spanclass="n">join</span><spanclass="p">(</span><spanclass="n">current_dir</span><spanclass="p">,</span><spanclass="s">'bootstrap'</span><spanclass="p">)</span> <spanclass="k">def</span><spanclass="nf">main</span><spanclass="p">():</span> <spanclass="n">args</span><spanclass="o">=</span><spanclass="n">sys</span><spanclass="o">.</span><spanclass="n">argv</span><spanclass="p">[</span><spanclass="mi">1</span><spanclass="p">:]</span> <spanclass="n">os</span><spanclass="o">.</span><spanclass="n">environ</span><spanclass="p">[</span><spanclass="s">'PYTHONPATH'</span><spanclass="p">]</span><spanclass="o">=</span><spanclass="n">boot_dir</span> <spanclass="c">#执行后面的python程序命令</span> <spanclass="c">#sys.executable是python解释器程序的绝对路径``whichpython``</span> <spanclass="c">#>>>sys.executable</span> <spanclass="c">#'/usr/local/var/pyenv/versions/3.5.1/bin/python3.5'</span> <spanclass="n">os</span><spanclass="o">.</span><spanclass="n">execl</span><spanclass="p">(</span><spanclass="n">sys</span><spanclass="o">.</span><spanclass="n">executable</span><spanclass="p">,</span><spanclass="n">sys</span><spanclass="o">.</span><spanclass="n">executable</span><spanclass="p">,</span><spanclass="o">*</span><spanclass="n">args</span><spanclass="p">)</span> <spanclass="k">if</span><spanclass="n">__name__</span><spanclass="o">==</span><spanclass="s">'__main__'</span><spanclass="p">:</span> <spanclass="n">main</span><spanclass="p">()</span>
test.py的内容为:
$cattest.py importsys importhello print(sys.argv) print(hello.sleep(3))
使用方法:
$pythonagent.pytest.pyarg1arg2 find_moduleusercustomize find_modulehello load_modulehello ['test.py','arg1','arg2'] startfunc spent3.005035161972046s 3
至此,我们就实现了一个简单的python探针程序。当然,跟实际使用的探针程序相比肯定是有很大的差距的,这篇文章主要是讲解一下探针背后的实现原理。
如果大家对商用探针程序的具体实现感兴趣的话,可以看一下国外的NewRelic或国内的OneAPM,TingYun等这些APM厂商的商用python探针的源代码,相信你会发现一些很有趣的事情。