Java聊天室
开门见山直接来,先确定功能点
- 用户上线后可以被其他用户收到上线消息
- 用户上线后可以被其他用户收到上线消息
- 用户可以对某个在线的用户进行私聊
- 用户可以在公屏喊话发广播
代码中有注释可以帮助阅读,先新建一个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();
}
}
}
检验时间
先把Server
中main
函数执行起来(服务先跑)
此时应该是像这样什么事都不会做,等待着客户端链接
下面启动4个客户端来进行功能演示,默认情况下在idea
里一个类中的main
方法只能执行一次,可以把java
程序启动命令复制出来,idea
做的事情就是执行这一串命令,拿出来手动执行效果也是完全一样的,复制出下图的命令
在编辑器中打开
把开头红框部分换成java
,编码保持UTF8
,classpath
保留剩余红框部分(不动classpath
也可以),进行精简后的命令便剩下一行,在终端里输入即可启动一个客户端
启动4个客户端,并测试手动广播。首先可以看到已上线的客户端,在其他客户端链接上时能能收到上线通知。其次用1号客户端好运莲莲问候大家,每个客户端都能收到,功能ok
接着用3号给2号发私聊,可以看到只有2号孤单男孩收到了来自3号客户端的消息,功能ok
最后用4号客户端测试错误的消息格式和下线功能,可以看到错误的消息并没有被发出,杀掉进程Ctrl+C
或者关掉终端都可以,聊天室里的其他3位也能收到下线通知,功能ok
简易聊天室完成✅
附上github源码地址,clone下来就可以跑
完 :)