1.网络编程中的基本概念
1.1 请求和响应
一般来说,如果我们想要获取一个网络资源,就需要涉及到两次网络传输:
第一次:请求数据的发送
第二次:响应数据的发送
就好比我们在快餐店点一份盖浇饭:
先发起请求:老板来份盖浇饭,再有快餐店提供的对应响应:提供一份炒饭
1.2 客户端–服务器通信流程
服务器:在网络传输数据场景下负责提供服务的一方
客户端:获取服务的一端
2.Socket套接字
操作系统把与网络编程一些相关的操作封装了起来,提供了一组API供程序猿来调用,这组API就是Socket API
这组API的主要功能就是为了操作网卡这个硬件设备,来完成数据的传输。
操作系统对于网卡进行了抽象,在进程想去操作网卡的时候,就会打开一个“scoket”文件,通过读写这个文件,就能读写网卡了。(一切皆文件的思想)
系统所提供的Socket API共有两种风格:
一种是基于UDP的(数据包),另一种是基于TCP的(字节流)
这两种API有各自以下特点:
对上述特点的解释:
有连接就像是打电话,无连接就像是发微信
可靠传输:发送方能够知道对方是否收到了消息
不可靠传输:发送方不能知道对方是否收到了消息
面向数据包:以一个一个的数据包为基本单位进行数据传输(只能整个发送,半个接收不到,也发不出去)
面向字节流:可以自己定义发送和接收的字节总数及方式,比较灵活
全双工:双向通信-》A和B可以同时向对方发送接收数据
半双工:单向通信-》要么A给B发,要么B给A发,不能同时发
3.UDP网络编程
3.1 关键API
DatagramSocket: 对socket文件进行了封装
构造方法:
1)无参:客户端使用(此时端口号由系统分配)
2)有参:服务器使用(此时由用户指定端口号)
主要方法:
DatagramPacket: 对UDP数据包进行封装
构造方法:
1)传入空的缓冲区,构造一个空的packet(在receive方法时使用)
2)传入一个有数据的缓冲区,指定目标的IP和端口
3)传入一个有数据的缓冲区,指定目标的SocketAddress(内含目标的IP和端口)
3.2 回显程序
该程序只是简单实现了——客户端发送什么,服务器就回应什么的功能。
服务器:
设计步骤:
1.读取请求
2.根据请求来计算响应
3.把响应写回客户端
4.加上日志打印
public class UdpEchoServer { private DatagramSocket socket = null; /** * 服务器在启动的时候需要绑定一个端口号 * 在收到数据的时候,就会根据这个端口号来决定把数据交给哪个进程 * * @param port 端口号(范围为0~65535) * @throws SocketException */ public UdpEchoServer(int port) throws SocketException { socket = new DatagramSocket(port); } /** * 启动服务器的方法 */ public void start() throws IOException { System.out.println("服务器启动!"); //服务器一般是一直运行着的 while (true) { //1.读取请求 DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); /*参数DatagramPacket是一个输出型参数,socket中读到的数据会设置到这个参数的对象中 在构造的时候,需要指定一个缓冲区(就是一段内存空间,通常使用byte[])*/ socket.receive(requestPacket); /*当前服务器不知道客户端什么时候发来请求,receive方法会阻塞等待 如果有请求过来了,此时receive就会返回*/ String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); //把requestPacket对象里面的内容取出来,作为一个字符串 //2.根据请求来计算响应 String response = process(request); //3.把响应写回客户端 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); /*此处给DatagramPacket中设置的长度,必须是“字节的个数”, 而且在构造的时候,还需要指定这个包发给谁(其实发送给的目标就是发请求的那一方) */ socket.send(responsePacket); //4.加上日志打印 String log = String.format("[%s:%d] req: %s ;resp: %s", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); System.out.println(log); } } /** * 根据请求来计算响应的方法(此处仅为一个回显服务器,只把客户端发来的请求返回即可) * * @param request * @return */ private String process(String request) { return request; } public static void main(String[] args) throws IOException { UdpEchoServer server=new UdpEchoServer(9090); server.start(); } }
客户端:
设计步骤:
1.从标准输入流输入数据
2.把字符串构造成UDP请求,并发送数据
3.尝试从服务器读取响应
4.显示这个结果
public class UdpEchoClient { private DatagramSocket socket=null; private String serverIP; private int serverPort; /** * * @param serverIP 服务器的IP * @param serverPort 服务器的端口 * @throws SocketException */ public UdpEchoClient(String serverIP,int serverPort) throws SocketException { this.serverIP=serverIP; this.serverPort=serverPort; this.socket = new DatagramSocket(); //此处实例化Socket不用指定端口号,操作系统会随机分配空闲端口 } public void start() throws IOException { Scanner scanner=new Scanner(System.in); while(true) { System.out.print("->"); //1.从标准输入流输入数据 String request=scanner.nextLine(); if(request.equals("exit")) { System.out.println("exit!"); return; } //2.把字符串构造成一个UDP请求,并发送数据 DatagramPacket requestPacket=new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(serverIP),serverPort); socket.send(requestPacket); //3.尝试从服务器读取响应 DatagramPacket responsePacket=new DatagramPacket(new byte[4096],4096); socket.receive(responsePacket); //4.显示这个结果 String response=new String(responsePacket.getData(),0, responsePacket.getLength()); String log=String.format("req: %s ;resp: %s",request,response); System.out.println(log); } } public static void main(String[] args) throws IOException { UdpEchoClient client=new UdpEchoClient("127.0.0.1",9090); client.start(); } }
3.3 翻译程序
与2.2的回显程序框架相同,只是在process方法中加入了逻辑(也就是根据请求来计算响应的模块)。
该程序利用哈希表一一对应的结构,完成了一个简单的翻译功能。
服务器:
设计步骤:
1.读取请求并解析
2.根据请求计算响应
3.把响应写回到客户端
4.日志打印
public class UdpDictServer { private DatagramSocket socket=null; private HashMap<String,String> dict=new HashMap<>(); //词典 哈希表 一一对应 public UdpDictServer(int port) throws SocketException { this.socket = new DatagramSocket(port); //初始化词典内容 dict.put("hello","你好"); dict.put("cat","小猫"); dict.put("dog","小狗"); } public void start() throws IOException { System.out.println("服务器启动!"); while(true) { //1.读取请求并解析 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.把响应写回到客户端 DatagramPacket responsePacket=new DatagramPacket(response.getBytes(), response.getBytes().length,requestPacket.getSocketAddress()); socket.send(responsePacket); //4.日志打印 String log=String.format("[%s:%d] req: %s;resp: %s",requestPacket.getAddress(), requestPacket.getPort(),request,response); System.out.println(log); } } private String process(String request) { return dict.getOrDefault(request,"[单词在词典中不存在!]"); } public static void main(String[] args) throws IOException { UdpDictServer server=new UdpDictServer(9090); server.start(); } }
客户端:
设计步骤:
1.读取输入的数据
2.构造请求并发送给服务器
3.从服务器获取响应
4.把数据显示给用户
public class UdpDictClient { private DatagramSocket socket=null; private String serverIP; private int serverPort; public UdpDictClient(String serverIP,int serverPort) throws SocketException { this.serverIP=serverIP; this.serverPort=serverPort; this.socket=new DatagramSocket(); } public void start() throws IOException { Scanner scanner=new Scanner(System.in); while (true) { //1.读取输入的数据 System.out.print("->"); String request=scanner.next(); if(request.equals("exit")) { System.out.println("goodbye!"); return; } //2.构造请求并发送给服务器 DatagramPacket requestPacket=new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIP),serverPort); socket.send(requestPacket); //3.从服务器获取响应 DatagramPacket responsePacket=new DatagramPacket(new byte[4096],4096); socket.receive(responsePacket); String response=new String(responsePacket.getData(),0, responsePacket.getLength()); //4.把数据显示给用户 String log=String.format("req: %s;resp: %s",request,response); System.out.println(log); } } public static void main(String[] args) throws IOException { UdpDictClient client=new UdpDictClient("127.0.0.1",9090); client.start(); } }
4.TCP网络编程
4.1 关键API
ServerSocket: 用于服务器
Socket:服务器和客户端都需要使用
服务器流程:
- 1.创建ServerSocket关联上一个端口号。(该socket称为listenSocket,用于建立连接)
- 2.调用ServerSocket的accept方法,accept方法返回一个Socket实例,称为clientSocket(用于客户端和服务器的进一步交互)
- 3.使用clientSocket的getInputStream和getOutputStream得到字节流对象,就可以进行读取和写入操作了。
- 4.当客户端断开连接后,服务器应及时关闭clientSocket,防止出现文件资源泄露的情况
客户端流程:
- 1.创建一个Socket对象,创建时同时指定服务器的IP和端口号(该操作会让客户端和服务器建立TCP连接,也就是“三次握手”的过程,具体后续讲解)
- 2.客户端可以通过Socket对象的getInputStream和getOutputStream来和服务器进行通信
4.2 回显程序
服务器设计步骤:
1.读取请求并解析
2.根据请求计算响应
3.把响应写回客户端
public class TcpEchoServer { private ServerSocket listenSocket=null; public TcpEchoServer(int port) throws IOException { this.listenSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); while(true) { /* UDP的服务器进入主循环,就直接尝试receive读取请求了, 但是TCP是有连接的,先需要做的是建立好连接,当服务器运行的时候, 不确定当前是否有客户端来建立连接 如果客户端没有建立连接,accept就会阻塞等待 如果客户端建立连接了,此时accept就会返回一个Socket对象, 进一步的服务器与客户端间的交互就交给clientSocket来完成了 */ Socket clientSocket=listenSocket.accept(); processConnection(clientSocket); } } /** * 处理一个连接。在这个连接中可能会涉及到客户端和服务器的多次交互 * @param clientSocket */ private void processConnection(Socket clientSocket) throws IOException { String log=String.format("[%s:%d] 客户端上线!", clientSocket.getInetAddress().toString(),clientSocket.getPort()); System.out.println(log); try(InputStream inputStream= clientSocket.getInputStream(); OutputStream outputStream= clientSocket.getOutputStream()) { while (true) { //1.读取请求并解析 Scanner scanner=new Scanner(inputStream); /* 进入方法后代码在Scanner.hasNext阻塞,等待客户端发送请求; 在读取到客户端请求后会结束阻塞,读取到数据并处理返回响应, 循环转一圈后又会在hasNext阻塞。 只有当客户端退出的时候才会结束循环。 */ if(!scanner.hasNext()) { log=String.format("[%s:%d] 客户端下线!", clientSocket.getInetAddress().toString(),clientSocket.getPort()); System.out.println(log); //打印下线日志 break; } String request=scanner.next(); //2.根据请求计算响应 String response=process(request); //3.把响应写回客户端 PrintWriter writer=new PrintWriter(outputStream); writer.println(response); writer.flush(); log=String.format("[%s:%d] req: %s;resp: %s", clientSocket.getInetAddress().toString(), clientSocket.getPort(),request,response); System.out.println(log); } } catch (IOException e) { e.printStackTrace(); } finally { /* 当前的clientSocket的生命周期不是跟随整个程序,而是和连接相关 因此就需要每个连接结束时,都要进行关闭 否则随着连接的增多,这个socket文件就可能出现资源泄露的情况 */ clientSocket.close(); } } /** * 回显服务器:客户端发什么就返回什么 * @param request * @return */ public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer server=new TcpEchoServer(9090); server.start(); } }
客户端设计步骤:
1.从键盘上读取用户输入的内容
2.把读取的内容构造成请求,发送给服务器
3.从服务器读取响应并解析
4.把结果显示到界面上
public class TcpEchoClient { private String serverIP; private int serverPort; private Socket socket=null; public TcpEchoClient(String serverIP,int serverPort) throws IOException { this.serverIP = serverIP; this.serverPort=serverPort; //TCP网络编程中,让socket创建的同时,就和服务器尝试建立连接 this.socket=new Socket(serverIP,serverPort); } public void start() { 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")) { break; } //2.把读取的内容构造成请求,发送给服务器 PrintWriter printWriter=new PrintWriter(outputStream); printWriter.println(request); printWriter.flush(); //3.从服务器读取响应并解析 Scanner respScanner=new Scanner(inputStream); String response=respScanner.next(); //4.把结果显示到界面上 String log=String.format("req: %s;resp: %s",request,response); System.out.println(log); } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { TcpEchoClient client=new TcpEchoClient("127.0.0.1",9090); client.start(); } }
4.3 回显程序(多线程升级版)
在实际开发中,一个服务器应该对应很多个客户端,但如果按照4.2的代码写法,一个服务器只能服务于一个客户端。在idea中启动第二个客户端时,服务器并不会提示客户端2上线。
我们来看服务器的操作流程:
最开始时,没有客户端建立连接,此时服务器阻塞在accept方法上
当有客户端连上后,accept返回,得到clientScoket,并进入processConnection方法中;进入方法后代码在Scanner.hasNext阻塞,等待客户端发送请求;在读取到客户端请求后会结束阻塞,读取到数据并处理返回响应,循环转一圈后又会在hasNext阻塞。只有当客户端退出的时候才会结束循环。
为了解决这个问题,我们可以主线程中循环调用accept,每次获取到一个连接,就创建一个线程来给客户端提供服务
代码如下:
public class TcpThreadEchoServer { private ServerSocket listenSocket=null; public TcpThreadEchoServer(int port) throws IOException { this.listenSocket =new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); while(true) { //主线程中循环调用accept,就能保证在一次调用完成后,立刻进行再次调用 Socket clientSocket=listenSocket.accept(); //每次获取到一个连接,就创建一个线程来给客户端提供服务 Thread t=new Thread(()->{ try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } }); t.start(); } } private void processConnection(Socket clientSocket) throws IOException { String log=String.format("[%s:%d] 客户端上线!", clientSocket.getInetAddress().toString(),clientSocket.getPort()); System.out.println(log); try(InputStream inputStream= clientSocket.getInputStream(); OutputStream outputStream= clientSocket.getOutputStream()) { while(true) { //1.读取并请求解析 Scanner scanner=new Scanner(inputStream); if(!scanner.hasNext()) { log=String.format("[%s:%d] 客户端下线!", clientSocket.getInetAddress().toString(),clientSocket.getPort()); System.out.println(log); break; } String request= scanner.next(); //2.根据请求计算响应 String response=process(request); //3.把响应写回客户端 PrintWriter printWriter=new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); log=String.format("[%s:%d] req: %s;resp: %s",clientSocket.getInetAddress().toString(), clientSocket.getPort(),request,response); System.out.println(log); } } catch (IOException e) { e.printStackTrace(); } finally { clientSocket.close(); } } public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpThreadEchoServer server=new TcpThreadEchoServer(9090); server.start(); } }
4.4 回显程序(线程池升级版)
在4.3中我们通过多线程的方法解决了一个服务器服务于多个客户端的问题,但是如果有很多的客户端连接又退出,就会导致当前的服务器里频繁创建销毁线程,有很大的成本,为了改进这个问题,我们可以引进线程池,这样就既能处理多个客户端,也不需要频繁创建销毁线程了
代码如下:
public class TcpThreadPoolEchoServer { private ServerSocket listenSocket=null; public TcpThreadPoolEchoServer(int port) throws IOException { this.listenSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); ExecutorService executorService= Executors.newCachedThreadPool(); while (true) { Socket clientSocket=listenSocket.accept(); //使用线程池来处理当前的processConnection executorService.submit(()->{ try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } }); } } public void processConnection(Socket clientSocket) throws IOException { String log = String.format("[%s:%d] 客户端上线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { while (true) { // 1. 读取请求并解析 Scanner scanner = new Scanner(inputStream); if (!scanner.hasNext()) { log = String.format("[%s:%d] 客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); break; } String request = scanner.next(); // 2. 根据请求计算响应 String response = process(request); // 3. 把响应写回到客户端 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); log = String.format("[%s:%d] req: %s; resp: %s", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); System.out.println(log); } } catch (IOException e) { e.printStackTrace(); } finally { clientSocket.close(); } } public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpThreadPoolEchoServer server=new TcpThreadPoolEchoServer(9090); server.start(); } }
4.5 翻译程序
有了上边的优化,我们再来给服务器添加功能,完成翻译,由UDP翻译程序我们可以知道,其实主要更改的逻辑就是process方法中根据请求计算响应,为了代码复用,我们可以直接继承4.4,成为其子类,再只重写process方法即可。
代码如下:
public class TcpDictServer extends TcpThreadPoolEchoServer{ private HashMap<String,String> dict=new HashMap<>(); public TcpDictServer(int port) throws IOException { super(port); //初始化哈希表 dict.put("hello","你好"); dict.put("cat","小猫"); dict.put("dog","小狗"); } //start方法不需要变动 //processConnection方法也不需要改变 //仅把回显功能改为翻译功能即可 (仅修改process) @Override public String process(String request) { return dict.getOrDefault(request,"[要查的词不存在]"); } public static void main(String[] args) throws IOException { TcpDictServer server=new TcpDictServer(9090); server.start(); } }