О том, как мы делали игру «Стикеры» для Google Play
Давно у меня была мысль поделиться своими знаниями с сообществом. Сначала хотел написать что-нибудь по астрофизике или ОТО, но решил все же что корректнее будет писать о той предметной области, которой я занимаюсь профессионально. Итак, я постараюсь подробно изложить процесс создания и тонкости реализации игрового приложения под Android (начиная от проектирования, заканчивая публикацией и In App покупками).
Введение
Программированием я занимаюсь с первого класса, закончил Прикладную математику СпбГПУ. Недавно (где-то год назад) открыл для себя разработку под мобильные платформы. Стало интересно что это такое и с чем его едят. В настоящее время разрабатываю несколько проектов в команде друзей/коллег, но хотел бы написать о своём первом опыте. Таким опытом было написание игрового приложения — «Стикеры» (Кто Я?).
Игровой процесс представляет собой поочередное задание «да/нет» вопросов и получение ответов на них от остальных игроков.
Выбор пал на на «Стикеры» по нескольким причинам.
Во-первых, мы не нашли в маркете аналогов (имеется в виду реализация правил описанной застольной игры).
Во-вторых, хотелось написать что-нибудь не очень трудоёмкое.
В-третьих, игра достаточно популярна в наших кругах и мы подумали что возможно кому-нибудь было бы интересно поиграть в неё и виртуально.
Процесс разработки
Постановка задачи
Задача образовалась достаточно однозначная. Необходимо реализовать клиент-серверное приложение, позволяющее его пользователям использовать следующие возможности:
- Создавать собственную учётную запись
- Аутентификация с использованием собственной учётной записи
- Просмотр рейтинга игроков
- Создание игровой комнаты
- Вход в игровую комнату
- Участие в игровом процессе
Игровой процесс представляет собой поочередную смену фаз:
- Фаза вопросов
- Фаза голосования
Проектирование UI
К счастью, моя жена — дизайнер и мне не практически не пришлось принимать участие в выборе палитры, расположении элементов и прочих дизайнерских штуках. По итогам анализа возможностей, которые игра должна предоставлять игроку, было решено сколько будет игровых состояний (Activities) и какие элементы управления должны быть в каждом из них:
- Главное меню
- Аутентификация
- Регистрация
- Доступ к рейтингу игроков
- Рейтинг игроков
- Возврат в главное меню
- Список комнат
- Вход в комнату
- Создание своей комнаты
- Переход в главное меню
- Текущая комната
- Выход из комнаты
- Игровое состояние №1: ввод вопроса
- Ввод вопроса
- Переход к истории вопросов
- Переход к вводу ответа
- Переход в главное меню
- Игровое состояние №2: просмотр истории вопросов
- Переход к вводу вопроса
- Переход к вводу ответа
- Переход в главное меню
- Игровое состояние №3: ввод ответа
- Ввод ответа
- Переход к вводу вопроса
- Переход к истории вопросов
- Переход в главное меню
- Игровое состояние №4: голосование
- Ввод голосов
- Переход в главное меню
- Победа
- Переход в главное меню
- Поражение
- Переход в Wikipedia (на страницу персонажа)
- Переход в главное меню
Проектирование DB
Как только нам стало понятно какие игровые состояния и объекты существуют в игре, мы перешли к их формализации в терминах базы данных.
Итак, нам понадобятся следующие таблицы:
- Users. Таблица, в которой хранится информация о всех пользователях
- Games. Таблица, в которой хранится информация о всех комнатах
- Characters. Таблица, в которой хранится информация о всех персонажах
- Questions. Таблица, в которой хранятся вопросы пользователей о загаданных им персонажах
- Answers. Таблица, в которой хранятся ответы пользователей
В начальной версии игры были только эти таблицы, но игра развивалась и добавлялись новые. Я не буду описывать остальные таблицы, иначе повествование чрезмерно затянется. На схеме базы данных изображены все таблицы, но их присутствие не помешает дальнейшему расскажу.
Связи между таблицами менялись несколько раз (agile, так сказать), но в конечном итоге остались следующие:
- Каждому пользователю может быть поставлен в соответствие один персонаж
- Каждый пользователь может находиться только в одной комнате
- Каждый вопрос может быть задан только одним пользователем
- Каждый вопрос может относиться только к одному персонажу
- Каждый вопрос может быть задан только в рамках одной игры
- Каждый ответ может быть дан только одним пользователем
- Каждый ответ может быть дан только на один вопрос
Уровень приложения
Наконец-то мы добрались до программной реализации. Итак, начну с самых общих слов. Весь проект состоит из 4х модулей:
- Venta. Библиотека, в которой собраны полезные утилиты
- Protocol. Библиотека с описанием протокола взаимодействия
- Server. Серверная часть приложения
- Client. Клиентская часть приложения
Библиотека Venta
Поскольку я люблю изобретать велосипеды и не люблю мешанины сторонних библиотек (да, да классическая проблема многих программистов-педантов), я решил написать некоторые вещи сам. Эта библиотека пишется мною давно и в ней собраны многие полезные для меня утилиты (работа с базой данных, клиент-серверное взаимодействие, акторы, математика, шифрование...).
В рамках данной статьи я хочу рассказать о сетевой части данной библиотеки. Реализацию взаимодействия между клиентом и сервером я решил сделать путем сериализации/десериализации объектов, среди которых есть и запросы и ответы. В качестве элементарной пересылаемой единицы информации (на уровне библиотеки, разумеется) выступает объект Message:
package com.gesoftware.venta.network.model;
import com.gesoftware.venta.utility.CompressionUtility;
import java.nio.charset.Charset;
import java.io.Serializable;
import java.util.Arrays;
/* *
* Message class definition
* */
public final class Message implements Serializable {
/* Time */
private final long m_Timestamp;
/* Message data */
private final byte[] m_Data;
/* *
* METHOD: Message class constructor
* PARAM: [IN] data - bytes array data
* AUTHOR: Eliseev Dmitry
* */
public Message(final byte data[]) {
m_Timestamp = System.currentTimeMillis();
m_Data = data;
} /* End of 'Message::Message' method */
/* *
* METHOD: Message class constructor
* PARAM: [IN] data - bytes array data
* AUTHOR: Eliseev Dmitry
* */
public Message(final String data) {
this(data.getBytes());
} /* End of 'Message::Message' method */
/* *
* METHOD: Message class constructor
* PARAM: [IN] object - some serializable object
* AUTHOR: Eliseev Dmitry
* */
public Message(final Object object) {
this(CompressionUtility.compress(object));
} /* End of 'Message::Message' method */
/* *
* METHOD: Bytes data representation getter
* RETURN: Data bytes representation
* AUTHOR: Eliseev Dmitry
* */
public final byte[] getData() {
return m_Data;
} /* End of 'Message::getData' method */
/* *
* METHOD: Gets message size
* RETURN: Data size in bytes
* AUTHOR: Eliseev Dmitry
* */
public final int getSize() {
return (m_Data != null)?m_Data.length:0;
} /* End of 'Message::getSize' method */
@Override
public final String toString() {
return (m_Data != null)?new String(m_Data, Charset.forName("UTF-8")):null;
} /* End of 'Message::toString' method */
/* *
* METHOD: Compares two messages sizes
* RETURN: TRUE if messages has same sizes, FALSE otherwise
* PARAM: [IN] message - message to compare with this one
* AUTHOR: Eliseev Dmitry
* */
private boolean messagesHasSameSizes(final Message message) {
return m_Data != null && m_Data.length == message.m_Data.length;
} /* End of 'Message::messagesHasSameSize' method */
/* *
* METHOD: Compares two messages by their values
* RETURN: TRUE if messages has same sizes, FALSE otherwise
* PARAM: [IN] message - message to compare with this one
* AUTHOR: Eliseev Dmitry
* */
private boolean messagesAreEqual(final Message message) {
/* Messages has different sizes */
if (!messagesHasSameSizes(message))
return false;
/* At least one of characters is not equal to same at another message */
for (int i = 0; i < message.m_Data.length; i++)
if (m_Data[i] != message.m_Data[i])
return false;
/* Messages are equal */
return true;
} /* End of 'Message::messagesAreEqual' method */
/* *
* METHOD: Tries to restore object, that may be packed in message
* RETURN: Restored object if success, null otherwise
* AUTHOR: Eliseev Dmitry
* */
public final Object getObject() {
return CompressionUtility.decompress(m_Data);
} /* End of 'Message::getObject' method */
/* *
* METHOD: Gets message sending time (in server time)
* RETURN: Message sending time
* AUTHOR: Eliseev Dmitry
* */
public final long getTimestamp() {
return m_Timestamp;
} /* End of 'Message::getTimestamp' method */
@Override
public final boolean equals(Object obj) {
return obj instanceof Message && messagesAreEqual((Message) obj);
} /* End of 'Message::equals' method */
@Override
public final int hashCode() {
return Arrays.hashCode(m_Data);
} /* End of 'Message::hashCode' method */
} /* End of 'Message' class */
Я не буду подробно останавливаться на описании этого объекта, код достаточно комментирован.
Упрощение работы с сетью происходит за счёт использования двух классов:
- Server (серверная часть)
- Connection (клиентская часть)
При создании объекта типа Server, необходимо указать порт, на котором он будет ожидать входящие соединения и реализацию интерфейса IServerHandler
package com.gesoftware.venta.network.handlers;
import com.gesoftware.venta.network.model.Message;
import com.gesoftware.venta.network.model.ServerResponse;
import java.net.InetAddress;
/* Server handler interface declaration */
public interface IServerHandler {
/* *
* METHOD: Will be called right after new client connected
* RETURN: True if you accept connected client, false if reject
* PARAM: [IN] clientID - client identifier (store it somewhere)
* PARAM: [IN] clientAddress - connected client information
* AUTHOR: Eliseev Dmitry
* */
public abstract boolean onConnect(final String clientID, final InetAddress clientAddress);
/* *
* METHOD: Will be called right after server accept message from any connected client
* RETURN: Response (see ServerResponse class), or null if you want to disconnect client
* PARAM: [IN] clientID - sender identifier
* PARAM: [IN] message - received message
* AUTHOR: Eliseev Dmitry
* */
public abstract ServerResponse onReceive(final String clientID, final Message message);
/* *
* METHOD: Will be called right after any client disconnected
* PARAM: [IN] clientID - disconnected client identifier
* AUTHOR: Eliseev Dmitry
* */
public abstract void onDisconnect(final String clientID);
} /* End of 'IServerHandler' interface */
Клиент, в свою очередь, при создании объекта типа Connection должен предоставить реализацию интерфейса IClientHandler.
package com.gesoftware.venta.network.handlers;
import com.gesoftware.venta.network.model.Message;
import com.gesoftware.venta.network.model.ServerResponse;
import java.net.InetAddress;
/* Server handler interface declaration */
public interface IServerHandler {
/* *
* METHOD: Will be called right after new client connected
* RETURN: True if you accept connected client, false if reject
* PARAM: [IN] clientID - client identifier (store it somewhere)
* PARAM: [IN] clientAddress - connected client information
* AUTHOR: Eliseev Dmitry
* */
public abstract boolean onConnect(final String clientID, final InetAddress clientAddress);
/* *
* METHOD: Will be called right after server accept message from any connected client
* RETURN: Response (see ServerResponse class), or null if you want to disconnect client
* PARAM: [IN] clientID - sender identifier
* PARAM: [IN] message - received message
* AUTHOR: Eliseev Dmitry
* */
public abstract ServerResponse onReceive(final String clientID, final Message message);
/* *
* METHOD: Will be called right after any client disconnected
* PARAM: [IN] clientID - disconnected client identifier
* AUTHOR: Eliseev Dmitry
* */
public abstract void onDisconnect(final String clientID);
} /* End of 'IServerHandler' interface */
Теперь немного о внутреннем устройстве сервера. Как только к серверу присоединяется очередной клиент, для него вычисляется уникальный хэш и создаются два потока: поток приема и поток отправки. Поток приёма блокируется и ожидает сообщения от клиента. Как только сообщение от клиента было принято, оно передаётся зарегистрированному пользователем библиотеки обработчику. В результате обработки может произойти одно из пяти событий:
- Отсоединение клиента (скажем, пришёл запрос на отключение)
- Отправка клиенту ответа
- Отправка ответа другому клиенту
- Отправка ответа всем присоединённым клиентам
- Отправка ответа некоторой группе клиентов
Если теперь необходимо отправить сообщение какому-нибудь из подключенных клиентов, оно помещается в очередь отправки сообщений данного клиента, а поток, отвечающий за отправку уведомляется о том, что в очереди появились новые сообщения.
Наглядно, поток данных можно продемонстрировать схемой ниже.
Клиент X посылает запрос на сервер (красная стрелка). Запрос принимается в соответствующем клиенту потоке-приёмнике. Он немедленно вызывает обработчик сообщения (желтая стрелка). В результате обработки формируется некоторый ответ, который помещается в очередь отправки клиента X (зеленая стрелка). Поток отправки проверяет наличие сообщений в очереди отправки (черная стрелка) и отправляет ответ клиенту (синяя стрелка).
package com.gesoftware.venta.network;
import com.gesoftware.venta.logging.LoggingUtility;
import com.gesoftware.venta.network.handlers.IClientHandler;
import com.gesoftware.venta.network.handlers.IServerHandler;
import com.gesoftware.venta.network.model.Message;
import com.gesoftware.venta.network.model.ServerResponse;
import java.net.InetAddress;
import java.util.TimerTask;
public final class NetworkTest {
private final static int c_Port = 5502;
private static void startServer() {
final Server server = new Server(c_Port, new IServerHandler() {
@Override
public boolean onConnect(final String clientID, final InetAddress clientAddress) {
LoggingUtility.info("Client connected: " + clientID);
return true;
}
@Override
public ServerResponse onReceive(final String clientID, final Message message) {
LoggingUtility.info("Client send message: " + message.toString());
return new ServerResponse(message);
}
@Override
public void onDisconnect(final String clientID) {
LoggingUtility.info("Client disconnected: " + clientID);
}
});
(new Thread(server)).start();
}
private static class Task extends TimerTask {
private final Connection m_Connection;
public Task(final Connection connection) {
m_Connection = connection;
}
@Override
public void run() {
m_Connection.send(new Message("Hello, current time is: " + System.currentTimeMillis()));
}
}
private static void startClient() {
final Connection connection = new Connection("localhost", c_Port, new IClientHandler() {
@Override
public void onReceive(final Message message) {
LoggingUtility.info("Server answer: " + message.toString());
}
@Override
public void onConnectionLost(final String message) {
LoggingUtility.info("Connection lost: " + message);
}
});
connection.connect();
(new java.util.Timer("Client")).schedule(new Task(connection), 0, 1000);
}
public static void main(final String args[]) {
LoggingUtility.setLoggingLevel(LoggingUtility.LoggingLevel.LEVEL_DEBUG);
startServer();
startClient();
}
}
Довольно коротко, не правда ли?
Игровой сервер
Архитектура игрового сервера многоуровневая. Сразу же приведу её схему, а затем и описание.
Итак, для взаимодействия с базой данных используется пул соединений (я использую библиотеку BoneCP). Для работы с подготовленными запросами (prepared statements), я завернул соединение в свой собственный класс (библиотека Venta).
package com.gesoftware.venta.db;
import com.gesoftware.venta.logging.LoggingUtility;
import com.jolbox.bonecp.BoneCPConfig;
import com.jolbox.bonecp.BoneCP;
import java.io.InputStream;
import java.util.AbstractList;
import java.util.LinkedList;
import java.util.HashMap;
import java.util.Map;
import java.sql.*;
/**
* DB connection class definition
**/
public final class DBConnection {
/* Connections pool */
private BoneCP m_Pool;
/**
* DB Statement class definition
**/
public final class DBStatement {
private final PreparedStatement m_Statement;
private final Connection m_Connection;
/* *
* METHOD: Class constructor
* PARAM: [IN] connection - current connection
* PARAM: [IN] statement - statement, created from connection
* AUTHOR: Dmitry Eliseev
* */
private DBStatement(final Connection connection, final PreparedStatement statement) {
m_Connection = connection;
m_Statement = statement;
} /* End of 'DBStatement::DBStatement' class */
/* *
* METHOD: Integer parameter setter
* RETURN: True if success, False otherwise
* PARAM: [IN] index - parameter position
* PARAM: [IN] value - parameter value
* AUTHOR: Dmitry Eliseev
* */
public final boolean setInteger(final int index, final int value) {
try {
m_Statement.setInt(index, value);
return true;
} catch (final SQLException e) {
LoggingUtility.debug("Can't set integer value: " + value + " because of " + e.getMessage());
}
return false;
} /* End of 'DBStatement::setInteger' class */
/* *
* METHOD: Long parameter setter
* RETURN: True if success, False otherwise
* PARAM: [IN] index - parameter position
* PARAM: [IN] value - parameter value
* AUTHOR: Dmitry Eliseev
* */
public final boolean setLong(final int index, final long value) {
try {
m_Statement.setLong(index, value);
return true;
} catch (final SQLException e) {
LoggingUtility.debug("Can't set long value: " + value + " because of " + e.getMessage());
}
return false;
} /* End of 'DBStatement::setLong' class */
/* *
* METHOD: String parameter setter
* RETURN: True if success, False otherwise
* PARAM: [IN] index - parameter position
* PARAM: [IN] value - parameter value
* AUTHOR: Dmitry Eliseev
* */
public final boolean setString(final int index, final String value) {
try {
m_Statement.setString(index, value);
} catch (final SQLException e) {
LoggingUtility.debug("Can't set string value: " + value + " because of " + e.getMessage());
}
return false;
} /* End of 'DBStatement::setString' class */
/* *
* METHOD: Enum parameter setter
* RETURN: True if success, False otherwise
* PARAM: [IN] index - parameter position
* PARAM: [IN] value - parameter value
* AUTHOR: Dmitry Eliseev
* */
public final boolean setEnum(final int index, final Enum value) {
return setString(index, value.name());
} /* End of 'DBStatement::setEnum' method */
/* *
* METHOD: Binary stream parameter setter
* RETURN: True if success, False otherwise
* PARAM: [IN] index - parameter position
* PARAM: [IN] stream - stream
* PARAM: [IN] long - data length
* AUTHOR: Dmitry Eliseev
* */
public final boolean setBinaryStream(final int index, final InputStream stream, final long length) {
try {
m_Statement.setBinaryStream(index, stream);
return true;
} catch (final SQLException e) {
LoggingUtility.debug("Can't set stream value: " + stream + " because of " + e.getMessage());
}
return false;
} /* End of 'DBStatement::setBinaryStream' method */
} /* End of 'DBConnection::DBStatement' class */
/* *
* METHOD: Class constructor
* PARAM: [IN] host - Database service host
* PARAM: [IN] port - Database service port
* PARAM: [IN] name - Database name
* PARAM: [IN] user - Database user's name
* PARAM: [IN] pass - Database user's password
* AUTHOR: Dmitry Eliseev
* */
public DBConnection(final String host, final int port, final String name, final String user, final String pass) {
final BoneCPConfig config = new BoneCPConfig();
config.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + name);
config.setUsername(user);
config.setPassword(pass);
/* Pool size configuration */
config.setMaxConnectionsPerPartition(5);
config.setMinConnectionsPerPartition(5);
config.setPartitionCount(1);
try {
m_Pool = new BoneCP(config);
} catch (final SQLException e) {
LoggingUtility.error("Can't initialize connections pool: " + e.getMessage());
m_Pool = null;
}
} /* End of 'DBConnection::DBConnection' method */
@Override
protected final void finalize() throws Throwable {
super.finalize();
if (m_Pool != null)
m_Pool.shutdown();
} /* End of 'DBConnection::finalize' method */
/* *
* METHOD: Prepares statement using current connection
* RETURN: Prepared statement
* PARAM: [IN] query - SQL query
* AUTHOR: Dmitry Eliseev
* */
public final DBStatement createStatement(final String query) {
try {
LoggingUtility.debug("Total: " + m_Pool.getTotalCreatedConnections() + "; Free: " + m_Pool.getTotalFree() + "; Leased: " + m_Pool.getTotalLeased());
final Connection connection = m_Pool.getConnection();
return new DBStatement(connection, connection.prepareStatement(query, Statement.RETURN_GENERATED_KEYS));
} catch (final SQLException e) {
LoggingUtility.error("Can't create prepared statement using query: " + e.getMessage());
} catch (final Exception e) {
LoggingUtility.error("Connection wasn't established: " + e.getMessage());
}
return null;
} /* End of 'DBConnection::createStatement' method */
/* *
* METHOD: Closes prepared statement
* PARAM: [IN] sql - prepared statement
* AUTHOR: Dmitry Eliseev
* */
private void closeStatement(final DBStatement query) {
if (query == null)
return;
try {
if (query.m_Statement != null)
query.m_Statement.close();
if (query.m_Connection != null)
query.m_Connection.close();
} catch (final SQLException ignored) {}
} /* End of 'DBConnection::closeStatement' method */
/* *
* METHOD: Executes prepared statement like INSERT query
* RETURN: Inserted item identifier if success, 0 otherwise
* PARAM: [IN] sql - prepared statement
* AUTHOR: Dmitry Eliseev
* */
public final long insert(final DBStatement query) {
try {
/* Query execution */
query.m_Statement.execute();
/* Obtain last insert ID */
final ResultSet resultSet = query.m_Statement.getGeneratedKeys();
if (resultSet.next())
return resultSet.getInt(1);
} catch (final SQLException e) {
LoggingUtility.error("Can't execute insert query: " + query.toString());
} finally {
closeStatement(query);
}
/* Insertion failed */
return 0;
} /* End of 'DBConnection::insert' method */
/* *
* METHOD: Executes prepared statement like UPDATE query
* RETURN: True if success, False otherwise
* PARAM: [IN] sql - prepared statement
* AUTHOR: Dmitry Eliseev
* */
public final boolean update(final DBStatement query) {
try {
query.m_Statement.execute();
return true;
} catch (final SQLException e) {
LoggingUtility.error("Can't execute update query: " + query.m_Statement.toString());
} finally {
closeStatement(query);
}
/* Update failed */
return false;
} /* End of 'DBConnection::update' method */
/* *
* METHOD: Executes prepared statement like COUNT != 0 query
* RETURN: True if exists, False otherwise
* PARAM: [IN] sql - prepared statement
* AUTHOR: Dmitry Eliseev
* */
public final boolean exists(final DBStatement query) {
final AbstractList<Map<String, Object>> results = select(query);
return results != null && results.size() != 0;
} /* End of 'DBConnection::DBConnection' method */
/* *
* METHOD: Executes prepared statement like SELECT query
* RETURN: List of records (maps) if success, null otherwise
* PARAM: [IN] sql - prepared statement
* AUTHOR: Dmitry Eliseev
* */
public final AbstractList<Map<String, Object>> select(final DBStatement query) {
try {
/* Container for result set */
final AbstractList<Map<String, Object>> results = new LinkedList<Map<String, Object>>();
/* Query execution */
query.m_Statement.execute();
/* Determine columns meta data */
final ResultSetMetaData metaData = query.m_Statement.getMetaData();
/* Obtain real data */
final ResultSet resultSet = query.m_Statement.getResultSet();
while (resultSet.next()) {
final Map<String, Object> row = new HashMap<String, Object>();
/* Copying fetched data */
for (int columnID = 1; columnID <= metaData.getColumnCount(); columnID++)
row.put(metaData.getColumnName(columnID), resultSet.getObject(columnID));
/* Add row to results */
results.add(row);
}
/* That's it */
return results;
} catch (final SQLException e) {
LoggingUtility.error("Can't execute select query: " + query.toString());
} finally {
closeStatement(query);
}
/* Return empty result */
return null;
} /* End of 'DBConnection::select' method */
} /* End of 'DBConnection' class */
Ещё следует обратить внимание на класс DBController.java:
package com.gesoftware.venta.db;
import com.gesoftware.venta.logging.LoggingUtility;
import java.util.*;
/**
* DB controller class definition
**/
public abstract class DBController<T> {
/* Real DB connection */
protected final DBConnection m_Connection;
/* *
* METHOD: Class constructor
* PARAM: [IN] connection - real DB connection
* AUTHOR: Dmitry Eliseev
* */
protected DBController(final DBConnection connection) {
m_Connection = connection;
LoggingUtility.core(getClass().getCanonicalName() + " controller initialized");
} /* End of 'DBController::DBController' method */
/* *
* METHOD: Requests collection of T objects using select statement
* RETURN: Collection of objects if success, empty collection otherwise
* PARAM: [IN] selectStatement - prepared select statement
* AUTHOR: Dmitry Eliseev
* */
protected final Collection<T> getCollection(final DBConnection.DBStatement selectStatement) {
if (selectStatement == null)
return new LinkedList<T>();
final AbstractList<Map<String, Object>> objectsCollection = m_Connection.select(selectStatement);
if ((objectsCollection == null)||(objectsCollection.size() == 0))
return new LinkedList<T>();
final Collection<T> parsedObjectsCollection = new ArrayList<T>(objectsCollection.size());
for (final Map<String, Object> object : objectsCollection)
parsedObjectsCollection.add(parse(object));
return parsedObjectsCollection;
} /* End of 'DBController::getCollection' method */
/* *
* METHOD: Requests one T object using select statement
* RETURN: Object if success, null otherwise
* PARAM: [IN] selectStatement - prepared select statement
* AUTHOR: Dmitry Eliseev
* */
protected final T getObject(final DBConnection.DBStatement selectStatement) {
if (selectStatement == null)
return null;
final AbstractList<Map<String, Object>> objectsCollection = m_Connection.select(selectStatement);
if ((objectsCollection == null)||(objectsCollection.size() != 1))
return null;
return parse(objectsCollection.get(0));
} /* End of 'DBController::getObject' method */
/* *
* METHOD: Parses object's map representation to real T object
* RETURN: T object if success, null otherwise
* PARAM: [IN] objectMap - object map, obtained by selection from DB
* AUTHOR: Dmitry Eliseev
* */
protected abstract T parse(final Map<String, Object> objectMap);
} /* End of 'DBController' class */
Класс DBController предназначен для работы с объектами какой-нибудь конкретной таблицы. В серверном приложении созданы контроллеры для каждой из таблиц базы данных. На уровне контроллеров реализованы методы вставки, извлечения, обновления данных в базе данных.
Некоторые операции требуют изменения данных сразу в нескольких таблицах. Для этого создан уровень менеджеров. У каждого менеджера есть доступ ко всем контроллерам. На уровне менеджеров реализованы операции более высокого уровня, например «Поместить пользователя X в комнату A». Помимо перехода к новому уровню абстракции менеджеры реализую механизм кэширования данных. Например, незачем лезть в базу данных всякий раз, когда кто-нибудь пытается пройти процедуру аутентификации или хочет узнать свой рейтинг. В менеджерах, ответственных за пользователей или рейтинг пользователей эти данные хранятся. Таким образом, общая нагрузка на базу данных снижается.
Следующий уровень абстракции — это обработчики. В качестве реализации интерфейса IserverHandler используется следующий класс:
package com.gesoftware.stickers.server.handlers;
import com.gesoftware.stickers.model.common.Definitions;
public final class StickersHandler implements IServerHandler {
private final Map<Class, StickersQueryHandler> m_Handlers = new SynchronizedMap<Class, StickersQueryHandler>();
private final StickersManager m_Context;
private final JobsManager m_JobsManager;
public StickersHandler(final DBConnection connection) {
m_Context = new StickersManager(connection);
m_JobsManager = new JobsManager(Definitions.c_TasksThreadSleepTime);
registerQueriesHandlers();
registerJobs();
}
private void registerJobs() {
m_JobsManager.addTask(new TaskGameUpdateStatus(m_Context));
m_JobsManager.addTask(new TaskGameUpdatePhase(m_Context));
}
private void registerQueriesHandlers() {
/* Menu handlers */
m_Handlers.put(QueryAuthorization.class, new QueryAuthorizationHandler(m_Context));
m_Handlers.put(QueryRegistration.class, new QueryRegistrationHandler(m_Context));
m_Handlers.put(QueryRating.class, new QueryRatingHandler(m_Context));
/* Logout */
m_Handlers.put(QueryLogout.class, new QueryLogoutHandler(m_Context));
/* Rooms handlers */
m_Handlers.put(QueryRoomRefreshList.class, new QueryRoomRefreshListHandler(m_Context));
m_Handlers.put(QueryRoomCreate.class, new QueryRoomCreateHandler(m_Context));
m_Handlers.put(QueryRoomSelect.class, new QueryRoomSelectHandler(m_Context));
m_Handlers.put(QueryRoomLeave.class, new QueryRoomLeaveHandler(m_Context));
/* Games handler */
m_Handlers.put(QueryGameLeave.class, new QueryGameLeaveHandler(m_Context));
m_Handlers.put(QueryGameIsStarted.class, new QueryGameIsStartedHandler(m_Context));
m_Handlers.put(QueryGameWhichPhase.class, new QueryGameWhichPhaseHandler(m_Context));
/* Question handler */
m_Handlers.put(QueryGameAsk.class, new QueryGameAskHandler(m_Context));
/* Answer handler */
m_Handlers.put(QueryGameAnswer.class, new QueryGameAnswerHandler(m_Context));
/* Voting handler */
m_Handlers.put(QueryGameVote.class, new QueryGameVoteHandler(m_Context));
/* Users handler */
m_Handlers.put(QueryUserHasInvites.class, new QueryUserHasInvitesHandler(m_Context));
m_Handlers.put(QueryUserAvailable.class, new QueryUserAvailableHandler(m_Context));
m_Handlers.put(QueryUserInvite.class, new QueryUserInviteHandler(m_Context));
}
@SuppressWarnings("unchecked")
private synchronized Serializable userQuery(final String clientID, final Object query) {
final StickersQueryHandler handler = getHandler(query.getClass());
if (handler == null) {
LoggingUtility.error("Handler is not registered for " + query.getClass());
return new ResponseCommonMessage("Internal server error: can't process: " + query.getClass());
}
return handler.processQuery(m_Context.getClientsManager().getClient(clientID), query);
}
private StickersQueryHandler getHandler(final Class c) {
return m_Handlers.get(c);
}
private ServerResponse answer(final Serializable object) {
return new ServerResponse(new Message(object));
}
@Override
public boolean onConnect(final String clientID, final InetAddress clientAddress) {
LoggingUtility.info("User <" + clientID + "> connected from " + clientAddress.getHostAddress());
m_Context.getClientsManager().clientConnected(clientID);
return true;
}
@Override
public final ServerResponse onReceive(final String clientID, final Message message) {
final Object object = message.getObject();
if (object == null) {
LoggingUtility.error("Unknown object accepted");
return answer(new ResponseCommonMessage("Internal server error: empty object"));
}
return new ServerResponse(new Message(userQuery(clientID, object)));
}
@Override
public void onDisconnect(final String clientID) {
m_Context.getClientsManager().clientDisconnected(clientID);
LoggingUtility.info("User <" + clientID + "> disconnected");
}
public void stop() {
m_JobsManager.stop();
}
}
Этот класс содержит отображение классов объектов-запросов в соответствующие объекты-обработчики. Такой подход (хоть он и не самый быстрый по времени выполнения) позволяет хорошо, на мой взгляд, организовать код. Каждый обработчик решает только одну конкретную задачу, связанную с запросом. Например, регистрация пользователей.
package com.gesoftware.stickers.server.handlers.registration;
import com.gesoftware.stickers.model.enums.UserStatus;
import com.gesoftware.stickers.model.objects.User;
import com.gesoftware.stickers.model.queries.registration.QueryRegistration;
import com.gesoftware.stickers.model.responses.registration.ResponseRegistrationInvalidEMail;
import com.gesoftware.stickers.model.responses.registration.ResponseRegistrationFailed;
import com.gesoftware.stickers.model.responses.registration.ResponseRegistrationSuccessfully;
import com.gesoftware.stickers.model.responses.registration.ResponseUserAlreadyRegistered;
import com.gesoftware.stickers.server.handlers.StickersQueryHandler;
import com.gesoftware.stickers.server.managers.StickersManager;
import com.gesoftware.venta.logging.LoggingUtility;
import com.gesoftware.venta.utility.ValidationUtility;
import java.io.Serializable;
public final class QueryRegistrationHandler extends StickersQueryHandler<QueryRegistration> {
public QueryRegistrationHandler(final StickersManager context) {
super(context);
}
@Override
public final Serializable process(final User user, final QueryRegistration query) {
if (!ValidationUtility.isEMailValid(query.m_EMail))
return new ResponseRegistrationInvalidEMail();
if (m_Context.getUsersManager().isUserRegistered(query.m_EMail))
return new ResponseUserAlreadyRegistered();
if (!m_Context.getUsersManager().registerUser(query.m_EMail, query.m_PasswordHash, query.m_Name))
return new ResponseRegistrationFailed();
LoggingUtility.info("User <" + user.m_ClientID + "> registered as " + query.m_EMail);
return new ResponseRegistrationSuccessfully();
}
@Override
public final UserStatus getStatus() {
return UserStatus.NotLogged;
}
}
Код читается довольно легко, не правда ли?
Клиентское приложение
В клиентском приложении реализована точно такая же логика с обработчиками, но только серверных ответов. Реализована она в классе, унаследованном от интерфейса IClientHandler.
Количество различных Activities совпадает с количеством игровых состояний. Принцип взаимодействия с сервером достаточно простой:
- Пользователь совершает какое-нибудь действие (например, нажимает кнопку «Войти в игру»)
- Клиентское приложение отображает Progress диалог пользователю
- Клиентское приложение отправляет на сервер учетные данные пользователя
- Сервер обрабатывает запрос и посылает обратно ответ
- Соответствующий ответу обработчик скрывает Progress диалог
- Происходит обработка ответа и вывод результатов клиенту
Таким образом, бизнес-логика как на клиенте, так и на сервере разбита на большое количество маленьких структурированных классов.
Ещё одна вещь, о которой хотелось бы рассказать — это покупки внутри приложения. Как было замечено в нескольких статьях здесь, довольно удобным решением для монетизации приложения являются покупки внутри приложения. Я решил воспользоваться советом и добавил в приложение рекламу и возможность её отключения за 1$.
Когда я только начинал разбираться с биллингом, я убил огромное количество времени на осмысление принципа его работы в Google. Я достаточно долго времени пытался понять как же осуществить валидацию платежа на сервере, ведь логичным кажется после выдачи Google'ом некой информации о платеже (скажем, номер платежа), передать его на игровой сервер и уже с него, обратившись через API Google, проверить выполнен ли платёж. Как оказалось, такая схема работает только для подписок. Для обычных покупок все гораздо проще. При осуществлении покупки в приложении, Google возвращает JSON с информацией о покупке и её статусе (чек) и электронную подпись этого чека. Таким образом все упирается в вопрос «доверяете ли Вы компании Google?». :) Собственно, после получения такой пары, она пересылается на игровой сервер, которому только останется проверить две вещи:
- Не присылали ли на сервер уже такой запрос (это для неконтроллируемых гуглом операций, скажем покупка игровой валюты)
- Правильно ли подписан чек электронной подписью (ведь общий ключ гугла всем известен, в том числе и серверу)
На этой ноте хотелось бы закончить свое первое и сумбурное повествование. Я прочел свою статью несколько раз, понимаю что это не идеал технического текста, и, возможно, она достаточно трудна для восприятия, но в будущем (если оно наступит), постараюсь исправить ситуацию.
Ссылки
- In-app Billing Overview
- In-App purchasing или внутренние платежи в приложениях для Android
- In-App Purchase в Android приложениях
Сторонние библиотеки
Заключение
Если у кого-то хватило терпения дочитать до конца, выражаю свою признательность, так как не претендую на звание профессионального писателя. Прошу сильно ругать, так как это мой первый опыт публикации здесь. Одной из причин публикации является предполагаемый «хабраэффект», который мне необходим для проведения нагрузочного тестирования сервера, а также, набора игровой аудитории, так что прошу прощения за корыстную составляющую цели публикации. Буду признателен за указание на ошибки/неточности. Спасибо за внимание!
В заключении, небольшой опрос (не могу его добавить в настоящий момент): стоит ли в дальнейшем публиковаться? Если да, то на какую тему интересны были бы публикации:
- Математика: линейная алгебра
- Математика: анализ
- Математика: численные методы и методы оптимизации
- Математика: дискретная математика и теория алгоритмов
- Математика: вычислительная геометрия
- Программирование: основы компьютерной графики (на примере этого проекта)
- Программирование: программирование шейдеров
- Программирование: game development
- Физика: теория относительности
- Физика: астрофизика
Что где?
Автор: Trimax