示例3 · Erase 网络版
双人联网擦除俄罗斯方块:流、异常处理、多线程、网络
示例3 · Erase 网络版
贯穿式教学示例(三):以「网络版擦除俄罗斯方块」(Erase-JAVA-NET,包
com.zmofun.neterase)为载体,重点介绍流、异常处理、多线程、网络。素材在本仓库素材/Erase-JAVA-NET/,以下代码均摘自其源码(有删节)。
一、项目概览:单机版 + 一对 Server/Client
在示例1 的单机版基础上,把「同一台机器的两个棋盘」换成「两台机器各一个棋盘」:
| 类 | 角色 |
|---|---|
Server | 监听端口、接受连接、为每个客户端开一个线程、把 A 的操作转发给 B |
Client | 连接服务器、发送自己的操作(sendMe)、接收对手数据(ReadThread) |
PlayInfo | 可序列化的「对局快照」:方块矩阵 + 棋盘 + 分数/关卡/速度 |
GameBox/Shape | 与单机版相同——网络层不重写游戏逻辑 |
架构要点:服务器只做转发(match + forward),双方各自运行完整的游戏逻辑。每方看到的「对手棋盘」是对方发来的快照。
二、网络:Socket 连接建立
服务器端:ServerSocket + accept()
// Server(节选)
static {
properties = new Properties();
inputStream = new FileInputStream("res/config-server.ini");
properties.load(inputStream);
serverSocket = new ServerSocket(Integer.valueOf(properties.getProperty("port")));
}
public void startServer() {
while (true) {
Socket clientSocket = serverSocket.accept(); // 阻塞等待客户端
String key = String.valueOf(i++);
ServThreadPerClient client = new ServThreadPerClient(key, clientSocket);
clients.put(key, client);
client.start(); // 每客户端一个线程
}
}
- 端口从配置文件读(
res/config-server.ini)——程序与部署环境解耦; accept()阻塞直到有客户端连入,然后为每个连接开一个ServThreadPerClient线程。
客户端:new Socket(host, port)
// Client(节选)
public void connect(String server, String port) {
clientSocket = new Socket(server, Integer.valueOf(port));
lastReadTime = Calendar.getInstance().getTime();
sendMe(); // 连上后立刻发送自己——相当于向服务器注册
}
主机名 + 端口号就是 TCP 连接的「地址」;连上后先 sendMe(),服务器收到第一个包就知道新玩家来了。
三、流:对象序列化收发
双方交换的不是零散字节,而是整个 PlayInfo 对象——靠对象流完成:
// Client.sendMe()(节选)——发送
os = new ObjectOutputStream(clientSocket.getOutputStream());
os.writeObject(me); // 把 PlayInfo 序列化成字节写出
os.flush();
// Client.ReadThread.run()(节选)——接收
is = new ObjectInputStream(clientSocket.getInputStream());
fellow = (PlayInfo) is.readObject(); // 反序列化还原对象
Socket.getOutputStream()/getInputStream():套接字上的字节流;ObjectOutputStream/ObjectInputStream:在字节流之上封装的对象流——writeObject/readObject直接收发对象,前提是类实现了Serializable;PlayInfo里装的是完整对局快照(方块 5×5、棋盘 41×22、score/level/speed),一次收发同步整个局面。
四、多线程:谁在并发?
| 线程 | 位置 | 职责 |
|---|---|---|
ServThreadPerClient(每客户端一个) | Server | 循环读该客户端的 PlayInfo,转发给对手;掉线则清理 |
ReadThread | Client | 循环读服务器转来的对手数据 → 更新 boxFellow → play(1) |
CheckThread | Client | 每 10 秒检查一次:超过 10 秒没收到对手数据 → 判定掉线 |
| Swing EDT(事件线程) | 双方 | 按键、绘制——与网络线程并行 |
// CheckThread(节选)
while (true) {
if (lastReadTime != null &&
Calendar.getInstance().getTime().getTime() - lastReadTime.getTime() > 10000) {
fellowName = null; // 对手掉线
}
Thread.sleep(10000);
}
并发带来的新问题:多个线程同时碰
clients(HashMap)和对手匹配状态——所以matchFellow()/clearFellow()都声明为 synchronized(示例2 里Clerk.isServiceState()的 synchronized 在这里再次出现)。
五、异常处理:网络一定会出问题
ServThreadPerClient.run() 的 catch 块是教科书式的分级处理:
} catch (Exception e) {
if (e.getClass().getTypeName().contains("SocketException")) {
if (e.getMessage().contains("Connection reset")) {
// 对端正常断开(关窗口)→ 安静清理
Log.debug(String.format("client %s:%s offline!", this.myKey, this.me.getName()));
this.clientSocket.close();
}
} else {
// 数据异常 → 视为探测攻击,同样断开
System.out.println("client error!");
this.clientSocket.close();
}
clearFellow(); // 通知对手「你掉线了」
clients.remove(this.myKey);
this.interrupt();
}
- 区分异常种类:
SocketException + Connection reset(正常断开)与数据异常(可疑流量)走不同分支; - 清理三步曲:通知对手 → 从
clients移除 →interrupt()结束本线程——资源不泄漏; - 客户端侧同理:
sendMe()捕获异常后sleep(5000),若 socket 已关则connect(server, port)重连。
网络程序的异常处理原则:把「对端消失」当作常态设计——每个收发点都假设可能失败,失败路径必须能清理、能恢复。
六、知识点映射
| 课程知识点 | Erase 网络版中的落点 |
|---|---|
| 流 | Socket.getInputStream/OutputStream;ObjectInputStream/ObjectOutputStream 收发 PlayInfo |
| 异常处理 | IOException 分级捕获;Connection reset 判定;重连逻辑 |
| 多线程 | 每客户端一线程(Server);ReadThread/CheckThread(Client);synchronized 保护共享状态 |
| 网络 | ServerSocket(port) + accept();new Socket(host, port);配置文件里的 host/port |
| (呼应示例1) | GameBox/Shape 原样复用——网络层不碰游戏逻辑 |
思考题
- 服务器为什么为每个客户端开一个线程,而不是单线程轮流处理?(提示:
accept()与readObject()都会阻塞) PlayInfo为什么要实现Serializable?如果只发score一个 int,还需要对象流吗?CheckThread用「10 秒没收到数据」判定掉线——如果对手只是网络卡顿 15 秒,会发生什么?如何改进?
学习小测验
学习小测验
Server.startServer() 中 serverSocket.accept() 的作用是( )
双方交换对局数据用的是哪对流?
客户端 ReadThread 收到对手的 PlayInfo 后做什么?
服务器端 matchFellow()、clearFellow() 声明为 synchronized 是为了( )
CheckThread 每隔 10 秒检查什么?
客户端 sendMe() 发送失败且 socket 已关闭时的处理是( )