1. 前言
推荐一个开源项目,可以优雅的为 Java or Kotlin 项目生成类图。
本文按照下面的脑图顺时针展开,揭开这一工具的诞生过程
2. 主要问题与方案
背景
毋庸置疑,准确的 、关键的 算法流程图,时序图,组件图,状态图,类图等, 对于产品本身的维护及发展具有很大帮助!对于研发工作者而言,高度概括流程、设计、算法等的专业工具图对工作有极大帮助。既然需要审核的文档中也需要这些内容,又对工作有帮助,何不做的好一些呢。
上文提到的各类 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大版本进行适配;
所以最终方案为:从注解处理阶段入手,分析编译中间产物,最终生成类图
3. 问题分治与解决
分治1 — 简化输出产物
确定了大方向之后,我们需要再思考下整个问题的方方面面。生成类图有两大问题需要解决:
- 从源码、或者编译的中间产物中分析出类关系 (ps:我们已经确定了要从编译中间产物出发)
- 将类关系转变为图
显然,”开发一个用来生成图的引擎”,这件事情成本过大且没有必要。所幸的是,UML不是一个新生物,业内也有大名鼎鼎的Plant Uml 。
PlantUml 基于 Graphviz , Graphviz 本身使用Dot语法描述元素与元素关系, 直接使用 Graphviz 比较朴素,PlantUml通过自定义语法,使得内容可阅读性提升,且无须关注转换图片时进行各类装饰问题
于是,我们可以将问题转化为:从编译的中间产物中分析出类关系,将关系按照PlantUml语法生成puml文件,它的内容是纯文本。
分治2 — 确定分析的起始点
如果从最终结果看,我们得到的是一个 有方向的图,那么 按照图本身的起始点出发 比较符合习惯。
也就是说,我们将在起始点所对应的类上添加注解,作为注解处理的目标起始点
例如:
Cat 和 Dog 将作为起始点。
因为只需要标记类,我们约定注解:
@Target( annotation Target.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) 表述 从From 到 End 的关系
执行一轮 分治2&分治3,我们将得到一系列的 Relation(From,End),此时我们将所有的 End 作为 新的From,不断迭代这一过程,即可完成图的遍历!
那么合适结束这一过程呢?
我们只需要维护一个集合 Sfrom,存储迭代过程中的 From ,每次得到的 End 只有满足 “不存在于Sfrom中” 这一条件时,才是 新的From,当无法获得新的From 时,迭代结束
分治5 — 分治3的补充,处理集合、数组、 泛型
按照分治3中的约定,我们将集合、数组涉及的类型,以及泛型去泛化时的类型,都认为和当前类型是 依赖关系;虽然这并不严谨
得益于 TypeMirror 的 Visitor模式 实现,我们很容易写出以下代码,获取我们关心的内容!
private abstract class CastingTypeVisitor<T> constructor(private val label: String) :
SimpleTypeVisitor<T, Void ?>() {
override fun defaultAction(e: TypeMirror, v: Void?): T {
throw IllegalArgument Exception ("$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 =
}
其Element被处理后,将转变为如下的文本内容:
abstract class "SuperClz"{
.. fields ..
{field}-superI : int
.. methods ..
{method}+ getSuperI(): int
{method}+ setSuperI(int): void
}
由PlantUml处理后形如:
分治8 — 输出为PlantUml文件
得益于我先前的一些探索,我曾开发过一款用于生成文档的注解处理器,设计中采用了SPI机制,我们可以很轻松地实现一个扩展,实现上面提到的所有内容,并且轻松地输出文本文档。
虽然在APT中输出一些文本文档是一件很简单的事情,但我决定使用以前造的轮子,毕竟它本身就是为了生成文档而开发的
至此,我们已经对整个问题的主要流程进行了推演,可以得出结论:这件事情可以成!
4. 保持优雅
上述的内容仅止于 “解决问题” , 还不能 “出色地解决问题”。
例如:
- 绘制多张ClassDiagram
- 增加配置,屏蔽一些输出,例如:不想看见 private 修饰的fields
- 包名是在是太长了,存在阅读干扰 等。
下面继续雕琢
维持简单
在继续雕琢功能的同时,我们必须兼顾 简单性 ,这一点非常重要!
一方面,我们不要过早的考虑用不着的功能,维持功能体系的简单。另一方面,功能使用要简单,方法或规则要明确。
例如在实现 “绘制多张ClassDiagram” 功能时:
- 我最先想到的是在 GenerateClassDiagram 中添加 `qualifier:List“, 可以将被标识的类分配到不同的组别。但是它看起来并不太友好
- 于是我产生了:将配置与标识分离的想法。定义一个注解,可以进行配置,它仅可被标识于注解。
@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(), Sealed I {
val int: Int? = null
}
目前增加的功能,仅仅是在前文大流程上的细节优化,实现不再展开
结合我目前的工作使用了一段时候后,插件的功能还够用,就先不做超前的功能实现了
减少侵入
我们从上文获知,使用这一插件,需要在代码中进行侵入修改,增加注解标注。理论上而言,侵入应该最少越好!在后续功能迭代设计中,同样需要考虑这一点。
在分治3中,我们将组合、聚合等关系暂时全部认为是依赖关系。
在最初的设计中,需要使用者通过注解标识其关系,但侵入性大大提升。
过多的注解会 影响源码可读性 以及 增大侵入 !
在我的构想中,将通过 ClassDiagram 对 依赖 、 关联 、 组合 等进行一些约定,以解决这一问题并尽可能地减少侵入。
扩展能力
目前还有一些功能尚未有优雅的解决方案,在前文已经提及。 我预留了足够多的扩展性用于装饰Plant-Uml语法文档,如果读者有处理方案,可以直接扩展后提PR。
因本文 并非 为 介绍编程技巧 或者 剖析如何提升项目的扩展能力 ,故不再展开
项目地址:
5. 使用用例
添加依赖
implementation "io.github.leobert-lan:class-diagram-reporter:.0.0"
annotationProcessor "io.github.leobert-lan:report-anno-compiler:.1.4"
annotationProcessor "io.github.leobert-lan:class-diagram-reporter:.0.0"
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文件,渲染后得到:
恰好满足了plant-uml中package的语法,正常情况不会有package
我们可以注意到,关系上没有太大问题, AbstractMessage 与 MessageImplementor 之间表现为关联更加恰当。
除此之外,从阅读习惯上而言,图中的一些位置关系,还需要再调整,我们可以在后续的版本加,添加相应的配置方式。