In the last few chapters we’ve setup a basic Micronaut project with JWT authentication against Postgres table.
You can find the chapters here:
- Setup Micronaut + Postgres
- Setup base JWT Authentication
- Add encoding for security (encode user password)
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:
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!
Pingback: 8. Connect Unreal Engine to mmo web server