diff --git a/common/src/main/java/com/discordsrv/common/config/connection/ConnectionConfig.java b/common/src/main/java/com/discordsrv/common/config/connection/ConnectionConfig.java index e185c615..8b938959 100644 --- a/common/src/main/java/com/discordsrv/common/config/connection/ConnectionConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/connection/ConnectionConfig.java @@ -34,6 +34,8 @@ public class ConnectionConfig implements Config { public Bot bot = new Bot(); + public StorageConfig storage = new StorageConfig(); + @ConfigSerializable public static class Bot { diff --git a/common/src/main/java/com/discordsrv/common/config/connection/StorageConfig.java b/common/src/main/java/com/discordsrv/common/config/connection/StorageConfig.java new file mode 100644 index 00000000..4712732f --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/config/connection/StorageConfig.java @@ -0,0 +1,80 @@ +package com.discordsrv.common.config.connection; + +import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Comment; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +@ConfigSerializable +public class StorageConfig { + + @Comment("The storage backend to use.\n\n" + + "- H2\n" + + "- MySQL\n") + public String backend = "h2"; + + @Comment("Connection options for remote databases (MySQL)") + public Remote remote = new Remote(); + + @Comment("Extra connection properties for database drivers") + public Map driverProperties = new LinkedHashMap() {{ + put("useSSL", "false"); + }}; + + public Properties getDriverProperties() { + Properties properties = new Properties(); + for (Map.Entry property : driverProperties.entrySet()) { + String key = property.getKey(); + String value = property.getValue(); + if (value.equals("true")) { + properties.put(key, true); + } else if (value.equals("false")) { + properties.put(key, false); + } else { + properties.put(key, value); + } + } + return properties; + } + + public static class Remote { + + @Comment("The database address.\n" + + "Uses the default port (MySQL: 3306)\n" + + "for the database if a port isn't specified in the \"address:port\" format") + public String databaseAddress = "localhost"; + + @Comment("The name of the database") + public String databaseName = "minecraft"; + + @Comment("The database username and password") + public String username = "root"; + public String password = ""; + + @Comment("Connection pool options. Don't touch these unless you know what you're doing") + public Pool poolOptions = new Pool(); + + } + + public static class Pool { + + @Comment("The maximum amount of concurrent connections to keep to the database") + public int maximumPoolSize = 5; + + @Comment("The minimum amount of concurrent connections to keep to the database") + public int minimumPoolSize = 2; + + @Comment("How frequently to attempt to keep connections alive, in order to prevent being timed out by the database or network infrastructure.\n" + + "The time is specified in milliseconds. Use 0 to disable keepalive." + + "The default is 0 (disabled)") + public long keepaliveTime = 0; + + @Comment("The maximum time a connection will be kept open in milliseconds.\n" + + "The time is specified in milliseconds. Must be at least 30000ms (30 seconds)" + + "The default is 1800000ms (30 minutes)") + public long maximumLifetime = 1800000; + + } +} diff --git a/common/src/main/java/com/discordsrv/common/dependency/DependencyLoader.java b/common/src/main/java/com/discordsrv/common/dependency/DependencyLoader.java index 442c8c8d..dfd09a51 100644 --- a/common/src/main/java/com/discordsrv/common/dependency/DependencyLoader.java +++ b/common/src/main/java/com/discordsrv/common/dependency/DependencyLoader.java @@ -20,6 +20,7 @@ package com.discordsrv.common.dependency; import com.discordsrv.common.DiscordSRV; import dev.vankka.dependencydownload.DependencyManager; +import dev.vankka.dependencydownload.classloader.IsolatedClassLoader; import dev.vankka.dependencydownload.classpath.ClasspathAppender; import dev.vankka.dependencydownload.repository.StandardRepository; @@ -70,6 +71,12 @@ public class DependencyLoader { return download(dependencyManager, classpathAppender); } + public IsolatedClassLoader loadIntoIsolated() throws IOException { + IsolatedClassLoader classLoader = new IsolatedClassLoader(); + process(classLoader).join(); + return classLoader; + } + private CompletableFuture download(DependencyManager manager, ClasspathAppender classpathAppender) { CompletableFuture future = new CompletableFuture<>(); diff --git a/common/src/main/java/com/discordsrv/common/exception/StorageException.java b/common/src/main/java/com/discordsrv/common/exception/StorageException.java new file mode 100644 index 00000000..6b1c9edb --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/exception/StorageException.java @@ -0,0 +1,12 @@ +package com.discordsrv.common.exception; + +public class StorageException extends RuntimeException { + + public StorageException(Throwable cause) { + super(cause); + } + + public StorageException(String message) { + super(message); + } +} diff --git a/common/src/main/java/com/discordsrv/common/function/CheckedConsumer.java b/common/src/main/java/com/discordsrv/common/function/CheckedConsumer.java new file mode 100644 index 00000000..7ebf7426 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/function/CheckedConsumer.java @@ -0,0 +1,7 @@ +package com.discordsrv.common.function; + +@FunctionalInterface +public interface CheckedConsumer { + + void accept(I input) throws Throwable; +} diff --git a/common/src/main/java/com/discordsrv/common/storage/Storage.java b/common/src/main/java/com/discordsrv/common/storage/Storage.java index 6ef14fc2..6ec7e77a 100644 --- a/common/src/main/java/com/discordsrv/common/storage/Storage.java +++ b/common/src/main/java/com/discordsrv/common/storage/Storage.java @@ -27,6 +27,9 @@ import java.util.UUID; @Blocking public interface Storage { + void initialize(); + void close(); + @Nullable Long getUserId(@NotNull UUID player); diff --git a/common/src/main/java/com/discordsrv/common/storage/impl/package-info.java b/common/src/main/java/com/discordsrv/common/storage/impl/package-info.java new file mode 100644 index 00000000..a871c9e3 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/storage/impl/package-info.java @@ -0,0 +1 @@ +package com.discordsrv.common.storage.impl; diff --git a/common/src/main/java/com/discordsrv/common/storage/impl/sql/SQLStorage.java b/common/src/main/java/com/discordsrv/common/storage/impl/sql/SQLStorage.java new file mode 100644 index 00000000..3aea4ad4 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/storage/impl/sql/SQLStorage.java @@ -0,0 +1,112 @@ +package com.discordsrv.common.storage.impl.sql; + +import com.discordsrv.common.exception.StorageException; +import com.discordsrv.common.function.CheckedConsumer; +import com.discordsrv.common.function.CheckedFunction; +import com.discordsrv.common.storage.Storage; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.UUID; + +public abstract class SQLStorage implements Storage { + + public abstract Connection getConnection(); + public abstract boolean isAutoCloseConnections(); + + private void useConnection(CheckedConsumer connectionConsumer) throws StorageException { + useConnection(connection -> { + connectionConsumer.accept(connection); + return null; + }); + } + + private T useConnection(CheckedFunction connectionFunction) throws StorageException { + try { + if (isAutoCloseConnections()) { + try (Connection connection = getConnection()) { + return connectionFunction.apply(connection); + } + } else { + return connectionFunction.apply(getConnection()); + } + } catch (Throwable e) { + throw new StorageException(e); + } + } + + private void exceptEffectedRows(int rows, int expect) { + if (rows != expect) { + throw new StorageException("Excepted to effect " + expect + " rows, actually effected " + rows); + } + } + + @Override + public void initialize() { + useConnection(connection -> { + try (Statement statement = connection.createStatement()) { + statement.execute("create table if not exists LINKED_ACCOUNTS (ID int not null auto_increment, PLAYER_UUID uuid, USER_ID bigint)"); + } + }); + } + + @Override + public @Nullable Long getUserId(@NotNull UUID player) { + return useConnection(connection -> { + try (PreparedStatement statement = connection.prepareStatement("select USER_ID from LINKED_ACCOUNTS where PLAYER_UUID = ?;")) { + statement.setObject(1, player); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getLong("USER_ID"); + } + } + } + return null; + }); + } + + @Override + public @Nullable UUID getPlayerUUID(long userId) { + return useConnection(connection -> { + try (PreparedStatement statement = connection.prepareStatement("select PLAYER_UUID from LINKED_ACCOUNTS where USER_ID = ?;")) { + statement.setLong(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getObject("PLAYER_UUID", UUID.class); + } + } + } + return null; + }); + } + + @Override + public void link(@NotNull UUID player, long userId) { + useConnection(connection -> { + try (PreparedStatement statement = connection.prepareStatement("insert into LINKED_ACCOUNTS (PLAYER_UUID, USER_ID) values (?, ?);")) { + statement.setObject(1, player); + statement.setLong(2, userId); + + exceptEffectedRows(statement.executeUpdate(), 1); + } + }); + } + + @Override + public int getLinkedAccountCount() { + return useConnection(connection -> { + try (Statement statement = connection.createStatement()) { + try (ResultSet resultSet = statement.executeQuery("select count(*) from LINKED_ACCOUNTS;")) { + if (resultSet.next()) { + return resultSet.getInt(1); + } + } + } + return 0; + }); + } +} diff --git a/common/src/main/java/com/discordsrv/common/storage/impl/sql/file/H2Storage.java b/common/src/main/java/com/discordsrv/common/storage/impl/sql/file/H2Storage.java new file mode 100644 index 00000000..e534ef6c --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/storage/impl/sql/file/H2Storage.java @@ -0,0 +1,83 @@ +package com.discordsrv.common.storage.impl.sql.file; + +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.config.connection.StorageConfig; +import com.discordsrv.common.dependency.DependencyLoader; +import com.discordsrv.common.exception.StorageException; +import com.discordsrv.common.storage.impl.sql.SQLStorage; +import dev.vankka.dependencydownload.classloader.IsolatedClassLoader; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Properties; + +public class H2Storage extends SQLStorage { + + private final DiscordSRV discordSRV; + private IsolatedClassLoader classLoader; + private Connection connection; + + public H2Storage(DiscordSRV discordSRV) { + this.discordSRV = discordSRV; + } + + @Override + public void initialize() { + try { + classLoader = DependencyLoader.h2(discordSRV).loadIntoIsolated(); + } catch (IOException e) { + throw new StorageException(e); + } + + StorageConfig storageConfig = discordSRV.connectionConfig().storage; + + try { + Class clazz = classLoader.loadClass("org.h2.jdbc.JdbcConnection"); + Constructor constructor = clazz.getConstructor( + String.class, // url + Properties.class, // info + String.class, // username + Object.class, // password + Boolean.class // forbidCreation + ); + connection = (Connection) constructor.newInstance( + "jdbc:h2:" + discordSRV.dataDirectory().resolve("h2-database").toAbsolutePath(), + storageConfig.getDriverProperties(), + null, + null, + false + ); + } catch (ReflectiveOperationException e) { + throw new StorageException(e); + } + super.initialize(); + } + + @Override + public void close() { + if (connection != null) { + try { + connection.close(); + } catch (SQLException ignored) {} + } + if (classLoader != null) { + try { + classLoader.close(); + } catch (IOException e) { + discordSRV.logger().error("Failed to close isolated classloader", e); + } + } + } + + @Override + public synchronized Connection getConnection() { + return connection; + } + + @Override + public boolean isAutoCloseConnections() { + return false; + } +} diff --git a/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/HikariStorage.java b/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/HikariStorage.java new file mode 100644 index 00000000..6fc88cfe --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/HikariStorage.java @@ -0,0 +1,80 @@ +package com.discordsrv.common.storage.impl.sql.hikari; + +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.config.connection.StorageConfig; +import com.discordsrv.common.exception.StorageException; +import com.discordsrv.common.storage.impl.sql.SQLStorage; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.sql.Connection; +import java.sql.SQLException; + +public abstract class HikariStorage extends SQLStorage { + + protected final DiscordSRV discordSRV; + private HikariDataSource hikariDataSource; + + public HikariStorage(DiscordSRV discordSRV) { + this.discordSRV = discordSRV; + } + + protected abstract void applyConfiguration(HikariConfig config, StorageConfig storageConfig); + + protected T initializeWithContext(T classLoader) { + Thread currentThread = Thread.currentThread(); + ClassLoader originalContext = currentThread.getContextClassLoader(); + try { + currentThread.setContextClassLoader(classLoader); + initializeInternal(); + } finally { + currentThread.setContextClassLoader(originalContext); + } + return classLoader; + } + + private void initializeInternal() { + StorageConfig storageConfig = discordSRV.connectionConfig().storage; + StorageConfig.Remote remoteConfig = storageConfig.remote; + StorageConfig.Pool poolConfig = remoteConfig.poolOptions; + + HikariConfig config = new HikariConfig(); + config.setPoolName("discordsrv-pool"); + config.setUsername(remoteConfig.username); + config.setPassword(remoteConfig.password); + config.setMinimumIdle(poolConfig.minimumPoolSize); + config.setMaximumPoolSize(poolConfig.maximumPoolSize); + config.setMaxLifetime(poolConfig.maximumLifetime); + config.setKeepaliveTime(poolConfig.keepaliveTime); + applyConfiguration(config, storageConfig); + + hikariDataSource = new HikariDataSource(config); + super.initialize(); + } + + @Override + public void initialize() { + initializeInternal(); + } + + @Override + public void close() { + if (hikariDataSource != null) { + hikariDataSource.close(); + } + } + + @Override + public Connection getConnection() { + try { + return hikariDataSource.getConnection(); + } catch (SQLException e) { + throw new StorageException(e); + } + } + + @Override + public boolean isAutoCloseConnections() { + return true; + } +} diff --git a/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/MySQLStorage.java b/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/MySQLStorage.java new file mode 100644 index 00000000..5c9d6826 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/storage/impl/sql/hikari/MySQLStorage.java @@ -0,0 +1,65 @@ +package com.discordsrv.common.storage.impl.sql.hikari; + +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.config.connection.StorageConfig; +import com.discordsrv.common.dependency.DependencyLoader; +import com.discordsrv.common.exception.StorageException; +import com.zaxxer.hikari.HikariConfig; +import dev.vankka.dependencydownload.classloader.IsolatedClassLoader; + +import java.io.IOException; +import java.util.Map; + +public class MySQLStorage extends HikariStorage { + + private IsolatedClassLoader classLoader; + + public MySQLStorage(DiscordSRV discordSRV) { + super(discordSRV); + } + + @Override + public void close() { + super.close(); + if (classLoader != null) { + try { + classLoader.close(); + } catch (IOException e) { + discordSRV.logger().error("Failed to close isolated classloader", e); + } + } + } + + @Override + public void initialize() { + try { + classLoader = initializeWithContext(DependencyLoader.mysql(discordSRV).loadIntoIsolated()); + } catch (IOException e) { + throw new StorageException(e); + } + } + + @Override + protected void applyConfiguration(HikariConfig config, StorageConfig storageConfig) { + String address = storageConfig.remote.databaseAddress; + if (!address.contains(":")) { + address += ":3306"; + } + + config.setDriverClassName("com.mysql.cj.jdbc.Driver"); + config.setJdbcUrl("jdbc:mysql://" + address + "/" + storageConfig.remote.databaseName); + for (Map.Entry entry : storageConfig.getDriverProperties().entrySet()) { + config.addDataSourceProperty((String) entry.getKey(), entry.getValue()); + } + + // https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration + config.addDataSourceProperty("prepStmtCacheSize", 250); + config.addDataSourceProperty("prepStmtCacheSqlLimit", 2048); + config.addDataSourceProperty("cachePrepStmts", true); + config.addDataSourceProperty("useServerPrepStmts", true); + config.addDataSourceProperty("cacheServerConfiguration", true); + config.addDataSourceProperty("useLocalSessionState", true); + config.addDataSourceProperty("rewriteBatchedStatements", true); + config.addDataSourceProperty("maintainTimeStats", false); + } +}