18- 网络编程
一 网络编程基础
1.1 网络编程简介
所谓的网络编程,就是编写程序,实现让同一个网络中的机器可以进行数据的传递,实现通信。
Java是 Internet 的语言,它从语言级上提供了对网络应用程序的支持。
Java提供的网络类库,可以实现无痛的网络连接,联网的底层细节被隐藏在 Java 的本机安装系统里,由 JVM 进行控制,并且Java 实现了一个跨平台的网络库,因此程序员面对的是一个统一的网络编程环境,很容易开发常见的网络应用程序。
- 网络编程需要具备的条件
如果要实现两台机器之间实现通信,必须要满足几点要求:
-
需要知道对方的 IP 地址
-
需要知道对方的哪一个端口号来做数据的接收。
-
通信的双方,需要遵循相同的通信协议。
-
IP****地址和端口号
-
IP是 Internet Protocol (网络互连协议),在计算机中,使用IP地址来描述一个上网终端的唯一的地址编号。分为 IPv4 和 IPv6。
-
- IPv4 : 使用4个字节(32位)来描述一个IP地址,由四部分组成,每一部分一个字节。 192.168.1.xxx
- IPv6 : 使用6个字节(48位)来描述一个IP地址,由六部分组成,每一部分一个字节。
-
32位IP地址分为网络位和地址位,这样做可以减少路由器中路由表记录的数目,有了网络地址,就可以限定拥有相同网络地址的终端都在同一个范围内,那么路由表只需要维护一条这个网络地址的方向,就可以找到相应的这些终端了。
-
IP 地址的分类:
-
- A类: 1.0.0.1 ~ 126.255.255.254 :保留给政府机构
- B类: 128.0.0.1 ~ 191.255.255.254 :分配给大中型企业
- C类: 192.0.0.1 ~ 223.255.255.254 :分配给任何有需要的个人
- D类: 224.0.0.1 ~ 239.255.255.254 :用于组播
- E类: 240.0.0.1 ~ 255.255.255.254 :用于实验
-
端口是设备与外界进行通信的数据的出口。端口号的范围: [0,65535]。
常见的端口占用:
3306: MySQL
1521 :Oracle
8080 :Tomcat
1.2 TCP/IP通信协议
所谓通信协议,是指通信双方在进行数据交换时必须遵守的规则和约定。这些规则确保了双方能够有效地进行通信,实现信息的交换和资源共享。通信协议定义了传输时的数据格式、控制信息以及传输顺序和速度等,确保双方能够“说同样的语言”,从而成功地进行通信。
从产生"用户数据"到真正使用"通信电缆"将数据发送出去这个阶段,需要将用户数据进行各种封装与设计。而如何封装和设计,这里有两种模型可以选择:
- TCP/IP****模型 : 设计4个层次,来封装"用户数据"。 简单、方便、易用, 而被广泛使用。
- OSI模型 : 设计了7个层次,来封装"用户数据"。该模型过于理想化,未能在因特网上进行广泛推广TCP****连接(三次握手)
三次握手的原文是 three-way handshake
,整个名词的可以翻译为:需要三个步骤才能建立握手/连接的机制。当然,三次握手也可以叫 three-message handshake
,通过三条消息来建立的握手/连接。
进行三次握手的主要作用就是为了确认双方的接收能力和发送能力是否正常、指定自己的 初始化序列号(Init Sequense Number, ISN
****) 为后面的可靠性传输做准备。
SYN
:连接请求/接收报文段seq
:发送的第一个字节的序号ACK
:确认报文段ack
:确认号。希望收到的下一个数据的第一个字节的序号
刚开始客户端处于 Closed
的状态,而服务端处于 Listen
状态:
- CLOSED :没有任何连接状态
- LISTEN :侦听来自远方 TCP 端口的连接请求
1)第一次握手:客户端向服务端发送一个连接请求报文。报文中有SYN = 1,和 seq = x(x是随机的初始化序列号ISN,表示本报文段所发送的数据的第一个字节的序号)。TCP规定,SYN报文段(SYN=1的报文段)不能携带数据,但需要消耗掉一个序号。这是三次握手中的开始。表示客户端想要和服务端建立连接。此时客户端处于 SYN_Sent
状态。
SYN-SENT
:同步已发送状态
2)第二次握手:服务器收到客户端的 SYN 报文之后,如何同意连接,则发出确认报文。确认报文中有SYN = 1,ACK = 1,同时把 x + 1 作为确认号 ack 的值,并且指定自己的初始化序列号 ISN(y),即图中的 seq = y。这个报文也不能携带数据,但是同样要消耗一个序号。这个报文带有SYN(建立连接)和ACK(确认)标志,询问客户端是否准备好。此时服务器处于 SYN_REVD
的状态。
SYN-RECEIVED
:同步收到状态
3)第三次握手:客户端收到服务器端的确认报文后,还会再向服务器发送一个 确认报文。确认报文中有ACK = 1 ,同时把服务器的 ISN + 1 作为 ack 的值(ack = y + 1),表示已经收到了服务端发来的的 SYN 报文。并指明此时客户端的序列号 seq = x + 1(表示第二个报文)。
TCP规定,ACK报文段可以携带数据,但是如果不携带数据则不消耗序号。这里客户端表示我已经准备好。
此时客户端处于 Establised
状态。服务器收到 ACK 报文之后,也处于 Establised 状态
,至此,双方建立起了 TCP 连接。
ESTABLISHED
:已建立连接状态
为什么要三次握手?
三次够用了,不需要四次。二次握手就建立连接的话,有可能在网络波动的情况下让服务端产生连接错觉。
举例:已失效的连接请求报文段。
client发送了第一个连接的请求报文,但是由于网络不好,这个请求没有立即到达服务端,而是在某个网络节点中滞留了,直到某个时间才到达server
本来这已经是一个失效的报文,但是server端接收到这个请求报文后,还是会向client发出确认的报文,表示同意连接。
假如不采用三次握手,那么只要server发出确认,新的建立就连接了,但其实这个请求是失效的请求,client是不会理睬server的确认信息,也不会向服务端发送确认的请求
但是server认为新的连接已经建立起来了,并一直等待client发来数据,这样,server的很多资源就没白白浪费掉了
采用三次握手就是为了防止这种情况的发生,server会因为收不到确认的报文,就知道client并没有建立连接。这就是三次握手的作用。
1.3 C/S架构模型
CS架构由客户端和服务器两个部分组成,通常采用C/S模式进行通信。其中,客户端负责向用户提供界面和交互功能,而服务器则负责存储和处理数据。在这种架构中,客户端和服务器之间通过网络进行通信,客户端向服务器发出请求,服务器响应并返回相应的结果。
目前,CS架构已被广泛应用于各种场景中,如Web应用程序、电子邮件、数据库管理等,并且具有可扩展性、可靠性和安全性等优点。
TCP利用Socket(套接字)接口来实现C/S模型的网络程序开发,其早已被广泛的采用。通常,主动发起通信的应用程序属于客户端。而服务器则是等待通信请求,当服务器收到客户端的请求,执行需要的运算然后向客户端返回结果。
如我们使用QQ软件时,我们电脑上安装的就是一个客户端软件,而腾讯必需运行一个服务器。
1.4 SocketAPI
socket被称为套接字,用于描述IP地址和端口号。主机上一般运行了多个服务软件,同时提供多种服务,每种服务都会打开一个Socket,并绑定到一个端口上,不同的端口对应不同的服务。
Socket和ServerSocket类位于java.net包中,ServerSocket用于服务端,Socket是建立网络连接时使用的。在连接成功时,应用程序两端都会产生一个Socket实例,两个Socket构成一个通信管道。操作这个实例,完成所需要的通信。
1.4.1 InetAddress
InetAddress,是一个用来描述IP地址的类;常用的两个子类,分别是Inet4Address 、Inet6Address 。
InetAddress的实例对象由一个IP地址和可能与之对应的主机名(域名)组成
互联网中的主机地址有两种表现形式:
- 域名: www.baidu.com
- IP地址: 110.242.68.4
域名容易记忆,当在连接网络时输入一个主机的域名后,域名服务器(DNS)负责将域名转化成IP地址,这样才能和主机建立连接。
-
常用API
static InetAddress getLocalHost() 返回本地主机的地址 static InetAddress getByName(String host) 返回指定域名的IP地址,也可以传入IP字符串或者主机名称 static InetAddress[] getAllByName(String host) 返回一个域名对应的所有的主机 String getHostAddress() 返回文本显示中的IP地址字符串 String getHostName() 获取此IP地址的主机名 -
try {// 1、获取本机InetAddress address1 = InetAddress.getLocalHost();// 2、通过主机名字,获取一个主机InetAddress address2 = InetAddress.getByName("懿范 ");// 3、通过IP地址字节数组,获取一个主机(需要注意字节数组中的每一个元素是否溢出)InetAddress address3 = InetAddress.getByAddress(new byte[] {10, 0, (byte)153, 27});// 4、通过IP地址字符串获取一个主机InetAddress address4 = InetAddress.getByName("10.0.153.27");// 5、通过域名获取主机InetAddress address5 = InetAddress.getByName("www.taobao.com");// 6、获取一个域名对应的所有的主机InetAddress[] addresses = InetAddress.getAllByName("www.taobao.com");for (InetAddress address : addresses) {System.out.println(address);}// 通过 InetAddress 对象 // 1、获取主机名字System.out.println(address1.getHostName());// 2、获取主机地址System.out.println(address1.getHostAddress());// 3、获取一个IP地址字节数组byte[] addr = address1.getAddress();for (byte b : addr) {System.out.println(b);}} catch (UnknownHostException e) {e.printStackTrace(); }
1.4.2 ServerSocket
是一个用来描述TCP中的服务端的类
-
常用构造器
ServerSocket(int port) 监听该机器上的一个端口号,作为通信服务端口
-
常用方法
void setSoTimeout(int timeout) throws SocketException 设置accept()方法等待连接的时间为timeout毫秒。该方法必须在accept()之前调用才有效。超出时间,还没有客户端连接,则抛出InterruptedIOException异常,accept()方法不再阻塞,执行后续代码设置为0,则表示accept()永远等待。 Socket accept() throws IOException 等待客户端的连接请求,返回与该客户端进行通信用的Socket对象 void close()throws IOException 关闭监听Socket InetAddress getInetAddress() 返回此服务器套接字的本地地址 int getLocalPort() 返回此套接字在其上监听的端口号 SocketAddress getLocalSocketAddress() 返回此套接字绑定的端点的地址
1.4.3 Socket
是一个用来描述TCP中的客户端的类
-
常用构造器:
Socket(InetAddress address, int port) 向指定服务器发送请求,并返回一个客户端对象 Socket(String host, int port) 向指定服务器发送请求,并返回一个客户端对象
-
常用方法
InetAddress getLocalAddress() 返回对方Socket中的IP的InetAddress对象 int getLocalPort() 返回本地Socket中的端口号 InetAddress getInetAddress() 返回对方Socket中IP地址 int getPort() 返回对方Socket中的端口号 void close() throws IOException 关闭Socket,释放资源 InputStream getInputStream() throws IOException 获取与Socket相关联的字节输入流,用于从Socket中读数据。 OutputStream getOutputStream() throws IOException 获取与Socket相关联的字节输出流,用于向Socket中写数据。
1.5 Socket编程步骤
套接字编程分为服务器编程和客户端编程,其通信模型如图所示:
- 服务端编程步骤如下:
- 调用 ServerSocket(int port) 创建一个服务器端套接字,并绑定到指定端口上。
- 调用 accept(),监听连接请求,如果客户端请求连接,则接受连接,返回通信套接字。
- 调用 Socket类的 getOutputStream 和 getInputStream 获取输出流和输入流,开始网络数据的发送和接收。
- 最后关闭通信套接字。
- 客户段编程步骤如下:
- 创建 Socket。根据指定的 IP和port构造 Socket 类对象,并发送连接请求。如服务器端响应,则建立客户端到服务器的通信线路。
- 通过Socket获取与服务器的通信流。 使用 getInputStream()方法获得输入流,使用 getOutputStream()方法获得输出流。
- 按照一定的协议对 Socket进行读/写操作。通过输入流读取服务端响应的信息,通过输出流将信息发送给服务端。
- 关闭 Socket。断开客户端到服务器的连接,释放线路
- 流连接
- 客户端和服务器端的套接字对象诞生以后,必须进行输入、输出流的连接。
- 套接字调用 close()可以关闭双方的套接字连接,只要一方关闭连接,就会导致对方发生IOException异常。
总结,简单来说,套接字的基本操作就七个基本操作:
- 连接到远程主机
- 绑定到端口
- 接收从远程机器来的连接请求
- 监听到达的数据
- 发送数据
- 接收数据
- 关闭连接。
二 实训项目
2.1 项目介绍
- 项目名称:聊天室
- 功能简介:
- 公聊功能:聊天内容为公开的,所有用户都能看到聊天的内容信息
- 私聊功能:与具体某一个用户进行对话,其他用户看不到聊天内容
- 详细步骤:
- 客户端向服务端发送消息,服务端打印消息
- 客户端循环向服务端发送信息,服务端负责打印
- 客户端收到服务端转发的消息
- 客户端多开,客户端自己和自己聊天
- 一个客户端与多个客户端对话,同时处理某一个客户端下线的问题
- 私聊
- 功能拓展:
- 聊天界面开发
2.2 具体代码
2.2.1 功能需求1
我们先完成这样的需求:客户端向服务端发送消息,服务端打印消息。
- 服务端的开发步骤
- 调用ServerSocket构造器,监听一个端口号
- 调用阻塞方法,等待客户端连接,并返回与客户端进行交互的Socket对象
- 获取输入流对象
- 读取流对象里的客户端发送过来的信息
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;/*** 聊天服务器*/
public class ChatServer {//定义一个TCP通信协议的服务端属性private ServerSocket server;public ChatServer(int port) {try {server = new ServerSocket(port);} catch (IOException e) {System.out.println("---服务器启动失败---");}}/*** 聊天的主方法*/public void start() {try {System.out.println("---等待客户端连接---");Socket socket = server.accept();System.out.println("---一个客户端连接上了---");//通过服务端的Socket对象,获取输入流,接受客户端发送过来的数据InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));String info = br.readLine();System.out.println("客户端发送过来的信息:"+info);} catch (IOException e) {System.out.println("---一个客户端离线了---");}}public static void main(String[] args) {//创建一个具体的服务器对象ChatServer chatServer = new ChatServer(8888);//调用聊天的主方法chatServer.start();}
}
- 客户端的开发
- 调用Socket构造器,向指定服务器(ip+port)发送请求
- 获取输出流对象
- 调用流对象的输出方法,将信息发送给服务端
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;/*** 聊天客户端*/
public class ChatClient {//定义一个TCP通信协议的客户端属性private Socket socket;public ChatClient() {//获取Socket对象,同时向服务端发送连接请求, 连接失败:Connection resettry {socket = new Socket("localhost",8888);} catch (IOException e) {System.out.println("---服务器崩溃中---");}}// 聊天的主方法public void start(){PrintWriter out = null;try {//获取向服务器发送信息的输出流对象OutputStream outputStream = socket.getOutputStream();//封装成字符缓存流,可以按行输出out = new PrintWriter(new OutputStreamWriter(outputStream,"UTF-8"),true);out.println("我是客户端");} catch (IOException e) {e.printStackTrace();} finally {out.close();}}public static void main(String[] args) {//创建一个具体的聊天客户端对象ChatClient client = new ChatClient();//调用客户端的主方法client.start();}
}
至此,启动服务端,再启动客户端。服务端就能收到客户端发送过来的信息了。
2.2.2 功能需求2
第二个需求是:客户端循环向服务端发送信息,服务端负责打印。
- 修改服务端
- 使用while循环结构,来打印客户端源源不断发送过来的信息
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;/*** 聊天服务器*/
public class ChatServer {//定义一个TCP通信协议的服务端属性private ServerSocket server;public ChatServer(int port) {try {server = new ServerSocket(port);} catch (IOException e) {System.out.println("---服务器启动失败---");}}/*** 聊天的主方法*/public void start() {try {System.out.println("---等待客户端连接---");Socket socket = server.accept();System.out.println("---一个客户端连接上了---");//通过服务端的Socket对象,获取输入流,接受客户端发送过来的数据InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));//循环打印客户端发送过来的信息String info = "";while ((info = br.readLine()) != null) {System.out.println("客户端发送过来的信息:"+info);}} catch (IOException e) {System.out.println("---一个客户端离线了---");}}public static void main(String[] args) {//创建一个具体的服务器对象ChatServer chatServer = new ChatServer(8888);//调用聊天的主方法chatServer.start();}
}
- 修改客户端
- 使用Scanner类型来开启控制台输入功能
- 使用while循环结构,进行多次输入信息
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;/*** 聊天客户端*/
public class ChatClient {//定义一个TCP通信协议的客户端属性private Socket socket;public ChatClient() {//获取Socket对象,同时向服务端发送连接请求, 连接失败:Connection resettry {socket = new Socket("localhost",8888);} catch (IOException e) {System.out.println("---服务器崩溃中---");}}// 聊天的主方法public void start(){PrintWriter out = null;try {//获取向服务器发送信息的输出流对象OutputStream outputStream = socket.getOutputStream();//封装成字符缓存流,可以按行输出out = new PrintWriter(new OutputStreamWriter(outputStream,"UTF-8"),true);//使用控制台扫描对象类型,不断的扫描控制台上的文字Scanner scan = new Scanner(System.in);System.out.println("====开始聊天====");while(true){String info = scan.nextLine();//将扫描的信息发送到服务端out.println(info);}} catch (IOException e) {e.printStackTrace();} finally {out.close();}}public static void main(String[] args) {//创建一个具体的聊天客户端对象ChatClient client = new ChatClient();//调用客户端的主方法client.start();}
}
2.2.3 功能需求3
服务器将客户端发送过来的信息,再发送回给客户端。
- 服务端的修改
- 在循环结构前,获取该套接字socket的输出流
- 在循环结构里,调用输出流的输出方法,将信息写出去
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;/*** 聊天服务器*/
public class ChatServer {//定义一个TCP通信协议的服务端属性private ServerSocket server;public ChatServer(int port) {try {server = new ServerSocket(port);} catch (IOException e) {System.out.println("---服务器启动失败---");}}/*** 聊天的主方法*/public void start() {try {System.out.println("---等待客户端连接---");Socket socket = server.accept();System.out.println("---一个客户端连接上了---");//通过服务端的Socket对象,获取输入流,接受客户端发送过来的数据InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));//通过服务端的Socket对象,获取输出流,将信息返回给客户端OutputStream outputStream = socket.getOutputStream();PrintWriter pw = new PrintWriter(new OutputStreamWriter(outputStream,"utf-8"),true);//循环打印客户端发送过来的信息String info = "";while ((info = br.readLine()) != null) {//使用输出流,将其返回即可pw.println(info);}} catch (IOException e) {System.out.println("---一个客户端离线了---");}}public static void main(String[] args) {//创建一个具体的服务器对象ChatServer chatServer = new ChatServer(8888);//调用聊天的主方法chatServer.start();}
}
- 客户端的修改
- 需要异步处理返回的信息。编写一个内部类,实现Runnable接口
- 重新run方法,获取输入流,调用输入方法,循环打印信息
- 在客户端发送信息之前,开启线程,处理返回信息。
import java.io.*;
import java.net.Socket;
import java.util.Scanner;/*** 聊天客户端*/
public class ChatClient {//定义一个TCP通信协议的客户端属性private Socket socket;public ChatClient() {//获取Socket对象,同时向服务端发送连接请求, 连接失败:Connection resettry {socket = new Socket("localhost",8888);} catch (IOException e) {System.out.println("---服务器崩溃中---");}}// 聊天的主方法public void start(){try {//获取一个获取服务端信息的处理器任务Runnable task = new GetServerInfoHandler();Thread thread = new Thread(task);thread.start();//获取向服务器发送信息的输出流对象OutputStream outputStream = socket.getOutputStream();//封装成字符缓存流,可以按行输出PrintWriter out = new PrintWriter(new OutputStreamWriter(outputStream,"UTF-8"),true);//使用控制台扫描对象类型,不断的扫描控制台上的文字Scanner scan = new Scanner(System.in);System.out.println("====开始聊天====");while(true){String info = scan.nextLine();//将扫描的信息发送到服务端out.println(info);}} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) {//创建一个具体的聊天客户端对象ChatClient client = new ChatClient();//调用客户端的主方法client.start();}/*** 编写一个获取服务端信息的处理器。即一个任务体*/class GetServerInfoHandler implements Runnable{public void run(){try {//获取接受服务端发送过来的数据的输入流对象InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));String line = "";while((line = br.readLine()) != null){System.out.println("服务端返回的数据:"+line);}} catch (IOException e) {e.printStackTrace();}}}
}
2.2.4 功能需求4
客户端多开。客户端自己与自己聊天。上一个功能其实就是客户端自己与自己对话,因此客户端的代码不需要再做修改了,只需要修改服务端的代码即可
- 将阻塞方法以及处理客户端的信息的代码部分,嵌入到while循环结构中。
- 因为处理客户端的信息也是一个不能停止的while循环,造成后续的客户端不能连接该服务端,所以需要另一个线程来处理客户端的信息。
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;/*** 聊天服务器*/
public class ChatServer {//定义一个TCP通信协议的服务端属性private ServerSocket server;public ChatServer(int port) {try {server = new ServerSocket(port);} catch (IOException e) {System.out.println("---服务器启动失败---");}}/*** 聊天的主方法*/public void start() {try {while (true) {System.out.println("---等待客户端连接---");Socket socket = server.accept();System.out.println("---一个客户端连接上了---");//每获取一个客户端的Socket对象,就应该将其放入一个并发线程中Runnable task = new GetClientInfoHandler(socket);Thread thread = new Thread(task);thread.start();}} catch (IOException e) {System.out.println("---一个客户端离线了---");}}public static void main(String[] args) {//创建一个具体的服务器对象ChatServer chatServer = new ChatServer(8888);//调用聊天的主方法chatServer.start();}/*** 定义一个处理客户端信息的处理器,即一个任务体*/class GetClientInfoHandler implements Runnable {//因为run方法中使用了 start方法中的socket局部变量//所以可以在该类中添加一个属性,run方法中可以访问该类的属性private Socket socket;//提供一个构造器,将start方法中的局部变量socket传进来,给属性赋值, 这样,run方法中使用的socket就是//start方法中的局部变量指向的对象public GetClientInfoHandler(Socket socket) {this.socket = socket;}@Overridepublic void run() {try {//通过服务端的Socket对象,获取输入流,接受客户端发送过来的数据InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));//通过服务端的Socket对象,获取输出流,将信息返回给客户端OutputStream outputStream = socket.getOutputStream();PrintWriter pw = new PrintWriter(new OutputStreamWriter(outputStream,"utf-8"),true);//循环打印客户端发送过来的信息String info = "";while ((info = br.readLine()) != null) {//使用输出流,将其返回即可pw.println(info);}} catch (IOException e) {e.printStackTrace();}}}
}
2.2.5 功能需求5
一个客户端与多个客户端进行通信。
- 服务端的修改
-
服务端如果想要将某一个客户端发送过来的信息,转发给所有客户端,那么在转发信息时,就应该使用每个socket的输出流对象进行输出,因此可以将每个socket的输出流保存到一个集合对象里。我们也可以直接为私聊功能做准备,每个输出流对象,都绑定一个唯一标识符,我们可以使用Map集合对象来存储标识符和流对象。
-
提供Map属性,存储客户端的唯一标识符和对应的流对象:
private Map<String,PrintWriter> allOut;
别忘记初始化
-
将客户端发送过来的第一句话,作为唯一标识符。(正常逻辑下,应该将登录用户名等具有唯一性的数据,作为唯一标识符)。如果重名了,加随机后缀,告诉客户端。
-
将唯一标识符和输出流对象,保存到Map集合中
-
将转发功能封装成一个方法
sendToAllClient(ID,info)
,来表示ID说了info -
如果一个客户端离线了,要删除Map里的键值对
-
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;/*** 聊天服务器*/
public class ChatServer {//定义一个TCP通信协议的服务端属性private ServerSocket server;//添加一个Map属性,用于存储多个客户端的标识与输出流对象。 Map集合(散列表)private Map<String,PrintWriter> allOut;public ChatServer(int port) {try {server = new ServerSocket(port);//给map集合赋值allOut = new HashMap<String,PrintWriter>();} catch (IOException e) {System.out.println("---服务器启动失败---");}}/*** 聊天的主方法*/public void start() {try {while (true) {System.out.println("---等待客户端连接---");Socket socket = server.accept();System.out.println("---一个客户端连接上了---");//每获取一个客户端的Socket对象,就应该将其放入一个并发线程中Runnable task = new GetClientInfoHandler(socket);Thread thread = new Thread(task);thread.start();}} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) {//创建一个具体的服务器对象ChatServer chatServer = new ChatServer(8888);//调用聊天的主方法chatServer.start();}/*** 定义一个处理客户端信息的处理器,即一个任务体*/class GetClientInfoHandler implements Runnable {//因为run方法中使用了 start方法中的socket局部变量//所以可以在该类中添加一个属性,run方法中可以访问该类的属性private Socket socket;//提供一个构造器,将start方法中的局部变量socket传进来,给属性赋值, 这样,run方法中使用的socket就是//start方法中的局部变量指向的对象public GetClientInfoHandler(Socket socket) {this.socket = socket;}@Overridepublic void run() {String id= null;try {//通过服务端的Socket对象,获取输入流,接受客户端发送过来的数据InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));//通过服务端的Socket对象,获取输出流,将信息返回给客户端OutputStream outputStream = socket.getOutputStream();PrintWriter pw = new PrintWriter(new OutputStreamWriter(outputStream,"utf-8"),true);//先获取客户端的昵称id= br.readLine();//判断是否已经被占用,如果已经被占用,加后缀while(allOut.containsKey(id)){id= id+Math.random();}//将昵称告诉客户端pw.println(id);//将输出流添加到Map集合中allOut.put(id,pw);System.out.println("-----在线人数:"+allOut.size()+"--------");//循环打印客户端发送过来的信息String info = "";while ((info = br.readLine()) != null) {//将信息info发送到所有的客户端里sendToAllClient(id,info);}} catch (IOException e) {System.out.println("---一个客户端离线了---");//删除对应的键值对allOut.remove(id);System.out.println("-----在线人数:"+allOut.size()+"--------");}}}public void sendToAllClient(String fromId, String info) {//遍历Map里的所有输出流,来发送info信息Set<String> ids= allOut.keySet();for (String id: ids) {//获取对应的输出流,然后发送信息PrintWriter writer = allOut.get(id);writer.println(fromId+"说:"+info);}}
}
- 客户端的修改
- 提供一个静态的CLIENT_ID属性:
private static final String CLIENT_ID =UUID.
randomUUID
().toString();
- 发送的第一句话为ID。
- 设置收到的第一句话为服务端返回的ID(因为ID被占用了,服务端会随机一个新的)
- 提供一个静态的CLIENT_ID属性:
import java.io.*;
import java.net.Socket;
import java.util.Scanner;/*** 聊天客户端*/
public class ChatClient {//提供一个唯一标识符 UUID是一个随机ID的工具类,可以产生一个随机字符串。 重复出现的概率非常非常小。private static final String CLIENT_ID =UUID.randomUUID().toString();//定义一个TCP通信协议的客户端属性private Socket socket;public ChatClient() {//获取Socket对象,同时向服务端发送连接请求, 连接失败:Connection resettry {socket = new Socket("localhost",8888);} catch (IOException e) {System.out.println("---服务器崩溃中---");}}// 聊天的主方法public void start(){try {//获取一个获取服务端信息的处理器任务Runnable task = new GetServerInfoHandler();Thread thread = new Thread(task);thread.start();//获取向服务器发送信息的输出流对象OutputStream outputStream = socket.getOutputStream();//封装成字符缓存流,可以按行输出PrintWriter out = new PrintWriter(new OutputStreamWriter(outputStream,"UTF-8"),true);//将ID发送给服务器,让服务器校验昵称是否已经被占用out.println(CLIENT_ID); //使用控制台扫描对象类型,不断的扫描控制台上的文字Scanner scan = new Scanner(System.in); System.out.println("====开始聊天====");while(true){String info = scan.nextLine();//将扫描的信息发送到服务端out.println(info);}} catch (IOException e) {e.printStackTrace();}}public static void main(String[] args) {//创建一个具体的聊天客户端对象ChatClient client = new ChatClient();//调用客户端的主方法client.start();}/*** 编写一个获取服务端信息的处理器。即一个任务体*/class GetServerInfoHandler implements Runnable{public void run(){try {//获取接受服务端发送过来的数据的输入流对象InputStream inputStream = socket.getInputStream();BufferedReader br = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));//获取自己的ID:String id= br.readLine();System.out.println("您的id:"+id);String line = "";while((line = br.readLine()) != null){System.out.println(line);}} catch (IOException e) {e.printStackTrace();}}}
}
2.2.6 功能扩展:私聊
私聊模式的设计
- 如果想要与某一个客户端进行单独聊天,输入的格式为:
@对方ID:xxxxxxxxx
- 服务端解析要转发的信息中是否包含@和ID,如果是则调用私聊方法
sendToOneClient(id,info)
,否则调用sendToAllClient(id,info)
方法
三 UDP通信协议
3.1 UDP通信协议简介
在计算机网络领域,传输控制协议(TCP)和用户数据报协议(UDP)是两种至关重要的传输层协议,它们在网络数据传输中扮演着核心角色。尽管两者都服务于数据在网络中的传输,但它们在多个方面存在显著差异。本文将从连接性、可靠性、速度与效率、数据包大小以及适用场景等维度,深入解析UDP与TCP的区别。
- 连接性
- TCP:面向连接的协议
- TCP是一种面向连接的协议,这意味着在数据传输之前,发送方和接收方必须首先建立一条逻辑连接。这一连接过程通常通过三次握手实现,即发送方发送SYN包请求连接,接收方回复SYN+ACK包确认请求,最后发送方发送ACK包完成连接建立。这种机制确保了数据传输的可靠性和有序性。
- UDP:无连接的协议
- 与TCP不同,UDP是一种无连接的协议。发送方在发送数据前无需与接收方建立连接,而是直接将数据包封装成UDP报文并发送。这种无连接特性使得UDP在数据传输时更加灵活和高效,但同时也牺牲了部分可靠性。
- TCP:面向连接的协议
- 可靠性
-
TCP:高可靠性
-
TCP对数据的可靠性要求非常严格。它采用确认和重传机制来确保数据的完整性和正确性。如果接收方未收到某个数据包,发送方会不断重传该数据包,直到接收方确认收到为止。此外,TCP还通过滑动窗口机制进行流量控制和拥塞控制,以优化网络资源的利用和数据传输的效率。
-
UDP:低可靠性
-
UDP对数据的可靠性要求较低,它不提供确认、重传和流量控制机制。因此,当网络拥塞或数据包丢失时,UDP不会进行重传,这可能导致接收方收到的数据不完整或丢失。然而,在某些对实时性要求较高但对少量丢包不敏感的应用场景中,UDP的这种特性反而成为其优势。
-
- 速度与效率
-
TCP:速度相对较慢
-
由于TCP需要建立连接、使用确认重传机制和拥塞控制算法,因此在数据传输速度上相对较慢。特别是在网络拥堵时,TCP会主动降低发送速率以避免进一步加剧拥塞,这进一步限制了其传输速度。
-
UDP:速度较快
-
UDP没有连接建立和确认重传的开销,且不受拥塞控制的限制,因此在数据传输速度上通常比TCP更快。这使得UDP成为对实时性要求较高的应用场景中的理想选择。
-
- 数据包大小
-
TCP:较小的数据包
-
TCP将数据划分为较小的数据包进行传输,并根据网络状况动态调整数据包的大小。这种机制有助于在网络拥堵时减少丢包率并提高传输效率。
-
UDP:较大的数据报
-
UDP允许发送方一次性将多个数据包打包成一个较大的UDP数据报进行传输。数据报的大小通常由应用层决定,这使得UDP在传输大量数据时能够减少传输次数并提高传输效率。
-
- 适用场景
- TCP适用场景
- 文件传输:如FTP协议,确保文件的完整性和正确性。
- 电子邮件:如SMTP协议,保证邮件的可靠传输和顺序接收。
- 网页浏览:HTTP协议使用TCP来传输网页内容,确保数据的可靠性和顺序性。
- 远程登录:如Telnet和SSH等协议,提供安全的登录通道。
- UDP适用场景
- 实时通信:如音频、视频会议以及实时游戏等应用,利用UDP的快速传输特性实现实时交互。
- 流媒体传输:如音频和视频的实时播放,对丢失少量数据不敏感但对传输速度要求高。
- DNS解析:域名系统(DNS)使用UDP进行域名解析请求和响应,以快速获取域名对应的IP地址。
- 广播和多播:UDP支持广播和多播传输,适用于向多个主机发送数据的应用场景。
- TCP适用场景
3.2 UDPAPI以及案例
3.2.1 API简介
JAVA中以两个类来支持UDP通信:java.net``.DatagramPacket
和j``ava.net``.DatagramSocket
。这两个类代表了UDP通信中的数据包和套接字。而使用UDP通信也非常简单,简单归纳成三步,就是创建数据包、创建套接字、用套接字发送/接受数据包。
1)发送数据的三步骤
- 步骤1:创建数据包
-
发送数据包的时候,一般需要知道远程主机的地址和端口,所以使用DatagramPacket的四参构造函数较为方便:
public DatagramPacket(byte[] buf, int length, InetAddress address, int port)
。前两个参数用来指定发送的字节数组及长度,后两个参数用来指定IP地址及端口号。一个简单的代码段如下: -
byte[] buff = "What time is it?".getBytes(); DatagramPacket dp = new DatagramPacket(buff, buff.length, addr, port);
-
- 步骤2:创建套接字绑定到本地端口
由于我们并不关心使用哪个端口发送,所以可以使用DatagramSocket的无参构造函数,这样它会任选一个空闲的端口绑定:
DatagramSocket ds = new DatagramSocket();
- 步骤3:发送数据包
UDP是无连接的协议,所以直接使用DatagramSocket 的send方法发送数据包:
ds.send(dp);
2)接受数据的三步骤
- 步骤1:创建数据包 创建数据包只是为了接收数据,并不需要指定IP地址和端口,所以可以使用2参的构造函数:
DatagramPacket(byte[] buf, int length)
参数分别代表接收字符缓冲区及其大小。一个简单的代码段如下:-
byte[] buffres = new byte[1024]; DatagramPacket dprec = new DatagramPacket(buffres, buffres.length);
-
- 步骤2:创建套接字绑定到本地端口 由于 发送端需要知道接收端的端口号,所以在创建套接字的时候需要绑定到一个固定的端口上:
-
DatagramSocket ds = new DatagramSocket(8888);
-
- 步骤3:接收数据包 接收UDP数据包的时候使用DatagramSocket的receive方法:
-
ds.receive(dp);
-
注意,receive()
是一个阻塞的方法,可以使用setSoTimeout()
方法设置超时时间,也可以创建一个新的线程。 代码段如下:
ds.setSoTimeout(2000);
ds.receive(dprec);
3.2.2 案例演示
发送方:
public class UDPSender { public static void main(String[] args) {int i = 0;int timeout = 5000;/*定义报文长度*/int length = 1024;/*无限循环,不断发送报文给客户端,并接收客户端返回的报文,当客户端没有启动时,报超时错误*/while(true){try {/*udp的发送端的ip和端口不是必须指定的*/int sendPost = 9000;// 创建发送方的套接字,IP默认为本地,端口号随机 DatagramSocket sendSocket = new DatagramSocket(sendPost); //指定timeout时间,防止进入无限等待状态 sendSocket.setSoTimeout(timeout); //要发送的消息: String mes = "接收端,收到请回答!"; // 讲消息内容转换成字节数组byte[] buffer = mes.getBytes(); // 确定发送方的IP地址及端口号,地址为本地机器地址int receivePort = 8888; InetAddress ip = InetAddress.getLocalHost(); // 构造要发送的报文DatagramPacket sendPacket = new DatagramPacket(buffer, buffer.length, ip, receivePort); // 通过套接字发送数据报文: sendSocket.send(sendPacket); // 确定接受反馈数据的缓冲存储器,即存储数据的字节数组 byte[] getBuf = new byte[length]; // 创建接受类型的数据报 DatagramPacket getPacket = new DatagramPacket(getBuf, getBuf.length); // 通过套接字接受数据 sendSocket.receive(getPacket); // 解析反馈的消息,并打印 String backMes = new String(getBuf, 0, getPacket.getLength()); System.out.println("收到接收端的消息:" + backMes); // 关闭套接字 sendSocket.close(); } catch(SocketTimeoutException e){System.out.println("接收端接收超时");} catch (Exception e) { e.printStackTrace(); } finally{try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("_________________i="+ i++); }} }
}
接收方
public class UDPReceive {public static void main(String[] args) { int j = 0;/*定义报文长度*/int length = 1024;while(true){try { // 确定接受方的IP和端口号,IP地址为本地机器地址 InetAddress ip = InetAddress.getLocalHost(); int port = 8888; // 创建接收方的套接字,并制定端口号和IP地址 DatagramSocket getSocket = new DatagramSocket(port, ip); // 确定数据报接受的数据的数组大小 byte[] buf = new byte[length]; // 创建接受类型的数据报,数据将存储在buf中 DatagramPacket getPacket = new DatagramPacket(buf, buf.length); // 通过套接字接收数据 getSocket.receive(getPacket); // 解析发送方传递的消息,并打印 String getMes = new String(buf, 0, getPacket.getLength()); System.out.println("发送端的消息:" + getMes); // 通过数据报得到发送方的IP和端口号,并打印 InetAddress sendIP = getPacket.getAddress(); int sendPort = getPacket.getPort(); System.out.println("发送端的IP地址是:" + sendIP.getHostAddress()); System.out.println("发送端的端口号是:" + sendPort); // 通过数据报得到发送方的套接字地址 SocketAddress sendAddress = getPacket.getSocketAddress(); // 确定要反馈发送方的消息内容,并转换为字节数组 String feedback = "接收端说:我收到了!"; byte[] backBuf = feedback.getBytes(); // 创建发送类型的数据报 DatagramPacket sendPacket = new DatagramPacket(backBuf, backBuf.length, sendAddress); // 通过套接字发送数据 getSocket.send(sendPacket); // 关闭套接字 getSocket.close(); } catch(SocketTimeoutException e){System.out.println("发送端接收超时");} catch (Exception e) { e.printStackTrace(); } finally{try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("_________________j="+ j++); } }}
} // 确定要反馈发送方的消息内容,并转换为字节数组 String feedback = "接收端说:我收到了!"; byte[] backBuf = feedback.getBytes(); // 创建发送类型的数据报 DatagramPacket sendPacket = new DatagramPacket(backBuf, backBuf.length, sendAddress); // 通过套接字发送数据 getSocket.send(sendPacket); // 关闭套接字 getSocket.close(); } catch(SocketTimeoutException e){System.out.println("发送端接收超时");} catch (Exception e) { e.printStackTrace(); } finally{try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("_________________j="+ j++); } }}
}