首页
/ Flutter Engine 开源合规检查器 licenses_cpp:构建、运行与源码解析

Flutter Engine 开源合规检查器 licenses_cpp:构建、运行与源码解析

2026-09-07 09:49:44作者:殷蕙予

本文以 Flutter Engine 仓库中的 licenses_cpp/GEMINI.md 为核心指南,系统讲解这套在 CI 中运行的 C++ 许可证收集与校验工具的构建目标、目录结构、命令行用法,并结合同目录下的源码(main.cc、license_checker.cc、Catalog/Filter 等)剖析其"按 git 仓库 + DEPS 全量扫描、用正则目录匹配许可证、统一汇总输出"的底层工作原理。读完你将掌握如何在本机用 et 构建工具与测试、对单个文件快速做许可证合法性核验,以及理解整个检查流水线的判定逻辑。

一、licenses_cpp 是什么

根据 GEMINI.md 与同目录 README.md 的定义:

A tool run during ci to collect and verify source code's licenses.

licenses_cpp 是 Flutter Engine 构建 CI 流程中负责收集并验证源码许可证的命令行工具。它的职责可以拆成两半:

  • 收集(collect):扫描 Engine 源码树,把每个"软件包"(package)对应的开源许可证文本汇总、去重后写入一个统一的许可证输出文件(例如仓库根目录的 licenses.txt)。
  • 验证(verify):对源码文件头注释中的 license/copyright 声明、以及各 LICENSE/COPYING 文件的内容,与预先登记的"已知许可证目录"进行正则匹配。匹配不上的情况会被记录为错误,从而在 CI 阶段尽早暴露许可证合规问题。

从数据目录可以看到它的支撑规模:data/licenses/ 中登记了几十种许可证模板,包括 Apache 2.0(apache2.txtapache2_header.txt)、BSD 2/3-Clause、MIT(mit.txtmit_starred.txt 等多个变体)、Boost、libpng、ICU、SQLite、Mozilla 2.0、Unicode、V8 等,说明它需要识别的许可证面相当广。

二、构建目标(GN Targets)与目录结构

2.1 GN 构建目标

BUILD.gn 中定义了四个目标:

Target 类型 说明
//flutter/tools/licenses_cpp:licenses source_set 核心库,包含 catalog、comments、data、deps_parser、filter、license_checker、mmap_file 等源文件
//flutter/tools/licenses_cpp executable 主程序 licenses_cpp,入口为 src/main.cc
//flutter/tools/licenses_cpp:license_file_compare executable 辅助程序,对比许可证文件(基于 re2 + mmap)
//flutter/tools/licenses_cpp:licenses_cpp_testrunner executable(testonly) 单元测试运行器,聚合 catalog/comments/deps_parser/filter/license_checker 五个测试文件

其外部依赖主要为 //flutter/third_party/re2(正则引擎)和 //third_party/abseil-cpp 的 flags/parse、log、status 组件。测试目标还依赖 //flutter/third_party/googletest。文档建议主程序用 profile 配置构建(即文档所说 "best run with a profile config"),因为全量扫描对性能有要求。

2.2 目录布局

GEMINI.md 归纳了两个核心目录:

  • data/ —— 工具所需的数据文件,如各种正则与许可证模板
  • src/ —— 源码目录

展开来看(见 目录列表):

licenses_cpp/
├── data/
│   ├── include.txt          # 参与检查的文件路径正则(全匹配)
│   ├── exclude.txt          # 需要排除的文件路径正则(全匹配)
│   ├── licenses/            # 可接受的已知许可证模板(数据格式见下文)
│   ├── secondary/           # 需要"原文附加"的二级许可证
│   └── README.md            # 数据格式说明
├── src/
│   ├── main.cc              # 入口:解析命令行 flags 并分派执行
│   ├── license_checker.{h,cc}   # 核心扫描逻辑
│   ├── catalog.{h,cc}       # 已知许可证目录与两级匹配
│   ├── filter.{h,cc}        # 多正则过滤器(include/exclude 共用的实现)
│   ├── comments.{h,cc,l}    # 注释提取(.l 为 flex 词法定义)
│   ├── comments_util.{h,cc}
│   ├── deps_parser.{h,cc}   # DEPS 文件中依赖列表的解析
│   ├── data.{h,cc}          # Data::Open 加载整个 data 目录
│   ├── mmap_file.{h,cc}     # mmap 文件读取
│   └── *unittests.cc        # 五个单元测试
├── tools/convert.dart
├── BUILD.gn
├── GEMINI.md
└── README.md

其中关于 include/exclude/catalog/匹配数据格式的权威说明记录在 data/README.md

三、构建并运行单元测试

GEMINI.md 给出了构建测试并运行的命令。注意这些命令的 ../../ 相对路径都是以 engine/src/flutter/tools/licenses_cpp 目录为基准的(在 Engine 检出中即 flutter/engine/src/flutter/tools/licenses_cpp 一级):

../../bin/et build --no-rbe -c host_debug_unopt_arm64 //flutter/tools/licenses_cpp:licenses_cpp_testrunner
../../../out/host_debug_unopt_arm64/licenses_cpp_testrunner

参数含义:

  • ../../bin/et:Flutter Engine 的构建入口脚本 et,它封装了 gn 生成与 ninja 编译。
  • --no-rbe:本次构建不经过 RBE 远程构建服务(本机编译)。
  • -c host_debug_unopt_arm64:配置为 host 端、debug、非优化、arm64。
  • //flutter/tools/licenses_cpp:licenses_cpp_testrunner:要构建的 GN 目标。
  • ../../../out/host_debug_unopt_arm64/:产物输出目录(相对该目录上溯三级)。

构建完成后直接执行测试运行器即可。它聚合了 catalog_unittests.cccomments_unittests.ccdeps_parser_unittests.ccfilter_unittests.cclicense_checker_unittests.cc 这五组测试,验证的是目录匹配、注释提取、DEPS 解析、过滤器与整体检查器各自的行为。

四、对单个文件运行许可证检查

GEMINI.md 提供的最实用示例是只检查一个文件

../../bin/et build --no-rbe -c host_profile_arm64 //flutter/tools/licenses_cpp
../../../out/host_profile_arm64/licenses_cpp \
  --working_dir ../.. \
  --data_dir ./data  \
  --licenses_path licenses.txt \
  --input ../../third_party/icu/source/i18n/collunsafe.h \
  --v=3

这里用 profile 配置构建主程序,然后对 ICU 的 collunsafe.h 做单文件核验。各参数:

Flag 必填 说明
--working_dir ../.. 待扫描的目录根(此处为 engine 源码根)。源码中的相对路径都要相对它解析
--data_dir ./data 存放许可证数据(licenses 模板、include/exclude 正则)的目录
--licenses_path licenses.txt 汇总许可证内容的输出文件路径
--input <file> 指定只检查这一个文件;缺省则执行全目录扫描
--include_filter <regex> 覆盖默认 include 过滤器的正则(注意:与 --input 互斥,见下文)
--root_package <name> 指定根软件包名称
--treat_unmatched_comments_as_errors 把"匹配不到已知许可证的注释"视为错误
--v=<n> 日志详细级别,--v=3 可输出较详细的处理/未匹配信息

这些 flags 的定义可以在 main.ccABSL_FLAG 声明处逐一找到,例如 working_dir/data_dir/licenses_path 在源码注释中都标注为 [REQUIRED]--v 默认值为 0。

4.1 命令行解析与分派

main.cc 的执行逻辑可以归纳为:

  1. absl::ParseCommandLine 解析参数,并把 --v 应用到全局日志级别。
  2. 三个必填参数缺一不可working_dirdata_dirlicenses_path。三者同时存在才继续,否则逐个在 stderr 打印缺失项并返回 1。
  3. 打开 licenses_path 输出流,写入失败则退出。
  4. 分派路径
    • 若提供了 --input(单文件模式):先拒绝同时使用 --include_filter(打印 --input_filter not supported with --input),然后用 fs::canonical 规范化路径后调用 LicenseChecker::FileRun
    • 否则是全量模式:有 --include_filter 时用它构造 Filter 覆盖默认 include 规则后调用 LicenseChecker::Run,无则直接全量扫描。
  5. 收集到的错误逐条打印到 stderr,只要有错误,进程就返回 1——这正是 CI 中把它当作"检查门禁"的基础。

五、深入核心执行引擎:license_checker 的扫描模型

单文件与全量模式最终都汇入 license_checker.ccProcessFile/Run/FileRun。其整体数据流如下。

5.1 数据加载与双重过滤

LicenseChecker::Run 首先通过 data.h 中的 Data::Open(data_dir) 把 data 目录加载进内存,形成:

  • include_filter / exclude_filter:由 include.txtexclude.txt 解析而来,每个文件就是多行正则拼接而成(实现见 filter.hFilter::Matches 用单个 RE2 做全匹配);
  • catalog:已知许可证目录;
  • secondary_dir:二级许可证目录。

每个待检文件进入 ProcessFile 后第一道关卡就是:

if (!data.include_filter.Matches(relative_path.string()) ||
    data.exclude_filter.Matches(relative_path.string())) {
  VLOG(1) << "EXCLUDE: ...";
  return absl::OkStatus();
}

即"不在 include 白名单,或命中 exclude 黑名单"的文件被直接跳过。根据 data/README.md,include/exclude 中的每条正则都必须是对相对 --working_dir 路径的完整匹配(full match) 才生效,行首 # 为注释。

5.2 软件包归属判定(GetPackage)

接着 GetPackage 依据路径在目录树中的位置判定该文件属于哪个"软件包":

  • 默认包名取根目录名(working_dir 的 filename,见 GetDirFilename),可由 --root_package 覆盖;
  • 一旦路径组件中出现 third_party,该文件就被标记为非根包(is_root_package = false),third_party 之后的第一个目录组件即包名;
  • 存在忽略名单 kThirdPartyIgnore = {"pkg", "vulkan-deps"},命中它们会被跳过、不作为包名;
  • 同时向上逐级查找最近的 LICENSE 文件(候选名单见 kLicenseFileNamesLICENSELICENSE.TXT/.txt/.md/.MITCOPYINGLicense.txtdocs/FTL.TXTREADME.ijg),作为该包最贴近的许可证文件。

5.3 两套正文处理策略

每个文件在 mmap 后按文件名分派:

  • 名为 NOTICES 的文件ProcessNotices:用正则按"项目列表 + 空行 + 许可证正文 + 80 个 - 分隔线"的格式切块,把每块正文拿去匹配目录,并把该块头部列出的每个项目名都登记到该许可证下。
  • 其它文件ProcessSourceCode:先用词法分析器(comments.l/comments.ccIterateComments)抽出所有注释;凡注释中出现 licensecopyright(大小写不敏感,正则见 LicenseChecker::kHeaderLicenseRegex)即送入 Catalog 匹配。若一个文件里连注释都没有,则把整段文本当作文本文件整体尝试匹配。

5.4 Catalog:两级目录匹配

Catalog 采用两级检索来压缩 RE2::Set 的规模:先用一个"selector"(由每个条目的 unique 正则构成)决定该用哪个 matcher,再对命中条目执行精确正文匹配。每个许可证条目对应 data 中一个文件,格式为(data/README.md):

  1. 第一行——matcher 名称;
  2. 第二行——unique 正则,各条目间不得重叠;
  3. 其余行——用于提取许可证完整正文的 matcher 正则,其中所有空白视作 \s+、行尾空白忽略、被捕获的分组会从输出中剔除。

匹配成功的正文会被登记进 LicenseMap(按许可证正文去重、追加包名集合);匹配失败时,若启用了 treat_unmatched_comments_as_errors,未匹配的注释会作为错误上报——这是"校验"语义的主要落点。

5.5 全量模式的扫描范围

Run 的全量扫描覆盖三块区域,理解它们就理解了 GEMINI.md 所说 "collect" 的广度:

  1. DEPS 中声明但非 git 仓库的依赖FindFileInParentDirectories 向上查找 DEPS 文件,用 deps_parser.cc 解析出依赖子路径(须落在 working_dir 之内),对其整目录递归检查——这正是为了覆盖并非 git 仓库的依赖。
  2. working_dir 下所有 git 仓库GetGitRepos 递归寻找 .git 目录得到仓库列表,再对每个仓库执行 git ls-files(源码中调用 git -C <repo> ls-files)拿到受版本控制的文件清单逐一检查。
  3. secondary 目录secondary/ 的目录结构需与 working_dir 一致;其中每个许可证文件会被原样(verbatim)附加到最终输出(这部分不参与目录匹配)。

全部文件处理完毕后,state.license_map.Write(licenses) 把结果写入 --licenses_path。输出格式(见 LicensesWriter):同一许可证先按包名排序逐行列出包名,空一行后是许可证正文;不同许可证之间用 80 个 - 分隔。在 TTY 上运行且日志级别不低于 1 时还会显示 progress: [oooo....] 样式的进度条。

六、错误语义与 CI 集成要点

  • 所有问题(找不到 LICENSE 文件、正文无法匹配任何已知许可证、根包缺少期望的版权头等)都被收集为 absl::Status 返回,最终汇总为退出码 1 并打印 Error count: N。也就是说,licenses_cpp 是非零即失败的门禁型工具。
  • 三级日志便于排查:--v=1 输出每个文件 OK:/EXCLUDE: 的判定,--v=2 输出 NOT_FOUND 与具体原因,--v=3 起还会打印未能归属到 LICENSE 文件等中间信息;注释原文默认在 --v=4 打印(见 ProcessSourceCode 中的 VLOG 分级)。
  • 单文件模式(--input)与 --include_filter 互斥;临时想看某个第三方文件能否被识别,推荐用单文件模式快速试错。

七、局限与注意事项

从源码结构可以看到一些约定与限制,使用前值得留意:

  • 全部数据(include/exclude 正则、许可证模板)都存放在 data/ 内,判定能力完全取决于该目录的完备程度;新增一种第三方许可证时需要同时登记模板并保证其 unique 正则不与既有条目冲突。
  • include/exclude 的正则必须是完整匹配,且 # 仅支持行首注释(data/README.md 明确写到 trailing # 注释暂不支持)。
  • secondary 目录必须与 working_dir 结构一致,否则会报 secondary license path mismatch 错误。
  • 命令中的相对路径(../../bin/et../../../out/...)均以 engine/src/flutter/tools/licenses_cpp 为基准,实际执行前请把该目录下的 engine/out 换成你本地 Engine 检出(flutter/engine)中的对应位置,并保证已按 Engine 开发文档完成 gclient 同步与本机工具链准备。

参考文件索引

登录后查看全文
热门项目推荐
相关项目推荐