Java 网络编程 之 socket 的用法与实现

一、概念

TCP

TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793定义。在简化的计算机网络OSI模型中,它完成第四层传输层所指定的功能,用户数据报协议(UDP)是同一层内 另一个重要的传输协议。在因特网协议族(Internet protocol suite)中,TCP层是位于IP层之上,应用层之下的中间层。不同主机的应用层之间经常需要可靠的、像管道一样的连接,但是IP层不提供这样的流机制,而是提供不可靠的包交换。

应用层向TCP层发送用于网间传输的、用8位字节表示的数据流,然后TCP把数据流分区成适当长度的报文段(通常受该计算机连接的网络的数据链路层的最大传输单元( MTU)的限制)。之后TCP把结果包传给IP层,由它来通过网络将包传送给接收端实体的TCP层。TCP为了保证不发生丢包,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收。然后接收端实体对已成功收到的包发回一个相应的确认(ACK);如果发送端实体在合理的往返时延(RTT)内未收到确认,那么对应的数据包就被假设为已丢失将会被进行重传。TCP用一个校验和函数来检验数据是否有错误;在发送和接收时都要计算校验和。

JAVA Socket

所谓socket 通常也称作”套接字“,用于描述IP地址和端口,是一个通信链的句柄。应用程序通常通过”套接字”向网络发出请求或者应答网络请求。

以J2SDK-1.3为例,Socket和ServerSocket类库位于java.net包中。ServerSocket用于服务器端,Socket是建立网络连接时使用的。在连接成功时,应用程序两端都会产生一个Socket实例,操作这个实例,完成所需的会话。对于一个网络连接来说,套接字是平等的,并没有差别,不因为在服务器端或在客户端而产生不同级别。不管是Socket还是ServerSocket它们的工作都是通过SocketImpl类及其子类完成的。

重要的Socket API

java.net.Socket继承于java.lang.Object,有八个构造器,其方法并不多,下面介绍使用最频繁的三个方法,其它方法大家可以见JDK-1.3文档。

Accept方法用于产生”阻塞”,直到接受到一个连接,并且返回一个客户端的Socket对象实例。”阻塞”是一个术语,它使程序运行暂时”停留”在这个地方,直到一个会话产生,然后程序继续;通常”阻塞”是由循环产生的。
getInputStream方法获得网络连接输入,同时返回一个InputStream对象实例。
getOutputStream方法连接的另一端将得到输入,同时返回一个OutputStream对象实例。
注意:其中getInputStream和getOutputStream方法均会产生一个IOException,它必须被捕获,因为它们返回的流对象,通常都会被另一个流对象使用。

二、TCP 编程

服务器端套路

创建ServerSocket对象,绑定监听端口。
通过accept()方法监听客户端请求。
连接建立后,通过输入流读取客户端发送的请求信息。
通过输出流向客户端发送响应信息。
关闭响应的资源。
客户端套路

创建Socket对象,指明需要连接的服务器的地址和端口号。
连接建立后,通过输出流向服务器发送请求信息。
通过输入流获取服务器响应的信息。
关闭相应资源。
多线程实现服务器与多客户端之间通信步骤

服务器端创建ServerSocket,循环调用accept()等待客户端连接。
客户端创建一个socket并请求和服务器端连接。
服务器端接受客户端请求,创建socket与该客户建立专线连接。
建立连接的两个socket在一个单独的线程上对话。
服务器端继续等待新的连接。
三、Socket通信基本示例

1、基础模式

这种基础模式,必须掌握,后期对Socket的优化都是在这个基础上的,也是为以后学习NIO做铺垫。

import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 55533;
ServerSocket server = new ServerSocket(port);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
  //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
  sb.append(new String(bytes, 0, len,"UTF-8"));
}
System.out.println("get message from client: " + sb);
inputStream.close();
socket.close();
server.close();

}
}
服务端监听一个端口,等待连接的到来。

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

public class SocketClient {
public static void main(String args[]) throws Exception {
// 要连接的服务端IP地址和端口
String host = “127.0.0.1”;
int port = 55533;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
OutputStream outputStream = socket.getOutputStream();
String message=“你好 yiwangzhibujian”;
socket.getOutputStream().write(message.getBytes(“UTF-8”));
outputStream.close();
socket.close();
}
}
客户端通过ip和端口,连接到指定的server,然后通过Socket获得输出流,并向其输出内容,服务器会获得消息。最终服务端控制台打印如下:

server将一直等待连接的到来
get message from client: 你好 yiwangzhibujian
通过这个例子应该掌握并了解:

Socket服务端和客户端的基本编程
传输编码统一指定,防止乱码
这个例子做为学习的基本例子,实际开发中会有各种变形,比如客户端在发送完消息后,需要服务端进行处理并返回,如下:

2、双向通信,发送消息并接受消息

这个也是做为Socket编程的基本,应该掌握,例子如下:

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 55533;
ServerSocket server = new ServerSocket(port);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
//只有当客户端关闭它的输出流的时候,服务端才能取得结尾的-1
while ((len = inputStream.read(bytes)) != -1) {
  // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
  sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from client: " + sb);

OutputStream outputStream = socket.getOutputStream();
outputStream.write("Hello Client,I get the message.".getBytes("UTF-8"));

inputStream.close();
outputStream.close();
socket.close();
server.close();

}
}
与之前server的不同在于,当读取完客户端的消息后,打开输出流,将指定消息发送回客户端,客户端程序为:

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

public class SocketClient {
public static void main(String args[]) throws Exception {
// 要连接的服务端IP地址和端口
String host = “127.0.0.1”;
int port = 55533;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
OutputStream outputStream = socket.getOutputStream();
String message = “你好 yiwangzhibujian”;
socket.getOutputStream().write(message.getBytes(“UTF-8”));
//通过shutdownOutput高速服务器已经发送完数据,后续只能接受数据
socket.shutdownOutput();

1
2
3
4
5
6
7
8
9
10
11
12
13
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
  //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
  sb.append(new String(bytes, 0, len,"UTF-8"));
}
System.out.println("get message from server: " + sb);

inputStream.close();
outputStream.close();
socket.close();

}
}
客户端也有相应的变化,在发送完消息时,调用关闭输出流方法,然后打开输出流,等候服务端的消息。

这个模式的使用场景一般用在,客户端发送命令给服务器,然后服务器相应指定的命令,如果只是客户端发送消息给服务器,然后让服务器返回收到消息的消息,这就有点过分了,这就是完全不相信Socket的传输安全性,要知道它的底层可是TCP,如果没有发送到服务器端是会抛异常的,这点完全不用担心。

3、如何告知对方已发送完命令

其实这个问题还是比较重要的,正常来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,那么服务端会一直等待下去,直到读取超时。所以怎么告知服务端已经发送完消息就显得特别重要。

1> 通过Socket关闭

这个是第一章介绍的方式,当Socket关闭的时候,服务端就会收到响应的关闭信号,那么服务端也就知道流已经关闭了,这个时候读取操作完成,就可以继续后续工作。

但是这种方式有一些缺点

客户端Socket关闭后,将不能接受服务端发送的消息,也不能再次发送消息
如果客户端想再次发送消息,需要重现创建Socket连接
2> 通过Socket关闭输出流的方式

这种方式调用的方法是:

socket.shutdownOutput();
而不是(outputStream为发送消息到服务端打开的输出流):

outputStream.close();
如果关闭了输出流,那么相应的Socket也将关闭,和直接关闭Socket一个性质。

调用Socket的shutdownOutput()方法,底层会告知服务端我这边已经写完了,那么服务端收到消息后,就能知道已经读取完消息,如果服务端有要返回给客户的消息那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭Socket。

这种方式通过关闭客户端的输出流,告知服务端已经写完了,虽然可以读到服务端发送的消息,但是还是有一点点缺点:

不能再次发送消息给服务端,如果再次发送,需要重新建立Socket连接。

这个缺点,在访问频率比较高的情况下将是一个需要优化的地方。

3> 通过约定符号

这种方式的用法,就是双方约定一个字符或者一个短语,来当做消息发送完成的标识,通常这么做就需要改造读取方法。

假如约定单端的一行为end,代表发送完成,例如下面的消息,end则代表消息发送完成:

hello yiwangzhibujian
end
那么服务端响应的读取操作需要进行如下改造:

Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
BufferedReader read=new BufferedReader(new InputStreamReader(socket.getInputStream(),“UTF-8”));
String line;
StringBuilder sb = new StringBuilder();
while ((line = read.readLine()) != null && “end”.equals(line)) {
//注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
sb.append(line);
}
可以看见,服务端不仅判断是否读到了流的末尾,还判断了是否读到了约定的末尾。

这么做的优缺点如下:

优点:不需要关闭流,当发送完一条命令(消息)后可以再次发送新的命令(消息)

缺点:需要额外的约定结束标志,太简单的容易出现在要发送的消息中,误被结束,太复杂的不好处理,还占带宽。

经过了这么多的优化还是有缺点,难道就没有完美的解决方案吗,答案是有的,看接下来的内容。

4> 通过指定长度

如果你了解一点class文件的结构(后续会写,敬请期待),那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。

现在首要的问题就是用几个字节指定长度呢,我们可以算一算:

1个字节:最大256,表示256B
2个字节:最大65536,表示64K
3个字节:最大16777216,表示16M
4个字节:最大4294967296,表示4G
依次类推
这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点UTF-8的编码方式(字符编码后续会写,敬请期待),那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:

第一个字节首位为0:即0XXXXXXX,表示长度就一个字节,最大128,表示128B
第一个字节首位为110,那么附带后面一个字节表示长度:即110XXXXX 10XXXXXX,最大2048,表示2K
第一个字节首位为1110,那么附带后面二个字节表示长度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K
依次类推
上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用2个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾。

服务端程序:
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 55533;
ServerSocket server = new ServerSocket(port);

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
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
InputStream inputStream = socket.getInputStream();
byte[] bytes;
// 因为可以复用Socket且能判断长度,所以可以一个Socket用到底
while (true) {
  // 首先读取两个字节表示的长度
  int first = inputStream.read();
  //如果读取的值为-1 说明到了流的末尾,Socket已经被关闭了,此时将不能再去读取
  if(first==-1){
    break;
  }
  int second = inputStream.read();
  int length = (first << 8) + second;
  // 然后构造一个指定长的byte数组
  bytes = new byte[length];
  // 然后读取指定长度的消息即可
  inputStream.read(bytes);
  System.out.println("get message from client: " + new String(bytes, "UTF-8"));
}
inputStream.close();
socket.close();
server.close();

}
}
此处的读取步骤为,先读取两个字节的长度,然后读取消息,客户端为:

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

public class SocketClient {
public static void main(String args[]) throws Exception {
// 要连接的服务端IP地址和端口
String host = “127.0.0.1”;
int port = 55533;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
OutputStream outputStream = socket.getOutputStream();
String message = “你好 yiwangzhibujian”;
//首先需要计算得知消息的长度
byte[] sendBytes = message.getBytes(“UTF-8”);
//然后将消息的长度优先发送出去
outputStream.write(sendBytes.length >>8);
outputStream.write(sendBytes.length);
//然后将消息再次发送出去
outputStream.write(sendBytes);
outputStream.flush();
//==========此处重复发送一次,实际项目中为多个命名,此处只为展示用法
message = “第二条消息”;
sendBytes = message.getBytes(“UTF-8”);
outputStream.write(sendBytes.length >>8);
outputStream.write(sendBytes.length);
outputStream.write(sendBytes);
outputStream.flush();
//==========此处重复发送一次,实际项目中为多个命名,此处只为展示用法
message = “the third message!”;
sendBytes = message.getBytes(“UTF-8”);
outputStream.write(sendBytes.length >>8);
outputStream.write(sendBytes.length);
outputStream.write(sendBytes);

1
2
outputStream.close();
socket.close();

}
}
客户端要多做的是,在发送消息之前先把消息的长度发送过去。

这种事先约定好长度的做法解决了之前提到的种种问题,Redis的Java客户端Jedis就是用这种方式实现的。

当然如果是需要服务器返回结果,那么也依然使用这种方式,服务端也是先发送结果的长度,然后客户端进行读取。当然现在流行的就是,长度+类型+数据模式的传输方式。

四、服务端优化

在上面的例子中,服务端仅仅只是接受了一个Socket请求,并处理了它,然后就结束了,但是在实际开发中,一个Socket服务往往需要服务大量的Socket请求,那么就不能再服务完一个Socket的时候就关闭了,这时候可以采用循环接受请求并处理的逻辑:

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String args[]) throws IOException {
// 监听指定的端口
int port = 55533;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println(“server将一直等待连接的到来”);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while(true){
  Socket socket = server.accept();
  // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
  InputStream inputStream = socket.getInputStream();
  byte[] bytes = new byte[1024];
  int len;
  StringBuilder sb = new StringBuilder();
  while ((len = inputStream.read(bytes)) != -1) {
    // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
    sb.append(new String(bytes, 0, len, "UTF-8"));
  }
  System.out.println("get message from client: " + sb);
  inputStream.close();
  socket.close();
}

}
}
这种一般也是新手写法,但是能够循环处理多个Socket请求,不过当一个请求的处理比较耗时的时候,后面的请求将被阻塞,所以一般都是用多线程的方式来处理Socket,即每有一个Socket请求的时候,就创建一个线程来处理它。

不过在实际生产中,创建的线程会交给线程池来处理,为了:

线程复用,创建线程耗时,回收线程慢。

防止短时间内高并发,指定线程池大小,超过数量将等待,方式短时间创建大量线程导致资源耗尽,服务挂掉。

import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SocketServer {
public static void main(String args[]) throws Exception {
// 监听指定的端口
int port = 55533;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println(“server将一直等待连接的到来”);

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
//如果使用多线程,那就需要线程池,防止并发过高时创建过多线程耗尽资源
ExecutorService threadPool = Executors.newFixedThreadPool(100);

while (true) {
  Socket socket = server.accept();
 
  Runnable runnable=()->{
    try {
      // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
      InputStream inputStream = socket.getInputStream();
      byte[] bytes = new byte[1024];
      int len;
      StringBuilder sb = new StringBuilder();
      while ((len = inputStream.read(bytes)) != -1) {
        // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
        sb.append(new String(bytes, 0, len, "UTF-8"));
      }
      System.out.println("get message from client: " + sb);
      inputStream.close();
      socket.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
  };
  threadPool.submit(runnable);
}

}
}
使用线程池的方式,算是一种成熟的方式。可以应用在生产中。

ServerSocket有以下3个属性。

SO_TIMEOUT:表示等待客户连接的超时时间。一般不设置,会持续等待。
SO_REUSEADDR:表示是否允许重用服务器所绑定的地址。一般不设置,经我的测试没必要,下面会进行详解。
SO_RCVBUF:表示接收数据的缓冲区的大小。一般不设置,用系统默认就可以了。
五、总结

对于同一个socket,如果关闭了输出流比如(pw.close()),则与该输出流关联的socket也会关闭,所以一般不需要关闭输出流,当关闭socket的时候,输出流也会关闭,直接关闭socket就行。

在使用TCP通信传输信息时,更多是使用对象的形式来传输,可以使用ObjectOutputStream对象序列化流来传递对象,比如ObjectOutputStream os = new ObjectOutputStream(socket.getOutputStream());User user = new User(“admin”,“123”); os.writeObject(user);