This is an example project, to showcase how a gradle multi-module projects can be used to have a single code base for spring-angular projects.
This project utilizes the following tools and frameworks
- Spring as server/backend-framework
- Angular as frontend-framework
- (Kotlin) Gradle as build tool for spring
- Node as Fe build tool
- Jib as Docker build tool
This project is designed to fill the following requirements:
- Everything is packaged together as a single docker container with a single gradle command (
gradlew jibDocker
) - The BE generates an OpenApi specification
- The OpenApi Specification is used to generate an angular client
- The angular application consumes this client to make request to the BE
- The angular application is served via the BE (this relates to the first point)
Use your favorite IDE for that. I personally recommend IntelliJ. The project should contain at this point
- the gradle wrapper
- build.gradle.kts
- gradlew
- gradlew.bat for windows
- settings.gradle.kts
Create your spring project with the spring initializer. I used again IntelliJ for that, but you can also use the Spring Initializer.
Delete the gradle wrapper, gradlew and gradlew.bat files from the spring project. We only need them in the top level
project. Finally, open the settings.gradle.kts
of the parent project and include the backend project. It should look
in the end something like this
rootProject.name = "spring-angular-demo"
include("be")
Your repository should look something like this at the end.
Refresh your gradle project. If you use IntelliJ the gradle tab on the right should look something like this. Note: The BE is part if the parent project. If the BE is still registered as a module of its own, right click it and unlink the BE
Now comes the angular project. Again use your favorite tool to create an angular project. My is named fe
and it must
be placed in a folder like the backend project. Your project should look something like this now.
Create two new files named settings.gradle.kts
and build.gradle.kts
in the fe directory with the following
content
// build.gradle.kts
plugins {
java
}
group = "com.github.simonhauck"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
# settings.gradle.kts
rootProject.name = "fe"
Finally, add io the root settings.gradle.kts
the line include("fe")
. It should look now like this
rootProject.name = "spring-angular-demo"
include("be")
include("fe")
It would be easier to set this project up with an api-first approach. There you would have the openApi json first and would create client and server stubs directly.
In this project we want to use the code-first approach and document our api with the springdoc-openApi project. This requires a bit more setup because we want to
build be --> generate api json --> build fe client --> build fe --> include fe in be build
But since the backend is already built it is a bit more complicated to include. I will demonstrate my proposed solution in the next steps.
Let's start by adding the following dependencies in our be project. Since this is a kotlin project, i will use the Kotlin dependencies additionally but for java it should be quite similar.
// in be module : build.gradle.kts
dependencies {
// other dependencies
// OpenApi / Swagger
implementation("org.springdoc:springdoc-openapi-ui:1.6.6")
implementation("org.springdoc:springdoc-openapi-kotlin:1.6.5")
}
Now create a RestController and a response object. Below is my example
@RestController
@RequestMapping("example")
class ExampleController {
@GetMapping("hello")
fun getHelloWorld(): HelloWorldDto {
return HelloWorldDto("Word: ${System.currentTimeMillis()}")
}
}
data class HelloWorldDto(
val response: String,
)
Start the backend project, and you should see
- on http://localhost:8080/swagger-ui/index.html the swagger ui
- on http://localhost:8080/v3/api-docs the open api json
Now lets start generating the openApi json as part of our build. We will need
the springdoc-openapi-gradle-plugin plugin. Add it as
dependency in the backend module in the build.gradle.kts
// be build.gradle.kts
plugins {
// other plugins
id("org.springdoc.openapi-gradle-plugin") version "1.3.3"
id("com.github.johnrengelman.processes") version "0.5.0" // This one is also required for me to compile the be. If it works without it, remoe it
}
// Configure the path of the json
openApi {
outputDir.set(file("$buildDir/docs"))
}
Now you can generate the openApi json with the command gradlew generateOpenApiDocs
The client will be generated with the gradle open api generator . In the fe module add the following plugin and the configuration. This will generate and angular client with the specified version. It will use the openapi.json from the backend and place the generated code in the build directory of the frontend. Of course change this stuff to match your personal needs ;)
// fe build.gradle.kts
plugins {
// Other plugins
id("org.openapi.generator") version "5.4.0"
}
val angularBindingPath = "$buildDir/angular-binding/generated"
val beBuildDir = project(":be").buildDir
val openApiJsonFile = "$beBuildDir/docs/openapi.json"
openApiGenerate {
generatorName.set("typescript-angular")
inputSpec.set(openApiJsonFile)
outputDir.set(angularBindingPath)
configOptions.set(
mapOf(
"npmName" to "@mycompany/myproject-api",
"snapshot" to "false",
"npmVersion" to "1.0.0",
"ngVersion" to "13.2.0",
)
)
}
To have a fully working task we have to configure it correctly in the frontend build.gradle.kts
. The task should
delete the old api binding when being executed (that there are no leftovers), depend on the openapi.json. To achive
this, add the following configuration.
// fe build.gradle.kts
import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
val generateApi = tasks.withType<GenerateTask>() {
dependsOn(":be:generateOpenApiDocs")
doFirst {
delete(angularBindingPath)
}
inputs.file(openApiJsonFile)
outputs.dir(angularBindingPath)
}
Now you should be able to generate the fe task with gradlew :fe:openApiGenerate
. Note: its importent to specify
the :fe
else it could lead to compile errors.
If everything worked, you should have a typescript client in your fe/build/angular-binding/generated
directory.
Before we use the api client, lets build the fe with gradle. For local development, It's still possible to
use ng serve
or any other command, but for production we will build the frontend, pack the generated sources in a
jar, which will be included and served in the backend.
The angular project will be build with the gradle node plugin. This gives us the option to run different npm/yarn tasks.
Add the plugin in the fe module and add the following configuration. This will download a node and place in the .cache folder of the module. This node will be used for all our other tasks.
// fe build.gradle.kts
plugins {
// --- Other plugins
id("com.github.node-gradle.node") version "3.2.0"
}
node {
version.set("16.10.0")
download.set(true)
workDir.set(file("${project.projectDir}/.cache/nodejs"))
}
After that, we can create an buildAngularTask
with this configuration in our frontend build.gradle.kts
. To get
proper gradle layer caching we define the src directory as input as well as the generatedApiCode. So if the backend api
changes, the frontend is also rebuild. Also we want the npm install task to wait for generatedApiCode. This will be
important later. But the essence is, our npm install will fail when our dependency is not here ;)
// fe build.gradle.kts
import com.github.gradle.node.npm.task.NpmInstallTask
import com.github.gradle.node.npm.task.NpmTask
val buildAngularTask = tasks.register<NpmTask>("buildAngular") {
dependsOn(tasks.npmInstall)
npmCommand.set(listOf("run", "build"))
inputs.dir("src")
inputs.dir(angularBindingPath)
outputs.dir("${buildDir}/dist")
}
tasks.withType<NpmInstallTask>() {
dependsOn(tasks.getByName("openApiGenerate"))
}
Now comes already the last step for the frontend. We will pack the generated sources as jar so that we can easily include them in the backend. Add the following configuration. We define the folder with the generated resources as sourceSet so that it is picked up by the jar task.
The jar task depends on the angular build and copies the output from this directory of the static folder of the jar. Spring is so smart, that we can serve static resources later automatically.
// fe build.gradle.kts
val angularBuildDir = "dist/fe"
java.sourceSets.create("angular") {
resources.srcDir(angularBuildDir)
}
// Pack compiled angular in jar
tasks.withType<Jar> {
dependsOn(buildAngularTask)
from(angularBuildDir)
into("static")
}
Now comes the first tricky part. The generated typescript code is not compiled and (atleast for me) this leads to compile errors. So in theory, you can locally compile and pack a npm package. This works in theory, but node caching lead to some problems for me. The package was "updated" when the api changed, but some export statements where not available at runtime.
One possible solution can be, to compile, pack and publish the dependency to a registry. But in that case, we would lose our advantage to build everything locally with one command. So I decided to compile the library with the other angular code. This even saves some build time ;)
So lets get started. First add the dependency in your package.json
{
// other package.json stuff
"dependencies": {
// other libs
"@mycompany/myproject-api": "file:build/angular-binding/generated"
}
}
To compile the library with your project open the tsconfig.json
and add teh following path
section under the
compile options.
To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
// Other stuff
"paths": {
// This issue is the reason why we have to do this https://github.com/angular/angular/issues/25813
"@angular/*": ["./node_modules/@angular/*"],
"@mycompany/myproject-api": ["./build/angular-binding/generated"]
}
},
}
Run a npm install and it should already work. You can test it by creating a simple service that calls our api. To prevent CORS issues, add this configuration to your BE (TODO Add Config).
Create a simple angular service and make sure the constructor is actually called. In the appModule app.module.ts
you
have to also add the HttpClientModule to make those reqeuest.
// Example service
import { Injectable } from '@angular/core';
import { ExampleControllerService } from "@mycompany/myproject-api";
@Injectable({
providedIn: 'root'
})
export class ExampleService {
constructor(private api: ExampleControllerService) {
console.log("Test")
api.getHelloWorld().subscribe(value => console.log("Received: " + value.response));
}
}
// App module with HttpClient
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpClientModule, //<---- This is important
],
providers: [ExampleService],
bootstrap: [AppComponent]
})
export class AppModule {
}
Start the BE and fe and give it a quick test :) I received only a blob
as response which is quite strange. In theory
this should not matter, but if you specify in the backend that the default return type is json, everything works. Modify
your application.yml / application.property file accordingly. I hope this will be fixed in the future. If you find a
solution, please open a pull request.
springdoc:
default-produces-media-type: application/json
With all those obstacles tackled, i and hopefully you too, have a proper api response...hurrayyy :D
Note: For better code completion sense you can (at least in IntelliJ) mark the angular-binding
folder as
generatedSources by right click > mark directory as > generated sources.
Now to the grand finale. Lets bundle it all together and deploy it as docker container. I will use Jib for that. Add the gradle plugin and configure your container. The registry credentials are of course optional and can be used with a CI for example.
// In be build.gradle.kts
plugins {
// Other plugins ...
id("com.google.cloud.tools.jib") version "3.2.0"
}
jib {
to {
val registry = System.getenv("REGISTRY_URL") ?: "local"
image = "$registry/company/${project.name}"
tags = setOf("${project.version}")
auth {
username = System.getenv("REGISTRY_USERNAME")
password = System.getenv("REGISTRY_PASSWORD")
}
}
container {
ports = listOf("8080")
}
}
Last, we have to add the FE project as dependency and make jib depend on it. As stated at the beginning, we can't just include the frontend as a normal implementation dependency because we would have gradle build cycles. So the fe will not be available with the normal bootJar task! (Atle ast for this does not matter. For development i can start the FE manually and for production, in jib, its packed correctly.)
Open your build.gradle.kts in the backend porject and create a new configuration like the following snippet below.
val feBuildConfiguration: Configuration by configurations.creating {} // <--- Create a new config
dependencies {
// ... Other dependencies
// Web frontend will be wired after compilation
feBuildConfiguration(project(":fe")) // <---- this configuration can define dependencies
}
Now we can define a new task, that depends on the feBuildConfiguration
and copies the static files in our build
directory. Additionally, we configure our jib task to depend on the copyFeToBuildDirTask
task.
// be build.gradle.kts
import com.google.cloud.tools.jib.gradle.JibTask
// Add angular fe to be served from BE
val copyFeToBuildDirTask = tasks.register<ProcessResources>("copyFeToBuildDir") {
dependsOn(feBuildConfiguration)
val zipTree = zipTree(feBuildConfiguration.singleFile)
from(zipTree)
into("$buildDir/resources/main")
}
tasks.withType<JibTask> {
dependsOn(copyFeToBuildDirTask)
}
Build the container with gradlew be:jibDockerBuild
. Afterwards you should be able to start it
with docker run -p 8080:8080 local/company/be:0.0.1-SNAPSHOT
If you have come so far, you should see we are nearly done. The frontend is served via the backend. THe api generation works (nearly perfect). For other host you should overwrite the api base-path.
Now let's override the base api path in production, so that the projects works regardless of the host and port. In the
angular environmen.ts
introduce a new serverBase
value and fill it with the server url. This will probably
be http://localhost:8080
.
export const environment = {
production: false,
serverBase: 'http://localhost:8080',
};
In the production config, we can override this value to use the host url, like shown in the following snippet.
// environment.prod.ts
export const environment = {
production: true,
serverBase: window.location.origin,
};
In your app.module.ts
add a new BASE_PATH
provider like in the following example
import { BASE_PATH } from "@mycompany/myproject-api";
import { environment } from "../environments/environment";
@NgModule({
declarations: [
AppComponent,
],
// ... other imports ...
providers: [{provide: BASE_PATH, useValue: environment.serverBase}],
})
export class AppModule {
}
For some projects it might be required to provide a .jar. As you may have noticed, the spring bootJar does not contain the fe because of the build cycle. There are different ways how this issue can be solved. One solution I want to showcase here uses the gradle plugin shadow plugin. With this plugin we can easily create another jar, that contains the backend and frontend code and works more a less the same as the docker container.
Start by adding the shadow gradle plugin to the backend build.gradle.kts
.
// be build.gradle.kts
plugins {
// ... Other plugins
id("com.github.johnrengelman.shadow") version "7.1.2"
application // <- optional. Offers the runShadow gradle task and can create bash/batch scripts to start the jar
}
To get a working jar with all the spring dependencies, we have to add the following configuration in our
backend build.gradle.kts
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer
tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
// Include spring stuff to get a fat jar
mergeServiceFiles()
append("META-INF/spring.handlers")
append("META-INF/spring.schemas")
append("META-INF/spring.tooling")
transform(com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer::class.java) {
paths = listOf("META-INF/spring.factories")
mergeStrategy = "append"
}
}
Last but not least, the shadowJar task must depend on the copyFeToBuildDirTask, which we have already created for the docker build and we have to specify the mainClass
// be build.gradle.kts
tasks.withType<ShadowJar> {
dependsOn(copyFeToBuildDirTask)
}
// This is only required when the application plugin is imported
application {
mainClass.set("com.github.simonhauck.be.BeApplicationKt")
}
If you have only the shadowJar plugin, you can generate the jar with gradlew shadowJar
. The generated jar will be in
the build folder (it should be the one with the suffix "all" in /build/libs). You should be able to run it like every
other jar with java -jar be-0.0.1-SNAPSHOT-all.jar
If you have the application plugin installed, you can also use the gradlew runShadow
task to build and run the jar.
I hope the instructions are detailed enough, so that every person that makes it this far has a project with frontend and backend running together without any issues.
My recommended development flow is: For local development start the backend and frontend separately and let the ci build the final project. If you ever need to test/debug something locally with the fill stack, use the runShadow task. At least from IntelliJ you can debug the jar.
For an even better development experience, you may have to teak the gradle input/outputs for the individual tasks, so
that the gradle caching works its magic. If this is correctly configured, you can also use the parallel
flag gradlew someTask --parallel
to execute tasks even faster.
If you have any feedback or suggestions, please let me know.