Waf - a Python based build system

看懂这篇文章需要一点使用waf的经验,不过也不费事,看看例子也够了。

构建系统简谈

软件构建系统不像是个很多人在研究的东西,所以在网络上很少能找到剖析某个构建系统原理、或者阐述构建系统principle的文章。看ns3的过程中接触到了waf,发现其文档waf book很好的阐述了构建系统的一些基础知识,个人认为比cmake的文档好一些。因为其核心只有十几个文件,这个构建系统只需要一个10k+的waf文件,所以可以放到版本库里(像对python的评价一样,batteries included),唯一要求就是环境中有python,而这对一个开发人员来说显然不是一件困难的事情。

|-- Build.py
|-- ConfigSet.py
|-- Configure.py
|-- Context.py
|-- Errors.py
|-- Logs.py
|-- Node.py
|-- Options.py
|-- Runner.py
|-- Scripting.py
|-- Task.py
|-- TaskGen.py
|-- Tools [directory]
|-- Utils.py
|-- ansiterm.py
|-- extras
|-- fixpy2.py
`-- processor.py

以上便是所有waf的内容,可以看到涉及到的文件不算多。Tools下包含了很多语言的构建工具,比如c/c++/java/qt/ruby/tex等等,如果自己有能力定制,可以只保留自己项目里需要的tool,可以做到更小。(虽然个人认为没有必要)

核心抽象

如果是写编译语言的(c/c++/rust/go/fc/d),那么构建系统是每天都在用的。在敲击make之后,屏幕上出现了一系列的自动运行的命令,然后就是漫长的等待。用waf也一样,一般是./waf configure build clean dist…再等机器的轰鸣停止后继续工作流。waf提供了一些核心的抽象,从而能够表达出构建这个活动的几个关键方面:

  1. 像make clean dist类似,可以在构建命令后面自行添加指令,这种capibility由Context提供
  2. 构建系统最重要的功能就是按需构建,要判断出哪些文件要编译而哪些是不用的,这用到了TaskGen与Task的抽象
  3. 并行构建提升速度,由Runner来提供。

这3个抽象几乎相互独立,个人认为是很好的一个抽象。

Context

每一个跟在./waf后面的指令,都对应一个Context。如果是build/configure/list/step/install/uninstall,waf自行提供了对应的Context的子类用于执行这些命令,如果是其他的自定义函数,那么就会依托于Context本身,可以在自定义函数里用Context自定义的函数,比如recurse来遍历子目录执行子目录里的同名自定义函数。

如果项目根目录下的wscript有do_sth,就可以./waf do_sth

def do_sth(ctx):
    ctx.load('compiler_cxx')    # 加载工具
    ctx.recurse(['src','dep'])  # 遍历子目录,执行子目录下wscript里的do_sth
    ctx.exec_command('touch foo.txt')
    ctx.msg('hello')

这里函数参数ctx就是指向了Context的一个实例,而do_sth是作为Context上的一个方法而存在的,可以直观的理解为,我们为Context增加了一个自定义的do_sth方法,所以可以自由调用Context里本来提供的方法。

./waf build执行时绑定的Context是BuildConetxt,在Build.py里被定义,在waf build的时候,执行的是wscript里def build(bld)这个方法。举一个例子

def configure(conf):
    conf.load('compiler_cxx')
def build(bld):
    bld.shlib(source='a.cpp', target='mylib3')
    bld.program(source='main.cpp', target='app', use='mylib')
    bld.stlib(target='foo', source='b.cpp')
    # 直接调用bld
    bld(features = 'c cprogram glib2',
        use      = 'GLIB GIO GOBJECT',
        source   = 'main.c org.glib2.test.gresource.xml',
        target   = 'gsettings-test')

这里bld指向了BuildContext的一个实例,这意味着BuildContext里所有的方法都在这个函数里都是可用的,可以通过bld.xxx来调用。

值得注意的是,在Build.py中,可是找不到shlib/probram/stlib这3个方法的,但是在这里却调用成功没有报错,这全部依赖于conf.load('compiler_cxx')这一句。执行这句话后,就给bld指向的BuildContext实例绑定了shlib/program/stlib这3个方法。 那直接调用bld()呢?这个就要看Build.py里的BuildContex():__call__方法了。从这里开始,就涉及到TaskGen这个抽象了。

TaskGen & Task

最终需要执行的编译指令、中间代码生成等,每一条都对应一个task,我们不可能去一个一个的写task,而是希望以一种声明式的方法表达想要做的事情,这就是task_gen所完成的任务。从声明式表达到生成task的这项任务,由waf build完成。在执行的过程中,会对搜集到的每个task_gen执行一下post(),然后这个task_gen就生成了自己所有的task。作为一个灵活的构建系统,waf提供了很多方法来让我们hook到post()的过程中。对于每个task,到底该不该执行需不需要执行,它自己会追踪自己的依赖,职责分离,我很喜欢这个设计思路。

以前一小节为例,共在build(bld)里一共进行了4次调用,这意味着生成了4个task_gen的实例,在真正执行构建过程之前,会有一个地方对这4个实例各自调用一下post(),把所有的task_gen都消灭掉,变成task。至于怎么hook,这是个比较关键的点,如果理解了,就能很好的自定义waf了。

首先看看写好的wscript,它的声明式体现在什么地方呢?体现在函数参数里。得益于python的语言特点,可以随便加参数,然后在函数实现里用**kw来取这些值。这意味着可以随便加自己想要的key=value进去,这些加进去的参数是可以在自定义的hook过程中取到的,这算是可自定义的一个基础。(ruby自定义的能力更强,毕竟dsl是其强项,但可能限于ruby的流行程度以及发行版是否默认安装,让作者最后选择了python,不过也已经够用了)

在post()的过程中,会从task_gen.meths[]里依次取出方法来执行,hook的方式就是把自定义的方法塞到这个task_gen.meths[]之中。这只要在自定义的方法上加一个@TaskGen.taskgen_method的注解就能实现,还是挺简洁的吧?声明式中写的key=val,都能通过taskgen.key取到,这样一来,几乎就获得了无限的能力来自定义构建过程了。

taskgen.meths[]里有几项预定义的方法,waf也提供了指令来让我们定制自己方法执行的位置。总而言之,想要什么内容,直接在wscript里以key=val的方式指定,然后在自己的方法里用getattr来取就行了。

这也只是个支持性框架,具体到某个语言(c/c++)是怎么做的,到后面再看。

Runner

waf自己会默认起和cpu core相同数量的进程来执行构建认任务,而且构建过程的输出也很清晰漂亮。waf也提供了lazy的模式,不是一下子把所有的task_gen都转化,所以也是用了一些技巧来达成这个目的。在看waf代码的过程中,能看到很多pythonic和近乎炫技的技法,可见作者真是把python语言玩弄于股掌之中。

如何实现make -j的效果?答案是Semphore,这里的job control是由几个类相互交互完成的。虽说Python的线程是鸡肋,但完成任务分派还是绰绰有余。这里分三类线程:

  1. 主线程,只有1个,即敲回车后生成的Python进程,其中负责交互的类为Parallel
  2. 分派线程,也只有1个,叫Spawner,与Parallel互相引用。主线程决定了并行数量,然后在分派线程里初始化一个对应数量的Semphore
  3. 工作线程Consumer,有一个Task,就得起1个Consumer

其实到现在的位置,要执行的Task已经都放在一个队列ready里了。遍历这个队列,acquire semphore,开新的Consume执行Task。如果Semphore用完了,那么遍历的过程就阻塞,直到Task执行结束后Consumer再把这个Semphore加回去。

Consumeready队列里获取任务执行,结束后放回out队列里。主线程在一个循环里从out往回拿任务,看看对不对,然后做一些统计或者直接结束构建。

这里提到的所有类,都在Runner.py里。

Consumer里调用的方法最后都会走到Utils.run_regular_process里,通过subprocess.Popen来完成真正的命令调用。

C++的构建

前面的核心抽象确实相当抽象,只是提供了一种框架来执行并行执行一些任务,关于构建本身则没有任何的提及。至于如何用这种工具做到构建C++工程,则并不是一件容易的事情。

cmake社区近些年发起了轰轰烈烈的modern cmake的运动,即迁移到target based的构建描述,而非原先支持的流水帐构建。反观waf自带的C++构建方式,天然就是target based,只不过在waf的范畴里,这个叫task generator。

C++代码最终的产出是什么呢?有3种:

  1. 可执行程序
  2. 静态库
  3. 动态库

有的C++程序其实是作为其他程序的依赖而存在,典型的比如各种libssl-dev。这种类型的产出不仅只有可加载的二进制,而且还要给其他库提供编译支持,即头文件。

有人喜欢写all in one的代码,典型的比如Fabrice Bellard写quickjs,一个文件搞定。这种代码,其实并不太需要构建系统,几行shell脚本就全都搞定,反正每次都要重新编译。不过普通人还是选普通配置,该分模块就分模块,老老实实的一个一个module去完成功能。减少构建的时间,减少重复编译的工作,这就需要构建系统的辅助,来找出哪些需要重新编译而那些可以复用。

可以从2个角度来思考C++的构建

  1. 找出来哪些需要重新构建,这个工作叫依赖管理
  2. 每个构建应该怎么完成

以如下的构建脚本为例吧。

def build(bld):
	bld.shlib(source='a.cpp b.cpp', target='mylib')
	bld.program(source='main.cpp', target='app', use='mylib')

这里申明了一个动态库mylib,由2个文件构建而成;然后申明了一个二进制的程序app,用到了mylib。 我们用手工编译的话,需要如下的步骤:

$ g++ -c a.cpp -o a.o
$ g++ -c b.cpp -o b.o
$ g++ --shared a.o b.o -o mylib.so
$ g++ -c main.cpp -o main.o
$ g++ main.o -o app -lmylib

其中每一行就是一个task,那么如何从build里的那几句话得到这些task呢?说来话长,要用到waf提供的一系列脚手架,就一个一个慢慢来吧!

Task Generator解构

这两次对bld.xxx的调用,生成了2个task generator,之后task_gen经过一系列的处理,生成了5个task。 不过,task generator到底是什么?看看文档里的说法吧!

task generator应当有如下的特征

  1. attribute(就是bld.shlib、bld.program的入参)仅在需要处理的时候才处理
  2. 对attribute的存在性不做要求
  3. 可以根据单个task generator来对构建过程做出调整
  4. 应该提供与插件结合的能力

所以,实现这样的一个功能还是挺难的,文档里列举了这么一些方式:

  1. 用类来抽象task generator,通过继承的方式来解决添加功能的问题
  2. 用python decorator来添加新功能,不过这种方式只能达到添加的功能,没办法删除已有的功能
  3. 扁平化的方式,只声明自己功能执行的时候需要满足的条件,就像面向切片编程一样。

第3个看起来不错,不如看看是怎么实现的吧。

要产出task,那么最开始的方法是什么?是task_gen.post(),在BuildContext里被调用。这个方法其实只做了一件事情:根据设置的feature,填充task_gen.meths,处理一下里面顺序,然后挨个调用就行。

那么就很明显了,对于所有的task generator,都有一个feature是*,而与*相互关联的方法只有2个:

@feature('*')
def process_source(self):
    ...处理bld.xyz(source='a.c b.py c.tex')source

@feature('*')
@before_method('process_source')
def process_rule(self):
    ...处理bld.xyz(rule='cp SRC[0] TGT[0]')rule

这就是waf所有魔法的起点。有了这2个方法,好像就有了锚点一样,如果有自己的功能想要添加,就用@feature加上去,如果想要调整顺序,还有@before_method\@after_method

extension mapping

在C++构建中,光有这些方法,离生成可用的task好像还有点远。其实并不远,只是需要明确到底是在哪一步hook进去的,这步就是前面提到的process_source

process_source里,对source这个attribute里的每一个文件,都通过其后缀找到对应的处理函数并执行。c/c++代码的后缀无非就是c/cc/cxx/cpp/h/hpp/hxx之类的,这些waf自带的tool就已经把这些常用的都包含进去了。

是不是有种,你以为我在第二层,其实我在第五层的感觉?实话说这就是我自己在追逻辑时候的感觉。如果feature是第一层,那么feature塞function到task_gen.meths里就是第二层;第二层提供了的一个方法process_source是第三层,process_source自己又提供了extension mapping,这就是第四层;对应到每一个extension,就可以到各自的构建过程了,这也是提供给其他的插件的hook点。

真真儿的,有5层。

@TaskGen.extension('.cpp','.cc','.cxx','.C','.c++')
def cxx_hook(self, node):
	"Binds c++ file extensions to create :py:class:`waflib.Tools.cxx.cxx` instances"
	return self.create_compiled_task('cxx', node)

看函数名就知道了,这里就是task真正产生的地方!

后记

其实也不能完全算看完吧,还有很多具体的细节没有提到,典型的比如变动检测(用md5而没用update time,并且联系到一个动态ID上),动态编译build function(把run_str变成一个task的方法,执行这个方法调用subprocess.Popen),以及其他种种;不过已经可以稍微帮助别人理解一下这个构建工具的基本思想,以及一个稍微具体的实例来体会构建过程,希望能起到一点抛砖引玉的作用。