Graphviz 的主要特点和用途包括:
1.灵活的渲染功能:Graphviz 可以生成多种格式的图形文件,包括 raster 和 vector 格式,如 PNG、PDF、SVG 等。
2.自动布局:Graphviz 的一个主要特点是其自动布局能力。用户只需定义图的元素和它们之间的关系,Graphviz 就能够自动计算出合适的布局。
3.扩展性:Graphviz 提供了多种工具和库,可以用于各种应用,如 Web 服务、生成报告,或与其他软件的集成。
4.广泛的应用:Graphviz 被广泛用于各种领域,包括软件工程(如代码依赖关系图)、网络设计和分析、生物信息学(如基因表达网络)等。
name: moudule_a version: 1.0.0 environment: sdk: '>=2.17.0 <3.0.0' dependencies: path: ^1.8.0 module_b: path: module_b可以看出组件 A 依赖了组件 B(module_b)和其他 SDK。 组件 B 的依赖关系如下:
name: moudule_b version: 0.0.1 environment: sdk: '>=2.17.0 <3.0.0' dependencies: meta: 1.7.04.1 我们要实现的效果
/// 基于 .yaml 文件生成 Pubspec 类 Pubspec rootPubspec() { assert( Directory(rootPackageDir).existsSync(), '`$rootPackageDir` does not exist.', ); // 获取 pubspec.yaml 文件路径 final pubspecPath = p.join(rootPackageDir, 'pubspec.yaml'); // 将 pubspec.yaml 文件转换成 Pubspec 对象 return Pubspec.parse( File(pubspecPath).readAsStringSync(), sourceUrl: Uri.parse(pubspecPath), ); }
final commandArgs = _pubCommand(['deps', '-s', 'list']); /// 执行 flutter pub deps -s list 命令,获取命令行执行结果 String _pubCommand(List<String> commandArgs) { final proc = _isFlutterPkg ? 'flutter' : 'dart'; final args = [ ...['pub'], ...commandArgs ]; final result = Process.runSync( proc, args, runInShell: true, workingDirectory: rootPackageDir, ); return result.stdout as String; }解析出来的结果如下所示:
Dart SDK 2.17.0 Flutter SDK 3.0.0 module_a 1.0.0 dependencies: path 1.8.3 module_b 0.0.1 meta 1.7.0 transitive dependencies: meta 1.7.0
/// pubspec为5.1.1中获取主工程依赖 final pubspec = rootPubspec(); /// 主工程的依赖信息 转 VizPackage VizPackage rootPackage = VizPackage( pubspec.name, null, Dependency.getDependencies( pubspec, includeDevDependencies: !productionDependenciesOnly, ), null, ); /// Pubspec 转 Dependency static Set<Dependency> getDependencies( parse.Pubspec pubspec, { bool includeDevDependencies = true, }) { // 依赖关系结果集 final deps = <Dependency>{}; // 堆代码 duidaima.com // 正式依赖:对应 pubspec.yaml 中的 dependencies _populateFromSection(pubspec.dependencies, deps, false); if (includeDevDependencies) { // 开发依赖:对应 pubspec.yaml 中的 dev_dependencies _populateFromSection(pubspec.devDependencies, deps, true); } return deps; }5.2.1.2 组件依赖数据结构转换
// 匹配一个包的名字的正则表达式 const _identifierRegExp = r'[a-zA-Z_]\w*'; // 匹配允许的软件包名称的正则表达式 const _pkgName = '$_identifierRegExp(?:\\.$_identifierRegExp)*'; /// Section头匹配正则,例如:dependencies: final _sectionHeaderLine = RegExp(r'([a-zA-Z ]+):\n'); /// 一级包依赖匹配正则,例如:http 0.13.4 final _usageLine = RegExp('- ($_pkgName) (.+)\n'); /// 二级包依赖匹配正则,例如:async ^2.5.0 final _depLine = RegExp(' - ($_pkgName) (.+)\n'); /// scanner就是4.1.2中获取到的所有组件依赖关系的字符串 MapEntry<String, Map<VersionedEntry, Map<String, VersionConstraint>>> _scanSection(StringScanner scanner) { /// 开始匹配Section头 scanner.expect(_sectionHeaderLine, name: 'section header'); final header = scanner.lastMatch![1]!; /// 依赖关系结果集,key: 一级包信息,value: 二级包依赖信息 final entries = <VersionedEntry, Map<String, VersionConstraint>>{}; void scanUsage() { /// 开始匹配一级包依赖 scanner.expect(_usageLine, name: 'dependency'); final entry = VersionedEntry.fromMatch(scanner.lastMatch!); assert(!entries.containsKey(entry.name)); final deps = entries[entry] = {}; /// 开始匹配二级包依赖 while (scanner.scan(_depLine)) { deps[scanner.lastMatch![1]!] = VersionConstraint.parse(scanner.lastMatch![2]!); } } do { scanUsage(); } while (scanner.matches(_usageLine)); return MapEntry(header, entries); }至此,组件间依赖的数据结构被转换成了 DepsList 类型。但是主工程的依赖是 VizPackage 类型。二者数据结构不同,仍然不能直接合并。接下来需要对就需要再将 DepsList 转换为 VizPackage 类型。由于 DepsList 中的 sections 存储了所有组件的依赖关系,下面展示一下如何将单个 section 转换为 VizPackage。全部转换只需遍历调用即可。
/// 将DepsList中,sections中单个元素的格式转换为 VizPackage VizPackage addPkg(VersionedEntry key, Map<String, VersionConstraint> value) { final pkg = VizPackage( key.name, key.version, SplayTreeSet.of( value.entries .where((element) => !_ignoredPackages.contains(element.key)) .map( (entry) => Dependency(entry.key, entry.value.toString(), false), ), ), flagOutdated ? _latest(key.name) : null, ); return pkg; }5.2.2 依赖合并
/// 获取主工程依赖关系 Pubspec rootPubspec() { // ...参考 5.1.1 章节代码 } /// 获取所有组件的依赖关系 /// ...参考 5.1.2 章节代码 DepsList rootDeps() { final commandOutput = _pubCommand(['deps', '-s', 'list']); return DepsList.parse(commandOutput); } /// 所有依赖关系合并 Future<Map<String, VizPackage>> getReferencedPackages( bool flagOutdated, bool directDependenciesOnly, bool productionDependenciesOnly, ) async { /// 1. 创建最后的依赖关系结果集 final map = SplayTreeMap<String, VizPackage>(); /// 2.1 获取主工程依赖关系 final deps = rootPubspec(); /// 2.2 主工程的依赖关系转换 rootPackge = VizPackage( pubspec.name, null, Dependency.getDependencies( pubspec, includeDevDependencies: !productionDependenciesOnly, ), null, ); /// 2.3 主工程的依赖关系保存 map[rootPackge.name] = rootPackge; /// 3.1 获取组件间依赖关系 final deps = rootDeps(); /// 3.2 组件间依赖关系转换并保存 addSectionValues(deps.sections['dependencies'] ?? const {}) /// 3.2.1 遍历组件间依赖,转换依赖关系 void addSectionValues( Map<VersionedEntry, Map<String, VersionConstraint>> section, ) { for (var entry in section.entries) { addPkg(entry.key, entry.value); } } /// 3.2.2 将依赖关系转换为 VizPackage,并保存 void addPkg(VersionedEntry key, Map<String, VersionConstraint> value) { final pkg = VizPackage( key.name, key.version, SplayTreeSet.of( value.entries .where((element) => !_ignoredPackages.contains(element.key)) .map( (entry) => Dependency(entry.key, entry.value.toString(), false), ), ), flagOutdated ? _latest(key.name) : null, ); map[pkg.name] = pkg; } return map; }至此,两部分依赖关系被转换成为了同一个数据结构,并合并到了一起。接下来只需要将最终结果转换成 DOT 就可以愉快地拿去可视化了。
dependencies: gviz: ^0.4.0具体的转换逻辑封装到了toDot方法中:
import 'package:gviz/gviz.dart'; /// 将依赖关系结果集转换成 dot 文本 /// [packages] 项目依赖关系结果集 /// [ignorePackages] 需要忽略的 package 名称 String toDot( Map<String, VizPackage> packages, { bool escapeLabels = false, Iterable<String> ignorePackages = const [], }) { // 初始化 Gviz,设置绘制属性 final gviz = Gviz( name: 'demo', graphProperties: {'nodesep': '0.2'}, edgeProperties: {'fontcolor': 'gray'}, ); for (var pack in packages.values.where((v) => !ignorePackages.contains(v.name))) { gviz.addBlankLine(); _writeDot(pack, gviz, 'demo', escapeLabels, ignorePackages); } return gviz.toString(); } // 绘制点和连线 void _writeDot( VizPackage pkg, Gviz gviz, String rootName, bool escapeLabels, Iterable<String> ignorePackages, ) { final isRoot = rootName == pkg.name; final newLine = escapeLabels ? r'\n' : '\n'; // 模块展示内容:名称+版本号 var label = pkg.name; if (pkg.version != null) { label = '$label$newLine${pkg.version}'; } final props = {'label': label}; // ...设置字体,间距等样式,此部分代码省略 // 追加节点 gviz.addNode(pkg.name, properties: props); final orderedDeps = pkg.dependencies.toList(growable: false)..sort(); for (var dep in orderedDeps.where((d) => !ignorePackages.contains(d.name))) { if (!dep.isDevDependency || isRoot) { final edgeProps = <String, String>{}; // 连线展示内容 if (!dep.versionConstraint.isAny) { edgeProps['label'] = '${dep.versionConstraint}'; } // ...设置字体,间距等样式,此部分代码省略 if (dep.name == rootName) { // 如果一个包依赖于根节点,它不应该影响布局 edgeProps['constraint'] = 'false'; } // 绘制连线 gviz.addEdge(pkg.name, dep.name, properties: edgeProps); } } }以前面的 Demo 工程为例,输出的 .dot 文件内容如下:
digraph demo { graph [nodesep="0.2"]; edge [fontcolor=gray]; meta [label="meta 1.7.0", shape=box, margin="0.25,0.15"]; module_a [label=module_a, fontsize="18", style=bold, shape=box, margin="0.25,0.15"]; module_a -> module_b [label="<empty>", penwidth="2"]; module_a -> path [label="^1.8.0", penwidth="2"]; module_b [label="module_b 0.0.1", shape=box, margin="0.25,0.15", style=bold]; module_b -> meta [label="1.7.0"]; path [label="path 1.8.3", shape=box, margin="0.25,0.15", style=bold]; }5.3 绘制可视化关系图
// 堆代码 duidaima.com brew install graphviz执行 dot 命令输出依赖关系图:
dot x.dot -T png -o x.png至此我们已经可以从一个工程中,分析依赖,并得到了一张清晰明了的依赖关系图。 例如,Demo工程的依赖关系图如下:
通过前面的分析可知,gviz 分别用了 2 种不同的方式来解析主工程和子组件工程的依赖清单,并且解析结果的数据结构也不一致,需要额外进行合并操作,经实测,统一采用同一种方式解析就可以实现,个人更推荐第二种 (命令行方式) 。作者做 yaml 文件解析目的主要是为了获取主工程 pubsepc.yaml 文件配置信息,便于后续绘制依赖关系图能区分出主工程做一些特殊处理。
#自定义的yaml文件节点 dependency_rules: # 需要统计的组件清单 include: - search - chat - ...在解析依赖树时,用此清单(白名单)进行过滤,这样,用于生成dot文件的组件清单全部在白名单内。
#自定义的yaml文件节点 dependency_rules: # 需要统计的路径 include: - plugins/** - packages/common/**在 include 指定的路径下扫描出所有工程的 pubspec.yaml 文件,并解析出所有的组件名(库名)列表,也就是自动生成白名单,剩下的跟方法一相同。
#自定义的yaml文件节点 dependency_rules: # 需要统计的路径 include: - plugins/** - packages/common/** # 不需要统计的路径 exclude: - example/** - plugins/**/example/知其然,知其所以然,才能做到化繁为简,更好的结合和服务自身的业务。经过过滤之后,我们得到的依赖关系图就可以是下面这样的了: