janScheible / pocketsaw

Compile time sub-module system, aimed at package group dependency organization within a Maven project resp. Java 9 module (and even more, see the Angular example).
MIT License
6 stars 0 forks source link

Pocketsaw

Compile time sub-module system, aimed at package group dependency organization within a Maven project resp. Java 9 module (and even more, see for example Using Pocketsaw in an Angular project).

Motivation

Package cycles are bad. Especially between packages on the same level. They make the codebase harder to change (if A depends on B and vice versa, then changing A would most likely require to also change B and both will be effectively one thing) and understand.

That package cycles should be avoided is an open secret (see SEI CERT Oracle Coding Standard for Java SEI CERT Oracle Coding Standard for Java at Carnigen Mellon Software Engineering). But as for example Jens Schauder mentions in his blog entry the awareness among developers seems not to be that high. This awesome article sheds some light on the internal package organization of the Spring Framework. It shows that the Spring guys really take care of their dependencies (in contrast to other as well shortly mentioned Open Source projects). And this was the main motivation for creating Pocketsaw: Having an easy and lightweight tool to model and check the internal package structure.

The following image shows the Pocketsaw package group structure. Yellow boxes are sub-modules while the blue ones are external functionalities. The gray ones are a special case, they represent the shaded libraries which are modeled as sub-modules because they are part of the codebase (see Shaded dependencies). In case of a not allowed code dependency there would be a red arrow whilst in case of a defined but not used in the code dependency a gray arrow would be displayed.

pocketsaw-package-group-structure

Since version 1.2.0 there is an additional visualization available. In the layered sub modules view all allowed dependencies go from top to bottom. Horizontal or bottom to top dependencies are not allowed and marked red.

pocketsaw-layered-sub-modules

In version 1.3.0 the used types per sub-module are available as a visualization. They represent the API exposed to other sub-modules. If a sub-module exposes many types its API is rather wide and might cause maintainability problems in the future.

pocketsaw-used-sub-module-types

Background

Highly inspired by the awesome Jabsaw project. And yes, pocketsaw instead of jabsaw (which is already a smaller jigsaw) is an intended pun. ;-)

The main differences are:

Installation

The Maven artifacts can't be found in an official repository yet (JitPack usage is pending until this issue is resolved).

For building locally these are the prerequisites:

  1. at least JDK 8
  2. a recent Maven
  3. mvn clean install of javascript-es2020-parser 0.5.1

And then mvn clean install in the working directory of this repository.

Workflow for using Pocketsaw in a Java project

Adding of Maven dependency

Add

<dependency>
    <groupId>com.scheible.pocketsaw.impl</groupId>
    <artifactId>pocketsaw-impl</artifactId>
    <version>1.7.1</version>
    <scope>test</scope>
</dependency>

to project.

If not a Spring based project add

<dependency>
    <groupId>io.github.classgraph</groupId>
    <artifactId>classgraph</artifactId>
    <version>4.8.90</version>
    <scope>test</scope>
</dependency>

as well. Currently ClassGraph and its predecessor FastClasspathScanner are the supported classpath scanners. A custom one can simply be used by extending the class com.scheible.pocketsaw.impl.descriptor.ClasspathScanner and using any already available classpath scanning facilities (for example the Reflections library).

ClassGraph plays a dual role because it also provides dependency scan facilities (by extending DependencyAwareClasspathScanner) and can be used with Pocketsaw.analizeClasspath(...) that was introduced in version 1.4.0 of Pocketsaw.

Execution of Pocketsaw in the build

Create a unit test like:

public class PocketsawSubModulesTest {

    private static Pocketsaw.AnalysisResult result;

    @BeforeClass
    public static void beforeClass() {
        result = Pocketsaw.analizeCurrentProject(SpringClasspathScanner.create(PocketsawSubModulesTest.class));
    }

    @Test
        public void todo() {
    }
}

It is assumed that the test class is created in the root package of the project because classpath scanning will start with the package of the class used for classpath scanner creation. For non Spring projects use FastClasspathScanner.create(...) and for Spring based ones SpringClasspathScanner.create(...).

To use Pocketsaw.analizeClasspath(...) ClassGraph is required. Neither SpringClasspathScanner nor FastClasspathScanner provide a dependency scan. To make all project classes available on the classpath the PocketsawSubModulesTest has to be placed in the main project that includes all other sub-projects as dependencies. Then the base package has to defined as a string (even if the test is defined in a package like com.domain.project.app):

public static void beforeClass() {
    result = Pocketsaw.analizeClasspath(
            ClassgraphClasspathScanner.create("com.domain.project"));
}

Auto matching

Since version 1.6.0 auto matching is available. It can be enabled with enableAutoMatching() called on the ClasspathScanner implementation. Sub-module and external functionality descriptors are generated automatically if they are not explicitly defined. For sub-modules sub packages are never included. If this is not the desired behavior for some packages a corresponding @SubModule has to be defined explicitly.

In the following the structure of Pocketsaw is visualized with auto matching enabled: Pocketsaw auto matching

Explicit matching of packages with sub-modules and external functionalities

Add @SubModule and @ExternalFunctionality annotated classed until every package is matched and the unit test passes. In case of not yet matched package a error message like UnmatchedPackageException: The package 'com.scheible.pocketsaw.impl.visualization' was not matched at all! is displayed and either a sub-module or an external functionality has to be added.

@SubModule annotated classes have to be either placed in the root package of the sub-module it describes or in any arbitrary package with basePackageClass set. Setting basePackageClass is for example needed if a sub-module of another project is declared when Pocketsaw.analizeClasspath(...) is used.

The default is that all sub-packages are include as well but this behavor can be override by includeSubPackages = false. For external functionalities a package match pattern has to specified. The syntax supports Ant style pattern (e.g. com.test.* matches all classes in the com.test package and com.test.** matches the classes in the sub-packages too).

The following conventions might be used:

As soon as all packages are matched the dependency graph HTML is generated. It can be found in ./target/pocketsaw-dependency-graph.html. The full path is also printed on standard out while analyzing the project.

Definition of the allowed sub-module dependencies

After every package is matched, uses relations have to be added to the sub-modules until all arrows are green. Uses relations are defined in the @SubModule annotation like @SubModule({SpringBeans}) or @SubModule(includeSubPackages = false, uses = {{SpringBeans}) in case of multiple values.

The following sequence illustrates that process: adding-uses-workflow

Automatic enforcement of allowed dependencies

To make sure that the sub-modules and their dependencies are verify automatically replace the todo() test with:

@Test
public void testNoDescriptorCycle() {
    assertThat(result.getAnyDescriptorCycle()).isEmpty();
}

@Test
public void testNoCodeCycle() {
    assertThat(result.getAnyCodeCycle()).isEmpty();
}

@Test
public void testNoIllegalCodeDependencies() {
    assertThat(result.getIllegalCodeDependencies()).isEmpty();
}

In this example AssertJ is used and displays nice error messages in case of one of the asserts is violated.

In the future the unit test might fail when new packages or additional libraries are added. The approach described in Matching of all packages with sub-modules and external functionalities is used then.

It might also fail if one of the asserts are violated. In this case either the code has to be fixed to remove the not allowed code dependency or an additional usage relation has to be added like described in Definition of the allowed sub-module dependencies.

Using Pocketsaw in an Angular project

Since version 1.1.0 of Pocketsaw in addition to Java-only projects it can be used for asserting the sub-module structure of projects containing an Angular frontend as well.

The first step is to install Dependency Cruiser with npm install --save-dev dependency-cruiser. Next it is easiest to add the following to the scripts section of the package.json:

"dependencies": "dependency-cruise --ts-pre-compilation-deps -T json --exclude \"^node_modules\" src > dependencies.json"

Pocketsaw can then be run via the CLI:

java -jar pocketsaw-1.7.1.jar sub-module.json dependencies.json dependency-cruiser pocketsaw-dependency-graph.html --ignore-illegal-code-dependencies

Angular Tour Of Heros Example

In the following the structure of an Angular Tour Of Heroes example is visualized:

angular-tour-of-heroes-dependencies

The good news is that the "children" of App have no dependencies with their siblings at all. Also, the two-way relation between them and App could perhaps easily be resolved by moving HeroService and MessageService to dedicated sub-directories and therefore sub-modules.

Using Pocketsaw with a Spring Boot JAR

Since version 1.3.0 there is also support for analyzing Spring BOOT JARs "from the outside". That means instead of using annotations in the code an external sub-module.json is used. The use case is to analyze an unmodified code base that does (not yet) use Pocketsaw.

java -jar pocketsaw-1.7.1.jar sub-module.json target/spring-boot-app.jar spring-boot-jar:root-packages=sample.multimodule target/pocketsaw-dependency-graph.html --ignore-illegal-code-dependencies

Spring Boot Multimodule Example

In the following the structure of a Spring Boot Multimodule project is visualized:

disid-multimodule-spring-boot

Using Pocketsaw in a ES6 JavaScript project

Since version 1.5.0 of Pocketsaw ES6 JavaScript projects are natively supported. That means Dependency Cruiser is not required and therefore no Node.js installation at all is needed.

java -jar pocketsaw-1.7.1.jar sub-module.json ./src es6-modules:print-bundle-report=true pocketsaw-dependency-graph.html

Or invocation in a Java unit test:

final Es6ModulesSource es6ModulesSource = new Es6ModulesSource();

result = Pocketsaw.analize(new File("./src/main/frontend/sub-modules.json"),
        es6ModulesSource.read(new File("./src/main/frontend"), printBundleReport(new HashSet<>())),
        Optional.of(new File("./target/pocketsaw-frontend-dependency-graph.html")));

ES6 JavaScript modules example

In the following the structure of the Three.js 3D library is visualized:

threejs-es6-modules

Bundle Report

The bundle report is an experimental analysis of frontend code. It searches for a single sub-module with only outgoing dependencies. This sub-module is treated as the root of the dependency graph. Every dynamic import in the graph is then the entry point of a lazy loaded route and therefor starts a bundle. All sub-modules that belong to more than a single bundle are assigned to the default bundle.

In case of multiple sub-modules with only outgoing dependencies there is since version 1.5.2 also an option available to choose a root sub-module explicitly. Es6ModulesSource.ParameterBuilder.startModule(...) is passed as a parameter to es6ModulesSource#read(...).

The following example sub-module dependency graph from the unit tests results in the bundle report shown under the graph.

unit-test-es6-modules-dependency-graph

Module bundle report:
 * app                    *default*
 * button                 first-page-bundle
 * first-page             first-page-bundle
 * first-page-component   first-page-bundle
 * label                  *default* (second-page-bundle, first-page-bundle)
 * router                 *default*
 * second-page            second-page-bundle
 * second-page-component  second-page-bundle
 * util                   *default* (second-page-bundle, first-page-bundle)

The final bundled app has then 3 bundles: *default* (which is loaded eagerly) and 2 lazy loaded ones (first-page-bundle and second-page-bundle).

Using Pocketsaw with esbuild

Since version 1.7.0 of Pocketsaw esbuild metadata as dependency source is supported. The --metafile flag of esbuild can be used to generate a metadata file.

java -jar pocketsaw-1.7.1.jar sub-module.json ./target/esbuild-metadata.json esbuild-metadata pocketsaw-dependency-graph.html

Or invocation in a Java unit test:

final EsBuildMetadata esBuildMetadata = new EsBuildMetadata();

result = Pocketsaw.analize(new File("./src/main/frontend/sub-modules.json"),
        esBuildMetaData.read(new File("./target/esbuild-metadata.json")),
        Optional.of(new File("./target/pocketsaw-frontend-dependency-graph.html")));

## CLI

Since version 1.1.0 there is CLI support available via the `com.scheible.pocketsaw.impl.cli.Main` class.

usage: pocketsaw {dependency-cruiser|spring-boot-jar|es6-modules|esbuild-metadata} [--ignore-illegal-code-dependencies] [--auto-matching] [--verbose]

options: --ignore-illegal-code-dependencies Does not fail in case of illegal code dependencies. --auto-matching Enables auto matching ( is optional then). --verbose Print detailed information.


Dependency information sources might require specific parameters to be passed.
The format for that is `dependency-source:foo=bar:value=42`.

### Sub-modules descriptors

For CLI usage the sub-modules descriptors are read from a JSON file.
The file format looks like this:

```json
{
  "autoMatching": false,
  "subModules": [
    {
      "name": "First",
      "packageName": "project.first",
      "includeSubPackages": false,
      "color": "red"
    },
    {
      "name": "FirstChild",
      "packageName": "project.first.child",
      "uses": ["First"]
    }
  ],
  "externalFunctionalities": [
    {
      "name": "Spring",
      "packageMatchPattern": [
        "org.springframework.beans.**",
        "org.springframework.context.**"
      ]
    },
    {
      "name": "Guava",
      "packageMatchPattern": "com.google.common.**"
    }
  ]
}

Third-party dependencies information source

To add an third-party dependency information source the interface PackageDependencySource has to be implemented in an separated Maven project. Pocketsaw then uses JDK's ServiceLoader to find and load the dependency source. Therefore a no-args constructor is mandatory. For an example of such an implementation see the one of Dependency Cruiser.

Dependency Cruiser dependency information source

No parameters supported.

NOTE: For now the reported dependencies are limited to TypeScript files excluding all *.spec.ts.

NOTE: Currently no external functionalities are supported.

Spring Boot JAR dependency information source

Required parameters:

Optional parameters:

ES6 Modules dependency information source

Optional parameters:

esbuild metadata dependency information source

Optional parameters:

Maven usage

To automated Pocketsaw execution in a Maven project with an Angular frontend the exec-maven-plugin can be use like this:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>1.6.0</version>
    <executions>
        <execution>
            <id>pocketsaw</id>
            <goals>
                <goal>exec</goal>
            </goals>
            <phase>verify</phase>
            <configuration>
                <executable>java</executable>
                <classpathScope>test</classpathScope>
                <arguments>
                    <argument>-classpath</argument>
                    <classpath/>
                    <argument>com.scheible.pocketsaw.impl.cli.Main</argument>
                    <argument>${project.basedir}/pocketsaw-sub-modules.json</argument>
                    <argument>${project.basedir}/target/dependency-cruiser-dependencies.json</argument>
                    <argument>dependency-cruiser</argument>
                    <argument>${project.basedir}/target/pocketsaw-dependency-graph.html</argument>
                    <argument>--verbose</argument>
                </arguments>
            </configuration>
        </execution>
    </executions>
</plugin>

CLI exit codes

The CLI uses the following exit codes to allow easy scripting:

exit code description
-1 unexpected fatal error
1 no package dependency source was found on classpath
2 wrong arguments
3 also wrong arguments (for backwards compatibility only)
4 found a descriptor cycle
5 found a code cycle
6 found illegal code dependencies

Shaded dependencies

To avoid unnecessary Maven dependency conflicts the following libraries are included shaded:

Licencse

MIT License