一、网络发展历史
互联网从何而来?
这要追溯到上个世纪 50 - 60 年代,当时正逢美苏争霸冷战,核武器给战争双方提供了足够的威慑力,想要保全自己,就要保证自己的反制手段是有效的。
如何保证能够反击:
- 保存指挥机构
- 保存核弹头和发射井
- 指挥机构和核弹头之间的通信链路
需要保证通信链路在核弹洗地的情况下仍然能正常运作
最终方案,以力破巧!让指挥机构和核弹头之间,有无数条可以通信的链路,哪怕其中一部分被打掉了,剩余的仍然能够正常工作,从而衍生出了今天的互联网。中国互联网的发展是非常滞后的,90年代左右,国内的计算机才逐渐多了起来,随着计算机和网络的普及,中国这个十亿级别的市场开始爆发整个互联网行业出现井喷式发展。
2007年,国外出现了一件惊天动地的大事,乔布斯发布了第一代苹果手机 lPhone,手机从功能机向智能机转变。
智能手机对于国内的影响,其实大概是2012年左右才开始,这个时候国内的智能手机才逐渐普及。
2012年以后,互联网行业迎来了第二波发展高峰:移动互联网。
二、网络通信基础
1、局域网 / 广域网
Local Area Network,简称LAN。
Local 即标识了局域网是本地,局部组建的一种私有网络局域网内的主机之间能方便的进行网络通信,又称为内网;局域网和局域网之间在没有连接的情况下,是无法通信的。
两根线把三个主机给连起来,这三个主机就构成了一个局域网。
局域网组建网络的方式有很多种,咱们日常使用的电脑一般都是一个网口,但是也有的主机是带有多个网口的,这种组网方式是非常少见 (非常费网线,也非常费网口)
一般组件局域网,都会使用一些转发设备:交换机,路由器
-
交换机
- 借助交换机,就组成了一个局域网,交换机上面的网口之间都是对等 (都是─样的口)
效果就是把插在上面的设备给组建成一个局域网,这个局域网内部的主机之间就可以相互进行访问 - 交换机是把若干个设备给组建到一个局域网中
- 借助交换机,就组成了一个局域网,交换机上面的网口之间都是对等 (都是─样的口)
-
路由器
- 这个是咱们日常中最常见的情况。路由器这里其实有两类端口,
WAN 口
LAN 口
其中插在 LAN 口上的设备,在一个局域网里,通过 wan 口连接到另外一个局域网 - 路由器则是连接了两个局域网 (LAN口是一个,WAN又连了一个)
- 这个是咱们日常中最常见的情况。路由器这里其实有两类端口,
-
集线器:
- 实际上基本没有使用集线器组网的,集线器相当于把一根网线给分叉了
- 分出来的两个叉不能一起用,用一个的时候另一个就不好使
上述讨论的区别,局限于 "传统”,的交换机和路由器。
实际上,真实的交换机和路由器之间的界限,已经越来越模糊了,路由器的很多功能,交换机也有,交换机的很多功能,路由器也有
通过路由器 / 交换机,组建起来的这些都叫做局域网。
广域网其实和局域网之间,没有明确界限。认为比较大的局域网,就可以称为 “广域网”。
全世界最大的广域网,叫做 Internet (因特网)
2、IP地址 & 端口号
IP 地址:描述了网络上的一个主机的位置 (收货地址)
IP地址本质上是一个 32 位的整数,但是由于32位的整数,不方便人来读和记忆,一般常见的操作都是把这个 32 位的整数,按照每个字节,分成四个部分,中间用
.
分割,称为 点分十进制 。
例如:123.139.170.225,范围是 0-255。
127.0.0.1 (一个特殊的IP地址,环回IP,表示自己这个主机)
端口号:描述了一个主机上的某个应用程序 (收件人的电话)
端口号本质上是一个 2 个字节 (16位) 的无符号整数,范围 0-65535
例如:3306,MySQL 默认的端口号
服务器程序在启动的时候,就需要绑定上一个端口号,以便客户端程序来访问
3、协议
3.1、协议的概念
进行有效的通信,前提就是能够明确通信协议。本质上就是约定,发出来的数据是什么的格式,接收方按照对应的格式来进行解析
网络通信的时候,本质上,传输的是光信号和电信号
- 通过光信号的频率 (高频率 / 低频率),电信号的电平 (高电平 / 低电平),来表示 0 和 1。
关于协议分层
网络通信这个过程,其实很复杂,里面有很多很多的细节,
如果就只通过一个协议,来约定所有的细节,这个协议就会非常庞大,复杂,
更好的办法,就是把一个大的复杂的协议,拆成多个小的,更简单的协议,每个协议,负责一部分工作
(就和写代码一样,写一个复杂的程序,不能指望说,一个文件把所有的代码都装进去,把这个代码拆分成多个更小的,更简单的文件,每个文件负责一部分工作)
- 好处1:每层协议不需要理解其他层协议的细节 (更好的做到了封装)
打电话的人,不需要理解电话的工作原理,就能完成打电话的操作,制造电话的人,也不需要称为语言大师- 好处2:把对应层的协议替换成其他协议 (更好的解耦合)
打电话的人,可以不使用有线电话,可以使用无线电话
打电话的人,也可以使用英语,不使用汉语
互联网中的分层具体怎么分:
OSI 七层网络模型
- 这种模型只是存在于教科书中,真实的情况是 OSI 的简化版本:
TCP / IP 五层 (四层) 网络模型
站在一个全局的角度,五层模型
站在纯程序猿的角度,最下面的物理层描述的是硬件设备 (和软件没啥关系,和程序猿距离比较远) 这个时候就认为是四层下面四层都是一样的,这四层,和咱们程序猿的关系都不是很大,这里的代码逻辑都是由操作系统和驱动以及硬件已经实现好的
程序猿打交道最多的,是这个应用层的协议
3.2、TCP五层网络模型
1、物理层: 网络通信中的硬件设备
通信需要网线 / 网卡… 针对硬件设备的约定,就是物理层协议所负责的范畴,需要保证所有的主机和网络设备之间,都是相互匹配的,随便买一个路由器都可以插我的网线
2、数据链路层: 负责完成相邻 (一根网线相连的两个设备) 的两个设备之间的通信的 [局部]
如果一个路由器连接了两个主机,路由器 和 主机 1 是相邻的,路由器和主机 2 是相邻的,主机 1 和主机 2 不是相邻的
3、网络层: 负责点到点之间的通信 [全局]
网络中的任意节点,到任意节点之间的通信 (不一定是相邻了,更多的是指不相邻的),网络层就负责在这两个点之间,规划出一条合适的路线
实际的网络环境结构非常复杂,两个点之间的路线不只一条,就需要规划处最合适的一条 [高德地图为你导航]
举个例子: 从西安到吉林省白城市安广镇,首先,规划路线
1.西安 -> 北京 -> 白城 -> 安广
2.西安 -> 长春 -> 白城 -> 安广
3.西安 -> 沈阳 -> 白城 -> 安广我就需要规划哪一条路线最优 (最优可能是指,时间最短,也可能是指成本最低,还可能是少换乘)
网络层负责这个事情,网络层允许用户根据情况来决定哪种是 “最优”。
假设我路线规划好了
西安 -> 长春 -> 白城 -> 安广
接下来就考虑具体如何实施,先考虑西安到长春,决定坐飞机,到了长春了,再考虑如何到白城,决定坐火车,到白城了,考虑如何去安广,决定坐大巴车,到了安广,考虑如何到家里,决定坐毛驴车这个过程是数据链路层负责的工作
4、传输层: 负责端到端(起点和终点) 之间的通信
只是关注结果 (数据到没到),不关注过程 (不关注数据是走哪条路,转发的)
例如我网上购物,我就需要填写自己的收件人地址和收件人姓名,商家就要根据这个地址把快递发给我
我和商家,都是只关注结果,不关注过程
快递公司,要关注中间的过程
5、应用层: 和应用程序密切相关的,你传输的这个数据,是跟什么用的
不同的应用程序就有不同的用途
举个例子:有一天我在网上买一个床刷子
商家,站在传输层,考虑这个东西是能不能发到我手上。快递公司,站在网络层规划路线。快递小哥,站在数据链路层,骑着电动车把货拉到集散中心。电动车 / 集装箱卡车 / 公路,站在物理层,提供传输的基础。
他们都是只在考虑包裹如何传输,不考虑这个包裹里面是什么,更不关心包裹里的东西的作用。但是我,作为买床刷子的人,就是抱着一定的用途 / 目的,来买的,这个是程序猿最最需要打交道的事情。
网络设备所在分层 (传统意义上的路由器和交换机)
- 一台主机,其实就对应了物理层到应用层五层 (把这五层都给实现了)
- 一台路由器,主要就是物理层到网络层(主要是实现了物理层,数据链路层,网络层)
- 一台交换机,主要就是物理层到数据链路层 (主要是实现了物理层,数据链路层)
4、封装,分用
4.1、封装
网络分层中的一组重要概念,封装和分用,(此处的 “封装”,和 Java 面向对象,“封装继承多态” 的封装,没什么关系)
不同的分层的协议之间,是如何相互配合的
例如,使用 QQ 给一个同学发送消息,用户 A 在键盘上输入了一个"hello",按下发送键
应用层 (QQ应用程序)
- 根据用户输入的内容,把数据构造成一个应用层的协议报文 (协议是一种约定,报文遵守了这个约定的一组数据)
- QQ 的代码中就会根据程序猿所设计的应用层协议,来构造出一个 应用层的数据报文
- 这个协议长啥样?都是程序猿自己约定的。QQ使用的应用层协议,是开发QQ的程序猿约定的;LOL使用的应用层协议,是开发LOL的程序猿;约定的淘宝使用的应用层协议,是开发淘宝的程序猿约定的。显然这些不同程序中使用的应用层协议大概率是不相同的,QQ之外的人,是不知道 QQ 使用的应用层协议是什么的。
- (其他的传输层、网络层… 的协议都是现成,操作系统 / 硬件 / 驱动已经实现好的),应用层的协议大概率是程序猿自己设定的。
- 应用层协议就调用操作系统提供的API (称为socket API),把应用层的数据,交给传输层 (就已经进入操作系统内核了)、
传输层 (操作系统内核)
根据刚才传过来的数据,基于当前使用的传输层协议,来构造出一个传输层的协议报文
传输层最典型的协议:UDP,TCP。以 TCP 为例:
- 在应用层数据的基础上加上一个 TCP 的协议报头
也就是说 TCP 的数据报 = TCP报头+数据载荷 (Payload,也就是一个完整的应用层数据)
- 可以简单的把这个构造 TCP 报文的过程视为是一个字符串拼接 (这里拼的是二进制数据)
- TCP的报头中有很多信息
其中最重要的,就是 “源端口” 和 “目的端口”,也就是发件人电话和收件人电话- 应用层和传输层的过程就是封装,类似于快递打包。
打包的目的,一方面是为了保护衣服,不被弄坏弄脏;另一方面,是为了往上面贴标签,标签上就有转发数据的重要辅助信息。
网络中的封装,不需要考虑 “数据弄坏弄脏的问题”。这里主要的目的就是为了“贴标签",贴上辅助转发的信息。- 接下来就会把这个传输层的数据报,交给网络层
网络层 (操作系统内核)
- 拿到了完整的传输层数据报,就会再根据当前使用的网络层协议 (例如IP),再次进行封装,把 TCP 数据报构造成 IP 数据报,还是添加上一个 IP 协议报头
IР 数据报 = IP 协议报头+载荷 (完整的 TCP / UDP 的数据报)- 这个报头中也有很多重要的信息
其中最重要的就是 源IP 和 目的IP,相当于发件人的地址,和收件人的地址
紧接着,当前的网络层协议,就会把这个 IР数据报,交给数据链路层
数据链路层 (驱动程序)
在刚才的 IP数据报基础上,根据当前使用的数据链路层的协议,给构造成一个 数据链路层的数据报 ,就是加上帧头和帧尾。
典型的数据链路层的协议,叫做 “以太网”,就会构造成一个 “以太网数据帧”。
以太网数据帧 = 帧头+IP数据报+帧尾帧头里也有很都重要的信息
最重要的信息,接下来要传给的设备的地址是什么IР协议 里面写的地址,是起点和终点 (西安和安广镇)
以太网数据帧,帧头里,写的地址,是接下来一个相邻节点的地址 (西安和长春),随着数据往下一个设备转发,帧头中的地址,一直在时刻发生改变。
我人在西安,这里的地址,写的是西安 / 长春
我人在长春,这里的地址,写的是长春 / 白城
我人在白城,这里的地址,写的是白城 / 安广数据链路层,又会把这个数据交个物理层
物理层 (硬件设备)
- 做的工作就是,根据刚才的以太网数据帧 (其实就是一组 0 1 ),把这里的 0 1 变成高低电平,通过网线传输出去。或者把这里的 0 1 变成高频 / 低频的电磁波,通过光纤 / 无线的方式传播出去。
以上都是封装,从上往下,就是数据从上层协议,交给下层协议,由下层协议进行封装 (构造成该层协议的报文)
4.2、分用
到了刚才这一步,此时数据就已经离开了当前主机,前往了下一个设备,下一个设备可能是路由器 / 交换机 / 其他设备
A 和 B 之间,大概率不是网线直连的,中间就有很多个路由器和交换机来负责数据的转发
中间的过程暂且不表,主要先看,数据到达 B 之后的表现
物理层 (硬件设备,网卡)
- 主机 B 的网卡感知到了一组高低电平,然后就会把这些电平翻译成 0 1 的一串数据,然后这一串 0 1 就是一个完整的以太网数据帧
物理层就把这个数据往上交给了数据链路层
数据链路层 (驱动程序)
- 数据链路层负责对这个数据进行解析,去掉帧头和帧尾,
取出里面的 IP数据报,然后交给网络层协议
网络层 (操作系统内核)
- 网络层协议 (IP 协议) 又会对这个数据进行解析,去掉 IP 协议报头
取出里面的 TCP 数据报再交给传输层
传输层 (操作系统内核)
- 传输层协议 (TCP 协议) 又会对这个数据进行解析,去掉 TCP 报头,
取出里面的 TCP 数据报,交给应用层(QQ)
应用层 (应用程序,QQ)
- 应用层就会调用 socket API,从内核中读取到这个应用层数据报,再按照应用层协议进行解析
根据解析结果给显示到窗口中心
以上是分用,分用就是封装的逆过程
封装是从上往下,数据依次被加上了协议报头 (包快递)
分用是从下往上,数据一次被去掉了协议报头 (拆快递)
上述讨论的只是起点和终点的情况,A 和 B 中间还有很多路由器和交换机
交换机先分用数据解析到数据链路层,更新以太网数据帧的帧头里的地址,然后再重新封装,并进行转发
路由器先分用数据到网络层,拿到 IP 地址之后,进行下一阶段的路径规划,然后重新往下封装,并进行转发
A 和 B 之间有多少个路由器或交换机,无论网络多么复杂,这里整体的传输过程都是类似的,只是在不停地重复封装和分用的过程
三、网络编程基本概念
为什么需要网络编程:
用户在浏览器中,打开在线视频网站,如优酷看视频,实质是通过网络,获取到网络上的一个视频资源
与本地打开视频文件类似,只是视频文件这个资源的来源是网络。
相比本地资源来说,网络提供了更为丰富的网络资源
网络资源:所谓的网络资源,其实就是在网络中可以获取的各种数据资源。
而所有的网络资源,都是通过网络编程来进行数据传输的。
网络编程,指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称为网络数据传输)
当然,我们只要满足进程不同就行;所以即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。
特殊的,对于开发来说,在条件有限的情况下,一般也都是在一个主机中运行多个进程来完成网络编程。
但是,我们一定要明确,我们的目的是提供网络上不同主机,基于网络来传输数据资源:
- 进程A:编程来获取网络资源
- 进程B:编程来提供网络资源
发送端和接收端:
在一次网络数据传输时:
发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
接收端:数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。
**收发端:**发送端和接收端两端,也简称为收发端。
注意:发送端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念
请求和响应:
一般来说,获取一个网络资源,涉及到两次网络数据传输:
第一次:请求数据的发送
第二次:响应数据的发送
好比在快餐店点一份炒饭:
先要发起请求:点一份炒饭,再有快餐店提供的对应响应:提供一份炒饭
客户端和服务端:
服务端:在常见的网络数据传输场景下,把提供服务的一方进程,称为服务端,可以提供对外服务。
客户端:获取服务的一方进程,称为客户端。
对于服务来说,一般是提供:
- 客户端获取服务资源
- 客户端保存资源在服务端
好比在银行办事:
银行提供存款服务:用户(客户端)保存资源(现金)在银行(服务端)
银行提供取款服务:用户(客户端)获取服务端资源(银行替用户保管的现金)
常见的客户端服务端模型:
最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序:
客户端先发送请求到服务端
服务端根据请求数据,执行相应的业务处理
服务端返回响应:发送业务处理结果
客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)
四、网络编程套接字
Socket套接字,是由系统提供用于网络通信的技术,是基于 TCP / IP协议的网络通信的基本操作单元。基于 Socket套接字的网络程序开发就是网络编程。
Socket 套接字主要针对传输层协议划分为如下三类:
流套接字:使用传输层 TCP 协议,TCP,即Transmission Control Protocol(传输控制协议),传输层协议
数据报套接字:使用传输层 UDP 协议
UDP,即User Datagram Protocol(用户数据报协议),传输层协议。原始套接字:原始套接字用于自定义传输层协议,用于读写内核没有处理的IP协议数据。
我们不学习原始套接字,简单了解即可。
1、TCP / UDP
网络编程套接字,是操作系统给应用程序提供的一组 API (叫做 socket API) socket 原义插座
socket 可以视为是应用层和传输层之间的通信桥梁,
传输层 的核心协议有两种:TCP,UDP
socket API 也有对应的两组,由于 TCP 和 UDP 协议差别很大,因此,这两组 API 差别也很大
- TCP:有连接,可靠传输,面向字节流,全双工
- UDP:无连接,不可靠传输,面向数据报,全双工
- 有连接:像打电话,得先接通,才能交互数据
- 无连接:像发微信,不需要接通,直接就能发数据
- 可靠传输:传输过程中,发送方知道接收方有没有收到数据
打电话,就是可靠传输
阿里旺旺 / 钉钉 / 飞书已读功能- 不可靠传输:传输过程中,发送方不知道接收方有没有收到数据
发微信,就是不可靠传输错误的理解:
可靠传输,就是数据发过去后 100% 能被对方收到 —— err
可靠传输,就是 “安全传输” —— err
- 面向字节流:以字节为单位进行传输 (非常类似于文件操作中的字节流)
- 面向数据报:以数据报为单位进行传输 (一个数据报都会明确大小),一次发送 / 接收必须是一个完整的数据报,不能是半个,也不能是一个半
在代码中体现地非常明显
- 全双工:一条链路,双向通信
- 半双工:一条链路,单向通信
TCP,UDP 都是全双工
以上,是 TCP 和UDP 直观上的区别,细节上还有很多很多的东西,后面再详细介绍
五、UDP socket
UDP socket 中,主要涉及到两个类
DatagramSocket
(Datagram:数据报)这一个 DatagramSocket 对象,就对应到操作系统中的一个 socket 文件
- 操作系统中的 “文件” 是一个广义的概念。平时说的文件。只是指普通文件 (硬盘上的数据)
实际上,操作系统中的文件还可能表示了一些硬件设备 / 软件资源- socket 文件,就对应这 "网卡” 这种硬件设备,从 socket 文件读数据,本质上就是读网卡,往 socket 文件写数据,本质上就是写网卡 。
你可以想象:socket 文件,就是一个遥控器,通过遥控器来操作网卡,这种行为非常常见,甚至早在三国时期,就有了董卓曹操,挟天子以令诸侯,天子就是这个天下的遥控器
DatagramPacket
代表了一个 UDP 数据报,使用 UDP 传输数据的基本单位。每次发送 / 接收数据,都是在传输一个 DatagramPacket 对象。
方法签名 | 方法说明 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
1、UDP 回显服务
1.1、服务器
写一个最简单的客户端服务器程序,回显服务 EchoServer,这样的程序属于最简单的网络编程中的程序,不涉及到任何的业务逻辑,就只是通过 socket api 单纯的转发
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
端口号:
此处在构造服务器这边的 socket 对象的时候就需要显式的绑定一个端口号
端口号是用来区分一个应用程序的,主机收到网卡上的数据的时候这个数据该给哪个程序?
port 在运行程序的时候来指定即可端口号可以是自己定,也可以让系统分配
当前这个写法,是自己定的,一会还能看到系统分配的SocketException:
构造socket对象有很多失败的可能
端口号已经被占用了,两个人不能有相同的电话号码,同一个主机的两个程序也不能有相同的端口号
多个进程不能绑定同一个端口
一个进程能不能绑定多个端口呢?可以的,一个人可以有多个手机号码
一个进程可以创建多个 socket 对象,每个 socket 对象都绑定自己的端口
如果一个程序需要使用网路通信,你至少得有一个端口。如果一个人需要网购,也得至少有一个电话号码。每个进程能够打开的文件个数,是有上限的。如果进程之前已经打开了很多很多的文件,就可能导致此处的 socket 文件就不能顺利打开
为什么服务器第一步就是接收客户端发来的请求,而不是发送呢?
因为,服务器的定义,就是 “被动接收请求” 的这一方。主动发送请求的这一方面,叫做客户端。
receive
方法是可能会阻塞的! 客户端什么时候给服务器发请求?不确定的!
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
// 1、网络编程,第一步就要准备好 socket 实例,这是进行网络编程的大前提
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
// 此处在构造服务器这边的 socket 对象的时候就需要显式的绑定一个端口号
// port 在运行程序的时候来指定即可
socket = new DatagramSocket(port);
}
// 启动服务器
public void start() throws IOException {
System.out.println("启动服务器!");
// UDP 不需要建立连接,直接接收从客户端来的数据即可
while (true) {
// 1、读取客户端发来的请求
// 为了接收数据,需要先准备好一个空的 DatagramPacket 对象,由 receive 进行填充数据
DatagramPacket requestPacket = new DatagramPacket(new byte[1024], 1024); // 把一个字节数组包装了
// 参数为 "输出型参数"
socket.receive(requestPacket);
// 把 DatagramPacket 解析成一个 String
// 假设此处的 UDP 数据报最长是 1024,这个长度不一定是 1024,实际的数据可能不够 1024
String request = new String(requestPacket.getData(), 0, requestPacket.getLength(), "UTF-8");
// 2、根据请求计算响应(由于咱们这是一个回显服务,2省略)
String response = process(request);
// 3、把响应写回到客户端
// send 方法的参数,也是 DatagramPacket,需要把响应数据先构造成一个 DatagramPacket 再进行发送,这里就不是构造一个空的数据报
// 这里的参数不再是一个空的字节数组了,response 是刚才根据请求计算得到的响应,非空的 DatagramPacket 里面的数据就是String response的数据
// 写成 response.length() 表示(字符的个数)。 这里拿到的是字节数组的长度(字节的个数)
/*如果代码光是这么写,还是不太行,此时就无法区分出,这个数据要交给谁了
在发送数据的时候,必须要指定,这个数据报发给谁?地址 + 电话
lP + port
在当前的场景中,哪个客户端发来的请求,就把数据返回给哪个客户端
进之后的版本,在 DatagramPacket构造方法中,指定了第三个参数,表示要把数据发给哪个地址 + 端口
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length);*/
// 改进:
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress()); // SocketAddress 就可以视为是一个类,里面包含了 IP 和端口
socket.send(responsePacket);
System.out.printf("[%s : %d] req: %s, req: %s\n",
requestPacket.getAddress().toString(), requestPacket.getPort(), request, response);
}
}
// 由于是回显服务,响应和请求一样
// 实际上对于一个真实的服务器来说,这个过程是最复杂的,为了实现这个过程,可能需要几万,几十万代码
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
1.2、客户端
指定端口号?
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port); // 自己指定
}
public UdpEchoClient() throws SocketException {
socket = new DatagramSocket(); // 系统随机分配
}
第一个就是你去营业厅办理电话卡,自己手动挑一个喜欢的号码
在客户端构造 socket 对象的时候,就不再手动指定端口号,使用无参版本的构造方法
不指定端口号,意思是,让操作系统自己分配一个空闲的端口号
这个操作就是办电话卡,对于号码无感,人家给你随机指定一个号码通常写代码的时候,服务器都是手动指定的,客户端都是由系统自动指定的 (系统随机分配一个)
对于服务器来说,必须要手动指定,后续客户端要根据这个端口来访问到服务器
如果让系统随机分配,客户端就不知道服务器的端口是啥,不能访问,对于客户端来说,如果手动指定,也行,但是系统随机分配更好
一个机器上的两个进程,不能绑定同一个端口
客户端就是普通用户的电脑,天知道用户电脑上都装了什么程序,天知道用户的电脑上已经被占用了哪些端口,如果你手动指定一个端口,万一这个端口被别的程序占用,咱们的程序不就不能正常工作了嘛?
而且由于客户端是主动发起请求的一方,客户端需要在发送请求之前,先知道服务器的地址 + 端口,但是反过来在请求发出去之前,服务器是不需要事先知道客户端的地址 + 端口
构造方法:
// 法是只构造了保存数据的空间,没有数据内容,也没有地址~[用于接收]
DatagramPacket requestPacket = new DatagramPacket(new byte[1024], 1024);
// 这种写法,也是,既构造了数据,有能构造目标地址.这个目标地址, IP和端口是合在一起的写法. (InetSocketAddress)[用于发送]
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
// 又使用到了一种DatagramPacket构造方法.既能构造数据,又能构造目标地址.这个目标地址是IP和端口分开的写法~~[用于发送)
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName("127.0.0.1"), 9090);
五元组:
写代码的时候,就会涉及到一系列的 ip 和 端口。
一次通信,是由五个核心信息,描述出来的。源 IP,源端口,目的IP,目的端口,协议类型。站在服务器的角度:
- 源IP:服务器程序本机的 IP
- 源端口:服务器绑定的端口 (此处手动指定了 9090)
- 目的 IP:包含在收到的数据报中 (客户端的 IP)
- 目的端口:包含在收到的数据报中 (客户端的端口)
- 协议类型:UDP
站在客户端的角度:
- 源IP:本机 IP
- 源端口:系统分配的端口
- 目的IP:服务器的 IP
- 目的端口:服务器的端口
- 协议类型:UDP
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpEchoClient(String ip, int port) throws SocketException {
socket = new DatagramSocket();
this.serverIP = ip;
this.serverPort = port;
// 此处的 port 是服务器的端口,客户端启动的时候,不需要给 socket 指定端口,客户端自己的端口是系统随机分配的
}
// 在客户端构造 socket 对象的时候,就不再手动指定端口号,使用无参版本的构造方法
/*public UdpEchoClient() throws SocketException {
socket = new DatagramSocket();
}*/
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// 1、先从控制台读取用户输入的字符串
System.out.print("-> ");
String request = scanner.next();
// 2、把这个用户输入的内容,构造成一个 UDP 请求,并发送
// 构造的请求里包含两部分信息
// 1) 数据的内容:request 字符串
// 2) 数据要发给谁:服务器的 IP + 端口号
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIP), serverPort);
socket.send(requestPacket);
// 3、从服务器读取响应数据,并解析
DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength(), "UTF-8");
// 4、把响应结果显示到控制台上
System.out.printf("request: %s, response: %s\n", request, response);
}
}
public static void main(String[] args) throws IOException {
// 由于客户端和服务器在同一个机器上,使用的 IP 仍是 127.0.0.1,如果是不同的机器,就要修改这里的 IP
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
早就已经把服务器启动起来了,启动了服务器之后,才开始写客户端代码的,在写客户端代码的这个过程中,显然,没人访问服务器的,
服务器其实就卡在 receive 这里,阻塞等待了。
启动服务器!
[/127.0.0.1 : 63140] request: hello, response: hello
63140:这个就是系统自动给客户端分配的端口
客户端是可以有很多的
一个服务器可以给很多很多客户端提供服务,一个餐馆,可以给很多很多的客人提供就餐服务的取决于服务器的能力,同一时刻服务器能够处理的客户端的数目存在上限的,
服务器处理每个请求,都需要消耗一定的硬件资源 (包括不限于,CPU,内存,磁盘,带宽…)
能处理多少客户端,取决于:
处理一个请求,消耗多少资源
机器一共有多少资源能用
(在 Java 中并不容易精确的计算消耗多少资源,,JVM 里面有很多辅助性的功能,也要消耗额外的资源)
实际开发中,通过性能测试的方式,就知道了能有多少个客户端
问题:
当我们像再启动一个客户端的时候,遇到了点小困难,idea 提示咱们要把上一个客户端给干掉
‘UdpEchoClient’ is not allowed to run in parallel.
Would you like to stop the running one?IDEA 中默认情况下,一个程序只能启动一个实例.再次启动就会干掉之前的实例,此处勾选上这个选项,就可以启动多个实例了
[/127.0.0.1y.S0368yreq: hello, resp: hello
[/127.0.e.1: 598o2]req: java,resp: java
每个客户端,都被系统分配了不同的端口:通常情况下,一个服务器,是要同时给多个客户端提供服务的
但是也有情况,就是一个服务器只给一个客户端提供服务 (典型就是在分布式系统中,两个节点之间的交互)
上述写的代码虽然只是针对一个简单的回显服务,但是对于一个复杂的服务器来说,做的工作的基本流程,也是类似的
2、UDP 翻译
再来写一个简单程序,带上点业务逻辑,写一个翻译程序 (英译汉)
请求是一些简单的英文单词,响应也就是英文单词对应的翻译
客户端不变,把服务器代码进行调整
主要是调整process
方法
读取请求并解析,把响应写回给客户端,这俩步骤都一样,关键的逻辑就是 “根据请求处理响应”
package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
public class UdpDictServer extends UdpEchoServer {
public HashMap<String, String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
// 简单构造几个词
dict.put("cat", "猫");
dict.put("dog", "狗");
dict.put("pig", "猪");
}
@Override
public String process(String request) {
// UdpEchoServer 中的 process 改成 public
return dict.getOrDefault(request, "该词无法被翻译!");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
六、TCP
1、TCP 回显服务
1.1、服务器
TCP api 中,也是涉及到两个核心的类
ServerSocket
(专门给 TCP 服务器用的)Socket
(既需要给服务器用,又需要给客户端用)主要通过这样的类,来描述一个 socket 文件即可,而不需要专门的类表示 “传输的包”,面向字节流,以字节为单位传输的
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.util.Scanner;
public class TcpEchoServer {
// listen 英文原意:监听。但是 Java socket 中体现出 "监听" 的含义,
// 这样叫是因为,操作系统原生的 API 中,有一个操作叫做 listen
// private ServerSocket listenSocket = null;
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动了!");
while (true) {
// 1、建立连接
// 由于 TCP 是有连接的,不能一上来就读数据,需要先建立连接 (接电话)
// accept 就是在接电话,接电话的前提是,有人给你打,如果当前客户端尝试建立连接,此处的 accept 就会阻塞
// accept 返回了一个 socket 对象,称为 clientSocket,后续和客户端之间的沟通,都是都过 clientSocket 来完成的
Socket clientSocket = serverSocket.accept();
// 2、处理连接
// 这里之所分成了两步 就是因为要建立连接 一个专门负责建立连接 一个专门负责数据通信
processConnection(clientSocket);
}
}
// 处理连接
private void processConnection(Socket clientSocket) {
System.out.printf("[%s : %d] 客户端建立连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 接下来处理请求和响应
try (InputStream inputStream = clientSocket.getInputStream()) {
try (OutputStream outputStream = clientSocket.getOutputStream()) {
// 把 inputStream 中的数据读出来,写入到 outputStream 中
// 循环地处理每个请求,分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true) {
// 1、读取请求
if (!scanner.hasNext()) {
System.out.printf("[%s : %d] 客户端断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next(); // 此处用 Scanner 更方便,如果用 InputStream 的 read 也可以
// 2、根据请求,计算响应
String response = process(request);
// 3、将这个响应返回客户端
// 方便起见,用 PrintWriter 把 OutputStream 包裹一下
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
// 刷新,如果没有这个刷新,可能客户端就不能第一时间看到响应结果
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 {
// 记得关闭!
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
在上述代码中,针对这里的 clientSocket
特意关闭了一下,但是对于 ServerSocket
就没有关闭,同理 UDP 版本的代码里,也没有针对 socket
的关闭,为什么?
关闭的目的是为了 “释放资源” ,释放资源的前提,是已经不再使用这个资源了,
对于 UDP 的程序和 serversocket
来说,这些 socket
都是贯穿程序始终的,
这些资源最迟最迟,也就是跟随进程的退出一起释放了 (进程才是系统分配资源的基本单位)
而 clientSocket
这个是每个连接有一个的一,数目很多,连接断开,也就不再需要了
每次都得保证处理完的连接都给进行释放
1.2、客户端
对于 UDP 的
DatagramSocket
来说,构造方法指定的端口,表示自己绑定哪个端口
对于 TCP 的ServerSocket
来说,构造方法指定的端口,也是表示自己绑定哪个端口
对于 TCP 的Socket
来说,构造方法指定的端口,表示要连接的服务器的端口,要和哪一个服务器上的哪一个端口建立连接
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
// 用普通的 socket 即可,不用 ServerSocket 了
private Socket socket = null;
public TcpEchoClient(String serverIP, int serverPort) throws IOException {
// 这里可以给端口号,但还这里给了之后,含义是不同的
// 传入的 IP 和 端口号 的含义表示的不是自己绑定,而是服务器的,表示和这个 IP 端口 建立连接!
// 调用这个构造方法,就是和服务器建立连接 (打电话拨号了)
socket = new Socket(serverIP, serverPort);
}
public void start() {
System.out.println("和服务器连接成功");
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream()) {
try (OutputStream outputStream = socket.getOutputStream()) {
while (true) {
// 仍是四个步骤
// 1、先从控制台读取用户输入的字符串
System.out.print("-> ");
String request = scanner.next();
// 2、把这个用户输入的内容,构造成一个请求,并发送
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush(); // 刷新,如果没有这个刷新,可能客户端就不能第一时间看到响应结果
// 3、从服务器读取响应数据,并解析
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
// 4、把响应结果显示到控制台上
System.out.printf("req : %s, resp : %s\n", request, 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();
}
}
运行结果:
[/127.0.0.1 : 7085] 客户端建立连接!
[/127.0.0.1 : 7085] req : test, resp: test!
[/127.0.0.1 : 7085] 客户端断开连接!
1.3、服务器 — 多线程
问题:
虽然此时的 TCP 代码已经跑起来了,但是此处还存在一个很严重的问题!
当前的服务器,同一时刻,只能处理一个连接! [不科学]
为啥当前咱们的服务器程序,只能处理一个客户端?
能够和客户端交互的前提是,要先调用 accept,接收连接 (接通电话)上面的代码,第一次
accept
结束之后,就会进入processConnection
,在processConnection
又会有一个循环
如果 processConnection 里面的循环不结束,processConnection 就无法执行完成
如果无法执行完成,就导致外层循环无法进入下一轮,也就无法第二次调用 accept , 也就不能接收第二个客户端的连接了当前这个问题,就好像你接了个电话,和对方你一言我一语的聊天,然后其他人再打电话,就没法继续接通了
解决:
要想解决上述问题,就得让
processConnection
的执行,和前面的 accept 的执行互相不干扰,不能让processConnection
里面的循环导致 accept 无法及时调用多线程!
问题:为啥咱们刚才 UDP 版本的程序就没用多线程,也是好着的呀?
因为 UDP 不需要处理连接,UDP 只要一个循环,就可以处理所有客户端的请求
但是此处,TCP 既要处理连接,又要处理一个连接中的若干次请求,就需要两个循环,里层循环,就会影响到外层循环的进度了
- 主线程,循环调用
accept
,当有客户端连接上来的时候,就直接让主线程创建一个新线程,由新线程负责对客户端的若干个请求,提供服务,(在新线程里,通过 while 循环来处理请求),这个时候,多个线程是并发执行的关系 (宏观上看起来同时执行),就是各自执行各自的了,就不会相互干扰
(也要注意,每个客户端连上来都得分配一个线程)
只需要在刚刚的代码中,改动 start()
的即可:
Thread t = new Thread(() -> {
processConnection(clientSocket);
});
t.start();
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
完整代码:
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.util.Scanner;
public class TcpThreadEchoServer {
// listen 英文原意:监听。但是 Java socket 中体现出 "监听" 的含义,
// 这样叫是因为,操作系统原生的 API 中,有一个操作叫做 listen
// private ServerSocket listenSocket = null;
private ServerSocket serverSocket = null;
public TcpThreadEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动了!");
while (true) {
// 1、建立连接
// 由于 TCP 是有连接的,不能一上来就读数据,需要先建立连接 (接电话)
// accept 就是在接电话,接电话的前提是,有人给你打,如果当前客户端尝试建立连接,此处的 accept 就会阻塞
// accept 返回了一个 socket 对象,称为 clientSocket,后续和客户端之间的沟通,都是都过 clientSocket 来完成的
Socket clientSocket = serverSocket.accept();
// [改进方法] 在这里,每次 accept 成功,都创建一个新的线程,由新线程负责执行这个 processConnection 方法,串行变并发
Thread t = new Thread(() -> {
// 2、处理连接
// 这里之所分成了两步 就是因为要建立连接 一个专门负责建立连接 一个专门负责数据通信
processConnection(clientSocket);
});
t.start();
}
}
// 处理连接
private void processConnection(Socket clientSocket) {
System.out.printf("[%s : %d] 客户端建立连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 接下来处理请求和响应
try (InputStream inputStream = clientSocket.getInputStream()) {
try (OutputStream outputStream = clientSocket.getOutputStream()) {
// 把 inputStream 中的数据读出来,写入到 outputStream 中
// 循环地处理每个请求,分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true) {
// 1、读取请求
if (!scanner.hasNext()) {
System.out.printf("[%s : %d] 客户端断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next(); // 此处用 Scanner 更方便,如果用 InputStream 的 read 也可以
// 2、根据请求,计算响应
String response = process(request);
// 3、将这个响应返回客户端
// 方便起见,用 PrintWriter 把 OutputStream 包裹一下
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
// 刷新,如果没有这个刷新,可能客户端就不能第一时间看到响应结果
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 {
// 记得关闭!
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpThreadEchoServer server = new TcpThreadEchoServer(9090);
server.start();
}
}
此时运行,没有问题:
改成多线程版本了之后,虽然前面的代码已经进入到处理连接的逻辑了,但是并不影响第二次去调用 accept
服务器启动了!
[/127.0.0.1 : 7366] 客户端建立连接!
[/127.0.0.1 : 7371] 客户端建立连接!
[/127.0.0.1 : 7366] req : hello, resp: hello!
[/127.0.0.1 : 7371] req : java, resp: java!
当前的这个问题,其实是电话打过去了,只是对方没接听,对方听到响铃了嘛?听到了
尝试建立连接的请求,已经发过去,对方也知道了,只是对方不想理你而已
当客户端 new Socket 成功的时候,其实在操作系统内核层面,已经建立好连接了 ( TCP 三次握手),但是应用程序 ,没有接通这个连接
1.4、服务器 — 线程池
还是在刚刚的代码中,改动 start()
的即可:
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.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpThreadPoolEchoServer {
// listen 英文原意:监听。但是 Java socket 中体现出 "监听" 的含义,
// 这样叫是因为,操作系统原生的 API 中,有一个操作叫做 listen
// private ServerSocket listenSocket = null;
private ServerSocket serverSocket = null;
public TcpThreadPoolEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动了!");
ExecutorService pool = Executors.newCachedThreadPool();
while (true) {
// 1、建立连接
// 由于 TCP 是有连接的,不能一上来就读数据,需要先建立连接 (接电话)
// accept 就是在接电话,接电话的前提是,有人给你打,如果当前客户端尝试建立连接,此处的 accept 就会阻塞
// accept 返回了一个 socket 对象,称为 clientSocket,后续和客户端之间的沟通,都是都过 clientSocket 来完成的
Socket clientSocket = serverSocket.accept();
// [改进方法] 在这里,每次 accept 成功,都创建一个新的线程,由新线程负责执行这个 processConnection 方法,串行变并发
// 通过线程池来实现
pool.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
// 处理连接
private void processConnection(Socket clientSocket) {
System.out.printf("[%s : %d] 客户端建立连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 接下来处理请求和响应
try (InputStream inputStream = clientSocket.getInputStream()) {
try (OutputStream outputStream = clientSocket.getOutputStream()) {
// 把 inputStream 中的数据读出来,写入到 outputStream 中
// 循环地处理每个请求,分别返回响应
Scanner scanner = new Scanner(inputStream);
while (true) {
// 1、读取请求
if (!scanner.hasNext()) {
System.out.printf("[%s : %d] 客户端断开连接!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
String request = scanner.next(); // 此处用 Scanner 更方便,如果用 InputStream 的 read 也可以
// 2、根据请求,计算响应
String response = process(request);
// 3、将这个响应返回客户端
// 方便起见,用 PrintWriter 把 OutputStream 包裹一下
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
// 刷新,如果没有这个刷新,可能客户端就不能第一时间看到响应结果
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 {
// 记得关闭!
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpThreadPoolEchoServer server = new TcpThreadPoolEchoServer(9090);
server.start();
}
}
2、TCP 翻译
继承
TcpThreadPoolEchoServer
,将 process 改成 public
package network;
import java.io.IOException;
import java.util.HashMap;
public class TcpDictServer extends TcpThreadPoolEchoServer {
private HashMap<String, String> dict = new HashMap<>();
public TcpDictServer(int port) throws IOException {
super(port);
dict.put("cat", "猫");
dict.put("dog", "狗");
dict.put("pig", "猪");
}
@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();
}
}
根据请求计算响应,是一个服务器程序最最复杂的过程
问题:
一个 TCP 服务器,能否让一个 UDP 客户端连上?
TCP 和 UDP 他们无论是 API 代码,还是协议底层 的工作过程,都是差异巨大的 (生殖隔离)。不是单纯的 “把流转成数据报” 就可以的,一次通信,需要用到五元组,协议类型不匹配,通信是无法完成的!