Add netty websocket game server with web client

This commit is contained in:
solver-app[bot]
2025-02-05 13:54:13 +00:00
committed by GitHub
parent b829ebc717
commit 5ad793e069
8 changed files with 521 additions and 2 deletions
+52 -2
View File
@@ -1,2 +1,52 @@
# test-solver
Test Solver
# Netty WebSocket Game Server
A simple game server implementation using Netty 4 with WebSocket support.
## Features
- WebSocket-based game server using Netty 4
- JSON message protocol for client-server communication
- Built-in HTTP server for serving the web client
- Simple web-based test client
## Prerequisites
- Java 11 or higher
- Maven 3.6 or higher
## Building the Project
```bash
mvn clean package
```
## Running the Server
```bash
java -cp target/netty-game-server-1.0-SNAPSHOT.jar com.gameserver.GameServer
```
By default, the server runs on port 8080. You can specify a different port as a command-line argument:
```bash
java -cp target/netty-game-server-1.0-SNAPSHOT.jar com.gameserver.GameServer 9000
```
## Testing the Server
1. Start the server using the instructions above
2. Open a web browser and navigate to `http://localhost:8080`
3. Use the web interface to send test messages to the server
## Message Protocol
The server uses a simple JSON-based message protocol:
```json
{
"type": "MESSAGE_TYPE",
"payload": "Message content"
}
```
### Supported Message Types
- `PING`: Basic ping message
- Any other type will be echoed back with a `SERVER_RESPONSE` type
## Project Structure
- `GameServer.java`: Main server class that initializes Netty
- `GameWebSocketHandler.java`: Handles WebSocket frame processing
- `GameMessage.java`: Message model for client-server communication
- `HttpStaticFileHandler.java`: Serves static web content
- `index.html`: Web-based test client
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gameserver</groupId>
<artifactId>netty-game-server</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<netty.version>4.1.94.Final</netty.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.7</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.8</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,33 @@
package com.gameserver;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class GameMessage {
private final String type;
private final String payload;
@JsonCreator
public GameMessage(
@JsonProperty("type") String type,
@JsonProperty("payload") String payload) {
this.type = type;
this.payload = payload;
}
public String getType() {
return type;
}
public String getPayload() {
return payload;
}
@Override
public String toString() {
return "GameMessage{" +
"type='" + type + '\'' +
", payload='" + payload + '\'' +
'}';
}
}
@@ -0,0 +1,85 @@
package com.gameserver;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GameServer {
private static final Logger logger = LoggerFactory.getLogger(GameServer.class);
private final int port;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
public GameServer(int port) {
this.port = port;
}
public void start() throws Exception {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// HTTP codec for WebSocket handshake and static files
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
// Static file handler for serving the web client
pipeline.addLast(new HttpStaticFileHandler("src/main/resources/static"));
// WebSocket protocol handler
pipeline.addLast(new WebSocketServerProtocolHandler("/game", null, true));
// Game handler for WebSocket frames
pipeline.addLast(new GameWebSocketHandler());
}
});
Channel channel = bootstrap.bind(port).sync().channel();
logger.info("Game server started on port {}", port);
channel.closeFuture().sync();
} finally {
shutdown();
}
}
public void shutdown() {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
logger.info("Game server shutdown complete");
}
public static void main(String[] args) {
int port = 8080;
if (args.length > 0) {
port = Integer.parseInt(args[0]);
}
GameServer server = new GameServer(port);
try {
server.start();
} catch (Exception e) {
logger.error("Failed to start game server", e);
}
}
}
@@ -0,0 +1,52 @@
package com.gameserver;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GameWebSocketHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
private static final Logger logger = LoggerFactory.getLogger(GameWebSocketHandler.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
if (frame instanceof TextWebSocketFrame) {
String text = ((TextWebSocketFrame) frame).text();
try {
GameMessage message = objectMapper.readValue(text, GameMessage.class);
logger.info("Received message: {}", message);
// Create response message
GameMessage response = new GameMessage("SERVER_RESPONSE", "Received: " + message.getType());
String responseJson = objectMapper.writeValueAsString(response);
ctx.channel().writeAndFlush(new TextWebSocketFrame(responseJson));
} catch (Exception e) {
logger.error("Error processing message: {}", e.getMessage());
ctx.channel().writeAndFlush(new TextWebSocketFrame("Error processing message"));
}
} else {
String message = "Unsupported frame type: " + frame.getClass().getName();
throw new UnsupportedOperationException(message);
}
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
logger.info("Client connected: {}", ctx.channel().remoteAddress());
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
logger.info("Client disconnected: {}", ctx.channel().remoteAddress());
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
logger.error("Channel exception: {}", cause.getMessage());
ctx.close();
}
}
@@ -0,0 +1,143 @@
package com.gameserver;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.handler.stream.ChunkedFile;
import io.netty.handler.ssl.SslHandler;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import static io.netty.handler.codec.http.HttpHeaderNames.*;
import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
import static io.netty.handler.codec.http.HttpResponseStatus.*;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
public class HttpStaticFileHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String staticPath;
public HttpStaticFileHandler(String staticPath) {
this.staticPath = staticPath;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
if (!request.decoderResult().isSuccess()) {
sendError(ctx, BAD_REQUEST);
return;
}
if (!HttpMethod.GET.equals(request.method())) {
sendError(ctx, METHOD_NOT_ALLOWED);
return;
}
final String uri = request.uri();
final String path = sanitizeUri(uri);
if (path == null) {
sendError(ctx, FORBIDDEN);
return;
}
File file = new File(staticPath + path);
if (!file.exists()) {
sendError(ctx, NOT_FOUND);
return;
}
if (file.isDirectory()) {
if (uri.endsWith("/")) {
file = new File(file, "index.html");
} else {
sendRedirect(ctx, uri + '/');
return;
}
}
if (!file.isFile()) {
sendError(ctx, FORBIDDEN);
return;
}
try {
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
HttpUtil.setContentLength(response, fileLength);
setContentTypeHeader(response, file);
if (HttpUtil.isKeepAlive(request)) {
response.headers().set(CONNECTION, KEEP_ALIVE);
}
ctx.write(response);
ChannelFuture sendFileFuture = ctx.write(new ChunkedFile(raf, 0, fileLength, 8192),
ctx.newProgressivePromise());
ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
if (!HttpUtil.isKeepAlive(request)) {
lastContentFuture.addListener(ChannelFutureListener.CLOSE);
}
} catch (FileNotFoundException fnfe) {
sendError(ctx, NOT_FOUND);
}
}
private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, status);
response.headers().set(CONTENT_TYPE, "text/plain; charset=UTF-8");
response.content().writeBytes(status.toString().getBytes(StandardCharsets.UTF_8));
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendRedirect(ChannelHandlerContext ctx, String newUri) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND);
response.headers().set(LOCATION, newUri);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static String sanitizeUri(String uri) {
try {
uri = URLDecoder.decode(uri, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
try {
uri = URLDecoder.decode(uri, StandardCharsets.ISO_8859_1.name());
} catch (UnsupportedEncodingException ex) {
throw new Error(ex);
}
}
if (!uri.startsWith("/")) {
return null;
}
uri = uri.replace('/', File.separatorChar);
if (uri.contains(File.separator + '.') ||
uri.contains('.' + File.separator) ||
uri.startsWith(".") || uri.endsWith(".") ||
uri.contains("..")) {
return null;
}
return uri;
}
private static void setContentTypeHeader(HttpResponse response, File file) {
String name = file.getName().toLowerCase();
if (name.endsWith(".html") || name.endsWith(".htm")) {
response.headers().set(CONTENT_TYPE, "text/html; charset=UTF-8");
} else if (name.endsWith(".css")) {
response.headers().set(CONTENT_TYPE, "text/css; charset=UTF-8");
} else if (name.endsWith(".js")) {
response.headers().set(CONTENT_TYPE, "application/javascript; charset=UTF-8");
} else if (name.endsWith(".json")) {
response.headers().set(CONTENT_TYPE, "application/json; charset=UTF-8");
} else {
response.headers().set(CONTENT_TYPE, "application/octet-stream");
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Set logging level for our game server -->
<logger name="com.gameserver" level="INFO"/>
<!-- Set Netty logging to WARN to reduce noise -->
<logger name="io.netty" level="WARN"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html>
<head>
<title>Game Server Test Client</title>
<style>
#messages {
height: 300px;
overflow-y: scroll;
border: 1px solid #ccc;
margin-bottom: 10px;
padding: 10px;
}
.sent { color: blue; }
.received { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h2>Game Server Test Client</h2>
<div id="messages"></div>
<div>
<label for="messageType">Message Type:</label>
<input type="text" id="messageType" value="PING" />
<label for="messagePayload">Payload:</label>
<input type="text" id="messagePayload" value="Hello server!" />
<button onclick="sendMessage()">Send</button>
</div>
<script>
let ws;
const messagesDiv = document.getElementById('messages');
function connect() {
ws = new WebSocket('ws://localhost:8080/game');
ws.onopen = function() {
appendMessage('Connected to server', 'received');
};
ws.onmessage = function(event) {
appendMessage('Received: ' + event.data, 'received');
};
ws.onclose = function() {
appendMessage('Disconnected from server', 'error');
// Try to reconnect in 5 seconds
setTimeout(connect, 5000);
};
ws.onerror = function(error) {
appendMessage('Error: ' + error.message, 'error');
};
}
function sendMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
appendMessage('Not connected to server', 'error');
return;
}
const type = document.getElementById('messageType').value;
const payload = document.getElementById('messagePayload').value;
const message = {
type: type,
payload: payload
};
ws.send(JSON.stringify(message));
appendMessage('Sent: ' + JSON.stringify(message), 'sent');
}
function appendMessage(message, className) {
const div = document.createElement('div');
div.className = className;
div.textContent = message;
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// Connect when page loads
connect();
</script>
</body>
</html>