Java 面向对象 · 交互课程

示例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();                                // 每客户端一个线程
    }
}

客户端: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();   // 反序列化还原对象

四、多线程:谁在并发?

线程位置职责
ServThreadPerClient(每客户端一个)Server循环读该客户端的 PlayInfo,转发给对手;掉线则清理
ReadThreadClient循环读服务器转来的对手数据 → 更新 boxFellow → play(1)
CheckThreadClient每 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();
}

网络程序的异常处理原则:把「对端消失」当作常态设计——每个收发点都假设可能失败,失败路径必须能清理、能恢复。

六、知识点映射

课程知识点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 原样复用——网络层不碰游戏逻辑

思考题

  1. 服务器为什么为每个客户端开一个线程,而不是单线程轮流处理?(提示:accept() 与 readObject() 都会阻塞)
  2. PlayInfo 为什么要实现 Serializable?如果只发 score 一个 int,还需要对象流吗?
  3. CheckThread 用「10 秒没收到数据」判定掉线——如果对手只是网络卡顿 15 秒,会发生什么?如何改进?

学习小测验

学习小测验

Q1

Server.startServer() 中 serverSocket.accept() 的作用是( )

Q2

双方交换对局数据用的是哪对流?

Q3

客户端 ReadThread 收到对手的 PlayInfo 后做什么?

Q4

服务器端 matchFellow()、clearFellow() 声明为 synchronized 是为了( )

Q5

CheckThread 每隔 10 秒检查什么?

Q6

客户端 sendMe() 发送失败且 socket 已关闭时的处理是( )