网络编程使计算机不再是一个孤立的个体,而是一个互联的整体,通过网络编程,可实现主机各进程间的数据传输。网络编程的核心就是套接字socket。socket是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。

socket主要分为三类:流套接字、数据报套接字、原始套接字。我们这里主要介绍前两种:

流套接字:使用传输层TCP协议,提供面向连接、可靠的数据传输,面向字节流,有接收缓冲区和发送缓冲区,传输数据大小无限制。

数据报套接字:使用传输层UDP协议,提供无连接、不可靠传输,面向数据报,只有接收缓冲区,传输大小受限,一次最多传输64K。

下面我们来看下Java中是如何使用socket来进行网络编程的。

数据报套接字

Java数据报套接字编程有两个核心的类,DatagramSocketDatagramPacket

两个核心类

DatagramSocket用于创建一个数据报套接字,用于发送和接收数据报。主要的构造方法如下:

方法名 说明
DatagramSocket() 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端)
DatagramSocket(int port) 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用 于服务端)

常用方法如下:

方法名 说明
void receive(DatagramPacket p) 从此套接字接收数据报(如果没有接收到数据报,会阻塞等待)
void send(DatagramPacket p) 从此套接字发送数据报包(不会阻塞等待,直接发送)
void close() 关闭此数据报套接字

方法内的参数类型,就是我们要介绍的另一个核心类:DatagramPacket,该类表示数据报包,用于进程间的发送和接收。主要构造方法如下:

方法名 说明
DatagramPacket(byte[] buf, int length) 构造一个数据报包,用来接收,接收的数据保存在字节数组buf中,接收指定长度length
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造一个数据报包,用来发送,发送的数据为字节数组buf,从offset到长度length,指明目标主机地址address,即IP和端口号

常用方法如下:

方法名 说明
InetAddress getAddress() 获取该数据报发送或接收数据报的计算机的IP地址。
getSocketAddress() 获取该数据包发送到或正在从其发送的远程主机的SocketAddress(通常为IP地址+端口号)。
int getPort() 获取该数据报发送或接收数据报的端口号
byte[] getData() 获取数据报中的数据
int getLength() 返回要发送的数据的长度或接收到的数据的长度。

服务器端

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
public class UdpServer {

private static DatagramSocket socket = null;

public UdpServer(int port) throws SocketException {
//创建一个服务器端的数据报套接字,通常指明端口
socket = new DatagramSocket(port);
}

public void start(){
System.out.println("服务器已连接");
try {
while(true){
//1.接收请求
//创建数据报包,用来接收客户端发来的请求
DatagramPacket reqPacket = new DatagramPacket(new byte[1024],1024);
//接收数据报,没有数据传来时会阻塞等待
socket.receive(reqPacket);
//★从接收的数据报中解析数据,构造成请求字符串
String req = new String(reqPacket.getData(),0,reqPacket.getLength(),"utf8");
//2.构造响应
//这里没有做处理,发来什么就返回什么。
String resp = process(req);
//创建数据报包,用来发送响应
//将响应字符串转换成字节数组,指明目的主机地址
DatagramPacket respPacket = new DatagramPacket(resp.getBytes(),resp.getBytes().length,reqPacket.getSocketAddress());
//3.发送响应
socket.send(respPacket);
//4.打印日志
System.out.printf("[%s:%d] req: %s, resp: %s\n",
reqPacket.getAddress().toString(), reqPacket.getPort(), req, resp);
}
} catch (IOException e) {
e.printStackTrace();
}
}

//构造响应
private String process(String req) {
return req;
}

public static void main(String[] args) throws SocketException {
UdpServer server = new UdpServer(9090);
server.start();
}
}

代码中★处要尤为注意:

构造请求字符串的第三个参数:请求字符串长度,是接收到的数据报的长度,reqPacket.getLength()。一定不要写成reqPacket.getData().length(),这个是字节数组的长度,该代码为1024。

客户端

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
public class UdpClient {

private final int port; //服务器端口号
private final String ip; //服务器ip地址
private DatagramSocket socket = null;

public UdpClient(String ip,int port) throws SocketException {
//创建一个客户端的数据报套接字,通常不指明端口
this.socket = new DatagramSocket();
this.ip = ip;
this.port = port;
}
public void start(){

try {
while(true){
//0.输入请求
System.out.println("请输入请求:");
Scanner scanner = new Scanner(System.in);
String req = scanner.nextLine();
//1.构造请求
//创建一个数据报包,用来发送请求★
DatagramPacket reqPacket = new DatagramPacket(req.getBytes(),req.getBytes().length,InetAddress.getByName(ip),port);
//2.发送请求
socket.send(reqPacket);
//创建接收数据包,用来接收服务器发来的响应
DatagramPacket respPacket = new DatagramPacket(new byte[1024],1024);
//3.接收响应
socket.receive(respPacket);
String resp = new String(respPacket.getData(),0,respPacket.getLength());
System.out.println("resp:"+resp);
}
} catch (IOException e) {
e.printStackTrace();
}

}

public static void main(String[] args) throws SocketException {
//参数内为服务器的ip和端口号
UdpClient client = new UdpClient("127.0.0.1",9090);
client.start();
}

代码中★处:

构造发送数据包时要传入目的主机的地址,InetAddress.getByName(ip),port分别为主机的ip地址和端口号,除了这样,还可以通过创建socketAddress类来指定主机地址。

1
2
SocketAddress socketAddress = new InetSocketAddress(ip,port);
DatagramPacket reqPacket = new DatagramPacket(req.getBytes(),req.getBytes().length,socketAddress);

流套接字

Java流套接字编程也有两个核心的类,ServerSocketSocket

两个核心类

ServerSockert通常用于创建一个服务器端的流套接字,常用构造方法如下:

方法名 说明
ServerSocket(int port) 创建一个服务端流套接字Socket,并绑定到指定端口

常用方法如下:

方法名 说明
Socket accept() 监听要连接此客户端的套接字,有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待
void close() 关闭此套接字

Socket通常用于常见一个客户端的流套接字,常用构造方法如下:

方法名 说明
Socket(String host, int port) 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接

常用方法如下:

方法名 说明
InetAddress getInetAddress() 返回套接字所连接的地址
InputStream getInputStream() 返回此套接字的输入流
OutputStream getOutputStream() 返回此套接字的输出流

服务器端

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
public class TcpServer {
ServerSocket server = null;

public TcpServer(int port) throws IOException {
//创建一个服务器套接字,指定服务器端口号
server = new ServerSocket(port);
}

public void start() throws IOException {
while(true){
//监听客户端并建立连接
Socket client = server.accept();
processConnection(client);
}
}

public void processConnection(Socket client) throws IOException {
System.out.printf("[%s,%d]客户端建立连接\n",client.getInetAddress(),client.getPort());
//创建输入输出流,输入流用来接收客户端传来的数据,输出流用来将数据发给客户端
try(InputStream inputStream = client.getInputStream()){
try(OutputStream outputStream = client.getOutputStream()){
while(true){
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()){
System.out.printf("[%s,%d]客户端断开连接",client.getInetAddress(),client.getPort());
break;
}
//1.接收请求
String req = scanner.nextLine();
//2.构造响应
String resp = process(req);
//3.返回响应
PrintWriter printWriter = new PrintWriter(outputStream);
//★调用println方法,不能是print和write
printWriter.println(resp);
printWriter.flush();
System.out.printf("req:%s,resp:%s\n",req,resp);
}
}
}finally {
//★关闭连接
client.close();
}
}

private String process(String req) {
return req;
}

public static void main(String[] args) throws IOException {
TcpServer server = new TcpServer(9090);
server.start();
}
}

★处要尤为注意:

  1. 调用的是println方法,不是print也不是write。println方法会为写入的数据后面添加上换行符,而print和write不会。客户端接收数据时是以换行符为结束来获取数据。如果找不到换行符,将一直不能接收响应,造成阻塞。同理,客户端发送请求时也要使用println方法,否则服务器也会接收不到请求而一直阻塞。

  2. 大家可能会想,为什么这里的client要关闭连接,而server和之前的数据报套接字都不需要关闭连接。关闭连接的前提是不再使用了,对于UDP和ServerSocket,它们是贯穿程序始终的,而这里的client,也就是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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class TcpClient {
private int port;
private String ip;
Socket client = null;

public TcpClient(String ip,int port) throws IOException {
this.port = port;
this.ip = ip;
//★创建客户端套接字,并将其连接到指定IP地址的指定端口号。
this.client = new Socket(ip,port);
}

public void start(){

try(InputStream inputStream = client.getInputStream()) {
try(OutputStream outputStream = client.getOutputStream()){

while(true){
//1.构造请求
Scanner scanner = new Scanner(System.in);
String req = scanner.next();
//2.发送请求
PrintWriter printWriter = new PrintWriter(outputStream);
//★调用println方法,不能是print和write
printWriter.println(req);
printWriter.flush();
//3.接收响应
Scanner respScan = new Scanner(inputStream);
String resp = respScan.next();
System.out.println("resp:"+resp);
}
}
} catch (IOException e) {
e.printStackTrace();
}

}

public static void main(String[] args) throws IOException {
TcpClient client = new TcpClient("127.0.0.1",9090);
client.start();
}
}

问题

流套接字的客户端服务器通信还存在一个问题,只能满足一个客户端的通信。其他客户端尝试与服务器连接时,会发生阻塞。观察服务器代码中下面这段代码:

1
2
3
4
5
6
7
public void start() throws IOException {
while(true){
//监听客户端并建立连接
Socket client = server.accept();
processConnection(client);
}
}

客户端1与服务器建立连接后,服务器线程执行processConnection代码中的操作,代码中存在while循环,不断地接收客户端1发来的请求,构造响应,返回响应……此时客户端2想要与服务器通信,而服务器线程正在processConnection的while循环中等待客户端1的请求,导致服务器不能与客户端2建立连接。

TCP每次都需要建立连接,只能支持一对一,也就是端到端的通信。就像打电话一样,同一时间只能接听一个电话,当正在通话时,别的电话是打不进来的。而UDP是无连接的,因此支持一对一、一对多、多对多交互通信。所以我们在数据报套接字时没有出现该问题。

解决办法也很简单,因为processConnection中的循环未结束导致服务器不能执行accept操作,所以单独创建一个新的线程来完成processConnection操作即可。我们可以用多线程或线程池来解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void start() throws IOException {
while(true){
//建立连接
Socket client = server.accept();
Thread t = new Thread(()->{
try {
processConnection(client);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();

}

其他代码不变,只需改动start方法内的代码,为processConnection操作创建新的线程。