最近在写的Java大作业中需要用到Java Socket通信,本文章在作业实践的基础上,将实践中学习的Socket通信的相关基础知识和遇到的难点进行记录和分析,并且给出解决办法。下面就是正片力!

Socket是个什么东东?

Socket的英文含义为插座,可谓是生动形象了。因为socket就像一个电话插座,负责联通两端的电话,进行点对点通信,此时端口就像插座上面的孔,端口不能同时被其它进程占用。而我们建立连接就像把插头插在插座上,这样在创建一个Socket实例开始监听接口后,这个电话插座就时刻监听消息的传入,谁连接上我的IP地址和端口,我就与其建立联系。

Socket通信基本原理

Socket是基于应用服务与TCP/IP通信之间的一个抽象。Socket将TCP/IP通信协议里面复杂的通信逻辑进行了封装,因此对用户来说,只需要使用Socket提供的方法就可以实现网络的连接与通信。客户端和服务端之间使用Socket通信的流程图如下所示。
Socket通信图.png

首先,服务器端初始化ServerSocket,然后对指定的端口进行监听,通过调用Accept()方法进行阻塞,此时,如果客户端有一个我socket连接到服务端,那么服务端通过监听和Accept()方法可以与客户端进行连接。

Socket通信中最基本的代码示例

通过前两个部分对于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
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
//服务端
public class ServerSocketTest{
public static void main(String[] args) {
try {
// 初始化服务端socket并且绑定9999端口
ServerSocket serverSocket = new ServerSocket(9999);
//等待客户端的连接
Socket socket = serverSocket.accept();
//获取输入流
BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream()));
//读取一行数据
String str = bufferedReader.readLine();
//输出打印
System.out.println(str);
}catch (IOException e) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
Socket socket =new Socket("127.0.0.1",9999);
BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="你好,这是我的第一个socket";
bufferedWriter.write(str);
}catch (IOException e) {
e.printStackTrace();
}
}
}

将代码复制并且运行,首先启动服务端ServerSocket的main函数,发现一切正常,其在等待客户端的连接:

服务端终端输出1.png

然后再启动客户端,发现客户端可以正常启动,执行完之后马上关闭,同时服务端控制台报出以下错误:

服务端终端输出2.png

出现如上报错的原因如下:首先socket通信时阻塞的,其会在以下几个地方进行阻塞:首先是accept()方法,服务端调用此方法后,会一直阻塞在那里,直到有客户端申请连接服务端;第二个是read()方法,调用read()方法也可以进行阻塞。通过以上代码实例可以发现,这个报错的罪魁祸首其实就是服务端的read()方法。首先通过调试功能测试客户端的运行没有问题,客户端确确实实把信息发送出去了。因此问题一定在服务端上面。当服务端调用read()方法后,它就会一直阻塞在那里,直到客户端把数据发送完毕。但是在这个代码示例中客户端并没有给服务端一个标识,使得服务端能够判断客户端消息是否已经发送完毕,所以在客户端关闭之后服务端依旧阻塞在read()方法处等待客户端的数据,因此才会报错java.net.SocketException: Connection reset

当然解决上面的问题方法其实很简单,只需要我们能够在客户端传输消息完毕后给服务端一个标识证明信息传输完成,那么服务端就可以正常工作了。而完成这种功能的方法通常有下面两种:

  • socket.close():关闭socket连接,此时如果服务端给客户端反馈信息的话,由于socket连接已经关闭,此时客户端是收不到的。
  • socket.shutdownOutput():该方法只是关闭输出流,不会关闭socket连接,所以客户端依旧可以收到服务端的反馈信息。

因此只要我们在以上代码中客户端的部分增加一个标识就行了。更新后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
Socket socket =new Socket("127.0.0.1",9999);
BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="你好,这是我的第一个socket";
bufferedWriter.write(str);
// 刷新输入流
bufferedWriter.flush();
//关闭socket的输出流
socket.shutdownOutput();
}catch (IOException e) {
e.printStackTrace();
}
}
}

先后运行服务端和客户端后,客户端正常结束,服务端的终端输出如下所示:

服务端终端输出3.png

通过分析以上案例,相信聪明的你已经基本了解了socket的通信原理了吧,下面就让我们来了解一些socket通信进阶版的写法吧。

Socket通信实现连续接受客户端信息

上面的代码示例中的socket客户端和服务端固然可以实现通信但是每次客户端发送消息后socket都会关闭,这显然和我们平时用到的在线聊天软件的功能差了十万八千里,毕竟没有什么软件需要在每次发送消息前都要登录。那么如何才能让我们的程序中的客户端可以连续给服务器端发送消息呢?答案是通过在服务端和客户端添加while()循环来实现这个功能。

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
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
//服务端
public class ServerSocketTest{
public static void main(String[] args) {
try {
// 初始化服务端socket并且绑定9999端口
ServerSocket serverSocket = new ServerSocket(9999);
//等待客户端的连接
Socket socket = serverSocket.accept();
//获取输入流
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8"));
//读取一行数据
String str;
//通过while循环不断读取打印信息
while((str = bufferedReader.readLine()) != null){
//打印输出信息
System.out.println(str);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.*;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
String str;
Socket socket =new Socket("127.0.0.1",9999);
BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
//通过标准输入流获取字符流
BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(System.in,"UTF-8"));
while(true){
str = bufferedReader.readLine();
bufferedWriter.write(str);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}

先后启动服务端和客户端,在客户端控制台输入信息,即可在服务端控制台查看到相关消息。

  • 客户端控制台输入:

客户端输入1.png

  • 服务端控制台输出:

服务端控制台输出4.png

通过如上两个图,我们可以看到通过while()循环就可以实现客户端不间断地通过标准输入流读取消息,并且发送给服务端。此时你或许会注意到,这次客户端中没有写socket.close()方法或socket.shutdownOutput()方法,那么服务端是怎么样判断客户端已经输入完成了呢?其实程序是通过客户端和用户端双方事先约定好一个标识符,当客户端发送这个标识符给服务端时,就表明客户端已经完成一个数据的传输。而在服务端接收数据的时候,也会通过这个标识符来判断,如果接收到这个标识符,就说明一次数据传输已经完成,那么服务端就可以放手干后面的活儿了。

比如说在上面地实例中,客户端每发送一行数据时,都会在最后增加一个标识符”\n”,告诉服务端数据已经传输完成。然后又通过while((str = bufferedReader.readLine())!=null)去判断是否读到了流的结尾,负责将服务端一直阻塞在这里,等待客户端数据传输完成。

当你看到这儿,恭喜你已经可以通过while()循环来实现多个客户端和服务端进行聊天了,可喜可贺!但是还是存在一些问题,我们之前讨论过socket的通信是阻塞式的,所以就会存在以下问题:假如说存在客户端A和客户端B,它们同时连接到服务端上,当客户端A发送的信息被服务端接收后,那么服务端将会一直被阻塞在客户端A上面,通过循环一直读取A的输入,假如此时用户端B尝试传输数据,就会被加入到阻塞队列中,直到服务端处理完客户端A传输的信息时,服务端才能建立与客户端B的通信。因此我们此时写的程序在事实上只是实现了客户端与服务端一对一的功能,因为只有在客户端A关闭后,客户端B才能和服务端建立连接,这明显不是我们想要的。

多线程下的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
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
//服务端
public class ServerSocketTest{
public static void main(String[] args) throws IOException {
//初始化服务器端并且绑定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
while(true){
//等待客户端连接
Socket socket = serverSocket.accept();
//对于每一个进来的客户端都创建一个线程进行处理
new Thread(new ListenServer(socket)).start();
}
}
}

class ListenServer implements Runnable{
private Socket socket;
public ListenServer(Socket socket){
this.socket = socket;
}

@Override
public void run(){
BufferedReader bufferedReader = null;
try{
bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8"));
//读取一行数据
String str;
//通过循环不断获取客户端传输过来的信息
while((str = bufferedReader.readLine()) != null){
System.out.println("客户端说:"+str);
}
}catch(IOException e) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.*;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try{
// 初始化服务
Socket socket = new Socket("127.0.0.1",9999);
//通过socket获取字符流
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
//通过标准输入获取字符流
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in,"UTF-8"));
while(true){
String str = bufferedReader.readLine();
bufferedWriter.write(str);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}

开启服务端后再开启两个客户端,分别输入一些信息后控制台输出如下所示:

服务端控制台输出5.png

通过这个实例测试我们发现,这时我们的程序中,客户端A和客户端B再同时连接到服务器端后,都可以与服务端进行通信,这样就杜绝了之前出现的A和B不能同时与服务端进行通信的情况。实现这种的功能的主要功臣就是服务端会为每一个请求连接的客户端创建一个单独的线程,与客户端进行数据交互,这样就保证了每个客户端处理数据时都是单独的,不会出现互相阻塞的情况,这样的话我们就实现了多对多的基本聊天功能了,可喜可贺!

但是,这种写法只是对于连接进来的客户端数量较少时才没有问题,假如说要为每一个客户端创建线程,但是此时成百上千的客户端连接到服务端时,我们那可怜的服务端是肯定承受不了的。为了解决这个问题,我们可以通过线程池技术来保证线程的复用,以下是采用线程池改良后的服务端代码:

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
import java.beans.Encoder;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
//服务端
public class ServerSocket{
public static void main(String[] args){
//初始化服务端socket并且绑定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
//创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(100);
while(true){
//等待客户端连接
Socket socket = serverSocket.accept();
executorService.submit(new ListenServer(socket));
}
}
}
class ListenServer implements Runnable{
private Socket socket;
public ListenServer(Socket socket){
this.socket = socket;
}

@Override
public void run(){
BufferedReader bufferedReader =null;
try {
bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
//读取一行数据
String str;
//通过while循环不断读取信息
while((str = bufferedReader.readLine()) != null)
{
System.out.println("客户端说:"+str);
}
}catch (IOException e){
e.printStackTrace();
}
}
}

当然还是存在一些问题,在实际应用中,socket传输的数据往往不是按照一行一行来发送的,所以我们不能够要求每一次发送数据都要加一个”\n”标识符。为了让服务端知道数据是否传输完毕,最常用的方法是数据长度+类型+数据

Socket传输指定长度的数据

由于Socket通信时我们发送的数据常常是不定长的,因此经常会出现如下问题:

半包

接收方没有接收到一个完整的包,只接受了一部分。

原因:TCP为了提高传输效率,将一个包分配得足够大,导致接收方一次没办法接受完。

影响:长连接和短连接中都会出现

粘包

发送方发送的多个包数据到接收方接收时粘成一个包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。

分类:一种是粘在一起得包都是完整的数据包,另一种是粘在一起的包中有不完整得包。

分包

  • 在出现粘包的时候,我们的接收方需要进行分包处理;
  • 一个数据包被分为多次接收;

原因:

  1. IP分片传输导致的问题;
  2. 传输过程中丢失部分包导致出现半包;
  3. 一个包可能被分成两次传输,在取数据时,先取到一部分(这个可能与接收的缓冲区大小有关系)。

影响:粘包和分包在长连接中都会出现。

解决办法

为了解决半包和粘包的问题,就涉及数据发送如何标识结束的问题,通常情况下有以下几个办法:

  1. 固定长度:每次发送固定长度的数据;
  2. 特殊标识:以回车,换行等作为特殊标识;获取特殊标识时,说明包获取完整;
  3. 包头+包长+包体的协议形式,当服务器获取指定的包长时才说明获取完整;

所以大部分情况下,双方使用socket通讯时都会约定一个定长头放在传输数据的最前端,用以标识数据体的长度,通常定长头有整型int,短整型short,字符串Strinng三种形式。其中我采用的是使用JSON格式进行数据传输,从分类上看类似于特殊标识法({}之间为一个包),只要事先导入对应的jar包即可使用。代码与上面的相差不大,在这里就不写了。

Socket的长连接和短连接

在对这部分进行说明时,我们先详细了解一下长连接和短链接的相关概念。

长连接

指在一个连接上面可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发送链路检测包。整个通讯过程中,客户端和服务端只有一个Socket对象,长期保持Socket的连接。适用于操作频繁,点对点的通讯,而且连接数量不会太多的情况。

短连接

短连接服务就是每次请求都要建立连接,客户端与服务端交互完毕后关闭连接。

心跳包

实际上在实际应用中的长连接也不是实际意义上的长连接,它是通过一种叫心跳包(链路检测包)的东西去检查Socket和输入/输出流是否关闭来维系长连接的。

Socket中长连接的实现方法

由于短连接每一次请求都要建立连接,这样的效率实在是太低了,TCP连接涉及三次握手以及四次握手,若每一个消息发送都要进行这些过程,就会非常浪费性能。因此我们选择通过心跳包建立长连接,这样即使服务端因为发生故障,在故障修复后客户端依旧能连接上服务端,相关代码如下:

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
import org.json.simple.JSONObject;

import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class Server {
public static void main(String[] args){

try {
System.out.println("Socket服务器开始运行...");
ServerSocket serverSocket = new ServerSocket(9999);
//创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(100);
while (true){
Socket socket = serverSocket.accept();
executorService.submit( new ListenServer(socket) );
executorService.submit( new ServerSend(socket) );
}
}catch (Exception e){
e.printStackTrace();
}

}
}

class ListenServer implements Runnable{
private Socket socket;

public ListenServer(Socket socket){
this.socket = socket;
}

@Override
public void run() {
try {
ObjectInputStream oin = new ObjectInputStream(socket.getInputStream());
while (true)
System.out.println(oin.readObject());
}catch (Exception e){
e.printStackTrace();
}finally {
try{
socket.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
}

class ServerSend implements Runnable{
private Socket socket;

public ServerSend(Socket socket){
this.socket = socket;
}

@Override
public void run() {
try {
ObjectOutputStream oout = new ObjectOutputStream(socket.getOutputStream());
Scanner scanner = new Scanner(System.in);
while (true){
String string = scanner.nextLine();
JSONObject obj = new JSONObject();
obj.put("type","chat");
obj.put("msg",string);
oout.writeObject(object);
oout.flush();
}
}catch (Exception e){
e.printStackTrace();
}
}
}
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import org.json.simple.JSONObject;

import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
import java.util.Scanner;

public class Client {
private static Socket socket;
public static boolean connection_state = false;

public static void main(String[] args){
if(connect()){
System.out.println("客户端连接成功......");
}else{
reConnect();
}
}

private static void connect(){
try {
socket = new Socket("127.0.0.1", 9999);
connection_state = true;
ObjectOutputStream oin = new ObjectOutputStream(socket.getOutputStream());
ObjectInputStream oout = new ObjectInputStream(socket.getInputStream());
new Thread(new ClientListen(socket,oin)).start();
new Thread(new ClientSend(socket,oout)).start();
new Thread(new ClientHeart(socket,oout)).start();
}catch (Exception e){
e.printStackTrace();
connection_state = false;
}
}

public static void reconnect(){
while (!connection_state){
System.out.println("正在尝试重新链接.....");
connect();
try {
Thread.sleep(3000);
}catch (Exception e){
e.printStackTrace();
}
}
}
}

class ClientListen implements Runnable{
private Socket socket;
private ObjectInputStream oin;

public ClientListen(Socket socket,ObjectInputStream oin){
this.socket = socket;
this.oin = oin;
}

@Override
public void run() {
try {
while (true){
System.out.println(oin.readObject());
}
}catch (Exception e){
e.printStackTrace();
}
}
}

class ClientSend implements Runnable{
private Socket socket;
private ObjectOutputStream oout;

public ClientSend(Socket socket, ObjectOutputStream oout){
this.socket = socket;
this.oout = oout;
}

@Override
public void run() {
try {
Scanner scanner = new Scanner(System.in);
while (true){
String string = scanner.nextLine();
JSONObject obj = new JSONObject();
obj.put("type","chat");
obj.put("msg",string);
oout.writeObject(object);
oout.flush();
}
}catch (Exception e){
e.printStackTrace();
try {
socket.close();
}catch (Exception ex){
ex.printStackTrace();
}
}
}
}

class ClientHeart implements Runnable{
private Socket socket;
private ObjectOutputStream oout;

public Client_heart(Socket socket, ObjectOutputStream oout){
this.socket = socket;
this.oout = oout;
}

@Override
public void run() {
try {
System.out.println("心跳包线程已启动...");
while (true){
Thread.sleep(5000);
JSONObject obj = new JSONObject();
obj.put("type","heart");
obj.put("msg","心跳包");
oout.writeObject(object);
oout.flush();
}
}catch (Exception e){
e.printStackTrace();
try {
socket.close();
Client.connection_state = false;
Client.reconnect();
}catch (Exception e1){
e1.printStackTrace();
}
}
}
}

经过检测后发现客户端能够在服务端重启后重新连接上服务端。

结语

至此我们就真正通过Java编程实现了多个客户端长连接服务端的Socket通信,剩下的就是在这个基础上堆加更多的功能了,比如说用户相关的登录注册功能等,发送语音图片等功能就可以在这基础上完成了。