简介:一款基于Netty框架的Java QQ斗地主游戏设计源码,定位为Java网络编程与多线程并发实战项目,适合想深入理解Netty高性能通信机制的开发者。资源共199个文件,以126个Java源文件为主干,实现网络服务、游戏逻辑与并发控制;55张JPG和7张PNG图片用于绘制登录、大厅、出牌等界面;4个XML配置文件存储网络与游戏参数;4个TXT文档提供项目说明和开发指南,另附GIF演示、License及gitignore文件,压缩包仅5.45MB,轻量易下载。目前已有358人学习下载,可结合项目体会Netty在游戏中的应用。源码采用common、Server、Client模块化结构,清晰展现服务器与客户端的协作方式。通过阅读和调试,能掌握连接建立、消息编解码、多线程调度与异常处理等技能,还可参考登录窗口、房间窗口和发牌演示,快速提升实战水平,为后续开发更复杂的网络应用打下基础。
1. Netty版QQ斗地主:它不只是课程设计,是能跑通全流程的网络编程样本
我第一次在网上搜Netty项目练手时,被这套源码的文件名挂住了:199个文件、126个Java源文件,一眼扫过去像是普通Java课程设计。真把代码过完一遍才发现,值钱的不是Swing做的发牌界面,而是压在底下的Netty通信层——登录、大厅建房、抢地主、出牌这些动作,全靠客户端和服务端之间的Handler把消息拆开、分发、再广播。对正在准备Java网络编程和Netty面试的人来说,把这几条管线理清楚,比背二十道八股题有用得多;对正在做Java课程设计的人,它就是一个能直接改成论文题目的完整多人通信框架。
2. 先看199个文件怎么布局:common、server、client三个模块各管哪一段
2.1 从文件清单读模块设计:Maven父子工程怎么组织
拿到源码第一件事,别急着打开IDE跑,先看pom.xml。这套项目用Maven管理,排列了多个模块,典型的父子工程结构。父pom负责锁版本,子模块有三个:QQLandlords-common、QQLandlordsServer、QQLandlordsClient。common是两头都要引用的公共代码,server和client分别依赖它。
三个模块的职责边界,我画成一张表就清楚了:
| 模块 | 主要代码 | 职责 |
|---|---|---|
| QQLandlords-common | LandlordsUtil、协议常量、公共工具 | 消息封装/拆包、类型声明、通用配置、截图工具 |
| QQLandlordsServer | ServerMessageHandler、服务端启动类 | 网络接收、登录鉴权、大厅与房间状态管理、消息广播、游戏逻辑 |
| QQLandlordsClient | NettyClientHandler、LoginWidow、GameLobbyWindow、LandlordsRoomWindow | 建立连接、发送指令、切换界面、接收服务端事件并渲染 |
注意类名里有个小坑:登录窗口类是LoginWidow.java,不是LoginWindow.java,少一个字母“d”,我一开始对着文档找LoginWindow.java找了半天。这种手误在真实项目里很常见,习惯就好。
common模块最重要的东西是协议定义。因为Netty只负责把字节从一端搬到另一端,至于字节里装的是什么、用什么分隔,得自己定义。这套源码里走的是文本指令协议,消息大概长这样:
public class LandlordsUtil { // 消息分隔符:客户端与服务端约定按英文分号切分字段 public static final String SEPARATOR = ";"; // 消息类型常量,前后端共同引用 public static final String TYPE_LOGIN = "1001"; // 登录 public static final String TYPE_CREATE_ROOM = "2001"; // 创建房间 public static final String TYPE_JOIN_ROOM = "2002"; // 加入房间 public static final String TYPE_POKER = "3001"; // 出牌/要牌 public static String buildMessage(String type, String... args) { StringBuilder sb = new StringBuilder(type); for (String arg : args) { sb.append(SEPARATOR).append(arg); } return sb.toString(); } }这段代码逻辑很简单:用字符串拼接的方式,把消息类型和参数拼成一行文本。服务端收到消息后,先按SEPARATOR把字符串切分成数组,数组第一位是消息类型,后面是具体的业务参数。好处是调试时直接用telnet发文本就能测协议,不需要额外的抓包工具;坏处是粘包时必须自己处理,这个问题后面避坑章节专门讲。
2.2 Netty在代码里的三个存在感:Bootstrap、ChannelPipeline、Handler
很多第一次拆Netty项目的人会对着源码发懵:代码里到处都是channel、pipeline、future,跟平时写的业务代码完全不是一回事。我习惯把Netty理解成一个管道系统:客户端发消息,消息不是直接飞到服务端,而是要经过一长串处理器,每个处理器只干一件事,干完传给下一个。
这套斗地主源码里,Netty的痕迹体现在三个地方。
第一个是客户端的启动器Bootstrap。LoginWidow里一定有一段初始化Netty的代码,典型写法是:
EventLoopGroup group = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new NettyClientHandler()); } }); ChannelFuture future = bootstrap.connect("127.0.0.1", 8080).sync();逻辑说明:group是Netty的线程池,负责处理所有IO事件,客户端用一个线程组就够了。channel指定底层用Java NIO的SocketChannel,不是传统的阻塞IO。TCP_NODELAY设为true是为了让小消息能即时发出去,玩斗地主这种高频小数据包场景必须开这个选项。handler里注册的是自己写的NettyClientHandler,项目里所有来自服务端的消息最终都会流经它。
第二个是ChannelPipeline,上面代码里addLast的过程就是在组装管道。管道里可以挂很多Handler,比如先挂编解码器,再挂业务Handler。这套源码的管道比较短,只有NettyClientHandler一个,因为文本协议不需要专门写编解码器,直接在Handler里解析字符串就行。
第三个是Handler本身。NettyClientHandler是客户端接收所有服务端消息的入口,这类Handler在Netty里叫入站处理器,核心在channelRead这个方法里。它拿到的是Netty帮你解码好的消息对象,然后按业务分发给不同的界面窗口。
2.3 common模块里的LandlordsUtil:不只是工具类,是全项目的协议标准
LandlordsUtil在三个模块里都被引用,它的地位比名字看起来重要得多。它定义了消息分隔符和消息构造方法,等于定下了全项目的通信标准。如果服务端和客户端各自定义一套拼接规则,消息必然对不上。
我建议拿到源码后第一件事,就是把LandlordsUtil里的所有常量列出来,对照着看每个窗口发送的消息格式。搞清楚消息结构之后,再去读ServerMessageHandler和NettyClientHandler,整个项目就串起来了。
3. 客户端的Netty接入:从Bootstrap启动到登录、大厅、房间的数据流转
3.1 LoginWidow:连接入口与发送登录指令
登录窗口是程序的起点。它类名虽然少了个字母,但做的事很全:创建Netty连接、初始化界面、把用户输入的昵称打包成登录消息发给服务端。
客户端连接部分,除了上一章说的Bootstrap,还要关注连接失败的处理。很多课程设计源码连接是同步阻塞的,bootstrap.connect()后面跟了.sync(),行与行之间会阻塞等待连接结果。我一般不建议在主线程里sync,不过对课程设计来说这样写最直观,失败时catch异常弹一个错误框就行。
发登录消息的代码逻辑,一般是这样的:
private void sendLogin(String nickname) { String message = LandlordsUtil.buildMessage( LandlordsUtil.TYPE_LOGIN, nickname, "default_avatar"); Channel channel = NettyClientHandler.getChannel(); channel.writeAndFlush(message); System.out.println("已发送登录消息: " + message); }逻辑说明:buildMessage把登录类型1001、昵称、头像三个字段用分隔符拼成一行字符串,然后通过channel发送。writeAndFlush是Netty的发送方法,write是把数据写到缓冲区,flush是把缓冲区数据真正推向网络,两个动作必须连着用,只write不flush数据会一直憋在缓冲区里不出去。
参数说明:第二个参数nickname在服务端会被用作玩家唯一标识,多个客户端登录时不能重名,否则服务端后登录的会把先登录的踢掉。头像参数如果为空,服务端会使用默认头像。
3.2 NettyClientHandler:一个入站处理器能把消息分发给三个窗口
NettyClientHandler是整个客户端的消息中枢。所有从服务端回来的消息都先进这个Handler,再由它决定把消息丢给哪个窗口。源码里不同版本的Handler写法有差异,但核心逻辑一样。
public class NettyClientHandler extends ChannelInboundHandlerAdapter { private static Channel channel; @Override public void channelActive(ChannelHandlerContext ctx) { channel = ctx.channel(); System.out.println("连接建立成功,本地地址:" + ctx.channel().localAddress()); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { String text = (String) msg; String[] parts = text.split(LandlordsUtil.SEPARATOR); String type = parts[0]; // 根据消息类型决定交给哪个界面组件 switch (type) { case LandlordsUtil.TYPE_LOGIN: // 登录结果:打开大厅窗口 GameLobbyWindow.getInstance().refreshPlayerList(parts); break; case LandlordsUtil.TYPE_CREATE_ROOM: case LandlordsUtil.TYPE_JOIN_ROOM: // 房间相关:打开/刷新房间窗口 LandlordsRoomWindow.getInstance().updateRoomInfo(parts); break; case LandlordsUtil.TYPE_POKER: // 出牌消息:更新牌桌 LandlordsRoomWindow.getInstance().refreshPoker(parts); break; default: System.out.println("未处理的消息类型: " + type); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } public static Channel getChannel() { return channel; } }逻辑说明:channelActive在TCP连接建立成功后触发,这时候把channel保存成静态变量,方便界面按钮到处调用发送方法。channelRead收到数据后转换字符串并切分,按消息类型决定调用哪个窗口的刷新方法。这三个窗口用的是单例模式,窗口全局只有一份,消息来的时候直接调用实例的方法更新UI。
参数说明:msg能直接转成String,是因为Netty默认的对象编码器已经把字节流转成了字符串。如果后来改了二进制协议,这里要跟着换成ByteBuf转byte[]的方式。
这里要强调一个概念:把channel保存成静态变量,是这套课程设计代码的一个技术债。如果项目里同时存在多个连接,静态变量会互相覆盖。但斗地主客户端只连一个服务端,这种写法能用。如果照着写进生产项目,建议把channel绑定到某个业务对象上,别用static。
3.3 GameLobbyWindow与LandlordsRoomWindow:界面层怎样驱动协议层
大厅窗口做的事比较固定:展示服务器返回的房间列表,玩家点击“创建房间”或“加入房间”,把动作转换成消息发出去。这两个窗口类都有带日期后缀的备份版本,说明作者当时改过界面布局,看代码时以不带日期的版本为准。
创建房间的发送方法,逻辑和登录完全一样:
public void createRoom(String roomName, String creator) { String message = LandlordsUtil.buildMessage( LandlordsUtil.TYPE_CREATE_ROOM, roomName, creator); NettyClientHandler.getChannel().writeAndFlush(message); }这段代码不用多解释,模式就是“先拼文本,再writeAndFlush”。真正值得注意的是停车场设计:客户端只管发消息,完全不关心消息怎么被处理。创建房间这个动作,服务端可能返回创建成功,还可能把房间信息广播给大厅里所有玩家,客户端只需要在channelRead里等TYPE_CREATE_ROOM类型的消息回来,然后调用GameLobbyWindow.getInstance().refreshPlayerList(parts)刷新列表。
这种设计的好处是界面和网络完全解耦。坏处是消息类型多了以后,NettyClientHandler里的switch分支会越来越长,项目里改造时可以把case分支抽成map映射,每个消息类型对应一个处理类。
4. 服务端并发处理:ServerMessageHandler的分发逻辑与EventLoop线程模型
4.1 服务端启动:ServerBootstrap与两个事件循环组
服务端代码是整个项目真正体现Netty价值的地方。客户端只要维护一个连接就行,服务端却要同时面对几百个连接、处理建房间、抢地主、同时出牌这些并发动作。
服务端启动的典型写法是:
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 接收新连接的线程 EventLoopGroup workerGroup = new NioEventLoopGroup(); // 处理已连接通道的IO ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new ServerMessageHandler()); } }); ChannelFuture future = bootstrap.bind(8080).sync();逻辑说明:bossGroup只有一个线程,干的事情就是不断accept新的TCP连接,accept之后把连接交给workerGroup。workerGroup里每个线程负责一组连接的所有读写事件,同一个连接总是被同一个线程执行,所以Handler里访问的业务数据天然没有线程竞争。这就是Netty线程模型最核心的一点:I/O线程和业务线程可以共用,也可以分离。
参数说明:SO_BACKLOG是TCP层的全连接队列长度,如果同一时刻大量客户端同时连接,队列满了之后的连接会被拒绝。本地开发设128足够,生产环境要根据服务器负载调大。SO_KEEPALIVE是TCP层的心跳机制,默认每两小时探测一次,对游戏这种长连接场景只靠它检测死连接太慢了,后面章节会讲在Netty层面加心跳。
4.2 ServerMessageHandler:服务端怎么拆消息、鉴权、再广播
ServerMessageHandler是服务端最重要的类,我把它比作整个系统的接线员。每个客户端发来消息,都会走到它的channelRead方法,在这个方法里完成三件事:解析消息类型,找到发消息的玩家,把消息内容分发给对应房间和大厅。
public class ServerMessageHandler extends SimpleChannelInboundHandler<String> { // 维护当前所有在线玩家:channelId -> 玩家信息 private static final Map<String, Player> onlinePlayers = new ConcurrentHashMap<>(); @Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { String[] parts = msg.split(LandlordsUtil.SEPARATOR); String type = parts[0]; switch (type) { case LandlordsUtil.TYPE_LOGIN: handleLogin(ctx, parts); break; case LandlordsUtil.TYPE_CREATE_ROOM: handleCreateRoom(ctx, parts); break; case LandlordsUtil.TYPE_JOIN_ROOM: handleJoinRoom(ctx, parts); break; case LandlordsUtil.TYPE_POKER: handlePlayPoker(ctx, parts); break; default: ctx.writeAndFlush("未知消息类型\n"); } } private void handleLogin(ChannelHandlerContext ctx, String[] parts) { String nickname = parts[1]; onlinePlayers.put(ctx.channel().id().asLongText(), new Player(nickname, ctx.channel())); ctx.writeAndFlush("1001;登录成功"); System.out.println("玩家 " + nickname + " 已登录"); } }逻辑说明:channelRead0方法只做消息分发,不同的消息类型对应不同的处理方法。handleLogin把当前连接channel和玩家昵称绑定到一个Map里,这样后续任何消息都能通过channel的id找到是哪个玩家发的。ctx.writeAndFlush是服务端给单个客户端回消息的方式。
参数说明:这里的Map用了ConcurrentHashMap,线程安全,适合多个worker线程同时写入。channelId.asLongText()是每个连接全局唯一的字符串,不要用channel.toString()当key,因为toString的内容不是严格唯一。SimpleChannelInboundHandler会自动释放消息对象的引用,比直接继承ChannelInboundHandlerAdapter更省心。
4.3 房间维度并发:一张表管理所有房间,还是用锁各自保护
斗地主服务端的并发压力主要集中在房间操作上。玩家创建房间、加入房间、开始游戏、出牌,全部请求都会落在一个房间状态对象上。如果两个玩家同时操作同一个房间,数据就会乱。
最忌讳的写法是把所有房间放在一个ArrayList里,然后给整个列表加锁。这样玩家A加房间B被锁挡住,玩家C加房间D也得排队等,性能浪费严重。常见做法是房间粒度锁:
public class Room { private final Lock lock = new ReentrantLock(); private List<Player> players = new ArrayList<>(3); public boolean joinRoom(Player player) { lock.lock(); try { if (players.size() >= 3) { return false; } players.add(player); return true; } finally { lock.unlock(); } } }逻辑说明:每个Room实例持有一把独立的锁,只有操作同一个房间的请求才会互相等待,不同房间互不干扰。斗地主房间上限三人,所以List初始容量设为3,超过就拒绝加入。
一个很容易被忽略的问题:锁内的操作要尽量少,不要把广播给玩家的逻辑也写进锁里。因为广播消息要遍历所有客户端channel并writeAndFlush,这个过程可能触发TCP阻塞,一旦把广播放在锁里,整个房间都会被卡住。正确做法是在锁内只修改房间状态,锁外做消息广播。
再来聊一个真实会踩的点:玩家的状态切换发生在多个地方,登录、建房、加入房间、游戏进行中,ServerMessageHandler里如果状态机管理不好,就会出现一个玩家同时坐在两个房间里。所以服务端维护玩家状态的Map一定要能在离开房间时及时清理。
5. 四个避坑记录:收不到消息、粘包、端口占用、图片资源,哪个最坑
5.1 服务端收到的消息粘在一起:Netty粘包处理
现象:客户端连续发两次登录指令,服务端日志里出现两行消息拼成一行的情况,比如1001;张三1001;李四解析成一个字符串,数组拆开后字段错位,玩家昵称变成乱码。
原因:TCP是面向字节流的协议,上层应用发的几条消息在底层可能被合并成一个包,也可能被拆开。课程设计里的文本协议没有定义消息边界,服务端每次channelRead拿到的可能不是完整的一条消息。
解决:这套代码最简单有效的办法是改用换行符分隔消息,在客户端发送的字符串末尾追加\n,服务端的pipeline里加一个LineBasedFrameDecoder:
ch.pipeline() .addLast(new LineBasedFrameDecoder(1024)) .addLast(new StringDecoder(Charset.forName("UTF-8"))) .addLast(new ServerMessageHandler());逻辑说明:LineBasedFrameDecoder会一直等到换行符出现才把完整一行交给后面的Handler,这样handler收到的msg永远是一条完整消息,不会出现半包和粘包。StringDecoder把解码后的字节变成字符串。顺序不能颠倒:万能拆包器在前,字符串转换在后,业务Handler最后。1024是单行消息最大长度,超过这个值会抛异常,斗地主的文本指令不可能超过这个长度。
注意:客户端也要同步改,发送消息时末尾加\n。如果只改服务端不改客户端,服务端会一直等不到换行符,表现为另一类问题“消息收不到”。
5.2 客户端收不到服务端消息:Handler里忘了ctx.fireChannelRead
现象:客户端能登录成功,也能看到自己发的消息,但服务端推送过来的大厅玩家列表、房间消息全部没有反应。
原因:这是自己在改造Handler时最容易犯的错。在channelRead里,如果自己处理完了数据,又没有调用ctx.fireChannelRead(msg),这条消息就在当前Handler里被消费掉了,后面的处理器永远不会看到。或者pipeline里加了多个Handler,前一个不调用fireChannelRead,链路就断了。
解决:在自定义Handler里,要么继承SimpleChannelInboundHandler,处理完消息框架自动释放;要么继承ChannelInboundHandlerAdapter,并且在不需要拦截的时候执行ctx.fireChannelRead(msg)。最朴素的排查方式是在每个Handler的channelRead第一行打印一行日志,看消息到底断在哪一环。我排查这类问题从来只靠一个办法:顺着pipeline顺序往每个Handler里加日志,五分钟内一定能定位到哪个Handler把消息吞了。
5.3 服务端重启后客户端连不上:端口被占用与SO_REUSEADDR
现象:开发时一直改服务端代码,改完直接重新启动,控制台报Address already in use,明明上一个进程已经被关掉了,端口还是被占着。
原因:服务端进程没完全退出,或者TCP连接处于TIME_WAIT状态没释放端口。Windows下按红色停止按钮并不是总是杀死进程,尤其是在IDE里跑Java程序时,经常出现僵尸进程占用端口。
解决:先查占用端口的进程,再强制杀掉。
# Windows netstat -ano | findstr 8080 taskkill /PID 进程号 /F # Linux/Mac lsof -i :8080 kill -9 进程号除了杀进程,服务端还可以在Bootstrap上开启地址复用,防止TIME_WAIT导致的端口不可用:
ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.option(ChannelOption.SO_REUSEADDR, true);注意:SO_REUSEADDR要在ServerBootstrap上用option,不是childOption。这个参数的意思是允许新的监听socket绑定到处于TIME_WAIT状态的端口。加上这个选项之后,开发体验能改善很多,至少不用频繁手动杀进程。
5.4 发牌界面起不来:图片资源路径写死导致的FileNotFound
现象:代码从作者电脑拷到自己电脑后,打开游戏大厅没有问题,一进入房间准备发牌,界面图标就消失或者直接抛java.io.FileNotFoundException。
原因:Swing界面加载图片用了new File("src/main/resources/images/xx.jpg")这种相对路径写法,相对路径依赖启动时的工作目录。自己的电脑上当前工作目录跟启动路径不一致,文件就找不到。
解决:别用File,改为从Classpath加载:
URL imageUrl = getClass().getClassLoader().getResource("images/poker_3.png"); ImageIcon icon = new ImageIcon(imageUrl); label.setIcon(icon);逻辑说明:getResource会从classpath根目录去找文件,无论项目从哪个目录启动,只要资源文件被Maven打进classpath,就一定能找到。这也是为什么这套源码里图片文件放在resources目录而不是直接跟源码混在一起的原因。
6. 往生产方向改代码:二进制定长消息体、Netty心跳与断线重连
文本协议在课程设计里够用,想拿来面试时讲出深度,就得把它改成二进制协议,再加两样东西:心跳和重连。
二进制协议我推荐按“消息类型+消息长度+消息体”三段式定义:
/** * 消息帧格式: * type : 1个字节,比如 1=登录 2=建房 3=出牌 * length : 4个字节,消息体长度,不含type和length自身 * body : length个字节,通常是JSON或自定义字节流 */ public class PokerFrameEncoder extends MessageToByteEncoder<String> { private static final Charset CHARSET = Charset.forName("UTF-8"); @Override protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) { byte[] body = msg.getBytes(CHARSET); out.writeByte(msgTypeOf(msg)); // 从消息里提取type out.writeInt(body.length); out.writeBytes(body); } }服务端在pipeline里配上对应的LengthFieldBasedFrameDecoder,粘包半包问题直接从根源解决,连文本协议里的换行符约定都可以去掉。
心跳机制用Netty自带的IdleStateHandler就够了:
ch.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 0, 60, TimeUnit.SECONDS));第三个参数是“所有事件空闲60秒”,超过这个时间没有发生任何读和写,就会触发IdleStateEvent。配合服务端的userEventTriggered方法,可以把长时间不说话的连接自动关闭。
客户端断线重连,可以在channelInactive里加退避重试:
@Override public void channelInactive(ChannelHandlerContext ctx) { // 连接断开后,延时2秒重新连接,失败则递增延时 ctx.channel().eventLoop().schedule(() -> { startConnect(); }, 2, TimeUnit.SECONDS); }逻辑说明:channelInactive是连接断开时触发,此时channel的eventLoop线程还活着,在线程上调度一个延迟任务执行重连。这样做能避免开新线程导致的线程数膨胀。重连间隔从2秒起步,连续失败就加倍到5秒、10秒,上限30秒,防止服务端没恢复时客户端疯狂重连。
我现在看这套源码,最大的习惯改变是:任何网络程序动工之前,先定义好协议边界,是换行符、定长还是长度字段,用嘴说清楚再写代码。Netty粘包这个坑,只要协议边界定得早,后面能少走一半弯路。希望这篇拆解能在你打开pom.xml之前,先把整个项目的通信脉络装进脑子里,帮你省下我当初几个晚上的排查时间。
本文还有配套的精品资源,点击获取