Today, I am starting with a different and equally challenging aspect: persistence. JOGRE has four basic types of data, which can be expressed through the following schema (using protocol buffer notation):
message GameInfo {
required int64 id = 1;
optional string game_key = 2;
optional string players = 3;
optional string results = 4;
optional int64 start_time = 5;
optional int64 end_time = 6;
optional string history = 7;
optional string score = 8;
}
message GameSummary {
required string game_key = 1;
required string username = 2;
optional int32 rating = 3;
optional int32 wins = 4;
optional int32 loses = 5;
optional int32 draws = 6;
optional int32 streak = 7;
}
message Snapshot {
required string game_key = 1;
optional int32 num_of_users = 2;
optional int32 num_of_tables = 3;
}
message User {
required string username = 1;
optional string password = 2;
optional int32 security_question = 3;
optional string security_answer = 4;
optional string year_of_birth = 5;
optional string email = 6;
optional bool receive_newsletter = 7;
}
A first goal should be to build a way of writing this information to the App Engine datastore. Performance is a non-goal of the first stage, since I currently do not know enough about the data to really have a feeling of where the bottlenecks might be. Getting the first iteration complete should give me a pretty good idea, though.
Step 1 is to find a seam of where to insert datastore functionality (I will wrap datastore access using the Persistence interface, but the concept should be same for JDO, JPA, or low-level API users). JOGRE already has a couple of different stores, but they seem a little copy-and-paste-ish (duplicated logic in
ServerDataDB and ServerDataXML for example), and I'd rather not create another very similar copy with very similar code.After some more digging, I found the perfect candidate to refactor: the SQL-based persistence was using an inner class called IBatis to translate operations on POJOS into SQL commands (through the open source project of the same name.). Through the "extract interface" refactoring, which can be automated through Eclipse, I replaced the use of the
IBatis class through a interface with the same method signature:package org.jogre.server.data.db;
import java.sql.SQLException;
import java.util.List;
public interface ORM {
public abstract Object getObject(String id,
Object parameterObject) throws SQLException;
public abstract List getList(String id,
Object parameterObject) throws SQLException;
public abstract List getList(String id)
throws SQLException;
public abstract void update(String id,
Object parameterObject) throws SQLException;
public abstract void update(String id)
throws SQLException;
}
Now I could create a second implementation,
PersistenceORM, that would use the App Engine datastore instead. Naturally, that is easier said then done. The interface above does not give us a lot of information about the context in which it is used. I would have to go through all of its uses in the code and figure out what combinations of an id string and a parameter object existed. The only problem is: the weather is waaaaay to nice for that :-)Here's what I am trying instead: my first implementation is a stub that breaks whenever it is called. I begin with a simple helper-method
checkId: private static void checkId(String id, String... supportedIds) {
for (String test : supportedIds) {
if (test.equals(id)) {
return;
}
}
throw new UnsupportedOperationException(id);
}
At the beginning of each of my interface-methods, I will call
checkId with the id that was handed into the method. This will cause the game server to crash, providing me with an exact stack trace of the context in which the ORM interface was used:java.lang.UnsupportedOperationException: selectGameSummary
at org.jogre.server.data.db.PersistenceORM.checkId(PersistenceORM.java:39)
at org.jogre.server.data.db.PersistenceORM.getObject(PersistenceORM.java:100)
at org.jogre.server.data.db.ServerDataDB.getGameSummary(ServerDataDB.java:221)
at org.jogre.server.controllers.ServerGameController.addNewUser(ServerGameController.java:436)
at org.jogre.server.controllers.ServerGameController.gameConnect(ServerGameController.java:209)
at org.jogre.server.controllers.ServerGameController.parseGameMessage(ServerGameController.java:96)
at org.jogre.server.ServerConnectionThread.parse(ServerConnectionThread.java:114)
at org.jogre.server.WebConnectionList.receive(WebConnectionList.java:128)
at com.appenginefan.toolkit.common.WebConnectionServer.dispatch(WebConnectionServer.java:229)
at org.jogre.server.JogreServer$1.handle(JogreServer.java:188)
at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:115)
at org.eclipse.jetty.server.Server.handle(Server.java:325)
at org.eclipse.jetty.server.HttpConnection.handleRequest(HttpConnection.java:539)
at org.eclipse.jetty.server.HttpConnection$RequestHandler.content(HttpConnection.java:896)
at org.eclipse.jetty.http.HttpParser.parseNext(HttpParser.java:745)
at org.eclipse.jetty.http.HttpParser.parseAvailable(HttpParser.java:218)
at org.eclipse.jetty.server.HttpConnection.handle(HttpConnection.java:398)
at org.eclipse.jetty.io.nio.SelectChannelEndPoint.run(SelectChannelEndPoint.java:423)
at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:404)
at java.lang.Thread.run(Thread.java:619)
I can look at the code and see how the incoming parameter objects look like. I can also set a breakpoint in my
checkId method and inspect all the objects that live on the call stack. By following this approach, I can gradually add methods that are needed simply by running the server and connecting with the standard game client. Here is a sample implementation that supports a few methods so far:package org.jogre.server.data.db;
import java.sql.SQLException;
import java.util.List;
import org.apache.log4j.Logger;
import org.jogre.server.data.ProtoSchema;
import org.jogre.server.data.ProtoSchema.GameInfo;
import org.jogre.server.data.ProtoSchema.GameSummary;
import org.jogre.server.data.ProtoSchema.Snapshot;
import org.jogre.server.data.ProtoSchema.User;
import com.appenginefan.toolkit.persistence.MapBasedPersistence;
import com.appenginefan.toolkit.persistence.Persistence;
import com.google.common.base.Function;
import com.google.common.base.Functions;
import com.google.common.base.Preconditions;
import static org.jogre.server.data.db.IDatabase.*;
/**
* ORM implementation that maps to Persistence objects
*
* @author Jens Scheffler
*
*/
public class PersistenceORM implements ORM {
private static final Logger LOG = Logger.getLogger(PersistenceORM.class.getName());
private static void checkId(String id, String... supportedIds) {
for (String test : supportedIds) {
if (test.equals(id)) {
return;
}
}
throw new UnsupportedOperationException(id);
}
private Persistence<ProtoSchema.GameInfo> infos;
private Persistence<ProtoSchema.GameSummary> summaries;
private Persistence<ProtoSchema.Snapshot> snapshots;
private Persistence<ProtoSchema.User> users;
public PersistenceORM(Persistence<GameInfo> infos,
Persistence<GameSummary> summaries,
Persistence<Snapshot> snapshots,
Persistence<User> users) {
Preconditions.checkNotNull(infos);
Preconditions.checkNotNull(summaries);
Preconditions.checkNotNull(snapshots);
Preconditions.checkNotNull(users);
this.infos = infos;
this.summaries = summaries;
this.snapshots = snapshots;
this.users = users;
}
@Override
public List getList(String id, Object parameterObject)
throws SQLException {
checkId(id);
// TODO Auto-generated method stub
return null;
}
@Override
public List getList(String id) throws SQLException {
checkId(id);
// TODO Auto-generated method stub
return null;
}
@Override
public Object getObject(String id, Object parameterObject)
throws SQLException {
checkId(id, ST_SELECT_USER);
if (ST_SELECT_USER.equals(id)) {
User result = users.get(
((org.jogre.server.data.User) parameterObject).getUsername());
if (result == null) {
return null;
}
org.jogre.server.data.User asPojo = new org.jogre.server.data.User();
asPojo.setEmail(result.getEmail());
asPojo.setPassword(result.getPassword());
asPojo.setReceiveNewsletter(result.getReceiveNewsletter());
asPojo.setSecurityAnswer(result.getSecurityAnswer());
asPojo.setSecurityQuestion(result.getSecurityQuestion());
asPojo.setUsername(result.getUsername());
asPojo.setYearOfBirth(result.getYearOfBirth());
return asPojo;
}
return null;
}
@Override
public void update(String id, Object parameterObject)
throws SQLException {
checkId(id, ST_ADD_SNAP_SHOT);
if (ST_ADD_SNAP_SHOT.equals(id)) { // update the snapshot for a given key
final org.jogre.server.data.SnapShot snapshot =
(org.jogre.server.data.SnapShot) parameterObject;
snapshots.mutate(
snapshot.getGameKey(),
new Function<ProtoSchema.Snapshot, ProtoSchema.Snapshot>(){
@Override
public Snapshot apply(Snapshot original) {
return ProtoSchema.Snapshot.newBuilder()
.setGameKey(snapshot.getGameKey())
.setNumOfTables(snapshot.getNumOfTables())
.setNumOfUsers(snapshot.getNumOfUsers())
.build();
}
});
}
// TODO Auto-generated method stub
}
@Override
public void update(String id) throws SQLException {
checkId(id, ST_DELETE_ALL_SNAP_SHOT);
LOG.warn("Attempt submitted to clear the entire store," +
" which is not supported!");
}
}
As I keep adding more methods, I will get a better understanding of how persistence is used in the JOGRE game server. This will also tell me of potential issues I might need to adress when completing the port onto the App Engine data store. After all, I would like my finished server to perform well in the cloud, with potentially tens of thousands of gamers playing in parallel. The road is long, but I intend to make progress crash by crash by crash...