17.3.5 记录用户信息

上面程序虽然已经完成了粗略的通信功能,每个客户端可以看到其他客户端发送的信息,但无法知道是哪个客户端发送的信息,这是因为服务器端从未记录过用户信息,当客户端使用Socket连接到服务器端之后,程序只是使用socketlist集合保存了服务器端对应生成的Socket,并没有保存该Socket关联的客户信息。

下面程序将考虑使用Map来保存用户状态信息,因为本程序将考虑实现私聊功能,也就是说,一个客户端可以将信息发送给另一个指定客户端。实际上,所有客户端只与服务器端连接,客户端之间并没有互相连接,也就是说,当一个客户端信息发送到服务器端之后,服务器端必须可以判断该信息到底是向所有用户发送,还是向指定用户发送,并需要知道向哪个用户发送。这里需要解决如下两个问题。

  • 客户端发送来的信息必须有特殊的标识——让服务器端可以判断是公聊信息,还是私聊信息
  • 如果是私聊信息,客户端会发送该消息的目的用户(私聊对象)给服务器端,服务器端如何将该信息发送给该私聊对象。

协议

为了解决第一个问题,可以让客户端在发送不同信息之前,先对这些信息进行适当处理,比如在内容前后添加一些特殊字符——这种特殊字符被称为协议字符。本例提供了一个CrazyitProtocol接口,该接口专门用于定义协议字符

协议接口CrazyitProtocol

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface CrazyitProtocol
{
// 定义协议字符串的长度
int PROTOCOL_LEN = 2;
// 下面是一些协议字符串,服务器和客户端交换的信息
// 都应该在前、后添加这种特殊字符串。
String MSG_ROUND = "§γ";
String USER_ROUND = "∏∑";
String LOGIN_SUCCESS = "1";
String NAME_REP = "-1";
String PRIVATE_ROUND = "★【";
String SPLIT_SIGN = "※";
}

实际上,由于服务器端和客户端都需要使用这些协议字符串,所以程序需要在客户端和服务器端同时保留该接口对应的.class文件

用户的Socket的映射

为了解决第二个问题,可以考虑使用一个Map来保存聊天室所有用户和对应Socket之间的映射关系,这样服务器端就可以根据用户名来找到对应的Socket。但实际上本程序并未这么做,程序仅仅是Map保存了聊天室所有用户名和对应输出流之间的映射关系,因为服务器端只要获取该用户名对应的输岀流即可。服务器端提供了一个HashMap的子类,该类不允许value重复,并提供了根据value获取key,根据value删除key等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.util.*;

// 通过组合HashMap对象来实现CrazyitMap,CrazyitMap要求value也不可重复
public class CrazyitMap<K, V> {
// 创建一个线程安全的HashMap
public Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>());

// 根据value来删除指定项
public synchronized void removeByValue(Object value) {
for (Object key : map.keySet()) {
if (map.get(key) == value) {
map.remove(key);
break;
}
}
}

// 获取所有value组成的Set集合
public synchronized Set<V> valueSet() {
Set<V> result = new HashSet<V>();
// 将map中所有value添加到result集合中
map.forEach((key, value) -> result.add(value));
return result;
}

// 根据value查找key。
public synchronized K getKeyByValue(V val) {
// 遍历所有key组成的集合
for (K key : map.keySet()) {
// 如果指定key对应的value与被搜索的value相同,则返回对应的key
if (map.get(key) == val || map.get(key).equals(val)) {
return key;
}
}
return null;
}

// 实现put()方法,该方法不允许value重复
public synchronized V put(K key, V value) {
// 遍历所有value组成的集合
for (V val : valueSet()) {
// 如果某个value与试图放入集合的value相同
// 则抛出一个RuntimeException异常
if (val.equals(value) && val.hashCode() == value.hashCode()) {
throw new RuntimeException("MyMap实例中不允许有重复value!");
}
}
return map.put(key, value);
}
}

严格来讲,CrazyitMap已经不是一个标准的Map结构了,但程序需要这样一个数据结构来保存用户名和对应输岀流之间的映射关系,这样既可以通过用户名找到对应的输出流,也可以根据输出流找到对应的用户名。

服务器端的主类

服务器端的主类一样只是建立ServerSocket来监听来自客户端Socket的连接请求,但该程序增加了一些异常处理,可能看上去比上一节的程序稍微复杂一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.net.*;
import java.io.*;

public class Server {
private static final int SERVER_PORT = 30000;
// 使用CrazyitMap对象来保存每个客户名字和对应输出流之间的对应关系。
public static CrazyitMap<String, PrintStream> clients = new CrazyitMap<>();

public void init() {
try (
// 建立监听的ServerSocket
ServerSocket ss = new ServerSocket(SERVER_PORT)) {
// 采用死循环来不断接受来自客户端的请求
while (true) {
Socket socket = ss.accept();
new ServerThread(socket).start();
}
}
// 如果抛出异常
catch (IOException ex) {
System.out.println("服务器启动失败,是否端口" + SERVER_PORT + "已被占用?");
}
}

public static void main(String[] args) {
Server server = new Server();
server.init();
}
}

该程序的关键代码只有三行:

1
2
3
ServerSocket ss = new ServerSocket(SERVER_PORT)
Socket socket = ss.accept();
new ServerThread(socket).start();

它们依然是完成建立ServerSocket,监听客户端Socket连接请求,并为已连接的Socket启动单独的线程。

服务器端线程类

服务器端线程类比上一节的程序要复杂一点,因为该线程类要分别处理公聊、私聊两类聊天信息除此之外,还需要处理用户名是否重复的问题。服务器端线程类的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import java.net.*;
import java.io.*;

public class ServerThread extends Thread {
private Socket socket;
BufferedReader br = null;
PrintStream ps = null;

// 定义一个构造器,用于接收一个Socket来创建ServerThread线程
public ServerThread(Socket socket) {
this.socket = socket;
}

public void run() {
try {
// 获取该Socket对应的输入流
br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 获取该Socket对应的输出流
ps = new PrintStream(socket.getOutputStream());
String line = null;
while ((line = br.readLine()) != null) {
// 如果读到的行以CrazyitProtocol.USER_ROUND开始,并以其结束,
// 可以确定读到的是用户登录的用户名
if (line.startsWith(CrazyitProtocol.USER_ROUND) && line.endsWith(CrazyitProtocol.USER_ROUND)) {
// 得到真实消息
String userName = getRealMsg(line);
// 如果用户名重复
if (Server.clients.map.containsKey(userName)) {
System.out.println("重复");
ps.println(CrazyitProtocol.NAME_REP);
} else {
System.out.println("成功");
ps.println(CrazyitProtocol.LOGIN_SUCCESS);
Server.clients.put(userName, ps);
}
}
// 如果读到的行以CrazyitProtocol.PRIVATE_ROUND开始,并以其结束,
// 可以确定是私聊信息,私聊信息只向特定的输出流发送
else if (line.startsWith(CrazyitProtocol.PRIVATE_ROUND)
&& line.endsWith(CrazyitProtocol.PRIVATE_ROUND)) {
// 得到真实消息
String userAndMsg = getRealMsg(line);
// 以SPLIT_SIGN分割字符串,前半是私聊用户,后半是聊天信息
String user = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[0];
String msg = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[1];
// 获取私聊用户对应的输出流,并发送私聊信息
Server.clients.map.get(user).println(Server.clients.getKeyByValue(ps) + "悄悄地对你说:" + msg);
}
// 公聊要向每个Socket发送
else {
// 得到真实消息
String msg = getRealMsg(line);
// 遍历clients中的每个输出流
for (PrintStream clientPs : Server.clients.valueSet()) {
clientPs.println(Server.clients.getKeyByValue(ps) + "说:" + msg);
}
}
}
}
// 捕捉到异常后,表明该Socket对应的客户端已经出现了问题
// 所以程序将其对应的输出流从Map中删除
catch (IOException e) {
Server.clients.removeByValue(ps);
System.out.println(Server.clients.map.size());
// 关闭网络、IO资源
try {
if (br != null) {
br.close();
}
if (ps != null) {
ps.close();
}
if (socket != null) {
socket.close();
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}

// 将读到的内容去掉前后的协议字符,恢复成真实数据
private String getRealMsg(String line) {
return line.substring(CrazyitProtocol.PROTOCOL_LEN, line.length() - CrazyitProtocol.PROTOCOL_LEN);
}
}

上面程序比前一节的程序除增加了异常处理之外,主要增加了对读取数据的判断。程序读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串对该内容进行相应的处理。

客户端

客户端主类

客户端主类增加了让用户输入用户名的代码,并且不允许用户名重复。除此之外,还可以根据用户的键盘输入来判断用户是否想发送私聊信息。客户端主类的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import java.net.*;
import java.io.*;
import javax.swing.*;

public class Client {
private static final int SERVER_PORT = 30000;
private Socket socket;
private PrintStream ps;
private BufferedReader brServer;
private BufferedReader keyIn;

public void init() {
try {
// 初始化代表键盘的输入流
keyIn = new BufferedReader(new InputStreamReader(System.in));
// 连接到服务器
socket = new Socket("127.0.0.1", SERVER_PORT);
// 获取该Socket对应的输入流和输出流
ps = new PrintStream(socket.getOutputStream());
brServer = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String tip = "";
// 采用循环不断地弹出对话框要求输入用户名
while (true) {
String userName = JOptionPane.showInputDialog(tip + "输入用户名"); // ①
// 将用户输入的用户名的前后增加协议字符串后发送
ps.println(CrazyitProtocol.USER_ROUND + userName + CrazyitProtocol.USER_ROUND);
// 读取服务器的响应
String result = brServer.readLine();
// 如果用户重复,开始下次循环
if (result.equals(CrazyitProtocol.NAME_REP)) {
tip = "用户名重复!请重新";
continue;
}
// 如果服务器返回登录成功,结束循环
if (result.equals(CrazyitProtocol.LOGIN_SUCCESS)) {
break;
}
}
}
// 捕捉到异常,关闭网络资源,并退出该程序
catch (UnknownHostException ex) {
System.out.println("找不到远程服务器,请确定服务器已经启动!");
closeRs();
System.exit(1);
} catch (IOException ex) {
System.out.println("网络异常!请重新登录!");
closeRs();
System.exit(1);
}
// 以该Socket对应的输入流启动ClientThread线程
new ClientThread(brServer).start();
}

// 定义一个读取键盘输出,并向网络发送的方法
private void readAndSend() {
try {
// 不断读取键盘输入
String line = null;
while ((line = keyIn.readLine()) != null) {
// 如果发送的信息中有冒号,且以//开头,则认为想发送私聊信息
if (line.indexOf(":") > 0 && line.startsWith("//")) {
line = line.substring(2);
ps.println(CrazyitProtocol.PRIVATE_ROUND + line.split(":")[0] + CrazyitProtocol.SPLIT_SIGN
+ line.split(":")[1] + CrazyitProtocol.PRIVATE_ROUND);
} else {
ps.println(CrazyitProtocol.MSG_ROUND + line + CrazyitProtocol.MSG_ROUND);
}
}
}
// 捕捉到异常,关闭网络资源,并退出该程序
catch (IOException ex) {
System.out.println("网络通信异常!请重新登录!");
closeRs();
System.exit(1);
}
}

// 关闭Socket、输入流、输出流的方法
private void closeRs() {
try {
if (keyIn != null) {
ps.close();
}
if (brServer != null) {
ps.close();
}
if (ps != null) {
ps.close();
}
if (socket != null) {
keyIn.close();
}
} catch (IOException ex) {
ex.printStackTrace();
}
}

public static void main(String[] args) {
Client client = new Client();
client.init();
client.readAndSend();
}
}

上面程序使用JOptionPane弹出一个输入对话框让用户输入用户名,如程序init()方法中的①号粗体字代码所示。然后程序立即将用户输入的用户名发送给服务器端,服务器端会返回该用户名是否重复的提示,程序又立即读取服务器端提示,并根据服务器端提示判断是否需要继续让用户输入用户名。
与前一节的客户端主类程序相比,该程序还增加了对用户输入信息的判断——程序判断用户输入的内容是否以斜线()开头,并包含冒号(:),如果满足该特征,系统认为该用户想发送私聊信息,就会将冒号(:)之前的部分当成私聊用户名,冒号(:)之后的部分当成聊天信息,如readAndSend()方法中粗体字代码所示。

客户端线程类

本程序中客户端线程类几乎没有太大的改变,仅仅添加了异常处理部分的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.io.*;

public class ClientThread extends Thread
{
// 该客户端线程负责处理的输入流
BufferedReader br = null;
// 使用一个网络输入流来创建客户端线程
public ClientThread(BufferedReader br)
{
this.br = br;
}
public void run()
{
try
{
String line = null;
// 不断从输入流中读取数据,并将这些数据打印输出
while((line = br.readLine())!= null)
{
System.out.println(line);
/*
本例仅打印了从服务器端读到的内容。实际上,此处的情况可以更复杂:
如果希望客户端能看到聊天室的用户列表,则可以让服务器在
每次有用户登录、用户退出时,将所有用户列表信息都向客户端发送一遍。
为了区分服务器发送的是聊天信息,还是用户列表,服务器也应该
在要发送的信息前、后都添加一定的协议字符串,客户端此处则根据协议
字符串的不同而进行不同的处理!
更复杂的情况:
如果两端进行游戏,则还有可能发送游戏信息,例如两端进行五子棋游戏,
则还需要发送下棋坐标信息等,服务器同样在这些下棋坐标信息前、后
添加协议字符串后再发送,客户端就可以根据该信息知道对手的下棋坐标。
*/
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
// 使用finally块来关闭该线程对应的输入流
finally
{
try
{
if (br != null)
{
br.close();
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
}

虽然上面程序非常简单,但正如程序注释中所指出的,如果服务器端可以返回更多丰富类型的数据,则该线程类的处理将会更复杂,那么该程序可以扩展到非常强大。
先运行上面的Server类,启动服务器;再多次运行Client类启动多个客户端,并输入不同的用户名,登录服务器后的聊天界面如图17.5所示。

本程序没有提供GUI界面部分,直接使用DOS窗口进行聊天——因为增加GUI界面会让程序代码更多,从而引起读者的畏难心理。如果读者理解了本程序之后,相信读者一定乐意为该程序添加界面部分,因为整个程序的所有核心功能都已经实现了。不仅如此,读者完全可以在本程序的基础上扩展成一个仿QQ游戏大厅的网络程序。