From 5ad793e06908382ede889e90d9ae56116aeed6c4 Mon Sep 17 00:00:00 2001 From: "solver-app[bot]" <152345546+solver-app[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:54:13 +0000 Subject: [PATCH] Add netty websocket game server with web client --- README.md | 54 ++++++- pom.xml | 54 +++++++ src/main/java/com/gameserver/GameMessage.java | 33 ++++ src/main/java/com/gameserver/GameServer.java | 85 +++++++++++ .../com/gameserver/GameWebSocketHandler.java | 52 +++++++ .../com/gameserver/HttpStaticFileHandler.java | 143 ++++++++++++++++++ src/main/resources/logback.xml | 18 +++ src/main/resources/static/index.html | 84 ++++++++++ 8 files changed, 521 insertions(+), 2 deletions(-) create mode 100644 pom.xml create mode 100644 src/main/java/com/gameserver/GameMessage.java create mode 100644 src/main/java/com/gameserver/GameServer.java create mode 100644 src/main/java/com/gameserver/GameWebSocketHandler.java create mode 100644 src/main/java/com/gameserver/HttpStaticFileHandler.java create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/static/index.html diff --git a/README.md b/README.md index 078c4a2..9619ce4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b5093f9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.gameserver + netty-game-server + 1.0-SNAPSHOT + + + 11 + 11 + 4.1.94.Final + UTF-8 + + + + + io.netty + netty-all + ${netty.version} + + + org.slf4j + slf4j-api + 2.0.7 + + + ch.qos.logback + logback-classic + 1.4.8 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + diff --git a/src/main/java/com/gameserver/GameMessage.java b/src/main/java/com/gameserver/GameMessage.java new file mode 100644 index 0000000..3794123 --- /dev/null +++ b/src/main/java/com/gameserver/GameMessage.java @@ -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 + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gameserver/GameServer.java b/src/main/java/com/gameserver/GameServer.java new file mode 100644 index 0000000..321c567 --- /dev/null +++ b/src/main/java/com/gameserver/GameServer.java @@ -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() { + @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); + } + } +} diff --git a/src/main/java/com/gameserver/GameWebSocketHandler.java b/src/main/java/com/gameserver/GameWebSocketHandler.java new file mode 100644 index 0000000..a5a48cb --- /dev/null +++ b/src/main/java/com/gameserver/GameWebSocketHandler.java @@ -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 { + 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(); + } +} diff --git a/src/main/java/com/gameserver/HttpStaticFileHandler.java b/src/main/java/com/gameserver/HttpStaticFileHandler.java new file mode 100644 index 0000000..1fcdfb6 --- /dev/null +++ b/src/main/java/com/gameserver/HttpStaticFileHandler.java @@ -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 { + 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"); + } + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..183f999 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..e8357ac --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,84 @@ + + + + Game Server Test Client + + + +

Game Server Test Client

+
+
+ + + + + +
+ + + +