首页
/ Flutter Engine 许可证合规工具 licenses_cpp 完全指南:构建、运行与数据格式解析

Flutter Engine 许可证合规工具 licenses_cpp 完全指南:构建、运行与数据格式解析

2026-09-07 10:49:49作者:范靓好Udolf

licenses_cpp 是 Flutter Engine 仓库内置的一套用 C++ 编写的许可证收集与校验工具,在 CI 中运行,用于扫描引擎全部源码与第三方依赖,逐一核对版权声明(copyright header)与独立许可证文件,最终汇总生成 licenses.txt。本文以该工具的 README 与配套的 GEMINI.md 为骨架,结合 src/ 下的真实实现与 data/ 数据文件,说明如何本地构建、跑测试、对单个文件做校验,并深入讲解数据目录的每一种格式与底层工作流。


一、工具定位:CI 中的许可证“守门人”

Flutter Engine 的代码库包含大量第三方依赖(ICU、libjpeg、HarfBuzz、v8、inja 等),每个都带有各自的许可协议。为了满足合规要求,引擎需要:

  1. 确认每个源码文件头部都带有可识别的版权/许可证注释;
  2. 确认每个第三方包的 LICENSE 文件属于已知、可接受的许可证集合;
  3. 把扫描到的许可证文本按“包名 → 许可证全文”的形式统一输出。

licenses_cpp 就是这个流程的可执行实现。它在 CI 阶段被调用,收集并校验源码许可证,发现未知许可证或缺失版权头时会以非零退出码报告错误(实现见 src/license_checker.ccRun/FileRunreturn errors.empty() ? 0 : 1;)。

BUILD.gn 可以清晰看到整个工具由四个 GN target 组成:

Target 类型 说明
//flutter/tools/licenses_cpp source_set 工具核心库(解析、匹配、过滤等模块),公开依赖 re2 与 absl(log、flags、status)
//flutter/tools/licenses_cpp:licenses_cpp executable 基于 src/main.cc 的命令行入口
//flutter/tools/licenses_cpp:license_file_compare executable 独立的许可证文件比对工具(由 src/license_file_compare.cc + src/mmap_file.cc 组成)
//flutter/tools/licenses_cpp:licenses_cpp_testrunner executable(testonly) 基于 googletest 的单元测试集

除代码之外,目录中还有 tools/convert.dart(历史数据转换脚本)、CODEOWNERS 以及承载全部校验规则与样例的 data/ 目录。


二、目录结构与工程布局

engine/src/flutter/tools/licenses_cpp/
├── BUILD.gn                 # 上述四个 GN target 定义
├── CODEOWNERS
├── GEMINI.md                # 本地构建与运行指引
├── README.md                # 工具一句话说明(入口文档)
├── data/                    # 校验所需的数据文件
│   ├── README.md            # 数据格式规范(本文第四节详述)
│   ├── include.txt          # 需要检查的文件路径正则清单
│   ├── exclude.txt          # 需要排除的文件路径正则清单
│   ├── licenses/            # 已接受的许可证“模板库”(约 80 个)
│   └── secondary/           # 需原样加入输出的补充许可证
├── src/                     # C++ 实现源码
│   ├── main.cc              # 命令行入口,解析 absl flags
│   ├── license_checker.cc/.h    # 核心扫描/校验/汇总逻辑
│   ├── catalog.cc/.h        # 许可证两级匹配目录(selector + matcher)
│   ├── comments.cc / comments.l/.h  # 源码注释提取(flex 词法)
│   ├── data.cc/.h           # data/ 目录的内存表示
│   ├── deps_parser.cc/.h    # 解析 DEPS 文件以补扫非 git 依赖
│   ├── filter.cc/.h         # include/exclude 正则过滤器
│   ├── mmap_file.cc/.h      # 内存映射文件读取(mmap)
│   └── license_file_compare.cc  # license 文件两两比对入口
└── tools/convert.dart
  • src/:全部工具实现代码;
  • data/:工具运行所需的数据,例如各类正则规则与许可证模板。

核心库 licenses 将“文件读取(mmap)”“注释提取(flex 词法)”“正则过滤(re2)”“许可证匹配(Catalog)”“包归属判定(GetPackage)”拆分为独立模块,每个模块都有对应的 *_unittests.cc 测试,代码组织非常便于单点验证。


三、本地构建:编译测试与校验工具

licenses_cpp 是 Flutter Engine monorepo 的一部分,需通过仓库内的 et(engine tool)构建。文档 GEMINI.md 给出的命令都假定当前 shell 已位于 engine/src/flutter/tools/licenses_cpp 目录。

3.1 构建并运行单元测试

# 1) 在仓库根目录执行构建(从工具目录向上两级进入 flutter/,再取 bin/et)
../../bin/et build --no-rbe -c host_debug_unopt_arm64 //flutter/tools/licenses_cpp:licenses_cpp_testrunner

# 2) 运行测试二进制(输出位于 engine/src/out/host_debug_unopt_arm64/ 下)
../../../out/host_debug_unopt_arm64/licenses_cpp_testrunner

拆解:

  • ../../bin/et build:调用引擎构建工具;
  • --no-rbe:不使用远程构建执行(本地编译);
  • -c host_debug_unopt_arm64:选择构建配置(此处为 host 端、debug、未优化、arm64 目标);
  • //flutter/tools/licenses_cpp:licenses_cpp_testrunner:编译含全部单元测试的 testonly target。

测试集对应 BUILD.gnlicenses_cpp_testrunner 的 5 个源文件:catalog_unittests.cccomments_unittests.ccdeps_parser_unittests.ccfilter_unittests.cclicense_checker_unittests.cc,分别覆盖“许可证目录匹配”“注释提取”“DEPS 解析”“正则过滤”“整体校验”五个子系统。

提示:GEMINI.md 注明主工具最好以 profile 配置构建(下文示例使用 host_profile_arm64),以贴近真实性能场景。

3.2 对单个文件执行许可证检查

想快速验证“某一条规则/某一个文件是否命中”,不必全库扫描,工具提供了 --input 单文件模式:

../../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

其中各参数含义(与 src/main.ccABSL_FLAG 定义一一对应):

参数 必填 作用
--working_dir 要扫描的目录(第 21 行注释明确标注 [REQUIRED]
--data_dir 存放许可证模板的目录,即本工具的 data/
--licenses_path 许可证汇总结果的写出路径(会以 ofstream 打开,失败即报错退出)
--input 选填 只扫描指定文件;与 --include_filter 互斥(同时给出会报错退出)
--include_filter 选填 覆盖默认 include 过滤规则的正则
--v 选填 日志详细级别(整数,默认 0;示例 --v=3 输出更细的 VLOG)
--treat_unmatched_comments_as_errors 选填 是否把“无法匹配的版权注释”视作错误(bool,默认 false)
--root_package 选填 根包名(覆盖默认从 working_dir 目录名推导的包名)

以示例命令中的路径为例:从 tools/licenses_cpp/ 出发,--working_dir ../.. 指向 engine/src/flutter--input ../../third_party/... 实际指向 engine/src/third_party/icu/...,即扫描范围被收窄到 flutter 代码树及其依赖。

main.cc 的启动逻辑值得一看(第 78–137 行):先 absl::ParseCommandLine 解析参数并设置全局 VLOG 级别,然后校验三个必填 flag——缺失任何一个都会在 stderr 打印 Expected --xxx flag. 并返回退出码 1;随后若提供 --inputLicenseChecker::FileRun(单文件),否则走整库 LicenseChecker::Run;全程通过 std::ofstream 把扫描结果写入 licenses_path


四、数据目录规范:include / exclude / catalog / secondary

真正决定“检查什么、接受什么”的,是 data/ 下的四类文件。官方规范见 data/README.md,原文明确指出所有正则一律采用 re2 语法

4.1 include.txt:决定哪些文件参与检查

include.txt 列出将要接受检查的文件,格式为每行一条正则,必须整行完全匹配才纳入检查。# 开头的行是注释;行尾注释(同一行其他字符后跟 #)暂不支持。

以仓库真实的 data/include.txt 为例:

# This file describes all the files we need to do a copyright header check on.
.*\.c$
.*\.cc$
.*\.cpp$
.*\.cxx$
.*\.dart$
.*\.frag$
.*\.glsl$
.*\.go$
.*\.h$
.*\.hh$
.*\.hpp$
.*\.hxx$
.*\.java$
.*\.js$
.*\.kt$
.*\.m$
.*\.mm$
.*\.py$
.*\.rb$
.*\.S$
.*\.sh$
.*\.swift$
.*\.ts$
.*\.txt$
.*\.ucm$
.*\.vert$
.*\.y$
^flutter/ci/licenses_golden/third_party/fuchsia_sdk/NOTICES$
^flutter/third_party/libjpeg-turbo/src/README\.ijg$

可以看到:常规规则按文件扩展名全匹配(.c/.cc/.cpp/.../.dart/.h/.../.kt/.mm/.py/.swift/.ts/.txt 等),末尾还以 ^...$ 锚定方式特例包含 fuchsia_sdk/NOTICESlibjpeg-turboREADME.ijg 两个非扩展名文件。实现层面对应 src/filter.h:一个 Filter 本质是把若干正则拼接成单个 RE2Matches() 判断输入是否命中。

4.2 exclude.txt:剔除不需要检查的文件

exclude.txtinclude.txt 同格式。关键约束:这些正则必须对“相对 --working_dir 的路径”做整行全匹配。执行时二者叠加使用——license_checker.ccProcessFile 中先判断 !include_filter.Matches(...) || exclude_filter.Matches(...),命中任一条件即打 EXCLUDE 日志并跳过该文件。

4.3 data/licenses/:可接受许可证的匹配模板库

data/licenses/(内存中由 Data.catalog 承载)存放的是“被认可、可出现在源码头或独立 LICENSE 文件中的许可证”,每个文件就是一种 matcher 条目,文件格式为三部分:

  1. 第一行:匹配器名称(例如 apache2 headermitbsd3);
  2. 第二行unique 正则——用于快速挑选候选 matcher,且不能与其他 matcher 的 unique 正则重叠;
  3. 其余行:抽取该许可证完整正文的 matcher 正则,规则如下:
    • 所有空白一律按 \s+ 处理;
    • 行尾空白被忽略;
    • 匹配出的分组会从输出中抽取,例如对 \[(.*)\] 匹配 [hi],得到 [](即捕获组之外的结构会保留空文本、捕获内容进入正文)。

以仓库真实的 data/licenses/apache2_header.txt 为例:

apache2 header
(?s)^\s*Copyright[^\n]*\d\d\d\d.*Licensed under the Apache License, Version 2\.0.*See the License for the specific language
(?s)^\s*Copyright[^\n]*\d\d\d\d.*

Licensed under the Apache License, Version 2.0 \(the "License"\);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

\s+http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

第一行是名称 apache2 header;第二行是 unique 正则(要求文本同时含带四位年份的 CopyrightApache License, Version 2.0See the License for the specific language);从第三行起为正文 matcher,各关键字间的实际间距、换行由 .*/空白容错覆盖。data/licenses/ 下同类文件还包括 apache2.txtbsd2_clause*.txtbsd3_clausemit*.txticu.txtharfbuzz*.txtsqlite.txtunicode.txtv8.txt 等约 80 个模板,基本覆盖引擎第三方依赖常见的 BSD/Apache/MIT/ISC/zlib/ICU 等许可文本变体。

实现层面 src/catalog.h 明确注释了设计动机:这是两级搜索——先用 RE2::Set 批量匹配 unique(selector)缩小候选集,再对命中的 matcher 做精匹配,从而“minimize the size of the RE2::Set”;当 selector 无命中时返回 kNotFound,多于一个时返回 kInvalidArgumentcatalog_unittests.cc 对该匹配行为做了专项覆盖。

4.4 data/secondary/:原样附加的补充许可证

secondary/ 目录结构必须与 working_dir 中被扫描树的结构逐级一致。这里存放的许可证文件会被原样(verbatim)写入输出,不经过模板匹配。真实的 data/secondary/ 下能看到形如 flutter/third_party/inja/third_party/include/nlohmann/loitsch.txtflutter/third_party/libcxx/boost.txt 的镜像路径——运行时会对每个 secondary 文件计算相对路径,若 working_dir 下不存在对应父目录,则报告 secondary license path mixmatch 错误(见 license_checker.cc 中对 data.secondary_dir 的处理)。这样能保证最终 licenses.txt 中附带一些“无独立源码可归属、但必须随包分发”的第三方许可文本。

4.5 include.txt 与 exclude.txt 实现细节

Filter::Openfilter.h)可接受文件路径或输入流两种来源——这正对应 main.cc 中 --include_filter 用一个字符串流构造自定义 Filter 以覆盖默认 include 规则的路径(main.cc 第 57–66 行),从而实现不修改数据文件即可定向收窄扫描范围。


五、源码级工作流:从整库扫描到 licenses.txt

了解了数据文件,再看核心执行器 src/license_checker.cc 是如何把它们串起来的。LicenseChecker::Run 的整体流程可归纳为 5 步:

  1. 发现 git 仓库GetGitRepos 递归遍历 working_dir,找出所有含 .git 的目录;若 working_dir 自身不在其中则补入,从而允许扫描 engine 这类子目录。随后对每个仓库执行 git ls-files 拿到被 git 跟踪的文件清单,再对每个文件调用 ProcessFile

  2. 补扫 DEPS 中的非 git 依赖:不是每个依赖都是独立 git 仓库。工具向上查找 DEPS 文件,用 deps_parser 解析其中的依赖路径,仅处理仍位于 working_dir 内的目录(越界依赖直接跳过),并对其做递归遍历。

  3. 确定文件归属的“包”GetPackage 沿文件所在路径向上逐级寻找“归属包”。它维护一个从根包名开始的推导过程——一旦路径中出现 third_party/,随后的下一级目录名就构成新包名,且遇到 pkgvulkan-depskThirdPartyIgnore)这类忽略目录时不产生包名拆分;同时用 FindLicense 沿路径向上查找最近一层的许可证文件。kLicenseFileNames 中登记了 9 个会被识别为许可证文件的常见命名(LICENSELICENSE.TXT/.txt/.md/.MITCOPYINGLicense.txtdocs/FTL.TXTREADME.ijg)。

  4. 逐文件校验(ProcessFile):先经 include/exclude 过滤;有归属许可证文件的包先执行 MatchLicenseFile——用 mmap 读文件内容并在 Catalog 中查找匹配,命中则登记(license_map->Add(package.name, match)),未命中产生 Unknown license in <路径> 错误。同一许可证文件用 seen_license_files 集合保证只校验一次。随后对正文做处理:

    • 文件名恰好是 NOTICES 时走 ProcessNotices,用固定分隔线(80 个 -)把文件切成“项目列表 + 许可证正文”区块,逐块匹配;
    • 其他文件走 ProcessSourceCode,通过 flex 词法器(comments.l)迭代提取注释,仅对含 license/copyright 字样的注释(正则 (?i)(license|copyright))尝试匹配;若整份文件连一条注释都没有,则把全文当作文本文件直接匹配。未匹配到版权时:根包文件报 Expected root copyright in ...,非根包若存在目录级许可证则放行,否则报 Expected copyright in ...
  5. 汇总输出LicenseMap 以许可证文本为 key、包名集合为 value 做倒排去重;LicensesWriter 把包名按字典序排序后,以“80 个 - 分隔线 + 包名列表 + 空行 + 许可证全文”的块结构写入输出流,即生成标准 licenses.txt。全量扫描时终端还会打印 progress: [ooo....] 形式的 ASCII 进度条(仅当 stdout 是终端且 VLOG 未开启时)。

值得注意的性能与工程细节:文件读取统一走 mmap_file.cc 的内存映射而非流式读入,以支撑全库数万文件的扫描;错误通过 absl::Status 收集后统一打印,main.cc 依 errors.empty() 决定进程退出码,这正是 CI 失败判定的依据。


六、输出格式与 CI 判定

--licenses_path 指向的结果文件具备稳定可解析的结构。每次写入一个块时,LicensesWriter 会:

----------------------------------------------------------------------------
<包名A>
<包名B>                    # 同一份许可证的多个包,按字典序排列

<许可证全文>              # 来自 Catalog 匹配抽取的正文,或 secondary 的原样内容

多个“许可证文本完全相同”的第三方包会共享同一个块,避免重复输出;文本不同的包各自成块。无论扫描单文件还是整库,Run/FileRun 都遵循同一输出与退出码约定,CI 只需检查退出码与生成的 licenses.txt 是否与 golden 一致即可完成合规门禁。


七、单元测试矩阵与回归保障

为保证 CI 门禁可靠,BUILD.gn 的 testrunner 集合了 5 组 googletest 用例,各组职责与校验焦点如下:

测试文件 验证对象 典型场景
filter_unittests.cc include/exclude 正则过滤器 多正则拼接、全匹配语义、注释行跳过
comments_unittests.cc flex 注释词法器 各语言注释形态的提取边界
deps_parser_unittests.cc DEPS 文件解析 依赖路径切分与过滤
catalog_unittests.cc 许可证模板两级匹配 unique 选择器命中/冲突、正文抽取、分组规则
license_checker_unittests.cc 端到端扫描流程 整目录/单文件运行、错误收集与退出码

开发者新增一种许可证模板(例如引入新的第三方库)时,标准流程是:把模板按“名称 + unique 正则 + 正文正则”格式写入 data/licenses/,在 include.txt 补齐需要检查的文件类型(如需),必要时在 secondary/ 镜像添加原样分发的许可文本,最后跑一遍第三节的 testrunner 与单文件命令,确认 golden 与错误计数都符合预期后再提交。


八、实践小结

  • 何时用单文件模式:调规则、debug 某文件为何报 Expected copyright 时,用 --input ... --v=3 把 VLOG 开到 3,可看到 Process:EXCLUDE:OK:NOT_FOUND: 等逐文件判定日志;
  • 何时用整库模式:提交/升级第三方依赖后,以 --working_dir 指向引擎树、不传 --input 全量扫描,确认退出码为 0 且 licenses.txt 内容正确;
  • 数据文件是核心资产include.txt/exclude.txt 决定扫描边界,data/licenses/ 的每个模板文件决定“接受哪些许可文本”,secondary/ 决定“哪些许可必须随包原样分发”——三者共同构成引擎许可证合规的策略面,而 src/ 中 mmap 读取、flex 注释提取、re2 两级匹配与 LicenseMap 汇总输出则构成高性能执行面。这套“数据与逻辑分离”的设计,也使其规则可测试、可审计、易维护。

进一步阅读:数据格式权威说明见 data/README.md,构建 target 一览见 BUILD.gn,命令行 flag 的完整定义见 src/main.cc,扫描主流程见 src/license_checker.cc

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