- PVSM.RU - https://www.pvsm.ru -
О том, как мы делали игру «Стикеры» для Google Play
Давно у меня была мысль поделиться своими знаниями с сообществом. Сначала хотел написать что-нибудь по астрофизике или ОТО, но решил все же что корректнее будет писать о той предметной области, которой я занимаюсь профессионально. Итак, я постараюсь подробно изложить процесс создания и тонкости реализации игрового приложения под Android (начиная от проектирования, заканчивая публикацией и In App покупками).
Программированием я занимаюсь с первого класса, закончил Прикладную математику СпбГПУ. Недавно (где-то год назад) открыл для себя разработку под мобильные платформы. Стало интересно что это такое и с чем его едят. В настоящее время разрабатываю несколько проектов в команде друзей/коллег, но хотел бы написать о своём первом опыте. Таким опытом было написание игрового приложения — «Стикеры [1]» (Кто Я?).
Выбор пал на на «Стикеры» по нескольким причинам.
Во-первых, мы не нашли в маркете аналогов (имеется в виду реализация правил описанной застольной игры).
Во-вторых, хотелось написать что-нибудь не очень трудоёмкое.
В-третьих, игра достаточно популярна в наших кругах и мы подумали что возможно кому-нибудь было бы интересно поиграть в неё и виртуально.
Задача образовалась достаточно однозначная. Необходимо реализовать клиент-серверное приложение, позволяющее его пользователям использовать следующие возможности:
Игровой процесс представляет собой поочередную смену фаз:
К счастью, моя жена — дизайнер и мне не практически не пришлось принимать участие в выборе палитры, расположении элементов и прочих дизайнерских штуках. По итогам анализа возможностей, которые игра должна предоставлять игроку, было решено сколько будет игровых состояний (Activities) и какие элементы управления должны быть в каждом из них:
Как только нам стало понятно какие игровые состояния и объекты существуют в игре, мы перешли к их формализации в терминах базы данных.
Итак, нам понадобятся следующие таблицы:
В начальной версии игры были только эти таблицы, но игра развивалась и добавлялись новые. Я не буду описывать остальные таблицы, иначе повествование чрезмерно затянется. На схеме базы данных изображены все таблицы, но их присутствие не помешает дальнейшему расскажу.
Связи между таблицами менялись несколько раз (agile, так сказать), но в конечном итоге остались следующие:
Наконец-то мы добрались до программной реализации. Итак, начну с самых общих слов. Весь проект состоит из 4х модулей:
Поскольку я люблю изобретать велосипеды и не люблю мешанины сторонних библиотек (да, да классическая проблема многих программистов-педантов), я решил написать некоторые вещи сам. Эта библиотека пишется мною давно и в ней собраны многие полезные для меня утилиты (работа с базой данных, клиент-серверное взаимодействие, акторы, математика, шифрование...).
В рамках данной статьи я хочу рассказать о сетевой части данной библиотеки. Реализацию взаимодействия между клиентом и сервером я решил сделать путем сериализации/десериализации объектов, среди которых есть и запросы и ответы. В качестве элементарной пересылаемой единицы информации (на уровне библиотеки, разумеется) выступает объект 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, необходимо указать порт, на котором он будет ожидать входящие соединения и реализацию интерфейса 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 совпадает с количеством игровых состояний. Принцип взаимодействия с сервером достаточно простой:
Таким образом, бизнес-логика как на клиенте, так и на сервере разбита на большое количество маленьких структурированных классов.
Ещё одна вещь, о которой хотелось бы рассказать — это покупки внутри приложения. Как было замечено в нескольких статьях здесь, довольно удобным решением для монетизации приложения являются покупки внутри приложения. Я решил воспользоваться советом и добавил в приложение рекламу и возможность её отключения за 1$.
Когда я только начинал разбираться с биллингом, я убил огромное количество времени на осмысление принципа его работы в Google. Я достаточно долго времени пытался понять как же осуществить валидацию платежа на сервере, ведь логичным кажется после выдачи Google'ом некой информации о платеже (скажем, номер платежа), передать его на игровой сервер и уже с него, обратившись через API Google, проверить выполнен ли платёж. Как оказалось, такая схема работает только для подписок. Для обычных покупок все гораздо проще. При осуществлении покупки в приложении, Google возвращает JSON с информацией о покупке и её статусе (чек) и электронную подпись этого чека. Таким образом все упирается в вопрос «доверяете ли Вы компании Google?». :) Собственно, после получения такой пары, она пересылается на игровой сервер, которому только останется проверить две вещи:
На этой ноте хотелось бы закончить свое первое и сумбурное повествование. Я прочел свою статью несколько раз, понимаю что это не идеал технического текста, и, возможно, она достаточно трудна для восприятия, но в будущем (если оно наступит), постараюсь исправить ситуацию.
Если у кого-то хватило терпения дочитать до конца, выражаю свою признательность, так как не претендую на звание профессионального писателя [10]. Прошу сильно ругать, так как это мой первый опыт публикации здесь. Одной из причин публикации является предполагаемый «хабраэффект», который мне необходим для проведения нагрузочного тестирования сервера, а также, набора игровой аудитории, так что прошу прощения за корыстную составляющую цели публикации. Буду признателен за указание на ошибки/неточности. Спасибо за внимание!
В заключении, небольшой опрос (не могу его добавить в настоящий момент): стоит ли в дальнейшем публиковаться? Если да, то на какую тему интересны были бы публикации:
Что где?
Автор: Trimax
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/46004
Ссылки в тексте:
[1] Стикеры: https://play.google.com/store/apps/details?id=com.gesoftware.stickers
[2] Image: http://habr.habrastorage.org/post_images/1a9/783/485/1a9783485dfadb199e0d511720195cf0.png
[3] Venta: https://github.com/Trimax/venta
[4] In-app Billing Overview: http://developer.android.com/google/play/billing/billing_overview.html
[5] In-App purchasing или внутренние платежи в приложениях для Android: http://habrahabr.ru/post/117944/
[6] In-App Purchase в Android приложениях: http://habrahabr.ru/post/123642/
[7] Android Billing Library: https://github.com/robotmedia/AndroidBillingLibrary
[8] Bone CP: http://jolbox.com/
[9] JDBC: http://www.oracle.com/technetwork/java/javase/jdbc/index.html
[10] писателя: http://ru.wikipedia.org/wiki/%D0%9A%D0%B0%D1%81%D0%BB_%28%D1%82%D0%B5%D0%BB%D0%B5%D1%81%D0%B5%D1%80%D0%B8%D0%B0%D0%BB%29
[11] на примере этого проекта: https://github.com/Trimax/ventaengine
[12] Источник: http://habrahabr.ru/post/198054/
Нажмите здесь для печати.