网络编程使计算机不再是一个孤立的个体,而是一个互联的整体,通过网络编程,可实现主机各进程间的数据传输。网络编程的核心就是套接字socket。socket是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。
socket主要分为三类:流套接字、数据报套接字、原始套接字。我们这里主要介绍前两种:
流套接字 :使用传输层TCP协议,提供面向连接、可靠的数据传输,面向字节流,有接收缓冲区和发送缓冲区,传输数据大小无限制。
数据报套接字 :使用传输层UDP协议,提供无连接、不可靠传输,面向数据报,只有接收缓冲区,传输大小受限,一次最多传输64K。
下面我们来看下Java中是如何使用socket来进行网络编程的。
数据报套接字
Java数据报套接字编程有两个核心的类,DatagramSocket
和DatagramPacket
。
两个核心类
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 ){ DatagramPacket reqPacket = new DatagramPacket(new byte [1024 ],1024 ); socket.receive(reqPacket); String req = new String(reqPacket.getData(),0 ,reqPacket.getLength(),"utf8" ); String resp = process(req); DatagramPacket respPacket = new DatagramPacket(resp.getBytes(),resp.getBytes().length,reqPacket.getSocketAddress()); socket.send(respPacket); 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; 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 ){ System.out.println("请输入请求:" ); Scanner scanner = new Scanner(System.in); String req = scanner.nextLine(); DatagramPacket reqPacket = new DatagramPacket(req.getBytes(),req.getBytes().length,InetAddress.getByName(ip),port); socket.send(reqPacket); DatagramPacket respPacket = new DatagramPacket(new byte [1024 ],1024 ); 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 { 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流套接字编程也有两个核心的类,ServerSocket
和Socket
。
两个核心类
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 ; } String req = scanner.nextLine(); String resp = process(req); PrintWriter printWriter = new PrintWriter(outputStream); 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(); } }
★处要尤为注意:
调用的是println方法,不是print也不是write。println方法会为写入的数据后面添加上换行符,而print和write不会。客户端接收数据时是以换行符为结束来获取数据。如果找不到换行符,将一直不能接收响应,造成阻塞。同理,客户端发送请求时也要使用println方法,否则服务器也会接收不到请求而一直阻塞。
大家可能会想,为什么这里的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; this .client = new Socket(ip,port); } public void start () { try (InputStream inputStream = client.getInputStream()) { try (OutputStream outputStream = client.getOutputStream()){ while (true ){ Scanner scanner = new Scanner(System.in); String req = scanner.next(); PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(req); printWriter.flush(); 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操作创建新的线程。