Auto-Generating UML Class Diagrams with Java Reflection and Classpath Search

Auto-Generating UML Class Diagrams with Java Reflection and Classpath Search

Are you struggling to keep your code and UML class diagrams synchronized?Imagine the possibilities if this process were automated. Discover how libraries like java2uml have been developed to simplify this task, allowing for the textual generation of class diagrams from Java code.

Introduction

Despite many developers showing less enthusiasm for documentation, UML (Unified Modeling Language) class diagrams remain widely used for documenting object-oriented systems and reducing the complexity of more intricate scenarios. They offer a clear and detailed view of a system’s structure, illustrating the relationships and interactions between classes, interfaces, and other components.

Although it is sometimes necessary to generate the class diagram before coding, in other cases, such as for documentation or for carrying out maintenance and extensions, the code already exists when the diagram is created.

The shift towards textual generation of diagrams, emerging as a viable alternative to traditional CASE (Computer-Aided Software Engineering) tools, is gaining traction due to its ease of use, compatibility with version control systems, reduced emphasis on manual visual design, and accessibility to visually impaired people. Tools like yUML and PlantUML exemplify this trend. For instance, the diagram illustrated below was crafted using yUML with the following syntax:

A yUML class diagram for a virtual drive domain.
[?interface?;FileSystemItem|+getName(): String;+getSize(): long;+remove(): void;+hasParent():boolean;+getParent():Optional?Folder?]
[_AbstractFileSystemItem_;-name:String;-parent:Folder|+getName():String;+setName(String):void;+hasParent():boolean;+getParent():Optional?Folder?]
[Folder||+addItem(FileSystemItem):void;+removeItem(FileSystemItem):boolean;+countItems():int;+getItems(): FileSystemItem[]]
[File|-contentType:String;-size:long;-location:String|+getContentType():String;+setContentType(String):void;+getLocation():String;+setLocation(String):void]

[_AbstractFileSystemItem_]-.-^[FileSystemItem]
[File]-^[_AbstractFileSystemItem_]
[Folder]1<>-> * has as child   [FileSystemItem]
[Folder]-^[_AbstractFileSystemItem_]        

Searching for classes inside Java packages

A key requirement for generating class diagrams from code is the ability to find all classes within specified packages. Java doesn’t offer a direct method to achieve this. While third-party libraries like Google Guava and Spring can be used, developing a custom solution is feasible and can be more enlightening.

Classes in Java are loaded by a class loader. Given the JVM internal representation of a package (e.g. java/util), you can ask the class loader to find the absolute path to the folder on the disk where it is located. The class loader can be obtained in various ways, one of which is:

ClassLoader classLoader = Thread.currentThread().getContextClassLoader();        

Once you have the class loader, you can find the path to a package by replacing the package’s dots (‘.’) with slashes (‘/’) and using the getResources() method:

Enumeration<URL> resources = classLoader.getResources(packageName.replace('.', '/'));        

The getResources method in Java's class loader plays an important role in resource management. It is designed to locate all resources with a specified name within the classpath. The class loader conducts a thorough search across the classpath, which includes a sequence of directories, JAR archives, and ZIP archives containing class files and other resources. This method meticulously scans these locations to identify all available resources that match the given name, returning an enumeration of URL objects. Each URL in this enumeration points to a resource found, allowing for further processing or reading.

Once the resources are identified, the next step involves file processing. Java’s IO/NIO API comes into play here, offering mechanisms to search for specific file types, such as those ending in .class. This is particularly useful for examining contents within a folder and its subfolders. The following code snippet illustrates how this can be achieved:

public Class<?>[] searchClasses() {
  Set<Class<?>> classes = new HashSet<>();

  // Iterate over each package to find classes
  packages.forEach(packageName -> {
    try {
      String path = packageName.replace('.', '/');
      Enumeration<URL> resources = Thread.currentThread().getContextClassLoader().getResources(path);
      while (resources.hasMoreElements()) {
        URL resource = resources.nextElement();
        File directory = new File(resource.toURI());
        classes.addAll(searchClassesRecursively(directory, packageName));
      }
    } catch (IOException | URISyntaxException e) {
      logger.warning("Error accessing package: " + packageName);
    }
  });

  return classes.toArray(new Class<?>[0]);
}

// Recursive method to search classes in a directory
private Set<Class<?>> searchClassesRecursively(File directory, String packageName) {
  Objects.requireNonNull(directory, "Directory cannot be null");
  Objects.requireNonNull(packageName, "Package name cannot be null");

  if (!directory.exists() || !directory.isDirectory()) {
    return Collections.emptySet();
  }

  Set<Class<?>> classes = new HashSet<>();
  File[] files = directory.listFiles();
  if (files == null)
    return classes;

  for (File file : files) {
    String fileName = file.getName();
    if (file.isDirectory()) {
      String newPackage = packageName + "." + fileName;
      classes.addAll(searchClassesRecursively(file, newPackage));
    } else if (fileName.endsWith(".class")) {
      String qualifiedClassName = packageName + '.' + fileName.substring(0, fileName.length() - 6);
      try {
        classes.add(Class.forName(qualifiedClassName));
      } catch (ClassNotFoundException e) {
        logger.warning("Class not found: " + qualifiedClassName);
      }
    }
  }
  return classes;
}        

The searchClasses method returns an array of all classes found within the specified packages. In this code, the Class.forName method is used to return an instance of Class for the fully-qualified class name. This approach can also be applied in building dependency injection libraries, MVC-like frameworks, and other similar use cases.

Getting information from classes for class diagram generation

After identifying the classes within a list of packages, the next step in creating a class diagram is to gather information about the classes’ names, modifiers, fields, constructors, and methods. How is this achieved in Java?

Languages such as Java and C# boast a powerful reflection API that enables code to introspect upon itself, offering a dynamic way to examine and modify the behavior of applications at runtime. This feature allows for a high degree of flexibility and adaptability in software development.

The Java reflection API is available in the java.lang.reflect package.

For instance, you can query a class about its fields, their access modifiers, and even dynamically execute methods based on their names and parameter types.

The Class class is the gateway to the Reflection API, offering methods such as:

  • getSimpleName(): Returns the class name without the package.
  • isInterface(): Checks if the class is an interface.
  • isEnum(): Checks if the class is an enum.
  • getModifiers(): Returns an integer representing the class's modifiers (abstract, static, final, etc.).
  • getDeclaredFields(): Retrieves all fields (class Field), including private ones, but excluding inherited fields.
  • getDeclaredConstructors(): Retrieves all constructors (class Constructor), including private ones.
  • getDeclaredMethods(): Retrieves all methods (class Method), including private ones, but excluding inherited methods.
  • getSuperclass(): Returns a reference to the superclass.
  • getInterfaces(): Returns references to the interfaces that the class implements.

To obtain an instance of Class, you can use File.class, file.getClass(), Class.forName("br.com.virtualdrive.File"), among others.

Using these methods and common Java features, the following method generates a yUML textual representation of a class:

public String getTextualRepresentation(Class<?> clazz) {
  StringBuilder classAsText = new StringBuilder(getClassBeginning(clazz));
  classAsText.append(getClassFields(clazz)).append("|");
  classAsText.append(getClassConstructors(clazz));
  if (clazz.getDeclaredConstructors().length > 0) {
    classAsText.append(";");
  }
  classAsText.append(getClassMethods(clazz)).append("]");
  return classAsText.toString();
}

private String getClassBeginning(Class<?> clazz) {
  String classAsText = "[";
  if (clazz.isInterface()) {
    classAsText += "?interface?;%s".formatted(clazz.getSimpleName());
  } else if (Modifier.isAbstract(clazz.getModifiers())) {
    classAsText += "_%s_".formatted(clazz.getSimpleName());
  } else {
    classAsText += clazz.getSimpleName();
  }
  classAsText += "|";
  return classAsText;
}

private String getClassFields(Class<?> clazz) {
  return Stream.of(clazz.getDeclaredFields())
      .map(this::getField)
      .collect(Collectors.joining(";"));
}

private String getField(Field field) {
  return "%s:%s".formatted(field.getName(), field.getType().getSimpleName());
}

private String getClassConstructors(Class<?> clazz) {
  return Stream.of(clazz.getDeclaredConstructors())
      .map(this::getConstructor)
      .collect(Collectors.joining(";"));
}

private String getConstructor(Constructor method) {
  String parameters = Stream.of(method.getParameters())
      .map(Parameter::getName)
      .collect(Collectors.joining(","));
  return "?create?%s(%s)".formatted(method.getDeclaringClass().getSimpleName(), parameters);
}

private String getClassMethods(Class<?> clazz) {
  return Stream.of(clazz.getDeclaredMethods())
      .map(this::getMethod)
      .collect(Collectors.joining(";"));
}

private String getMethod(Method method) {
  String parameters = Stream.of(method.getParameters())
      .map(Parameter::getName)
      .collect(Collectors.joining(","));
  return "%s(%s):%s".formatted(method.getName(), parameters, method.getReturnType().getSimpleName());        

The code uses various methods of the Java Reflection API to extract class information. For instance, calling getTextualRepresentation(File.class) on the following source code generates a yUML textual representation:

// Generated yUML representation:
// [File|location:String;contentType:String;size:long|?create?File(parent,name,location,contentType,size);size():long;getLocation():String;setSize(size):void;setLocation(location):void;setContentType(contentType):void;getContentType():String]

public class File extends AbstractFileSystemItem {
  private String location;
  private String contentType;
  private long size;

  public File(Folder parent, String name, String location, String contentType, long size) {
    super(name, parent);
    setLocation(location);
    setContentType(contentType);
    setSize(size);
  }

  public String getLocation() {
    return location;
  }

  public void setLocation(String location) {
    Objects.requireNonNull(location);
    location = location.trim();
    if (location.isEmpty()) {
      throw new IllegalArgumentException("Cannot be empty!");
    }
    this.location = location;
  }

  public String getContentType() {
    return contentType;
  }

  public void setContentType(String contentType) {
    Objects.requireNonNull(contentType);
    contentType = contentType.trim();
    if (contentType.isEmpty()) {
      throw new IllegalArgumentException("Cannot be empty!");
    }
    this.contentType = contentType;
  }

  public void setSize(long size) {
    if (size < 0) {
      throw new IllegalArgumentException("Must be positive.");
    }
    this.size = size;
  }

  @Override
  public long size() {
    return this.size;
  }
}        

Handling relationships

To identify associations, one approach is to examine whether a field is of a specific type of interest, such as another class within the targeted packages. Additionally, it’s important to consider if the field is an array, a collection, or utilizes generics. For handling arrays, including multidimensional arrays (arrays of arrays), the following method can be used:

if(field.getType().isArray()) {
  Class<?> arrayType = field.getType().getComponentType();
  // Handle the case of arrays of arrays.
  while (arrayType.isArray()) {
    arrayType = componentType.getComponentType();
  }
  return componentType;
}        

For generics, collections, or arrays of generics, the following approach is useful:

Type genericFieldType = field.getGenericType();
// Handle the case of arrays of arrays.
while (genericFieldType instanceof GenericArrayType) {
  genericFieldType = ((GenericArrayType) genericFieldType).getGenericComponentType();
}

Set<Class<?>> scopedGenerics = new HashSet<>();
if (genericFieldType instanceof ParameterizedType) {
  ParameterizedType generics = (ParameterizedType) genericFieldType;
  Type[] typeArguments = generics.getActualTypeArguments();
  for (Type typeArgument : typeArguments) {
    if (typeArgument instanceof Class<?> originalClass) {
        scopedGenerics.add(originalClass);
    }
  }
}        

This code snippet demonstrates a method for analyzing the generic type of a field in Java, with a focus on handling scenarios such as arrays of arrays and parameterized types. Here’s a breakdown of its key aspects:

  1. Retrieving the Generic Type: The code begins by obtaining the generic type of a field using field.getGenericType(). This step is crucial for understanding the field's data type, especially when generics are involved.
  2. Handling Arrays of Arrays: The while loop addresses the case where the field's type is an array of arrays. It iteratively resolves the generic component type of each array level using ((GenericArrayType) genericFieldType).getGenericComponentType(). This loop continues until it reaches a non-array type, effectively unwrapping the nested arrays to their core component type.
  3. Processing Parameterized Types: The if statement checks if the resolved generic field type is a ParameterizedType. In Java’s type system, ParameterizedType represents a parameterized type, a class or interface that has been specified with actual type parameters.
  4. Extracting Type Arguments: Once confirmed as a ParameterizedType, the code extracts its actual type arguments using generics.getActualTypeArguments(). These type arguments are the generic types specified within the angle brackets (e.g., in List<String>, String is the type argument).
  5. Identifying and Storing Class Types: The code then iterates over these type arguments. For each argument, it checks if it is an instance of Class<?>. If so, it means the type argument is a non-generic class, and the code adds it to the scopedGenerics set. This set accumulates all the specific class types found within the generic field type, which is particularly useful for further processing or analysis.

Consider the following examples:

private List<File>[] files1;
private Map<Integer, File> files2;        

In these cases, the executed code will identify and return the File type as the generic type for both files1 and files2 fields.

To differentiate simple associations, aggregations, and compositions without understanding the code semantics, consider using custom annotations to specify the relationship type. Annotations in Java are a means of adding metadata to the code, which can be processed at compile-time or runtime.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Relationship {
  Relationships value();
}        
public enum Relationships {
  ASSOCIATION, AGGREGATION, COMPOSITION
}        

Now, fields can be annotated to specify their relationship types. For example, a field can be annotated as follows.

@Relationship(Relationships.COMPOSITION)
private List<File>[] files1;        

To determine if a field is annotated and to identify its relationship type using reflection, you can use the following approach:

Relationship relationshipAnnotation = getField().getDeclaredAnnotation(Relationship.class);
if (relationshipAnnotation != null) {
  // The field has a relationship annotation
  Relationships relationshipType = relationshipAnnotation.value();
  // Utilize the relationship type for generating the class diagram's textual representation
}        

This method allows for the detection of annotations on fields and the extraction of their specified relationship types, which can then be incorporated into the class diagram’s representation.

When it comes to identifying dependency relationships, a strategy involves examining the parameters and return types of methods. By scrutinizing these elements, it’s possible to discern how different classes are interconnected.

For a more comprehensive (yet under development) implementation of a library that assists with class diagram generation, check out the code of java2uml. Contributions to the library are welcome.

Observations about Java Modules

With the release of Java 9, the landscape of reflection underwent significant changes, introducing new limitations that developers must navigate. These changes, primarily revolving around encapsulation and module systems, can impact the way reflection is used, especially when accessing private members and classes in other modules. In order to overcome them, you can the --add-opens option of the JVM or you can open your module resources for reflection:

module com.mymodule {
  opens com.mymodule.internal to yourlibrary.package;
}        

Final Thoughts

Reflecting on the journey of using Classpath search and Java’s Reflection API for UML class diagram generation, it’s clear that this approach offers a dynamic and introspective way to understand and represent the structure of Java applications. The ability to automatically generate these diagrams not only saves time but also ensures accuracy and consistency in documentation.

Thank you for exploring this fascinating aspect of Java programming with me. If you want to learn more about Java and system architecture design, please follow me.

要查看或添加评论,请登录

Leandro Luque的更多文章

社区洞察

其他会员也浏览了