Improved readme, added JNA for windows terminal support.

This commit is contained in:
Andrew Lalis 2021-08-27 10:18:16 +02:00
parent 1a923e0ff8
commit 009c3a7c21
8 changed files with 228 additions and 76 deletions

View File

@ -1,11 +1,45 @@
# Concord # Concord
Console-based real-time messaging platform, inspired by Discord. Console-based real-time messaging platform, inspired by Discord.
This platform is organized by many independent servers, each of which supports the following: ## Vision
This platform will be organized by many independent servers, each of which will support the following:
- Multiple message channels. By default, there's one `general` channel. - Multiple message channels. By default, there's one `general` channel.
- Broadcasting itself on certain discovery servers for users to find. The server decides where it wants to be discovered, if at all. - Broadcasting itself on certain discovery servers for users to find. The server decides where it wants to be discovered, if at all.
- Starting threads as spin-offs of a source message (with infinite recursion, i.e. threads within threads). - Starting threads as spin-offs of a source message (with infinite recursion, i.e. threads within threads).
- Private message between users in a server. **No support for private messaging users outside the context of a server.** - Private message between users in a server. **No support for private messaging users outside the context of a server.**
- Banning users from the server. - Banning users from the server.
# Concord Client
To use the client, simply download the latest `concord-client.jar` JAR file from [the releases page](https://github.com/andrewlalis/Concord/releases) and run it with Java (version 16 or higher) from anywhere on your machine.
Once you've started it, press **Enter** to click the button "Connect to Server". You will be prompted for the server IP address, and then a nickname for yourself, before you join the server. To disconnect, press **CTRL+C** at any time.
# Concord Server
To start up your own server, download the latest `concord-server.jar` JAR file from [the releases page](https://github.com/andrewlalis/Concord/releases) and run it with Java (version 16 or higher). The first time you run the server with `java -jar concord-server.jar`, it will generate a `server-config.json` configuration file, and a `concord-server.db` database file.
## Configuring the Server
You probably want to customize your server a bit. To do so, first stop your server by typing `stop` in the console where you started the server initially. Now you can edit `server-config.json` and restart the server once you're done. A description of the attributes is given below:
- `name` The name of the server.
- `description` A short description of what this server is for, or who it's run by.
- `port` The port on which the server accepts client connections.
- `chatHistoryMaxCount` The maximum amount of chat messages that a client can request from the server at any given time. Decrease this to improve performance.
- `chatHistoryDefaultCount` The default number of chat messages that are provided to clients when they join a channel, if they don't explicitly request a certain amount. Decrease this to improve performance.
- `maxMessageLength` The maximum length of a message. Messages longer than this will be rejected.
- `channels` Contains a list of all channels that the server uses. Each channel has an `id`, `name`, and `description`. **It is advised that you do not add or remove channels manually!** Instead, use the `add-channel` and `remove-channel` CLI commands that are available while the server is running.
- `discoveryServers` A list of URLs to which this server should send its metadata for publishing. Keep this empty if you don't want your server to be publicly visible.
## Server CLI
As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show which commands are available via the `help` command. The following is a list of some of the most useful commands and a description of their functionality:
- `add-channel <name>` Adds a new channel to the server with the given name. Channel names cannot be blank, and they cannot be duplicates of an existing channel name.
- `remove-channel <name>` Removes a channel.
- `list-clients` Shows a list of all connected clients.
- `stop` Stops the server, disconnecting all clients.
Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information. Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.

View File

@ -23,6 +23,16 @@
<artifactId>lanterna</artifactId> <artifactId>lanterna</artifactId>
<version>3.2.0-alpha1</version> <version>3.2.0-alpha1</version>
</dependency> </dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.9.0</version>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.9.0</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -1,5 +1,7 @@
module concord_client { module concord_client {
requires concord_core; requires concord_core;
requires com.googlecode.lanterna; requires com.googlecode.lanterna;
requires com.sun.jna;
requires com.sun.jna.platform;
requires static lombok; requires static lombok;
} }

View File

@ -17,6 +17,15 @@ public class ChannelManager {
this.server = server; this.server = server;
this.channelNameMap = new ConcurrentHashMap<>(); this.channelNameMap = new ConcurrentHashMap<>();
this.channelIdMap = new ConcurrentHashMap<>(); this.channelIdMap = new ConcurrentHashMap<>();
// Initialize the channels according to what's defined in the server's config.
for (var channelConfig : server.getConfig().getChannels()) {
this.addChannel(new Channel(
server,
UUID.fromString(channelConfig.getId()),
channelConfig.getName(),
server.getDb().getCollection("channel-" + channelConfig.getId())
));
}
} }
public Set<Channel> getChannels() { public Set<Channel> getChannels() {

View File

@ -1,8 +1,5 @@
package nl.andrewl.concord_server; package nl.andrewl.concord_server;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.Getter; import lombok.Getter;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
@ -16,15 +13,9 @@ import org.dizitart.no2.Nitrite;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -36,45 +27,62 @@ import java.util.stream.Collectors;
* The main server implementation, which handles accepting new clients. * The main server implementation, which handles accepting new clients.
*/ */
public class ConcordServer implements Runnable { public class ConcordServer implements Runnable {
private static final Path CONFIG_FILE = Path.of("server-config.json");
private static final Path DATABASE_FILE = Path.of("concord-server.db");
private final Map<UUID, ClientThread> clients; private final Map<UUID, ClientThread> clients;
private volatile boolean running; private volatile boolean running;
private final ServerSocket serverSocket;
/**
* Server configuration data. This is used to define channels, discovery
* server addresses, and more.
*/
@Getter @Getter
private final ServerConfig config; private final ServerConfig config;
/**
* The component that generates new user and channel ids.
*/
@Getter @Getter
private final IdProvider idProvider; private final IdProvider idProvider;
/**
* The database that contains all messages and other server information.
*/
@Getter @Getter
private final Nitrite db; private final Nitrite db;
/**
* A general-purpose executor service that can be used to submit async tasks.
*/
@Getter @Getter
private final ExecutorService executorService; private final ExecutorService executorService = Executors.newCachedThreadPool();
/**
* Manager that handles incoming messages and events by clients.
*/
@Getter @Getter
private final EventManager eventManager; private final EventManager eventManager;
/**
* Manager that handles the collection of channels in this server.
*/
@Getter @Getter
private final ChannelManager channelManager; private final ChannelManager channelManager;
// Components for communicating with discovery servers. private final DiscoveryServerPublisher discoveryServerPublisher;
private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
public ConcordServer() { public ConcordServer() throws IOException {
this.idProvider = new UUIDProvider(); this.idProvider = new UUIDProvider();
this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); this.config = ServerConfig.loadOrCreate(CONFIG_FILE, idProvider);
this.db = Nitrite.builder() this.discoveryServerPublisher = new DiscoveryServerPublisher(this.config);
.filePath("concord-server.db") this.db = Nitrite.builder().filePath(DATABASE_FILE.toFile()).openOrCreate();
.openOrCreate();
this.clients = new ConcurrentHashMap<>(32); this.clients = new ConcurrentHashMap<>(32);
this.executorService = Executors.newCachedThreadPool();
this.eventManager = new EventManager(this); this.eventManager = new EventManager(this);
this.channelManager = new ChannelManager(this); this.channelManager = new ChannelManager(this);
for (var channelConfig : config.getChannels()) { this.serverSocket = new ServerSocket(this.config.getPort());
this.channelManager.addChannel(new Channel(
this,
UUID.fromString(channelConfig.getId()),
channelConfig.getName(),
this.db.getCollection("channel-" + channelConfig.getId())
));
}
} }
/** /**
@ -115,10 +123,29 @@ public class ConcordServer implements Runnable {
} }
} }
/**
* @return True if the server is currently running, meaning it is accepting
* connections, or false otherwise.
*/
public boolean isRunning() { public boolean isRunning() {
return running; return running;
} }
/**
* Stops the server. Has no effect if the server has not started yet or has
* already been stopped.
*/
public void stop() {
this.running = false;
if (!this.serverSocket.isClosed()) {
try {
this.serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public List<UserData> getClients() { public List<UserData> getClients() {
return this.clients.values().stream() return this.clients.values().stream()
.sorted(Comparator.comparing(ClientThread::getClientNickname)) .sorted(Comparator.comparing(ClientThread::getClientNickname))
@ -153,64 +180,54 @@ public class ConcordServer implements Runnable {
} }
} }
private void publishMetaDataToDiscoveryServers() { /**
if (this.config.getDiscoveryServers().isEmpty()) return; * Shuts down the server cleanly by doing the following things:
ObjectNode node = this.mapper.createObjectNode(); * <ol>
node.put("name", this.config.getName()); * <li>Disconnecting all clients.</li>
node.put("description", this.config.getDescription()); * <li>Shutting down any executor services.</li>
node.put("port", this.config.getPort()); * <li>Flushing and compacting the message database.</li>
String json; * <li>Flushing the server configuration one last time.</li>
try { * </ol>
json = this.mapper.writeValueAsString(node); */
} catch (JsonProcessingException e) { private void shutdown() {
throw new UncheckedIOException(e); System.out.println("Shutting down the server.");
for (var clientId : this.clients.keySet()) {
this.deregisterClient(clientId);
} }
var discoveryServers = List.copyOf(this.config.getDiscoveryServers()); this.scheduledExecutorService.shutdown();
for (var discoveryServer : discoveryServers) { this.executorService.shutdown();
System.out.println("Publishing this server's metadata to discovery server at " + discoveryServer); this.db.close();
var request = HttpRequest.newBuilder(URI.create(discoveryServer))
.POST(HttpRequest.BodyPublishers.ofString(json))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(3))
.build();
try { try {
this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()); this.config.save();
} catch (IOException | InterruptedException e) { } catch (IOException e) {
e.printStackTrace(); System.err.println("Could not save configuration on shutdown: " + e.getMessage());
}
} }
} }
@Override @Override
public void run() { public void run() {
this.running = true; this.running = true;
this.scheduledExecutorService.scheduleAtFixedRate(this::publishMetaDataToDiscoveryServers, 0, 1, TimeUnit.MINUTES); this.scheduledExecutorService.scheduleAtFixedRate(this.discoveryServerPublisher::publish, 0, 1, TimeUnit.MINUTES);
ServerSocket serverSocket;
try {
serverSocket = new ServerSocket(this.config.getPort());
StringBuilder startupMessage = new StringBuilder(); StringBuilder startupMessage = new StringBuilder();
startupMessage.append("Opened server on port ").append(config.getPort()).append("\n"); startupMessage.append("Opened server on port ").append(config.getPort()).append("\n")
.append("The following channels are available:\n");
for (var channel : this.channelManager.getChannels()) { for (var channel : this.channelManager.getChannels()) {
startupMessage.append("\tChannel \"").append(channel).append('\n'); startupMessage.append("\tChannel \"").append(channel).append('\n');
} }
System.out.println(startupMessage); System.out.println(startupMessage);
while (this.running) { while (this.running) {
try { try {
Socket socket = serverSocket.accept(); Socket socket = this.serverSocket.accept();
ClientThread clientThread = new ClientThread(socket, this); ClientThread clientThread = new ClientThread(socket, this);
clientThread.start(); clientThread.start();
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); System.err.println("Could not accept new client connection: " + e.getMessage());
} }
} }
serverSocket.close(); this.shutdown();
} catch (IOException e) {
System.err.println("Could not open server socket: " + e.getMessage());
}
this.scheduledExecutorService.shutdown();
} }
public static void main(String[] args) { public static void main(String[] args) throws IOException {
var server = new ConcordServer(); var server = new ConcordServer();
new Thread(server).start(); new Thread(server).start();
new ServerCli(server).run(); new ServerCli(server).run();

View File

@ -0,0 +1,56 @@
package nl.andrewl.concord_server;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import nl.andrewl.concord_server.config.ServerConfig;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
/**
* This component is responsible for publishing the server's metadata to any
* discovery servers that have been defined in the server's configuration file.
*/
public class DiscoveryServerPublisher {
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ServerConfig config;
public DiscoveryServerPublisher(ServerConfig config) {
this.config = config;
}
public void publish() {
if (this.config.getDiscoveryServers().isEmpty()) return;
ObjectNode node = this.mapper.createObjectNode();
node.put("name", this.config.getName());
node.put("description", this.config.getDescription());
node.put("port", this.config.getPort());
String json;
try {
json = this.mapper.writeValueAsString(node);
} catch (JsonProcessingException e) {
throw new UncheckedIOException(e);
}
var discoveryServers = List.copyOf(this.config.getDiscoveryServers());
for (var discoveryServer : discoveryServers) {
var request = HttpRequest.newBuilder(URI.create(discoveryServer))
.POST(HttpRequest.BodyPublishers.ofString(json))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(3))
.build();
try {
this.httpClient.send(request, HttpResponse.BodyHandlers.discarding());
} catch (IOException | InterruptedException e) {
System.err.println("Could not publish metadata to " + discoveryServer + " because of exception: " + e.getClass().getSimpleName());
}
}
}
}

View File

@ -4,6 +4,7 @@ import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.command.AddChannelCommand; import nl.andrewl.concord_server.cli.command.AddChannelCommand;
import nl.andrewl.concord_server.cli.command.ListClientsCommand; import nl.andrewl.concord_server.cli.command.ListClientsCommand;
import nl.andrewl.concord_server.cli.command.RemoveChannelCommand; import nl.andrewl.concord_server.cli.command.RemoveChannelCommand;
import nl.andrewl.concord_server.cli.command.StopCommand;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@ -22,10 +23,19 @@ public class ServerCli implements Runnable {
this.commands.put("list-clients", new ListClientsCommand()); this.commands.put("list-clients", new ListClientsCommand());
this.commands.put("add-channel", new AddChannelCommand()); this.commands.put("add-channel", new AddChannelCommand());
this.commands.put("remove-channel", new RemoveChannelCommand()); this.commands.put("remove-channel", new RemoveChannelCommand());
this.commands.put("stop", new StopCommand());
this.commands.put("help", (s, args) -> {
System.out.println("The following commands are available:");
for (var key : commands.keySet().stream().sorted().toList()) {
System.out.println("\t" + key);
}
});
} }
@Override @Override
public void run() { public void run() {
System.out.println("Server command-line-interface initialized. Type \"help\" for a list of available commands.");
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String line; String line;
try { try {

View File

@ -0,0 +1,14 @@
package nl.andrewl.concord_server.cli.command;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.ServerCliCommand;
/**
* This command forcibly stops the server, disconnecting any clients.
*/
public class StopCommand implements ServerCliCommand {
@Override
public void handle(ConcordServer server, String[] args) throws Exception {
server.stop();
}
}