从一个类名到一张类图:用反射自动生成 PlantUML

给一个全限定类名,输出一段能直接渲染的 PlantUML。单文件、253 行、零依赖。 本文是 如何自动生成简单的 PlantUML 类图 的重写版:工具没变,补上的是原来没讲的原理、怎么看图,以及它会在哪里骗你。

为什么要自动画类图

读 JDK 源码时,我们常常需要弄清楚这个类在继承体系里的位置。

手画流程图的过程可能是这样的:打开源码,看 extends,看 implements,再去看父类的父类......画完一张图,半天过去了;一个地方画错了,还得调整。

先看结果

下面这张图覆盖了 HashMap、LinkedHashMap、ConcurrentHashMap,由一条命令生成:

bash 复制代码
java ClassDiagramGenerator 'java.util.HashMap' 'java.util.LinkedHashMap' 'java.util.concurrent.ConcurrentHashMap'

它输出的是文本而不是图片,上面那张图是借助 IntelliJ IDEA 里的 PlantUML 插件渲染出来的。文本长这样 ⬇️

text 复制代码
@startuml

interface java.lang.Cloneable
interface java.io.Serializable
interface java.util.concurrent.ConcurrentMap
interface java.util.SequencedMap
interface java.util.Map
abstract java.util.AbstractMap
class java.util.HashMap
class java.util.LinkedHashMap
class java.util.concurrent.ConcurrentHashMap
java.util.Map <|.. java.util.AbstractMap
java.util.AbstractMap <|-- java.util.HashMap
java.util.Map <|.. java.util.HashMap
java.lang.Cloneable <|.. java.util.HashMap
java.io.Serializable <|.. java.util.HashMap
java.util.Map <|-- java.util.SequencedMap
java.util.HashMap <|-- java.util.LinkedHashMap
java.util.SequencedMap <|.. java.util.LinkedHashMap
java.util.Map <|-- java.util.concurrent.ConcurrentMap
java.util.AbstractMap <|-- java.util.concurrent.ConcurrentHashMap
java.util.concurrent.ConcurrentMap <|.. java.util.concurrent.ConcurrentHashMap
java.io.Serializable <|.. java.util.concurrent.ConcurrentHashMap

@enduml

9 个类型、12 条关系,图里能直接读出三件事:

  • 三个 Map 都继承(或者间接继承) AbstractMap,但 LinkedHashMap 是间接 继承 ------ 它先继承 HashMap;
  • ConcurrentHashMap 实现的是 ConcurrentMap 而不是 Map,而 ConcurrentMap 本身是 Map 的子接口;
  • Cloneable / Serializable 这两个标记接口挂在最外围,剪掉之后图会干净很多(就是后面 -i 干的事)。

它是怎么工作的

整个工具只做三件事。

第一步:把类名变成 Class 对象

java 复制代码
Class<?> clazz = Class.forName(className);

Class.forName 要的是全限定名 :java.util.HashMap 可以,HashMap 不行;内部类写成 java.util.Arrays$ArrayList。

第二步:递归收集父类和接口

全部逻辑的核心不到 30 行 ⬇️

java 复制代码
private void analyzeHierarchy(Class<?> currClass) {
    if (!analyzedClasses.contains(currClass) && !ignoredClasses.contains(currClass)) {
        analyzeSuperClass(currClass);
        analyzeInterfaces(currClass);

        analyzedClasses.add(currClass);
    }
}

private void analyzeSuperClass(Class<?> currClass) {
    Class<?> superclass = currClass.getSuperclass();
    if (superclass == null || ignoredClasses.contains(superclass)) {
        return;
    }
    analyzeHierarchy(superclass);
    if (!inheritanceRelations.containsKey(currClass)) {
        inheritanceRelations.put(currClass, superclass);
    }
}

private void analyzeInterfaces(Class<?> currClass) {
    Class<?>[] interfaces = currClass.getInterfaces();
    for (Class<?> item : interfaces) {
        analyzeHierarchy(item);
    }
    // ...
}

analyzedClasses 承担了"去重"和"控制输出顺序"两个职责:它是 LinkedHashSet,所以同一个输入每次输出都一样。

第三步:分两类关系打印

继承和实现分开存:inheritanceRelations 记录 类 → 父类,realizationRelations 记录 类 → 接口数组,最后统一打印。

java 复制代码
analyzedClasses.forEach(c -> {
    if (inheritanceRelations.containsKey(c)) {
        System.out.printf("%s <|-- %s%n", inheritanceRelations.get(c).getName(), c.getName());
    }
    if (realizationRelations.containsKey(c)) {
        String type = c.isInterface() ? "<|--" : "<|..";
        // ...
    }
});

三个设计选择

为什么默认排除 Object。 每个类最终都继承 Object,画出来就是所有节点连一条线到同一个角,纯噪音。构造函数里一句 ignoredClasses.add(Object.class) 就够了。

为什么要有 -i 选项。 Cloneable、Serializable 这类标记接口没有方法,对理解结构没有帮助,但几乎每个集合类都实现它们。-i 就是用来剪枝的。

剪掉的类不能凭空消失。 用了 -i 之后,图里会多一段图例,写明哪些类型被剪掉了 ------ 否则读者会以为这个类真的没实现 Serializable:

text 复制代码
legend left
请注意: 以下类/接口在类图中被忽略了
<i>java.lang.Cloneable</i>
<i>java.io.Serializable</i>
end legend

怎么用

bash 复制代码
javac ClassDiagramGenerator.java

# 基本用法
java ClassDiagramGenerator 'java.util.ArrayList'

# 忽略两个标记接口
java ClassDiagramGenerator -i 'java.lang.Cloneable' -i 'java.io.Serializable' 'java.util.ArrayList'

两条参数规则:

  • 至少要给一个类名;
  • -i 必须写在所有类名之前 。解析器只从参数开头连续读取 -i,写在后面会被当成类名,直接报 Class with name=-i can't be found。

例 1:ArrayList

bash 复制代码
java ClassDiagramGenerator -i 'java.lang.Cloneable' -i 'java.io.Serializable' 'java.util.ArrayList'

这张图回答了一个常见问题:ArrayList 为什么能当 List、Collection、Iterable 用?因为它实现了 List,而 List 又依次继承了 SequencedCollection → Collection → Iterable。

例 2:EnumSet 的两个实现

bash 复制代码
java ClassDiagramGenerator 'java.util.JumboEnumSet' 'java.util.RegularEnumSet'

EnumSet 是抽象类,of() 会按枚举常量的个数挑实现:不超过 64 个走 RegularEnumSet(一个 long 当位图),超过就走 JumboEnumSet(long[])。

例 3:自己写的类

java 复制代码
interface Drawable { void draw(); }

interface Named { String name(); }

abstract class Shape implements Drawable, Named {
    abstract double area();
}

class Circle extends Shape {
    double area() { return Math.PI; }
    public void draw() { }
    public String name() { return "circle"; }
}

class Rectangle extends Shape {
    double area() { return 1; }
    public void draw() { }
    public String name() { return "rectangle"; }
}

enum Color { RED, GREEN, BLUE }
bash 复制代码
javac Shapes.java
java ClassDiagramGenerator Shape Circle Rectangle Color

这张最适合拿来对照"怎么读图":一个枚举、两个接口、一个抽象类、两个实现,外加 Enum 自带的那串接口。

还能画什么

输入 说明
java.util.Arrays$ArrayList Arrays.asList(...) 返回的实例
java.util.List / java.util.Set / java.util.Deque 只看这几个接口自己的继承关系
java.util.ImmutableCollections$Set12 Set.of(...) 返回的实例(1~2 个元素时)
java.util.concurrent.ThreadPoolExecutor 线程池的继承链,比想象中长

怎么读这张图

  • 节点 :C 普通类,A 抽象类,I 接口,E 枚举;
  • 实线三角 :继承(extends),写作 父 <|-- 子;
  • 虚线三角 :实现(implements),写作 接口 <|.. 类;
  • 方向:箭头永远从子指向父。

读的时候只问一个问题:这个方法是从哪来的? 顺着线往上走,第一个定义它的类就是答案。

它会在哪里骗你

这个工具有三个局限,不是 bug:

  1. 没有泛型。 输出的是 List 而不是 List<E> ------ 泛型在运行时被擦除了,反射拿不到。
  2. 没有字段和方法。 只画类型之间的关系。字段其实能拿到(getDeclaredFields()),但一旦画上去,图立刻变得不可读,所以我把它留成手工补。
  3. 只能看到 classpath 里的类。 java.* 开箱即用,分析第三方库要先加 -cp。

确实需要字段时(比如讲 HashMap 的五个字段),我觉得一种可行的做法是:先生成骨架,再手改那一处 ⬇️

text 复制代码
class java.util.HashMap {
  - Node[] table
  - int size
  - int threshold
  - float loadFactor
  - int modCount
}

掘金不渲染 puml:把生成的文本贴到 PlantUML 的在线编辑器(或 Intellij IDEA 的插件)里就能看到图。同系列还有一篇 Mermaid 版,语法不同、思路一样。

完整代码

请将以下代码保存为 ClassDiagramGenerator.java ⬇️

java 复制代码
import java.lang.reflect.AccessFlag;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class ClassDiagramGenerator {
    private final Map<Class<?>, Class<?>[]> realizationRelations = new HashMap<>();
    private final Map<Class<?>, Class<?>> inheritanceRelations = new HashMap<>();

    private final Set<Class<?>> analyzedClasses = new LinkedHashSet<>();

    private final Set<Class<?>> ignoredClasses = new HashSet<>();

    private static final Class<Object> OBJECT_CLASS = Object.class;
    private static final String IGNORE_OPTION = "-i";
    private static final String USAGE = "Usage: java ClassDiagramGenerator [-i 'java.io.Serializable'] [-i 'java.lang.Cloneable'] 'java.util.ArrayList' 'java.util.LinkedList'";

    public static void main(String[] args) {
        ClassDiagramGenerator generator = new ClassDiagramGenerator();
        generator.convert(args).forEach(generator::analyzeHierarchy);

        generator.generateClassDiagram();
    }

    public ClassDiagramGenerator() {
        ignoredClasses.add(OBJECT_CLASS);
    }

    private void ignoreClass(String className) {
        try {
            Class<?> clazz = Class.forName(className);
            ignoredClasses.add(clazz);
        } catch (ClassNotFoundException e) {
            String message = String.format("Class with name=%s can't be found, please check!", className);
            throw new RuntimeException(message);
        }
    }

    /**
     * Convert class name to the corresponding class object.
     * The "-i" option (it means some class should be ignored) will be handled in this method.
     *
     * @param classNames give class names
     * @return a list that contains corresponding class objects
     */
    private List<Class<?>> convert(String[] classNames) {
        int index = 0;
        while (index < classNames.length && classNames[index].equals(IGNORE_OPTION)) {
            if (index + 1 >= classNames.length) {
                String hint = "Please refer to below usage and specify correct parameter with the '-i' option!";
                throw new IllegalArgumentException(String.join(System.lineSeparator(), hint, USAGE));
            }
            ignoreClass(classNames[index + 1]);
            index += 2;
        }

        if (index == classNames.length) {
            String hint = "Please refer to below usage and specify at least ONE class name!";
            throw new IllegalArgumentException(String.join(System.lineSeparator(), hint, USAGE));
        }

        List<Class<?>> classList = new ArrayList<>(classNames.length);
        for (; index < classNames.length; index++) {
            String className = classNames[index];
            try {
                Class<?> clazz = Class.forName(className);
                classList.add(clazz);
            } catch (ClassNotFoundException e) {
                String message = String.format("Class with name=%s can't be found, please check!", className);
                throw new RuntimeException(message);
            }
        }
        return classList;
    }

    private void showIgnoredClasses() {
        List<Class<?>> classes = ignoredClasses.stream().
                filter(c -> c != OBJECT_CLASS).
                toList();
        if (!classes.isEmpty()) {
            printAnEmptyLine();
            System.out.println("legend left");
            System.out.println("请注意: 以下类/接口在类图中被忽略了");
            classes.forEach(c -> System.out.println("<i>" + c.getName() + "</i>"));
            System.out.println("end legend");
        }
    }

    /**
     * Generate first line for PlantUML class diagram
     */
    private void generateFirstLine() {
        System.out.println("@startuml");
    }

    /**
     * Generate last line for PlantUML class diagram
     */
    private void generateLastLine() {
        System.out.println("@enduml");
    }

    /**
     * Generate main content in PlantUML class diagram
     */
    private void generateClassDiagram() {
        generateFirstLine();
        printAnEmptyLine();

        doGenerateClassDiagram();

        showIgnoredClasses();

        printAnEmptyLine();
        generateLastLine();
    }

    private void printAnEmptyLine() {
        System.out.println();
    }

    private void doGenerateClassDiagram() {
        generateNames();

        analyzedClasses.forEach(c -> {
            if (inheritanceRelations.containsKey(c)) {
                System.out.printf("%s <|-- %s%n", inheritanceRelations.get(c).getName(), c.getName());
            }
            if (realizationRelations.containsKey(c)) {
                String type = c.isInterface() ? "<|--" : "<|..";
                Arrays.stream(realizationRelations.get(c)).forEach(item ->
                        System.out.printf("%s %s %s%n", item.getName(), type, c.getName()));
            }
        });
    }

    /**
     * This method generates names for
     * 1. Enum classes
     * 2. Annotation classes
     * 3. Interfaces
     * 4. Abstract classes
     * 5. Other classes
     */
    private void generateNames() {
        Set<Class<?>> enums = analyzedClasses.stream()
                .filter(Class::isEnum)
                .collect(Collectors.toSet());

        // note: each annotation will appear in "annotations" and "interfaces" and "abstractClasses"
        Set<Class<?>> annotations = analyzedClasses.stream()
                .filter(Class::isAnnotation)
                .collect(Collectors.toSet());

        // note: each interface will appear in "interfaces" and "abstractClasses"
        Set<Class<?>> interfaces = analyzedClasses.stream()
                .filter(Class::isInterface)
                .collect(Collectors.toSet());

        Set<Class<?>> abstractClasses = analyzedClasses.stream()
                .filter(this::isAbstractClass)
                .collect(Collectors.toSet());

        generateEnumNames(enums);
        generateAnnotationNames(annotations);
        generateInterfaceNames(interfaces, annotations);
        generateAbstractClassNames(abstractClasses, enums, interfaces);

        generateRemainingClassNames(List.of(enums, abstractClasses, interfaces));
    }

    private boolean isAbstractClass(Class<?> candidate) {
        return candidate.accessFlags().contains(AccessFlag.ABSTRACT);
    }

    private void generateEnumNames(Set<Class<?>> enums) {
        Consumer<Class<?>> enumDisplayer = (annotationItem) ->
                System.out.println("enum " + annotationItem.getName());

        enums.forEach(enumDisplayer);
    }

    private void generateAnnotationNames(Set<Class<?>> annotations) {
        Consumer<Class<?>> interfaceDisplayer = (annotationItem) ->
                System.out.println("annotation " + annotationItem.getName());

        annotations.forEach(interfaceDisplayer);
    }

    private void generateInterfaceNames(Set<Class<?>> interfaces, Set<Class<?>> annotations) {
        Consumer<Class<?>> interfaceDisplayer = (interfaceItem) ->
                System.out.println("interface " + interfaceItem.getName());

        interfaces.stream()
                .filter(interfaceItem -> !annotations.contains(interfaceItem))
                .forEach(interfaceDisplayer);
    }

    private void generateAbstractClassNames(Set<Class<?>> abstractClasses, Set<Class<?>> enums, Set<Class<?>> interfaces) {
        Consumer<Class<?>> abstractClassDisplayer = (abstractClass) ->
                System.out.println("abstract " + abstractClass.getName());

        abstractClasses.stream()
                .filter(abstractClass -> !enums.contains(abstractClass))
                .filter(abstractClass -> !interfaces.contains(abstractClass))
                .forEach(abstractClassDisplayer);
    }

    private void generateRemainingClassNames(List<Set<Class<?>>> alreadyProcessedClasses) {
        Predicate<Class<?>> alreadyProcessed = (clazz) ->
                alreadyProcessedClasses.stream().anyMatch(classes -> classes.contains(clazz));

        analyzedClasses.stream()
                .filter(item -> !alreadyProcessed.test(item))
                .map(Class::getName)
                .forEach(className -> System.out.println("class " + className));
    }

    private void analyzeHierarchy(Class<?> currClass) {
        if (!analyzedClasses.contains(currClass) && !ignoredClasses.contains(currClass)) {
            analyzeSuperClass(currClass);
            analyzeInterfaces(currClass);

            analyzedClasses.add(currClass);
        }
    }

    private void analyzeSuperClass(Class<?> currClass) {
        Class<?> superclass = currClass.getSuperclass();
        if (superclass == null || ignoredClasses.contains(superclass)) {
            return;
        }
        analyzeHierarchy(superclass);
        if (!inheritanceRelations.containsKey(currClass)) {
            inheritanceRelations.put(currClass, superclass);
        }
    }

    private void analyzeInterfaces(Class<?> currClass) {
        Class<?>[] interfaces = currClass.getInterfaces();
        for (Class<?> item : interfaces) {
            analyzeHierarchy(item);
        }

        if (!realizationRelations.containsKey(currClass)) {
            Class<?>[] filteredInterfaces = Arrays.stream(interfaces).
                    filter(c -> !ignoredClasses.contains(c)).
                    toArray(Class<?>[]::new);
            realizationRelations.put(currClass, filteredInterfaces);
        }
    }
}

小结

  1. 类图自动化的最小闭环就三步:Class.forName → 递归 getSuperclass / getInterfaces → 按继承、实现两类打印。
  2. 输出顺序要稳定(LinkedHashSet),否则每次 diff 都是噪声。
  3. Object 默认排除,标记接口用 -i 剪掉,剪掉的必须写进图例。
  4. 它画的是骨架:泛型、字段、方法都不在范围内,需要时手工补。
  5. 它真正的价值不是「省一次画图的时间」,而是可复现:同一条命令,任何时候都能得到同一张图。

参考资料

版权问题

本文展示的代码是我自己写的,读者可以自由复制、修改、再发布,不必通知我。

相关推荐
追逐时光者1 小时前
一款基于 .NET 开源、免费的数据库自治诊断平台
后端·.net
nice_lcj5201 小时前
Java 并发容器入门:ConcurrentHashMap、CopyOnWriteArrayList、阻塞队列
java·面试·并发编程·阻塞队列·concurrenthashmap·copyonwritearraylist
微小的鱼1 小时前
Linux的渲染与显示系统链路
android·java·linux
追逐时光者1 小时前
让 GitHub Copilot 更懂你的 .NET 代码风格:一份精简实用的 Copilot Instructions
后端·.net
小满zs2 小时前
Go语言第十八章(架构设计)
后端·go
若亦_2 小时前
Java中关于this的三种用法
java·开发语言·intellij-idea
山甫aa2 小时前
锁一篇搞定:乐观锁、悲观锁、CAS、数据库锁与分布式锁【从零开始的 Web 后端学习】
java·数据库·分布式·后端·学习·web
软件技术NINI2 小时前
基于SpringBoot和Vue的博物馆预约平台管理系统的设计与实现
java·毕业设计·毕设
IT_陈寒3 小时前
Java开发实战:从入门到精通
前端·人工智能·后端