白嫖一个优雅的类图生成工具

7,010 阅读15分钟

前言

首先必须声明,这不是一篇广告或者标题党。 而是我开源了一个工具,可以优雅的为Java or Kotlin 项目生成 Class Diagram

我推测列为读者会进来阅读,原因无非以下两点:

  • 获得一个生成类图的工具,并通过文章快速了解是否 方便好用
  • 了解一下我是如何折腾的

仅关心如何使用的,可以移步 使用示例

我们将按照下面的脑图顺时针展开,揭开这一工具的诞生过程

class-diagram-guide.png

主要问题与方案

背景

背景:笔者今年换了份工作,所在的公司属于 医疗器械 下的细分领域,而相比于 纯互联网行业 领域,医疗器械 领域所属的配套软件, 都有明确的文档要求,并非 可有可无 ,而且公司管理层比较重视细节(核心产品为颅内、体内植入的医疗器械,确实需要非常认真仔细)。

毋庸置疑,准确的关键的 算法流程图,时序图,组件图,状态图,类图等, 对于产品本身的维护及发展具有很大帮助!

对于研发工作者而言,高度概括流程、设计、算法等的专业工具图对工作有极大帮助。既然需要审核的文档中也需要这些内容,又对工作有帮助,何不做的好一些呢

上文提到的各类UML图中, 类图 Class-Diagram 是非常特殊的, 它表述的是 类之间的关系基于源码文件分析可以得出准确的结果。 而 流程图时序图状态图组件图 等则不行。

问题

随着行业的发展,软件开发也演变为 以迭代的方式,依次实现最重要的功能,持续性交付,顺其自然的,我们已经不再像几十年前的前辈们那样:代码未动,文档与UML图先行。 一般概要设计后,方案可行便进行编码了。

根据我的实际情况,复杂的功能一般在草稿纸上画画草图,简单的就脑子里想想,难以留下存档

在这一工作模式下,笔者也遇到了一些问题:

  • 业务迭代后或者代码改进后,文档(uml图)未及时更新
  • 手动维护耗时耗力

如果这件事情可以交给机器来做,那显然是极好的!而让机器维护类图是最容易实现的!

综上所述:我们需要一款工具或者插件,可以直接基于源码生成类图 (或者中间产物,例如:plant-uml文件) ,能够配合其他工具链,直接进行归档。

当然,最重要是免费, 这省去了说服公司进行购买

留有类图的好处:

  • 方便向他人介绍业务和代码
  • 项目庞大或者复杂时,更容易找到需求对应的关注点,重新维护时日久远的业务时,状态来的快
  • 图比代码亲切而且保护隐私🤣

解决方案

众所周知,Intellij-Idea的官方插件可以分析出类图,但是Idea是收费软件,付费支持官方插件是一个省时省力的方案,这是个兜底方案。最终没辙时,我们再考虑它。

编码时分析

仿照官方插件的思路,基于源码文档树进行分析,在Intellij的支持下,基于 PSI和uast 即可分析出类之间的关系。这需要一定的PSI、uast知识基础。

编译时分析

在整个编译环节中,有一些切面应对特定问题,例如:"注解处理" 、 "Gradle Transformer" ,在此切面处,我们可以基于编译中间产物,间接分析出类之间的关系。

最简单的是 注解处理阶段 介入,这只需要对 Element 和 TypeMirror 有一定的知识基础即可

运行时反射分析

显然这不是一个太好的切入点,直接pass。


考虑到PSI方面的知识体系掌握地不太完善,Intellij跨越大版本时,会有较大变更, 而注解处理方面的知识还过的了关,搞个类图生成问题不大。

PS: AndroidStudio 基于Intellij核心二次开发,PSI插件跟随Idea大版本进行适配

所以最终方案为:从注解处理阶段入手,分析编译中间产物,最终生成类图


问题分治与解决

分治1 -- 简化输出产物

确定了大方向之后,我们需要再思考下整个问题的方方面面。生成类图有两大问题需要解决:

  • 源码、或者编译的中间产物中分析出类关系; ps:我们已经确定了要从编译中间产物出发
  • 将类关系转变为图

显然,"开发一个用来生成图的引擎",这件事情成本过大且没有必要。所幸的是,UML不是一个新生物,业内也有大名鼎鼎的PlantUml。

PlantUml 基于 Graphviz , Graphviz 本身使用Dot语法描述元素与元素关系, 直接使用 Graphviz 比较朴素,PlantUml通过自定义语法,使得内容可阅读性提升,且无须关注转换图片时进行各类装饰问题

于是,我们可以将问题转化为:从编译的中间产物中分析出类关系,将关系按照PlantUml语法生成puml文件,它的内容是纯文本。

分治2 -- 确定分析的起始点

如果从最终结果看,我们得到的是一个 有方向的图,那么 按照图本身的起始点出发 比较符合习惯。

也就是说,我们将在起始点所对应的类上添加注解,作为注解处理的目标起始点

例如:

img.png

Cat 和 Dog 将作为起始点。

因为只需要标记类,我们约定注解:

@Target(AnnotationTarget.CLASS)
annotation class GenerateClassDiagram {}

在代码上,将表现为:

class Animal

@GenerateClassDiagram
class Dog : Animal()

@GenerateClassDiagram
class Cat : Animal()

在示例中,当我们处理 GenerateClassDiagram 时,可以扫描获得 Cat 以及 Dog 类对应的 javax.lang.model.element.Element 示例,下文简称 Element

几点可能存在的疑惑:

  • 为何不 "双向" 分析:继承和实现关系,双向分析会带来额外的复杂度,且在使用上规则不清晰,依赖关系难以双向分析。 但是,如果使用规则上可以做到清晰明了,这一点值得实现
  • 为何不标注在Animal上,进行反向分析: 如果高层级的类在库包中,则需要修改库包,这不利于日常管理与维护
  • 如果只标注了Cat而没有标注Dog,Dog将不会体现在图中?:是的
  • 如果全部标注了,是否产生不良影响 :不会,但是没有必要

分治3 -- 确定关系的分析方法

继承&实现

因为注解的标记对象是类 或者接口,我们理应得到 TypeElement,基于 Element 的访问者模式实现,这一点并不难。

public interface TypeElement extends Element, Parameterizable, QualifiedNameable {

    TypeMirror getSuperclass();

    List<? extends TypeMirror> getInterfaces();

    //其他无关代码略去
}

不言自明,我们可以通过 TypeMirror getSuperclass(); 得到继承关系,通过 List<? extends TypeMirror> getInterfaces(); 得到实现关系

注意,此处可以细分,接口和枚举仅需要分析实现关系即可,通过 Element#getKind():ElementKind 可以判断类型

依赖&关联&聚合&组合

这四个关系非常的类似但又不同,先降低复杂度,均认为是依赖关系,在后续迭代中,可以进一步增加功能,将关系细化

进一步降低复杂度,我们仅从类的属性出发,分析依赖关系,忽略掉 方法声明 (可分析)方法体 (无法分析)静态块 (无法分析) 中 所包含的关系。

public interface TypeElement extends Element, Parameterizable, QualifiedNameable {

    List<? extends Element> getEnclosedElements();
    //无关代码略去
}

不言自明,通过这一API,配合 ElementFilter#fieldsIn(Iterable<? extends Element>):List<VariableElement> 可以得到声明的 fields;

通过Element的API可以很轻易的得到 命名 以及 修饰

通过 Element#asType():TypeMirror API 将其转换为TypeMirror后, 基于其Visitor模式设计可以得到field的类型 DeclaredType 并通过 DeclaredType#asElement():Element API重新得到Element

分治4 -- 确定分析的终点

在分治2中,我们已经确定了分析的起点 (可能有多个) , 在分治3中,我们已经确定了关系的分析方式。为了方便表述,我们以:

Relation(From,End) 表述 从FromEnd 的关系

执行一轮 分治2&分治3,我们将得到一系列的 Relation(From,End),此时我们将所有的 End 作为 新的From,不断迭代这一过程,即可完成图的遍历!

那么合适结束这一过程呢?

我们只需要维护一个集合 Sfrom,存储迭代过程中的 From ,每次得到的 End 只有满足 "不存在于Sfrom中" 这一条件时,才是 新的From,当无法获得 新的From 时,迭代结束

分治5 -- 分治3的补充,处理集合、数组、泛型

按照分治3中的约定,我们将集合、数组涉及的类型,以及泛型去泛化时的类型,都认为和当前类型是 依赖关系虽然这并不严谨

得益于 TypeMirrorVisitor模式 实现,我们很容易写出以下代码,获取我们关心的内容

private abstract class CastingTypeVisitor<T> constructor(private val label: String) :
    SimpleTypeVisitor6<T, Void?>() {
    override fun defaultAction(e: TypeMirror, v: Void?): T {
        throw IllegalArgumentException("$e does not represent a $label")
    }
}

private class FetchClassTypeVisitor : CastingTypeVisitor<List<DeclaredType>>(label = "") {
    override fun defaultAction(e: TypeMirror, v: Void?): List<DeclaredType> {
        //ignore it
        return emptyList()
    }

    override fun visitArray(t: ArrayType, p: Void?): List<DeclaredType> {
        return t.componentType.accept(this, p)
    }

    override fun visitWildcard(t: WildcardType, p: Void?): List<DeclaredType> {
        val ret = arrayListOf<DeclaredType>()

        t.superBound?.let {
            ret.addAll(it.accept(this, p))
        }
        t.extendsBound?.let {
            ret.addAll(it.accept(this, p))
        }
        return ret
    }

    override fun visitDeclared(t: DeclaredType, p: Void?): List<DeclaredType> {
        val ret = arrayListOf(t)
        t.typeArguments?.forEach {
            ret.addAll(it.accept(this, p))
        }
        return ret.toSet().toList()
    }

    override fun visitError(t: ErrorType, p: Void?): List<DeclaredType> {
        return visitDeclared(t, p)
    }

    override fun visitTypeVariable(t: TypeVariable, p: Void?): List<DeclaredType> {
        val ret = arrayListOf<DeclaredType>()

        t.lowerBound?.let {
            ret.addAll(it.accept(this, p))
        }
        t.upperBound?.let {
            ret.addAll(it.accept(this, p))
        }
        return ret
    }
}

fun TypeMirror.fetchDeclaredType(): List<DeclaredType> {
    return this.accept(FetchClassTypeVisitor(), null)
}

分治6 -- 关系的存储

显然,我们需要一个合适的数据结构用以存储图,得益于我去年在 组件化:组件的按序初始化 方面的一些探索,当时我开发了 Maat 其中包含 组件依赖关系的有向无环图分析,其中包含DAG的实现。

很显然,我们将 "无环检测" 禁用,就可以直接将数据结构拿来使用了,不需要再制造轮子!

显而易见,Relation的各种情况可以和 建立映射关系,人为维护一个 虚拟顶点 作为遍历的起始点可以减少很多麻烦。

分治7 -- 类型的细节处理

在分治3中,我们已经已经对类型 (enum、class、interface) 进行了很充分的分析,但还遗漏了一些细节,例如方法、修饰符等;

在分治6中,我们确定了关系存储方案,我们还需要描述图的顶点。

我们定义 UmlElement类来进行描述

abstract class UmlElement(val diagram: ClassDiagram?, val element: Element?) {
    /**
     * return: plant-uml 中相应的文本
     * */
    abstract fun umlElement(context: MutableSet<UmlElement>): String
    
    abstract fun parseFieldAndMethod(diagram: ClassDiagram,
                                     graph: DAG<UmlElement>,
                                     cache: MutableSet<UmlElement>)
    
    abstract fun drawField(fieldDrawer: FieldDrawer, 
                           builder: StringBuilder,
                           context: MutableSet<UmlElement>)
    
    abstract fun drawMethod(methodDrawer: MethodDrawer,
                            builder: StringBuilder,
                            context: MutableSet<UmlElement>)
}

并实现:

  • UmlInterface:接口
  • UmlEnum: 枚举
  • UmlClass: 类
  • UmlStub:分治6中提到的虚拟顶点

定义:IElementDrawer 接口 与 IJavaxElementDrawer 接口

interface IElementDrawer {
    fun drawAspect(builder: StringBuilder, element: UmlElement, context: MutableSet<UmlElement>)
}

interface IJavaxElementDrawer {
    fun drawAspect(builder: StringBuilder, element: Element, context: MutableSet<UmlElement>)
}

并参考Plant-Uml的语法规则,实现了一系列的切面处理,例如:修饰符解析与输出,类型解析与输出,名称解析与输出,方法解析与输出

此处使用了责任链,将Element转化plantUml语法进行了链式切分,并定义了一系列的切面处理。限于篇幅不做展开,有兴趣的读者可以在文末获取源码了解更多

例如:

abstract class SuperClz : SealedI {
    var superI: Int = 0
}

其Element被处理后,将转变为如下的文本内容:

abstract class "SuperClz"{
  .. fields ..
  {field}-superI : int
  .. methods ..
  {method}+ getSuperI(): int
  {method}+ setSuperI(int): void
}

由PlantUml处理后形如:

img2.png

分治8 -- 输出为PlantUml文件

得益于我先前的一些探索,我曾开发过一款用于生成文档的注解处理器,通过 博客连接 简单了解 。

设计中采用了SPI机制,我们可以很轻松地实现一个扩展,实现上面提到的所有内容,并且轻松地输出文本文档。

虽然在APT中输出一些文本文档是一件很简单的事情,但我决定使用以前造的轮子,毕竟它本身就是为了生成文档而开发的


至此,我们已经对整个问题的主要流程进行了推演,可以得出结论:这件事情可以成!

感谢我老婆早早就买到了中秋节回家的高铁票,在 回家的途中 便完成了方案的推演,并进行了框架编写与冒烟。 而现在整理博客的时间远远超过了编码🤣


保持优雅

显而易见,上述的内容仅止于 "解决问题" , 还不能 "出色地解决问题"。

例如:

  • 绘制多张ClassDiagram
  • 增加配置,屏蔽一些输出,例如:不想看见 private 修饰的fields
  • 包名是在是太长了,存在阅读干扰

等。

下面继续雕琢

维持简单

在继续雕琢功能的同时,我们必须兼顾 简单性 ,这一点非常重要!

一方面,我们不要过早的考虑用不着的功能,维持功能体系的简单。另一方面,功能使用要简单,方法或规则要明确。

例如在实现 "绘制多张ClassDiagram" 功能时:

  • 我最先想到的是在 GenerateClassDiagram 中添加qualifier:List<String>, 可以将被标识的类分配到不同的组别。但是它看起来并不太友好
  • 于是我产生了:将配置与标识分离的想法。定义一个注解,可以进行配置,它仅可被标识于注解。
@Target(AnnotationTarget.ANNOTATION_CLASS)
annotation class ClassDiagram(
    val qualifier: String = "",
    val fieldVisible: Array<Visible> = [Visible.Private, Visible.Protected, Visible.Package, Visible.Public],
    val methodVisible: Array<Visible> = [Visible.Private, Visible.Protected, Visible.Package, Visible.Public],
)

这样使用者可以自由的定义注解,例如:

@ClassDiagram("Demo")
annotation class DemoDiagram

这样,注解处理器需要关心的注解将变为两个:

  • ClassDiagram : 标识注解表达分组,并且包含配置
  • GenerateClassDiagram :标识类图中的分析起始点

如此,我们使用时的规则更加清晰!注意,被GenerateClassDiagram 注解的类,必须添加分组注解,即 被DemoDiagram注解的注解 否则将被忽略

例如:

@GenerateClassDiagram
@DemoDiagram
class Clz : SuperClz(), SealedI {
    val int: Int? = null
}

目前增加的功能,仅仅是在前文大流程上的细节优化,实现不再展开

结合我目前的工作使用了一段时候后,插件的功能还够用,就先不做超前的功能实现了

减少侵入

我们从上文获知,使用这一插件,需要在代码中进行侵入修改,增加注解标注。理论上而言,侵入应该最少越好!在后续功能迭代设计中,同样需要考虑这一点。

在分治3中,我们将组合、聚合等关系暂时全部认为是依赖关系。

在最初的设计中,需要使用者通过注解标识其关系,但侵入性大大提升

过多的注解会 影响源码可读性 以及 增大侵入

在我的构想中,将通过 ClassDiagram依赖关联组合 等进行一些约定,以解决这一问题并尽可能地减少侵入。

扩展能力

目前还有一些功能尚未有优雅的解决方案,在前文已经提及。 我预留了足够多的扩展性用于装饰Plant-Uml语法文档,如果读者有处理方案,可以直接扩展后提PR。

因本文 并非介绍编程技巧 或者 剖析如何提升项目的扩展能力 ,故不再展开

项目地址

使用示例

添加依赖

implementation "io.github.leobert-lan:class-diagram-reporter:1.0.0"
annotationProcessor "io.github.leobert-lan:report-anno-compiler:1.1.4"
annotationProcessor "io.github.leobert-lan:class-diagram-reporter:1.0.0"

均已发布到MavenCentral,最新版本号可参考以下:

* *

* *

配置信息:

kapt {
    arguments {
        arg("module", "ktsample") //模块名称
        arg("mode", "mode_file") 
        arg("active_reporter", "on")
    }
}

自行定义注解

该注解需被 ClassDiagram 注解,其他自行配置

例如:

@ClassDiagram(qualifier = "BridgePattern")
annotation class BridgePatternDiagram

//or

@ClassDiagram(
    qualifier = "AAAB",
    fieldVisible = {Visible.Package, Visible.Public}
)
public @interface AAAB {
}

配合 GenerateClassDiagram 注解使用

此处以桥接模式示例,一个满足桥接模式的代码实现如下:

class BridgePattern {

    @ClassDiagram(qualifier = "BridgePattern")
    annotation class BridgePatternDiagram

    interface MessageImplementor {
        fun send(message: String, toUser: String)
    }

    abstract class AbstractMessage(private val impl: MessageImplementor) {
        open fun sendMessage(message: String, toUser: String) {
            impl.send(message, toUser)
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class CommonMessage(impl: MessageImplementor) : AbstractMessage(impl)

    @BridgePatternDiagram
    @GenerateClassDiagram
    class UrgencyMessage(impl: MessageImplementor) : AbstractMessage(impl) {
        override fun sendMessage(message: String, toUser: String) {
            super.sendMessage("加急:$message", toUser)
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class MessageSMS : MessageImplementor {
        override fun send(message: String, toUser: String) {
            println("使用系统内短消息的方法,发送消息'$message'给$toUser")
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class MessageEmail : MessageImplementor {
        override fun send(message: String, toUser: String) {
            println("使用邮件短消息的方法,发送消息'$message'给$toUser")
        }
    }
}

编译后,我们得到的puml文件,渲染后得到:

bridge-pattern1.png

😂恰好满足了plant-uml中package的语法,正常情况不会有package

我们可以注意到,关系上没有太大问题,AbstractMessage 与 MessageImplementor 之间表现为关联更加恰当。

除此之外,从阅读习惯上而言,图中的一些位置关系,还需要再调整,我们可以在后续的版本加,添加相应的配置方式。

絮叨

从中秋节开始设计,零零碎碎完成开发,再到国庆准备写博客,又陆续做了几次修改。期间,家中也出了意外,在此也诚挚的希望各位读者注意身体,多花些时间在家人身上。代码是写不完的,知识是学不完的,但健康和生命都是有尽头的。

本篇属于好玩系列,了解好玩系列 的创作初衷。