> 文档中心 > 【Java 网络编程】网络通信原理、TCP、UDP 回显服务

【Java 网络编程】网络通信原理、TCP、UDP 回显服务


一、网络发展历史

互联网从何而来?

这要追溯到上个世纪 50 - 60 年代,当时正逢美苏争霸冷战,核武器给战争双方提供了足够的威慑力,想要保全自己,就要保证自己的反制手段是有效的。

如何保证能够反击:

  1. 保存指挥机构
  2. 保存核弹头和发射井
  3. 指挥机构和核弹头之间的通信链路
    需要保证通信链路在核弹洗地的情况下仍然能正常运作
    最终方案,以力破巧!让指挥机构和核弹头之间,有无数条可以通信的链路,哪怕其中一部分被打掉了,剩余的仍然能够正常工作,从而衍生出了今天的互联网。

中国互联网的发展是非常滞后的,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:编程来提供网络资源

发送端和接收端:
在一次网络数据传输时:

  • 发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。

  • 接收端:数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。

  • **收发端:**发送端和接收端两端,也简称为收发端。

注意:发送端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念

请求和响应:
一般来说,获取一个网络资源,涉及到两次网络数据传输:

  • 第一次:请求数据的发送

  • 第二次:响应数据的发送

好比在快餐店点一份炒饭:
先要发起请求:点一份炒饭,再有快餐店提供的对应响应:提供一份炒饭

客户端和服务端:

  • 服务端:在常见的网络数据传输场景下,把提供服务的一方进程,称为服务端,可以提供对外服务。

  • 客户端获取服务的一方进程,称为客户端。

对于服务来说,一般是提供:

  • 客户端获取服务资源
  • 客户端保存资源在服务端

好比在银行办事:
银行提供存款服务:用户(客户端)保存资源(现金)在银行(服务端)
银行提供取款服务:用户(客户端)获取服务端资源(银行替用户保管的现金)

常见的客户端服务端模型:
最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序:

  1. 客户端先发送请求到服务端

  2. 服务端根据请求数据,执行相应的业务处理

  3. 服务端返回响应:发送业务处理结果

  4. 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)


四、网络编程套接字

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,目的端口,协议类型

站在服务器的角度:

  1. 源IP:服务器程序本机的 IP
  2. 源端口:服务器绑定的端口 (此处手动指定了 9090)
  3. 目的 IP:包含在收到的数据报中 (客户端的 IP)
  4. 目的端口:包含在收到的数据报中 (客户端的端口)
  5. 协议类型:UDP

站在客户端的角度:

  1. 源IP:本机 IP
  2. 源端口:系统分配的端口
  3. 目的IP:服务器的 IP
  4. 目的端口:服务器的端口
  5. 协议类型: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,内存,磁盘,带宽…)
能处理多少客户端,取决于:

  1. 处理一个请求,消耗多少资源

  2. 机器一共有多少资源能用

(在 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 代码,还是协议底层 的工作过程,都是差异巨大的 (生殖隔离)。不是单纯的 “把流转成数据报” 就可以的,一次通信,需要用到五元组,协议类型不匹配,通信是无法完成的!