9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-30 12:19:20 +00:00

Start 2.0 rewrite

Use redis key caching, remove need for proxy plugin
Make platform independent to allow porting to other platforms
This commit is contained in:
William
2022-07-02 00:17:51 +01:00
parent 633847a254
commit 9471e0cbff
91 changed files with 2117 additions and 6639 deletions

View File

@@ -0,0 +1,156 @@
package net.william278.husksync.database;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* An abstract representation of the plugin database, storing player data.
* <p>
* Implemented by different database platforms - MySQL, SQLite, etc. - as configured by the administrator.
*/
public abstract class Database {
/**
* Name of the table that stores player information
*/
protected final String playerTableName;
/**
* Name of the table that stores data
*/
protected final String dataTableName;
/**
* The maximum number of user records to store in the database at once per user
*/
protected final int maxUserDataRecords;
/**
* Logger instance used for database error logging
*/
private final Logger logger;
/**
* Returns the {@link Logger} used to log database errors
*
* @return the {@link Logger} instance
*/
protected Logger getLogger() {
return logger;
}
/**
* The {@link ResourceReader} used to read internal resource files by name
*/
private final ResourceReader resourceReader;
protected Database(@NotNull String playerTableName, @NotNull String dataTableName, final int maxUserDataRecords,
@NotNull ResourceReader resourceReader, @NotNull Logger logger) {
this.playerTableName = playerTableName;
this.dataTableName = dataTableName;
this.maxUserDataRecords = maxUserDataRecords;
this.resourceReader = resourceReader;
this.logger = logger;
}
/**
* Loads SQL table creation schema statements from a resource file as a string array
*
* @param schemaFileName database script resource file to load from
* @return Array of string-formatted table creation schema statements
* @throws IOException if the resource could not be read
*/
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
return formatStatementTables(
new String(resourceReader.getResource(schemaFileName)
.readAllBytes(), StandardCharsets.UTF_8))
.split(";");
}
/**
* Format all table name placeholder strings in a SQL statement
*
* @param sql the SQL statement with un-formatted table name placeholders
* @return the formatted statement, with table placeholders replaced with the correct names
*/
protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%players_table%", playerTableName)
.replaceAll("%data_table%", dataTableName);
}
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> initialize();
/**
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
*
* @param user The {@link User} to ensure
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> ensureUser(@NotNull User user);
/**
* Get a player by their Minecraft account {@link UUID}
*
* @param uuid Minecraft account {@link UUID} of the {@link User} to get
* @return A future returning an optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid);
/**
* Get a user by their username (<i>case-insensitive</i>)
*
* @param username Username of the {@link User} to get (<i>case-insensitive</i>)
* @return A future returning an optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
/**
* Get the current user data for a given user, if it exists.
*
* @param user the user to get data for
* @return an optional containing the user data, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user);
/**
* Get all UserData entries for a user from the database.
*
* @param user The user to get data for
* @return A future returning a list of a user's data
*/
public abstract CompletableFuture<List<UserData>> getUserData(@NotNull User user);
/**
* Prune user data records for a given user to the maximum value as configured
*
* @param user The user to prune data for
* @return A future returning void when complete
*/
protected abstract CompletableFuture<Void> pruneUserDataRecords(@NotNull User user);
/**
* Add user data to the database<p>
* This will remove the oldest data for the user if the amount of data exceeds the limit as configured
*
* @param user The user to add data for
* @param userData The data to add
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData);
}

View File

@@ -0,0 +1,289 @@
package net.william278.husksync.database;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
/**
* MySQL server hostname
*/
private final String mySqlHost;
/**
* MySQL server port
*/
private final int mySqlPort;
/**
* Database to use on the MySQL server
*/
private final String mySqlDatabaseName;
private final String mySqlUsername;
private final String mySqlPassword;
private final String mySqlConnectionParameters;
private final int hikariMaximumPoolSize;
private final int hikariMinimumIdle;
private final int hikariMaximumLifetime;
private final int hikariKeepAliveTime;
private final int hikariConnectionTimeOut;
private static final String DATA_POOL_NAME = "HuskHomesHikariPool";
private HikariDataSource dataSource;
public MySqlDatabase(@NotNull Settings settings, @NotNull ResourceReader resourceReader, @NotNull Logger logger) {
super(settings.getStringValue(Settings.ConfigOption.DATABASE_PLAYERS_TABLE_NAME),
settings.getStringValue(Settings.ConfigOption.DATABASE_DATA_TABLE_NAME),
settings.getIntegerValue(Settings.ConfigOption.SYNCHRONIZATION_MAX_USER_DATA_RECORDS),
resourceReader, logger);
mySqlHost = settings.getStringValue(Settings.ConfigOption.DATABASE_HOST);
mySqlPort = settings.getIntegerValue(Settings.ConfigOption.DATABASE_PORT);
mySqlDatabaseName = settings.getStringValue(Settings.ConfigOption.DATABASE_NAME);
mySqlUsername = settings.getStringValue(Settings.ConfigOption.DATABASE_USERNAME);
mySqlPassword = settings.getStringValue(Settings.ConfigOption.DATABASE_PASSWORD);
mySqlConnectionParameters = settings.getStringValue(Settings.ConfigOption.DATABASE_CONNECTION_PARAMS);
hikariMaximumPoolSize = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_SIZE);
hikariMinimumIdle = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MIN_IDLE);
hikariMaximumLifetime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_LIFETIME);
hikariKeepAliveTime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_KEEPALIVE);
hikariConnectionTimeOut = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_TIMEOUT);
}
/**
* Fetch the auto-closeable connection from the hikariDataSource
*
* @return The {@link Connection} to the MySQL database
* @throws SQLException if the connection fails for some reason
*/
private Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public CompletableFuture<Void> initialize() {
return CompletableFuture.runAsync(() -> {
// Create jdbc driver connection url
final String jdbcUrl = "jdbc:mysql://" + mySqlHost + ":" + mySqlPort + "/" + mySqlDatabaseName + mySqlConnectionParameters;
dataSource = new HikariDataSource();
dataSource.setJdbcUrl(jdbcUrl);
// Authenticate
dataSource.setUsername(mySqlUsername);
dataSource.setPassword(mySqlPassword);
// Set various additional parameters
dataSource.setMaximumPoolSize(hikariMaximumPoolSize);
dataSource.setMinimumIdle(hikariMinimumIdle);
dataSource.setMaxLifetime(hikariMaximumLifetime);
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(DATA_POOL_NAME);
// Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) {
// Load database schema CREATE statements from schema file
final String[] databaseSchema = getSchemaStatements("database/mysql_schema.sql");
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : databaseSchema) {
statement.execute(tableCreationStatement);
}
}
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "An error occurred creating tables on the MySQL database: ", e);
}
});
}
@Override
public CompletableFuture<Void> ensureUser(@NotNull User user) {
return CompletableFuture.runAsync(() -> getUser(user.uuid).thenAccept(optionalUser ->
optionalUser.ifPresentOrElse(existingUser -> {
if (!existingUser.username.equals(user.username)) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%players_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
statement.setString(1, user.username);
statement.setString(2, existingUser.uuid.toString());
statement.executeUpdate();
}
getLogger().log(Level.INFO, "Updated " + user.username + "'s name in the database (" + existingUser.username + " -> " + user.username + ")");
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%players_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, user.username);
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to insert a user into the database", e);
}
})));
}
@Override
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%players_table%`
WHERE `uuid`=?"""))) {
statement.setString(1, uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<Optional<User>> getUserByName(@NotNull String username) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%players_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UserData data = UserData.fromJson(resultSet.getString("data"));
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
return Optional.of(data);
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<List<UserData>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
final ArrayList<UserData> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UserData data = UserData.fromJson(resultSet.getString("data"));
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
retrievedData.add(data);
}
return retrievedData;
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
});
}
@Override
protected CompletableFuture<Void> pruneUserDataRecords(@NotNull User user) {
return CompletableFuture.runAsync(() -> getUserData(user).thenAccept(data -> {
if (data.size() > maxUserDataRecords) {
Collections.reverse(data);
data.subList(0, data.size() - maxUserDataRecords).forEach(dataToDelete -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%data_table%`
WHERE `version_uuid`=?"""))) {
statement.setString(1, dataToDelete.getDataUuidVersion().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to prune user data from the database", e);
}
});
}
}));
}
@Override
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`data`)
VALUES (?,?,?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, userData.getDataUuidVersion().toString());
statement.setTimestamp(3, Timestamp.from(Instant.ofEpochMilli(userData.getCreationTimestamp())));
statement.setString(4, userData.toJson());
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to set user data in the database", e);
}
}).thenRunAsync(() -> pruneUserDataRecords(user).join());
}
}