目录:
前言
基于UDP实现客户端服务器的网络通信程序
基于TCP实现客户端服务器的网络通信程序
前言
网络编程的核心是Socket API,它是操作系统给应用程序提供的网络编程API,可以认为是socket api是和传输层密切相关的。在传输层里面,提供了两个最核心的协议,UDP和TCP。因此,socket api 也提供了两种风格(UDP和TCP)。
先来对UDP和TCP做一个简单的总结:
UDP:无连接、不可靠传输、面向数据报、全双工
TCP:有连接、可靠传输、面向字节流、全双工
关于有无连接,可以用打电话和发微信来类比,打电话就是有连接,有无接通可以被感知,而发微信就是无连接,对方是否已经看到消息是无感知的。
关于可靠与不可靠传输,还是可以用打电话和发微信来类比,打电话就是可靠传输,对方接通并回应就是确认收到了,发微信就是不可靠传输,消息发送出去并不知道对方已经确认收到,就是不可靠传输。
面向字节流:数据传输就和文件读写类似,是“流式”的。
面向数据报:数据传输则以一个个的“数据报”为基本单位,一个数据报可能是若干个字节且带有一定格式。
全双工:一个通信通道,可以双向传输,既可以发送也可以接受。比如一根水管,是单向传输的,就是半双工。
基于UDP实现客户端服务器的网络通信程序
使用DatagramSocket这个类,表示一个socket对象。在操作系统中,把这个socket对象也是当成一个文件来处理的,相当于是文件描述符表上的一项。其中普通的文件对应的硬件设备是硬盘,socket文件对应的硬件设备是网卡。一个socket对象就可以和另外一台主机进行网络通信了,如果需要和多个不同的主机通信,则需要创建多个socket对象。
DatagramSocket(): 无参数就是没有指定端口,系统则会自动分配一个空闲的端口
DatagramSocket(int port): 传入了一个端口号,就是让当前的socket对象和这个指定的端口关联起来。从本质上来说,不是进程和端口来建立联系,而是进程中的socket对象和端口建立了联系。
DatagramPacket:表示UDP中传输的一个报文,也是UDP传输数据的基本单位。
receive(DatagramPacket p):此处传入的是一个空的对象,空的报文,receive方法内部会对参数的这个空对象进行内容填充,从而构造出数据。
send(DatagramPacket p): 将构造好的报文发送出去。
下面这里将写一个简单的回显客户端服务器,当然对于服务器来说,对于请求的处理,即业务逻辑的处理是最核心最复杂的,我们这里就回显设置,重点在于理解整个通信过程。
服务器端代码:
public class UdpEchoServer {
// 网络编程, 本质上是要操作网卡.
// 但是网卡在操作系统内核中,不方便直接操作,使用了"socket" 这样的文件来抽象表示网卡
// 因此进行网络通信, 必需要先有一个 socket 对象
private DatagramSocket socket = null;
// 对于服务器来说, 创建 socket 对象的同时, 要让它绑定上一个具体的端口号,服务器一定要关联上一个具体的端口
// 服务器在网络传输中才知道此时客户端的端口, 才能进行通信
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
// 服务器不是只给一个客户端提供服务就完了,需要服务很多客户端,所以需要用到循环
while (true){
//1.读取客服端发来的请求
//receive方法的参数是一个输出型参数, 需要先构造好个空白的 DatagramPacket 对象,交给 receive 来进行填充
DatagramPacket requestPack = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPack);
//把数据拿出来, 构造成一个字符串
String request = new String(requestPack.getData(),0,requestPack.getLength());
//2.根据请求计算响应,此时为回显服务器,所以写得比较简单,请求和响应相同
String response = process(request);
//3.把响应写回客户端
//send 的参数也是 DatagramPacket,也是需要把这个 Packet 对象构造好
//此处构造的响应对象, 不能是用空的字节数组构造了, 而是要使用响应数据来构造
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPack.getSocketAddress());
socket.send(responsePacket);
//4.打印当前这次请求响应的处理结果
System.out.printf("[%s:%d] req: %s; resp: %s\n",requestPack.getAddress().toString(),
requestPack.getPort(),request,response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
客户端代码:
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp = null;
private int serverPort = 0;
// 服务器 ip 和 端口 也需要告诉客户端. 才能顺利把消息发个服务器
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){
//1.从控制台读取数据
System.out.print("=>:");
String request = scanner.next();
if (request.equals("exit")){
System.out.println("goodBye!");
break;
}
//2.构造UDP请求并发送
//构造这个 Packet 的时候, 需要把 serverIp 和 port 都传入过来,此处 IP 地址需要填写的是一个 32位的整数形式.
//上述的 IP 地址是一个字符串,需要使用 InetAddress.getByName 来进行一个转换
DatagramPacket requestPack = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverIp),serverPort);
socket.send(requestPack);
//3.读取服务器的UDP响应并解析
DatagramPacket responsePack = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePack);
String response = new String(responsePack.getData(),0,responsePack.getLength());
//4.打印解析的结果
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1",9090);
client.start();
}
}
通信逻辑顺序图:
基于TCP实现客户端服务器的网络通信程序
TCP提供的API主要是两个类:
ServerSocket:专给服务器使用的Socket对象
Socket:既会给服务器使用,也会给客户端使用。
TCP的传输与UDP有所不同,它并不是以数据报为单位进行传输的,而是以字节的方式,流式传输。
// 使用这个 clientSocket 和具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
accept的作用就是接受连接,客户端在构造Socket对象的时候就会指定服务器的IP和端口,如果没有客户端来连接,此时accept就会阻塞。只要任意一个客户端连接上来,服务器这边都会创建一个Socket对象,每次创建一个Socket对象,就要占用一个文件描述符表的一个位置,因此在使用完毕后要进行释放。前面我们写的UDP就不存在这个情况,一方面是因为UDP中的Socket对象的生命周期是跟随整个程序的,声明周期更长,另一方面是Socket数量不是很多。
一次连接只能处理一个客户端的请求,那么同时有非常多的客户端发出请求,也就是高并发,此时可以用多线程来解决,为了避免线程频繁的创建和销毁,也可以用线程池。
while (true){
// 使用这个 clientSocket 和具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
//多线程版本
/* Thread t = new Thread(()->{
processConnection(clientSocket);
});
t.start();*/
//线程池版本
threadPool.submit(()->{
processConnection(clientSocket);
});
}
但是对于特别量大的客户端,多线程和线程池也是不行的,可以多开服务器可以解决,但是毕竟成本高,还有一种方式就是IO多路复用。大概逻辑就是:给一个线程安排一个集合,这个集合里面放了一堆连接,这个线程就负责监听这个集合,哪一个连接有数据来了,这个线程就先处理哪个连接。IO多路复用本质上来说,就是充分利用等待的时间来做别的事情。
服务器端代码:
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("启动服务器!");
// 此处使用 CachedThreadPool, 使用 FixedThreadPool的时候线程数是固定的,不太合适
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true){
// 使用这个 clientSocket 和具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
//多线程版本
/* Thread t = new Thread(()->{
processConnection(clientSocket);
});
t.start();*/
//线程池版本
threadPool.submit(()->{
processConnection(clientSocket);
});
}
}
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d]客户端上线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
// 基于上述 socket 对象和客户端进行通信
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 由于要处理多个请求和响应, 也是使用循环来进行
while (true){
//1.读取请求
Scanner scanner =new Scanner(inputStream);
if (!scanner.hasNext()){
System.out.printf("[%s:%d]客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//此处使用 next 是一直读取到换行符/空格/其他空白符结束, 但是最终返回结果里不包含上述换行符/空格/其他空白符
String request = scanner.next();
//2.根据请求构造响应
String response = process(request);
//3.返回响应结果
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处使用 println 来写入,让结果中带有一个 \n 换行. 方便对端来接收解析
printWriter.println(response);
// flush 用来刷新缓冲区, 保证当前写入的数据是真正发送出去了
printWriter.flush();
//4.打印当前这次请求响应的处理结果
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();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
客户端代码:
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){
//1.从控制台读取数据
System.out.print("=>:");
String request = scanner.next();
if (request.equals("exit")){
System.out.println("goodBye!");
break;
}
//2.构造请求并发送
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();
//3.读取服务器响应
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
//4.回显
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();
}
}
总结
在上面代码中,关于有无连接,面向字节流和面向数据报,以及全双工都能在代码中体现。但是,在TCP中的可靠传输是在代码层面感知不到的。从TCP诞生的意义来说,就是为了解决可靠传输的问题的。关于TCP能够保证可靠传输问题 ,后面再来总结。