目录
1.TCP和UDP的区别
2.基于UDP的 Socket API
总结
3.基于TCP的Socket API
服务器程序的问题
网络编程的目的:通过网络,让不同主机之间能够进行通信。
在进行网络编程的时候,需要操作系统提供一组API,也就是Socket API,才能完成编程。Socket API可以认为是应用层和传输层之间交互的路径。
Socket:也被称为套接字,就是网络中不同主机上的应用程序之间进行双向通信的端点的抽象。其本质上是一种特殊的文件。与普通文件不同的是,普通文件都是在同一台计算机上,两个进程之间传输数据。而Socket可以实现在不同计算机之间传输数据,也就是网络传输数据。比如说打开QQ,打开一个网页,这些都是socket来实现通信的,而网络通信又必须遵守的两个协议,tcp/ip协议和udp协议,Socket里面已经封装好了这两个协议,我们直接拿来用即可。Socket就属于是把"网卡"这个设备给抽象成文件了。往Socket文件中写数据,就相当于通过网卡发送数据;从Socket文件读数据,就相当于通过网卡接收数据。
传输层提供的网络协议主要有两个:TCP和UDP。
详细来说这四个区别。
1. TCP是有连接的,UDP是无连接的。
2. TCP是可靠传输的,UDP是不可靠传输的。
举个例子:A给B发送消息,发送成功还是失败,若A这边都能及时知道,那这就是可靠传输,否则就是不可靠传输。但是要想可靠传输,也得付出一些代价:
3. TCP是面向字节流的,UDP是面向数据报的。
4. TCP和UDP都是全双工的。
一个信道,允许双向通信,就是全双工。
一个信道,只能单向通信,就是半双工。
双向通信的原因:与网线有关,一根网线里通常有8根网线,4个为一组,每一组又是单向传输的,这样这一组负责数据传过来,另一组负责数据传过去。就构成了双向通信。
在Java中也提供了一组API,用来操作Socket,具体使用DatagramSocket这个类操作。其常用方法有接收数据receive()方法,发送数据send()方法,关闭文件close()方法。DatagramPacket这个类用来创建数据包,每次发送数据或者是接收数据都要以数据包的形式传递。且发送数据时数据包还要加上发送目的地(IP,端口等)。接收数据时还要提前创建好空的数据报。
为啥要以数据包的形式接收和发送?
因为UDP是面向数据包的。是以数据报为基本单位。
接下来我们实现一个简单的基于UDP的客户端/服务器通信的程序。(回显服务器)
服务器程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
//服务器程序
public class MyServer {
private DatagramSocket socket = null;
//port 端口号 服务器需要手动指定端口号
public MyServer(int port) throws SocketException {
//折磨写就是手动指定端口
socket = new DatagramSocket(port);
//这么写就是让系统自动分配端口
//socket = new DatagramSocket();
}
//服务器启动方法start
public void start() throws IOException {
System.out.println("服务器启动!");
while(true) {
// 1.读取请求并解析
//创建一个空的数据报,用来存储服务器接收客户端传来的请求
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
//讲数据报传入socket中
socket.receive(requestPacket);
//将数据报中存储的客户端的请求数据从字节流转成字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
// 2.根据请求计算响应
String response = process(request);
//将计算后的响应数据一个传入新创建的数据报 参数有 字节数组 字节数组长度 数据报的发送地址
DatagramPacket responsePacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
requestPacket.getSocketAddress());
// 3.把响应写回给客户端 以数据包形式
socket.send(responsePacket);
// 4.打印日志
System.out.printf("[%s:%d] request=%s response=%s\n",requestPacket.getAddress().toString(),
responsePacket.getPort(),request,response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
//设置服务器的端口号为9090
MyServer server = new MyServer(9090);
server.start();
}
}
客户端程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;
//客户端程序
public class MyClient {
private DatagramSocket socket = null;
//服务器IP
private String serverIp =null;
//服务器端口
private int serverPort = 0;
//构造方法 参数为服务器IP和服务器端口号
public MyClient(String IP,int port) throws SocketException {
//客户端端口号不需要手动指定 由系统自动分配
socket = new DatagramSocket();
this.serverIp = IP;
this.serverPort = port;
}
//客户端启动程序
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner sc = new Scanner(System.in);
while(true) {
// 1.将客户端的请求数据发给服务器
System.out.print("->");
String request = sc.next();
//创建数据报 将请求数据以字节流形式装入包中
//因为是充当发送的数据报 所以数据包中要有 数据+发送目的地
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIp),serverPort);
//将数据报发送给服务器
socket.send(requestPacket);
// 2.接收服务器响应回的数据
//再创建一个空的数据包接收服务器返回的数据
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地址和端口号
MyClient client = new MyClient("127.0.0.1",9090);
client.start();
}
}
基于UCP协议的话:服务器和客户端之间的来回receive和send都是以数据包形式传递数据 所以要经常创建数据包。数据包中可包含数据 发送的目的地。充当发送的数据包中是要有发送的地址的,充当接收的数据包要预先创建一个空的数据包。这就是两种方式下数据包的不同的用法。
上述代码中的几个细节:
1.既然说Socket是文件,为啥使用完后不进行close操作呢?不怕文件资源泄露吗?
文件资源泄露的原因:在一个程序中,频繁地打开文件而不去关闭。会使文件描述符表满。
2.服务器和客户端的端口号都需要手动添加吗?
3.这段代码服务器和客户端各自的工作流程?
4.为啥接收数据的时候需要提前手动创建一个有大小的数据报?
基于上述代码再写一个简单的翻译器程序(继承)
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class udpTranServer extends udpServer{
private Map map = new HashMap<>();
public udpTranServer(int port) throws SocketException {
super(port);
map.put("cat","猫");
map.put("dog","狗");
map.put("tiger","老虎");
//......
}
//重写process方法 服务器处理请求
@Override
public String process(String request) {
//英译汉
String response = map.getOrDefault(request,"没有这个单词!");
return response;
}
public static void main(String[] args) throws IOException {
udpTranServer server = new udpTranServer(9090);
server.start();
}
}
有两个关键的类:
ServerSocket:只给服务器使用,用来绑定端口号。
Socket:既给服务器用,也给客户端用,用来接收(receive)和发送(send)数据(字节流)。
常用的四个方法:
由于TCP是面向字节流的,以字节为基本单位。所以传输过程中就会用到InputStream和OutputStream流进行接收和发送。通过InputStream进行read操作,就是接收,通过OutputStream进行write操作,就是发送。
服务器
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;
//服务器
public class tcpServer {
private ServerSocket serverSocket = null;
public tcpServer(int port) throws IOException {
//服务器手动指定端口号
serverSocket = new ServerSocket(port);
}
//这个方法用来启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while(true) {
//服务器接收请求
Socket requestSocket = serverSocket.accept();
//先需要建立连接 细节流程是由内核完成的 只需要调用即可
processConnection(requestSocket);
}
}
private void processConnection(Socket requestSocket) throws IOException {
//打印日志
System.out.printf("[%s:%d] 上线啦! %s\n",requestSocket.getInetAddress(),requestSocket.getPort());
//处理请求
try(InputStream is = requestSocket.getInputStream();
OutputStream os = requestSocket.getOutputStream()) {
Scanner sc = new Scanner(is);
PrintWriter writer = new PrintWriter(os);
while(true) {
// 字节流转字符流
// 先读 Scanner
if (!sc.hasNext()) {
System.out.printf("[%s:%d] 下线啦!\n",requestSocket.getInetAddress(),requestSocket.getPort());
break;
}
String request = sc.next();
// 处理请求
String response = process(request);
//打印日志
System.out.printf("[%s:%d] req=%s res=%s\n",requestSocket.getInetAddress(),requestSocket.getPort(),request,response);
// 将请求处理结果返回给客户端
// 再写 PrintWriter 或者将 字符串转成字节数组
writer.println(response);
// 字符串转成字节数组
// os.write(response.getBytes());
// 刷新缓冲
writer.flush();
// os.flush();
}
} catch (IOException e) {
e.printStackTrace();
}finally {
requestSocket.close();
}
}
private String process(String request) {
//这里负责处理请求并返回响应
return request;
}
public static void main(String[] args) throws IOException {
tcpServer server = new tcpServer(9090);
server.start();
}
}
客户端
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 tcpClient {
Socket socket = null;
//需要明确访问服务器的IP地址和端口号
public tcpClient(String serverIp,int serverPort) throws IOException {
//具体链接的细节,不需要我们手动干预,内核自动负责
//当我们new这个对象的时候,操作系统就进行三次握手 具体细节,完成建立的细节
socket = new Socket(serverIp,serverPort);
}
public void start() {
System.out.println("客户端启动!");
Scanner sc = new Scanner(System.in);
try(InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream()) {
Scanner res = new Scanner(is);
PrintWriter writer = new PrintWriter(os);
while(true) {
System.out.print("->");
String request = sc.next();
//将请求以字节流形式发送给服务器
//os.write(request.getBytes());
writer.println(request);
//刷新缓冲区
writer.flush();
//os.flush();
//接收服务器传回来的响应
//传回来的是字节流 进一步要转成字符流
String response = res.next();
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
tcpClient client = new tcpClient("127.0.0.1",9090);
client.start();
}
}
问题一:为啥光Socket对象要进行close操作而ServerSocket不用close,为啥这个会资源泄露?
原因是这个对象在while循坏中,每次有一个客户端来请求都会创建一个,这个Socket对象也就会占据文件描述符的位置,久而久之,不及时关闭就会造成文件资源泄露,而同一个代码中ServerSocket不用close,是因为它的对象只有一个,且生命周期贯穿整个程序。
但是我们不是加了try()语句吗,这种语句形式不是会自动关闭的吗?
原因是这只是关闭了Socket对象自带的流对象,本身本体Socket对象并没有被关闭。
问题二:上面这种代码如果启动多个客户端会有什么问题,如何解决?
虽然客户端是多个,但其实进线程只有一个。 最先启动的客户端是可以正常交互的。而后面启动的这些客户端没有响应。并且当第一个客户端结束后,第二个又可以正常运行,并且会将之前积压的请求由服务器全部正常处理。以此类推,即同一时间只能正常运行一个,仿佛是其他客户端都在等待自己的前一个客户端结束后才活过来。这是与我们服务器的代码逻辑有关,在代码中,我们有两层嵌套while(true)循环,这样第一个客户端上线后,进程就会阻塞在里面的while循环的判断语句hasNext这块,还是上面所说的虽然客户端是多个,但其实线程只有一个。所以只有这个用户下线后,里面的while循环才能跳出来,继续执行。
解决办法:使用多线程并发执行。
1. 把连接操作放在Thread里
2. 采用线程池
线程池的方式虽然可以降低线程的频繁创建与销毁的开销。但如果同一时刻大量用户进行访问,线程池也要同时创建多个线程,也会招架不住。还可以采用协程(3)或IO多路复用(4)。
3.协程
4.IO多路复用/IO多路转接
IO多路复用/IO多路转接:用一个线程可以同时处理多个客户端的Socket。在Java中是NIO,其底层就是IO多路复用。
代码如下:
只需要改服务器程序。
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 tcpServer {
private ServerSocket serverSocket = null;
//使用线程池 保证多个客户端正常运行
ExecutorService service = Executors.newCachedThreadPool();
public tcpServer(int port) throws IOException {
//服务器手动指定端口号
serverSocket = new ServerSocket(port);
}
//这个方法用来启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while(true) {
//服务器接收请求
Socket requestSocket = serverSocket.accept();
//先需要建立连接 细节流程是由内核完成的 只需要调用即可
//第一种 最简单的多线程处理
/*
Thread thread = new Thread(() -> {
try {
processConnection(requestSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
thread.start();
*/
//第二种 线程池
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(requestSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
//协程和IO多路复用以后实现
}
}
private void processConnection(Socket requestSocket) throws IOException {
//打印日志
System.out.printf("[%s:%d] 上线啦! \n",requestSocket.getInetAddress(),requestSocket.getPort());
//处理请求
try(InputStream is = requestSocket.getInputStream();
OutputStream os = requestSocket.getOutputStream()) {
Scanner sc = new Scanner(is);
PrintWriter writer = new PrintWriter(os);
while(true) {
// 字节流转字符流
// 先读 Scanner
if (!sc.hasNext()) {
System.out.printf("[%s:%d] 下线啦!\n",requestSocket.getInetAddress(),requestSocket.getPort());
break;
}
String request = sc.nextLine();
// 处理请求
String response = process(request);
//打印日志
System.out.printf("[%s:%d] req=%s res=%s\n",requestSocket.getInetAddress(),requestSocket.getPort(),request,response);
// 将请求处理结果返回给客户端
// 再写 PrintWriter 或者将 字符串转成字节数组
writer.println(response);
// 字符串转成字节数组
// os.write(response.getBytes());
// 刷新缓冲
writer.flush();
// os.flush();
}
} catch (IOException e) {
e.printStackTrace();
}finally {
requestSocket.close();
}
}
private String process(String request) {
//这里负责处理请求并返回响应
return request;
}
public static void main(String[] args) throws IOException {
tcpServer server = new tcpServer(9191);
server.start();
}
}