Pull to refresh

Делаем лог-систему для Minecraft

Reading time5 min
Views13K

Приветствую, Хаброжители!


Сегодня речь пойдет о мире, о который большинство из вас не знает, но при этом там крутятся многие отличные инженеры-разработчики и большие деньги. Да, как ни странно, речь пойдет о Minecraft.


Minecraft — игра-песочница и на мультиплеер-серверах остро стоит проблема гриферства (от англ. griefing — вредительство), когда игроки рушат чужие постройки. На серверах с этой проблемой справляются по-разному. На публичных используют плагин на 'приват', на остальных же все строится на доверии.


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


Выбор базы данных


Итак, вот у нас массив данных и хорошо бы его куда-то сохранять. Умные люди давно придумали БД. Лично у меня требования к БД были такие:


  • Быстрая вставка
  • Максимальное сжатие данных
  • Возможность из Java без root-прав развертываться без лишних телодвижений

Последний пункт появился из-за того, что не на всех хостингах есть возможность получить root-доступ или установить какой-либо пакет. К тому же, не хотелось усложнять процедуру установки, а остановиться на "Кинул и забыл".


Базы данных, которые удовлетворяли бы всем критериям я не нашел, поэтому решил сделать свою мини-БД на Java.



Оптимизация места на жёстком диске


Основная проблема игры, как считают многие, — все её вычисления происходят в одном потоке. Это настоящая боль держателей серверов. Распараллелить изначально однопоточную архитектуру — надо постараться.



Поэтому само логгирование пришлось вынести в отдельный поток. А чтобы система не захлебнулась от Event'ов в очереди, добавить поддержку воркеров. Количество воркеров настраеваемое.


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



Оптимизация места на жёстком диске


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


Изначально строчка в логфайле выглядела так:


[2001-07-04T12:08:56.235-0700]Player PLACE <blockid> to 128,128,128


При беглом взгляде можно заметить, что 2001-07-04T12:08:56.235-0700 можно сократить до Timestamp, а PLACE или REMOVE на символ '+' и '-' соответственно. Ну и уберем нафиг 'to':


[123454678]Player + <blockid> 128,128,128


Не сложно заметить, что в логе будет часто повторятся nickname и blockid. Соответсвенно, их можно вынести в отдельный файл, а в лог писать только id


[123454678]1 + 1 128,128,128


В итоге я пришел к тому, что в строчке лога остались только числа и один символ. Мы сэкономим много места, если уберем разделители (пробелы) и числа будем записывать как байты, а не как символы. Сообственно, это привело меня к решению использовать байтовые логи.


Сама байтовая строка теперь выглядит так:


Name posX posY posZ typeaction playerid blockid timestamp
Field Length (bytes) 4 byte 4 byte 4 byte 1 byte ('0' for Remove, '1' for Insert) 4 byte 8 byte 8 byte

Итого мы имеем 35 байтов на строку фиксированно (1 байт для разделения строк).
Вначале был соблазн оставить 34 байта, но так как запись ведется в один файл, то в случае с фиксированной длинной, если побьется одна строка, весь файл станет нечитаемым.


Путь для логов: /{save}/{world/dimension}/*.bytelog


Структура строки для blockname to id:


Name id blockname
Field Length (bytes) 8 byte 1 byte per symbols

Итого: ~ 21 байтов на блок
Имя файла: blockmap.bytelog


Структура строки для nickname to id:


Name id nickname
Field Length (bytes) 4 byte 1 byte per symbols

Итого: ~ 10 байтов на игрока
Имя файла: nickmap.bytelog


Оптимизация памяти


Чтобы быстро маппить blockname и nickname в id пришлось держать содержимое обоих файлов в памяти. Java не может в HashMap хранить примитивные типы, поэтому каждый Integer будет стоить нам ~50 байт в памяти, что очень много.


Решить эту проблему нам поможет библиотека trove.


private final TObjectIntHashMap uuidToId = new TObjectIntHashMap();

Но каждый символ у нас занимает примерно 2 байта. Мы можем снизить потребления памяти с помощью самописного файла ASCIString, в котором символы хранятся в byte[], а не в char[].


Тестирование


В тестировании байтовой сериализации и десериализации ничего необычного нет, а вот для тестирования компонентов, к которым требовался многопоточный доступ пришлось использовать фреймворк от гугла Thread Weaver. Обычный тест с использованием этого фреймворка выглядит так:


public class NickMapperAsyncTest extends TestCase {
    private volatile NickMapper nickMapper;

    public void testNickMapper() {
        final AnnotatedTestRunner runner = new AnnotatedTestRunner();
        runner.runTests(this.getClass(), NickMapper.class);
    }

    @ThreadedBefore
    public void before() throws IOException {
        nickMapper = new NickMapper();
    }

    @ThreadedMain
    public void main() {
        nickMapper.getOrPutUser(new ASCIString("2"));
        nickMapper.getOrPutUser(new ASCIString("LionZXY"));
        nickMapper.getOrPutUser(new ASCIString("3"));
    }

    @ThreadedSecondary
    public void secondary() {
        nickMapper.getOrPutUser(new ASCIString("2"));
        nickMapper.getOrPutUser(new ASCIString("LionZXY"));
        nickMapper.getOrPutUser(new ASCIString("3"));

    }

    @ThreadedAfter
    public void after() {
        final int first = nickMapper.getOrPutUser(new ASCIString("LionZXY"));
        final int second = nickMapper.getOrPutUser(new ASCIString("2"));
        final int third = nickMapper.getOrPutUser(new ASCIString("3"));
        assertEquals(3, nickMapper.size());
        assertEquals(Integer.MIN_VALUE + 3, Collections.max(Arrays.asList(first, second, third)).intValue());
    }
}

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


Заключение


Пока по количеству скачиваний будет понятно стоит ли развивать дальше этот мод и идею. Из примерных планов на будущее:


  • Добавить возможность удалять старые и неактуальные логи автоматически
  • Добавить сжатие для файлов

Ссылки



P.S. Вы только посмотрите какая офигенная конфиг-система в новых версиях Minecraft Forge
@Config(modid = FastLogBlock.MODID)
@Config.LangKey("fastlogblock.config.title")
public class LogConfig {
    @Config.Comment("Enable handling event")
    public static boolean loggingEnable = true;
    @Config.Comment("Filepath from minecraft root folder to block log path")
    public static String logFolderPath = "blocklog";
    @Config.Comment("Path to nickname mapper file from logFolderPath")
    public static String nickToIntFilePath = "nicktoid.bytelog";
    @Config.Comment("Path to block mapper file from logFolderPath")
    public static String blockToLongFilePath = "blocktoid.bytelog";
    public static HashConfig HASH_CONFIG = new HashConfig();
    @Config.Comment("File splitter type. SINGLE for single-file strategy, BLOCKHASH for file=HASH(BlockPos) strategy")
    public static FileSplitterEnum fileSplitterType = FileSplitterEnum.BLOCKHASH;
    @Config.Comment("Utils information for migration")
    public static int logSchemeVersion = 1;
    @Config.Comment("Utils information for migration")
    public static int writeWorkersCount = 4;
    @Config.Comment("Regular expression for block change event ignore")
    public static String[] ignoreBlockNamesRegExp = new String[]{"<minecraft:tallgrass:*>"};
    @Config.Comment("Permission level for show block log.")
    public static boolean onlyForOP = true;

    public static class HashConfig {
        @Config.Comment("Max logfile count")
        public final int fileCount = 16;

        @Config.Comment("Pattern for log filename. %d - file number. Default: part%d.bytelog")
        public final String fileNamePattern = "part%d.bytelog";
    }

    @Mod.EventBusSubscriber(modid = FastLogBlock.MODID)
    private static class EventHandler {

        /**
         * Inject the new values and save to the config file when the config has been changed from the GUI.
         *
         * @param event The event
         */
        @SubscribeEvent
        public static void onConfigChanged(final ConfigChangedEvent.OnConfigChangedEvent event) {
            if (event.getModID().equals(FastLogBlock.MODID)) {
                ConfigManager.sync(FastLogBlock.MODID, Config.Type.INSTANCE);
            }
        }
    }
}
Only registered users can participate in poll. Log in, please.
Приходилось ли вам делать моды для Minecraft?
7.14% Да, регулярно9
12.7% Да, было давно16
22.22% Нет, но было в планах28
46.83% Нет59
11.11% Узнать результаты14
126 users voted. 19 users abstained.
Tags:
Hubs:
Total votes 13: ↑12 and ↓1+11
Comments27

Articles