- PVSM.RU - https://www.pvsm.ru -

О том, как мы делали игру для Google Play

О том, как мы делали игру «Стикеры» для Google Play
О том, как мы делали игру для Google Play

Давно у меня была мысль поделиться своими знаниями с сообществом. Сначала хотел написать что-нибудь по астрофизике или ОТО, но решил все же что корректнее будет писать о той предметной области, которой я занимаюсь профессионально. Итак, я постараюсь подробно изложить процесс создания и тонкости реализации игрового приложения под Android (начиная от проектирования, заканчивая публикацией и In App покупками).

Введение

Программированием я занимаюсь с первого класса, закончил Прикладную математику СпбГПУ. Недавно (где-то год назад) открыл для себя разработку под мобильные платформы. Стало интересно что это такое и с чем его едят. В настоящее время разрабатываю несколько проектов в команде друзей/коллег, но хотел бы написать о своём первом опыте. Таким опытом было написание игрового приложения — «Стикеры [1]» (Кто Я?).

Что ещё за Стикеры такие?

Для тех, кто не в курсе — поясню. Стикеры — это такая застольная игра, в которой каждому игроку на лоб клеится бумажка с каким-нибудь известным персонажем (персонажей придумывают друг другу играющие). Цель каждого участника — отгадать загаданного ему персонажа.
Игровой процесс представляет собой поочередное задание «да/нет» вопросов и получение ответов на них от остальных игроков.

Выбор пал на на «Стикеры» по нескольким причинам.
Во-первых, мы не нашли в маркете аналогов (имеется в виду реализация правил описанной застольной игры).
Во-вторых, хотелось написать что-нибудь не очень трудоёмкое.
В-третьих, игра достаточно популярна в наших кругах и мы подумали что возможно кому-нибудь было бы интересно поиграть в неё и виртуально.

Процесс разработки

Постановка задачи

Задача образовалась достаточно однозначная. Необходимо реализовать клиент-серверное приложение, позволяющее его пользователям использовать следующие возможности:

  • Создавать собственную учётную запись
  • Аутентификация с использованием собственной учётной записи
  • Просмотр рейтинга игроков
  • Создание игровой комнаты
  • Вход в игровую комнату
  • Участие в игровом процессе

Игровой процесс представляет собой поочередную смену фаз:

  • Фаза вопросов
  • Фаза голосования
Проектирование UI

К счастью, моя жена — дизайнер и мне не практически не пришлось принимать участие в выборе палитры, расположении элементов и прочих дизайнерских штуках. По итогам анализа возможностей, которые игра должна предоставлять игроку, было решено сколько будет игровых состояний (Activities) и какие элементы управления должны быть в каждом из них:

  • Главное меню
    • Аутентификация
    • Регистрация
    • Доступ к рейтингу игроков

  • Рейтинг игроков
    • Возврат в главное меню

  • Список комнат
    • Вход в комнату
    • Создание своей комнаты
    • Переход в главное меню

  • Текущая комната
    • Выход из комнаты

  • Игровое состояние №1: ввод вопроса
    • Ввод вопроса
    • Переход к истории вопросов
    • Переход к вводу ответа
    • Переход в главное меню

  • Игровое состояние №2: просмотр истории вопросов
    • Переход к вводу вопроса
    • Переход к вводу ответа
    • Переход в главное меню

  • Игровое состояние №3: ввод ответа
    • Ввод ответа
    • Переход к вводу вопроса
    • Переход к истории вопросов
    • Переход в главное меню

  • Игровое состояние №4: голосование
    • Ввод голосов
    • Переход в главное меню

  • Победа
    • Переход в главное меню

  • Поражение
    • Переход в Wikipedia (на страницу персонажа)
    • Переход в главное меню

Схема переходов между состояниями

О том, как мы делали игру для Google Play

Проектирование DB

Как только нам стало понятно какие игровые состояния и объекты существуют в игре, мы перешли к их формализации в терминах базы данных.

Итак, нам понадобятся следующие таблицы:

  • Users. Таблица, в которой хранится информация о всех пользователях
  • Games. Таблица, в которой хранится информация о всех комнатах
  • Characters. Таблица, в которой хранится информация о всех персонажах
  • Questions. Таблица, в которой хранятся вопросы пользователей о загаданных им персонажах
  • Answers. Таблица, в которой хранятся ответы пользователей

В начальной версии игры были только эти таблицы, но игра развивалась и добавлялись новые. Я не буду описывать остальные таблицы, иначе повествование чрезмерно затянется. На схеме базы данных изображены все таблицы, но их присутствие не помешает дальнейшему расскажу.

Схема базы данных

О том, как мы делали игру для Google Play [2]

Связи между таблицами менялись несколько раз (agile, так сказать), но в конечном итоге остались следующие:

  • Каждому пользователю может быть поставлен в соответствие один персонаж
  • Каждый пользователь может находиться только в одной комнате
  • Каждый вопрос может быть задан только одним пользователем
  • Каждый вопрос может относиться только к одному персонажу
  • Каждый вопрос может быть задан только в рамках одной игры
  • Каждый ответ может быть дан только одним пользователем
  • Каждый ответ может быть дан только на один вопрос

А где же нормализация данных?

Дублирующие связи нужны лишь для снижения нагрузки на СУБД и появились они далеко не в первой версии игры. С увеличением количества таблиц, выросло количество агрегаций, которое необходимо производить для определённых выборок данных.
Уровень приложения

Наконец-то мы добрались до программной реализации. Итак, начну с самых общих слов. Весь проект состоит из 4х модулей:

  • Venta. Библиотека, в которой собраны полезные утилиты
  • Protocol. Библиотека с описанием протокола взаимодействия
  • Server. Серверная часть приложения
  • Client. Клиентская часть приложения
Схема проекта

О том, как мы делали игру для Google Play
Библиотека Venta [3]

Поскольку я люблю изобретать велосипеды и не люблю мешанины сторонних библиотек (да, да классическая проблема многих программистов-педантов), я решил написать некоторые вещи сам. Эта библиотека пишется мною давно и в ней собраны многие полезные для меня утилиты (работа с базой данных, клиент-серверное взаимодействие, акторы, математика, шифрование...).
В рамках данной статьи я хочу рассказать о сетевой части данной библиотеки. Реализацию взаимодействия между клиентом и сервером я решил сделать путем сериализации/десериализации объектов, среди которых есть и запросы и ответы. В качестве элементарной пересылаемой единицы информации (на уровне библиотеки, разумеется) выступает объект Message:

”Message.java”

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

”IServerHandler.java”

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.

”IClientHandler.java”

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 */

Теперь немного о внутреннем устройстве сервера. Как только к серверу присоединяется очередной клиент, для него вычисляется уникальный хэш и создаются два потока: поток приема и поток отправки. Поток приёма блокируется и ожидает сообщения от клиента. Как только сообщение от клиента было принято, оно передаётся зарегистрированному пользователем библиотеки обработчику. В результате обработки может произойти одно из пяти событий:

  • Отсоединение клиента (скажем, пришёл запрос на отключение)
  • Отправка клиенту ответа
  • Отправка ответа другому клиенту
  • Отправка ответа всем присоединённым клиентам
  • Отправка ответа некоторой группе клиентов

Если теперь необходимо отправить сообщение какому-нибудь из подключенных клиентов, оно помещается в очередь отправки сообщений данного клиента, а поток, отвечающий за отправку уведомляется о том, что в очереди появились новые сообщения.

Наглядно, поток данных можно продемонстрировать схемой ниже.

Поток данных в сетевом модуле библиотеки

О том, как мы делали игру для Google Play

Клиент 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();
    }
}

Довольно коротко, не правда ли?

Игровой сервер

Архитектура игрового сервера многоуровневая. Сразу же приведу её схему, а затем и описание.

Схема архитектуры сервера

О том, как мы делали игру для Google Play

Итак, для взаимодействия с базой данных используется пул соединений (я использую библиотеку BoneCP). Для работы с подготовленными запросами (prepared statements), я завернул соединение в свой собственный класс (библиотека Venta).

DBConnection.java

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:

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 используется следующий класс:

StickersHandler.java

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?». :) Собственно, после получения такой пары, она пересылается на игровой сервер, которому только останется проверить две вещи:

  • Не присылали ли на сервер уже такой запрос (это для неконтроллируемых гуглом операций, скажем покупка игровой валюты)
  • Правильно ли подписан чек электронной подписью (ведь общий ключ гугла всем известен, в том числе и серверу)

На этой ноте хотелось бы закончить свое первое и сумбурное повествование. Я прочел свою статью несколько раз, понимаю что это не идеал технического текста, и, возможно, она достаточно трудна для восприятия, но в будущем (если оно наступит), постараюсь исправить ситуацию.

Ссылки

Сторонние библиотеки

Заключение

Если у кого-то хватило терпения дочитать до конца, выражаю свою признательность, так как не претендую на звание профессионального писателя [10]. Прошу сильно ругать, так как это мой первый опыт публикации здесь. Одной из причин публикации является предполагаемый «хабраэффект», который мне необходим для проведения нагрузочного тестирования сервера, а также, набора игровой аудитории, так что прошу прощения за корыстную составляющую цели публикации. Буду признателен за указание на ошибки/неточности. Спасибо за внимание!

В заключении, небольшой опрос (не могу его добавить в настоящий момент): стоит ли в дальнейшем публиковаться? Если да, то на какую тему интересны были бы публикации:

  • Математика: линейная алгебра
  • Математика: анализ
  • Математика: численные методы и методы оптимизации
  • Математика: дискретная математика и теория алгоритмов
  • Математика: вычислительная геометрия
  • Программирование: основы компьютерной графики (на примере этого проекта [11])
  • Программирование: программирование шейдеров
  • Программирование: game development
  • Физика: теория относительности
  • Физика: астрофизика

Что где?

Автор: 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/