Java Review(三十八、网络编程)
@
网络基础
计算机网络是指两台或更多的计算机组成的网络,在同一个网络中,任意两台计算机都可以直接通信,因为所有计算机都需要遵循同一种网络协议。
那什么是互联网呢?互联网是网络的网络(internet),即把很多计算机网络连接起来,形成一个全球统一的互联网。
对某个特定的计算机网络来说,它可能使用网络协议ABC,而另一个计算机网络可能使用网络协议XYZ。如果计算机网络各自的通讯协议不统一,就没法把不同的网络连接起来形成互联网。因此,为了把计算机网络接入互联网,就必须使用TCP/IP协议。
TCP/IP协议泛指互联网协议,其中最重要的两个协议是TCP协议和IP协议。只有使用TCP/IP协议的计算机才能够联入互联网,使用其他网络协议(例如NetBIOS、AppleTalk协议等)是无法联入互联网的。
IP地址
在互联网中,一个IP地址用于唯一标识一个网络接口(Network Interface)。一台联入互联网的计算机肯定有一个IP地址,但也可能有多个IP地址。
IP地址分为IPv4和IPv6两种。IPv4采用32位地址,类似:101.202.99.12,
而IPv6采用128位地址,类似:2001:0DA8:100A:0000:0000:1020:F2F3:1428。
IPv4地址总共有2的32次方个(大约42亿),而IPv6地址则总共有2128个(大约340万亿亿亿亿),IPv4的地址目前已耗尽,而IPv6的地址是根本用不完的。
IP地址又分为公网IP地址和内网IP地址。公网IP地址可以直接被访问,内网IP地址只能在内网访问。内网IP地址类似于:
- 192.168.x.x
- 10.x.x.x
有一个特殊的IP地址,称之为本机地址,它总是127.0.0.1。
IPv4地址实际上是一个32位整数。例如:
106717964 = 0x65ca630c
= 65 ca 63 0c
= 101.202.99.12
如果一台计算机只有一个网卡,并且接入了网络,那么,它有一个本机地址127.0.0.1,还有一个IP地址,例如101.202.99.12,可以通过这个IP地址接入网络。
如果一台计算机有两块网卡,那么除了本机地址,它可以有两个IP地址,可以分别接入两个网络。通常连接两个网络的设备是路由器或者交换机,它至少有两个IP地址,分别接入不同的网络,让网络之间连接起来。
如果两台计算机位于同一个网络,那么他们之间可以直接通信,因为他们的IP地址前段是相同的,也就是网络号是相同的。网络号是IP地址通过子网掩码过滤后得到的。例如:
某台计算机的IP是101.202.99.2,子网掩码是255.255.255.0,那么计算该计算机的网络号是:
IP = 101.202.99.2
Mask = 255.255.255.0
Network = IP & Mask = 101.202.99.0
每台计算机都需要正确配置IP地址和子网掩码,根据这两个就可以计算网络号,如果两台计算机计算出的网络号相同,说明两台计算机在同一个网络,可以直接通信。如果两台计算机计算出的网络号不同,那么两台计算机不在同一个网络,不能直接通信,它们之间必须通过路由器或者交换机这样的网络设备间接通信,我们把这种设备称为网关。
网关的作用就是连接多个网络,负责把来自一个网络的数据包发到另一个网络,这个过程叫路由。
所以,一台计算机的一个网卡会有3个关键配置:
- IP地址,例如:10.0.2.15
- 子网掩码,例如:255.255.255.0
- 网关的IP地址,例如:10.0.2.2
端口
端口是一个 16 位的整数, 用于表示数据交给哪个通信程序处理。 因此, 端口就是应用程序与外界交流的出入口, 它是一种抽象的软件结构, 包括一些数据结构和 I/O (基本输入/输出) 缓冲区。
不同的应用程序处理不同端口上的数据, 同一台机器上不能有两个程序使用同一个端口, 端口号可以从 0 到 65535, 通常将它分为如下三类。
- 公认端口Well Known Ports): 从 0 到 1023, 它们紧密绑定 Binding) —些特定的服务。
- 注 册 端 口 ( Registered Ports): 从 1024 到 49151, 它们松散地绑定一些服务。 应用程序通常应该使用这个范围内的端口。
- 动态和/或私有端口 Dynamic and/or Private Ports): 从 49152 到 65535, 这些端口是应用程序使用的动态端口, 应用程序一般不会主动使用这些端口。
如果把 IP 地址理解为某个人所在地方的地址( 包括街道和门牌号), 但仅有地址还是找不到这个人,还需要知道他所在的房号才可以找到这个人。
域名
因为直接记忆IP地址比较困难,所以通常使用域名访问某个特定的服务。域名解析服务器DNS负责把域名翻译成对应的IP,客户端再根据IP地址访问服务器。
用nslookup可以查看域名对应的IP地址:
网络模型
国际标准化组织 ISO 于 1978 年提出“ 开放系统互连参考模型”, 即著名的 OSI ( Open System Interconnection )。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20191228193716587.png#pic_center)
按 TCP/IP 协议模型, 网络通常被分为一个四层模型, 这个四层模型和前面的 OSI 七层模型有大致的对应关系。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20191228193908114.png?#pic_center)
常用协议
IP协议是一个分组交换,它不保证可靠传输。而TCP协议是传输控制协议,它是面向连接的协议,支持可靠传输和双向通信。TCP协议是建立在IP协议之上的,简单地说,IP协议只负责发数据包,不保证顺序和正确性,而TCP协议负责控制数据包传输,它在传输数据之前需要先建立连接,建立连接后才能传输数据,传输完后还需要断开连接。TCP协议之所以能保证数据的可靠传输,是通过接收确认、超时重传这些机制实现的。并且,TCP协议允许双向通信,即通信双方可以同时发送和接收数据。
TCP协议也是应用最广泛的协议,许多高级协议都是建立在TCP协议之上的,例如HTTP、SMTP等。
UDP协议(User Datagram Protocol)是一种数据报文协议,它是无连接协议,不保证可靠传输。因为UDP协议在通信前不需要建立连接,因此它的传输效率比TCP高,而且UDP协议比TCP协议要简单得多。
选择UDP协议时,传输的数据通常是能容忍丢失的,例如,一些语音视频通信的应用会选择UDP协议。
Java 的基本网络支持
Java 为网络支持提供了 java.net 包, 该包下的 URL 和 URLConnection 等类提供了以编程方式访问Web 服务的功能, 而 URLDecoder 和 URLEncoder 则提供了普通字符串和 application/x-www-formurlencoded MIME 字符串相互转换的静态方法。
使用 InetAddress
Java 提供了 InetAddress 类来代表 IP 地址, InetAddress 下还有两个子类: Inet4Address、Inet6Address它们分别代表 Internet Protocol version 4 ( IPv4) 地 址 和 Internet Protocol version 6 ( IPv6) 地 址。InetAddress 类没有提供构造器, 而是提供了如下两个静态方法来获取 InetAddress 实例:
- getByName(String host) 根据主机获取对应的 InetAddress 对象。
- getByAddress(byte[]addr) 根据原始 IP 地址来获取对应的 InetAddress 对象。
InetAddress 还提供了如下三个方法来获取 InetAddress 实例对应的 IP 地址和主机名。
- String getCanonicalHostName() 获取此 IP 地址的全限定域名。
- String getHostAddress() 返回该 InetAddress 实例对应的 IP 地址字符串(以字符串形式)。
- String getHostName() 获取此 IP 地址的主机名。除此之外, InetAddress 类还提供了一个 getLocalHost()方法来获取本机 IP 地址对应的 InetAddress实例。
InetAddress 类还提供了一个 isReachable()方法, 用于测试是否可以到达该地址。 该方法将尽最大努力试图到达主机, 但防火墙和服务器配置可能阻塞请求, 使得它在访问某些特定的端口时处于不可达状态。 如果可以获得权限, 典型的实现将使用 ICMP ECHO REQUEST 否则它将试图在目标主机的端口 7
(Echo) 上 建 立 TCP 连接。
下面程序测试了 InetAddress 类的简单用法:
import java.net.*;
public class InetAddressTest
{
public static void main(String[] args)
throws Exception
{
// 根据主机名来获取对应的InetAddress实例
InetAddress ip = InetAddress.getByName("www.csdn.net");
// 判断是否可达
System.out.println("csdn.net是否可达:" + ip.isReachable(2000));
// 获取该InetAddress实例的IP字符串
System.out.println(ip.getHostAddress());
// 根据原始IP地址来获取对应的InetAddress实例
InetAddress local = InetAddress.getByAddress(
new byte[]{127,0,0,1});
System.out.println("本机是否可达:" + local.isReachable(5000));
// 获取该InetAddress实例对应的全限定域名
System.out.println(local.getCanonicalHostName());
}
}
URL、 URLConnection 和 URLPermission
URL ( Uniform Resource Locator ) 对象代表统一资源定位器, 它是指向互联网“ 资源” 的指针。 资源可以是简单的文件或目录, 也可以是对更为复杂对象的引用, 例如对数据库或搜索引擎的查询。 在通
常情况下, URL 可以由协议名、 主机、 端口和资源组成, 即满足如下格式:
protocol://host:port/resourceName
例如如下的 URL 地址:
https://editor.csdn.net/md?articleId=103746723
JDK 中还提供了 一个URI ( Uniform Resource Identifiers ) 类, 其实例代表一个统一资源标识符, Java 的 URI 不能用于定位任何资源 , 它的唯一作用就是解析。
URL 类提供了多个构造器用于创建 URL 对象, 一旦获得了 URL 对象之后, 就可以调用如下方法来访问该 URL 对应的资源。
- String getFile(): 获取该 URL 的资源名。
- String getHost(): 获取该 URL 的主机名。
- String getPath(): 获取该 URL 的路径部分。
- int getPort(): 获取该 URL 的端口号。
- String getProtocol(): 获取该 URL 的协议名称。
- String getQuery(): 获取该 URL 的查询字符串部分。
- URLConnection openConnection(): 返回一个 URLConnection 对象, 它代表了与 URL 所引用的远程对象的连接。
- InputStream openStream(): 打开与此URL的连接, 并返回一个用于读取该 URL 资源的InputStream。
URL 对象中的前面几个方法都非常容易理解, 而该对象提供的 openStream()方法可以读取该 URL资源的 InputStream, 通过该方法可以非常方便地读取远程资源—甚至实现多线程下载。
如下程序实现了一个多线程下载工具类:
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
public class DownUtil
{
// 定义下载资源的路径
private String path;
// 指定所下载的文件的保存位置
private String targetFile;
// 定义需要使用多少线程下载资源
private int threadNum;
// 定义下载的线程对象
private DownThread[] threads;
// 定义下载的文件的总大小
private int fileSize;
public DownUtil(String path, String targetFile, int threadNum)
{
this.path = path;
this.threadNum = threadNum;
// 初始化threads数组
threads = new DownThread[threadNum];
this.targetFile = targetFile;
}
public void download() throws Exception
{
URL url = new URL(path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5 * 1000);
conn.setRequestMethod("GET");
conn.setRequestProperty(
"Accept",
"image/gif, image/jpeg, image/pjpeg, image/pjpeg, "
+ "application/x-shockwave-flash, application/xaml+xml, "
+ "application/vnd.ms-xpsdocument, application/x-ms-xbap, "
+ "application/x-ms-application, application/vnd.ms-excel, "
+ "application/vnd.ms-powerpoint, application/msword, */*");
conn.setRequestProperty("Accept-Language", "zh-CN");
conn.setRequestProperty("Charset", "UTF-8");
conn.setRequestProperty("Connection", "Keep-Alive");
// 得到文件大小
fileSize = conn.getContentLength();
conn.disconnect();
int currentPartSize = fileSize / threadNum + 1;
RandomAccessFile file = new RandomAccessFile(targetFile, "rw");
// 设置本地文件的大小
file.setLength(fileSize);
file.close();
for (int i = 0; i < threadNum; i++)
{
// 计算每条线程的下载的开始位置
int startPos = i * currentPartSize;
// 每个线程使用一个RandomAccessFile进行下载
RandomAccessFile currentPart = new RandomAccessFile(targetFile,
"rw");
// 定位该线程的下载位置
currentPart.seek(startPos);
// 创建下载线程
threads[i] = new DownThread(startPos, currentPartSize,
currentPart);
// 启动下载线程
threads[i].start();
}
}
// 获取下载的完成百分比
public double getCompleteRate()
{
// 统计多条线程已经下载的总大小
int sumSize = 0;
for (int i = 0; i < threadNum; i++)
{
sumSize += threads[i].length;
}
// 返回已经完成的百分比
return sumSize * 1.0 / fileSize;
}
private class DownThread extends Thread
{
// 当前线程的下载位置
private int startPos;
// 定义当前线程负责下载的文件大小
private int currentPartSize;
// 当前线程需要下载的文件块
private RandomAccessFile currentPart;
// 定义已经该线程已下载的字节数
public int length;
public DownThread(int startPos, int currentPartSize,
RandomAccessFile currentPart)
{
this.startPos = startPos;
this.currentPartSize = currentPartSize;
this.currentPart = currentPart;
}
@Override
public void run()
{
try
{
URL url = new URL(path);
HttpURLConnection conn = (HttpURLConnection)url
.openConnection();
conn.setConnectTimeout(5 * 1000);
conn.setRequestMethod("GET");
conn.setRequestProperty(
"Accept",
"image/gif, image/jpeg, image/pjpeg, image/pjpeg, "
+ "application/x-shockwave-flash, application/xaml+xml, "
+ "application/vnd.ms-xpsdocument, application/x-ms-xbap, "
+ "application/x-ms-application, application/vnd.ms-excel, "
+ "application/vnd.ms-powerpoint, application/msword, */*");
conn.setRequestProperty("Accept-Language", "zh-CN");
conn.setRequestProperty("Charset", "UTF-8");
InputStream inStream = conn.getInputStream();
// 跳过startPos个字节,表明该线程只下载自己负责哪部分文件。
inStream.skip(this.startPos);
byte[] buffer = new byte[1024];
int hasRead = 0;
// 读取网络数据,并写入本地文件
while (length < currentPartSize
&& (hasRead = inStream.read(buffer)) != -1)
{
currentPart.write(buffer, 0, hasRead);
// 累计该线程下载的总大小
length += hasRead;
}
currentPart.close();
inStream.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
}
在主程序中调用该工具类的 down()方法执行下载:
public class MultiThreadDown
{
public static void main(String[] args) throws Exception
{
// 初始化DownUtil对象
final DownUtil downUtil = new DownUtil("https://www.pexels.com"
+ "attachments/month_1403/1403202355ff6cc9a4fbf6f14a.png"
, "ios.png", 4);
// 开始下载
downUtil.download();
new Thread(() -> {
while(downUtil.getCompleteRate() < 1)
{
// 每隔0.1秒查询一次任务的完成进度,
// GUI程序中可根据该进度来绘制进度条
System.out.println("已完成:"
+ downUtil.getCompleteRate());
try
{
Thread.sleep(1000);
}
catch (Exception ex){}
}
}).start();
}
}
URLConnection 和 HttpURLConnection 对象, 其中前者表示应用程序和 URL 之间的通信连接, 后者表示与 URL 之间的 HTTP 连接。 程序可以通过URLConnection 实例向该 URL 发送请求、 读取 URL 引用的资源。
Java 8 新增了 一 个 URLPermission 工具类, 用于管理 HttpURLConnection 的权限问题, 如果在HttpURLConnection 安装了安全管理器, 通过该对象打幵连接时就需要先获得权限。
通常创建一个和 URL 的连接, 并发送请求、 读取此 URL 引用的资源需要如下几个步骤:
- 1 通过调用 URL 对象的 openConnection()方法来创建 URLConnection 对象。
- 2 设置 URLConnection 的参数和普通请求属性。
- 3 如果只是发送 GET 方式请求, 则使用 connect()方法建立和远程资源之间的实际连接即可; 如果需要发送 POST 方式的请求, 则需要获取 URLConnection 实例对应的输出流来发送请求参数。
- 4 远程资源变为可用, 程序可以访问远程资源的头字段或通过输入流读取远程资源的数据。
在建立和远程资源的实际连接之前, 程序可以通过如下方法来设置请求头字段:
- setAllowUserInteraction(): 设置该 URLConnection 的 allowUserlnteraction 请求头字段的值。
- setDoInput(): 设置该 URLConnection 的 dolnput 请求头字段的值。
- setDoOutput(): 设置该 URLConnection 的 doOutput 请求头字段的值。
- setIfModifiedSince(): 设置该 URLConnection 的 ifModifiedSince 请求头字段的值。
- setUseCaches(): 设置该 URLConnection 的 useCaches 请求头字段的值。
除此之外, 还可以使用如下方法来设置或增加通用头字段。 - setRequestProperty(String key, String value): 设置该 URLConnection 的 key 请求头字段的值为value。 如下代码所示:
conn.setRequestProperty("accept" , "*/ ")
- addRequestProperty(String key,String value): 为该 URLConnection 的 key 请求头字段增加 value值, 该方法并不会覆盖原请求头字段的值, 而是将新值追加到原请求头字段中。
当远程资源可用之后, 程序可以使用以下方法来访问头字段和内容:
- Object getContent(): 获取该 URLConnection 的内容。
- String getHeaderField(String name): 获取指定响应头字段的值。
- getInputStream(): 返回该 URLConnection 对应的输入流, 用于获取 URLConnection 响应的内容。
- getOutputStream(): 返回该 URLConnection 对应的输出流, 用于向URLConnection 发送请求参数。
- getHeaderFidd()方法用于根据响应头字段来返回对应的值。 而某些头字段由于
经常需要访问, 所以Java 提供了以下方法来访问特定响应头字段的值。
- getContentEncoding(): 获取 content-encoding 响应头字段的值。
- getContentLength(): 获取 content-length 响应头字段的值。
- getContentType(): 获取 content-type 响应头字段的值。
- getDate(): 获取 date 响应头字段的值。
- getExpiration(): 获取 expires 响应头字段的值。
- getLastModified(): 获取 last-modified 响应头字段的值。
下面程序示例了如何向 Web 站点发送 GET 请求、 POST 请求, 并从 Web 站点取得响应。
import java.io.*;
import java.net.*;
import java.util.*;
public class GetPostTest
{
/**
* 向指定URL发送GET方法的请求
* @param url 发送请求的URL
* @param param 请求参数,格式满足name1=value1&name2=value2的形式。
* @return URL所代表远程资源的响应
*/
public static String sendGet(String url , String param)
{
String result = "";
String urlName = url + "?" + param;
try
{
URL realUrl = new URL(urlName);
// 打开和URL之间的连接
URLConnection conn = realUrl.openConnection();
// 设置通用的请求属性
conn.setRequestProperty("accept", "*/*");
conn.setRequestProperty("connection", "Keep-Alive");
conn.setRequestProperty("user-agent"
, "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)");
// 建立实际的连接
conn.connect();
// 获取所有响应头字段
Map<String, List<String>> map = conn.getHeaderFields();
// 遍历所有的响应头字段
for (String key : map.keySet())
{
System.out.println(key + "--->" + map.get(key));
}
try(
// 定义BufferedReader输入流来读取URL的响应
BufferedReader in = new BufferedReader(
new InputStreamReader(conn.getInputStream() , "utf-8")))
{
String line;
while ((line = in.readLine())!= null)
{
result += "\n" + line;
}
}
}
catch(Exception e)
{
System.out.println("发送GET请求出现异常!" + e);
e.printStackTrace();
}
return result;
}
/**
* 向指定URL发送POST方法的请求
* @param url 发送请求的URL
* @param param 请求参数,格式应该满足name1=value1&name2=value2的形式。
* @return URL所代表远程资源的响应
*/
public static String sendPost(String url , String param)
{
String result = "";
try
{
URL realUrl = new URL(url);
// 打开和URL之间的连接
URLConnection conn = realUrl.openConnection();
// 设置通用的请求属性
conn.setRequestProperty("accept", "*/*");
conn.setRequestProperty("connection", "Keep-Alive");
conn.setRequestProperty("user-agent",
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)");
// 发送POST请求必须设置如下两行
conn.setDoOutput(true);
conn.setDoInput(true);
try(
// 获取URLConnection对象对应的输出流
PrintWriter out = new PrintWriter(conn.getOutputStream()))
{
// 发送请求参数
out.print(param);
// flush输出流的缓冲
out.flush();
}
try(
// 定义BufferedReader输入流来读取URL的响应
BufferedReader in = new BufferedReader(new InputStreamReader
(conn.getInputStream() , "utf-8")))
{
String line;
while ((line = in.readLine())!= null)
{
result += "\n" + line;
}
}
}
catch(Exception e)
{
System.out.println("发送POST请求出现异常!" + e);
e.printStackTrace();
}
return result;
}
// 提供主方法,测试发送GET请求和POST请求
public static void main(String args[])
{
// 发送GET请求
String s = GetPostTest.sendGet("http://localhost:8888/abc/a.jsp"
, null);
System.out.println(s);
// 发送POST请求
String s1 = GetPostTest.sendPost("http://localhost:8888/abc/login.jsp"
, "name=crazyit.org&pass=leegang");
System.out.println(s1);
}
}
上面程序中发送 GET 请求时只需将请求参数放在 URL 字符串之后, 以?隔开, 程序直接调用URLConnection 对象的 connect()方法即可, 如 sendGet()方法中粗体字代码所示; 如果程序要发送 POST请求, 则需要先设置 doln 和doOut 两个请求头字段的值, 再使用 URLConnection 对应的输出流来发送
请求参数, 如 sendPost()方法中粗体字代码所示。
不管是发送 GET 请求, 还是发送 POST 请求, 程序获取 URLConnection 响应的方式完全一样——如果程序可以确定远程响应是字符流, 则可以使用字符流来读取; 如果程序无法确定远程响应是字符流,则使用字节流读取即可。
目前JAVA实现HTTP请求的方法用的最多的有两种:一种是通过HttpURLConnection类,比较原生,需要自己处理IO流。另外一种一种是通过HTTPClient这种第三方的开源框架去实现。HTTPClient对HTTP的封装性比较好,通过它基本上能够满足大部分的需求,HttpClient3.1 是org.apache.commons.httpclient下操作远程 url的工具包,已经不再更新。HttpClient4.5是org.apache.http.client下操作远程 url的工具包,目前是最新的。
API:java.net.URL
API:java.net.URLConnection
API:java.net.URLPermission
API:java.net.HttpURLConnection
基于TCP协议的网络编程
Socket、TCP和部分IP的功能都是由操作系统提供的,不同的编程语言只是提供了对操作系统调用的简单的封装。例如,Java提供的几个Socket相关的类就封装了操作系统提供的接口。
TCP协议基础
IP 协议是 Internet 上使用的一个关键协议, 它的全称是 Internet Protocol, 即 Internet 协议, 通常简称 IP 协议。 通过使用 IP 协议, 从而使 Internet 成为一个允许连接不同类型的计算机和不同操作系统的网络。
要使两台计算机彼此能进行通信, 必须使两台计算机使用同一种“ 语言”, IP 协议只保证计算机能发送和接收分组数据。 IP 协议负责将消息从一个主机传送到另一个主机, 消息在传送的过程中被分割成一个个的小包。
尽管计算机通过安装 IP 软件, 保证了计算机之间可以发送和接收数据, 但 IP 协议还不能解决数据分组在传输过程中可能出现的问题。 因此, 若要解决可能出现的问题, 连上 Internet 的计算机还需要安装 TCP 协议来提供可靠并且无差错的通信服务。
TCP 协议被称作一种端对端协议。 这是因为它对两台计算机之间的连接起了重要作用—当一台计算机需要与另一台远程计算机连接时, TCP 协议会让它们建立一个连接: 用于发送和接收数据的虚拟链路。
TCP 协议负责收集这些信息包, 并将其按适当的次序放好传送, 接收端收到后再将其正确地还原。TCP 协议保证了数据包在传送中准确无误。 TCP 协议使用重发机制——当一个通信实体发送一个消息给另一个通信实体后, 需要收到另一个通信实体的确认信息,如果没有收到另一个通信实体的确认信息,则会再次重发刚才发送的信息。
通过这种重发机制, TCP 协议向应用程序提供了可靠的通信连接, 使它能够自动适应网上的各种变化。 即使在 Internet 暂时出现堵塞的情况下, TCP 也能够保证通信的可靠性。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20191229103143171.png?#pic_center)
使用 ServerSocket 创建TCP 服务器端
Java 中能接收其他通信实体连接请求的类是 ServerSocket, ServerSocket 对象用于监听来自客户端的Socket 连接, 如果没有连接, 它将一直处于等待状态。 ServerSocket 包含一个监听来自客户端连接请求的方法。
- Socket accept(): 如果接收到一个客户端 Socket 的连接请求, 该方法将返回一个与客户端 Socket对应的 Socket ( 每个 TCP 连接有两个 Socket ); 否则该方法将一直处于等待状态, 线程也被阻塞。
为了创建 ServerSocket 对象, ServerSocket 类提供了如下几个构造器。
- ServerSocket(int port): 用指定的端口 port 来创建一个 ServerSocket。 该端口应该有一个有效的端口整数值, 即 0-65535。
- ServerSocket(int port,int backlog) 增加一个用来改变连接队列长度的参数 backlog
- ServerSocket(int port,int backlog,InetAddress localAddr) 在机器存在多个 IP 地址的情况下, 允许通过 localAddr 参数来指定将 ServerSocket 绑定到指定的 IP 地址。
当 ServerSocket 使用完毕后, 应使用 ServerSocket 的 close()方法来关闭该 ServerSocket 在通常情况下, 服务器不应该只接收一个客户端请求, 而应该不断地接收来自客户端的所有请求, 所以 Java 程序通常会通过循环不断地调用 ServerSocket 的 acceptQ方法。 如下代码片段所示:
// 创建一个 ServerSocket 用于监听客户端 Socket 的连接请求
ServerSocket ss = new ServerSocket(30000);
// 采用循环不断地接收来自客户端的请求
while (true){
// 每当接收到客户端 Socket 的请求时, 服务器端也对应产生一个 Socket
Socket s=ss.accept();
//下面就可以使用 Socket 进行通信了
……
}
使用 Socket 进行通信
客户端通常可以使用 Socket 的构造器来连接到指定服务器, Socket 通常可以使用如下两个构造器:
- Socket(InetAddress/String remoteAddress,int port): 创建连接到指定远程主机、 远程端口的 Socket,该构造器没有指定本地地址、 本地端口, 默认使用本地主机的默认 IP 地址, 默认使用系统动态分配的端口。
- Socket(InetAddress/String remoteAddress,int port,InetAddress localAddr,int localPort): 创建连接到指定远程主机、 远程端口的 Socket, 并指定本地 IP 地址和本地端口, 适用于本地主机有多个 IP地址的情形。
上面两个构造器中指定远程主机时既可使用 InetAddress 来指定, 也可直接使用 String 对象来指定,但程序通常使用 String 对象( 如 192.168.2.23) 来指定远程 IP 地址。 当本地主机只有一个 IP 地址时,使用第一个方法更为简单。 如下代码所示:
// 创建连接到本机、 30000 端口的 Socket
Socket s = new Socket("127.0.0.1" , 30000);
// 下面就可以使用 Socket 进行通信了
……
Socket 提供了如下两个方法来获取输入流和输出流。
- InputStream getInputStream(): 返回该 Socket 对象对应的输入流, 让程序通过该输入流从 Socket中取出数据。
- OutputStream getOutputStream(): 返回该Socket 对象对应的输出流, 让程序通过该输出流向Socket中输出数据。
网络通信实例:
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
public class Server {
public static void main(String[] args) {
try {
ServerSocket ss = new ServerSocket(6666);
System.out.println("监听端口号:6666");
Socket s = ss.accept();
//输入流
InputStream is = s.getInputStream();
// 把输入流封装在DataInputStream
DataInputStream dis = new DataInputStream(is);
//输出流
OutputStream os = s.getOutputStream();
//把输出流封装在DataInputStream
DataOutputStream dos = new DataOutputStream(os);
while(true) {
// 读取客户端信息,使用readUTF读取字符串
String msg = dis.readUTF();
System.out.println("客户端发来消息:");
System.out.println(msg);
//结束连接
if(msg.equals("0")) {
System.out.println("客户端结束连接");
dis.close();
is.close();
dos.close();
os.close();
s.close();
ss.close();
break;
}
// 使用Scanner读取控制台的输入,并发送到客户端
System.out.println("--向客户端发送消息--");
Scanner sc = new Scanner(System.in);
String str = sc.next();
dos.writeUTF(str);
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;
public class Client {
public static void main(String[] args) {
try {
Socket s = new Socket("127.0.0.1", 6666);
//输出流
OutputStream os = s.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
// 使用Scanner读取控制台的输入,并发送到服务端
Scanner sc = new Scanner(System.in);
//输入流
InputStream is = s.getInputStream();
// 把输入流封装在DataInputStream
DataInputStream dis = new DataInputStream(is);
while(true) {
//向服务端发送信息
System.out.println("--向服务端发送消息(输入0结束连接)--");
String str = sc.next();
dos.writeUTF(str);
//结束连接
if(str.equals("0")) {
dis.close();
is.close();
dos.close();
os.close();
s.close();
break;
}
// 读取服务端信息,使用readUTF读取字符串
String msg = dis.readUTF();
System.out.println("服务端消息:");
System.out.println(msg);
}
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
一旦使用 ServerSocket、Socket 建立网络连接之后, 程序通过网络通信与普通 IO 并没有太大的区别。
加入多线程
在使用传统 BufferedReader 的 readLine()方法读取数据时, 在该方法成功返回之前, 线程被阻塞,程序无法继续执行。 考虑到这个原因, 服务器端应该为每个 Socket 单独启动一个线程, 每个线程负责与一个客户端进行通信。
客户端读取服务器端数据的线程同样会被阻塞, 所以系统应该单独启动一个线程, 该线程专门负责读取服务器端数据。
现在准备实现一个命令行界面的 C/S 聊天室应用, 服务器端应该包含多个线程, 每个 Socket 对应一个线程, 该线程负责读取 Socket 对应输入流的数据( 从客户端发送过来的数据), 并将读到的数据向每个 Socket 输出流发送一次( 将一个客户端发送的数据“ 广播” 给其他客户端), 因此需要在服务器端使用 List 来保存所有的 Socket。
下面是服务器端的实现代码, 程序为服务器端提供了两个类, 一个是创建 ServerSocket 监听的主类,一个是负责处理每个 Socket 通信的线程类。
import java.net.*;
import java.io.*;
import java.util.*;
public class MyServer
{
// 定义保存所有Socket的ArrayList,并将其包装为线程安全的
public static List<Socket> socketList
= Collections.synchronizedList(new ArrayList<>());
public static void main(String[] args)
throws IOException
{
ServerSocket ss = new ServerSocket(30000);
while(true)
{
// 此行代码会阻塞,将一直等待别人的连接
Socket s = ss.accept();
socketList.add(s);
// 每当客户端连接后启动一条ServerThread线程为该客户端服务
new Thread(new ServerThread(s)).start();
}
}
}
服务器端只负责接收客户端 Socket 的连接请求, 每当客户端 Socket 连接到该
ServerSocket 之后, 程序将对应 Socket 加入 socketList 集合中保存, 并为该 Socket 启动一个线程, 该线程负责处理该 Socket 所有的通信任务。
import java.io.*;
import java.net.*;
// 负责处理每个线程通信的线程类
public class ServerThread implements Runnable
{
// 定义当前线程所处理的Socket
Socket s = null;
// 该线程所处理的Socket所对应的输入流
BufferedReader br = null;
public ServerThread(Socket s)
throws IOException
{
this.s = s;
// 初始化该Socket对应的输入流
br = new BufferedReader(new InputStreamReader(s.getInputStream()));
}
public void run()
{
try
{
String content = null;
// 采用循环不断从Socket中读取客户端发送过来的数据
while ((content = readFromClient()) != null)
{
// 遍历socketList中的每个Socket,
// 将读到的内容向每个Socket发送一次
for (Socket s : MyServer.socketList)
{
PrintStream ps = new PrintStream(s.getOutputStream());
ps.println(content);
}
}
}
catch (IOException e)
{
e.printStackTrace();
}
}
// 定义读取客户端数据的方法
private String readFromClient()
{
try
{
return br.readLine();
}
// 如果捕捉到异常,表明该Socket对应的客户端已经关闭
catch (IOException e)
{
// 删除该Socket。
MyServer.socketList.remove(s); // ①
}
return null;
}
}
服务器端线程类不断地读取客户端数据, 程序使用 readFromClientO方法来读取客户端数据,如果读取数据过程中捕获到 IOException 异常, 则表明该 Socket 对应的客户端 Socket 出现了问题, 程序就将该 Socket 从 socketList 集合中删除。
客户端应该包含两个线程, 一个负责读取用户的键盘输入, 并将用户输入的数据写入 Socket对应的输出流中; 一个负责读取 Socket 对应输入流中的数据( 从服务器端发送过来的数据), 并将这些数据打印输出。
import java.io.*;
import java.net.*;
public class MyClient
{
public static void main(String[] args)throws Exception
{
Socket s = new Socket("127.0.0.1" , 30000);
// 客户端启动ClientThread线程不断读取来自服务器的数据
new Thread(new ClientThread(s)).start(); // ①
// 获取该Socket对应的输出流
PrintStream ps = new PrintStream(s.getOutputStream());
String line = null;
// 不断读取键盘输入
BufferedReader br = new BufferedReader(
new InputStreamReader(System.in));
while ((line = br.readLine()) != null)
{
// 将用户的键盘输入内容写入Socket对应的输出流
ps.println(line);
}
}
}
当主线程使用 Socket 连接到服务器之后, 启动了 ClientThread 来处理该线程的 Socket通信。
import java.io.*;
import java.net.*;
public class ClientThread implements Runnable
{
// 该线程负责处理的Socket
private Socket s;
// 该线程所处理的Socket所对应的输入流
BufferedReader br = null;
public ClientThread(Socket s)
throws IOException
{
this.s = s;
br = new BufferedReader(
new InputStreamReader(s.getInputStream()));
}
public void run()
{
try
{
String content = null;
// 不断读取Socket输入流中的内容,并将这些内容打印输出
while ((content = br.readLine()) != null)
{
System.out.println(content);
}
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
记录用户信息
上面程序虽然己经完成了粗略的通信功能, 每个客户端可以看到其他客户端发送的信息, 但无法知道是哪个客户端发送的信息, 这是因为服务器端从未记录过用户信息, 当客户端使用 Socket 连接到服务器端之后, 程序只是使socketList 集合保存了服务器端对应生成的 Socket, 并没有保存该 Socket 关联的
客户信息。
可以使用 Map 来保存用户状态信息,实现私聊功能, 也就是说,一个客户端可以将信息发送给另一个指定客户端。 实际上, 所有客户端只与服务器端连接, 客户端之间并没有互相连接, 也就是说, 当一个客户端信息发送到服务器端之后, 服务器端必须可以判断该信息到底是向所有用户发送, 还是向指定用户发送, 并需要知道向哪个用户发送。 这里需要解决如下两个问题。
- 客户端发送来的信息必须有特殊的标识—让服务器端可以判断是公聊信息, 还是私聊信息。
- 如果是私聊信息, 客户端会发送该消息的目的用户( 私聊对象) 给服务器端, 服务器端如何将该信息发送给该私聊对象。
为了解决第一个问题, 可以让客户端在发送不同信息之前, 先对这些信息进行适当处理, 比如在内容前后添加一些特殊字符—这种特殊字符被称为协议字符。
本例提供了一个 CmzyitProtocol 接口, 该接口专门用于定义协议字符:
public interface CrazyitProtocol {
// 定义协议字符串的长度
int PROTOCOL_LEN = 2;
// 下面是一些协议字符串,服务器和客户端交换的信息
// 都应该在前、后添加这种特殊字符串。
String MSG_ROUND = "§γ";
String USER_ROUND = "∏∑";
String LOGIN_SUCCESS = "1";
String NAME_REP = "-1";
String PRIVATE_ROUND = "★【";
String SPLIT_SIGN = "※";
}
为了解决第二个问题,用 Map 保存聊天室所有用户名和对应输出流之间的映射关系:
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
// 通过组合HashMap对象来实现CrazyitMap,CrazyitMap要求value也不可重复
public class CrazyitMap<K,V>
{
// 创建一个线程安全的HashMap
public Map<K ,V> map = Collections.synchronizedMap(new HashMap<K,V>());
// 根据value来删除指定项
public synchronized void removeByValue(Object value)
{
for (Object key : map.keySet())
{
if (map.get(key) == value)
{
map.remove(key);
break;
}
}
}
// 获取所有value组成的Set集合
public synchronized Set<V> valueSet()
{
Set<V> result = new HashSet<V>();
// 将map中所有value添加到result集合中
map.forEach((key , value) -> result.add(value));
return result;
}
// 根据value查找key。
public synchronized K getKeyByValue(V val)
{
// 遍历所有key组成的集合
for (K key : map.keySet())
{
// 如果指定key对应的value与被搜索的value相同,则返回对应的key
if (map.get(key) == val || map.get(key).equals(val))
{
return key;
}
}
return null;
}
// 实现put()方法,该方法不允许value重复
public synchronized V put(K key,V value)
{
// 遍历所有value组成的集合
for (V val : valueSet() )
{
// 如果某个value与试图放入集合的value相同
// 则抛出一个RuntimeException异常
if (val.equals(value)
&& val.hashCode()== value.hashCode())
{
throw new RuntimeException("MyMap实例中不允许有重复value!");
}
}
return map.put(key , value);
}
}
服务器端的主类一样只是建立 ServerSocket 来监听来自客户端 Socket 的连接请求:
import java.net.*;
import com.empty.chapter38.senior.CrazyitMap;
import java.io.*;
public class Server
{
private static final int SERVER_PORT = 30000;
// 使用CrazyitMap对象来保存每个客户名字和对应输出流之间的对应关系。
public static CrazyitMap<String , PrintStream> clients
= new CrazyitMap<>();
public void init()
{
try(
// 建立监听的ServerSocket
ServerSocket ss = new ServerSocket(SERVER_PORT))
{
// 采用死循环来不断接受来自客户端的请求
while(true)
{
Socket socket = ss.accept();
new ServerThread(socket).start();
}
}
// 如果抛出异常
catch (IOException ex)
{
System.out.println("服务器启动失败,是否端口"
+ SERVER_PORT + "已被占用?");
}
}
public static void main(String[] args)
{
Server server = new Server();
server.init();
}
}
服务器端线程类:
import java.net.*;
import com.empty.chapter38.senior.CrazyitProtocol;
import java.io.*;
public class ServerThread extends Thread
{
private Socket socket;
BufferedReader br = null;
PrintStream ps = null;
// 定义一个构造器,用于接收一个Socket来创建ServerThread线程
public ServerThread(Socket socket)
{
this.socket = socket;
}
public void run()
{
try
{
// 获取该Socket对应的输入流
br = new BufferedReader(new InputStreamReader(socket
.getInputStream()));
// 获取该Socket对应的输出流
ps = new PrintStream(socket.getOutputStream());
String line = null;
while((line = br.readLine())!= null)
{
// 如果读到的行以CrazyitProtocol.USER_ROUND开始,并以其结束,
// 可以确定读到的是用户登录的用户名
if (line.startsWith(CrazyitProtocol.USER_ROUND)
&& line.endsWith(CrazyitProtocol.USER_ROUND))
{
// 得到真实消息
String userName = getRealMsg(line);
// 如果用户名重复
if (Server.clients.map.containsKey(userName))
{
System.out.println("重复");
ps.println(CrazyitProtocol.NAME_REP);
}
else
{
System.out.println("成功");
ps.println(CrazyitProtocol.LOGIN_SUCCESS);
Server.clients.put(userName , ps);
}
}
// 如果读到的行以CrazyitProtocol.PRIVATE_ROUND开始,并以其结束,
// 可以确定是私聊信息,私聊信息只向特定的输出流发送
else if (line.startsWith(CrazyitProtocol.PRIVATE_ROUND)
&& line.endsWith(CrazyitProtocol.PRIVATE_ROUND))
{
// 得到真实消息
String userAndMsg = getRealMsg(line);
// 以SPLIT_SIGN分割字符串,前半是私聊用户,后半是聊天信息
String user = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[0];
String msg = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[1];
// 获取私聊用户对应的输出流,并发送私聊信息
Server.clients.map.get(user).println(Server.clients
.getKeyByValue(ps) + "悄悄地对你说:" + msg);
}
// 公聊要向每个Socket发送
else
{
// 得到真实消息
String msg = getRealMsg(line);
// 遍历clients中的每个输出流
for (PrintStream clientPs : Server.clients.valueSet())
{
clientPs.println(Server.clients.getKeyByValue(ps)
+ "说:" + msg);
}
}
}
}
// 捕捉到异常后,表明该Socket对应的客户端已经出现了问题
// 所以程序将其对应的输出流从Map中删除
catch (IOException e)
{
Server.clients.removeByValue(ps);
System.out.println(Server.clients.map.size());
// 关闭网络、IO资源
try
{
if (br != null)
{
br.close();
}
if (ps != null)
{
ps.close();
}
if (socket != null)
{
socket.close();
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
// 将读到的内容去掉前后的协议字符,恢复成真实数据
private String getRealMsg(String line)
{
return line.substring(CrazyitProtocol.PROTOCOL_LEN
, line.length() - CrazyitProtocol.PROTOCOL_LEN);
}
}
客户端主类增加了让用户输入用户名的代码, 并且不允许用户名重复。 除此之外, 还可以根据用户的键盘输入来判断用户是否想发送私聊信息。 客户端主类的代码如下:
import java.net.*;
import java.io.*;
import javax.swing.*;
import com.empty.chapter38.senior.CrazyitProtocol;
public class Client
{
private static final int SERVER_PORT = 30000;
private Socket socket;
private PrintStream ps;
private BufferedReader brServer;
private BufferedReader keyIn;
public void init()
{
try
{
// 初始化代表键盘的输入流
keyIn = new BufferedReader(
new InputStreamReader(System.in));
// 连接到服务器
socket = new Socket("127.0.0.1", SERVER_PORT);
// 获取该Socket对应的输入流和输出流
ps = new PrintStream(socket.getOutputStream());
brServer = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
String tip = "";
// 采用循环不断地弹出对话框要求输入用户名
while(true)
{
String userName = JOptionPane.showInputDialog(tip
+ "输入用户名"); //①
// 将用户输入的用户名的前后增加协议字符串后发送
ps.println(CrazyitProtocol.USER_ROUND + userName
+ CrazyitProtocol.USER_ROUND);
// 读取服务器的响应
String result = brServer.readLine();
// 如果用户重复,开始下次循环
if (result.equals(CrazyitProtocol.NAME_REP))
{
tip = "用户名重复!请重新";
continue;
}
// 如果服务器返回登录成功,结束循环
if (result.equals(CrazyitProtocol.LOGIN_SUCCESS))
{
break;
}
}
}
// 捕捉到异常,关闭网络资源,并退出该程序
catch (UnknownHostException ex)
{
System.out.println("找不到远程服务器,请确定服务器已经启动!");
closeRs();
System.exit(1);
}
catch (IOException ex)
{
System.out.println("网络异常!请重新登录!");
closeRs();
System.exit(1);
}
// 以该Socket对应的输入流启动ClientThread线程
new ClientThread(brServer).start();
}
// 定义一个读取键盘输出,并向网络发送的方法
private void readAndSend()
{
try
{
// 不断读取键盘输入
String line = null;
while((line = keyIn.readLine()) != null)
{
// 如果发送的信息中有冒号,且以//开头,则认为想发送私聊信息
if (line.indexOf(":") > 0 && line.startsWith("//"))
{
line = line.substring(2);
ps.println(CrazyitProtocol.PRIVATE_ROUND +
line.split(":")[0] + CrazyitProtocol.SPLIT_SIGN
+ line.split(":")[1] + CrazyitProtocol.PRIVATE_ROUND);
}
else
{
ps.println(CrazyitProtocol.MSG_ROUND + line
+ CrazyitProtocol.MSG_ROUND);
}
}
}
// 捕捉到异常,关闭网络资源,并退出该程序
catch (IOException ex)
{
System.out.println("网络通信异常!请重新登录!");
closeRs();
System.exit(1);
}
}
// 关闭Socket、输入流、输出流的方法
private void closeRs()
{
try
{
if (keyIn != null)
{
ps.close();
}
if (brServer != null)
{
ps.close();
}
if (ps != null)
{
ps.close();
}
if (socket != null)
{
keyIn.close();
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
public static void main(String[] args)
{
Client client = new Client();
client.init();
client.readAndSend();
}
}
客户端线程类代码如下:
import java.io.BufferedReader;
import java.io.IOException;
public class ClientThread extends Thread
{
// 该客户端线程负责处理的输入流
BufferedReader br = null;
// 使用一个网络输入流来创建客户端线程
public ClientThread(BufferedReader br)
{
this.br = br;
}
public void run()
{
try
{
String line = null;
// 不断从输入流中读取数据,并将这些数据打印输出
while((line = br.readLine())!= null)
{
System.out.println(line);
/*
本例仅打印了从服务器端读到的内容。实际上,此处的情况可以更复杂:
如果希望客户端能看到聊天室的用户列表,则可以让服务器在
每次有用户登录、用户退出时,将所有用户列表信息都向客户端发送一遍。
为了区分服务器发送的是聊天信息,还是用户列表,服务器也应该
在要发送的信息前、后都添加一定的协议字符串,客户端此处则根据协议
字符串的不同而进行不同的处理!
更复杂的情况:
如果两端进行游戏,则还有可能发送游戏信息,例如两端进行五子棋游戏,
则还需要发送下棋坐标信息等,服务器同样在这些下棋坐标信息前、后
添加协议字符串后再发送,客户端就可以根据该信息知道对手的下棋坐标。
*/
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
// 使用finally块来关闭该线程对应的输入流
finally
{
try
{
if (br != null)
{
br.close();
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
}
基于UDP协议的网络编程
和TCP编程相比,UDP编程就简单得多,因为UDP没有创建连接,数据包也是一次收发一个,所以没有流的概念。
UDP协议基础
UDP是User Datagram Protocol(用户数据报协议)的缩写,主要用来支持那些需要在计算机之间传输数据的网络连接。 UDP 协议从问世至今己经被使用了很多年, 虽然 UDP 协议目前应用不如 TCP 协议广泛, 但 UDP 协议依然是一个非常实用和可行的网络传输层协议。 尤其是在一些实时性很强的应用场景中, 比如网络游戏、 视频会议等, UDP 协议的快速更具有独特的魅力。
UDP 协议是一种面向非连接的协议, 面向非连接指的是在正式通信前不必与对方先建立连接, 不管对方状态就直接发送。 至于对方是否可以接收到这些数据内容, UDP 协议无法控制, 因此说 UDP 协议是一种不可靠的协议。 UDP 协议适用于一次只传送少量数据、 对可靠性要求不高的应用环境。
与前面介绍的 TCP 协议一样, UDP 协议直接位于 IP 协议之上。 实际上, IP 协议属于 OSI 参考模型的网络层协议, 而 UDP 协议和 TCP 协议都属于传输层协议。
因为 UDP 协议是面向非连接的协议, 没有建立连接的过程, 因此它的通信效率很高; 但也正因为如此, 它的可靠性不如 TCP 协议。
UDP 协议的主要作用是完成网络数据流和数据报之间的转换一在信息的发送端, UDP 协议将网络数据流封装成数据报, 然后将数据报发送出去; 在信息的接收端, UDP 协议将数据报转换成实际数据内容。
UDP 协议和 TCP 协议简单对比如下。
- TCP 协议: 可靠, 传输大小无限制, 但是需要连接建立时间, 差错控制开销大。
- UDP 协议: 不可靠, 差错控制开销较小, 传输大小限制在 64KB 以下, 不需要建立连接。
使用 DatagramSocket 发送、 接收数据
Java 使用 DatagramSocket 代表 UDP 协议的 Socket, DatagramSocket 本身只是码头, 不维护状态,不能产生 IO 流, 它的唯一作用就是接收和发送数据报, Java 使用 DatagramPacket 来代表数据报,
DatagramSocket 接收和发送的数据都是通过 DatagramPacket 对象完成的。
DatagramSocket 的构造器:
- DatagramSocket(): 创建一个 DatagramSocket 实例, 并将该对象绑定到本机默认 IP 地址、 本机所有可用端口中随机选择的某个端口。
- DatagramSocket(int prot): 创建一个 DatagramSocket 实例, 并将该对象绑定到本机默认 IP 地址、指定端口。
- DatagramSocket(int port,InetAddress laddr): 创建一个 DatagramSocket 实例, 并将该对象绑定到指定 IP 地址、 指定端口。
通过上面三个构造器中的任意一个构造器即可创建一个 DatagramSocket 实例, 通常在创建服务器时, 创建指定端口的 DatagramSocket 实例 这样保证其他客户端可以将数据发送到该服务器。 一旦得到了 DatagramSocket 实例之后, 就可以通过如下两个方法来接收和发送数据:
- receive(DatagramPacket p): 从该 DatagramSocket 中接收数据报。
- send(DatagramPacket p): 以该 DatagramSocket 对象向外发送数据报。
从上面两个方法可以看出, 用 DatagramSocket 发送数据时,DatagramSocket 并不知道将该数据报发送到哪里, 由DatagramPacket 自身决定数据报的目的地。 就像码头并不知道每个集装箱的目的地, 码头只是将这些集装箱发送出去, 而集装箱本身包含了该集装箱的目的地。
下面看一下 DatagramPacket 的构造器:
- DatagramPacket(byte[] buf,int length): 以一个空数组来创建 DatagramPacket对象, 该对象的作用是接收 DatagramSocket 中的数据。
- DatagramPacket(byte[] buf, int length, InetAddress addr, int port): 以一个包含数据的数组来创建DatagramPacket 对象, 创建该 DatagramPacket 对象时还指定了 IP 地址和端口 这就决定了该数据报的目的地。
- DatagramPacket(byte[] buf, int offset, int length): 以一个空数组来创建 DatagramPacket 对象, 并指定接收到的数据放入 buf 数组中时从 offset 开始, 最多放 length 个字节。
- DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port): 创建一个用于发送的DatagramPacket 对象, 指定发送 buf 数组中从 offset 开始, 总共 length 个字节。
当 Client/Server 程序使用 UDP 协议时, 实际上并没有明显的服务器端和客户端, 因为两方都需要先建立一个 DatagramSocket 对象, 用来接收或发送数据报, 然后使用 DatagramPacket对象作为传输数据的载体。
在接收数据之前, 应该米用上面的第一个或第三个构造器生成一个DatagramPacket 对象, 给出接收数据的字节数组及其长度。 然后调用DatagramSocket 的 receive()方法等待数据报的到来, receive()将一直等待( 该方法会阻塞调用该方法的线程), 直到收到一个数据报为止。 如下代码所示:
// 创建一个接收数据的 DatagramPacket 对象
DatagramPacket packet=new DatagramPacket(buf, 256);
// 接 收 数 据 报
socket.receive(packet);
在发送数据之前, 调用第二个或第四个构造器创建 DatagramPacket 对象, 此时的字节数组里存放了想发送的数据。 除此之外, 还要给出完整的目的地址, 包括 IP 地址和端口号。 发送数据是通过DatagramSocket 的 send()方法实现的, send()方法根据数据报的目的地址来寻径以传送数据报。 如下代码所示:
// 创建一个发送数据的 DatagramPacket 对象
DatagramPacket packet = new DatagramPacket(buf, length, address, port);
// 发 送 数 据 报
socket.send(packet);
当服务器端( 也可以是客户端) 接收到一个 DatagramPacket 对象后, 如果想向该数据报的发送者“反馈” 一些信息, 但由于 UDP 协议是面向非连接的, 所以接收者并不知道每个数据报由谁发送过来,但程序可以调用 DatagramPacket 的如下三个方法来获取发送者的 IP 地址和端口:
- InetAddress getAddress(): 当程序准备发送此数据报时, 该方法返回此数据报的目标机器的 IP地址; 当程序刚接收到一个数据报时, 该方法返回该数据报的发送主机的 IP 地址。
- int getPort(): 当程序准备发送此数据报时, 该方法返回此数据报的目标机器的端口; 当程序刚接收到一个数据报时, 该方法返回该数据报的发送主机的端口。
- SocketAddress getSocketAddress(): 当程序准备发送此数据报时, 该方法返回此数据报的目标SocketAddress; 当程序刚接收到一个数据报时, 该方法返回该数据报的发送主机的 SocketAddress。
下面程序使用 DatagramSocket 实现了 Server/Client 结构的网络通信。 本程序的服务器端使用循环1000 次来读取 DatagramSocket中的数据报, 每当读取到内容之后便向该数据报的发送者送回一条信息。
服务器端程序代码如下:
import java.net.*;
import java.io.*;
public class UdpServer
{
public static final int PORT = 30000;
// 定义每个数据报的最大大小为4K
private static final int DATA_LEN = 4096;
// 定义接收网络数据的字节数组
byte[] inBuff = new byte[DATA_LEN];
// 以指定字节数组创建准备接受数据的DatagramPacket对象
private DatagramPacket inPacket =
new DatagramPacket(inBuff , inBuff.length);
// 定义一个用于发送的DatagramPacket对象
private DatagramPacket outPacket;
// 定义一个字符串数组,服务器发送该数组的的元素
String[] books = new String[]
{
"春风得意马蹄疾,一日看尽长安花",
"黄沙百战穿金甲,不破楼兰终不还",
"莫滴水西桥畔泪,男儿西北有神州",
"马作的卢飞快,弓如霹雳弦惊"
};
public void init()throws IOException
{
try(
// 创建DatagramSocket对象
DatagramSocket socket = new DatagramSocket(PORT))
{
// 采用循环接受数据
for (int i = 0; i < 1000 ; i++ )
{
// 读取Socket中的数据,读到的数据放入inPacket封装的数组里。
socket.receive(inPacket);
// 判断inPacket.getData()和inBuff是否是同一个数组
System.out.println(inBuff == inPacket.getData());
// 将接收到的内容转成字符串后输出
System.out.println(new String(inBuff
, 0 , inPacket.getLength()));
// 从字符串数组中取出一个元素作为发送的数据
byte[] sendData = books[i % 4].getBytes();
// 以指定字节数组作为发送数据、以刚接受到的DatagramPacket的
// 源SocketAddress作为目标SocketAddress创建DatagramPacket。
outPacket = new DatagramPacket(sendData
, sendData.length , inPacket.getSocketAddress());
// 发送数据
socket.send(outPacket);
}
}
}
public static void main(String[] args)
throws IOException
{
new UdpServer().init();
}
}
客户端程序代码也与此类似, 客户端采用循环不断地读取用户键盘输入, 每当读取到用户输入的内容后就将该内容封装成 DatagramPacket 数据报, 再将该数据报发送出去; 接着把 DatagramSocket 中的数据读入接收用的DatagramPacket 中( 实际上是读入该 DatagramPacket 所封装的字节数组中)。
客户端程序代码如下:
import java.net.*;
import java.io.*;
import java.util.*;
public class UdpClient
{
// 定义发送数据报的目的地
public static final int DEST_PORT = 30000;
public static final String DEST_IP = "127.0.0.1";
// 定义每个数据报的最大大小为4K
private static final int DATA_LEN = 4096;
// 定义接收网络数据的字节数组
byte[] inBuff = new byte[DATA_LEN];
// 以指定字节数组创建准备接受数据的DatagramPacket对象
private DatagramPacket inPacket =
new DatagramPacket(inBuff , inBuff.length);
// 定义一个用于发送的DatagramPacket对象
private DatagramPacket outPacket = null;
public void init()throws IOException
{
try(
// 创建一个客户端DatagramSocket,使用随机端口
DatagramSocket socket = new DatagramSocket())
{
// 初始化发送用的DatagramSocket,它包含一个长度为0的字节数组
outPacket = new DatagramPacket(new byte[0] , 0
, InetAddress.getByName(DEST_IP) , DEST_PORT);
// 创建键盘输入流
Scanner scan = new Scanner(System.in);
// 不断读取键盘输入
while(scan.hasNextLine())
{
// 将键盘输入的一行字符串转换字节数组
byte[] buff = scan.nextLine().getBytes();
// 设置发送用的DatagramPacket里的字节数据
outPacket.setData(buff);
// 发送数据报
socket.send(outPacket);
// 读取Socket中的数据,读到的数据放在inPacket所封装的字节数组里。
socket.receive(inPacket);
System.out.println(new String(inBuff , 0
, inPacket.getLength()));
}
}
}
public static void main(String[] args)
throws IOException
{
new UdpClient().init();
}
}
客户端同样也是使用 DatagramSocket 发送、 接收 DatagramPacket ,这些代码与服务器端基本相似。 而客户端与服务器端的唯一区别在于: 服务器端的 IP 地址、 端口是固定的, 所以客户端可以直接将该数据报发送给服务器端, 而服务器端则需要根据接收到的数据报来决定“ 反馈” 数据报的目的地。
使用 MulticastSocket 实现多点广播
DatagramSocket 只允许数据报发送给指定的目标地址, 而 MulticastSocket 可以将数据报以广播方式发送到多个客户端。
若要使用多点广播, 则需要让一个数据报标有一组目标主机地址, 当数据报发出后, 整个组的所有主机都能收到该数据报。 IP 多点广播( 或多点发送) 实现了将单一信息发送到多个接收者的广播, 其思想是设置一组特殊网络地址作为多点广播地址, 每一个多点广播地址都被看做一个组, 当客户端需要发送、 接收广播信息时, 加入到该组即可。
IP 协议为多点广播提供了这批特殊的 IP 地址, 这些 IP 地址的范围是 224.0.0.0 至 239.255.255.255 。多点广播示意图如下:
MulticastSocket 类是实现多点广播的关键, 当 MulticastSocket 把一个DatagramPacket 发送到多点广播 IP 地址时, 该数据报将被自动广播到加入该地址的所有 Multicast SocketcMulticastSocket 既可以将数据报发送到多点广播地址, 也可以接收其他主机的广播信息。
MulticastSocket 有点像 DatagramSocket, 事 实 上 MulticastSocket 是 DatagramSocket 的一个子类, 也就是说, MulticastSocket 是特殊的DatagramSocket 。
当要发送一个数据报时, 可以使用随机端口创建MulticastSocket, 也可以在指定端口创建 MulticastSocket。 MulticastSocket 提供了如下三个构造器:
- public MulticastSocket(): 使用本机默认地址、 随机端口来创建MulticastSocket 对象。
- public MulticastSocket(int portNumber): 使用本机默认地址、指定端口来创建 MulticastSocket 对象。
- public MulticastSocket(SocketAddress bindaddr): 使 用 本 机 指 定 IP 地 址、 指定端口来创建MulticastSocket 对 象。
创建MulticastSocket 对象 后 , 还 需 要 将 该 MulticastSocket 加 入 到 指 定 的 多 点广播地 址 ,MulticastSocket 使 用 joinGroup()方法加入指定组; 使用 leaveGroup()方法脱离一个组。
- joinGroup(InetAddress multicastAddr): 将该 MulticastSocket 加入指定的多点广播地址。
- leaveGroup(InetAddress multicastAddr): 让 该 MulticastSocket 离开指定的多点广播地址。
在某些系统中, 可能有多个网络接口。 这可能会给多点广播带来问题, 这时候程序需要在一个指定的网络接口上监听, 通过调用 setlnterface()方法可以强制 MulticastSocket 使用指定的网络接口; 也可以使用 getlnterface()方法查询 MulticastSocket 监听的网络接口。
MulticastSocket 用于发送、 接收数据报的方法与 DatagramSocket 完全一样。 但 MulticastSocket 比DatagramSocket 多了一个 setTimeToLive(int ttl)方法, 该 ttl 参数用于设置数据报最多可以跨过多少个网络,
- 当 ttl 的值为 0 时, 指定数据报应停留在本地主机;
- 当 ttl 的值为 1 时, 指定数据报发送到本地局域网;
- 当 ttl 的值为 32 时, 意味着只能发送到本站点的网络上; 当 ttl 的值为 64 时, 意味着数据报应保留在本地区;
- 当 ttl 的值为 128 时, 意味着数据报应保留在本大洲; 当 ttl 的值为 255 时, 意味着数据报可发送到所有地方;
- 在默认情况下, 该 ttl 的 值 为 1。
下面程序使用 MulticastSocket 实现了一个基于广播的多人聊天室。 程序只需要一个 MulticastSocket, 两个线程, 其中 MulticastSocket 既用于发送, 也用于接收; 一个线程负责接收用户键盘输入, 并向 MulticastSocket 发送数据, 另一个线程则负责从 MulticastSocket 中读取数据。
import java.net.*;
import java.io.*;
import java.util.*;
// 让该类实现Runnable接口,该类的实例可作为线程的target
public class MulticastSocketTest implements Runnable
{
// 使用常量作为本程序的多点广播IP地址
private static final String BROADCAST_IP
= "230.0.0.1";
// 使用常量作为本程序的多点广播目的的端口
public static final int BROADCAST_PORT = 30000;
// 定义每个数据报的最大大小为4K
private static final int DATA_LEN = 4096;
//定义本程序的MulticastSocket实例
private MulticastSocket socket = null;
private InetAddress broadcastAddress = null;
private Scanner scan = null;
// 定义接收网络数据的字节数组
byte[] inBuff = new byte[DATA_LEN];
// 以指定字节数组创建准备接受数据的DatagramPacket对象
private DatagramPacket inPacket
= new DatagramPacket(inBuff , inBuff.length);
// 定义一个用于发送的DatagramPacket对象
private DatagramPacket outPacket = null;
public void init()throws IOException
{
try(
// 创建键盘输入流
Scanner scan = new Scanner(System.in))
{
// 创建用于发送、接收数据的MulticastSocket对象
// 由于该MulticastSocket对象需要接收数据,所以有指定端口
socket = new MulticastSocket(BROADCAST_PORT);
broadcastAddress = InetAddress.getByName(BROADCAST_IP);
// 将该socket加入指定的多点广播地址
socket.joinGroup(broadcastAddress);
// 设置本MulticastSocket发送的数据报会被回送到自身
socket.setLoopbackMode(false);
// 初始化发送用的DatagramSocket,它包含一个长度为0的字节数组
outPacket = new DatagramPacket(new byte[0]
, 0 , broadcastAddress , BROADCAST_PORT);
// 启动以本实例的run()方法作为线程体的线程
new Thread(this).start();
// 不断读取键盘输入
while(scan.hasNextLine())
{
// 将键盘输入的一行字符串转换字节数组
byte[] buff = scan.nextLine().getBytes();
// 设置发送用的DatagramPacket里的字节数据
outPacket.setData(buff);
// 发送数据报
socket.send(outPacket);
}
}
finally
{
socket.close();
}
}
public void run()
{
try
{
while(true)
{
// 读取Socket中的数据,读到的数据放在inPacket所封装的字节数组里。
socket.receive(inPacket);
// 打印输出从socket中读取的内容
System.out.println("聊天信息:" + new String(inBuff
, 0 , inPacket.getLength()));
}
}
// 捕捉异常
catch (IOException ex)
{
ex.printStackTrace();
try
{
if (socket != null)
{
// 让该Socket离开该多点IP广播地址
socket.leaveGroup(broadcastAddress);
// 关闭该Socket对象
socket.close();
}
System.exit(1);
}
catch (IOException e)
{
e.printStackTrace();
}
}
}
public static void main(String[] args)
throws IOException
{
new MulticastSocketTest().init();
}
}
使用代理服务器
代理服务器的功能就是代理用户去取得网络信息。 当使用浏览器直接连接其他 Internet 站点取得网络信息时, 通常需要先发送请求, 然后等响应到来。 代理服务器是介于浏览器和服务器之间的一台服务器, 设置了代理服务器之后, 浏览器不是直接向 Web 服务器发送请求, 而是向代理服务器发送请求, 浏览器请求被先送到代理服务器, 由代理服务器向 真正的 Web 服务器发送请求, 并取回浏览器所需要的信息, 再送回给浏览器。 由于大部分代理服务器都具有缓冲功能, 它会不断地将新取得的数据存储到代理服务器的本地存储
器上, 如果浏览器所请求的数据在它本机的存储器上已经存在而且是最新的, 那么它就无须从 Web 服务器取数据, 而直接将本地存储器上的数据送回浏览器, 这样能显著提高浏览速度。 归纳起来, 代理服务器主要提供如下两个功能。
- 突破自身IP 限制, 对外隐藏自身 IP 地址。 突破 IP 限制包括访问国外受限站点,访问国内特定单位、 团体的内部资源。
- 提高访问速度, 代理服务器提供的缓冲功能可以避免每个用户都直接访问远程主机, 从而提高客户端访问速度。
直接使用 Proxy 创建连接
Proxy 有一个构造器: Proxy(Proxy.Type type, SocketAddress sa), 用于创建表示代理服务器的 Proxy对象。 其中 sa 参数指定代理服务器的地址, type 表示该代理服务器的类型, 该服务器类型有如下三种:
- Proxy.Type.DIRECT: 表示直接连接, 不使用代理。
- Proxy.Type.HTTP: 表示支持高级协议代理, 如 HTTP 或 FTP。
- Proxy.Type.SOCKS: 表示 SOCKS ( V4 或 V5 ) 代理。
一旦创建了 Proxy 对象之后, 程序就可以在使用 URLConnection 打开连接时, 或者创建 Socket 连接时传入一个 Proxy 对象, 作为本次连接所使用的代理服务器。
其中 URL 包含了一个 URLConnection openConnection(Proxy proxy)方法, 该方法使用指定的代理服务器来打开连接; 而 Socket 则提供了一个 Socket(Proxy proxy)构造器, 该构造器使用指定的代理服务器创建一个没有连接的 Socket 对象。
下面以 URLConnection 为例来在 URLConnection 中使用代理服务器:
import java.io.*;
import java.net.*;
import java.util.*;
public class ProxyTest
{
// 下面是代理服务器的地址和端口,
// 换成实际有效的代理服务器的地址和端口
final String PROXY_ADDR = "129.82.12.188";
final int PROXY_PORT = 3124;
// 定义需要访问的网站地址
String urlStr = "http://www.crazyit.org";
public void init()
throws IOException , MalformedURLException
{
URL url = new URL(urlStr);
// 创建一个代理服务器对象
Proxy proxy = new Proxy(Proxy.Type.HTTP
, new InetSocketAddress(PROXY_ADDR , PROXY_PORT));
// 使用指定的代理服务器打开连接
URLConnection conn = url.openConnection(proxy);
// 设置超时时长。
conn.setConnectTimeout(5000);
try(
// 通过代理服务器读取数据的Scanner
Scanner scan = new Scanner(conn.getInputStream(), "utf-8");
PrintStream ps = new PrintStream("index.htm"))
{
while (scan.hasNextLine())
{
String line = scan.nextLine();
// 在控制台输出网页资源内容
System.out.println(line);
// 将网页资源内容输出到指定输出流
ps.println(line);
}
}
}
public static void main(String[] args)
throws IOException , MalformedURLException
{
new ProxyTest().init();
}
}
API:java.net.Proxy
使用 ProxySelector 自动选择代理服务器
直接使用 Proxy 对象可以在打开 URLConnection 或 Socket 时指定代理服务器, 但使用这种方式每次打开连接时都需要显式地设置代理服务器, 比较麻烦。 如果希望每次打开连接时总是具有默认的代理服务器, 则可以借助于ProxySelector 来实现。
ProxySelector 代表一个代理选择器, 它本身是一个抽象类, 程序无法创建它的实例, 开发者可以考虑继承 ProxySelector 来实现自己的代理选择器。 实现 ProxySelector 的步骤非常简单, 程序只要定义一个继承 ProxySelector 的类, 并让该类实现如下两个抽象方法。
- List<Proxy> select(URI uri): 根据业务需要返回代理服务器列表, 如果该方法返回的集合中只包含一个 Proxy, 该 Proxy 将会作为默认的代理服务器。
- connectFailed(URl uri, SocketAddress sa, IOException ioe): 连接代理服务器失败时回调该方法。
实现了自己的 ProxySelector 类之后, 调用 ProxySelector 的setDefault(ProxySelector ps)静态方法来注册该代理选择器即可。
下面程序示范了如何让自定义的 ProxySelector 来自动选择代理服务器:
import java.io.*;
import java.net.*;
import java.util.*;
public class ProxySelectorTest
{
// 下面是代理服务器的地址和端口,
// 随便一个代理服务器的地址和端口
final String PROXY_ADDR = "139.82.12.188";
final int PROXY_PORT = 3124;
// 定义需要访问的网站地址
String urlStr = "http://www.crazyit.org";
public void init()
throws IOException , MalformedURLException
{
// 注册默认的代理选择器
ProxySelector.setDefault(new ProxySelector()
{
@Override
public void connectFailed(URI uri
, SocketAddress sa, IOException ioe)
{
System.out.println("无法连接到指定代理服务器!");
}
// 根据"业务需要"返回特定的对应的代理服务器
@Override
public List<Proxy> select(URI uri)
{
// 本程序总是返回某个固定的代理服务器。
List<Proxy> result = new ArrayList<>();
result.add(new Proxy(Proxy.Type.HTTP
, new InetSocketAddress(PROXY_ADDR , PROXY_PORT)));
return result;
}
});
URL url = new URL(urlStr);
// 没有指定代理服务器、直接打开连接
URLConnection conn = url.openConnection(); //①
// 设置超时时长。
conn.setConnectTimeout(3000);
try(
// 通过代理服务器读取数据的Scanner
Scanner scan = new Scanner(conn.getInputStream());
PrintStream ps = new PrintStream("index.htm"))
{
while (scan.hasNextLine())
{
String line = scan.nextLine();
// 在控制台输出网页资源内容
System.out.println(line);
// 将网页资源内容输出到指定输出流
ps.println(line);
}
}
}
public static void main(String[] args)
throws IOException , MalformedURLException
{
new ProxySelectorTest().init();
}
}
思维导图总结
参考:
【1】:《疯狂Java讲义》
【2】:廖雪峰的官方网站:网络编程基础
【3】: 网络编程系列教材 (二)- Java Socket 收发消息入门例子
【4】:廖雪峰的官方网站:TCP编程
【5】:深入理解UDP编程
【6】:廖雪峰的官方网站:UDP编程
【7】:java之UDP协议下socket编程(单播广播多播)
【8】:廖雪峰的官方网站:HTTP编程
【9】:代理服务器(Proxy)原理
【10】:微信公众号:Java思维导图
【11】:HttpClient工具类
【12】:JAVA实现HTTP请求的方法