1. 前置知识
在学习网络编程前,我们需要先了解一些前置知识
1.1 客户端和服务器
在网络编程中,客户端和服务器是两个关键的角色。
客户端是发起连接并向服务器发送请求的一方。客户端通常是一个应用程序或设备,通过与服务器建立连接,发送请求并接收响应来获取所需的服务或数据。
服务器是提供服务或数据的一方。服务器通常是一个强大的计算机,它等待客户端的连接请求,并根据请求提供相应的服务或数据。服务器可以同时处理多个客户端的请求,每个请求都会分配给一个独立的线程或进程进行处理。服务器也使用特定的协议来与客户端进行通信。
1.2 请求和响应
请求(Request):请求是客户端发起的一个操作或服务请求。客户端通过发送请求给服务器,表达其需要获取某项服务、数据或执行某个操作的意图。
响应(Response):响应是服务器对客户端请求的回应。服务器接收到请求后,根据请求内容执行相应的操作,并返回相应的结果给客户端。
请求和响应之间的关系通常是一对一的。每个请求都对应着一个相应的响应。客户端发送请求后,服务器接收并处理请求,并生成相应的响应返回给客户端。客户端接收到响应后,可以根据响应的状态码和内容进行相应的处理。
也有特定情况下,存在一对多,多对一,多对多。
2. TCP/UDP 协议之间的差别
进行网络编程,本质上是使用传输层的协议提供的API接口。传输层主要有两个协议,TCP和UDP由于这两个协议之间存在一些差异,所以,它们的API也存在一些差异。这里我们先简单介绍一下TCP和UDP的差异。
TCP的特点:
-
有连接(Connection-Oriented):TCP是一种面向连接的协议,即在进行数据传输之前,必须先建立双方之间的连接。连接建立后,双方可以进行数据传输,传输完成后再关闭连接。
-
可靠传输(Reliable Transmission):TCP提供可靠的数据传输机制,保证数据的完整性、顺序性和不丢失。为了实现可靠传输,TCP采用了多种机制,如序列号、确认应答、超时重传、流量控制和拥塞控制等。通过这些机制,TCP可以检测并纠正数据传输中的错误,并确保数据按正确的顺序到达目标。
-
面向字节流(Byte-Oriented):TCP是一种面向字节流的协议,意味着数据在发送端和接收端之间是按照字节流的方式进行传输的,而不考虑应用层的消息边界。发送端将应用层数据分割成小块的字节流,在接收端进行重新组装。这种特性使得TCP更加灵活,可以适应不同大小的数据传输。
-
全双工(Full Duplex):TCP连接是全双工的,意味着数据可以在双方同时进行双向传输。发送端和接收端可以同时发送和接收数据,而且两个方向的数据流是独立的,互不影响。这种特性使得双方可以同时进行实时的双向通信,提高了传输效率。
UDP的特点:
-
无连接(Connectionless):UDP是一种无连接的协议,发送端和接收端之间不需要建立连接。每个UDP数据包都是独立的,可以单独发送并独立处理,不需要等待前面的数据包确认。
-
不可靠传输(Unreliable Transmission):与TCP不同,UDP不提供可靠的数据传输机制。UDP数据包被发送后,不会去确认是否到达目标地址,也不会进行重传操作。这意味着在网络传输过程中,可能会出现丢包、乱序或重复的情况。
-
面向数据报(Datagram-Oriented):UDP是一种面向数据报的协议,每个UDP数据包被视为一个独立的数据报文。每个数据报都有自己的头部信息,包含了源地址、目标地址、长度等字段。由于数据报之间是独立的,因此UDP可以灵活地处理不同大小的数据。
-
全双工(Full Duplex)UDP也可以在双方同时进行双向传输。
3. 网络编程
操作系统给我们提供的网络编程的 API叫做 "Socket APOI" 即 "网络编程套接字"
3.1 UDP Socket API 的使用
在Java中,其实是把操作系统提供的原生API进行封装过的,所以我们调用的API其实是JVM提供的。
使用UDP进行网络编程,核心的类有两个:
3.1.1 DatagramSocket
DatagramSocket类表示一个UDP套接字,它用于在端点之间发送和接收UDP数据包。DatagramSocket对象可以绑定到本地IP地址和端口号,以便在该地址和端口上进行监听和传输数据包(操作系统中有一类文件叫做Socket,Socket文件抽象表示了网卡这样的设备,DatagramSocket就是通过读写Socket文件,来发送和接收数据的)。
DatagramSocket的常用方法包括:
- DatagramSocket():构造函数,创建一个绑定到本机随机端口号的DatagramSocket对象(通常用于客户端。
- DatagramSocket(int port):构造函数创建一个绑定到本机指定端口号的DatagramSocket对象(通常用于服务器)。
- void send(DatagramPacket p):发送指定的数据包。
- void receive(DatagramPacket p):接收一个数据包,并将其存储在指定的DatagramPacket对象中,如果没有接收到数据报,该方法会阻塞。
- void close():关闭DatagramSocket对象。
3.1.2 DatagramPacket
DatagramPacket类表示一个UDP数据包,它包含了要发送或接收的数据、目标地址、目标端口等信息,相当于储存数据的一个载体。DatagramPacket对象可以用于在DatagramSocket之间传递UDP数据包。
DatagramPacket的常用方法包括:
- DatagramPacket(byte[] buf, int length):构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length)。
- DatagramPacket(byte[] buf, int length, InetAddress address, int port):构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP,port表示端口号。
- byte[] getData():获取数据包的数据。
- int getLength():获取数据包的长度。
- InetAddress getAddress():获取数据包的发送端地址。
- int getPort():获取数据包的发送端端口号。
- void setData(byte[] buf):设置数据包的数据。
- void setLength(int length):设置数据包的长度。
- void setAddress(InetAddress address):设置数据包的目标地址。
- void setPort(int port):设置数据包的目标端口号。
3.1.3 代码示例
编写代码实现一个回显服务器,返回客户端发送的请求,即客户端发什么就返回什么。
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);
//调用receive方法接收数据
socket.receive(requestPacket);
//将接收到的数据转为字符串存储起来
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
//打印出发送端的IP,端口号,和接收到的数据
System.out.print(requestPacket.getAddress().toString() + " " + requestPacket.getPort() + ":" + request);
//调用方法,构造出对应的响应(这个方法需自己实现)
String response = process(request);
//构造响应数据报,注意此处要传入对应的IP地址和端口号,可以用接收到的数据报,调用对应方法获取
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getAddress(),requestPacket.getPort());
//发送数据
socket.send(responsePacket);
//打印出返回的信息
System.out.println(" <返回>:" + response);
}
}
private String process(String request) {
//根据构造响应,我们这里直接返回request
return request;
}
public static void main(String[] args) throws IOException {
//实例化回显服务器
UdpEchoServer udpEchoServer = new UdpEchoServer(7510);
//调用start方法启动服务器
udpEchoServer.start();
}
有了服务器我们还要实现一个客户端用来发送请求:
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp = null;//目标IP地址,即服务器的地址
private int serverPort = 0;//目标端口号,即服务器的端口号
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
//构造方法
socket = new DatagramSocket();
this.serverIp = serverIp;
this.serverPort = serverPort;
}
//实现start方法用于启动服务器
public void start() throws IOException {
System.out.println("客户端 启动!!!");
Scanner in = new Scanner(System.in);//用于输入请求
while(true) {
//输入请求
String request = in.next();
//构造请求数据报,注意不能直接把字符串形式的IP地址传入,需要调用InetAddress类中的getByName方法,把字符串传入
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 {
//创建客户端对象,注意,IP和端口号要和服务器的对应,127.0.0.0 为本机IP
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 7510);
udpEchoClient.start();
}
}
现在我们可以运行代码查看效果了,注意要先运行服务器,再运行客户端。
3.2 TCP Socket API 的使用
UDP传输数据是无连接的,相当于 "发短信" ,TCP传输数据是有连接的,相当于 "打电话"
3.2.1 ServerSocker
ServerSocket类是用于在服务器端监听特定端口、接受客户端连接请求的类。
通过创建ServerSocket对象,可以将其绑定到指定的IP地址和端口号,从而使服务器能够监听该端口并等待客户端连接。
ServerSocket类的常用方法:
- ServerSocket(int port):创建一个绑定到指定端口的ServerSocket对象。
- Socket accept():监听客户端连接请求,接受客户端的连接,并返回一个Socket对象用于与客户端进行通信,如果当前没有客户端连接,该方法会阻塞。
- void close():关闭ServerSocket对象,释放相关资源。
ServerSocket只能给服务器使用。
3.2.2 Socket
Socket类是用于在客户端与服务器端建立连接并进行通信的类。
通过创建Socket对象,可以指定服务器的IP地址和端口号,从而与服务器建立连接。
- Socket(String host, int port):创建一个与指定服务器IP地址和端口号建立连接的Socket对象。
- InputStream getInputStream():获取与Socket对象关联的输入流,用于从服务器端接收数据。
- OutputStream getOutputStream():获取与Socket对象关联的输出流,用于向服务器端发送数据。
- InetAddress getInetAddress():获取连接的地址。
- int getPort():获取连接的端口号。
- void close():关闭Socket对象,释放相关资源。
3.2.3 代码示例
接下来我们同样使用TCP的API实现一个回显服务器,和客户端。
服务器:
public class TcpEchoServer {
private ServerSocket socket = null;
public TcpEchoServer(int port) throws IOException {
socket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器 启动!!!");
while(true) {
//和客户端建立连接,如果没有客户端连接,该方法会阻塞
Socket client = socket.accept();
//连接成功,打印信息
System.out.printf("[%s,%d]已连接\n", client.getInetAddress(), client.getPort());
//通过接收到的Socket对象,获取到输入,输出流
//循环读取输入流中的需求
try(InputStream inputStream = client.getInputStream();
OutputStream outputStream = client.getOutputStream()) {
while(true) {
//通过scanner在输入流中读取数据
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
//读取完毕,说明连接断开了
System.out.printf("[%s,%d]已断开\n", client.getInetAddress(), client.getPort());
break;
}
String request = scanner.next();
//调用方法根据请求构造响应
String response = process(request);
//使用print的子类PrintWriter,写入响应
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
//调用flush方法立刻把response写入硬盘
printWriter.flush();
//打印日志
System.out.printf("[%s,%d] req:%s,res:%s\n",client.getInetAddress(),client.getPort(), request, response);
}
//释放资源
client.close();
}
}
}
public String process(String request) {
//根据请求构造响应
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(7510);
server.start();
}
}
客户端:
package TCP;
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(String ServerIp, int ServerPort) throws IOException {
//传入服务器的 IP地址 和 端口号,在创建该对象时就会向服务器发送连接,
// 创建完毕后等待服务器调用accept即可连接上
socket = new Socket(ServerIp, ServerPort);
}
public void start() throws IOException {
System.out.println("客户端 启动!!!");
//连接成功,循环输入请求,获取响应
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
//从键盘接收请求
Scanner in = new Scanner(System.in);
//输出响应
Scanner scanner = new Scanner(inputStream);
//把请求输出给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
while(true) {
//接收请求
System.out.print("> ");
String request = in.next();
//发送给服务器
printWriter.println(request);
//调用flush方法立刻把response写入硬盘
printWriter.flush();
//接受响应并输出
String response = scanner.next();
System.out.println(" >" + response);
}
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 7510);
client.start();
}
}
现在我们运行代码,发现可以正常完成功能:
3.2.4 改进
我们现在的代码每次只能连接一个客户端,因为我们连接了一个客户端之后,除非这个客户端断开连接,否则是出不了循环的,也就无法再次执行accept 连接新的客户端。
所以我们可以使用多线程的方法,给每个连接的客户端分配一个线程:
public class TcpEchoServer {
private ServerSocket socket = null;
public TcpEchoServer(int port) throws IOException {
socket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器 启动!!!");
while(true) {
//和客户端建立连接,如果没有客户端连接,该方法会阻塞
Socket client = socket.accept();
//连接成功,打印信息
System.out.printf("[%s,%d]已连接\n", client.getInetAddress(), client.getPort());
//创建一个线程,处理本次连接
Thread t = new Thread(()->{
//为了代码简洁,我们把处理逻辑单独封装为一个方法
try {
processConnect(client);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
public void processConnect(Socket client) throws IOException {
try(InputStream inputStream = client.getInputStream();
OutputStream outputStream = client.getOutputStream()) {
while(true) {
//通过scanner在输入流中读取数据
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
//读取完毕,说明连接断开了
System.out.printf("[%s,%d]已断开\n", client.getInetAddress(), client.getPort());
break;
}
String request = scanner.next();
//调用方法根据请求构造响应
String response = process(request);
//使用print的子类PrintWriter,写入响应
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
//调用flush方法立刻把response写入硬盘
printWriter.flush();
//打印日志
System.out.printf("[%s,%d] req:%s,res:%s\n",client.getInetAddress(),client.getPort(), request, response);
}
//释放资源
client.close();
}
}
public String process(String request) {
//根据请求构造响应
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(7510);
server.start();
}
}
现在我们的服务器就可以同时连接多个客户端了,我们也可以把上述代码优化为线程池的版本,节省线程大量创建和销毁带来的开销。