网络编程
指网络上的主机, 通过不同的进程, 以编程的方式实现 网络通信 (或称为网络数据传输) (同一台主机的不同进程间, 基于网路的通信也可以称为网络编程)
服务端 & 客户端
网络编程中的概念
服务端: 网络通信中, 提供服务的一方 (进程)
客户端: 网络通信中, 获取服务的一方 (进程)
Socket 套接字
Socket 套接字, 是由系统提供的, 用于网络通信的技术, 是基于 TCP/IP 协议的, 网络通信的基本操作单元. 基于 Socket 套接字的网络程序开发就是网络编程
Socket 的分类
Socket 套接字根据 针对的传输层协议 可以分为三类:
- 流套接字 – 使用传输层 TCP 协议
- 数据报套接字 – 使用传输层 UDP 协议
- 原始套接字 – 使用自定义传输层协议
TCP & UDP
TCP (Transmission Control Protocol) 传输控制协议 (传输层协议), 特点:
- 有连接
- 可靠传输
- 面向字节流
- 全双工
- 有发送缓冲区, 也有接收缓冲区
- 大小不限 (基于 IO 流)
UDP (User Datagram Protocol) 用户数据报协议 (传输层协议), 特点:
- 无连接
- 不可靠传输
- 面向数据报
- 全双工
- 有接收缓冲区, 无发送缓冲区
- 大小受限, 一次最多 64K
TCP 中的长短连接
短连接: 每次接收到数据并返回响应后, 都会关闭连接. (短连接只能一次收发数据)
长连接: 不关闭连接, 一直保持连接状态, 双方不停的收发数据. (长连接可以多次收发数据)
长短连接拥有不同的特点 :
建立连接, 关闭连接都需要消耗资源, 因此长连接效率更高
短连接一般是客户端主动向服务端发送请求.
而长连接可以是客户端主动向服务端发送请求, 也可以是服务端主动向客户端推送消息
短连接适用于客户端请求频率不高的场景, eg: 浏览网页.
长连接适用于客户端和服务器通信频繁的场景, eg: 聊天室, 实时游戏
BIO & NIO
长连接有两种实现方式, 基于 BIO 的长连接和基于 NIO 的长连接
- BIO (同步阻塞 IO) : 基于 BIO 的长连接会一直占用系统资源. 在并发情况下, 每个连接都需要阻塞等待, 接收数据. 即每个连接在一个线程中运行, 消耗极大.
BIO & NIO
长连接有两种实现方式, 基于 BIO 的长连接和基于 NIO 的长连接
- BIO (同步阻塞 IO) : 基于 BIO 的长连接会一直占用系统资源. 在并发情况下, 每个连接都需要阻塞等待, 接收数据. 即每个连接在一个线程中运行, 消耗极大.
- 如果使用 小众协议 / 自定义协议, 这个动作称为 序列化 (一般是将对象转换成特定数据格式)
对于 接收数据 时的 数解析动作 来说
- 如果使用 知名协议, 这个动作称为 分用
- 如果使用 小众协议 / 自定义协议, 这个动作称为 反序列化 (一般是基于接收数据特定的格式, 转换成程序中的对象 )
协议的设计
一般根据字段的特点进行设计
除此之外, 协议中还会包含: 状态码, 请求类型 等等内容
基于 TCP 的回显服务器设计
服务端
- 使用 ServerScoket 创建服务端程序
- 调用
ServerSocket.accept()
方法建立连接 - 读取请求数据
- 根据请求计算响应 (此处为回显服务器, 将请求数据当作响应数据直接返回)
- 返回响应数据
package network; 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.nio.charset.StandardCharsets; import java.util.Scanner; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; 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) { Socket clientSocket = serverSocket.accept(); 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 sc = new Scanner(inputStream); if(!sc.hasNext()) { // 没有下个数据, 说明读完了. (客户端关闭了连接) System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort()); break; } // 注意, 此处使用 next 是一直读取到 换行符/空格/其他空白符 结束, 但是最终结果不包含上述 空白符(因此后面要自己再补一个空白符). String request = sc.next(); // 根据请求构造响应 String response = process(request); // 3.返回响应结果. outputStream.write((response+"\n").getBytes(StandardCharsets.UTF_8)); outputStream.flush(); // OutputString 没有 write(String) 这样的功能. 可以把 String 里的字节数组拿出来, 进行写入; // 也可以使用 字符流 来转换 // PrintWriter printWriter = new PrintWriter(outputStream); // 此处使用 println 来输出 response, 让结果中带有一个 \n 换行. 方便对端来接收解析. // printWriter.println(response); // flush 用来刷新缓冲区, 保证当前写入的数据, 一定会发送出去(println 输出后有可能会先放在缓冲区, 等缓冲区满会自动输出) // printWriter.flush(); System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); } } catch (IOException e) { e.printStackTrace(); } finally { // finally 里的内容保证一定可以执行到 try { clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9090); server.start(); } }
客户端
- 使用 Socket 创建客户端程序
- 调用 Socket 的构造方法时, 会自动进行 TCP 连接操作
- 发送请求数据 (数据这里是由键盘读入)
- 接收返回响应数据
package network; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Scanner; public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String ip, int port) throws IOException { // Socket 构造方法, 能够识别 点分十进制 格式的 ip 地址, 比 DatagramPacket 更方便. // new 对象的同时, 就会进行 Tcp 连接操作. socket = new Socket(ip, port); } 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("bye")) { System.out.println("goodbye"); break; } // 2. 把读取的内容构造成请求, 发送给服务器. outputStream.write((request+"\n").getBytes(StandardCharsets.UTF_8)); outputStream.flush(); // PrintWriter printWriter = new PrintWriter(outputStream); // printWriter.println(request); // // flush 保证数据不会停留在缓冲区 // 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(); } }
基于 UDP 的回显服务器设计
服务端
- 使用 DatagramSocket 创建 Socket 对象, 来建立连接
- 读取请求数据 (使用 DatagramPacket 来接收数据 [输出型参数] )
- 根据请求计算响应
- 返回响应数据
package network; import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.SocketException; // UDP 版本的回显服务器 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 requestPacket = new DatagramPacket( new byte[4096] ,0, 4096); socket.receive(requestPacket); // 此时这个 DatagramPacket 是一个特殊的对象, 但不方便直接进行处理. 可以把这里包含的数据拿出来, 构造成一个字符串, 以便处理 // requestPacket.getLength(): 是数据内容的长度 requestPacket.getData().length: 是构成 packet 的字节数组的容量 String request = new String( requestPacket.getData(), 0, requestPacket.getLength()); //new String 后面的参数是要数据内容的长度 // 2. 根据请求计算响应, 由于此处是回显服务器, 相应和请求相同 String response = process(request); // 3. 把响应写回到客户端. send 的参数也是 DatagramPacket. 需要把这个 Packet 对象构造好. // 此处构造的响应对象, 不能是用空的字节数组构造了, 而是要使用响应数据来构造. //SocketAddress 里面有 address 和 port (IP和端口号) DatagramPacket responsePacket = new DatagramPacket( response.getBytes(), 0 , response.getBytes().length,requestPacket.getSocketAddress()); socket.send(responsePacket); // 4. 打印本次请求相应的处理中间结果 System.out.printf("[%s:%d] req: %s; resp: %s;\n", requestPacket.getAddress().toString(),requestPacket.getPort(), request, response); } } // 这个方法就是 "根据请求计算相应" public String process(String request) { return request; } public static void main(String[] args) throws IOException { // 端口号的指定, 1024 ~ 65535 均可 UdpEchoServer server = new UdpEchoServer(9090); server.start(); } }
客户端
- 使用 DatagramSocket 来创建 Socket 对象
- 创建 DatagramPacket (内含 信息发送到的服务器信息 : ip, port)
- 发送请求数据 (DatagramPacket 中的服务器信息, 可以告诉 Socket 对象, “请把我发送给谁”)
- 接收响应数据
package network; import java.io.IOException; import java.net.*; import java.util.Scanner; // UDP 版本的回显客户端 public class UdpEchoClient { private DatagramSocket socket = null; // 存储的是服务器的 ip 和 端口号, 以便通讯使用 private String serverIp; private int serverPort; // 一次通信, 需要有两个 ip, 两个 端口 // 客户端的 ip : 127.0.0.1 已知 // 客户端的 port 是系统自动分配 // 服务器的 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 sc = new Scanner(System.in); while(true) { // 1. 从控制台读取要发送的数据 System.out.println(">"); String request = sc.next(); if(request.equals("bye")) { System.out.println("goodbye!"); break; } // 2. 构造成 UDP 请求, 并发送 // 构造这个 Packet 的时候, 需要把 severIp 和 port 都传进来. 但是此处 IP 地址需要填写一个 32 位的整数形式 // 上述的 IP 地址是一个字符串. 需要使用 InetAddress.getByName 来进行一个转换. DatagramPacket requestPacket = new DatagramPacket( request.getBytes(), 0, request.getBytes().length, InetAddress.getByName(this.serverIp), this.serverPort); socket.send(requestPacket); // 3. 读取服务器的 UDP 响应, 并解析 DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 0, 4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(), 0 , responsePacket.getLength()); // 4. 把解析好的结果显示出来 System.out.printf("[%s:%d] response: %s; request: %s;\n", responsePacket.getAddress().toString(), responsePacket.getPort(), response, request); } } public static void main(String[] args) throws IOException { UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090); client.start(); } }