思路:
要实现聊天功能,我们就必须有服务器和客户端。客户端连接到服务器,然后通过发送消息到服务器及从服务器读取消息来达到多客户端通信的目的。简单来说,所有客户端都是通过服务器来进行身份验证和消息发送的。要达到通信的目的,我们首先要做的是实现多客户端与服务器的连接,当客户端连接上服务器之后,服务器需要做的就是每来一个客户端,就处理该客户端的业务,如登录,单聊等;客户端要做的就是通过读取服务器的数据、写入数据到服务器来实现与其他客户端的交互。
先通过两张图了解一下客户端与服务器的交互:
了解完大体思路之后,接下来我们来看一下各个步骤的具体实现方法:
- 客户端与服务器的连接
套接字使用TCP可以实现两台计算机之间的通信,客户端创建一个套接字,并尝试连接服务器的套接字。java.net.Socket类代表一个套接字,java.net.ServerSocket类为服务器提供了一种监听并连接的通信机制。在连接过程中:
1. 服务器会创建一个ServerSocket对象,表示通过服务器的端口进行通信。
public ServerSocket(int port) throws IOExecption //创建绑定到特定端口的服务器套接字
2. 服务器调用ServerSocket类的accept()方法,该方法将阻塞等待客户端的连接。
public Socket accept() throws IOException //侦听并接收到此套接字的连接
3. 当服务器在等待客户端连接时,此时一个客户端创建一个Socket对象,并指定服务器名称和端口号请求连接。
public Socket(String host, int port) throws UnknownHostException, IOException. 创建一个
流套接字并将其连接到指定主机上的指定端口号。
4. 当客户端连接上服务器之后,在服务器端,accept()方法会返回一个新的socket引用,该socket连接到客户端的socket。
- 服务器的工作
当客户端连接上服务器之后,我们知道accept()方法会返回给服务器一个新的socket引用,该socket连接到客户端的socket。那么此时只要有一个客户端连接到服务器了,服务器就要处理客户端的业务。我们知道每个客户端其实都是独立的,而且每个客户端连接服务器的时间都不定,所以我们可以创建一个线程池,每来一个客户端,就将该客户端的业务提交到线程池中,由线程池执行器来执行任务。由于每个客户的业务需求不同,我们可以创建一个类(ExecuteClient)专门来处理客户的业务。
- 客户端的工作
当客户端连接上服务器之后,每一个客户端都会有一个Socket对象,该对象通过往服务器写入数据,从服务器读取数据可以实现不同客户端之间的通信。这时候我们也可以创建两个线程专门处理往服务器写入数据(WriteDataToServerThread)和从服务器读取数据(ReadDataFromServerThread)的功能。
那么服务器可以处理客户端的哪些业务呢?
由于客户端和服务器都有Socket对象,我们先来看看它们都能调用哪些方法:
public InputStream getInputStream() throws IOException //返回此套接字的输入流
public OutputStream getOutputStream() throws IOException //返回此套接字的输出流
public void close() throws IOException //关闭此套接字
此前我们先做如下约定:
1. 当输入register:<userName>:<password>时,表示用户注册
2. 当输入login:<userName>:<password>时,表示用户登录
3.当输入private:<userName>:<message>时,表示单聊
4.当输入group:<message>时,表示群聊
5.当输入bye时,表示用户退出
- 注册(register)
注册的时候有以下几点要求:
1. 用户名、密码均不能为空
2. 用户名、密码都仅支持字母和数字
3. 用户名长度:8-15,密码长度:6-10
4. 用户名不能重复,密码无要求
5. 注册信息存储(用户名+密码)
思路:当新用户注册时,首先会输入用户名和密码,在用户名和密码均满足上述要求时,我们需要对用户信息(用户名+密码)进行存储,以便再有新用户要注册时进行用户名是否重复的比较。在保存用户信息时,由于一个用户名对应一个密码,我们可以用Map<String,String> USER_PASSWORD_MAP来保存,其中key为用户名,value为密码。当新用户注册成功后,告知该用户注册成功;否则将注册失败原因告知,如用户名重复,密码长度不对等。
//给客户端发送信息
private void sendMessage(Socket client, String message) {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
writer.write(message + "\n");
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
//注册
private void register(String userName, String password, Socket client) {
if (userName == null) {
this.sendMessage(client, "用户名不能为空");
return;
}
if (password == null) {
this.sendMessage(client, "密码不能为空");
return;
}
char[] name = userName.toCharArray(); //字符串转数组
char[] passWord = password.toCharArray(); //字符串转数组
int nLength = name.length; //用户名的长度
int pLength = passWord.length; //密码的长度
if (!((nLength >= 8 && nLength <= 15) && (pLength >= 6 && pLength <= 10))) {
this.sendMessage(client, "输入的用户名或密码长度不符合要求(用户名长度8到15,密码长度6到10)");
return;
}
for (char n : name) {
if (!((n >= 'A' && n <= 'Z') || (n >= 'a' && n <= 'z') || (n >= '0' && n <= '9'))) {
this.sendMessage(client, "用户名仅支持字母、数字");
return;
}
}
for (char p : passWord) {
if (!((p >= 'A' && p <= 'Z') || (p >= 'a' && p <= 'z') || (p >= '0' && p <= '9'))) {
this.sendMessage(client, "密码仅支持字母、数字");
return;
}
}
for (Map.Entry<String, String> entry : USER_PASSWORD_MAP.entrySet()) {
if (userName.equals(entry.getKey())) {
this.sendMessage(client, "用户名" + userName + "已被占用");
return;
}
}
USER_PASSWORD_MAP.put(userName, password); //保存新注册的用户信息
this.sendMessage(this.client, "用户" + userName + "注册成功"); //通知客户端注册成功
}
- 登录(login)
登录的时候有以下几点要求:
1. 用户名密码均正确
2. 在线用户、离线用户存储
3. 在线用户不能重复登录,离线用户可以再次登录
思路: 当用户注册并登录成功后,我们可以将其保存在在线用户中,以此来检测是否有在线用户重复登录;当用户离线后,将其保存在离线用户中。由于一个用户名对应一个用户,所以我们可以用Map<String,Socket> ONLINE_USER_MAP保存在线用户,Map<String,Socket> OFFLINE_USER_MAP保存离线用户。如果用户登录成功,则发送“用户登录成功”给该用户,否则发送未登录成功的原因,如用户名或密码不正确,在线用户不能重复登录等。
//给客户端发送信息
private void sendMessage(Socket client, String message) {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
writer.write(message + "\n");
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
//登录
private void login(String userName, String password, Socket client) {
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (userName.equals(entry.getKey())) {
this.sendMessage(client, "用户" + userName + "已在线,不可重复登录");
return;
}
}
for (Map.Entry<String, String> entry : USER_PASSWORD_MAP.entrySet()) {
if (userName.equals(entry.getKey()) && password.equals(entry.getValue())) {
System.out.println("用户" + userName + "加入聊天室");
ONLINE_USER_MAP.put(userName, client); //将登录成功的用户存入 在线用户
printOnlineUser(); //打印在线用户
this.sendMessage(client, "用户" + userName + "登录成功"); //通知客户端登录成功
return;
}
}
this.sendMessage(client, "用户名或密码输入不正确");
return;
}
- 单聊(privateChat)
单聊的时候有以下几点要求:
1. 不能自己给自己发消息
2. 不支持给离线用户发送消息
思路:我们知道单聊是一个用户(currentUserName)给另一个用户(target) 发消息(message),那么为了用户更好的体验,在发送信息时,我们应该将发送者的用户名告知给被发送者。如果被发送者是自己,则不发送信息;如果被发送者已离线,则告知发送者被发送者已离线。
//获取当前用户的用户名
private String getCurrentUserName() {
String currentUserName = null;
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (entry.getValue().equals(this.client)) {
currentUserName = entry.getKey();
break;
}
}
return currentUserName;
}
//给客户端发送信息
private void sendMessage(Socket client, String message) {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
writer.write(message + "\n");
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
//单聊
private void privateChat(String userName, String message) {
String currentUserName = this.getCurrentUserName();
Socket target = ONLINE_USER_MAP.get(userName);
if (currentUserName.equals(userName)) { //不能自己给自己发消息
return;
}
if (target != null) {
this.sendMessage(target, currentUserName + "对你说:" + message);
} else {
this.sendMessage(this.client, "用户" + userName + "已下线,此条消息未发出");
}
}
- 群聊(groupChat)
群聊的时候有以下几点要求:
1. 发送者发出的消息只显示给其他在线用户(不包括发送者本人)
思路:群聊即一个用户发了消息,其他在线用户都能收到消息并做出回应。
//获取当前用户的用户名
private String getCurrentUserName() {
String currentUserName = null;
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (entry.getValue().equals(this.client)) {
currentUserName = entry.getKey();
break;
}
}
return currentUserName;
}
//给客户端发送信息
private void sendMessage(Socket client, String message) {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
writer.write(message + "\n");
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
//群聊
private void groupChat(String message) {
String currentUserName = this.getCurrentUserName();
for (Socket socket : ONLINE_USER_MAP.values()) {
if (socket.equals(this.client)) {
continue;
}
this.sendMessage(socket, currentUserName + "说:" + message);
}
}
- 退出(quit)
思路:因为客户端每输入一句话都会发给服务器,然后服务器根据用户输入的信息作出相应的处理。当用户输入bye时,表明用户要退出,此时用户将关闭客户端,且停止往服务器写入数据功能。当服务器收到用户发送的bye之后,服务器将该用户从在线用户中去除,之后发送bye给用户,通知用户可以退出了。
//获取当前用户的用户名
private String getCurrentUserName() {
String currentUserName = null;
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (entry.getValue().equals(this.client)) {
currentUserName = entry.getKey();
break;
}
}
return currentUserName;
}
//退出
private void quit() {
String currentUserName = this.getCurrentUserName();
System.out.println("用户" + currentUserName + "下线");
Socket socket = ONLINE_USER_MAP.get(currentUserName);
this.sendMessage(socket, "bye");
ONLINE_USER_MAP.remove(currentUserName);
printOnlineUser();
OFFLINE_USER_MAP.put(currentUserName, socket);
printOfflineUser();
}
完整源代码如下:
github地址:https://github.com/huiforeverlin/chat_room
服务器:
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
//服务器主类
public class MultiThreadServer {
public static void main(String[] args) {
final ExecutorService executorService = Executors.newFixedThreadPool(10);//核心线程10个
int port = 6666;//默认端口
try {
if (args.length > 0) { //命令行参数大于0,则将第一个参数作为端口值
port = Integer.parseInt(args[0]);
}
} catch (NumberFormatException e) {
System.out.println("端口参数不正确,将采用默认端口:" + port);
}
try {
ServerSocket serverSocket = new ServerSocket(port); //servereSocket表示服务器
System.out.println("等待客户端连接...");
while (true) {
Socket client = serverSocket.accept(); //支持多客户端连接
System.out.println("客户端连接端口号:" + client.getPort());
executorService.submit(new ExecuteClient(client)); //通过提交任务到线程池来执行每个客户的业务
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.ConcurrentHashMap;
//执行客户业务
public class ExecuteClient implements Runnable {
private final Socket client; //客户
private static final Map<String, Socket> ONLINE_USER_MAP = new ConcurrentHashMap<String, Socket>();//在线用户
private static final Map<String, Socket> OFFLINE_USER_MAP = new ConcurrentHashMap<String, Socket>();//离线用户
private static final Map<String, String> USER_PASSWORD_MAP = new ConcurrentHashMap<>(); //用户信息(用户名,密码)
public ExecuteClient(Socket client) {
this.client = client;
}
//实现run方法
public void run() {
try {
InputStream clientInput = client.getInputStream(); //从客户端读取数据
Scanner scanner = new Scanner(clientInput);
while (true) {
String line = scanner.nextLine();
if (line.startsWith("register")) { //注册
String[] segments = line.split("\\:");
String userName = segments[1]; //第一个参数表示用户名
String password = segments[2]; //第二个参数表示密码
this.register(userName, password, client);
continue;
}
if (line.startsWith("login")) { //登录
String[] segments = line.split("\\:");
String userName = segments[1]; //第一个参数表示用户名
String password = segments[2]; //第二个参数表示密码
this.login(userName, password, client);
continue;
}
if (line.startsWith("private")) { //单聊
String[] segments = line.split("\\:");
String userName = segments[1]; //第一个参数表示被发送者的名称
String message = segments[2]; //第二个参数表示要发送的信息
this.privateChat(userName, message);
continue;
}
if (line.startsWith("group")) { //群聊
String message = line.split("\\:")[1]; //要发送的信息
this.groupChat(message);
continue;
}
if (line.startsWith("bye")) { //退出
this.quit();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
//注册
private void register(String userName, String password, Socket client) {
if (userName == null) {
this.sendMessage(client, "用户名不能为空");
return;
}
if (password == null) {
this.sendMessage(client, "密码不能为空");
return;
}
char[] name = userName.toCharArray(); //字符串转数组
char[] passWord = password.toCharArray(); //字符串转数组
int nLength = name.length; //用户名的长度
int pLength = passWord.length; //密码的长度
if (!((nLength >= 8 && nLength <= 15) && (pLength >= 6 && pLength <= 10))) {
this.sendMessage(client, "输入的用户名或密码长度不符合要求(用户名长度8到15,密码长度6到10)");
return;
}
for (char n : name) {
if (!((n >= 'A' && n <= 'Z') || (n >= 'a' && n <= 'z') || (n >= '0' && n <= '9'))) {
this.sendMessage(client, "用户名仅支持字母、数字");
return;
}
}
for (char p : passWord) {
if (!((p >= 'A' && p <= 'Z') || (p >= 'a' && p <= 'z') || (p >= '0' && p <= '9'))) {
this.sendMessage(client, "密码仅支持字母、数字");
return;
}
}
for (Map.Entry<String, String> entry : USER_PASSWORD_MAP.entrySet()) {
if (userName.equals(entry.getKey())) {
this.sendMessage(client, "用户名" + userName + "已被占用");
return;
}
}
USER_PASSWORD_MAP.put(userName, password); //保存新注册的用户信息
this.sendMessage(this.client, "用户" + userName + "注册成功"); //通知客户端注册成功
}
//登录
private void login(String userName, String password, Socket client) {
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (userName.equals(entry.getKey())) {
this.sendMessage(client, "用户" + userName + "已在线,不可重复登录");
return;
}
}
for (Map.Entry<String, String> entry : USER_PASSWORD_MAP.entrySet()) {
if (userName.equals(entry.getKey()) && password.equals(entry.getValue())) {
System.out.println("用户" + userName + "加入聊天室");
ONLINE_USER_MAP.put(userName, client); //将登录成功的用户存入 在线用户
printOnlineUser(); //打印在线用户
this.sendMessage(client, "用户" + userName + "登录成功"); //通知客户端登录成功
return;
}
}
this.sendMessage(client, "用户名或密码输入不正确");
return;
}
//单聊
private void privateChat(String userName, String message) {
String currentUserName = this.getCurrentUserName();
Socket target = ONLINE_USER_MAP.get(userName);
if (currentUserName.equals(userName)) { //不能自己给自己发消息
return;
}
if (target != null) {
this.sendMessage(target, currentUserName + "对你说:" + message);
} else {
this.sendMessage(this.client, "用户" + userName + "已下线,此条消息未发出");
}
}
//群聊
private void groupChat(String message) {
String currentUserName = this.getCurrentUserName();
for (Socket socket : ONLINE_USER_MAP.values()) {
if (socket.equals(this.client)) {
continue;
}
this.sendMessage(socket, currentUserName + "说:" + message);
}
}
//退出
private void quit() {
String currentUserName = this.getCurrentUserName();
System.out.println("用户" + currentUserName + "下线");
Socket socket = ONLINE_USER_MAP.get(currentUserName);
this.sendMessage(socket, "bye");
ONLINE_USER_MAP.remove(currentUserName);
printOnlineUser();
OFFLINE_USER_MAP.put(currentUserName, socket);
printOfflineUser();
}
//给客户端发送信息
private void sendMessage(Socket client, String message) {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
writer.write(message + "\n");
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
//获取当前用户的用户名
private String getCurrentUserName() {
String currentUserName = null;
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
if (entry.getValue().equals(this.client)) {
currentUserName = entry.getKey();
break;
}
}
return currentUserName;
}
//打印在线用户
private void printOnlineUser() {
System.out.println("在线用户人数:" + ONLINE_USER_MAP.size() + ", 用户列表:");
for (Map.Entry<String, Socket> entry : ONLINE_USER_MAP.entrySet()) {
System.out.print(entry.getKey() + " ");
}
System.out.println("");
}
//打印离线用户
private void printOfflineUser() {
System.out.println("离线用户人数:" + OFFLINE_USER_MAP.size() + ", 离线用户:");
for (Map.Entry<String, Socket> entry : OFFLINE_USER_MAP.entrySet()) {
System.out.print(entry.getKey() + " ");
}
System.out.println("");
}
}
客户端:
import java.io.IOException;
import java.net.Socket;
//客户端主类
public class MultiThreadClient {
public static void main(String[] args) {
try {
String host = "127.0.0.1";
int port = 6666;
try {
if (args.length > 0) {
port = Integer.parseInt(args[0]);
}
} catch (NumberFormatException e) {
System.out.println("端口参数不正确,将采用默认端口:" + port);
}
if (args.length > 1) {
host = args[1];
}
final Socket client = new Socket(host, port);
new WriteDataToServerThread(client).start();
new ReadDataFromServerThread(client).start();
} catch (IOException e) {
e.printStackTrace();
}
}
}
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.util.Scanner;
//从服务器读取数据
public class ReadDataFromServerThread extends Thread {
private final Socket client;
public ReadDataFromServerThread(Socket client) {
this.client = client;
}
@Override
public void run() {
try {
InputStream clientInput = client.getInputStream();
Scanner scanner = new Scanner(clientInput);
while (true) {
String message = scanner.nextLine();
System.out.println("来自服务器的消息:" + message);
if (message.equals("bye")) {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.util.Scanner;
//往服务器写入数据
public class WriteDataToServerThread extends Thread {
private final Socket client;
public WriteDataToServerThread(Socket client) {
this.client = client;
}
@Override
public void run() {
try {
OutputStream clientOutput = client.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(clientOutput);
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("请输入消息:");
String message = scanner.nextLine();
writer.write(message + "\n");
writer.flush();
if (message.equals("bye")) {
client.close(); //客户端关闭退出
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}