Java基于Socket多线程实现一个简易聊天室

566 阅读6分钟

Java聊天室

开门见山直接来,先确定功能点

  1. 用户上线后可以被其他用户收到上线消息
  2. 用户上线后可以被其他用户收到上线消息
  3. 用户可以对某个在线的用户进行私聊
  4. 用户可以在公屏喊话发广播

代码中有注释可以帮助阅读,先新建一个Server类,使其能接受客户端的链接
所有异常均简单抛出或catch打印处理(处理异常不是这篇文章重点)

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

public class Server {
    // ID自增器,需要保证操作是原子的,所以使用AtomicInteger
    private AtomicInteger COUNTER = new AtomicInteger(0);
    
    // Socket就是一条双向的数据管道,哪一端都可以同时接发消息,而这些IO操作速度并不快
    // 所以需要使用多线程来实现高并发
    private final ServerSocket serverSocket;
    
    // 用于收集注册的客户端信息,ClientConnection类的定义在后面
    // 这里不能使用HashMap,因为是多线程操作,所以要是用ConcurrentHashMap,它可以保证线程安全
    private final Map<Integer, ClientConnection> clientMap = new ConcurrentHashMap<>();

    // 构造函数,传入端口号 0~65535
    public Server(int port) throws IOException {
        this.serverSocket = new ServerSocket(port);
    }

    // 启动服务,持续保持监听客户端链接,只要有客户端链接就开一个线程去处理客户端的消息发送
    public void start() throws IOException {
        while (true) {
            // 调用accept方法后,会进入阻塞状态,直到有客户端链接为止
            Socket socket = serverSocket.accept();
            new ClientConnection(COUNTER.incrementAndGet(), this, socket).start();
        }
    }

    // 启动服务
    public static void main(String[] args) throws IOException {
        Server server = new Server(8080);
        server.start();
    }

    // 当调用注册客户端时,说明有用户上线,此时需要进行全员广报,并且把所有用户当id和名字告诉其他用户
    public void registerClient(ClientConnection clientConnection) {
        // 存到内存中
        clientMap.put(clientConnection.getClientId(), clientConnection);
        // 广播用户上线了
        this.clientOnline(clientConnection);
    }

    // 用户上线广播方法
    // 入参为上线的客户端sokcet
    private void clientOnline(ClientConnection clientConnection) {
        clientMap.values().stream().forEach(client -> dispatchMessage(client, "系统", "所有人", client.getClientName() + "上线了 " + getAllClientInfo()));
    }

    // 用户下线广播方法,参考上线
    public void clientOffLine(ClientConnection clientConnection) {
        clientMap.remove(clientConnection.getClientId());
        clientMap.values().stream().forEach(client -> dispatchMessage(client, "系统", "所有人", client.getClientName() + "下线了 " + getAllClientInfo()));
    }

    // 获取所有客户端的信息,取出id和name做字符串拼接,用户简陋的提示其他用户的目标id,方便私聊
    public String getAllClientInfo() {
        return this.clientMap.entrySet().stream().map(entry -> entry.getKey() + ":" + entry.getValue().getClientName())
                .collect(Collectors.joining(","));
    }

    // 发送消息,入参为要发消息的客户端以及消息
    public void sendMessage(ClientConnection source, Message message) {
        String msg = message.getMsg();
        // 如果id为0,表示公屏喊话所有人都看得到
        if (message.getId() == 0) {
            clientMap.values().forEach(client -> dispatchMessage(client, source.getClientName(), "所有人", msg));
        } else {
            // 非0则是私聊,只有目标客户端能收到
            int id = message.getId();
            ClientConnection targetClient = clientMap.get(id);
            if (Objects.isNull(targetClient)) {
                System.err.println("目标用户:" + id + "不存在");
            } else {
                dispatchMessage(targetClient, source.getClientName(), "你", msg);
            }
        }
    }

    // 发送消息处理
    public void dispatchMessage(ClientConnection clientConnection, String source, String target, String msg) {
        clientConnection.sendMessage("【" + source + "】对【" + target + "】说" + msg);
    }
}

ClientConnection类的定义,继承Thread类

引入了fastjson进行处理,基于maven的项目在pom依赖中引入

import com.alibaba.fastjson.JSON;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.util.Objects;

public class ClientConnection extends Thread {
    // 客户端名称,由客户端第一次链接注册时提供
    private String clientName;
    
    // 客户端id,服务度第一次接收链接时注册客户端信息到Map中,并自动生成一个唯一的id
    private int clientId;
    
    // 服务端实例对象,用于注册,断开,发消息
    private Server server;
    
    // socket实例,用于进行IO的读写
    private Socket socket;

    public String getClientName() {
        return clientName;
    }

    public int getClientId() {
        return clientId;
    }
    
    // 构造函数,客户端id由服务端生成
    public ClientConnection(int clientId, Server server, Socket socket) {
        this.clientId = clientId;
        this.server = server;
        this.socket = socket;
    }

    // 重写run方法,让其一直读输入流
    @Override
    public void run() {
        try {
            BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            String line = null;
            while (Objects.nonNull(line = br.readLine())) {
                // 如果客户端没有名字,说明是第一次注册
                if (Objects.isNull(clientName)) {
                    clientName = line;
                    server.registerClient(this);
                } else {
                    // 有名字说明就是私聊或者广播
                    Message message = JSON.parseObject(line, Message.class);
                    server.sendMessage(this, message);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            // 客户端进程被杀掉则会抛错走到finally,需要通知聊天室里的各位有客户端下机里
            server.clientOffLine(this);
        }
    }

    // 客户端发消息
    public void sendMessage(String msg) {
        Util.writeMessage(socket, msg);
    }
}

Message类

用于映射客户端的消息对象

public class Message {
    private int id;
    private String msg;

    public Message() {
    }

    public Message(int id, String msg) {
        this.id = id;
        this.msg = msg;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}

util类

没有什么内容,只是封装了一下输出流的操作,不用每次都写这一坨操作

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

public class Util {
    public static void writeMessage(Socket socket, String msg){
        try{
            OutputStream outputStream = socket.getOutputStream();
            outputStream.write(msg.getBytes());
            outputStream.write('\n');
            outputStream.flush();
        }catch(IOException exception){
            exception.printStackTrace();
        }
    }
}

client类

用于客户端的发起,主要功能是链接socket,输入网名后即可开始聊天(前提是服务端已开启)
为了简单,聊天内容的发送需要按照指定格式,当前线程负责消息输入发送,另开一个线程负责接收流

import com.alibaba.fastjson.JSON;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.util.Objects;
import java.util.Scanner;

public class Client {
    public static void main(String[] args) throws IOException {
        System.out.println("请输入你当网名,如(如孤独的狼🐺)");
        Scanner scanner = new Scanner(System.in);
        String name = scanner.nextLine();
        // 默认本机8080端口为服务端地址
        String host = "127.0.0.1";
        Socket socket = new Socket(host, 8080);

        // 创建链接后,相当于把插头插到了插座上,此时双方都可以互相发送和接收数据
        // 先发送注册客户端都消息
        Util.writeMessage(socket, name);

        // 开一个线程去读数据
        new Thread(() -> readFromServer(socket)).start();

        System.out.println("使用小助手:\n输入【hello:1】表示给id为1都用户发送消息\n输入【hi everybody:0】表示发送广播");
        System.out.println("-------------------");
        System.out.println("输入消息开始聊天之旅吧");
        while (true) {
            // 死循环的目的是可以一直发送消息(处于聊天窗的作用)
            String msg = scanner.nextLine();
            if (!msg.contains(":")) {
                System.out.println("发送失败,输入不正确,请参考使用小助手");
            } else {
                int colonIndex = msg.indexOf(":");
                // +1表示从冒号开始截取到尾部
                int id = Integer.parseInt(msg.substring(colonIndex + 1));
                String message = msg.substring(0, colonIndex);
                String json = JSON.toJSONString(new Message(id, message));
                // 发送聊天内容到服务端
                Util.writeMessage(socket, json);
            }
        }
    }

    // 子线程要做的事,一直读流,有内容就输出
    private static void readFromServer(Socket socket) {
        try {
            BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            String msg;
            while (Objects.nonNull(msg = br.readLine())) {
                System.out.println(msg);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

检验时间

先把Servermain函数执行起来(服务先跑)

image.png 此时应该是像这样什么事都不会做,等待着客户端链接

下面启动4个客户端来进行功能演示,默认情况下在idea里一个类中的main方法只能执行一次,可以把java程序启动命令复制出来,idea做的事情就是执行这一串命令,拿出来手动执行效果也是完全一样的,复制出下图的命令

image.png

在编辑器中打开

image.png

把开头红框部分换成java,编码保持UTF8classpath保留剩余红框部分(不动classpath也可以),进行精简后的命令便剩下一行,在终端里输入即可启动一个客户端

image.png

启动4个客户端,并测试手动广播。首先可以看到已上线的客户端,在其他客户端链接上时能能收到上线通知。其次用1号客户端好运莲莲问候大家,每个客户端都能收到,功能ok

image.png

接着用3号给2号发私聊,可以看到只有2号孤单男孩收到了来自3号客户端的消息,功能ok

image.png

最后用4号客户端测试错误的消息格式和下线功能,可以看到错误的消息并没有被发出,杀掉进程Ctrl+C或者关掉终端都可以,聊天室里的其他3位也能收到下线通知,功能ok

image.png

简易聊天室完成✅
附上github源码地址,clone下来就可以跑
完 :)