源本科技 | 码上会

Java 简易聊天程序

2026/02/01
86
0

概述

  • 可先下载程序运行看效果再开发

本项目的目标是构建一个基于 TCP 的多用户实时群聊系统,由两个独立运行的 Java 程序组成:

  • 服务端程序:长期运行,监听客户端连接,管理所有在线用户,并将任意用户发送的消息广播给其他所有用户

  • 客户端程序:用户启动后连接服务器,输入用户名,随后可发送消息;同时实时接收并显示所有其他用户的消息

核心功能需求

功能

描述

1. 用户连接与注册

客户端首次连接后,必须提供一个非空用户名。服务端记录该用户并通知其加入成功。

2. 消息广播

任一客户端发送的每条消息,服务端必须将其转发给除发送者外的所有已连接客户端。

3. 实时消息接收

客户端必须在后台持续监听服务器推送的消息,并立即打印到控制台。

4. 多客户端支持

服务端应能同时处理多个客户端连接,彼此互不影响。

5. 异常安全退出

客户端断开时,服务端应正确清理该用户,避免资源泄漏或空指针。

非功能性需求

  • 代码结构清晰:采用三层架构(表现层 / 业务逻辑层 / 数据访问层)组织代码。

  • 高内聚低耦合:各层之间通过接口交互,禁止直接依赖具体实现类。

  • 线程安全:服务端管理客户端列表时必须保证并发安全。

  • 资源管理:所有 I/O 流和 Socket 必须在使用完毕后正确关闭。

整体架构设计

整个系统分为两个独立程序,每个程序内部均采用三层架构:

Server 程序
├── 表现层(Presentation Layer)
│   └── ServerApp.java               // 启动入口
├── 业务逻辑层(Service Layer)
│   ├── IServerService.java          // 服务接口
│   └── ChatServerService.java       // 服务实现
└── 数据访问层(Data Access Layer)
    ├── IServerDataAccess.java       // 数据访问接口
    └── ServerSocketDataAccess.java  // 基于 Socket 的实现
    └── ClientHandler.java           // 单个客户端连接处理器(属于数据层辅助组件)

Client 程序
├── 表现层(Presentation Layer)
│   └── ClientApp.java               // 启动入口
├── 业务逻辑层(Service Layer)
│   ├── IClientService.java          // 客户端服务接口
│   └── ChatClientService.java       // 客户端服务实现
└── 数据访问层(Data Access Layer)
    ├── IClientDataAccess.java       // 客户端数据访问接口
    └── ClientSocketDataAccess.java  // 基于 Socket 的实现

关键原则:上层只依赖下层的接口,不依赖具体实现。例如 ChatServerService 依赖 IServerDataAccess,而非 ServerSocketDataAccess

关键协议约定

为确保客户端与服务端协同工作,双方需遵守以下通信协议:

步骤

方向

内容

说明

1

服务端 → 客户端

"请输入您的用户名:"

连接建立后第一条消息

2

客户端 → 服务端

<username>

第一行必须是用户名(可含空格)

3

服务端 → 客户端

"欢迎加入聊天室,<username>!"

登录成功确认

4

服务端 → 客户端

"您可以开始发送消息了..."

提示用户开始聊天

5

客户端 ↔ 服务端

任意文本行

聊天消息内容

6

服务端 → 客户端

"[<username>]: <message>"

广播格式(由服务端生成)

异常情况处理

场景

处理方式

客户端发送空用户名

服务端自动设为 "匿名用户"

客户端异常断开

readLine() 抛出 IOException 或返回 null,触发 finally 块清理

服务端崩溃

所有客户端的 receive() 返回 null,自动退出接收线程

多客户端同时发送

CopyOnWriteArrayList 保证广播遍历安全

端口被占用

服务端启动失败,打印错误信息

开发测试步骤

编写顺序

  • 先定义所有接口IServerService, IClientService 等)

  • 实现服务端数据层ServerSocketDataAccess, ClientHandler

  • 实现服务端业务层ChatServerService

  • 编写服务端启动类ServerApp),测试单客户端连接

  • 实现客户端各层,测试消息收发

  • 启动多个客户端,验证广播功能

测试用例

  • 启动服务端,无客户端连接 → 服务端正常等待

  • 启动一个客户端,输入用户名 → 收到欢迎消息

  • 该客户端发送消息 → 自己看不到自己的消息(符合广播规则)

  • 启动第二个客户端 → 两人互相可见对方消息

  • 关闭一个客户端 → 服务端日志显示“用户已离开”,另一客户端不受影响


实现

服务端程序

创建一个名为 ChatServer 的项目

定义接口

  • 业务逻辑层:IServerService

package com.lusifer.chat.server.service;

import com.lusifer.chat.server.handle.ClientHandler;

/**
 * 定义服务器服务的核心功能。
 * 该接口提供了启动服务器、广播消息、注册客户端以及注销客户端的方法。
 */
public interface IServerService {

  /**
   * 启动服务器并监听指定端口。
   *
   * @param port 服务器监听的端口号
   */
  void start(int port);

  /**
   * 向所有已连接的客户端广播一条消息。
   *
   * @param message        要广播的消息内容
   * @param senderUsername 发送消息的用户名
   */
  void broadcast(String message, String senderUsername);

  /**
   * 注册一个新的客户端处理器到服务器。
   *
   * @param handler 客户端处理器实例
   */
  void registerClient(ClientHandler handler);

  /**
   * 从服务器中注销一个客户端处理器。
   *
   * @param handler 要注销的客户端处理器实例
   */
  void unregisterClient(ClientHandler handler);

  /**
   * 服务端主动向所有客户端广播消息。
   *
   * @param message 要广播的消息内容
   */
  void broadcastFromServer(String message);
}
  • 数据访问层:IServerDataAccess

package com.lusifer.chat.server.dao;

import com.lusifer.chat.server.service.IServerService;

/**
 * 定义服务器数据访问的相关操作。
 * 该接口用于启动服务器监听指定端口,并将请求委托给指定的服务处理。
 */
public interface IServerDataAccess {

    /**
     * 启动服务器监听指定端口,并将接收到的请求交由指定的服务处理。
     *
     * @param port    服务器监听的端口号,必须是一个有效的端口值(通常为1-65535)。
     * @param service 用于处理客户端请求的服务实例,需实现IServerService接口。
     */
    void startListening(int port, IServerService service);
}

业务逻辑层实现

  • ChatServerService

package com.lusifer.chat.server.service.impl;

import com.lusifer.chat.server.dao.IServerDataAccess;
import com.lusifer.chat.server.dao.impl.ServerSocketDataAccess;
import com.lusifer.chat.server.handle.ClientHandler;
import com.lusifer.chat.server.service.IServerService;

import java.util.concurrent.CopyOnWriteArrayList;


public class ChatServerService implements IServerService {
  /**
   * 存储所有已连接的客户端处理器的线程安全列表。
   */
  private final CopyOnWriteArrayList<ClientHandler> clients = new CopyOnWriteArrayList<>();

  @Override
  public void start(int port) {
    // 创建数据访问对象并启动监听
    IServerDataAccess dataAccess = new ServerSocketDataAccess();
    dataAccess.startListening(port, this);
  }

  @Override
  public void broadcast(String message, String senderUsername) {
    // 遍历所有客户端,向非发送者发送消息
    for (ClientHandler client : clients) {
      if (!client
              .getUsername()
              .equals(senderUsername)) {
        client.sendMessage(message);
      }
    }
  }

  @Override
  public void registerClient(ClientHandler handler) {
    // 将客户端添加到列表中并打印日志
    clients.add(handler);
    System.out.println("【服务器】用户 [" + handler.getUsername() + "] 已加入。");
  }

  @Override
  public void unregisterClient(ClientHandler handler) {
    // 从列表中移除客户端并打印日志
    clients.remove(handler);
    System.out.println("【服务器】用户 [" + handler.getUsername() + "] 已离开。");
  }

  @Override
  public void broadcastFromServer(String message) {
    // 遍历所有客户端,向每个客户端发送消息
    for (ClientHandler client : clients) {
      client.sendMessage("[服务器]: " + message);
    }
  }
}

数据访问层实现

  • ServerSocketDataAccess

package com.lusifer.chat.server.dao.impl;

import com.lusifer.chat.server.dao.IServerDataAccess;
import com.lusifer.chat.server.handle.ClientHandler;
import com.lusifer.chat.server.service.IServerService;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerSocketDataAccess implements IServerDataAccess {

  @Override
  public void startListening(int port, IServerService service) {
    // 创建 ServerSocket 实例并绑定到指定端口,使用 try-with-resources 确保资源自动关闭
    try (ServerSocket serverSocket = new ServerSocket(port)) {
      // 输出服务器启动信息
      System.out.println("【服务器】已启动,监听端口 " + port + "...");

      // 持续监听客户端连接,直到当前线程被中断
      while (!Thread
              .currentThread()
              .isInterrupted()) {
        // 接受客户端连接,返回对应的 Socket 实例
        Socket clientSocket = serverSocket.accept();

        // 为每个客户端连接创建一个 ClientHandler 处理器
        ClientHandler handler = new ClientHandler(clientSocket, service);

        // 启动新线程处理客户端请求
        new Thread(handler).start();
      }
    } catch (IOException e) {
      // 捕获并输出服务器监听失败的错误信息
      System.err.println("【服务器】监听失败: " + e.getMessage());
    }
  }
}

客户端处理器

  • ClientHandler

package com.lusifer.chat.server.handle;

import com.lusifer.chat.server.service.IServerService;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

/**
 * 实现了 Runnable 接口,用于处理单个客户端的连接和通信。
 * 该类负责接收客户端的消息、广播消息到其他客户端,并管理客户端的注册与注销。
 */
public class ClientHandler implements Runnable {
  private final Socket socket;           // 客户端套接字连接
  private final PrintWriter out;         // 向客户端发送消息的输出流
  private final BufferedReader in;       // 从客户端读取消息的输入流
  private String username;               // 客户端的用户名
  private final IServerService serverService; // 服务器服务接口,用于注册、注销和广播消息

  /**
   * 构造函数,初始化客户端处理器。
   *
   * @param socket        客户端的套接字连接
   * @param serverService 服务器服务接口实例
   * @throws IOException 如果创建输入/输出流时发生错误
   */
  public ClientHandler(Socket socket, IServerService serverService) throws IOException {
    this.socket = socket;
    this.serverService = serverService;
    this.out = new PrintWriter(socket.getOutputStream(), true);
    this.in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  }

  /**
   * 获取客户端的用户名。
   *
   * @return 客户端的用户名
   */
  public String getUsername() {
    return username;
  }

  /**
   * 向客户端发送一条消息。
   *
   * @param message 要发送的消息内容
   */
  public void sendMessage(String message) {
    out.println(message);
  }

  /**
   * 实现 Runnable 接口的 run 方法,处理客户端的整个生命周期。
   * 包括获取用户名、注册到服务器、处理聊天消息以及最终的清理工作。
   */
  @Override
  public void run() {
    try {
      // 第一步:获取用户名并注册到服务器
      out.println("请输入您的用户名:");
      username = in.readLine();
      if (username == null || username
              .trim()
              .isEmpty()) {
        username = "匿名用户";
      }

      // 注册客户端到服务器
      serverService.registerClient(this);

      out.println("欢迎加入聊天室," + username + "!");
      out.println("您可以开始发送消息了...");

      // 第二步:持续读取客户端发送的消息并广播给其他客户端
      String line;
      while ((line = in.readLine()) != null) {
        String formattedMessage = "[" + username + "]: " + line;
        System.out.println(formattedMessage); // 打印到服务端日志
        serverService.broadcast(formattedMessage, username); // 广播消息给所有其他客户端
      }

    } catch (IOException ignored) {
      // 忽略IO异常
    } finally {
      // 清理资源:注销客户端并关闭套接字
      try {
        serverService.unregisterClient(this);
        socket.close();
      } catch (IOException ignored) {
        // 忽略关闭套接字时的异常
      }
    }
  }
}

启动入口

  • ServerApp

package com.lusifer.chat.server;

import com.lusifer.chat.server.service.IServerService;
import com.lusifer.chat.server.service.impl.ChatServerService;

import java.util.Scanner;

public class ServerApp {
  public static void main(String[] args) {
    IServerService serverService = new ChatServerService();

    // 启动一个线程监听服务端输入
    new Thread(() -> {
      Scanner scanner = new Scanner(System.in);
      System.out.println("输入消息并按回车即可广播给所有客户端:");
      while (true) {
        String input = scanner.nextLine(); // 读取服务端输入
        serverService.broadcastFromServer(input); // 广播消息
      }
    }).start();

    serverService.start(12346); // 启动服务
  }
}

客户端程序

创建一个名为 ChatClient 的项目

定义接口

  • 业务逻辑层:IClientService

package com.lusifer.chat.client.service;

/**
 * 定义了客户端服务的基本功能。
 * 该接口提供了连接服务器、接收消息和发送用户消息的方法。
 */
public interface IClientService {

  /**
   * 连接到指定的主机和端口。
   *
   * @param host 主机地址,用于建立网络连接。
   * @param port 端口号,用于建立网络连接。
   * @throws Exception 如果连接过程中发生错误,则抛出异常。
   */
  void connect(String host, int port) throws Exception;

  /**
   * 启动消息接收功能。
   * 调用此方法后,客户端将开始监听并处理来自服务器的消息。
   */
  void startReceivingMessages();

  /**
   * 发送用户消息到服务器。
   *
   * @param message 要发送的用户消息内容。
   */
  void sendUserMessage(String message);
}
  • 数据访问层:IClientDataAccess

package com.lusifer.chat.client.dao;

import java.io.IOException;

/**
 * 定义了客户端数据访问的基本操作。
 * 该接口提供了连接、发送消息、接收消息以及关闭连接的功能。
 */
public interface IClientDataAccess {

  /**
   * 建立与指定主机和端口的连接。
   *
   * @param host 主机地址,用于建立网络连接。
   * @param port 端口号,用于建立网络连接。
   * @throws IOException 当连接过程中发生I/O异常时抛出。
   */
  void connect(String host, int port) throws IOException;

  /**
   * 向已建立的连接发送消息。
   *
   * @param message 要发送的消息内容。
   */
  void send(String message);

  /**
   * 从连接中接收消息。
   *
   * @return 接收到的消息内容。
   */
  String receive();

  /**
   * 关闭当前的连接。
   */
  void close();
}

业务逻辑层实现

  • ChatClientService

package com.lusifer.chat.client.service.impl;

import com.lusifer.chat.client.dao.IClientDataAccess;
import com.lusifer.chat.client.service.IClientService;

public class ChatClientService implements IClientService {
  /**
   * 数据访问接口实例,用于执行底层的网络通信操作。
   */
  private final IClientDataAccess dataAccess;

  /**
   * 构造函数。
   *
   * @param dataAccess 数据访问接口实例,用于处理连接、发送和接收消息等操作。
   */
  public ChatClientService(IClientDataAccess dataAccess) {
    this.dataAccess = dataAccess;
  }

  @Override
  public void connect(String host, int port) throws Exception {
    dataAccess.connect(host, port);
  }

  @Override
  public void startReceivingMessages() {
    // 创建并启动一个新的线程用于接收消息
    new Thread(() -> {
      // 循环接收消息,直到线程被中断或连接关闭
      while (!Thread
              .currentThread()
              .isInterrupted()) {
        String message = dataAccess.receive();
        if (message != null) {
          System.out.println(message); // 显示所有收到的消息
        } else {
          break; // 连接关闭时退出循环
        }
      }
    }).start();
  }

  @Override
  public void sendUserMessage(String message) {
    dataAccess.send(message);
  }
}

数据访问层实现

  • ClientSocketDataAccess

package com.lusifer.chat.client.dao.impl;

import com.lusifer.chat.client.dao.IClientDataAccess;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public class ClientSocketDataAccess implements IClientDataAccess {
  private Socket socket;
  private PrintWriter out;
  private BufferedReader in;

  @Override
  public void connect(String host, int port) throws IOException {
    socket = new Socket(host, port);
    out = new PrintWriter(socket.getOutputStream(), true);
    in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
  }

  @Override
  public void send(String message) {
    out.println(message);
  }

  @Override
  public String receive() {
    try {
      return in.readLine();
    } catch (IOException e) {
      return null;
    }
  }

  @Override
  public void close() {
    try {
      if (in != null) in.close();
      if (out != null) out.close();
      if (socket != null && !socket.isClosed()) socket.close();
    } catch (IOException ignored) {
    }
  }
}

启动入口

  • ClientApp

package com.lusifer.chat.client;

import com.lusifer.chat.client.dao.IClientDataAccess;
import com.lusifer.chat.client.dao.impl.ClientSocketDataAccess;
import com.lusifer.chat.client.service.IClientService;
import com.lusifer.chat.client.service.impl.ChatClientService;

import java.util.Scanner;

public class ClientApp {

  public static void main(String[] args) {
    // 定义服务器主机地址和端口号
    final String SERVER_HOST = "localhost";
    final int SERVER_PORT = 12346;

    // 初始化数据访问层和服务层对象
    IClientDataAccess dataAccess = new ClientSocketDataAccess();
    IClientService clientService = new ChatClientService(dataAccess);

    try {
      // 1. 连接到指定的服务器
      clientService.connect(SERVER_HOST, SERVER_PORT);
      System.out.println("已连接到聊天服务器!");

      // 2. 启动后台线程以持续接收来自服务器的消息
      clientService.startReceivingMessages();

      // 3. 获取用户输入的用户名并发送给服务器
      Scanner scanner = new Scanner(System.in);
      String username = scanner.nextLine();
      clientService.sendUserMessage(username); // 发送用户名

      // 4. 主循环:持续读取用户输入并发送聊天消息
      while (true) {
        String message = scanner.nextLine();
        clientService.sendUserMessage(message);
      }

    } catch (Exception e) {
      // 捕获异常并输出错误信息,同时关闭数据访问连接
      System.err.println("客户端异常: " + e.getMessage());
      dataAccess.close();
    }
  }
}