Write Spring-less Java web apps as simple as on Node.js

When thinking of Java web applications, what comes to mind first? Spring, endless annotations, XML configurations, containers, layers upon layers of abstractions, and boilerplate code. A lot of boilerplate code. Node.js approach seems to be so much simpler, and this is probably one of the reasons newbies prefer Node.js to Java. But what if I told you Java can be that simple too?

To not sound unfounded and make everything illustrative, let’s rewrite a personal blog from this article and make it slightly different. Instead of Node.js for backend, we will write it in Java using DataKernel framework. The other parts of the implementation will stay the same. So, you can easily compare both approaches for the same task.

The final result will still look like this:

Note, the frontend part won’t be described here as we just leave it the same as in the original source, thanks to the author. You can add it later on by yourself.

Get started

Let’s nail out the basics. You should probably have some experience with Java to successfully complete the tutorial. Also make sure that you have all the necessary prerequisites installed:

  • JDK 1.8+
  • IDE (might be IntelliJ IDEA)
  • Maven 3.0+
  • Node.js 8+
  • MongoDB

Step-by-step guide

Backend

At this point we’re ready to dive into backend implementation. Create the new Maven project in the IDE. Your project structure should look like this:

Then add Maven dependency to your project by configuring your pom.xml file in the following way:

    
<?xml version="1.0" encoding="UTF-8"?>
<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/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.tutorial</groupId>
    <artifactId>blog-tutorial</artifactId>
    <version>1.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>io.datakernel</groupId>
            <artifactId>datakernel-launchers-http</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>io.datakernel</groupId>
            <artifactId>datakernel-codec</artifactId>
            <version>3.1.0</version>
        </dependency>
      <dependency>
        <groupId>org.mongodb</groupId>
        <artifactId>mongodb-driver-sync</artifactId>
        <version>3.11.0</version>
      </dependency>
      <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.1.3</version>
        </dependency>
    </dependencies>
</project>
Note: Make sure that your project SDK is set 1.8+

Server setup

We are going to set up our server in the src/main/java folder. Let’s create a BlogLauncher.java class here. It will incorporate the main logic of our app and take care of application lifecycle.

Instead of middleware chains, DataKernel provides another concept named servlets. You don’t need an Express Router anymore, we will use RoutingServlet to handle all the endpoints. To handle requests independently and preserve modularity we create three async servlets:

  • staticServlet - takes care of our front
  • failServlet - handles possible errors
  • rootServlet - uses both of them, returns a RoutingServlet with CRUD operations processing.

So your BlogLauncher should look like this:

public class BlogLauncher extends HttpServerLauncher {
	@Provides
	@Named("app")
	Config config() {
		return ofClassPathProperties("blog.properties");
	}

	@Override
	protected Module getBusinessLogicModule() {
		return new DbModule();
	}

	@Provides
	Executor executor() {
		return newCachedThreadPool();
	}

	@Provides
	@Named("static")
	AsyncServlet staticServlet(Executor executor) {
		return StaticServlet.ofClassPath(executor, "dist")
				.withIndexHtml();
	}

	@Provides
	@Named("failed")
	AsyncServlet failServlet() {
		return $ -> notFound404();
	}

	@Provides
	public AsyncServlet rootServlet(@Named("failed") AsyncServlet failServlet,
									@Named("static") AsyncServlet staticServlet,
									ArticleSchemaDao articleDao) {
		return mapException(Objects::nonNull, failServlet)
				.serve(RoutingServlet.create()
						.map("/*", staticServlet)
						.map("/api/articles/*", RoutingServlet.create()
								.map(GET, "/", $ -> ok200()
										.withJson(ofList(ARTICLE_CODEC_FULL),
												articleDao.findAll()
														.stream()
														.sorted(reverseOrder())
														.collect(Collectors.toList())))
								.map(POST, "/", loadBody().serve(request -> {
									try {
										return ok200()
												.withJson(ARTICLE_CODEC_FULL,
														articleDao.save(fromJson(ARTICLE_CODEC, request.getBody().getString(UTF_8))));
									} catch (ParseException e) {
										return ofCode(400);
									}
								}))
								.map(GET, "/:id", request -> {
									ArticleSchema foundArticle = articleDao.findById(request.getPathParameter("id"));
									return foundArticle == null ?
											ofCode(404) :
											ok200().withJson(ARTICLE_CODEC_FULL, foundArticle);
								})
								.map(DELETE, "/:id", request -> {
									articleDao.delete(request.getPathParameter("id"));
									return ok200();
								})
								.map(PATCH, "/:id", loadBody().serve(request -> {
									try {
										ArticleSchema updatedArticle = articleDao.update(request.getPathParameter("id"),
												fromJson(ARTICLE_CODEC, request.getBody().getString(UTF_8)));
										return updatedArticle == null ?
												ofCode(400) :
												ok200().withJson(ARTICLE_CODEC_FULL, updatedArticle);
									} catch (ParseException e) {
										return ofCode(400);
									}
								}))));
	}

	public static void main(String[] args) throws Exception {
		HttpServerLauncher launcher = new BlogLauncher();
		launcher.launch(args);
	}
}

Looking at the above code, you can note:

  • DataKernel provides powerful Dependency Injection module - we just mark the needed provider methods with the @Provides annotation and all the required components will be created automatically.
  • Usually these recipes can be put in the separate Module and then taken right in the place where we need them. Like DbModule in the getBusinessLogicModule(). Wait a second and we will deal with the DbModule and other configs.
  • DataKernel supports the Promise concept. For example, in GET processing, Promise.of() will return a successfully created Promise with the given content.
  • Since BlogLauncher extends HttpServerLauncher, we can easily launch our server with the help of the launch method in the main method.

DB setup

To make this example look similar to the application from the Node.js tutorial we will use MongoDB too, but with the Java client. Let’s add some new classes, so the structure of src/main/java directory will eventually look like this:

Add a blog resource bundle in the resources directory with the following content:

mongo.url=mongodb://localhost:27017
mongo.database=blog-database

In the DbModule.java we will provide MongoDB configurations:

public class DbModule extends AbstractModule {

	@Provides
	MongoClient mongoClient(MongoClientSettings settings) {
		return MongoClients.create(settings);
	}

	@Provides
	MongoClientSettings settings(@Named("app") Config config, CodecRegistry codecRegistry) {
		return MongoClientSettings.builder()
				.applyConnectionString(new ConnectionString(config.get("mongo.url")))
				.codecRegistry(codecRegistry)
				.build();
	}

	@Provides
	CodecRegistry codecRegistry() {
		return fromRegistries(MongoClientSettings.getDefaultCodecRegistry(),
				fromProviders(PojoCodecProvider.builder().automatic(true).build()));
	}

	@Provides
	MongoDatabase database(MongoClient mongoClient, @Named("app") Config config) {
		return mongoClient.getDatabase(config.get("mongo.database"));
	}

	@Provides
	MongoCollection<ArticleSchemaDao.ArticleSchema> articlesCollection(MongoDatabase database) {
		return database.getCollection("articles", ArticleSchemaDao.ArticleSchema.class);
	}

	@Provides
	@Export
	ArticleSchemaDao schemaDaoDb(MongoCollection<ArticleSchemaDao.ArticleSchema> collection) {
		return new ArticleSchemaDaoDb(collection);
	}
}

Note, schemaDaoDb() is marked with @Export annotation which allows us to explicitly say which instances we want to be exported from the module. ArticleSchema, ArticleSchemaDao and ArticleSchemaDaoDb that are used here will be shown next.

In ArticleSchemaDao.java we will simply create an interface which will be implemented in the ArticleSchemaDaoDb, and also the ArticleSchema - a plain java class with fields, constructors, and accessors.

ArticleSchemaDao:

public interface ArticleSchemaDao {
	ArticleSchema save(ArticleSchema articleSchema);

	List<ArticleSchema> findAll();

	@Nullable
	ArticleSchema findById(String id);

	void delete(String id);

	@Nullable
	ArticleSchema update(String id, ArticleSchema articleSchema);

	@SuppressWarnings({"unused"})
	final class ArticleSchema implements Comparable<ArticleSchema> {
		private ObjectId id;
		private String title;
		private String body;
		private String author;
		private Date createdAt;
		private Date updatedAt;

		public ArticleSchema() {
		}

		public ArticleSchema(String title, String body, String author) {
			long now = System.currentTimeMillis();
			this.id = new ObjectId();
			this.title = title;
			this.body = body;
			this.author = author;
			this.createdAt = new Date(now);
			this.updatedAt = new Date(now);
		}

		public ArticleSchema(ObjectId id, String title, String body, String author, Date createdAt, Date updatedAt) {
			this.id = id;
			this.title = title;
			this.body = body;
			this.author = author;
			this.createdAt = createdAt;
			this.updatedAt = updatedAt;
		}

		public ObjectId getId() {
			return id;
		}

		public ArticleSchema setId(ObjectId id) {
			this.id = id;
			return this;
		}

		public String getTitle() {
			return title;
		}

		public ArticleSchema setTitle(String title) {
			this.title = title;
			return this;
		}

		public String getBody() {
			return body;
		}

		public ArticleSchema setBody(String body) {
			this.body = body;
			return this;
		}

		public String getAuthor() {
			return author;
		}

		public ArticleSchema setAuthor(String author) {
			this.author = author;
			return this;
		}

		public Date getCreatedAt() {
			return createdAt;
		}

		public ArticleSchema setCreatedAt(Date createdAt) {
			this.createdAt = createdAt;
			return this;
		}

		public Date getUpdatedAt() {
			return updatedAt;
		}

		public ArticleSchema setUpdatedAt(Date updatedAt) {
			this.updatedAt = updatedAt;
			return this;
		}

		public void update() {
			updatedAt.setTime(System.currentTimeMillis());
		}

		@Override
		public int compareTo(@NotNull ArticleSchema articleSchema) {
			return createdAt.getTime() > articleSchema.createdAt.getTime() ? 1 : -1;
		}
	}
}

ArticleSchemaDaoDb:

public class ArticleSchemaDaoDb implements ArticleSchemaDao {
	private final MongoCollection<ArticleSchema> collection;

	public ArticleSchemaDaoDb(MongoCollection<ArticleSchema> collection) {
		this.collection = collection;
	}

	@Override
	public ArticleSchema save(ArticleSchema articleSchema) {
		collection.insertOne(articleSchema);
		return articleSchema;
	}

	@Override
	public List<ArticleSchema> findAll() {
		ArrayList<ArticleSchema> articles = new ArrayList<>();
		collection.find().forEach((Consumer<? super ArticleSchema>) articles::add);
		return articles;
	}

	@Nullable
	@Override
	public ArticleSchema findById(String id) {
		return collection.find(new BasicDBObject("_id", new ObjectId(id))).first();
	}

	@Override
	public void delete(String id) {
		collection.deleteOne(new BasicDBObject("_id", new ObjectId(id)));
	}

	@Nullable
	@Override
	public ArticleSchema update(String id, ArticleSchema articleSchema) {
		collection.updateOne(new BasicDBObject("_id", new ObjectId(id)),
				new BasicDBObject("$set", new BasicDBObject()
						.append("title", articleSchema.getTitle())
						.append("body", articleSchema.getBody())
						.append("author", articleSchema.getAuthor())
						.append("updatedAt", new Date())));
		return findById(id);
	}
}

Another important point is that DataKernel provides a Codec module which encodes and decodes custom objects. That’s quite important in our example since we work with JSON and want to encode/decode ArticleSchema objects to/from JSONs. For this purpose, let’s write down the following code in the ArticleCodecs:

@SuppressWarnings("WeakerAccess")
public final class ArticleCodecs {
	public static final StructuredCodec<ArticleSchema> ARTICLE_CODEC = object(ArticleSchema::new,
			"title", ArticleSchema::getTitle, STRING_CODEC,
			"body", ArticleSchema::getBody, STRING_CODEC,
			"author", ArticleSchema::getAuthor, STRING_CODEC);
	private static final StructuredCodec<Date> DATE_CODEC = LONG_CODEC.transform(Date::new, Date::getTime);
	private static final StructuredCodec<ObjectId> OBJECT_ID_STRUCTURED_CODEC = STRING_CODEC.transform(ObjectId::new, ObjectId::toHexString);
	public static final StructuredCodec<ArticleSchema> ARTICLE_CODEC_FULL = object(ArticleSchema::new,
			"_id", ArticleSchema::getId, OBJECT_ID_STRUCTURED_CODEC,
			"title", ArticleSchema::getTitle, STRING_CODEC,
			"body", ArticleSchema::getBody, STRING_CODEC,
			"author", ArticleSchema::getAuthor, STRING_CODEC,
			"createdAt", ArticleSchema::getCreatedAt, DATE_CODEC,
			"updatedAt", ArticleSchema::getUpdatedAt, DATE_CODEC)
			.transform(value -> value.setTitle("article"), articleSchema -> articleSchema);
}

Frontend

All we need is to add the frontend part and that’s it! Create a new client directory alongside src directory and fill it with this content. Don’t forget to install dependencies with npm i in the client directory. It should look like this:

Running the application

Now let’s test the application.

  • Don’t forget to start MongoDB:
sudo service mongod start
  • Run npm run build from the client directory.
  • Move generated dist folder to the resources in src/main.
  • Run BlogLauncher.main(), then open your favourite browser and go to localhost:8080.

Summary

Congratulations, you’ve just created a Java backend for the personal blog app. It wasn’t that hard, right?

What is next?

To see how you can use DataKernel, take a look at DataKernel tutorials or docs. Thank you for reading this tutorial and if you have faced any problems, take into account the full example sources on GitHub.