概述
TCP和UDP是传输层中的两个非常重要的协议,这两个协议对应的网络通信api(socket api)也有较大差异.
简单来说,TCP的特点是:有连接,可靠传输,面向字节流,全双工.而UDP的特点是:无连接,不可靠传输,面向数据报,全双工.
有无连接:如果通信的前提是要先建立连接(如打电话),我们称之为有连接;反之(如发微信),我们称之为无连接.
是否可靠传输:如果数据发送方能够知道接收方是否受到了其发送的数据,我们称之为可靠传输;反之,称之为不可靠传输.值得一提的是,在网络传输中,是无法保证百分之百的可靠传输的,极端情况下,假如网线断了,这就从物理层层面阻止了网络传输.
面向字节流/数据报:发送/接受数据以字节为单位,我们称之为面向字节流;以数据报为单位则称之为面向数据报.
UDP部分
基本类
UDP协议下主要有两个常用的Socket API.
1.DatagramSocket
这里需要明确的是,socket类,本质上也是"文件",打开一个socket文件同样会占用进程文件描述符表里的一个位置.socket文件对应到网卡设备.构造一个DatagramSocket对象,就相当于是打开了一个内核中的socket文件.
打开socket的文件之后,我们就可以借此实现端与端之间的数据传输了.有以下三个基本方法:
send():发送数据.
receive():接收数据.
close():关闭socket文件资源.
2.DatagramPacket
DatagramPacket表示一个UDP数据报.UDP协议传输数据就是以这个作为基本单位.
回显服务器(echo server)
服务器
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动:");
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);
System.out.printf("[%s:%d] req=%s; resp=%s\n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(8000);
udpEchoServer.start();
}
}
要点详解:
1.
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
此处创建一个构造方法,手动给服务器绑定一个端口号(port),方便客户端去访问.
一个操作系统上,有很多端口号,范围为0-65535.程序如果需要进行网络通信,就需要获取到一个端口号.端口号相当于网络中用于区分不同进程之间的标识符.(操作系统收到网卡的数据,就可以根据网络数据报中的端口号,来确定要把数据发送给哪个进程).
一个端口在通常情况下只能绑定一个进程,而一个进程可以同时绑定多个端口.如果尝试将一个进程与一个已经被占用的端口绑定,会直接抛出异常.
2.
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
此部分是UDP服务器接收数据的逻辑.由于UDP协议是面向数据报的协议,而客户端发送过来的数据通常是字符串.所以我们需要人为把客户端发送过来的数据封装成一个数据报.上述代码就是构造了空的数据报,用于将接收过来的字符串封装成一个UDP数据报(我们称这种参数为输出型参数).观察此处构造函数中的参数可以看出,DatagramPacket本质上就是一个字节数组.
此外,若服务器在启动后始终没有接收到客户端发来的数据,那么socket.receive()方法将会阻塞等待.
3.
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);
这部分是数据的解析与发送.
服务器利用receive中获得的数据报.可以重构回客户端发送的字符串数据(即getData()与getLength()).然后通过process()函数得到需要发送回给客户端的字符串数据.
然后就到了封装UDP数据报的部分.数据报的内容和长度我们可以通过String类中的方法确定(即getBytes()和getBytes().length).然后我们还需要确定这个数据报需要发送给谁.这部分有关于客户端的信息可以通过UDP数据报的getSocketAddress()方法获得(注意是request的).由此,我们就构造好了responsePacket.将其通过send()方法发送出去即可.
此处还需要注意的一点是,由于是回显服务器(echo server).服务器不需要对客户端发送的请求做任何处理,原本返回即可.
4.
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(8000);
udpEchoServer.start();
}
这部分代码是在进程中创建UDP服务器的实例.值得一提的是,我们不推荐给服务器分配号码数小于1024的端口号,因为小于1024的端口号通常是操作系统保留的.
客户端
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
public UdpEchoClient() throws SocketException {
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true){
System.out.print("<<");
String request = scanner.next();
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName("127.0.0.1"), 8000);
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.printf("req: %s; resp: %s\n",request,response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient();
udpEchoClient.start();
}
}
要点详解:
1.
private DatagramSocket socket = null;
public UdpEchoClient() throws SocketException {
socket = new DatagramSocket();
}
注意到客户端DatagramSocket的构造不需要手动传入端口号,而是选择让用户的操作系统自行分配.这是因为作为开发者,我们无法知道用户的机器上有哪些端口是空闲的,让操作系统自行分配可以避免异常.
2.
String request = scanner.next();
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName("127.0.0.1"), 8000);
socket.send(requestPacket);
此处是客户端的数据封装与发送.我们可以注意到此处UDP数据报的构造方式与服务器部分的不同:客户端在构造数据报时,需要带上自己的IP地址和服务器的端口号.这两者相当于快递的寄件方和收件方.而服务器在构造数据报时,只需要明确接收方即可.(此处客户端本地的地址获取调用了InetAddress类中的getByName()方法,"127.0.0.1"是自己主机的IP地址)
然后我们通过send()方法将数据报发送给服务器即可.
3.接收数据部分的逻辑与服务器一致,在此不再赘述.
TCP部分
基本类
1.ServerSocket:服务器使用的socket
2.Socket:服务器和客户端都会使用的socket
这两者的应用在后面会讲到.
回显服务器(echo server)
服务器 a
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
ExecutorService service = Executors.newCachedThreadPool();
while(true){
Socket socket = serverSocket.accept();
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
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()) {
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next();
String response = process(request);
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 {
clientSocket.close();
}
}
public String process(String resp){
return resp;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(8000);
tcpEchoServer.start();
}
}
要点介绍:
1.TCP服务器的构造与UDP类似,都需要手动指定端口号,方便客户端访问.
2.
ExecutorService service = Executors.newCachedThreadPool();
while(true){
Socket socket = serverSocket.accept();
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
这部分与UDP服务器的差异较大.在UDP服务器中,服务器无需与客户端建立连接,只需要不断尝试接收客户端可能会发送的信息即可.
而在TCP服务器中,客户端和服务器需要建立连接,在建立连接之后才能继续数据通信.这个部分依赖于先前提到的Serversocket类中的accept()方法.该方法相当于获取到了客户端的socket文件,然后直接通过客户端的socket文件进行进一步操作.
3.
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next();
String response = process(request);
printWriter.println(response);
printWriter.flush();
System.out.printf("[%s:%d] req: %s; resp: %s\n",
clientSocket.getInetAddress().toString(), clientSocket.getPort(),
request, response);
}
}
这部分是通过获取的客户端的socket文件,进一步获得客户端传输过来的数据.我们前面提到过TCP协议是通过字节流来传输信息的,所以在对应的socket文件里,我们可以获得对应的输入输出流(即getInputStream()和getOutputStream()方法).
为了方便读写数据,我们可以把输入输出流用Scanner和PrintWriter进一步封装.然后,我们可以通过scanner.next()方法将客户端socket文件中的数据读取出来,解析后使用printWriter.println()方法将结果写回给客户端.
此处使用flush()清空缓冲区是防止返回的数据进入了缓冲区,没有及时发送给客户端.
客户端
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient() throws IOException {
socket = new Socket("127.0.0.1",8000);
}
public void start(){
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) {
Scanner scannerNet = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
String request = scanner.next();
printWriter.println(request);
printWriter.flush();
String response = scannerNet.next();
System.out.printf("req: %s; resp: %s\n", request, response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient();
tcpEchoClient.start();
}
}
要点介绍:
1.客户端方面,我们可以发现客户端的收发逻辑和服务器几乎完全一致,这也是TCP协议和UDP协议的区别之一.
一些补充
端口进程查找
当我们发现想使用的某个端口被占用而我们不想换端口号时,我们可以在cmd下使用netstat -ano | findstr 8000 命令(这是一个管道复合命令)查找出占用了对应端口号的进程id,示例如图:
如上图所示,我们可以查找出当前主机下,占用了8000端口进程的进程id是12032.
多线程的使用
可以注意到在TCP服务器的实现中使用了线程池.这是由TCP服务器的实现逻辑决定的.可以看到在TCP服务器的processConnection()方法中,有一个死循环,这个死循环会不断读取客户端发送过来的数据(数据可能是一次大数据,也可能是多次小数据),直到读到EOF(如连接中断时就会读到).
这就导致如果在单线程的情况下,一旦一个客户端-服务器连接建立且不中断,服务器会永远阻塞在processConnection()方法中,无法与其他的客户端建立连接,这显然是无法接受的.对此,我们引入多线程:即每来一个客户端,我们就另起一个线程执行processConnection()方法.这就把accept()方法和后续的数据处理隔绝开来,实现了多客户端.采用线程池的原因是考虑到可能会有较大规模的线程的创建和销毁,线程池能节约系统资源.
实际上,我们也可以人为的约定数据的传输格式,手动在客户端中调用close()方法(如每发送一个字符串,中断一次) ,这样可以绕开多线程.
至于UDP协议,由于UDP协议下,传输数据不需要建立连接,所以也就不会存在"服务器等待客户端传输数据从而导致阻塞"这个问题,UDP服务器只需要不断接受数据即可,而服务器处理数据的速度是极快的,基本上可以看成是并发执行,也就没必要使用多线程.