Can you create a new Java project from scratch, manually, without copy & pasting? I don't. And probably you shouldn't as this is a pure waste of time considering how repetitive the base code and the directory structure is.
Spring team created for this reason Spring Initializr and it's web representation start.spring.io. All the other major frameworks copied this idea thanks to which it is a no brainer anymore to create a new Spring, Quarkus, Micronaut, Axon or Vaadin project (I am sure there is more).
But not every framework, not every platform offers such tool. Creating AWS Lambda, or Google Function with Java starts with copy & pasting code from documentation or sample projects. Even Maven - the most popular build tool for Java - does not offer simple mvn init
command to start a fresh, non-opinionated project from scratch. Yes, I know there are archetypes but I don't think they are very user-friendly.
There are quite a few project generator frameworks - tools to create your own project templates. The most popular one is probably Yeoman, used by JHipster. Today I came across Cookiecutter, used internally by AWS SAM CLI and decided to give it a try to create a simplified "missing mvn init
" with Cookiecutter.
Cookiecutter
Cookiecutter is a cross-platform tool to create projects from project templates, for any programming language. You can also mix different languages as all it actually cares is processing files and filtering placeholders. It is built with Python, so you must have Python installed on your system to create projects from Cookiecutter templates. You probably should also have some Python knowledge but don't stress too much about it - I have very little and managed to develop what I needed.
Package management in Python is a mess, so it's the best if you consult installation page in Cookiecutter documentation.
I've installed it on MacOS Monterey with this simple command:
$ pip install cookiecutter
Once Cookiecutter is installed, you can create project from local or remote templates:
$ cookiecutter local-template # from local directory
$ cookiecutter https://github.com/user/repo # from git repository
Cookiecutter project template
Project template must contain following elements:
- empty
__init__.py
file cookiecutter.json
with a list of parameters that user provides to generate a project together with default values:
{
"groupId": "com.example",
"artifactId": "demo",
"javaVersion": "11"
}
- generated project directory, which name can be also filtered. In our case it will be a directory named
\{\{ cookiecutter.artifactId \}\}
- exactly like that, with curly brackets in the name. Cookiecutter during project generation will replace this placeholder with an actual value.
During project generation, Cookiecutter replaces all placeholders like \{\{ cookiecutter.property-name \}\}
in all files in the \{\{ cookiecutter.artifactId \}\}
directory with a value provided by a user.
In our case complete project structure looks like this:
.
├── __init__.py
├── cookiecutter.json
└── \{\{cookiecutter.artifactId\}\}
├── pom.xml
└── src
└── main
└── java
└── demo
└── Main.java
Where pom.xml
contains:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>\{\{ cookiecutter.groupId \}\}</groupId>
<artifactId>\{\{ cookiecutter.artifactId \}\}</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>\{\{ cookiecutter.javaVersion \}\}</maven.compiler.source>
<maven.compiler.target>\{\{ cookiecutter.javaVersion \}\}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
And Main.java
:
package demo;
public class Main {
public static void main(String[] args) {
System.out.println("Hello world");
}
}
Generating project from the template
Now if we move one directory up from my-template
, we can create a new project:
$ cookiecutter my-template
We will be prompted to provide values for:
groupId [com.example]:
artifactId [demo]:
javaVersion [11]:
and the project will be generated 🎉
But we can do better.
- instead of generating
demo
Java package, the template should create a package that corresponds to thegroupId
and theartifactId
. - it would be nice if generated project had Maven Wrapper installed
- it would be nice if right after project generation, all the dependencies be installed
For that, we will use private variables and Hooks - simple and handy way to generate project a bit more dynamically.
Private Variables
cookiecutter.json
can contain private variables that user won't be required to fill. In our case we can use it to compute the project root package based on the groupId
and the artifactId
:
{
"groupId": "com.example",
"artifactId": "demo",
"javaVersion": "11",
"__package": "\{\{ cookiecutter.groupId + '.' + cookiecutter.artifactId \}\}"
}
Cookiecutter Hooks
Hooks can run either before or after project is generated and can be written either with Bash or Python. These scripts get also filtered with Cookiecutter parameters from cookiecutter.json
.
Our hook will address all the issues listed above. Since we are going to create the directory structure dynamically, we can remove src/main/java/demo
directory and move Main.java
file to the root of the \{\{ cookiecutter.artifactId \}\}
directory. Instead of hardcoding demo
pacakge in Main.java
, we can use now \{\{ cookiecutter.__package \}\}
variable:
package \{\{cookiecutter.__package\}\};
public class Main {
public static void main(String[] args) {
System.out.println("Hello world");
}
}
Then:
Create a directory
hooks
in your project template directory.Create a file
hooks/post_gen_project.py
import os
# converts groupId like com.example and artifact id like demo into a string com/example/hello
directory = '\{\{ cookiecutter.__package.replace('.','/') \}\}'
srcDir = 'src/main/java/' + directory
# create typical maven directory structure
os.makedirs(srcDir, exist_ok=True)
# move Main.java to Maven sources
os.rename("Main.java", srcDir + '/Main.java')
# generate Maven wrapper
os.system('mvn wrapper:wrapper')
# download dependencies
os.system('./mvnw verify')
Conditionals
It may and likely will happen that some pieces of projects are created or not depending on the values provided by the user. We can extend cookiecutter.json
to ask if project should include JUnit, and if it does then include the dependency in pom.xml
and create src/test/java
directory.
{
"groupId": "com.example",
"artifactId": "demo",
"javaVersion": "11",
"junit": [
"yes",
"no"
],
"__package": "\{\{ cookiecutter.groupId + '.' + cookiecutter.artifactId \}\}"
}
Cookiecutter uses Jinja2 templating system. Let's update pom.xml
with:
{% if cookiecutter.junit == 'yes'%}
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.0</version>
<scope>test</scope>
</dependency>
</dependencies>
{% endif %}
Our hook, now must create src/test/java
directory depending on the value of cookiecutter.junit
variable:
if '\{\{ cookiecutter.junit \}\}' == 'yes':
os.makedirs(testDir, exist_ok=True)
Try it out!
The following code is pushed to https://github.com/maciejwalkowiak/cookiecutter-maven-template repository. You can try it yourself by executing:
$ cookiecutter https://github.com/maciejwalkowiak/cookiecutter-maven-template
And just to make it clear - this project is not meant to be "the missing mvn init" - at this stage it does not offer much more than regular mvn archetype:generate
, but if you think it is an idea worth pursuing - PRs are welcome!
Conclusion
Cookiecutter is an interesting, simple but powerful option for generating project templates. From the user point of view - it is quite rough. It misses bells and whistles, animations, interactive CLI comboboxes that you may be familiar with from tools like Yeoman. But it does the job. I like concept of hooks, thanks to which it is very easy to code more advanced use cases. The drawback - purely from personal point of view - it is written in Python, which I am not a fan of. But that's just a matter of taste.