文章目录
- 1. 什么是网络编程
- 1.1 基础概念
- 2. Socket 套接字
- 2.1 概念
- 2.2 分类
- 2.2.1TCP 和 UDP 的区别
- 2.3 UDP数据报套接字编程
- 2.3.1 DatagramSocket
- 2.3.2 DatagramPacket
- 2.3.3 写一个简单的 UDP 的客户端程序
- 2.3.3.1 编写服务器代码
- 2.3.3.2 编写客户端代码
- 2.3.4 编写基于 echo server 的翻译服务器
- 2.4 TCP流套接字编程
- 2.4.1 ServerSocket
- 2.4.2 Socket
- 2.4.3 TCP 和 UDP 的特点对比
- 2.4.4 写一个简单的 TCP 的客户端程序
- 2.4.4.1 编写服务器代码
- 2.4.4.2 编写客户端代码
- 2.4.4.3 代码中的两个问题
1. 什么是网络编程
1.1 基础概念
通过网络,让两个主机之间能够进行通信,基于这样的通信来完成一定的功能
进行网络编程的时候,需要操作系统给我们提供一组 API,通过 API 才能完成编程
API 可以认为是 应用层 和 传输层 之间交互的路径
这个 API 被称为 Socket API(插座)
通过这一套 Socket API 可以完成不同主机之间,不同系统之间的网络通信
传输层,提供的网络协议,主要是两个 TCP 和 UDP
这俩协议的特性(工作原理)差异很大
导致使用这两种协议进行网络编程,也存在一定差别
系统就分别提供了两套 API
2. Socket 套接字
2.1 概念
Socket套接字,是由系统提供⽤于⽹络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元
基于Socket套接字的⽹络程序开发就是网络编程
2.2 分类
Socket套接字主要针对传输层协议划分为如下三类:
- 流套接字:使⽤传输层TCP协议
- 数据报套接字:使⽤传输层UDP协议
- 原始套接字:原始套接字⽤于⾃定义传输层协议,⽤于读写内核没有处理的IP协议数据
2.2.1TCP 和 UDP 的区别
- TCP 是有连接的,UDP 是无连接的
(这里的连接是 抽象 的概念)
计算机中,这种链接本质上就是建立链接的双方,各自保存对方的信息
两台计算机建立连接,就是双方彼此保存了对方的关键信息
TCP 要想通信,就需要先建立连接,做完之后,才能后续通信
UDP 想要通信,就直接发送数据即可,不需要征得对方的同意,UDP 自身也不会保存对方的信息
(UDP 不知道,但是写程序的人知道,UDP 自己不保存,但是你调用 UDP 的 socket api 的时候 要把对方的位置啥的给传过去) - TCP 是可靠传输的,UDP 是不可靠传输的
网络上进行通信,A ->B 发送一个消息,这个消息是不可能做到 100% 送达的
可靠传输,退而求其次
A -> B 发消息,消息是不是到达 B 这一方,A 自己能感知到 (A 心里有数)
进一步的,就可以在发送失败的时候采取一定的措施 (尝试重传之类的)
TCP 就内置了可靠传输机制
UDP 就没有内置可靠传输
为什么 UDP 没有可靠传输呢?
可靠传输要付出代价这样会使 机制更复杂,传输效率会降低 - TCP 是面向字节流的,UDP 是面向数据报
此处说的 字节流 和 文件 操作这里的 字节流是一个意思
TCP 和文件操作一样,以字节为单位来进行传输
UDP 则是按照数据报为单位,来进行传输的
UDP 数据报是有严格的格式的
网络通信数据的基本单位,涉及到多种说法:
1.数据报(Datagram)
2.数据包(Packet)
3.数据帧(Frame)
4.数据段(Segment)
- TCP 和 UDP 都是全双工的
一个信道,允许双向通信,就是全双工
一个信道,只能单向通信,就是半双工
代码中使用一个 Socket 队形,就可以发送数据也能接受数据
2.3 UDP数据报套接字编程
2.3.1 DatagramSocket
socket 其实也是操作系统中的一个概念
本质上是一种特殊的问价
Socket 就属于把“网卡”这个设备,给抽象成了文件
往 socket 文件中写数据,就相当于通过网卡发送数据
往 socket 文件读数据,就相当于通过网卡接受数据
这样,就把网络通信 和 文件操作给统一
在 Java 中使用DatagramSocket 这个类,来表示系统内部的 socket 文件
DatagramSocket 方法:
receive 里面的 DatagramPacket 是一个“输出型参数”
2.3.2 DatagramPacket
使用 DatagramPacket 类,来表示一个 UDP 数据报
UDP 是面对数据报的
每次进行传输,都要以 UDP 数据报为基本单位
2.3.3 写一个简单的 UDP 的客户端程序
这个程序没有什么业务逻辑,只是简单的调用 socket api
让客户端给服务器发送一个请求,请求就是一个从控制台输入的字符串
服务器收到字符串之后,也就会把这个字符串原封不动的返回客户端,客户端再显示出来
这样的就叫做“回显服务器”(echo server)
2.3.3.1 编写服务器代码
服务器和客户端都需要创建 Socket 对象
但是 服务器的 socket 一般要显示的指定一个端口号
而服务器的 socket 一般不能显示指定(不显示指定,此时系统会自动分配一个随机的端口)
此时的 socket 对象就能绑定到这个指定的端口
服务器需要把端口号明确下来,否则客户端找不到端口
客户端的端口号是不需要确定的,交给系统进行分配即可
如果你手动指定确定的端口,就可能和别人的程序的端口号冲突
那为什么服务器这边手动指定创建,就不会出现冲突?
客户端在意这个冲突,而服务器不在意呢?
服务器是在程序员手里,一个服务器上都有哪些程序,都使用哪些端口,程序员都是可控的
程序员写代码的时候,就可以指定一个空闲的端口,给当前的服务器使用
但是客户端就不可控了,客户端在用户的电脑上
每个用户电脑上装的程序都不一样,占用的端口也不一样
另一方面,用户出现端口冲突,用户也不会解决
所以交给系统分配比较稳妥
receive 内 先构造一个空的对象,然后出阿迪到方法内部,由 receive 内部对这个数据进行填充
这个对象用来承载从网卡这边读到的数据
收到数据的时候,需要搞一个内存空间来保存这个数据
DatagramPacket 内部不能自行分配内存空间
因此就需要程序员手动把空间创建好,交给 DatagramPacket 进行处理
这里只要服务器一旦启动,就会立即执行到这里的 receive 方法
此时,客户端的请求可能还没来
这种情况,receive 就会直接堵塞,一直堵塞到真正客户端吧请求发过来为止
UDP 是无连接的(UDP 自身不会保存数据要发给谁),这就需要每次发送的时候,重新指定,数据要发到哪里去
构造这个数据报,需要指定数据内容,也指定一下数据报要发给谁
上面半行是在表示数据是什么
下面半行是在指定 请求中的地址(数据从哪里来,就要到哪里去)
以下就是 开发服务器的基本步骤
public class UdpEchoServer {//创建一个 DatagramSocket 对象,后续操作网卡的基础private DatagramSocket socket = null;public UdpEchoServer(int port) throws SocketException {// 这么写就是手动指定端口socket = new DatagramSocket(port);// 这么写就是让系统自动分配端口// socket = new DatagramSocket();}public void start() throws IOException {//通过这个方法来启动服务器System.out.println("服务器启动!");///** 一个服务器程序中,经常能看到 while true 这样的代码* 因为一个服务器程序,经常要长时间运行* 你也不知道客户什么时候来* 为了给客户一个好的体验,所以就需要一直运行*/while (true) {//1.读取请求并解析DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);socket.receive(requestPacket);// 当前完成 receive 之后, 数据是以 二进制 的形式存储到 DatagramPacket 中了.// 要想能够把这里的数据给显示出来, 还需要把这个二进制数据给转成字符串.String requset = new String(requestPacket.getData(),0,requestPacket.getLength());//2.根据请求计算响应(一般的服务器都会经历的过程)/*** 这个步骤是一个服务器程序,最核心的步骤* 但是目前是 echo server 不涉及到这个流程,也不必考虑响应怎么计算* 只要请求进来,就把请求当做响应*///由于这里是回显服务器,请求是什么,响应就是什么String response = Process(requset);//3.把响应写回到客户端// 搞一个响应对象, DatagramPacket// 往 DatagramPacket 里构造刚才的数据, 再通过 send 返回.DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());socket.send(responsePacket);//4.打印一个日志,把这次数据交互的详情打印出来System.out.printf("[%s:%d] req=%s, resp=%s\n", requestPacket.getAddress().toString(),requestPacket.getPort(), requset, response);}}public String Process(String request) {return request;}public static void main(String[] args) throws IOException {UdpEchoServer server = new UdpEchoServer(9090);server.start();}
}
上述写的代码中,为什么没有 close?
socket 也是文件,不关闭不就文件资源泄露了吗?
为啥这里可以不写 close?
为啥不写 close 也不会出现文件资源泄露??
socket 是文件描述符表中的一个表项
每次打开一个文件,就会占用一个位置
文件描述符,是在 pcb 上的 (跟随进程的)
这个 socket 在整个程序运行过程中都是需要使用的 (不能提前关闭)
当 socket 不需要使用的时候,意味着程序就要结束了
进程结束,此时随之文件描述符表就会销毁了(PCB 都销毁了)
随着销毁的过程,被系统自动回收了
这样就不会泄露
啥时候才会出现泄露?
代码中频繁的打开文件,但是不关闭.
在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述表里面的内容
最终就耗殆尽了
如果进程的生命周期很短,打开没多久就关闭,就不会泄露了
文件资源泄露这样的问题,在服务器这边是比较严重的,在客户端这里没有很大的影响
2.3.3.2 编写客户端代码
在写这个代码过程中,用到三个 DatagramPacket 的构造方法
-
只指定字节数组缓冲区的(服务器收请求的时候需要使用,客户端收响应的时候也需要使用)
-
构造的时候需要指定字节数组缓冲区,同时指定一个 InetAddress 对象(这个对象就同时包含了 IP 和 端口)
(服务器返回响应给客户端)
-
指定字节数组缓冲区,同时指定 IP + 端口号
在下面需要把 ip 地址稍微转化一下
这些都是让数据报里面带上内容 也带上数据的目标地址
此时,客户端和服务器就可以通过网络配合完成通信过程了
public class UdpEchoClient {private DatagramSocket socket = null;private String serverIp = "";private int serverPort = 0;public UdpEchoClient(String ip, int port) throws SocketException {//创建这个对象,不能手动指定端口socket = new DatagramSocket();// 由于 UDP 自身不会持有对端的信息. 就需要在应用程序里, 把对端的情况给记录下来// 这里咱们主要记录对端的 ip 和 端口serverIp = ip;serverPort = port;}public void start() throws IOException {System.out.println("客户端启动!");Scanner scanner = new Scanner(System.in);while (true) {//1.从控制台读取数据,作为请求System.out.println("-> ");String request = scanner.next();// 2. 把请求内容构造成 DatagramPacket 对象, 发给服务器.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());System.out.println(response);}}public static void main(String[] args) throws IOException {UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);// UdpEchoClient client = new UdpEchoClient("42.192.83.143", 9090);client.start();}
}
- 服务器先启动,服务器启动之后,就会进入循环,执行到 receive 这里并阻塞(此时还没有客户端过来呢)
- 客户端开始启动,也会先进入 while 循环,执行 scanner.next,并且也在这里阻塞
当用户在控制台输入字符串之后,next 就会返回,从而构造请求数据并发送出来 - 客户端发送出数据之后
服务器: 就会从 receive 中返回,进一步的执行解析请求为字符串,执行 process 操作,执行 send 操作
客户端::继续往下执行,执行到 receive,等待服务器的响应 - 客户端收到从服务器返回的数据之后,就会从 receive 中返回执行这里的打印操作,也就把响应给显示出来了
- 服务器这边完成一次循环之后,又执行到 receive 这里客户端这边完成一次循环之后,又执行到 scanner.next 这里,又双双进入阻塞
2.3.4 编写基于 echo server 的翻译服务器
翻译操作本质上就是内置了一个类似哈希表的东西
start 方法中,调用 process 方法
当前是子类引用调用 start,this 就是指向子类引用,虽然 this 是父类的类型,但是实际指向的是子类引用
调用 process 自然也就会执行到子类的方法
虽然没有修改 start 的内容
但是仍然可以确保是按照新版本的 process 来执行的 (多态)
public class UdpDictServer extends UdpEchoServer{private Map<String, String> dict= new HashMap<>();public UdpDictServer(int port) throws SocketException {super(port);//此处可以往这个表里插入几千几万个这样的英文单词dict.put("dog","小狗");dict.put("cat","小猫");dict.put("pig","小猪");}//重写 process 方法,在重写的方法中完成翻译的过程//翻译本质上就是查表@Overridepublic String Process(String request) {return dict.getOrDefault(request,"该词在词典中不存在");}public static void main(String[] args) throws IOException {UdpDictServer server = new UdpDictServer(9090);server.start();}
}
上述重写 process 方法,就可以在子类中组织你想要的“业务逻辑”
2.4 TCP流套接字编程
TCP 的 socket api 和 UDP 的 socket api 差异很大
但是和前面讲的 文件操作,有密切的联系
2.4.1 ServerSocket
ServerSocket 这是给服务器使用的类,使用这个类来绑定端口号
2.4.2 Socket
Socket 既会给服务器用,又会给客户端用
上面的这两个类都是用来表示 socket 文件的
(抽象了网课这样的硬件设备)
TCP 是字节流的,传输的基本单为是 byte
2.4.3 TCP 和 UDP 的特点对比
- TCP 是有链接的,UDP 无连接
(连接:通信双方是否会记录保存对端的信息) - TCP 是可靠传输,UDP 不可靠传输
- TCP 是面向字节流,UDP 是面向数据报
- TCP 和 UDP 都是全双工
UDP 来说,每次发送数据都得手动在 send 方法中,指定目标的地址 (UDP 自身没有存储这个信息)
TCP 来说,则不需要前提是需要先把连接给建立上
连接如何建立,不需要代码干预,是系统内核自动负责完成的
对于应用程序来说,客户端这边,主要是要发起 “建立连接” 动作服务器这边,主要是要把建立好的连接从内核中拿到应用程序里
2.4.4 写一个简单的 TCP 的客户端程序
2.4.4.1 编写服务器代码
serverSocket 进行连接
clientSocket 进行后面程序的交互
InputStream 和 OutputStream 就是字节流
就可以借助这两个对象,完成数据的 “发送" 和 “接收"
通过 InputStream 进行 read 操作,就是 “接收”
通过 OutputStream 进行 write 操作,就是 “发送"
这两个进行发送和接收的时候,是以字节为单位
“空白符”是一类特殊的字符,比如换行、回车符、空格、制表符、翻页符、抽纸制表符…
后续客户端发送的请求,会以空白符作为结束标记
(此处就约定使用 \n)
由于TCP 是字节流通信方式,每次传输的字节都是非常灵活的,但是这种灵活的方式会导致代码出错
因此,我们往往会手动约定一个完整数据报的长度
每次循环就处理一个数据报
上述这里就是约定了 \n 作为数据报结束的标志,就正好可以搭配 scanner.next 来完成请求的读取
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("服务器启动");while (true) {//通过 accept 方法,把内核中已经建立好的连接拿到应用程序中//建立连接的细节流程都是内核自动完成的,应用程序只需要直接用Socket clientSocket = serverSocket.accept();processConnection(clientSocket);}}//通过这个方法,来处理当前的连接public void processConnection(Socket clientSocket) throws IOException {//进入这个方法先打印一个日志System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());//接下来进行数据的交互try (InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {//使用 try ( ) 方式, 避免后续用完了流对象, 忘记关闭.//由于客户端发来的数据, 可能是 "多条数据", 针对多条数据, 就循环的处理.while (true) {Scanner scanner = new Scanner(inputStream);if (!scanner.hasNext()) {//连接断开了. 此时循环就应该结束System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());break;}//1.读取请求并解析. 此处就以 next 来作为读取请求的方式,next 的规则是,读到 "空白符" 就返回.String request = scanner.next();//2.根据请求,计算响应String response = process(request);//3.把响应写回到客户端// 可以把 String 转成字节数组, 写入到 OutputStream// 也可以使用 PrintWriter 把 OutputStream 包裹一下, 来写入字符串.PrintWriter printWriter = new PrintWriter(outputStream);// 此处的 println 不是打印到控制台了, 而是写入到 outputStream 对应的流对象中, 也就是写入到 clientSocket 里面.// 自然这个数据也就通过网络发送出去了. (发给当前这个连接的另外一端)// 此处使用 println 带有 \n 也是为了后续 客户端这边 可以使用 scanner.next 来读取数据.printWriter.println(response);// 此处还要记得有个操作, 刷新缓冲区. 如果没有刷新操作, 可能数据仍然是在内存中, 没有被写入网卡.printWriter.flush();//4.打印一下这次请求交互过程的内容System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(),request, response);}} catch (IOException e) {e.printStackTrace();} finally {// 在这个地方, 进行 clientSocket 的关闭.// processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.clientSocket.close();}}public String process(String request) {//此处也是写的回显服务器,响应和请求是一样的return request;}public static void main(String[] args) throws IOException {TcpEchoServer server = new TcpEchoServer(9090);server.start();}
}
2.4.4.2 编写客户端代码
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIp, int serverPort) throws IOException {//需要再创建 Socket 的同时,和服务器”建立链接“,此时就要告诉 Socket 服务器在哪里//具体建立链接的细节,不需要代码手动干预,是内核自动服务的//当我们 new 这个对象的时候,操作系统内核就开始进行 三次握手的具体细节socket = new Socket(serverIp,serverPort);}public void start() {//tcp 的客户端行为和 udp 的客户端差不多Scanner scanner = new Scanner(System.in);try (InputStream inputStream = socket.getInputStream();OutputStream outputStream = socket.getOutputStream()) {PrintWriter writer = new PrintWriter(outputStream);Scanner scannerNetwork = new Scanner(inputStream);while (true) {//1.从控制台读取用户输入的内容System.out.println("->");String request = scanner.next();//2.把字符串作为请求,发送给服务器// 这里使用 println 是为了让请求后面带上换行// 也就是和服务器读取请求,scanner.next 呼应writer.println(request);writer.flush();//3.从服务器读取响应String response = scannerNetwork.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.4.4.3 代码中的两个问题
- 在服务器的代码中,如果没有写 close 会出现文件泄露的问题
这个时候就会有人有疑问
前面写过的 DatagramSocket 和 ServerSocket 都没有写 close
但是代码都没有问题
为什么 clientSocket 不关闭,就会初心问题呢?
因为 DatagramSocket 和 ServerSocket 都是在程序中,只有这么一个对象,周期都是贯穿整个程序的
clientSocket 则是在循环中,每次有一个新的客户端建立链接,都会创建出新 clientSocket
每次执行就会创建出新的 clientSocket
并且这个 socket 最多使用到 该客户端退出(断开连接)
此时如果有很多客户端都来建立链接
此时,就意味着每个链接都会创建 clientSocket
当连接断开的时候 clientSocket 就失去了作用了
但是如果没有手动 close,此时这个 socket 对象就会占据着文件描述表的位置
- 在客户端运行的代码截图中
我们可以看到,两次运行的程序并不是同一个端口号
那么当我们开启多个客户端的时候,会出现什么呢?
上述截图可知
当前启动两个客户端,同时连接服务器
其中一个客户端(先启动的客户端)一切正常
另一个客户端(后启动的客户端)没有办法和服务器进行任何交互(服务器不会提示“建立连接”,也不会针对请求做出任何响应)
现在看到的现象,就是当前代码的一个很明显的问题(bug)
解决这个问题的关键,就是让这两个循环能够“并发”执行
这里就需要用到 多线程
创建新的线程,让新的线程去调用 processConnection
主线程就可以继续执行下一次 accept 了
新线程内部负责 processConnection 内部的循环
此时意味着,每次有一个客户端,就得给分配一个新的线程
线程并发执行就可以顺利运行了