文章目录
我们知道在网络通信中, 数据的发送是从应用层开始, 一直封装到物理层然后进行发送的, 应用层要将数据交给传输层进行封装; 而接收方拿到数据后是从物理层到应用层进行分用, 传输层要将拿到的数据再分用给应用层进行使用, 网络编程实际操作中最关键的就是我们所能控制的应用层和传输层之间的交互, 而在操作系统中提供了一组API即socket
, 用来实现应用层和传输层之间的交互, Java当中把操作系统提供的API进行了进一步封装以便我们进行使用.
常见传输层协议有UDP和TCP两种, 其中UDP
的特点是无连接, 不可靠传输, 面向数据报, 全双工; TCP
的特点是有连接, 可靠传输, 面向字节流, 全双工.
使用TCP协议, 必须是通信双方先建立连接才能进行通信(想象打电话的场景), 而使用UDP协议在无连接的情况下可以进行通信(想象发微信, 短信的场景).
这里的可靠与不可靠传输指的不是安全性质, 而是说你发送出数据后, 能不能判断对方已经收到, 如果能够确定对方是否收到则就是可靠传输, 否则就是不可靠传输.
面向字节流就类文件读写数据的操作, 是 “流” 式的; 而面向数据报的话数据传输则是以一个个的 “数据报” 为基本单位(一个数据报可能是若干个字节, 是带有一定的格式的).
全双工是指一条通信链路, 可以双向传输(同一时间既可以发, 也可以收); 而半双工是一条链路, 只能单向通信.
1. UDP套接字
UDP
类型的Socket, 涉及两个核心类, 一个是DatagramSocket
, 其实例的对象表示UDP版本的Socket, 操作系统中将网卡这种硬件设备也抽象成了文件进行处理, 这个Soket对象也就成了文件描述表上面的一项, 通过操作这个Socket文件来间接的操作网卡, 就可以通信了.
有一个Socket对象就可以与另一台主机进行通信了, 但如果要和不同的主机通信, 就需要创建多个Socket对象, 使用DatagramSocket既可以发, 也可以收, 体现了UDP全双工的特点.
构造方法 | 作用 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket, 绑定到任意一个随机端口号(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket, 绑定到本机指定端口(一般用于服务器) |
关于这两个方法也是好理解的, 一般服务器的端口号需要自己指定, 如果随机分配的话, 你的客户端要怎么访问你的服务器呢?毕竟客户端才是主动的一方, 知道服务器在哪里才能找到它进行通信吧.
关键方法 | 作用 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据 (如果没有接收到数据报, 进行阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据包 (不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
这里receive
方法参数传入的是一个空的对象, receive方法内部会对这个对象进行填充, 从而构造出结果数据, 这个参数也是一个输出型参数.
第二个是DatagramPacket
, 表示一个UDP数据报, 在UDP的服务器和客户端都需要使用到, 接收和发送的数据就是在传输DatagramPacket对象, 这就是体现了UDP面向数据报的特点.
构造方法 | 作用 |
---|---|
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket用来接收数据报,接收的数据保存在字节数组里, 接受指定长度 |
DatagramPacket(byte[] buf, int offset,int length, SocketAddress address) | 构造一个DatagramPacket用来发送数据报,发送的数据为字节数据,从0到指定长度,address用来指定目的主机的IP和端口号 |
关键方法 | 作用 |
---|---|
InetAddress getAddress() | 从接受的数据报中,获取发送端IP地址,或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号,或从发送的数据报中,获取接收端的端口号 |
SocketAddress getSocketAddress() | 从接收的数据报中,获取发送端主机SocketAddress,或从发送的数据报中,获取接收端的SocketAddress(IP地址+端口号) |
byte[] getData() | 获取数据报的数据 |
在网络编程中, 一定要注意区分清楚服务器与客户端使用之间使用的五元组, 具体如下:
- 源IP, 就是发送方IP.
- 源端口, 发送方端口号, 服务器需要手动指定, 客户端让系统随机分配即可.
- 目的IP, 接收方IP, 包含在拿到的数据报中, 服务器响应时的目的IP就在客户端发来的数据报中, 客户端发送请求时的目的IP就是服务器的IP.
- 目的端口, 接收方端口号包含在数据报中, 服务器响应时的目的端口就在客户端发来的数据报中, 客户端发送请求时的目的端口就是服务器的端口号.
- 协议类型, 如UDP/TCP.
2. UDP客户端回显服务器程序
正常来说, 客户端和服务器程序要实现的是, 客户端发送请求, 服务器接收请求后, 要根据请求计算响应(业务逻辑), 然后把响应返回给客户端.
而在这里只是要演示Socket api的用法, 就不涉及业务逻辑了, 我们让服务器收到什么就给客户端返回什么, 这样实现服务器就叫做回显服务器.
2.1 UDP回显服务器
UDP服务器设计步骤:
- 创建Socket实例对象(
DatagramSocket
对象), 需要指定服务器的端口号, 因为服务器是被动接收和处理请求的一端, 而客户端是主动发起请求的一端, 客户端必须得知道服务器在哪里才能发送请求, 也就是需要知道服务器的端口号. - 服务器启动, 读取客户端请求, 把得到的数据填充到
DatagramPacket
对象中, 这里得到的请求中是包含着有关客户端的地址信息的(IP+端口号), 可以通过getSocketAddress
方法获取到. - 处理客户端请求, 计算响应, 这里实现的是一个回显服务,直接根据请求返回相同的响应即可, 但在实际开发中, 这个处理请求的部分其实是最关键的.
- 将响应返回给客户端, 要将响应数据构造成
DatagramPacket
对象, 注意要给出客户端的地址信息.
代码如下:
import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.SocketException;// UDP版本的回显服务器public class UdpEchoServer { //准备好socket实例,准备传输 private DatagramSocket socket = null; //构造时指定服务器的端口号 public UdpEchoServer(int port) throws SocketException { this.socket = new DatagramSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); //服务器要给多个客户端提供服务 while (true) { //1. 读取客户端发过来的请求 DatagramPacket requstPacket = new DatagramPacket(new byte[4096], 4096); //receive内部会对参数对象进行填充数据,填充的数据来源于网卡socket.receive(requstPacket); //解析收到的数据包,一般解析成字符串进行处理; 构造字符串的参数分别为数据数组,存入数据数组的起始下标,长度 String requst = new String(requstPacket.getData(), 0, requstPacket.getLength()); //2. 根据请求计算响应 String response = process(requst); //3. 把响应写回到客户端中 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requstPacket.getSocketAddress()); socket.send(responsePacket); //输出一下发送日志 System.out.printf("[%s:%d] req: %s, resp: %s \n", requstPacket.getAddress().toString(), requstPacket.getPort(), requst, response); } } public String process(String requst) { //...处理数据,这里是回显服务直返回原数据即可 return requst; } public static void main(String[] args) throws IOException { // 1024 到 65535即可 UdpEchoServer server = new UdpEchoServer(9090); server.start(); }}
上面代码中要注意
这里response.getBytes().length
要注意不能写成response.length()
, 因为DatagramPacket
不认字符只认字节, response.length()获取的是有效字符数, response.getBytes().length获取的是有效字节数.
代码中的循环是一个死循环, 这样设施也是没有问题的, 大部分服务器都是要7 * 24
小时运行的.
2.2 UDP客户端
UDP客户端设计步骤:
- 创建Socket实例对象(
DatagramSocket
对象), 可以指定端口号创建也可以让系统随机分配, 自己指定端口号容易与已经被使用的端口号冲突, 所以这里让所以系统随机分配更就行了, 不用担心端口号的冲突的问题. - 用户输入请求, 并使用
DatagramPacket
对象打包数据请求, 要注意给出服务器的地址(IP和端口号), 并将请求发送. - 读取服务器返回的响应并进行处理.
代码如下:
import java.io.IOException;import java.net.*;import java.util.Scanner;//UDP版本的回显客户端public class UdpEchoCliet { private DatagramSocket socket = null; //需要指定服务器的ip和端口号 private String serverIp = null; private int serverPort = 0; public UdpEchoCliet(String serverIp, int serverPort) throws SocketException { //让系统分配一个空闲的端口号即可 this.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("客户端退出"); break; } // 2. 构造UDP请求,并发送 //上面的IP地址是一个字符串,需要使用InetAddress.getByName来转换成一个整数. 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. 把解析好的结果显示出来 System.out.println(response); } } public static void main(String[] args) throws IOException { //在本机上测试,127.0.0.1是IP,表示自己主机 UdpEchoCliet cliet = new UdpEchoCliet("127.0.0.1", 9090); cliet.start(); }}
上面说到, 为了防止客户端端口号的冲突, 我们让系统为客户端随机分配了端口, 那么客户端为什么就不怕端口号冲突呢?
其实也好理解, 服务器肯定是程序员自己手里的机器, 上面运行啥, 程序员就可以安排哪个程序使用哪个端口, 也就是说, 服务器上面的程序是可控的; 而客户端是运行在用户电脑上的, 环境复杂, 不可控性高.
对于客户端服务器程序来说一个服务器是要给许多客户端提供服务的, 但是IDEA
默认只能启动一个客户端, 要想测试多个客户端, 需要我们手动设置一下IDEA.
第一步, 右键代码编辑处, 按下图进行操作.
第二步, 找到Modify options
并点击.
第三步, 勾选上Allow multiple instances
后, 点击OK即可.
测试结果:
首先一定要先启动服务, 然后再启动客户端进行测试.
2.3 UDP实现查词典的服务器
上面实现的回显服务器缺乏业务逻辑, 这里在上面的代码的基础上稍作调整, 实现一个 “查词典” 的服务器(将英文单词翻译成中文解释), 这里其实就很简单了, 对于客户端的代码还可以继续使用, 服务器只需把处理请求部分的代码修改即可, 我们可以继承上面的回显服务器, 重写请求部分的代码, 英语单词和汉语解释可以由一个哈希表实现映射关系, 构成词库, 然后根据请求来获取哈希表中对应的汉语解释即可.
import java.io.IOException;import java.net.SocketException;import java.util.HashMap;import java.util.Map;public class UdpDictServer extends UdpEchoServer{ private Map<String, String> dict = new HashMap<>(); public UdpDictServer(int port) throws SocketException { super(port); //词库 dict.put("cat", "小猫"); dict.put("dog", "小狗"); dict.put("bird", "小鸟"); dict.put("apple", "苹果"); dict.put("banana", "香蕉"); dict.put("strawberry", "草莓"); dict.put("watermelon", "西瓜"); //... } @Override public String process (String request) { //查词典 return dict.getOrDefault(request, "当前单词没有查到结果!"); } public static void main(String[] args) throws IOException { UdpDictServer server = new UdpDictServer(9090); server.start(); }}
关于网络编程这里涉及一个重要的异常, 如下图:
这是一个表示端口冲突的异常, 一个端口只能被一个进程使用, 如果有多个进程使用同一个端口, 就会出现如上图的异常.
1. TCP套接字
TCP相比于UDP有很大的不同, TCP
的话首先需要通信双方成功建立连接然后才可以进行通信, TCP进行网络编程的方式和文件读写中的字节流类似, 是字节为单位的流式传输, 如果对下面涉及的IO流操作不熟悉的话, 可以看一看我前面的一篇博客 Java文件IO操作及案例 .
对于TCP的套接字, Java提供了两个类来进行数据的传输, 一个是ServerSocket
, 是专门给服务器使用的Socket对象, 用来让服务器接收客户端的连接;
构造方法 | 解释 |
---|---|
ServerSocket(int port) | 创建一个服务器套接字Socket,并指定端口号 |
关键方法 | 解释 |
---|---|
Socket accept() | 开始监听指定端口,有客户端连接时,返回一个服务端Socket对象,并基于该Socket对象与客户端建立连接,否则阻塞等待 |
void close() | 关闭此套接字 |
第二个是Socket
, 这个类在客户端和服务器都会使用, 进行服务器与客户端之间的数据传输通信, TCP的传输可以类比打电话的场景, 客户端发送请求后, 服务器调用ServerSocket
类的accept
方法来 “建立连接” (接通电话), 建立连接后两端就可以进行通信了, Socket可以获取到文件(网卡)的输入输出流对象, 然后就可以流对象进行文件(网卡)读写了, 体现了TCP面向字节流, 全双工的特点.
构造方法 | 解释 |
---|---|
Socket(String host,int port) | 创建一个客户端Socket对象,并与对应IP主机,对应端口的进程进行连接 |
关键方法 | 解释 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回套接字的输入流 |
OutputStream getOutputStream() | 返回套接字的输出流 |
TCP的通信需要建立连接, 这里涉及长短连接的问题, 什么时候关闭连接就决定了是短连接还是长连接, 具体如下:
- 短连接: 每次接收到数据并返回响应后, 都关闭连接, 即是短连接; 也就是说, 短连接只能一次收发数据.
- 长连接: 不关闭连接, 一直保持连接状态, 双方不停的收发数据, 即是长连接; 也就是说, 长连接可以多次收发数据.
对比以上长短连接,两者区别如下:
- 建立连接, 关闭连接的耗时: 短连接每次请求, 响应都需要建立连接, 关闭连接; 而长连接只需要第一次建立连接, 之后的请求, 响应都可以直接传输; 相对来说建立连接, 关闭连接也是要耗时的, 长连接效率更高.
- 主动发送请求不同: 短连接一般是客户端主动向服务端发送请求, 而长连接可以是客户端主动发送请求, 也可以是服务端主动发.
- 两者的使用场景有不同: 短连接适用于客户端请求频率不高的场景, 如浏览网页等; 长连接适用于 客户端与服务端通信频繁的场景, 如聊天室, 实时游戏等.
2. TCP客户端回显服务器程序
2.1 TCP回显服务器
TCP服务器设计步骤:
- 创建
ServerSocket
实例对象, 需指定服务器端口号. - 启动服务器, 使用
accept
方法和客户端建立连接, 如果没有客户端来连接, 这里的accept方法会阻塞. - 接收客户端发来的请求(通过
Socket
获取到InputStream
流对象来读取请求). - 处理客户端请求, 计算响应.
- 将响应返回给客户端(通过
Socket
获取到OutputStream
流对象来发送响应).
import java.io.IOException;import java.io.InputStream;import java.io.OutputStream;import java.net.ServerSocket;import java.net.Socket;import java.util.Scanner;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;//TCP版本的回显服务器public class TcpEchoServer { //服务器专用的socket,用来和客户端建立连接 private ServerSocket serverSocket = null; public TcpEchoServer(int port) throws IOException { serverSocket = new ServerSocket(port); } //启动服务器 public void start() throws IOException { System.out.println("启动服务器!"); while (true) { //和客户端建立连接 Socket clientSocket = serverSocket.accept(); //和客户端进行交互, //这个写法只有一个线程,同一时间只能处理一个客户端 proccessConnection(clientSocket); } } //处理一个客户端连接 private void proccessConnection(Socket clientSocket) { System.out.printf("[%s:%d] 客户端端上线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort()); //基于clientSocket对象和客户端进行通信 try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { //客户端可能有多个请求,所以使用循环来处理 while (true) { // 1. 读取请求 Scanner scanner = new Scanner(inputStream); if (!scanner.hasNext()) { //hasNext()方法判断输入(文件,字符串,键盘等输入流)是否还有下一个输入项,若有,返回true,反之false. //hasNext会等待客户端那边的输入,即会阻塞等待输入源的输入 //当客户端那边关闭了连接,输入源也就结束了,没有了下一个数据,说明读完了,此时hasNext()就为false了 System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort()); break; } //next 是一直读取到换行符/空格/其他空白符结束,但是最终返回结果里不包含空白符. String requst = scanner.next(); // 2. 根据强求构造响应 String response = process(requst); // 3. 返回响应的结果 outputStream.write(response.getBytes(), 0, response.getBytes().length); byte[] blank= {'\n'}; outputStream.write(blank); outputStream.flush(); //或者使用println来写入,让结果中带有一个 \n 换行,方便对端来接收解析. System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), requst, response); } } catch (IOException e) { e.printStackTrace(); } finally { try { //释放资源,相当于挂断电话 clientSocket.close(); } catch (IOException e) { throw new RuntimeException(e); } } } public String process(String requst) { return requst; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9090); server.start(); }}
要注意理解这里的代码,
这里的 scanner.hasNext()
什么时候会使false
呢? 这是因为, 当客户端退出之后, 对应的流对象就读到了EOF
(文件结束标记), 那这里为啥会读到EOF, 这是因为客户端进程退出的时候, 就会触发socket.close()
, 也就触发FIN
(客户端端关闭连接的请求, 这个涉及到TCP协议连接管理的知识), 也就是操作系统内核收到客户端方发来的FIN数据报, 就会将输入源结束, 标记为EOF.
上面实现的TCP回显服务器的代码中有一个致命的缺陷就是, 这个代码同一时间只能连接一个客户端, 也就是只能处理一个客户端的请求, 下面先写客户端的代码, 然后再分析这里的问题.
2.2 TCP客户端
TCP客户端设计步骤:
- 创建
Socket
实例对象, 用于与服务器建立连接, 参数为服务器的IP地址和端口号, 在new Socket
实例对象的时候, 就会触发和TCP的连接过程. - 客户端启动, 用户输入请求, 构造构造请求并发送给服务器(使用
OutputStream/PrintWriter
), 要注意去刷新缓冲区保证数据成功写入网卡. - 读取服务器的响应并进行处理.
import java.io.IOException;import java.io.InputStream;import java.io.OutputStream;import java.io.PrintWriter;import java.net.Socket;import java.util.Scanner;//TCP版本回显客户端public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIp, int serverPort) throws IOException { // Socket构造方法,能够识别点分十进制格式的IP地址 // new这个对象的同时,就会进行TCP连接操作 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.printf("> "); String requst = scanner.next(); if (requst.equals("exit")) { System.out.println("客户端退出"); break; } // 2. 构造请求并发送给服务器 outputStream.write(requst.getBytes()); byte[] blank= {'\n'}; outputStream.write(blank); outputStream.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(); }}
2.3 解决服务器无法同时出力多个客户端的问题
在2.1中的代码有一个很严重的缺陷, 服务器是要处理多个客户端的请求的, 但为什么说上面代码只能连接处理一个客户端呢?
这是因为在服务器代码中的processContain
方法中有一层循环, 这层循环需要与当前通信的客户端传输完成后才会退出, 也就是说只要现在连接的客户端没有退出, 这里的循环就不会退出, Socket
对象就无法释放去建立另一个连接, 简单说就是一个线程只能连接一个客户端, 那么这里可以用多线程/线程池来解决这里存在的问题, 让主线程主线程专门负责进行accept
和客户端建立连接, 每收到一个连接, 创建新的线程, 由新线程来负责处理这个新的客户端请求.
修改部分代码如下:
public void start() throws IOException { System.out.println("启动服务器!"); // 此处使用 CachedThreadPool,线程数不太应该是固定的 ExecutorService threadPool = Executors.newCachedThreadPool(); while (true) { //和客户端建立连接 Socket clientSocket = serverSocket.accept(); //和客户端进行交互 // 使用线程池 threadPool.submit(() ->{ proccessConnection(clientSocket); }); } }
使用线程池相较于多线程是更优化的方案, 使用多线程的话每连接到一个客户端就会就会创建一个线程, 如果同一时刻连接过多, 这里线程创建和销毁的开销就比较大了, 使用线程池就可以减少这里开销, 提高效率.
上述的方案是使用了线程池, 但如果服务器连接的客户端非常多, 而且都迟迟不断开,就会导致有很多的线程, 这对于机器来说就是一个很大的负担, 这里还是有更好的实现方案的.
这就是在解决单机支持更大量客户端的问题了, 也是经典的C10M
(单机处理1KW个客户端)问题.
这里并不是说单机真正能处理1KW
个客户端, 只是表达说客户端的量非常大(比C10K, 1W
还多), 处理这个问题主要是去实现让一个线程可以处理客户端连接, 这就是IO多路复用, IO多路转接技术, 会充分利用等待的时间去做其他的事情.
这个实现思路是基于一个事实, 虽然服务器可能连接的客户端有很多, 但是这里的连接并不是严格意义上的去同时处理请求, 会有等待请求的时间, 也就是说多个客户端发来的请求是有先后的, 我们就可以利用等待请求的时间去处理另一个连接, 实现起来其实就是给线程安排一个集合, 这个集合放了一堆连接, 我们线程负责监听集合, 哪个连接有数据来了, 就处理哪个连接, 而其他的连接还可能在等待请求.
在操作系统里, 提供了一些API, 比如select
, poll
, epoll
; epoll是三代目, 解决了select, poll中的一些问题, 是目前最好的一个, 在Java中, 也提供了一组NIO
这样的类, 封装了上述技术.
2.4 TCP实现查词典的服务器
和UDP实现过程一个, 继承+哈希表词库, 再重写一下处理请求部分的代码即可.
import java.io.IOException;import java.util.HashMap;import java.util.Map;public class TcpDictServer extends TcpEchoServer{ Map<String, String> dict = new HashMap<>(); public TcpDictServer(int port) throws IOException { super(port); dict.put("cat", "小猫"); dict.put("dog", "小狗"); dict.put("bird", "小鸟"); dict.put("apple", "苹果"); dict.put("banana", "香蕉"); dict.put("strawberry", "草莓"); dict.put("watermelon", "西瓜"); //... } @Override public String process(String requst) { //查字典 return dict.getOrDefault(requst, "当前单词没有查到结果"); } public static void main(String[] args) throws IOException { TcpDictServer server = new TcpDictServer(9090); server.start(); }}