Swift自动生成UML类图
1. 方案调研
swift-syntax
github - https://github.com/apple/swift-syntax
这是苹果官方的工具,Xcode工具包里有,可以扫描Swift源文件,生成AST树(抽象语法树)
用法也很简单,命令行输入
xcrun swiftc -frontend -emit-syntax ./a.swift | python3 -m json.tool
a.swift是创建的测试文件
class Demo {
var a;
func foo() {
}
}
生成如下
{
"kind": "SourceFile",
"layout": [
{
"kind": "CodeBlockItemList",
"layout": [
{
"kind": "CodeBlockItem",
"layout": [
{
"kind": "ClassDecl",
"layout": [
null,
null,
{
"tokenKind": {
"kind": "kw_class"
},
"leadingTrivia": "",
"trailingTrivia": " ",
"presence": "Present"
},
{
"tokenKind": {
"kind": "identifier",
"text": "Demo"
},
"leadingTrivia": "",
"trailingTrivia": " ",
"presence": "Present"
}, // 下面省略几百行
可以看到,AST树的嵌套层级非常深,即使代码非常简单,结果也很复杂。因为它是官方工具,要非常完备地分析文件,但是对于UML类图来说并不需要。
swift-ast-explorer
github - https://github.com/SwiftFiddle/swift-ast-explorer
测试地址 - https://swift-ast-explorer.com/
这是把swift代码生成AST树的网页,也是用swift-syntax生成的
swift-auto-diagram
github - https://github.com/yoshimkd/swift-auto-diagram
它可以把目录下的swift文件生成UML类图,自动生成一个网页,可以缩放拖动。只需要输入一句命令行(YourSwiftDir代表包含swift文件的目录)
ruby generateEntityDiagram.rb ~/YourSwiftDir
它没有用AST,而是直接用正则表达式扫描分析文件中的类、对象和方法,优点是速度快,缺点是生成结果摆放非常乱,类型关系基本找不到。但是它使用的vis-network网页绘图方案很不错,支持任意摆放、拖动、缩放和添加箭头。
SourceKitten
github - https://github.com/jpsim/SourceKitten
Sourcekitten 是基于 Apple 的 SourceKit 封装的命令行工具,SourceKitten 链接并与 sourcekitd.framework 通信以解析 Swift AST 树,最终提取 Swift 或 ObjC 文件的类结构和方法等。
2. 实现方案
根据前面的研究,我采用Sourcekitten解析Swift文件,vis-network生成类图的方案。效果如下:
- 右边是类图主体,包括类名和类里面所有的属性和方法。类之间的关系:继承、实现、关联和依赖分别用不用的线段和箭头表示。
- 左边是文件夹的树状结构,可以点击隐藏和显示文件夹里的全部或单个文件,这样就不会因为类太多显示太乱。
- 左上角是继承、实现、关联和依赖四种关系线段的开关,也可以点击隐藏或显示某种关系。
2.1 解析Swift文件
主要是用python遍历文件夹并调用'sourcekitten structure --file '
解析文件,然后生成json文件,供网页读取。
遍历文件中的类,取出类名、父类名、属性类名和方法的参数返回值类名
def visitFile(path):
structure = os.popen('sourcekitten structure --file ' + path).read()
if printStructrue:
print(structure)
try:
dict = json.loads(structure)
except Exception as e:
print('Exception in file ' + path)
print(e)
return
for sub in dict['key.substructure']:
kind = sub['key.kind']
validKinds = ['source.lang.swift.decl.class', 'source.lang.swift.decl.struct', 'source.lang.swift.decl.protocol', 'source.lang.swift.decl.enum']
if kind not in validKinds:
continue
varDetails = []
funcDetails = []
print('-visit: ' + name)
if 'key.inheritedtypes' in sub: # ParentClass and protocols
i = 0
for s in sub['key.inheritedtypes']:
i += 1
name = s['key.name'] # ParentClass
if i == 1:
j = name.find('<') # ParentClass<T1, T2>
if j != -1:
arr = name[j+1:-1].split(', ')
for a in arr:
variables.append(a)
name = name[:j]
parents.append(name)
else:
protocols.append(s['key.name'])
if 'key.substructure' in sub: # class members
for s in sub['key.substructure']:
if s['key.kind'].startswith('source.lang.swift.decl.var'): # .instance/.static/.class
type = s['key.kind'].split('.')[-1]
type = type if type != 'instance' else ''
typename = '' if 'key.typename' not in s else s['key.typename']
if typename != '':
if type == '':
variables.append(typename)
else:
temporaries.append(typename)
s1 = '' if typename == '' else ': ' + typename
s2 = '' if type == '' else ' (' + type + ')'
varDetails.append('- ' + s['key.name'] + s1 + s2)
if s['key.kind'] == 'source.lang.swift.expr.call':
name = s['key.name']
i = name.find('.')
if i != -1: # MyClass.staticFunc
name = name[:i]
variables.append(name)
if s['key.kind'].startswith('source.lang.swift.decl.function.method'): # .instance/.static/.class
type = s['key.kind'].split('.')[-1]
type = type if type != 'instance' else ''
typename = '' if 'key.typename' not in s else s['key.typename']
if typename != '':
temporaries.append(typename)
s1 = '' if typename == '' else ': ' + typename
s2 = '' if type == '' else ' (' + type + ')'
funcDetails.append('+ ' + s['key.name'] + s1 + s2)
visitMethod(s, temporaries)
if s['key.kind'] == 'source.lang.swift.decl.enumcase':
varDetails.append('.' + s['key.substructure'][0]['key.name'])
s1 = '\n'.join(varDetails)
s2 = '\n'.join(funcDetails)
data['detail'] = '\n-------------------------\n'.join([data['name'], s1, s2])
递归遍历方法,取出方法里使用到的局部变量的类名
def visitMethod(sub, temporaries):
if 'key.substructure' in sub:
for s in sub['key.substructure']:
if s['key.kind'] == 'source.lang.swift.decl.var.parameter':
if 'key.typename' in s:
temporaries.append(s['key.typename'])
if s['key.kind'] == 'source.lang.swift.decl.var.local':
if 'key.typename' in s:
temporaries.append(s['key.typename'])
if s['key.kind'] == 'source.lang.swift.expr.call':
name = s['key.name']
i = name.find('.')
if i != -1: # MyClass.staticFunc
name = name[:i]
temporaries.append(name)
visitMethod(s, temporaries)
打开浏览器,启动一个简单的服务器(因为要访问json文件,所以要访问服务器的网页。我开发用的是VSCode和Live Preview插件,非常方便)。
webbrowser.open('http://localhost:8080/diagram.html')
os.system('python3 -m http.server 8080')
2.2 网页展示
用JavaScript读取json文件,生成vis-network需要的点node和线段edge就可以展示了。
读取json文件
function readTextFile(path) {
var rawFile = new XMLHttpRequest();
rawFile.open("GET", path, false);
rawFile.onreadystatechange = function ()
{
if(rawFile.readyState === 4)
{
if(rawFile.status === 200 || rawFile.status == 0)
{
var allText = rawFile.responseText;
handleJsonStr(path, allText);
}
}
}
rawFile.send(null);
}
生成目录树
function generateTree(obj) {
if (typeof obj == 'string') {
return `<div id='file'>${obj}</div>`
} else {
var str = `<div id='dir'>${obj['name']}</div><ul>`
for (var o of obj['list']) {
str += '<li>' + generateTree(o) + '</li>'
}
return str + '</ul>'
}
}
生成类图
function handleDataJson(dataArr) {
var nodeArr = []
var edgeArr = []
var nodeTypes = ['class','struct','protocol','enum']
var edgeTypes = ['parents','protocols','variables','temporaries']
generateCheck(edgeTypes)
var nameIdDict = {}
for (var data of dataArr) {
nameIdDict[data['name']] = data['id']
}
for (var data of dataArr) {
var node = createNode(data['id'], data['detail'], data['kind'], data['file'])
nodeArr.push(node)
var from = data['id']
for (var type of edgeTypes) {
for (var to of data[type]) {
to = nameIdDict[to]
if (to != undefined) {
var edge = createEdge(from, to, type)
edgeArr.push(edge)
}
}
}
}
let nodes = new vis.DataSet(nodeArr)
let edges = new vis.DataSet(edgeArr)
const nodesFilter = (node) => {
if (hideFiles[node.file] == true) {
return false
}
return true;
};
const edgesFilter = (edge) => {
if (hideEdges[edge.type] == true) {
return false
}
return true;
};
nodesView = new vis.DataView(nodes, { filter: nodesFilter });
edgesView = new vis.DataView(edges, { filter: edgesFilter });
// create a network
var container = document.getElementById("mynetwork");
var data = {
nodes: nodesView,
edges: edgesView,
};
var options = {
physics: createPhysicsConfig(),
// layout: {
// hierarchical: {
// direction: 'Up-Down',
// },
// },
};
var network = new vis.Network(container, data, options);
}
3 代码和运行
完整代码都在github仓库
运行方法:下载或克隆仓库,进入主目录,运行命令即可
python3 runSwift.py /YourSwiftProjectDir
调式方法:用VSCode(安装Python插件和Live Preview插件)打开项目,运行runSwift.py
python文件即可。
注意:运行需要Xcode环境,并且用brew安装好sourcekitten