注:此博文为本人学习过程中的笔记
1.socket api
这是操作系统提供的一组api,由传输层向应用层提供。
2.传输层的两个核心协议
传输层的两个核心协议分别是TCP协议和UDP协议,它们的差别非常大,编写代码的风格也不同,因此socket提供了两套api。TCP协议是有连接,可靠传输,面向字节流,全双工。UDP协议是无连接,不可靠传输,面向数据报,全双工。
1.有连接/无连接
这里的有无连接是抽象的概念,对于网络通信来说,物理上(网线)的连接时必须的。这里的连接是指在通信的时候有没有保存对方的信息。
对于TCP协议来说,A和B通信,会让A保存B的信息,B保存A的信息,让它们彼此知道谁是和它建立连接的那一个。
对于UDP协议来说,不保存对方的协议。当然程序员可以在自己的代码中保存对方的信息,但这不属于UDP协议的行为。
2.可靠传输/不可靠传输
网络上,数据是非常容易出现丢失的情况,用来传输的光/电信号很容易受到外界的影响。所以我们不能指望一个数据包发送之后,能百分之一百到达对方。
可靠传输的意思不是保证数据包百分之一百到达,而是尽可能提高传输成功的概率,如果丢包了,可以感知到。
不可靠传输就是指数据包发送之后就不管了。
3.面向字节流/面向数据报
面向字节流是指读写数据的时候以字节为单位。它可以灵活地控制读写的长度,但是容易出现粘包问题。
面向数据报是指读写数据的时候以数据报为单位。一次必须读取一个数据报的长度,但不容易出现粘包问题。
4.全双工/半双工
全双工是指一个通信链路支持双向通信
半双工是指一个通信链路只支持单向通信
3.使用socket api进行编程
1.UDP服务器
这是操作系统提供的功能,Java进行了封装。这里我们先讲解基于UDP协议的写法。
1.DatagramSocket
计算机中的文件广义上还能代指硬件设备,将它们抽象成文件。这里我们将网卡抽象成socket文件,操作网卡的时候和普通的文件差不多,打开(也会在文件描述符表分配一个表项)->读写->关闭。直接操作网卡不好操作,把网卡操作转换成socket文件操作更加方便。
1.构造方法
DatagramSocket()
创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
DatagramSocket(int port)
创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务器)
2.DatagramPacket
这个类表示一个完整的UDP数据报
1.构造方法
UDP数据报的载荷可以通过构造方法来指定
DatagramPacket(byte[] buf, int length)
构造一个DatagramPacket用来接收数据报,接收的数据报保存在字节数组,指定长度
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)
构造一个DatagramPacket用来接收数据报,接收的数据报保存在字节数组,指定数组下标,数组长度,目的IP和端口号
2.receive/send
1.void receive(DatagramPacket p)
接收数据报,没有接收到会阻塞等待
2.void send(DatagramPacket p)
发送数据报
3.代码示例
这里我们使用回显服务器,回显服务器是指响应和请求都是相同的服务器。我们实现的功能是用户输入一个字符串,服务器返回这个字符串。
1.服务器代码
代码展示
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) {//循环一次相当处理一次请求//处理请求的过程,典型的服务器分为三个步骤//1.读取请求并解析//DatagramPacket就表示一个UDP数据报,此处传入的字节数组保存UDP的载荷部分DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);socket.receive(requestPacket);//把读取到的二进制数据转化成字符串,只读取有效的部分String request = new String(requestPacket.getData(), 0, requestPacket.getLength());//2.根据请求,计算响应(服务器最关键的逻辑)//因为我们写的是回显服务器,所以这个步骤省略了String response = process(request);//3.把响应返回给客户端//根据response构造DatagramPacket返回给客户端DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length, requestPacket.getSocketAddress());socket.send(responsePacket);}}private String process(String request) {return request;}public static void main(String[] args) throws IOException {UdpEchoServer udpEchoServer = new UdpEchoServer(9090);udpEchoServer.start();}
}
解析
1. socket对象代表网卡文件,读这个文件相当于从网卡收数据,写这个文件相当于向网卡发数据
2.启动服务器,在循环中做三件事
3. 读取请求并解析
a)构造DatagramPacket对象,这个对象就代表UDP数据报,有表头和载荷(创建字节数组来保存数据)
b)调用receive接收数据,这里使用的是输出型参数,所以虽然我们是接收一个UDP数据报,但我们还是创建了一个空的DatagramPacket对象。
c)根据字节数组构造纯出一个String
4.根据请求计算响应
5.把响应返回给客户端
这里构造响应的数据报时,传入了字节数组作为载荷,指定了数组下标和有效长度,传入了目的端口和IP。
6.socket不用close
一个文件是否要关闭,需要考虑这个文件的生命周期,这里的socket对象自始至终都会伴随整个UDP服务器,如果服务器关闭,会自动释放PCB的文件描述附表里面的所有资源,所以就不用手动关闭了。
2.客户端代码
import java.io.IOException;
import java.net.*;
import java.util.Scanner;public class UdpEchoClient {private DatagramSocket socket = null;//UDP本身不保存对端的信息,所以我们在自己的代码保存一下private String serverIp;private int serverPort;//和服务器不同,这里的构造方法需要指定访问服务器的地址public UdpEchoClient(String serverIp, int serverPort) throws SocketException {this.serverIp = serverIp;this.serverPort = serverPort;//这里的DatagramSocket不推荐使用固定端口号,如果客户端是固定端口,//很可能在这个程序运行的时候指定的端口被其他程序占用了,客户端在用户手上,//程序员不能控制socket = new DatagramSocket();}public void start() throws IOException {Scanner scanner = new Scanner(System.in);//1.从控制台读取用户输入的内容System.out.println("请输入要发送的内容");String request = scanner.next();//2.把请求放松给服务器,需要构造DatagramPacket对象//构造过程中,不光要构造载荷,还要指定服务器的IP和端口号DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,InetAddress.getByName(serverIp), serverPort);//3.发送数据报socket.send(requestPacket);//4.接收服务器的响应DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);socket.receive(responsePacket);//5.把从服务器读取出来的数据开始解析,打印出来String response = new String(responsePacket.getData(), 0, responsePacket.getLength());System.out.println(response);}public static void main(String[] args) throws IOException {//127.0.0.1是一个环回ip,非常特殊,无论你的主机是什么,都可以用这个ip表示当前主机,相当于thisUdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);udpEchoClient.start();}
}
2.TCP服务器
因为TCP服务器进行网络通信的基本单位是字节,所以它不像UDP服务器的那样有DatagramPacket类作为基本单位,可以直接使用InputStream/OutputStream。
1.ServerSocket
这个类是专门给服务器用的,作为一开始的牵头人,和客户端建立连接后,就会使用Socket和客户端进行通信
ServerSocket(int port)
创建一个服务器端流套接字,并指定端口号
accept()
和客户端进行连接
2.Socket
这个类服务器和客户端都会使用
Socket(String host, int port)
这两个参数指的是需要指定的服务器IP和端口号
InputStream getInputStream()/OutputStream getOutputStream()
这两个方法是字节流对象
3.代码解析和修改
初始服务器代码
public class TcpEchoServer {private ServerSocket serverSocket= null;//这里和Udp服务器类似,也是在构造的时候指定端口号public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {System.out.println("启动服务器");while(true) {//对于tcp来说,需要向处理客户端发过来的连接//通过读写clientSocket和客户端进行通信//如果没有客户端发送连接,那么accept就会阻塞Socket clientSocket = serverSocket.accept();processConnection(clientSocket);}}//处理一个客户端的连接//可能涉及多个客户端的连接和响应,这里暂不涉及private void processConnection(Socket clientSocket) {try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);//处理三个步骤while(true) {//1.读取请求并解析,可以直接read,也可以借助scannerif(!scanner.hasNext()) {break;}String request = scanner.next();//2.根据请求计算响应String response = procoss(request);//3.返回响应到客户端writer.println(response);}} catch (IOException e) {e.printStackTrace();}}private String procoss(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
初始客户端代码
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIp, int port) throws IOException {//这里可以直接使用字符串的ip作为参数socket = new Socket(serverIp, port);}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);//从控制台读取请求String request = scanner.next();//发送给服务器printWriter.println(request);//获取服务器的响应String response = scannerNet.next();//打印到控制台System.out.println(response);} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);tcpEchoClient.start();}
}
问题1
当我们把客户端关闭再启动时,输入数据服务器没有响应。原因在于println只是把数据写入“发送缓冲区”,并没有真正写入网卡,此时我们需要用flush方法来属性缓冲区,让数据真正写入网卡。
问题2
这里的pringln里的ln是加上了换行,如果这里我们把ln删去,那么数据能发送过去,而服务器接收不到。
因为hasNext是以空白符(换行,回车,制表符,翻页符...)为基准,遇到空白符则是一个完整的next(),否则就会阻塞。
编写客户端代码的时候是需要约定请求和响应之间的分隔符的,这里我们使用的是\n
问题3
如果没有客户端连接,就会阻塞在accept这里
如果客户端不发送请求, 就会阻塞在hasNext这里
我们的服务器无法同时等待accept和客户端请求,当我们在等待客户端发送请求时,如果这时有新的客户端想要连接进来,就无法连接。
在这个场景下,我们就能引入多线程,让一个线程专门负责连接服务器。同时可以引入线程池优化效率。
问题4
服务器的socket要记得及时关闭,因为这个socket的生命周期不再是跟随整个服务器了
问题5
当我们的客户端多到一定程度时,服务器无法承担,此时我们就可以使用操作系统中内置的IO多路复用,这个操作本质上是让一个线程处理多个客户端的请求。多个客户端发送数据大概率不是同时的,客户端很可能在阻塞等待。
优化后服务器代码
public class TcpEchoServer {private ServerSocket serverSocket= null;//这里和Udp服务器类似,也是在构造的时候指定端口号public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {ExecutorService executorService = Executors.newCachedThreadPool();System.out.println("启动服务器");while(true) {//对于tcp来说,需要向处理客户端发过来的连接//通过读写clientSocket和客户端进行通信//如果没有客户端发送连接,那么accept就会阻塞//主线程负责进行accept,每次accept到一个新客户端,就创建一个新线程来处理客户端的请求Socket clientSocket = serverSocket.accept();executorService.submit(() -> {processConnection(clientSocket);});}}//处理一个客户端的连接//可能涉及多个客户端的连接和响应,这里暂不涉及private void processConnection(Socket clientSocket) {try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);//处理三个步骤while(true) {//1.读取请求并解析,可以直接read,也可以借助scannerif(!scanner.hasNext()) {break;}String request = scanner.next();//2.根据请求计算响应String response = procoss(request);//3.返回响应到客户端writer.println(response);writer.flush();}} catch (IOException e) {e.printStackTrace();}}private String procoss(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
优化后客户端代码
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIp, int port) throws IOException {//这里可以直接使用字符串的ip作为参数socket = new Socket(serverIp, port);}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);//从控制台读取请求String request = scanner.next();//发送给服务器printWriter.println(request);//加上刷新缓冲区操作,才是真正写入网卡printWriter.flush();//获取服务器的响应String response = scannerNet.next();//打印到控制台System.out.println(response);} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);tcpEchoClient.start();}
}