什么是网络编程
网络编程,指的是网络上的主机,通过不同的进程,以编程的方式实现网络通信(或成为网络数据传输)。
发送端和接收端
在一次网络数据传输时:
发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
接收端:数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。
请求和响应
一般来说,获取一个网络资源,涉及到两次网络数据传输:
第一次:请求数据的发送
第二次:相应数据的发送
客户端和服务端
服务端:在常见的网络数据传输场景下,把提供服务的一方进程,称为服务端,可以提供对外服务。
客户端:获取服务的一方进程,称为客户端。
常见的客户端服务器模型:
1. 客户端先发送请求到服务端
2. 服务端根据请求数据,执行相应的业务处理
3. 服务端返回响应:发送业务处理结果
4. 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)
Socket套接字
网络编程的核心,是Socket API,这是一个由操作系统给应用程序提供的网络编程API。
并且我们认为Socket API是和传输层密切相关的。
Socket套接字主要针对传输层协议分为以下几类:
流套接字:使用传输层TCP协议
数据报套接字:使用传输层UDP协议
UDP | 无连接 | 不可靠传输 | 面向数据报 | 全双工 |
TCP | 有连接 | 可靠传输 | 面向字节流 | 全双工 |
无连接、有连接:
打电话就是有连接的,需要连接建立了才能通信。连接建立需要对方来接收,如果连接没有建立好,就通信不了。
发短信、发微信就是无连接的。
不可靠传输、可靠传输:
网络环境天然就是复杂的,不可能保证传输的数据100%能够到达。发送方能知道自己的消息是发送过去了还是丢了,就是可靠\不可靠传输。
面向字节流、面向数据报:
数据传输就和文件读写类似,“流式”的,就叫面向字节流
数据传输以一个个的“数据报”(可能是若干字节,带有一定格式的)为基本单位,就叫面向数据报。
全双工、半双工:
一个通信通道,可以双向传输,既可以发送也可以接收就叫做全双工。
只能单向传输的就叫做半双工。
UDP
Java中使用UDP协议通信,主要基于DatagramSocket类来创建数据报套接字,并使用DatagramPacket作为发送或接收的UDP数据报,对于一次发送及接收UDP数据报的流程如下:
以上只是一次发送端的UDP数据报发送,及接收端的数据报接收,并没有返回的数据。也就只有请求,没有响应。对于一个服务器来说,重要的是提供多个客户端的请求处理及响应,流程如下:
TCP
Socket编程
首先先了解一些注意事项:
1.客户端和服务器:开发时,一般是基于一个主机开启两个进程作为客户端和服务器,但真实的场景一般都是不同主机。
2.注意目的IP和目的端口号,标识了一次数据传输时要发送数据的终点主机和进程。
3.Socket编程我们是使用流套接字和数据报套接字,基于传输层的TCP或UDP协议,但应用层协议, 也需要考虑,这块我们在后续来说明如何设计应用层协议。
4.关于端口被占用的问题:
如果一个进程A已经绑定了一个端口,再启动一个进程B绑定该端口,就会报错,这就叫端口被占用。对于Java进程来说,端口被占用的常见报错信息如下:
在cmd输入:netstat -ano | findstr 端口号 就可以显示对应进程的pid,然后在任务管理器中通过pid查找进程。
解决方法:
如果占用端口的进程A不需要运行,就可以关闭A后,再启动需要绑定该端口的进程B。
如果需要运行A进程,则可以修改进程B的绑定端口,换为其他没有使用的端口。
UDP数据报套接字编程
DatagramSocket API
DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。
在操作系统中,把这个socket对象也当成是一个文件来处理的,相当于是文件描述符表上的一项。只不过普通文件对应的设备是硬盘,而socket文件对应的设备是网卡。
DatagramSocket构造方法:
DatagramSocket 方法:
DatagramPacket API
DatagramPacket是UDP Socket发送和接收的数据报。
DatagramPacket 构造方法:
DatagramPacket 方法:
UDP客户端服务器程序
服务器代码
普通的服务器:收到请求,根据请求计算响应,返回响应。
而echo server(回显服务器)省略了其中的根据请求计算响应,请求是啥,就返回啥。
先来看一遍完整代码:
public class UdpServer {
private DatagramSocket socket = null;
public UdpServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
while(true) {
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
String response = process(request);
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());
socket.send(responsePacket);
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
UdpServer udpServer = new UdpServer(1000);
udpServer.start();
}
}
我们一点一点来解析:
private DatagramSocket socket = null; public UdpServer(int port) throws SocketException { socket = new DatagramSocket(port); }
在操作系统内核中, 使用了一种特殊的叫做 "socket" 这样的文件来抽象表示网卡,因此进行网络通信, 势必需要先有一个 socket 对象。
同时对于服务器来说, 创建 socket 对象的同时, 要让他绑定上一个具体的端口号,如果是操作系统随机分配的端口, 此时客户端就不知道这个端口是啥了, 也就无法进行通信了。
public void start() throws IOException { while(true) { DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); socket.receive(requestPacket); }
对于UDP来说,传输数据的基本单位是DatagramPacket,并且用一个while循环来表示循环接收请求,用DatagramPacket来表示接收到的,然后再用receive把这个数据报给网卡接收到。
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
此时的DatagramPacket是一个特殊的对象,并不方便直接进行处理,可以把这里包含的数据拿出来,通过构造字符串的方式来存到request里面去。
之前给的最大长度是4096,但是这里的空间不一定用满了,可能只用了一小部分,因此就通过getLength获取到实际的数据报长度,只把这个实际的有效部分给构造成字符串即可。
String response = process(request); public String process(String request){ return request; }
紧接着我们用一个process方法来表示服务器的响应。实际开发中这个部分是最重要的,服务器的响应是整个网络编程最核心的部分之一。
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());
获取到客户端的ip和端口号(这两个信息本身就在requestpacket中)
socket.send(responsePacket);
通过send方法把responsePacket方法里面的信息传出去。
主要的工作流程:
1.读取请求并解析
2.根据请求计算相应
3.构造响应并且写回客户端
客户端代码
一次通信,需要有两个ip,两个端口,客户端的ip是127.0.0.1,客户端的端口是系统自动分配的,服务器ip和端口需要告诉客户端,才能顺利把消息发给服务器。
先来看完整代码:
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP = null;
private int serverPort = 0;
public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
socket = new DatagramSocket();
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while(true){
System.out.println(">");
String request = scanner.next();
if(request.equals("exit")){
System.out.println("退出");
break;
}
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIP),serverPort);
socket.send(requestPacket);
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient Client = new UdpEchoClient("127.0.0.1",9090);
Client.start();
}
}
private DatagramSocket socket = null; private String serverIP = null; private int serverPort = 0; public UdpEchoClient(String serverIP, int serverPort) throws SocketException { socket = new DatagramSocket(); this.serverIP = serverIP; this.serverPort = serverPort; }
通过socket,IP和端口我们才能和服务器端连接起来。
public void start() throws IOException { System.out.println("客户端启动"); Scanner scanner = new Scanner(System.in); while(true){ System.out.println(">"); String request = scanner.next(); if(request.equals("exit")){ System.out.println("退出"); break; } DatagramPacket requestPacket = new DatagramPacket( request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIP), serverPort); socket.send(requestPacket);
在客户端中,需要用户自己输入,获取到用户的request后,需要打包成requestPacket然后通过socket.send发送给服务器。
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(),0,responsePacket.getLength()); System.out.println(response);
完成上一步后,等待服务器的响应,到客户端这边用receive接收,类型为responsePacket。最后再转换成String类型的response打印出来。
客户端发送给服务器后,就进入阻塞等待,这里的receive能阻塞,是因为操作系统原生提供的API就是阻塞的函数,这里的阻塞不是Java实现的,而是系统内核里实现的。
同时最后的main函数中,应该指定好ip和端口号,以便客户端能访问到服务器端。
同时也可以打开这个选项,同时开启多个客户端,共用一个服务器。
端口占用
针对上述的程序,来看看端口冲突是什么效果,一个端口只能被一个进程使用,如果有多个就不行。
TCP流套接字编程
TCP和UDP的差别还是有不少的,比如一个有连接一个无连接,一个是可以直接发送,一个需要数据报打包发送。
ServerSocket API
ServerSocket 是创建TCP服务端Socket的API。
构造方法:
方法:
Socket API
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。 不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。
构造方法:
方法:
TCP客户端服务器程序
服务器代码
public class TcpEchoServer {
private ServerSocket serverSocket = null;
private TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
while(true){
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
public void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线 \n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
while(true){
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()){
System.out.printf("[%s:%d]客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next();
String response = process(request);
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println((response));
printWriter.flush();
System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response);
}
}catch(IOException e){
e.printStackTrace();
}finally{
try{
clientSocket.close();
}catch (IOException e){
e.printStackTrace();
}
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
private ServerSocket serverSocket = null; private TcpEchoServer(int port) throws IOException { serverSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("启动服务器"); while(true){ Socket clientSocket = serverSocket.accept(); processConnection(clientSocket); } }
在这里有serverSocket和clientSocket,这两个socket是不同的,serverSocket接收端口号和Ip地址,然后通过clientSocket和客户端连接。因为需要连接上后才能发送消息,所以每用到一个clientSocket就会有一个客户端连接上来,都会返回/创建一个Socket对象,Socket就是文件,每次创建一个clientSocket对象,就要占用一个文件描述符表的位置。
因此这里的socket需要释放。前面的socket都没有释放,一方面这些socket生面周期更长,另一方面这些socket也不多。但是此处的clientSocket数量多,每个客户端都有一个,生命周期也更短。
accept如果没有连接到客户端,就会一直阻塞。
要注意,TCP server一次性只能处理一个客户端
public void processConnection(Socket clientSocket) throws IOException { System.out.printf("[%s:%d] 客户端上线 \n",clientSocket.getInetAddress().toString(),clientSocket.getPort()); try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()){
通过clientSocket进行processConnection进行了具体的连接以后,通过try with resources来完成InputStream和outputStream来完成字节流的传输。
while(true){ Scanner scanner = new Scanner(inputStream); if(!scanner.hasNext()){ System.out.printf("[%s:%d]客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); break; } String request = scanner.next(); String response = process(request); PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println((response)); printWriter.flush(); System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); } }catch(IOException e){ e.printStackTrace(); }finally{ try{ clientSocket.close(); }catch (IOException e){ e.printStackTrace(); } } }
通过InputStream接收到服务器端的数据后,再通过scanner写入到request,request传入到process方法返回服务器相应的数据。接下来应该用outputStream来写入服务器返回的数据,但是outputStream中并没有write String这样的功能,所以此处用println来写入。
并且println中会在发送的数据后面自动带上\n换行,TCP协议是面向字节流的协议,但是接收方如何知道这一次一共需要读多少字节呢?这就需要我们再数据传输中进行明确的规定:
此处代码中,隐式约定使用了\n来作为当前代码的请求、相应分割约定。
所以这里的println也可以当做是服务器发送给客户端的发送行为。
客户端代码
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
socket = new Socket(serverIp,serverPort);
}
public void start(){
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while(true){
System.out.println(">");
String request = scanner.next();
if(request.equals("exit")){
System.out.println("bye");
break;
}
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();
Scanner responseScanner = new Scanner(inputStream);
String response = responseScanner.next();
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIp,int serverPort) throws IOException { socket = new Socket(serverIp,serverPort); }
通过socket来接收服务器的ip和端口号。
public void start(){ System.out.println("客户端启动"); Scanner scanner = new Scanner(System.in); try(InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()){
和服务器不同的是,客户端方需要读取用户自己输入的数据,所以通过System.in来接收用户输入的,但是最终是需要用到流式传输中,所以需要用try with resources来包含InputStream和outputStream。
try(InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()){ while(true){ System.out.println(">"); String request = scanner.next(); if(request.equals("exit")){ System.out.println("bye"); break; } PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(request); printWriter.flush(); Scanner responseScanner = new Scanner(inputStream); String response = responseScanner.next(); System.out.println(response); } } catch (IOException e) { e.printStackTrace(); } }
通过request接收到用户输入的数据后,用PrintWriter来写入,再通过println来发送。并且以防万一,我们用flush来刷新缓冲区避免数据传输失败。
等待服务器返回消息后,用responseScanner来接收InputStream传输的数据,再打印出来。
多线程、线程池连接
当前咱们的服务器同一时刻只能给一个客户端提供服务,这是不科学的。当前启动服务器后,先后启动两个客户端,客户端1可以看到正常的上线提示,但是客户端2没有任何提醒。当结束客户端1后,客户端2马上显示上线。
当客户端连接上服务器之后,代码执行到processConnection这个方法中的while循环了,此时意味着,只要这个循环不结束,processConnection方法就结束不了。进一步的也就无法调用到第二次的accept。
解决办法就是:使用多线程
public void start() throws IOException {
System.out.println("启动服务器");
while(true){
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() ->{
processConnection(clientSocket);
});
t.start();
}
}
其实修改的部分很小,只要在启动连接的时候,作为一个单独的线程启动就大功告成。
但是呢,这里的多线程版本的程序,最大的问题就是可能会涉及到频繁申请释放线程,当客户端数量足够多,也会造成很大的资源消耗。
所以解决办法就是:使用线程池
public void start() throws IOException {
System.out.println("启动服务器");
ExecutorService threadPool = Executors.newCachedThreadPool();
while(true) {
Socket clientSocket = serverSocket.accept();
threadPool.submit(()->{
processConnection(clientSocket);
});
}
}
通过线程池的方法,就能进一步减少消耗。
但是呢,如果客户端都在响应,就算使用了线程池了但是还是不够,而且如果客户端非常多,客户端连接迟迟不断开,就会导致机器上有很多线程。
解决办法就是:IO多路复用,IO多路转接
给这个线程安排一个集合,这个集合就放了一堆连接。这个线程就来负责监听这个集合,哪个连接有数据来了,线程就处理哪个连接。这其实就是因为,虽然连接有很多很多,但是这些连接的请求并非完全严格的同时,总还是有先后的。
TCP中的长短连接
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
对比以上长短连接,两者区别如下:
建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。
主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。
总结
本文主要介绍了UDP和TCP的相关知识和一些差别。
UDP:无连接,不可靠传输,面向数据报,全双工
TCP:有连接,可靠传输,面向字节流,全双工
这其中很多特点都是可以从代码中直接看到的,但还有比如可靠传输,这个东西隐藏在TCP背后,从代码的角度是感知不到的。TCP诞生的意义,就是为了解决可靠传输的问题~