-
Notifications
You must be signed in to change notification settings - Fork 5
Technology Choices
I've had some questions about the various technologies in this project, how they are used and how they work together so I thought I'd put together a little overview to give a high level view of the project architecture.
It's very important to me to have a fast development cycle, and for development to happen locally on my laptop rather than on the server that players might be using. Way back in the late 90s and early 00s when I was running old school MUDs we used to have one copy of the game running, with players on it, and write code in vi
right there on the server. I even did some crazy things like attaching gdb
to the running game in order to identify bugs. Today, there is no excuse for doing things like that because the tools we have available to us are so much better.
At its core, Docker is a tool for running programs in a known environment regardless of what machine they are actually running on. In the case of the Agony Engine, running the build produces an executable JAR file. That JAR gets packaged into a very small container along with a JDK and little else. You can take that Docker container to any computer that has Docker on it and run it.
By using Docker you guarantee that the program you're running has all the dependencies it needs. You also guarantee that it has the OS it needs because that's built into the container. You also guarantee that it has no access to the host computer unless you specifically give it access by mounting a filesystem inside the container. The Docker container for The Agony Engine can be run on OSX, Linux and Windows all without installing anything but Docker.
The benefit to developers is clear: I can develop, build and run my program on any machine anywhere. I can start it up with one command and I can reset my environment to its default state in one command. Combined with the schema management tool Flyway I never have to actually touch my database.
Compose is a tool that comes with Docker, which is aimed at orchestrating multiple containers together. The Agony Engine actually requires three separate Docker containers to run: MySQL, ActiveMQ and the Agony Engine container. There is all sorts of configuration that has to be done for the three containers to cooperate with one another but Compose handles it for you.
There's too much in Compose to explain it fully here, but the most important magical thing that Compose does for The Agony Engine is to set up networking between the containers. For our agony-engine
container to see MySQL we can make the assumption that the DNS name for MySQL is aurora
because that's what we named its container (named as such because the production deployment uses AWS Aurora which is not technically a MySQL instance). The port for the database is well known, so we just connect to aurora:3306
and Compose makes that work. It's all standard DNS and networking, so the applications don't need to know anything about Docker internally.
Gradle is a build system like Maven, Ant or Make. I don't have any great love for it over the others out there, but it's what I've been using for all my projects in the past few years so it's familiar. It's very popular so there are lots of plugins and plenty of example code to read when I get stuck.
Using a build system such as Gradle is a huge help for the development process because it smooths over some things about Java that would be cumbersome or time consuming without it. For example, building an executable JAR file manually requires in-depth knowledge of how those work. There's a Manifest file and a specific directory structure you have to follow. Spring Boot adds another layer of complexity by bundling your application's dependencies inside your JAR using a non-standard "repackaging" scheme. It's very cool the way it works, but without using the Gradle or Maven plugin you just wouldn't be able to do it.
You may have noticed my examples all say to run ./gradlew
instead of gradle
. You may also have noticed there's a gradlew
script and a gradle
directory in the project, and my instructions never told you to install Gradle on your machine. You can thank the Gradle Wrapper for that.
The Gradle Wrapper is a script that lives in the project, and you run that instead of directly running the Gradle build system. The script looks to see if you have the right version of Gradle, and if you don't it will download one for you. Once you have downloaded it once you can keep using that version and avoid re-downloading it.
I chose to use the Gradle Wrapper so that every project I work on can specify which version of Gradle it expects. Old projects can keep using their crusty old versions of Gradle without having to update. Teams of many developers don't have to all agree on what version they should all install, because the project defines it. Updating a single property in the project will cause every developer's machine to get the required version. Add to that the fact that it's one more thing you don't have to download and install manually, and you have a winner in my book.
Websocket was an easy choice for this project. Old MUDs ran on telnet which is a two-way protocol. You establish a connection and you and the server can send data back and forth as long as the connection is open. On the other hand, HTTP is a protocol that basically only does one transaction per connection. You connect to a server, ask it for a resource, download the response and the connection ends. Using that protocol there's no way to do something like a MUD.
In contrast to HTTP, Websocket gives you a long-lived connection where you can send data back and forth with the server until you want to disconnect. It's typically used for chats, stock tickers and news feeds but it works really well for something like a MUD where clients send small input strings up to the server and the server sends relatively small strings back to the clients. I'm not making use of some of the routing and channel features that Websocket and STOMP give you, and instead I'm explicitly writing the code to choose which clients receive each message. The logic to dynamically create channels and manage subscriptions in STOMP would be horribly complex and not well suited to the task. It gives me the ability to target messages to individual clients, which is exactly what I need.
STOMP is a protocol that runs within your Websocket session. I really only chose it because Spring already had built in support for it. I'm not interested in doing really low level work at the protocol level for this project. As it is this game is probably going to take me a decade to finish, so why make things harder on myself?
I use the Spring Framework for almost every Java project I work on these days. I consider it indispensable for serious Java development.
The primary thing I use Spring for is dependency injection. You'll notice that nearly every class I write has a constructor that accepts any other classes it uses as parameters. That means that in unit tests I can pass mocks into the constructor and test just my one class in a known environment. Every class is explicit about what it depends on.
By default Spring beans are Singletons. Spring loads just one instance up, configures it and injects that same instance wherever it's needed. That makes the programming model for Spring applications quite simple, since you can inject a service object wherever you want it and know that you're only ever making a single, shared copy of it.
Spring is full of useful utilities like StringUtils
and RestTemplate
. It also has tons of frameworks such as Spring Security that implement things you definitely need to do in professional ways. I saved myself a huge amount of time by pulling in Spring frameworks rather than writing my own security, my own Java servlets, my own MVC framework, my own templating engine, my own database schema management, etc.
Spring Boot is Spring's opinionated cousin. It takes the toolbox that Spring gives you and provides default implementations and integrations for just about everything a typical application might need to do. It really shines when you're laying down the first project skeleton because with just five or 10 lines of code you can get something that builds and runs.
As you start to add your own code into the project the default Spring Boot implementations automatically turn off as you find you need to replace them.
Spring Data glosses over almost all the bad parts of using relational databases and Hibernate in Java. You define your entities using the usual Hibernate annotations, but to perform queries you just need to write an interface with some clever method names. Nowhere do you have to implement those methods. It just understands the method names and generates the queries. Magical.
If you find you need to do something more complicated than that it's easy to add methods with custom HQL or SQL queries, but I haven't had to do that yet.
It also irons out a lot of the configuration. I have exactly three database related parameters in agonyengine.env
and one line of pertinent configuration in the DataSourceConfiguration
class. That's everything I needed to establish a database connection, complete with a fast connection pool.
Flyway is a schema management tool. Basically, it maintains some metadata in your database about what "version" your database schema is, and it can run "migrations" to move from the current version to newer versions.
There are two kinds of migrations in Flyway:
-
Repeatable migrations have filenames that begin with "R". These run every time the game boots up. That's why they have a pretty convoluted syntax which does an "upsert". If the row does not exist it will be inserted. If it does exist, it will be updated. The repeatable migrations are great for loading data into the database that won't change.
-
Versioned migrations have filenames that begin with a version number. Flyway is very flexible about what is a version number, so I chose to stick to a convention
Vnnnn
where the _n_s are incrementing numbers. If the project has any versioned migrations that are higher than the current database schema version, Flyway will run those exactly one time to bring the database up to the latest version.
What this means in practice is that I never have to touch my database with manual queries. The production database gets brought up to date every time I release a new version because the migration I add will include statements to alter tables and modify data. My development database can be brought up and torn down as much as I like without any repercussions because Flyway has every migration I've ever checked in. It sees a completely empty database, and it runs every migration in order. When it's done it runs the repeatable ones, and I'm left with a database with the most recent schema and all the required data in it. I can always restore my database to this state if I screw something up just by deleting it and starting over.
The data for a MUD are highly relational and hierarchical. In the long run it will benefit from normalization and there will be a great deal of searching and filtering. I know it's vogue right now to use NoSQL but it's really not the right tool for this job. Having transactions and not worrying about "eventual consistency" is a huge plus as well.
My production deployment uses an AWS RDS Aurora database. I'm using the new "serverless" version which only has MySQL 5.6 compatibility. The Docker container I use is the closest version available to what RDS offers to minimize any differences between development and production.
I chose the "serverless" Aurora for a couple of reasons:
- I don't want to mess around with any servers. I just want AWS to make it work for me.
- It is a cluster that can auto-scale in response to traffic, rather than a single instance.
- Aurora offers significant performance improvements over regular MySQL especially in replication between instances. So not only do I not have to configure anything, I also have a best-of-breed, highly available database cluster.
The only downside of "serverless" Aurora so far is that it's expensive to run all the time. I'm minimizing the cost in other ways but the database is by far the most expensive part of my AWS bill.
I'm really pushing to be able to have high availability for this game. One of the strategies for that is to be able to deploy multiple copies of the game in multiple Availability Zones, and some day maybe even multiple Regions. It's important that players logged into any instance can interact with players logged into any other instance. I don't want any sharding like on the big MMOs. So, each instance of the game routes messages through an ActiveMQ message broker that allows messages to be passed between all clients and all instances of the game.
I chose ActiveMQ because AWS offers a managed ActiveMQ instance that can itself be highly available. I didn't want to bother with a lot of configuration or maintenance, so that was an ideal option. I did not go with SQS which seemed like the obvious choice because it does not offer any guarantee of the order that messages are delivered in. For an application like this it's important that the order of the messages be guaranteed so that the players don't get lines of text in a random order, or with some lines missing.