RSS

剖析Envoy Filter的Bazel编译配置

引言

Envoy是一款使用现代C++语言编写的L4/L7网络代理,采用Bazel进行依赖管理与执行编译过程。本文将以官方一个简单的HTTP Filter作为例子,分析Bazel在filter编译构建过程中所起的作用,为希望快速开始开发与编译filter的开发者提供指引,帮助大家快速了解envoy filter的编译流程。

一、Bazel简介

Bazel是一种类似CMake的用于组织编译过程的一个构建工具,类似Make,Maven和Gradle。其特点是可读性较好,支持跨语言,跨平台编译;并且可以定义代码库之间的依赖关系,支持跨代码库的联合构建。Bazel定义构建的依赖关系和规则,并管理构建生成的临时文件及二进制文件,具体的编译工作是调用各个语言的编译工具如gcc, javac等完成的。

这里简单介绍一下两个Bazel最重要的文件与相关概念:

WORKSPACE

WORKSPACE文件,用于指定当前文件夹就是一个Bazel的工作区。工作区是一个存放了所有源代码和Bazel编译输出文件的目录,也就是整个项目的根目录,WORKSPACE文件总是存在于项目的根目录下。

BUILD

一个或多个BUILD文件,用于告诉Bazel怎么构建项目的不同部分。如果工作区中的一个目录包含BUILD文件,那么它就是一个package。一个BUILD文件包含了几种不同类型的指令。其中最重要的是编译指令,它告诉Bazel如何编译想要的输出,比如可执行二进制文件或库。BUILD文件中的每一条编译指令被称为一个target。

接下来我们将开始分析Bazel在envoy-filter-example项目中的配置与作用,为了能够更好地阅读本文,希望大家已经有了Bazel的一些基础入门知识或者概念。关于Bazel的基础入门,可以参考一下两篇文章,看完了基本上就对Bazel有个总体了解了:

二、Filter示例的目录结构

首先,我们从github上clone下来filter的示例代码,并拉取子模块:

git clone https://github.com/envoyproxy/envoy-filter-example.git
cd envoy-filter-example
git submodule update --init

从仓库中,我们来分析一下使用Bazel编译时主要的目录结构:

.
├── WORKSPACE // 指定该目录是一个Bazel工作区,文件内容告诉Bazel如何去得到其他的工程源供BUILD使用
├── BUILD // 描述如何构建根目录Package
├── .bazelrc // 对envoy/.bazelrc进行了引用
├── .bazelversion // 文件内容表示该工作区需要使用的Bazel版本
├── echo2.cc
├── echo2.h
├── echo2_config.cc
├── echo2_integration_test.cc
├── bazel // 仅包含了一个get_workspace_status文件,根据envoy/.bazelrc的描述,构建时将使用其生成--workspace_status_command参数的值
├── envoy // envoy源码作外部源被引入
   ├── WORKSPACE // envoy目录也是一个Bazel工作区,文件内容告诉Bazel如何去得到其他的工程源供BUILD使用
   ├── BUILD // 描述了envoy根目录的构建规则
   ├── .bazelrc // 包含了大量预定义的在执行bazel build时的默认参数,以及自定义扩展参数的描述
   ├── .bazelversion // 文件内容表示该工作区需要使用的Bazel版本
   ├── bazel // 文件夹中包含了大量自定义的Bazel配置文件,使用类似于Python的Starlark语言编写,会被BUILD与WORKSPACE文件引用
└── http-filter-example // http filter的源码目录,工作区的一个Package
    ├── BUILD // 定义了http filter这个Package的构建规则
    ├── http_filter.cc
    ├── http_filter.h
    ├── http_filter.proto
    ├── http_filter_config.cc
    └── http_filter_integration_test.cc

三、WORKSPACE文件分析

envoy_filter_example的WORKSPACE文件与envoyproxy项目的WORKSPACE文件结构与内容大体一致,通过分析filter的WORKSPACE文件,我们也就明白envoy自身的工作区描述了。

workspace(name = "envoy_filter_example") # 工作区名称

# 使用local_repository引入通过git submodule拉下来的envoy项目,并将项目命名为envoy,后续可通过@envoy进行引用
local_repository( 
    name = "envoy",
    path = "envoy",
)

# 从envoy的bazel/api_binding.bzl中读取envoy_api_binding符号
load("@envoy//bazel:api_binding.bzl", "envoy_api_binding")

# envoy_api_binding函数会注册@envoy_api与@envoy_api_canonical两个外部依赖,分别指向envoy/generated_api_shadow以及envoy/api。
# 通过对函数注释的解读,以及对源码的全局搜索,可见envoy源码中仅使用了@envoy_api依赖引用,而@envoy_api则由@envoy_api_canonical通过脚本进行生成,从而能在编译时过滤掉不在使用,而又正在开发或已经废弃的api
envoy_api_binding()

# 从envoy的bazel/api_repositories.bzl中读取envoy_api_dependencies符号
load("@envoy//bazel:api_repositories.bzl", "envoy_api_dependencies")

# envoy_api_dependencies函数定义了@envoy_api所需要的外部依赖
envoy_api_dependencies()

# 从envoy的bazel/repositories.bzl中读取envoy_dependencies符号
load("@envoy//bazel:repositories.bzl", "envoy_dependencies")

# envoy_dependencies函数载入了所有envoy编译需要的核心依赖,以及根据配置载入了需要编译进envoy的扩展的源码目标位置
envoy_dependencies()

# 从envoy的bazel/repositories_extra.bzl中读取envoy_dependencies_extra符号
load("@envoy//bazel:repositories_extra.bzl", "envoy_dependencies_extra")

# envoy_dependencies_extra函数载入了在envoy编译过程中所需要的工具,主要是一些python库和工具
envoy_dependencies_extra()

# 从envoy的bazel/dependency_imports.bzl中读取envoy_dependency_imports符号
load("@envoy//bazel:dependency_imports.bzl", "envoy_dependency_imports")

# envoy_dependency_imports函数载入了编译envoy时需要的bazel扩展功能,包括对不支持Bazel的第三方依赖支持、golang编译扩展、proto编译支持等
envoy_dependency_imports()

以上便是WORKSPACE文件内容的分析,它将整个项目所需要的底层依赖都进行了描述,使得每个Package中的BUILD文件都能够从这些底层依赖中直接引用相关库与工具,分离了依赖的引入与对依赖所执行的动作。

四、echo filter的BUILD文件分析

接下来,我们将看一下echo filter的BUILD文件内容,分析一下它的编译过程。

# package函数修改了本Package规则的可见性,允许在其他Package中可以直接使用本Package的规则
package(default_visibility = ["//visibility:public"])

# load从在WORKSPACE中引入的@envoy依赖中加载envoy_cc_binary、envoy_cc_library与envoy_cc_test符号
load(
    "@envoy//bazel:envoy_build_system.bzl",
    "envoy_cc_binary",
    "envoy_cc_library",
    "envoy_cc_test",
)

# envoy_cc_binary是对Bazel自带的cc_binary的扩展函数
# 从函数定义中看出,envoy_cc_binary自动根据操作系统使用不同的linkopts连接参数、copts编译参数,以及添加默认依赖等
envoy_cc_binary(
    name = "envoy", 
    repository = "@envoy", # envoy_cc_binary自定义的参数,用于指定envoy的依赖名称,与WORKSPACE中的一致
    deps = [
        ":echo2_config",
        "@envoy//source/exe:envoy_main_entry_lib",
    ], # 编译依赖本项目的:echo2_config规则,以及@envoy中用于把main.cc编译为lib的规则
)

# envoy_cc_library是对Bazel自带的cc_library的扩展函数
# 与envoy_cc_binary一样,envoy_cc_library自动根据操作系统使用不同的copts编译参数、添加默认依赖、修改header路径前缀等功能;此外,它除了会生成一条同名的cc_library规则外,还会额外生成一条名为${name}_with_external_headers的cc_library规则,不同点在于使用了"nocompdb"的tag,用于供第三方项目引用envoy时使用。
envoy_cc_library(
    name = "echo2_lib", # echo2_lib规则用于filter逻辑实现代码部分的编译
    srcs = ["echo2.cc"], # 编译时直接使用的源代码文件
    hdrs = ["echo2.h"], # 编译时直接使用的头文件
    repository = "@envoy",
    deps = [
        "@envoy//include/envoy/buffer:buffer_interface",
        "@envoy//include/envoy/network:connection_interface",
        "@envoy//include/envoy/network:filter_interface",
        "@envoy//source/common/common:assert_lib",
        "@envoy//source/common/common:logger_lib",
    ], # 编译echo2_lib需要的envoy工作区依赖,此处不作展开
)

envoy_cc_library(
    name = "echo2_config", # echo2_config规则用于filter如何从配置中初始化filter,以及注册filter到envoy的代码部分的编译
    srcs = ["echo2_config.cc"],
    repository = "@envoy",
    deps = [
        ":echo2_lib",
        "@envoy//include/envoy/network:filter_interface",
        "@envoy//include/envoy/registry:registry",
        "@envoy//include/envoy/server:filter_config_interface",
    ],
)

# envoy_cc_test是对Bazel自带的cc_的扩展函数,与上述envoy_cc_binary功能类似,不再展开。
envoy_cc_test(
    name = "echo2_integration_test",
    srcs = ["echo2_integration_test.cc"],
    data =  ["echo2_server.yaml"],
    repository = "@envoy",
    deps = [
        ":echo2_config",
        "@envoy//test/integration:integration_lib"
    ],
)

# sh_test是Bazel自带的函数,允许编写一段Shell脚本进行测试
sh_test(
    name = "envoy_binary_test",
    srcs = ["envoy_binary_test.sh"], # 需要执行的Shell测试脚本
    data = [":envoy"], # 依赖的文件列表,此处依赖了编译生成的envoy二进制文件
)

五、http filter的BUILD文件分析

http filter的BUILD文件内容与上述echo filter的有相当一部分是相似的,而有少量新的规则出现在了http filter的BUILD中,下面主要分析一下新增的部分,对于其他类似的部分,参考上述对echo filter的分析即可。

http filter新增的规则,主要体现在对于filter的proto描述文件的处理上,包括转换为cc文件、对转换后的文件进行编译等动作。

package(default_visibility = ["//visibility:public"]) # 参考echo filter部分

load(
    "@envoy//bazel:envoy_build_system.bzl",
    "envoy_cc_binary",
    "envoy_cc_library",
    "envoy_cc_test",
) # 参考echo filter部分

# 从envoy的bazel/api_build_system.bzl中读取api_proto_package符号
load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package")

envoy_cc_binary(
    name = "envoy",
    repository = "@envoy",
    deps = [
        ":http_filter_config",
        "@envoy//source/exe:envoy_main_entry_lib",
    ],
) # 参考echo filter部分

# 执行api_proto_package函数,该函数会对proto文件转换为cc文件,下一个模块我们将对本函数进行分析
api_proto_package()

# http_filter_lib规则与上面的echo_lib规则相似,区别在于依赖了本Package的:pkg_cc_proto,而放眼整个BUILD文件,确找不到一个名为pkg_cc_proto的规则,我们将与上述api_proto_package()函数一起分析。
envoy_cc_library(
    name = "http_filter_lib",
    srcs = ["http_filter.cc"],
    hdrs = ["http_filter.h"],
    repository = "@envoy",
    deps = [
        ":pkg_cc_proto",
        "@envoy//source/exe:envoy_common_lib",
    ],
)

envoy_cc_library(
    name = "http_filter_config",
    srcs = ["http_filter_config.cc"],
    repository = "@envoy",
    deps = [
        ":http_filter_lib",
        "@envoy//include/envoy/server:filter_config_interface",
    ],
) # 参考echo filter部分

envoy_cc_test(
    name = "http_filter_integration_test",
    srcs = ["http_filter_integration_test.cc"],
    repository = "@envoy",
    deps = [
        ":http_filter_config",
        "@envoy//test/integration:http_integration_lib",
    ],
) # 参考echo filter部分

对于http filter的BUILD文件,我们将重点放在api_proto_package()这个函数中,接下来将分析一下与这个函数相关的自定义规则,梳理一下它的作用于执行过程。

# 下文中主要需要关注的外部规则
load("@com_envoyproxy_protoc_gen_validate//bazel:pgv_proto_library.bzl", "pgv_cc_proto_library")
load("@com_github_grpc_grpc//bazel:cc_grpc_library.bzl", "cc_grpc_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
# load more rules...

# 定义了一些字符串常量,方便后续使用
_PY_PROTO_SUFFIX = "_py_proto"
_CC_PROTO_SUFFIX = "_cc_proto"
_CC_GRPC_SUFFIX = "_cc_grpc"
_GO_PROTO_SUFFIX = "_go_proto"
_GO_IMPORTPATH_PREFIX = "github.com/envoyproxy/go-control-plane/"

# api_proto_package定义了对于作为envoy api的proto将如何进行转换与编译,提供了c++、python以及golang相关的规则描述
def api_proto_package(
        name = "pkg", # 默认名称为pkg
        srcs = [],
        deps = [],
        has_services = False,
        visibility = ["//visibility:public"]):
    if srcs == []:
        srcs = native.glob(["*.proto"]) # 默认src文件为Package目录下的所有*.proto文件

    name = "pkg" # 这里强制令name赋值为pkg
    # 调用api_cc_py_proto_library来生成proto的c++与python规则。由于pkg与srcs默认值的存在,开发者能够简单的调用api_proto_package()即可。
    api_cc_py_proto_library(
        name = name,
        visibility = visibility,
        srcs = srcs,
        deps = deps,
        has_services = has_services,
    )
    
    # compilers用于指定proto转换为go文件的工具,用于生成pb.go以及pb.validate.go
    compilers = ["@io_bazel_rules_go//proto:go_proto", "@envoy_api//bazel:pgv_plugin_go"]
    if has_services:
        compilers = ["@io_bazel_rules_go//proto:go_grpc", "@envoy_api//bazel:pgv_plugin_go"]

    # go_proto_library用于定义proto转换为golang代码的相关规则,envoy自身编译过程中并不依赖这些go library,主要被使用在测试与控制平面项目中。
    go_proto_library(
        name = name + _GO_PROTO_SUFFIX, # 默认为pkg_go_proto
        compilers = compilers,
        importpath = _GO_IMPORTPATH_PREFIX + native.package_name(), # 修改生成的go文件的import路径,使得生成的文件import路径的仓库变为github.com/envoyproxy/go-control-plane,使其在对应仓库中能够使用go module正确引用
        proto = name,
        visibility = ["//visibility:public"],
        deps = [_go_proto_mapping(dep) for dep in deps] + [
            "@com_github_golang_protobuf//ptypes:go_default_library",
            "@com_github_golang_protobuf//ptypes/any:go_default_library",
            "@com_github_golang_protobuf//ptypes/duration:go_default_library",
            "@com_github_golang_protobuf//ptypes/struct:go_default_library",
            "@com_github_golang_protobuf//ptypes/timestamp:go_default_library",
            "@com_github_golang_protobuf//ptypes/wrappers:go_default_library",
            "@com_envoyproxy_protoc_gen_validate//validate:go_default_library",
            "@com_google_googleapis//google/api:annotations_go_proto",
            "@com_google_googleapis//google/rpc:status_go_proto",
        ], # 若干依赖,包含了api proto文件中import的第三方依赖,比如googleapis等,此处不作深入分析
    )

# api_cc_py_proto_library定义了对proto文件转换为cc以及python文件,以及编译为c++目标文件的规则
def api_cc_py_proto_library(
        name,
        visibility = ["//visibility:private"],
        srcs = [],
        deps = [],
        linkstatic = 0,
        has_services = 0):
    relative_name = ":" + name
    # 使用Bazel自定义的proto_library规则来定义一个对应的proto规则,并添加了默认依赖
    proto_library(
        name = name,
        srcs = srcs,
        deps = deps + _COMMON_PROTO_DEPS, # _COMMON_PROTO_DEPS包含了@com_google_protobuf、@com_google_googleapis以及@com_envoyproxy_protoc_gen_validate等依赖
        visibility = visibility,
    )
    cc_proto_library_name = name + _CC_PROTO_SUFFIX # _CC_PROTO_SUFFIX=_cc_proto"
    # pgv_cc_proto_library函数是从https://github.com/envoyproxy/protoc-gen-validate项目中引入的规则(内容见:https://github.com/envoyproxy/protoc-gen-validate/blob/v0.4.0/bazel/pgv_proto_library.bzl#L25),它根据依赖的proto_library转换出对应的*.pb.h/*.pb.cc以及*.pb.validate.h/*.pb.validate.cc文件,同时编译出对应的目标文件
    pgv_cc_proto_library(
        name = cc_proto_library_name, # 由于api_proto_package的name参数默认值为pkg,因此这里的name默认就是pkg_cc_proto了
        linkstatic = linkstatic,
        cc_deps = [_cc_proto_mapping(dep) for dep in deps] + [
            "@com_google_googleapis//google/api:http_cc_proto",
            "@com_google_googleapis//google/api:httpbody_cc_proto",
            "@com_google_googleapis//google/api:annotations_cc_proto",
            "@com_google_googleapis//google/rpc:status_cc_proto",
        ],
        deps = [relative_name],
        visibility = ["//visibility:public"],
    )
    
    # _api_py_proto_library函数为proto描述定义了python转换方式,规则名称为${name}_py_proto,默认为pkg_py_proto,envoy自身编译时并未用到python相关的proto转换,主要用于附带的开发与测试工具使用
    _api_py_proto_library(name, srcs, deps)

    # 如果这个proto描述包含Service定义,则使用_api_cc_grpc_library(底层为cc_grpc_library)对grpc相关代码进行生成
    if has_services:
        cc_grpc_name = name + _CC_GRPC_SUFFIX
        cc_proto_deps = [cc_proto_library_name] + [_cc_proto_mapping(dep) for dep in deps]
        _api_cc_grpc_library(name = cc_grpc_name, proto = relative_name, deps = cc_proto_deps)

从以上分析可以看出,api_proto_package()函数巧妙的通过默认值,自动的把BUILD所在目录下的所有proto文件都整合到了:pkg_cc_proto、:pkg_py_proto以及:pkg_go_proto当中了,因此在BUILD文件后续的代码中,名为http_filter_lib的envoy_cc_library就直接对:pkg_cc_proto进行了依赖,而这个依赖是通过api_proto_package()自动生成的,这解析了为什么在BUILD文件中并没有定义:pkg_cc_proto而却可以直接引用依赖的原因了。

六、有用的Bazel命令

bazelisk工具

bazelisk是基于Go语言编写的Bazel启动器,它会为你的工作区下载最适合的Bazel,并且透明的将命令转发给该Bazel。通常通过.bazelversion文件内容来描述当前工作区所需要的Bazel版本号,如3.1.0。

关于如何安装,可参考官方仓库:https://github.com/bazelbuild/bazelisk

生成项目依赖图

通过Bazel,我们可以方便的生成整个项目的依赖关系图,帮助我们快速了解项目。

下面,我们继续以envoy的http-filter-example为例,介绍如何生成这样的依赖图。

cd envoy-filter-example
# 这里我们直接使用bazelisk代替bazel命令
# 生成graphviz文件。注意deps()中,我们把深度定为了2,能够较好的体现出依赖关系,如果使用3或更高的深度,依赖图会变得不可读,甚至无法生成,大家可以尝试一下。
bazelisk query --nohost_deps --noimplicit_deps 'deps(//http-filter-example:http_filter_config, 2)'  --output graph > http-filter-example-deps.dot
# 使用xdot工具查看依赖关系图(https://pypi.org/project/xdot/)
xdot http-filter-example-deps.dot

生成的依赖图: 依赖图

此外,我们也可以直接将生成的.dot文件的内容粘贴到在线graphviz网站中生成依赖图:http://www.webgraphviz.com/

开发过程中生成proto的c++代码

通过上文对于api_proto_package()函数的分析,我们已经清楚了BUILD文件中是如何对proto文件转换为c++等语言文件的。熟悉了这个规则之后,我们便可以直接使用bazel来生成一些中间代码,而无需手撸protoc命令或者每次都编译可执行文件了。

cd envoy-filter-example
# 生成当前package下*.proto对应的c++代码
bazelisk build //http-filter-example:pkg_cc_proto
# 生成的代码被放在如下目录,c++代码生成后,vscode等编辑器便能够准确地找到函数符号,正确的进行转跳了
ls -l bazel-bin/http-filter-example | grep pb
# -r-xr-xr-x  1 derekghchen  wheel   13843 Jul 12 21:53 http_filter.pb.cc
# -r-xr-xr-x  1 derekghchen  wheel   13245 Jul 12 21:53 http_filter.pb.h
# -r-xr-xr-x  1 derekghchen  wheel    1874 Jul 12 21:53 http_filter.pb.validate.cc
# -r-xr-xr-x  1 derekghchen  wheel     500 Jul 12 21:53 http_filter.pb.validate.h

同理,我们也用bazel build命令,将proto转换为golang代码:

# 构建pkg_go_proto目标,生成golang代码文件
bazelisk build //http-filter-example:pkg_go_proto
# 查看生成的golang文件
ls -l bazel-bin/http-filter-example/pkg_go_proto_/github.com/envoyproxy/go-control-plane/http-filter-example        
# -r-xr-xr-x  1 derekghchen  wheel  5503 Jul 12 22:01 http_filter.pb.go
# -r-xr-xr-x  1 derekghchen  wheel  2500 Jul 12 22:01 http_filter.pb.validate.go

七、总结

本文通过对官方envoy-filter-example的Bazel编译配置脚本进行分析,解释了envoy filter开发编译过程中的一些关键配置与依赖关系,帮助大家快速了解自定义filte被编译进envoy的过程。最后,介绍了几个有用的工具以及指令,以期帮助大家提高开发效率。同样希望对envoy filter编译配置的分析,令大家对Bazel有更多的了解与思考。