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.pypython文件即可。

注意:运行需要Xcode环境,并且用brew安装好sourcekitten

posted @ 2022-12-28 16:12  rome753  阅读(714)  评论(0编辑  收藏  举报