接上次博客:JavaEE初阶(8)网络初识(网络发展史、网络通信基础、IP地址、端口号、认识协议、五元组、协议分层、OSI七层模型、TCP/IP五层(或四层)模型、网络设备所在分层、网络分层对应、封装和分用 )_di-Dora的博客-CSDN博客
目录
网络编程
网络编程基础
为什么需要网络编程?——丰富的网络资源
什么是网络编程
网络编程中的基本概念
请求和响应
客户端和服务端
常见的客户端服务端模型
TCP(传输控制协议)和UDP(用户数据报协议)
Socket套接字
概念
分类
UDP数据报套接字编程
DatagramSocket API
DatagramPacket API
InetSocketAddress API
UDP服务端
UDP客户端
翻译服务器
TCP流套接字编程
ServerSocket API
Socket API
TCP连接和UDP无连接
TCP连接的建立和应用程序的角色
TCP中的长短连接
ServerSocket服务器
Socket 客户端
TCP版本的字典客户端和字典服务器
需要网络编程的原因之一是网络提供了丰富的资源和机会,让应用程序能够访问和利用这些资源。
用户在浏览器中,打开在线视频网站,如优酷看视频,实质是通过网络,获取到网络上的一个视频资源。与本地打开视频文件类似,只是视频文件这个资源的来源是网络。 相比本地资源来说,网络提供了更为丰富的网络资源。
所谓的网络资源,其实就是在网络中可以获取的各种数据资源。 而所有的网络资源,都是通过网络编程来进行数据传输的。
还有很多用到网络编程的地方:
访问远程数据: 互联网是一个巨大的信息存储库,包含了各种各样的数据,如网页、文档、图片、音频、视频等。通过网络编程,应用程序可以从远程服务器获取和检索这些数据,为用户提供广泛的信息来源。
在线媒体和娱乐: 网络编程使得流媒体服务、在线音乐、视频流、在线游戏等娱乐内容得以实现。用户可以通过网络编程访问并享受各种娱乐资源。
社交媒体和通信: 社交媒体平台、即时消息应用和电子邮件等通信工具都依赖于网络编程来实现用户之间的互动和信息交流。
在线购物和电子商务: 电子商务网站和应用程序通过网络编程提供在线购物、支付和订单处理服务,使用户能够轻松购买商品和服务。
云计算和在线存储: 云计算平台和在线存储服务利用网络编程实现数据存储、备份和计算资源的分配,使用户能够轻松管理和扩展其计算和存储需求。
远程协作和工作: 远程工作、远程协作和在线协作工具依赖于网络编程,使团队能够跨地域合作、共享文件和资源。
物联网(IoT): 物联网设备通过网络编程与互联网连接,实现了智能家居、智能城市、工业自动化等应用。
数据分析和研究: 数据科学家和研究人员使用网络编程来获取和分析大规模数据,支持科研和业务决策。
安全和隐私: 网络编程包括网络安全领域,涉及数据加密、身份验证、访问控制等技术,以保护用户的隐私和安全。
总之,网络编程是连接我们与互联网世界之间的桥梁,为我们提供了无限的机会和资源。它使得各种应用程序能够访问和利用互联网上的各种服务和数据,满足了人们的各种需求,从娱乐到工作,从社交到学习,从商务到科研。因此,网络编程在现代社会中变得至关重要。
网络编程,指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称为网络数据传输)。
网络编程是一种通过计算机程序来实现网络通信的技术。它涵盖了在计算机网络上发送、接收和处理数据的各种任务。网络编程的主要目的是允许不同计算机之间的数据交换和通信。这可以是在本地网络中的两台计算机之间,也可以是在全球互联网上的任何两台计算机之间。
当然,我们只要满足进程不同就行;
所以即便是同一个主机,只要是不同进程,基于网络来传输数据, 也属于网络编程。
特殊的,对于开发来说,在条件有限的情况下,一般也都是在一个主机中运行多个进程来完成网络编程。
但是,我们一定要明确,我们的目的是提供网络上不同主机,基于网络来传输数据资源:
在网络编程中,有一些基本概念和角色,这些概念有助于我们理解数据在网络中的传输和通信过程。以下是一些重要的网络编程基本概念:
1. 发送端(Sender):发送端是数据的发送方,通常是一个运行在源主机上的进程。发送端负责将数据封装并通过网络发送给接收端。
2. 接收端(Receiver):接收端是数据的接收方,通常是一个运行在目的主机上的进程。接收端负责从网络中接收数据包,并将其解析和处理。
3. 收发端(Sender-Receiver):收发端是发送端和接收端的合称,用于描述一次网络数据传输的两个角色。发送端和接收端在一次数据传输中扮演不同的角色,数据从发送端流向接收端。
4. 源主机(Source Host):源主机是网络通信中的发送端主机,它托管着发送端进程,并负责将数据发送到网络上。
5. 目的主机(Destination Host):目的主机是网络通信中的接收端主机,它托管着接收端进程,并接收从网络中传输过来的数据。
这些基本概念有助于我们理解数据在网络中的传输流向和网络编程中不同角色的职责。在网络通信中,数据的发送端和接收端扮演着不同的角色,它们协同工作以实现数据的可靠传输和通信。
在网络通信中,获取一个网络资源通常涉及两个关键阶段:请求和响应。这两个阶段分别表示了数据在网络上的发送和接收过程,是网络通信的核心概念。
1. 请求阶段:
2. 响应阶段:
这两个阶段构成了一次完整的网络通信过程。客户端通过请求发送请求信息,服务器根据请求处理后发送响应信息。这种请求和响应的模式是互联网上各种网络应用的基础,如网页浏览、电子邮件、文件下载等。
通过请求和响应,网络应用能够实现数据的传输、资源的获取,以及用户与服务器之间的交互。深入理解请求和响应的概念有助于开发和理解网络编程中的数据交互过程。
在网络通信中,有两个主要角色扮演着不同的角色:客户端和服务端。这两者之间形成了典型的请求和响应模式,构建了网络应用的核心。
1. 服务端:
2. 客户端:
在实际应用中,客户端和服务端之间的通信形成了网络应用的核心。客户端发出请求,服务端响应请求,这种请求和响应的模式构建了许多互联网应用,包括网页浏览、社交媒体、电子邮件通信等。深入理解客户端和服务端的角色和交互方式对于网络编程和开发网络应用至关重要。
对于服务来说,一般是提供:
客户端获取服务资源——客户端保存资源在服务端。
好比在银行办事:
银行提供存款服务:用户(客户端)保存资源(现金)在银行(服务端)
银行提供取款服务:用户(客户端)获取服务端资源(银行替用户保管的现金)
网络通信中,最常见的模型之一是客户端服务端模型。在这种模型中,客户端和服务端分别扮演不同的角色,通过请求和响应来实现通信。
以下是常见的客户端服务端模型的基本流程:
客户端发起请求:
服务端接收请求:
服务端处理请求:
服务端生成响应:
客户端接收响应:
客户端处理响应:
这种模型基于请求和响应的方式,实现了客户端和服务端之间的交互和通信。这种通信模型被广泛应用于互联网应用的开发中,如网页浏览、电子邮件、文件传输等。通过请求和响应的方式,客户端和服务端能够协同工作,实现各种复杂的功能和服务。
计算机技术和通信协议是计算机网络产生和发展的两个最基本的因素。计算机技术涵盖了计算机硬件和软件方面的知识和技能,而通信协议则是计算机网络中用于数据通信的规则和约定,确保数据能够在网络中正确传输和交换。这两个因素共同推动了计算机网络的发展。
网络编程是通过网络让两个主机之间能够进行通信,从而实现特定的功能。
进行网络编程的时候,需要操作系统给咱们提供一组API,通过这些API才能完成编程。
API可以认为是应用层和传输层之间交互的路径。
这组API通常被称为"Socket API",类似于应用层和传输层之间的交互路径。
Socket API允许我们在不同的主机和不同的操作系统之间进行网络通信。
在网络编程中,传输层起着关键的作用,而传输层主要提供了两种主要的网络协议:TCP(传输控制协议)和UDP(用户数据报协议)。这两种协议在工作原理和特性上存在明显的差异,因此在进行网络编程时,我们需要根据具体的需求选择合适的协议。
下面是TCP和UDP的主要区别:
连接性:此处抽象的“连接”本质上是建立连接的双方各自保存对方的信息。
可靠性:网络通信是不可能百分百送达。可靠传输是退而求其次,A发消息给B,消息是否到达A自己可以感知到,进一步的就可以再发送消息失败的时候采取一定的措施,比如尝试重新传输之类的,但是能不能成功就不清楚了。但是可靠传输的效果已经足够好了,足以应对日常开发的各种问题。
数据单位:此处的“字节流”和文件操作的“字节流”是一个意思。
全双工性:
全双工:比如,当你打电话时,你可以同时说话和听对方说话,这就是全双工通信的一个常见示例。在计算机网络和通信领域,全双工通信也非常常见,例如,网络中的数据传输通常是全双工的,允许数据同时在两个方向上传输,这提高了通信的效率和响应速度。
相对于半双工通信,全双工通信具有更高的通信带宽和更低的通信延迟,因为它不需要在发送和接收之间切换。这种通信方式在各种应用中都非常重要,特别是在网络通信和电话通信领域。
根据具体的需求,我们可以选择使用TCP或UDP进行网络编程。TCP适用于需要可靠传输和数据完整性的应用,如网页浏览和文件传输。UDP适用于需要低延迟和快速数据传输的应用,如实时音频/视频流和在线游戏。
总之,Socket API为网络编程提供了强大的工具,允许开发人员创建各种网络应用,从简单的文件传输到复杂的实时通信应用。选择合适的协议和API取决于项目的具体需求和性质。
当涉及到网络通信时,TCP(Transmission Control Protocol)和UDP(User Datagram Protocol)都有各自的优势和限制。
TCP的优势:
可靠性: TCP提供可靠的数据传输。它使用序号、确认和重传机制来确保数据的有序传输和完整性,如果数据包丢失或损坏,TCP会负责重新传输,从而保证数据的可靠性。
流控制: TCP具有流控制机制,可以防止发送方发送速度过快,导致接收方无法处理的情况。这有助于平衡发送和接收之间的数据流量,防止数据丢失和网络拥塞。
拥塞控制: TCP实施拥塞控制算法,以避免网络拥塞。它会自动调整发送速率,以确保网络不会过载,从而提高整体性能。
有序传输: TCP确保数据包按照发送的顺序到达接收端,这对于需要有序数据的应用非常重要,如文件传输和网页加载。
面向连接: TCP是一种面向连接的协议,建立连接和断开连接都有明确定义的过程,这有助于管理数据传输的状态。
TCP的限制:
高延迟: 由于TCP的连接建立和拥塞控制机制,它通常比UDP具有更高的延迟。这意味着在某些实时应用中,如在线游戏或实时视频通信,TCP可能不太适用。
资源消耗: TCP需要在发送和接收方维护连接状态信息,这会占用一定的系统资源。在大规模连接的情况下,可能会导致资源消耗较大。
不适用于广播和多播: TCP是一对一的通信协议,不支持广播和多播。如果需要向多个接收方发送相同的数据,UDP更适合。
复杂性: TCP的连接管理和拥塞控制机制使得它相对复杂,实现和维护起来可能需要更多的开销。
总结起来,TCP适用于需要可靠数据传输和有序数据的应用,如文件传输、电子邮件和网页浏览。但在对实时性要求高或资源受限的情况下,UDP可能更为合适,如在线游戏、实时音视频通信和传感器数据传输。选择使用TCP还是UDP通常取决于应用的性质和需求。
UDP协议在某些情况下确实有其独特的优势,但也存在一些限制,因此不能完全替代TCP:
UDP的优势:
低延迟: UDP协议通常比TCP更快,因为它不需要建立连接、维护状态信息或执行复杂的拥塞控制。这使得UDP在实时性要求高的应用中非常有用,如在线游戏、实时视频和音频通信。
简单: UDP的协议头部较小,不需要维护连接状态,因此实现和处理起来相对简单。
广播和多播: UDP支持广播和多播,可以向多个接收方发送相同的数据包,这在某些应用中很有用,如多媒体流传输。
UDP的限制:
不可靠传输: UDP是一种不可靠的传输协议,不提供数据包的可靠传输保证。这意味着数据包可能会丢失、乱序或重复,而且不会有任何通知或恢复机制。因此,对于需要确保每个数据包都可靠到达的应用来说,UDP通常不是一个好选择。
无拥塞控制: UDP不具备TCP的拥塞控制机制,这意味着当网络拥塞时,UDP会继续发送数据包,可能导致网络更加拥堵,从而影响整体性能。
无流量控制: UDP不提供流量控制机制,发送方可以不受限制地发送数据,这可能会导致接收方无法跟上数据的处理速度,造成数据丢失或溢出。
不支持重传: TCP具有重传机制,可以在数据包丢失时重新发送,确保可靠传输。而UDP不提供这种机制,如果数据包丢失,需要由应用层来处理重传或纠正错误。
综上所述,UDP适用于某些特定的应用场景,如实时性要求高、数据丢失可接受的情况下。但对于需要可靠传输、数据完整性和拥塞控制的应用,TCP仍然是更好的选择。通常,选择使用UDP还是TCP取决于应用的要求和性能需求。
网络通信数据的基本单位涉及到多个说法:
数据报(Datagram): 数据报通常用于描述网络层(网络协议栈中的第三层)中的数据传输单位。在这个层级上,数据被分割成一个个的数据报,每个数据报包含了目标主机的IP地址和其他相关信息,用于在网络中路由和传递数据。UDP是一个常见的协议,使用数据报传输数据。
数据包(Packet): 数据包是一个通用的术语,可以用于描述不同层级中的数据传输单位。在数据链路层(网络协议栈中的第二层)中,数据包通常称为帧(Frame)。在传输层(网络协议栈中的第四层)中,数据包可以称为段(Segment,对应于TCP协议)或数据报(Datagram,对应于UDP协议)。因此,数据包的具体含义取决于它所在的协议层级。
数据帧(Frame): 数据帧通常用于描述数据链路层中的数据传输单位。在这个层级上,数据被分割成一系列的数据帧,每个数据帧包含了目标设备的MAC地址等信息,用于在局域网中传递数据。以太网是一个常见的协议,使用数据帧传输数据。
数据段(Segment): 数据段通常用于描述传输层中的数据传输单位。在传输层,数据被分割成一系列的数据段,每个数据段包含了源端口和目标端口等信息,用于在两台主机之间传递数据。TCP协议和UDP协议都使用数据段传输数据。
这几个数据有区别,但是日常开发过程中这些术语也会经常混用。
Socket套接字是一项由操作系统提供的网络通信技术,它构成了基于TCP/IP协议的网络编程的基本操作单元。网络编程的核心概念就是通过Socket套接字来实现数据的发送和接收,从而实现主机之间的通信。
Socket套接字主要针对传输层协议划分为如下三类:
流套接字(Stream Socket):流套接字使用传输层TCP协议,即Transmission Control Protocol,这是一种可靠的、有连接的协议。
TCP的特点包括:
数据报套接字(Datagram Socket):数据报套接字使用传输层UDP协议,即User Datagram Protocol。
UDP的特点包括:
原始套接字(Raw Socket):原始套接字用于自定义传输层协议,允许读写内核没有处理的IP协议数据。原始套接字通常用于特定的网络调试和控制任务,不是通常网络应用程序的一部分,因此在网络编程中很少使用。
Socket套接字是网络编程的基础,通过选择合适的套接字类型和传输层协议,开发人员可以实现各种网络应用,从可靠的数据传输到快速的实时通信应用,以满足不同的需求和场景。在网络编程中,了解这些套接字类型的特点和用途非常重要。
Socket其实也是操作系统中的一个概念。本质上是一种特殊的文件。Socket就属于是把“网卡”这个设备给抽象成文件了。往Socket文件中写数据就相当于通过网卡发送数据;往Socket文件中读数据就是通过网卡接收数据。这样就把网络通信和文件给统一了。
Java中就是使用 DatagramSocket 这个类来表示系统内部的Socket文件了。
构造方法:
方法:
在 receive 方法中,DatagramPacket 参数是一个输出型参数,它用于接收和存储接收到的数据报的内容。DatagramPacket 中包含了数据报的内容以及发送方的地址和端口等信息。
DatagramPacket是UDP Socket发送和接收的数据报。
使用这个类来表示UDP数据报。既然UDP是面向数据报的,每次进行传输都要以UDP数据报为基本单位。
构造方法:
方法:
当构造 UDP 数据报时,通常会使用 SocketAddress 对象,可以使用 InetSocketAddress 类来创建。这个对象包含了目标主机的IP地址和端口号信息,用于指定数据报的目的地。
InetSocketAddress 是 SocketAddress 的一个子类,用于表示一个包含 IP 地址和端口号的套接字地址。以下是关于 InetSocketAddress 的构造方法和方法:
构造方法:
InetSocketAddress(InetAddress addr, int port): 创建一个 InetSocketAddress 对象,包含指定的 IP 地址和端口号。这个构造方法用于指定套接字的目的地。
方法:
接下来我们就先写一个简单的UDP客户端/服务器通信的程序。
这个程序没有啥业务逻辑,只是单纯的调用socket API,让客户端给服务器发送一个请求,这个请求就是一个字符串,服务器收到字符串之后,就会把这个字符串原封不动的返回给客户端,客户端再显示出来。就相当于是一个回显服务器(echo seives)(回显服务器(Echo Server)是一种非常简单的服务器应用,它接收客户端发送的数据并将接收到的数据原封不动地返回给客户端。这种服务器常用于测试和调试网络通信,以确保数据传输的正确性)
首先,我们创建一个UDP服务器程序,它将监听指定的端口,接收客户端发送的数据,然后将接收到的数据原封不动地发送回客户端。
服务器和客户端都需要创建Socket对象,但是服务器的一般要显式的指定一个端口号,但是客户端的一般不能显式指定(不显式指定,此时系统就会自动分配一个随机的端口)
服务器显式指定端口号:
避免冲突:在一个主机上运行的多个服务器应用程序可能需要监听不同的端口,以便客户端可以根据端口号找到并连接到特定的服务器。显式指定端口号有助于确保不同服务器之间不发生端口冲突。
方便管理:管理员可以轻松地查看服务器的配置文件或设置来了解每个服务器在哪个端口上运行,这有助于管理和维护服务器应用。
客户端不显式指定端口号:
避免冲突:客户端通常会主动发起连接请求,而不需要被动监听端口。因此,客户端可以使用操作系统分配的随机可用端口,避免与其他客户端冲突。
提高连接灵活性:不显式指定端口号使客户端更加灵活,可以随机分配可用端口,而无需担心端口冲突。这对于同时运行多个客户端应用程序或多次连接同一服务器的情况很有用。
简化客户端代码:客户端通常只需指定服务器的IP地址和端口号,不需要额外的配置,从而简化了客户端代码。
服务器这边手动指定端口就不会出现冲突吗?
服务器在程序猿手里,一个服务器上都有哪些程序、都使用哪些端口,程序猿都是可控的。所以写代码的时候就可以指定一个空闲的端口给当前的服务器使用即可。
但是客户端就不可控了,客户端是在用户的电脑上。一方面用户千千万,每个用户电脑上装的程序都不一样,占用的端口号也不一样。另一方面,用户这边出现端口冲突,用户也无法解决。所以交给系统分配更理想、更稳妥。
我们最近好像经常见到这个循环?
一个服务器经常是要长时间运行的(7*24小时),也不知道客户端什么时候来?来几个?
所以这样的过程就经常要用到循环:while(true)。
先构造一个这样空着的DatagramPacket对象,传递到方法内部,由 receive方法内部对这个数据进行填充。
服务器一旦启动,就会立即执行到receive方法。但是此时客户端的请求还没来。这种情况也没关系,receive方法会直接阻塞,一直阻塞到真正客户端把请求发过来为止。
这个 getLength()得到的结果是否是4096?
不是,此处这个结果时收到的数据的真实长度(取决于发送方这一次到底发送了多少)。
对于一个服务器来说,最重要的就是“根据请求计算响应”。
这句话的意思是,服务器的主要任务是根据客户端的请求来生成相应的数据或内容。
服务器是一种计算机程序或设备,其存在的主要目的是响应来自客户端的请求。当客户端发出请求时,服务器会根据请求的内容和类型执行适当的操作,然后生成一个响应,将响应发送回客户端。
这个过程可以是多样化的,取决于服务器的用途和应用领域。例如:
Web服务器:当浏览器向Web服务器发出HTTP请求时,服务器会根据请求的URL获取相应的网页或资源文件,并将其发送回浏览器,以供用户查看。
文件服务器:当用户请求在文件服务器上存储的文件时,服务器会检索请求的文件并将其发送给用户。
数据库服务器:当应用程序需要从数据库中检索或更新数据时,数据库服务器会执行相应的数据库操作,并将查询结果或更新操作的确认发送回应用程序。
游戏服务器:在在线游戏中,服务器会处理玩家的游戏动作和请求,并将游戏状态更新发送给所有玩家。
因此,根据请求计算响应是服务器的核心任务,服务器的性能和功能质量通常取决于其如何有效地执行这个任务。服务器的设计和实现需要考虑请求处理、数据存储、安全性、性能优化等多个方面的问题。
只不过当前我们写的是一个回显服务器,不涉及到这些流程,不需要考虑应该怎么计算,只要请求过来,就把它当成响应。真正的服务器的 process ( ) 是非常复杂度,可能需要几十万行代码来描述。
由于UDP是无连接的,所以构造这个数据报需要指定数据内容和发送位置。
构造方法里面,前两个参数描述了数据是啥,最后一个参数指定了请求中的地址。
我有一个问题——这里把这里的代码换成“ response.length()”是否可以?
如果这个字符串里的内容都是英文字符,此时字节和字符的个数是一样的,但是如果包含中文就不一样了。这种方式得到的是字符单位,而在进行网络传输的时候,肯定是要使用字节的。
这样,关于UDP服务器的代码
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
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 request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求计算响应(一般的服务器都会经历的过程)
// 由于此处是回显服务器, 请求是啥样, 响应就是啥样.
String response = process(request);
// 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(), request, 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?不写close也不会出现文件资源泄露?
原因也很简单,这里的socket是文件描述符表中的一个表项,每次打开一个文件就会占用一个位置。文件描述符是在PCB上(跟随进程的)。这个socket在整个程序运行过程中都是需要使用的,不能提前关闭。当socket不需要使用的时候,意味着程序要结束了,进程结束,此时随之文件描述符表也就会被销毁(PCB都销毁了),此时还谈何资源泄露???所有东西都随着销毁的过程被系统自动回收了。
啥时候才会资源泄露?代码中频繁打开文件但是不关闭,在一个进程运行过程中,不断积累打开的文件,逐渐消耗掉文件描述符表里的内容也就消耗殆尽了。但是如果进程的生命周期很短,打开没多久就关闭了,就谈不上泄露。
所以文件资源泄露这样的问题在服务器是比较严重,但是在客户端一般问题不大。
接下来,我们创建一个UDP客户端程序,它将发送一个字符串到服务器,并等待服务器的回应。
在写这个代码过程中,我们用到了三个DatagramPacket的构造方法:
1、只指定字节数组缓冲区的(服务器收请求的时候需要使用,客户端收响应的时候也需要):
2、指定字节数组缓冲区,同时指定一个InetAddress对象(包括IP和端口)(服务器返回响应给客户端)
3、指定字节数组缓冲区,同时指定IP和端口号。需要把IP地址稍微转换一下。
后面两个都是让数据报里面带上内容+带上数据的目标地址
由于在同一台服务器上,所以我们这里使用环回IP。
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
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.print("-> ");
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);
client.start();
}
}
此时,客户端和服务器就可以通过网终配合完成通信过程了:
刚才我们写的两个程序,都是在同一个主机上,并没有实现真正的“跨主机通信”效果。
能否让你使用你的客户端程序访问我的服务器代码呢?
如果我的服务器在我自己的电脑上,此时你是不能直接访问的。除非我们的电脑都在一个局域网(同一个路由器内)。
虽然我自己的电脑不行,但是我可以把程序放到一个特殊的电脑上(云服务器),这个时候就可以被你访问了。因为我自己的电脑没有公网IP,云服务器有。
"云服务器"(Cloud Server)是指托管在云计算基础设施上的虚拟服务器实例。云服务器是云计算服务的一种,它允许用户租用计算资源,如虚拟机(Virtual Machines),而无需购买和维护物理服务器硬件。云服务器通常运行在大型数据中心中,由云服务提供商负责管理和维护。
所以 ,在网络编程中,要进行跨主机通信,通信双方的主机需要能够相互访问,总共来说就以下三种方法:
局域网通信:如果我们的服务器和客户端都在同一个局域网内(例如,连接到同一台路由器),它们通常可以直接通信,因为它们共享相同的局域网IP地址。在这种情况下,你可以使用服务器的局域网IP地址来访问我的服务器。
公网IP和端口转发:如果你希望从互联网上的任何位置都能够访问你的服务器,就需要一个具有公网IP地址的主机。许多家庭和小型办公室网络都使用NAT(网络地址转换)来将多个内部设备共享单个公网IP地址。在这种情况下,我们可以使用路由器上的端口转发功能将外部请求路由到服务器的内部IP地址和端口上。
云服务器:使用云服务器是一种常见的方法,因为云服务器通常具有公网IP地址,可以直接从互联网访问。我们可以在云服务器上部署您的服务器应用程序,并使用其公网IP地址和端口来访问它。
总之,要使客户端程序能够访问您的服务器,您需要确保服务器具有公网可达性,这可以通过公网IP地址、端口转发或云服务器来实现。
基于echo server,我们再来写一个翻译服务器,此时就带有一定的业务逻辑了。
要求:请求是一个英文单词,响应就会返回对应的中文翻译。
public class UdpDictServer extends UdpEchoServer {
private Map dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
// 此处可以往这个表里插入几千几万个这样的英文单词.
dict.put("puppy", "小狗");
dict.put("kitty", "小猫");
dict.put("piggy", "小猪");
}
// 重写 process 方法, 在重写的方法中完成翻译的过程.
// 翻译本质上就是 "查表"
@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();
}
}
当前是子类引用调用start,this就是指向子类引用。虽然this是父类的类型,但是实际指向的是子类引用。调用process自然也就会执行到子类的process方法。
虽然没有修改start方法的内容(主干流程没有改变),但是仍然可以确保按照新版本的process来执行。
通过重写process方法,就可以在子类中组织你想要的业务逻辑了。
TCP(Transmission Control Protocol)流套接字编程是一种用于网络通信的编程方法,与我们之前学习的UDP有一些重要区别。
TCP里面有两个类:ServerSocket API 和Socket API,这两个类都是用来表示socket文件的(抽象了网卡这样的硬件设备)
ServerSocket API:ServerSocket类是用于创建TCP服务端套接字的API。它允许服务器端应用程序绑定到一个特定的端口并等待客户端的连接请求。一旦有连接请求到达,它会创建一个新的Socket对象,用于与客户端进行通信。
Socket API:Socket类是用于TCP通信的API,既可用于服务器端,也可用于客户端。在服务器端,Socket对象代表与客户端的连接。在客户端,Socket对象用于建立与服务器的连接和进行数据交换。
你可能会疑惑,之前在讲UDP的时候,它专门用了一个类——DatagramPacket,来发送和接收的数据报。我们使用这个类来表示UDP数据报,每次进行传输都要以UDP数据报为基本单位。
那么难道TCP不需要专门设置一个类来划分TCP数据报作基本单位吗?
不需要,TCP本身就是字节流的,传输基本单位就是字节Byte。
TCP的数据单位:TCP是基于字节流的协议,它没有内置的数据报概念。数据在TCP连接中被视为连续的字节流,而不是分割成数据包或数据报。因此,与UDP不同,TCP不需要专门的类来表示数据报。
ServerSocket 是创建TCP服务端Socket的API, 使用这个类来绑定端口号。
构造方法
ServerSocket(int port): 创建一个服务端流套接字Socket,并绑定到指定端口。
方法
Socket API 既会给服务器使用,又会给客户端使用。
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端 Socket。 不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据 的。
构造方法
Socket(String host, int port): 创建一个客户端流套接字Socket,并与指定IP的主机上的对应端口的进程建立连接。
方法
TCP(Transmission Control Protocol):TCP是一种面向连接的协议,它在通信开始之前需要建立连接,以确保数据的可靠传输。TCP连接是全双工的,允许双方(客户端和服务器)在同一时间发送和接收数据。TCP提供错误检测、流控制和拥塞控制等功能,以确保数据的可靠性。因此,TCP适用于需要可靠数据传输的应用,如文件传输、网页浏览和电子邮件等。
UDP(User Datagram Protocol):UDP是一种无连接的协议,它不需要在通信开始之前建立连接。UDP只负责将数据报从一个端点发送到另一个端点,而不提供可靠性保证。UDP连接是无状态的,每个数据包都是独立的,没有状态信息跟踪。UDP适用于需要低延迟但可以容忍数据包丢失的应用,如实时音频/视频通信、在线游戏和DNS查询等。
对UDP来说,每次发送数据都得手动在send方法中制定目标地址,UDP自身没有存储这个信息。
对TCP来说,则不需要。前提是我们需要先把连接建立好。
连接如何建立,不需要代码干预,是系统内核自动负责完成的。
对于应用程序来说,
客户端这边主要是发起 “建立连接” 动作;
服务器这边主要是要把建立好的连接从内核中拿到应用程序。
如果有客户端和服务器建立连接,这个时候服务器的应用程序是不需要做出任何操作(也没有任何感知的),内核直接就完成了连接建立的流程(三次握手)。完成流程之后,就会在内核的队列中(每个serverSocket 都有一个这样的队列)排队。
应用程序要想和这个客户端进行通信,就需要通过一个accept 方法把内核队列里已经建立好的连接对象,拿到应用程序中。如果没有连接,就等待。这很像一个阻塞队列。
其实,serverSocket.accept() 就是一个阻塞方法,它会一直等待,直到有客户端发起连接请求时才返回,并返回一个新的 Socket 对象,该对象代表了与客户端建立的连接。
每次循环迭代时,都会通过 accept 方法创建一个新的 clientSocket,以便与新的客户端建立通信连接。
我们再说的明白一点:
TCP连接的建立:TCP连接的建立过程通常涉及三次握手(Three-Way Handshake):
应用程序的角色:在TCP连接建立之前,应用程序需要调用Socket API来发起连接请求。客户端应用程序通过创建一个Socket对象,并指定目标服务器的地址和端口号来发起连接。服务器端应用程序需要在等待客户端连接请求时创建一个ServerSocket对象,并绑定到一个特定的端口。一旦连接建立,应用程序可以通过Socket对象进行数据的发送和接收。
连接的队列:服务器端的内核会维护一个连接队列,等待来自客户端的连接请求。一旦连接请求被接受,连接对象将从队列中移至应用程序,使应用程序能够与客户端通信。
总之,TCP连接的建立是通过内核自动处理的,但应用程序需要使用Socket API来发起连接请求或接受连接请求,以便进行数据的收发。这些概念对于理解网络编程中的套接字通信非常关键。
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
短连接(Short Connection)
连接生命周期:短连接是一种即建即断的连接方式。每次客户端发送请求并等待服务器响应后,连接就会立即关闭。这意味着每个请求都需要重新建立连接。
效率和性能:短连接的建立和关闭过程会消耗一定的时间,因此在高并发环境下,频繁的连接建立和关闭可能会影响系统性能,因为这些操作会占用资源和时间。
主动请求:通常情况下,短连接是客户端主动向服务器发送请求,并且每个请求都需要新建一个连接。
适用场景:短连接适用于客户端请求频率不高的场景,例如普通网页浏览,其中每个页面请求之间可能存在一些时间间隔。
长连接(Long Connection)
连接生命周期:长连接是一种保持连接状态的方式,即连接在一段时间内保持打开状态,不会在每次请求后立即关闭。这允许客户端和服务器在同一连接上进行多次数据传输。
效率和性能:由于长连接只需要在第一次建立连接时消耗一些时间,之后的请求和响应可以直接传输,因此长连接在高并发环境下效率更高。
主动请求:长连接可以由客户端或服务器主动发起请求,也就是说,双方都可以在连接处于打开状态时发送数据。
适用场景:长连接适用于需要频繁交换数据的场景,例如实时聊天室、在线游戏等,其中客户端和服务器需要保持实时通信。
对比以上长短连接,两者区别如下:
建立连接和关闭连接的耗时:
主动发送请求的方式:
使用场景的不同:
扩展了解
基于BIO的长连接:使用同步阻塞IO(BIO)实现的长连接会一直占用系统资源,因为每个连接都需要在一个线程中运行,而且需要不断地阻塞等待数据。这对于并发要求很高的服务端系统来说可能效率较低。
基于NIO的长连接:在实际应用中,服务端一般会使用同步非阻塞IO(NIO)来实现长连接。NIO允许服务器有效地管理多个连接,使用较少的线程处理多个连接,从而提高性能和资源利用率。这对于高并发的场景更为适用。
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 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) {
// 进入方法, 先打印一个日志, 表示当前有客户端连上了.
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();
}
}
public String process(String request) {
// 此处也是写的回显服务器. 响应和请求是一样的.
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
前者用于在服务器端监听并接受客户端连接请求,在内核中完成相应的操作;
后者的作用是接受客户端的连接请求并创建一个与客户端通信的新套接字对象 clientSocket。
InputStream 和 OutStream 就是字节流!后续进行数据的交互,就可以借助这两个对象完成数据的“发送”和“接收”。
InputStream 通过进行read操作,就是“接收”;
OutStream 通过进行write操作,就是“发送”。
( 这两个对象都是字节流,进行发送和接收的时候就是以字节为单位)
try-with-resources:try (资源初始化) 是一种 try-catch-finally 结构的扩展,其中资源(在此处是输入流和输出流)在 try 块开始时初始化,而在 try 块结束后自动关闭,无论是否发生异常。这确保了资源的正确关闭,无需手动处理关闭操作。
异常处理:如果在 try 块内部发生了任何异常,系统会自动确保资源被正确关闭。在 try 块结束后,控制流会进入 catch 块(如果有异常发生)或继续执行后续代码。
这种写法的好处是:
后续客户端发起的请求会以空白符作为结束标记(此处我们就直接约定使用\n),以此判断从哪儿到哪儿是一个完整的请求。
TCP是字节流通信方式,每次传输多少字节、读取多少字节,都是非常灵活的。这个特点是好也是坏,所以我们往往会手动约定出从何处到何处是一个完整的数据报。每次循环就处理一个数据报即可:
约定结束标记: 在TCP通信中,数据流是连续的字节流,没有固定的消息边界。为了从字节流中分割出完整的请求,服务器和客户端可以约定一个特殊字符作为消息的结束标记。在这段代码中,约定的结束标记是\n,也就是换行符。这意味着每次客户端发送的请求应该以\n结尾。
上述这里就是约定使用 \n 作为数据包的结束标记,正好搭配 scanner.next 来完成请求的读取过程(以上都是隐藏在next后面的):
数据流处理: TCP通信的特点是数据流,因此需要按照约定的结束标记分割成消息。在代码中,我们使用了Scanner来读取客户端发送的请求。scanner.next()方法会按照空白符(包括空格、制表符、回车等)来分割输入,直到遇到\n结束标记为止。这样可以确保每次读取的是一个完整的请求。
但是!当前我们写的这个代码其实还存在两个比较严重的问题:
问题一:这个服务器程序有可能会存在文件资源泄露。
你可能会说,我们前面写过的 DatagramSocket 和 ServerSocket 都没close,不也没出问题?
但是ClientSocket不关闭,就真的泄露了。
原因在这里:
所以DatagramSocket 和 ServerSocket 都是在程序里,只有那么一个对象,生命周期贯穿整个程序。
与之相比,ClientSocket则是在循环中每次有一个新的客户端建立连接,一个服务器可不止一个客户端。对于 Socket(ClientSocket),它们是为了处理特定的客户端连接而存在,每个连接结束后都应该关闭,以释放资源。
每次执行这个划红线的代码都会创建出新的ClientSocket,而这个Socket 最多使用到到该客户端退出(断开连接)就不再使用了。
此时如果有很多客户端都来建立连接,就意味着每个连接都会创建ClientSocket。当ClientSocket连接断开就失去作用,但是由于没有手动关闭,此时这个 Socket对象就会长期占据着文件描述符表的位置。久而久之,文件描述符表就会满,导致文件资源泄露。
而这里的关闭只是关闭了ClientSocket上自带的流对象,并没有关闭socket本身。所以还是需要我们手动关闭。
所以在这个代码中我们需要在方法末尾通过finally加上close,保证当前这个的socket能够被正确关闭掉。
// 通过这个方法, 来处理当前的连接.
public void processConnection(Socket clientSocket) {
// 进入方法, 先打印一个日志, 表示当前有客户端连上了.
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 {
try {
// 在这个地方, 进行 clientSocket 的关闭.
// processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
那么如果代码这么写:
这个代码看似正确执行close,但是不要忘了
processConnection(clientSocket);
的中途可能会抛出异常。
那我们这样改一下呢?这样虽然没有手动写close,但是try结束就可以关闭:
当前这个版本的代码这么写是可以的。但是我们后续为了解决第二个问题,还会进一步调整代码结构,就不能这么写了。
好了,我们得先把客户端代码写完,才能解释第二个问题:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
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 的客户端差不多.
// 都是:
// 3. 从服务器读取响应.
// 4. 把响应显示到界面上.
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.print("-> ");
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();
}
}
我们现在来运行一下看看:
TCP程序,客户端启动,就会和服务器建立连接,服务器会感受到(accept方法会返回,进一步进入到内部)
现在我们把客户端停掉:
再次启动客户端:
分两次运行,已经不是同一个客户端了,分配到的端口号也不同。
问题二:如果启动多个客户端,多个客户端同时和服务器建立连接。
默认情况下,JAVA IDEA只允许一个代码创建一个进程,我们勾选 Allow multiple instances,就可以运行多个进程了。
现在启动两个客户端同时连接服务器。先启动的客户端一切正常:
后启动的客户端则没法和服务器进行交互,服务器没反应,不会提示“建立连接”,也不会针对请求做出任何响应。
我们现在看到的现象就是一个很明显的bug!
这个bug和我们当前的代码逻辑结构密切相关。
第一个客户端过来之后,accept就返回了,得到一个ClientSocket,进入到 processConnection。
又进入了一个while循环,这个循环中就需要反复处理第一个客户端发来的请求数据。如果客户端没有发请求,服务器的代码就会阻塞在第二个循环里的 scanner . next里面。
此时此刻,第二个客户端也过来建立连接了。此时连接是可以建立成功的(内核负责),建立成功后,连接对象就会在内核的队列中等待accept方法的调用。
第一个客户端就会使服务器处于 processConnection 内部,进一步也就是当前的第一层循环无法第二次执行到accept。
得等到第一个客户端退出, 服务器才会从 processConnection 返回,才能执行到第二次accept,也就可以处理第二个客户端了。
如何解决上述问题?让我们的服务器能够同时接待多个客户端呢?
关键就是,在处理第一个客户端的请求过程中,要让代码能够快速第二次执行到accept。
你能想到什么?
多线程。
上述关键就是能够让这两个循环能够并发执行,互不影响。
创建一个新的线程,让一个新的线程去调用 processConnection 。主线程就可以继续执行下一次accept方法了。新线程内部负责 processConnection内部的循环。
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 TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
// 通过 accept 方法, 把内核中已经建立好的连接拿到应用程序中.
// 建立连接的细节流程都是内核自动完成的. 应用程序只需要 "捡现成" 的.
Socket clientSocket = serverSocket.accept();
// 此处不应该直接调用 processConnection, 会导致服务器不能处理多个客户端.
// 创建新的线程来调用更合理的做法.
Thread t = new Thread(() -> {
processConnection(clientSocket);
});
t.start();
}
}
// 通过这个方法, 来处理当前的连接.
public void processConnection(Socket clientSocket) {
// 进入方法, 先打印一个日志, 表示当前有客户端连上了.
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 {
try {
// 在这个地方, 进行 clientSocket 的关闭.
// processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.
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();
}
}
此时意味着每次有一个客户端,就都得给分配一个新的线程。
这两个操作都会执行的非常快,循环就能迅速的转回来,新的线程就去 processConnection 处理客户端的请求了。
这样就没有问题了:
刚才出现这个问题的关键在于两重循环在一个线程里面,进入二重循环的时候,无法继续执行第一个循环。
而UDP版本的服务器当时只有一个循环,不存在类似的问题。
到了这里还有一个问题,每个客户端都要创建一个线程。如果有很多客户端,频繁的来建立连接/断开连接,这个时候就会导致服务器频繁的创建/销毁线程,开销是巨大的!
所以还可以使用线程池进一步优化:
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 TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
// 通过 accept 方法, 把内核中已经建立好的连接拿到应用程序中.
// 建立连接的细节流程都是内核自动完成的. 应用程序只需要 "捡现成" 的.
Socket clientSocket = serverSocket.accept();
// 此处不应该直接调用 processConnection, 会导致服务器不能处理多个客户端.
// 创建新的线程来调用更合理的做法.
// 这种做法可行, 不够好
// Thread t = new Thread(() -> {
// processConnection(clientSocket);
// });
// t.start();
// 更好一点的办法, 是使用线程池.
service.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
// 通过这个方法, 来处理当前的连接.
public void processConnection(Socket clientSocket) {
// 进入方法, 先打印一个日志, 表示当前有客户端连上了.
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 {
try {
// 在这个地方, 进行 clientSocket 的关闭.
// processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.
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();
}
}
线程池是可以降低“频繁的创建/销毁线程”,但是如果同一时刻有大量的客户端来连接,就会使系统出现大量的线程!如果一个机器有几百个线程还可以勉强跑跑,如果是上万个……这个机器非挂不可!
还有没有其他办法?
协程可能是一个有效手段,但是是最近几年才有的。
还有一个手段,IO多路复用/IO多路转接,也就是用一个线程同时处理多个客户端的socket。这个东西涉及到不少系统底层的知识,这里只给简单介绍,感兴趣的可以自己去看看。
IO多路复用/IO多路转接是一种高效的网络编程技术,用于处理大量并发的客户端连接,而无需为每个连接创建一个单独的线程或进程。它允许单个线程同时监视和处理多个套接字(Socket),从而降低了系统资源的消耗,并提高了网络应用程序的性能和可伸缩性。
IO多路复用的核心思想是利用操作系统提供的机制,通过一个线程来管理多个套接字的IO操作。在主要的操作系统中,有以下两个常见的机制:
- select系统调用: select 是一种阻塞调用,它会监视多个套接字的可读、可写、异常等状态,并返回就绪的套接字。程序员可以使用 select 来等待多个套接字中的任何一个就绪,然后执行相应的操作。
- epoll(Linux系统中的机制): epoll 是一种高效的多路复用机制,特别适用于大规模并发的网络应用。与 select 不同,epoll 使用事件通知方式,只通知已经就绪的套接字,避免了对所有套接字的轮询,从而提高了性能。
使用IO多路复用的好处包括:
- 资源节约: 不需要为每个连接创建一个线程或进程,减少了线程切换和内存开销。
- 高并发处理: 单个线程可以管理大量的连接,使得服务器能够轻松处理数千甚至数万个并发客户端。
- 响应迅速: IO多路复用使得服务器能够迅速响应就绪的套接字,提高了实时性。
然而,IO多路复用也需要深入了解操作系统的底层机制和编程技巧,因此实现相对复杂,需要谨慎处理。在不同的操作系统中,IO多路复用的实现方式和调用接口可能不同,例如,在Linux系统中,可以使用epoll,而在Windows系统中,可以使用IOCP(I/O Completion Ports)。
总的来说,IO多路复用是一种强大的网络编程技术,可以有效地应对高并发的网络连接,降低系统资源消耗,提高网络应用程序的性能和可伸缩性。
要编写TCP版本的字典客户端和字典服务器,我们可以在上面提供的TcpEchoServer和TcpEchoClient的基础上进行修改。
首先,我们需要创建一个字典服务端(TcpDictionaryServer),该服务端将监听客户端的请求并提供字典查询功能。然后,还需要创建一个字典客户端(TcpDictionaryClient),它将连接到字典服务器并向其发送查询请求。
字典服务端(TcpDictionaryServer):
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
public class TcpDictionaryServer {
private ServerSocket serverSocket = null;
private Map dictionary = new HashMap<>();
public TcpDictionaryServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
// 初始化字典数据
dictionary.put("hello", "你好");
dictionary.put("world", "世界");
dictionary.put("apple", "苹果");
}
public void start() throws IOException {
System.out.println("字典服务器启动!");
while (true) {
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
public void processConnection(Socket clientSocket) {
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 使用 try-with-resources 自动关闭流
byte[] buffer = new byte[1024];
int bytesRead;
// 读取客户端的查询请求
bytesRead = inputStream.read(buffer);
if (bytesRead != -1) {
String query = new String(buffer, 0, bytesRead);
String response = lookupWord(query.trim());
// 发送查询结果给客户端
outputStream.write(response.getBytes());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public String lookupWord(String word) {
// 查找字典中的单词并返回翻译,如果找不到则返回"未找到"
String translation = dictionary.getOrDefault(word, "未找到");
return translation;
}
public static void main(String[] args) throws IOException {
TcpDictionaryServer server = new TcpDictionaryServer(9090);
server.start();
}
}
字典客户端(TcpDictionaryClient):
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
public class TcpDictionaryClient {
private Socket socket = null;
public TcpDictionaryClient(String serverIp, int serverPort) throws IOException {
socket = new Socket(serverIp, serverPort);
}
public void start() {
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while (true) {
System.out.print("输入要查询的单词(输入exit退出): ");
String query = scanner.nextLine();
if (query.equalsIgnoreCase("exit")) {
break;
}
// 发送查询请求给服务器
outputStream.write(query.getBytes());
// 读取服务器的响应并显示
bytesRead = inputStream.read(buffer);
if (bytesRead != -1) {
String response = new String(buffer, 0, bytesRead);
System.out.println("翻译结果: " + response);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws IOException {
TcpDictionaryClient client = new TcpDictionaryClient("127.0.0.1", 9090); // 此处填入字典服务器的IP和端口
client.start();
}
}