7. Easily configure your Micronaut with MongoDB – character controller example

In the last few chapters we’ve setup a basic Micronaut project with JWT authentication against Postgres table.

You can find the chapters here:

The code that we’re going through will be available in the GitHub repository, do note that the repository will grow as this blog progresses.

In this chapter, we will look to integrate MongoDB into our project and then create our character controllers.

Let’s just quickly explain why we’re doing this. This guide will look to create a basic MMO server, we will want a user to login and create a character. Once logged into the character we will want to move around the world and update the movement on the server (this will be thousands of transactions per second, millions if you have many users). Standard SQL databases, such as Postgres and MySQL will not scale too well with the requirements that we need. Redis sounds like a good alternative, but MongoDB can be horizontally scaled which I think will give the edge.

Here’s a brief overview of things we look at in this chapter:

Youtube overview of connecting MongoDB to your Micronaut project

Configuring MongoDB into your Micronaut project

The implementations are largely based on this guide.

Let’s get started with first adding the build.gradle dependencies

    // MongoDB
    implementation("io.micronaut.mongodb:micronaut-mongo-reactive")
    testImplementation("de.flapdoodle.embed:de.flapdoodle.embed.mongo:2.0.1")

Once this is done, let’s configure your resources/application.yml, add the following entries:

mongodb:
#  Set username/password as env vars
  uri: mongodb://mongo_mmo_server:mongo_password@localhost:27017/mmo_server?authSource=admin
  options:
    maxConnectionIdleTime: 10000
    readConcern: majority
#  For reactive MongoDB driver:
  cluster:
    maxWaitQueueSize: 5
  connectionPool:
    maxSize: 20

player-character:
  databaseName: "mmo-server"
  collectionName: "characters"

As always, you should keep your credentials as environment variables.

The player-character section is semi optional, we’re going to reference it in the next configuration file for ease of use; java/server/configuration/PlayerCharacterConfiguration.java

package server.configuration;

import io.micronaut.context.annotation.ConfigurationProperties;
import lombok.Data;

@ConfigurationProperties("player-character")
@Data
public class PlayerCharacterConfiguration {

    private String databaseName;
    private String collectionName;
}

This configuration file will just prevent us having a static variable(s) in class, we will be able to control it via the application.yml parameters for ease of access.

Repository Class

With these configurations done and out of the way, we can get to the meaty part, let’s get started with the repository class: java/server/player/character/repository/PlayerCharacterRepository.java

package server.player.character.repository;

import com.mongodb.client.model.Indexes;
import com.mongodb.client.result.DeleteResult;
import com.mongodb.reactivestreams.client.MongoClient;
import com.mongodb.reactivestreams.client.MongoCollection;
import io.reactivex.Flowable;
import io.reactivex.Maybe;
import io.reactivex.Single;
import io.reactivex.subscribers.DefaultSubscriber;
import server.configuration.PlayerCharacterConfiguration;
import server.player.character.dto.Character;

import javax.annotation.PostConstruct;
import javax.inject.Singleton;
import javax.validation.Valid;
import java.util.List;

import static com.mongodb.client.model.Filters.eq;

@Singleton
public class PlayerCharacterRepository {
    // This repository is connected to MongoDB
    PlayerCharacterConfiguration configuration;
    MongoClient mongoClient;
    MongoCollection<Character> characters;

    public PlayerCharacterRepository(
            PlayerCharacterConfiguration configuration,
            MongoClient mongoClient) {
        this.configuration = configuration;
        this.mongoClient = mongoClient;
        this.characters = getCollection();
    }

    @PostConstruct
    public void createIndex() {
        // Micronaut does not yet support index annotation, we have to create manually
        // https://www.javaer101.com/en/article/20717814.html

        characters.createIndex(Indexes.text("name"))
                .subscribe(new DefaultSubscriber<>() {
                    @Override
                    public void onNext(String s) {
                        System.out.format("Index %s was created.%n", s);
                    }

                    @Override
                    public void onError(Throwable t) {
                        t.printStackTrace();
                    }

                    @Override
                    public void onComplete() {
                        System.out.println("Completed");
                    }
                });
    }

    public Single<Character> save(@Valid Character character) {
        return findByName(character.getName())
                .switchIfEmpty(
                        Single.fromPublisher(
                                characters.insertOne(character))
                                .map(success -> character)
                );
    }

    public Single<Character> createNew(@Valid Character character) {
        // detect if we find character
        boolean exists = findByName(character.getName()).blockingGet() != null;

        if (exists) {
            // change to another error
            // this way we can keep the interface of .blockingGet and avoid nullptr ex
            return Single.error(new NullPointerException());
        }

        return save(character);
    }

    public Maybe<Character> findByName(String name) {
        // TODO: Ignore case
        return Flowable.fromPublisher(
                characters
                        .find(eq("name", name))
                        .limit(1)
        ).firstElement();
    }

    public Single<List<Character>> findByAccount(String accountName) {
        // TODO: Ignore case
        return Flowable.fromPublisher(
                characters.find(eq("accountName", accountName))
        ).toList();
    }

    public Single<DeleteResult> deleteByCharacterName(String name) {
        return Single.fromPublisher(
                characters.deleteOne(eq("name", name))
        );
    }

    private MongoCollection<Character> getCollection() {
        return mongoClient
                .getDatabase(configuration.getDatabaseName())
                .getCollection(configuration.getCollectionName(), Character.class);
    }
}

Repository explained

Let’s go through the file; first the constructor:

    public PlayerCharacterRepository(
            PlayerCharacterConfiguration configuration,
            MongoClient mongoClient) {
        this.configuration = configuration;
        this.mongoClient = mongoClient;
        this.characters = getCollection();
    }

Here we just initialise all required beans and classes, the main part this.characters = getCollection()

private MongoCollection<Character> getCollection() {
return mongoClient
.getDatabase(configuration.getDatabaseName())
.getCollection(configuration.getCollectionName(), Character.class);
}

Note that configuration is based on the variables we set up in the application.yml. Simply put, the character variable will hold the characters collection, holding the Character data class.

Next, let’s check the post construct, which is just called after the bean is constructed.

    @PostConstruct
    public void createIndex() {
        // Micronaut does not yet support index annotation, we have to create manually
        // https://www.javaer101.com/en/article/20717814.html

        characters.createIndex(Indexes.text("name"))
                .subscribe(new DefaultSubscriber<>() {
                    @Override
                    public void onNext(String s) {
                        System.out.format("Index %s was created.%n", s);
                    }

                    @Override
                    public void onError(Throwable t) {
                        t.printStackTrace();
                    }

                    @Override
                    public void onComplete() {
                        System.out.println("Completed");
                    }
                });
    }

The main part here is this: characters.createIndex(Indexes.text("name"))

Characters class has a field name and we want to add an index to it. Creating this index is optional.

Next, let’s check the save method, which we will mainly use as update

public Single<Character> save(@Valid Character character) {
return findByName(character.getName())
.switchIfEmpty(
Single.fromPublisher(
characters.insertOne(character))
.map(success -> character)
);
}

This function searches for the character (by name, which is unique) and saves it, alternatively it inserts the character if empty.

Next, we check createNew

    public Single<Character> createNew(@Valid Character character) {
        // detect if we find character
        boolean exists = findByName(character.getName()).blockingGet() != null;

        if (exists) {
            // change to another error
            // this way we can keep the interface of .blockingGet and avoid nullptr ex
            return Single.error(new NullPointerException());
        }

        return save(character);
    }

This is very similar to save. Our general ‘create character’ logic should not find and update existing character (e.g. it could be someone else’s character). Therefore, we check if a character exists first – if it does we return error otherwise we create the new character.

Let’s now evaluate the lookup functions:

 public Maybe<Character> findByName(String name) {
        // TODO: Ignore case
        return Flowable.fromPublisher(
                characters
                        .find(eq("name", name))
                        .limit(1)
        ).firstElement();
    }

    public Single<List<Character>> findByAccount(String accountName) {
        // TODO: Ignore case
        return Flowable.fromPublisher(
                characters.find(eq("accountName", accountName))
        ).toList();
    }

These are simple searches by <variable>, in our case name and accountName.

Finally we have the delete function:

    public Single<DeleteResult> deleteByCharacterName(String name) {
        return Single.fromPublisher(
                characters.deleteOne(eq("name", name))
        );
    }

This shows how we can use all CRUD (create update delete) operations.

Data Transfer Object, DTO

Now that we’ve explored the repository class, let’s look at the DTO that we’re storing and loading from MongoDB. java/server/player/character/dto/Character.java

package server.player.character.dto;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micronaut.core.annotation.Introspected;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.codecs.pojo.annotations.BsonCreator;
import org.bson.codecs.pojo.annotations.BsonProperty;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@Data
@Introspected
@NoArgsConstructor
public class Character {
    @BsonCreator
    @JsonCreator
    public Character(
            @JsonProperty("name")
            @BsonProperty("name") String name,
            @JsonProperty("xp")
            @BsonProperty("xp") Integer xp,
            @JsonProperty("accountName")
            @BsonProperty("accountName") String accountName) {
        this.name = name;
        this.xp = xp;
        this.accountName = accountName;
    }
    // This DTO should hold all the data that we need to load a player character
    // Hence, this list is far from finished. It will be incremented as development goes on

    // Make sure name only contains letters, allow upper
    @Pattern(message="Name can only contain letters and number", regexp = "^[a-zA-Z0-9]*$")
    @Size(min=3, max=25)
    String name;

    @Min(0)
    Integer xp;

    @NotBlank
    String accountName;
}

This shows a simple DTO for the fields: name, xp, accountName. There will be much more variables here, but this will do for now. Furthermore, we show how we can add simple validation annotations, such as ensuring the size of strings and having a regex pattern for the name. Note these validations are optional but advised.

Service class

The service class is the integration between the controller and repository. This would usually hold most of logic but in our case it’s quite simple as we’re not doing anything big. Our service class is: java/server/player/character/service/PlayerCharacterService.java

package server.player.character.service;

import server.player.character.dto.Character;
import server.player.character.repository.PlayerCharacterRepository;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.List;

@Singleton
public class PlayerCharacterService {

    @Inject
    PlayerCharacterRepository playerCharacterRepository;

    public List<Character> getAccountCharacters(String username) {
        return playerCharacterRepository.findByAccount(username).blockingGet();
    }

    public Character createCharacter(String characterName, String username) {
        Character newCharacter = new Character();
        newCharacter.setXp(0);
        newCharacter.setName(characterName);
        newCharacter.setAccountName(username);

        // this will throw if there's an entry
        return playerCharacterRepository.createNew(newCharacter).blockingGet();
    }

    // we're not going to support deletes for now.
}

We’re only implementing two functions for now: getAccountCharacters and createCharacter.

Very simply, for createCharacter we get the account name and the desired character name and we ask the repository to create this account. We then just forward back the result. In future we can add additional parameters to our response object to give meta data about the operation, e.g. validation errors or duplication errors etc.

getAccountCharacters is also very simple, just forward the request to our repository to handle with the account name.

Controller

The controller is what receives our API requests and asks the service layer to carry out the request.

java/server/player/controller/PlayerController.java

package server.player.controller;

import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import server.player.character.dto.Character;
import server.player.character.dto.CreateCharacterRequest;
import server.player.character.service.PlayerCharacterService;
import server.player.motion.dto.PlayerMotion;
import server.player.motion.dto.PlayerMotionList;
import server.player.motion.service.PlayerMotionService;

import javax.inject.Inject;
import javax.validation.Valid;
import java.security.Principal;
import java.util.List;

@Secured(SecurityRule.IS_AUTHENTICATED)
@Controller("/player")
public class PlayerController {

    @Inject
    PlayerMotionService playerMotionService;

    @Inject
    PlayerCharacterService playerCharacterService;

    @Post("/update-motion")
    public PlayerMotionList updatePlayerLocation(@Body PlayerMotion playerMotionRequest) {
        playerMotionService.updatePlayerState(playerMotionRequest);
        return playerMotionService.getPlayersNearMe(playerMotionRequest.getPlayerName());
    }

    @Get("/account-characters")
    public List<Character> getAccountCharacters(Principal principal) {
        // This endpoint will be for when user logs in.
        // They will be greeted with a list of characters
        return playerCharacterService.getAccountCharacters(principal.getName());
    }

    @Post("/create-character")
    public Character createCharacter(@Body @Valid CreateCharacterRequest createCharacterRequest, Principal principal) {
        // Principal is the authenticated user, we should not get it from body but JWT token as that is trusted
        String accountName = principal.getName();

        return playerCharacterService.createCharacter(createCharacterRequest.getName(), accountName);
    }
}

We will just look at getAccountCharacters and createCharacter.

First we note that our controller has path: @Controller("/player")

getAccountCharacters has path of /account-characters therefore the full path is: /player/account-characters

Next we note the following parameters in our function call:

(Principal principal) 

The Principal is part of security feature that comes with our JWT. Note our controller is

@Secured(SecurityRule.IS_AUTHENTICATED)

therefore a user must be signed in for them to use these endpoints. This Principal is what will give us the user information, i.e. the account name for the logged in user.

return playerCharacterService.getAccountCharacters(principal.getName());

This demonstrates that the API does not need to send a username parameter as a request, this enhances our security and makes things a little more straight forward.

Next we check our createCharacter funtion:

    @Post("/create-character")
    public Character createCharacter(@Body @Valid CreateCharacterRequest createCharacterRequest, Principal principal) {
        // Principal is the authenticated user, we should not get it from body but JWT token as that is trusted
        String accountName = principal.getName();

        return playerCharacterService.createCharacter(createCharacterRequest.getName(), accountName);
    }

Here we specify the POST request at /player/create-character path.

We also specify that this endpoint is expecting a body payload: @Body @Valid CreateCharacterRequest which we also validate. Furthermore, similar to getCharacters we include the Principal principal here to cross reference the account name.

We then create the character by asking the service to do it:

return playerCharacterService.createCharacter(createCharacterRequest.getName(), accountName);

Create character request DTO

Finally, let’s just check the request DTO that’s used in create character.

java/server/player/character/dto/CreateCharacterRequest.java

package server.player.character.dto;

import io.micronaut.core.annotation.Introspected;
import lombok.Data;

import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@Data
@Introspected
public class CreateCharacterRequest {
// there will be more as per requirements from UE

@Pattern(message="Name can only contain letters and numbers", regexp = "^[a-zA-Z0-9]*$")
@Size(min=3, max=25)
String name;
}

Its just a simple class that’s expecting a name field – this is because XP will be set to 0 by default when creating a new character and the account name will be taken from the logged in user (via Bearer token).

Testing

Ok lets see this all in action!

As always, I’m using Postman to test the code, as a side note, I’ve included a repository test java/server/player/character/repository/PlayerCharacterRepositoryTest.java and controller test in the repository: java/server/player/controller/PlayerControllerTest.java)

We now need to do the following

  • register user
  • log user in
  • create character
  • get character

let’s register two users so we can demo the characters working for different accounts at the same time.

Register is POST request to http://localhost:8081/register

Register user 2:

Login with first user, POST to http://localhost:8081/login

Copy the access token that you get, then add this token as Authorization Bearer token in your create character request.

For create character request, we also need the character name for the body:

Now configure the payload and send the request:

As you can see, we received the character data back, including name, xp and accountName.

Let’s now create second character:

With these two characters in place, let’s repeat the login process for the second user and create some characters for the second account.

Once done let’s call the getCharacters for the first user, using the bearer token from the start.

As you can see, the getCharacters will only return the accounts characters as expected.

Now we have a fast, scaleable solution for creating, updating and retrieving character information.

In the next post, I will aim to connect this with a basic Unreal Engine UI to create these characters.

Good luck!

1 Comment

Comments are closed