1.1 Java语言的发展简史

java 1

1995年年初,Sun公司发布了Java语言。
1996年年初,Sun发布了JDK1.0。这个版本包括两部分:运行环境(即JRE)和开发环境(即JDK)

  • 运行环境包括核心API、集成API、用户界面API、发布技术、Java虚拟机(JVM) 5个部分;
  • 开发环境包括编译Java程序的编译器(即Javac命令)。

1997年2月18日,Sun发布了JDK1.1JDK1.1增加了JIT(即时编译)编译器。JIT和传统的编译器不同,传统的编译器是编译一条,运行完后将其扔掉;而JIT会将经常用到的指令保存在内存中,当下次调用时就不需要重新编译了,通过这种方式让JDK在效率上有了较大提升。

java 2

1998年12月,Sun发布了Java历史上最重要的JDK版本:JDK1.2,伴随JDK1.2一同发布的还有JSP/ServletEJB等规范,并将Java分成了J2EEJ2SEJ2ME三个版本

  1. J2ME:主要用于控制移动设备和信息家电等有限存储的设备。
  2. J2SE:整个Java技术的核心和基础,它是J2MEJ2EE编程的基础,也是这本书主要介绍的内容。
  3. J2EEJava技术中应用最广泛的部分,J2EE提供了企业应用开发相关的完整解决方案。

JDK12还把它的API分成了三大类。

  1. 核心API:由Sun公司制定的基本的API,所有的Java平台都应该提供。这就是平常所说的Java核心类库。
  2. 可选API:这是SunJDK提供的扩充API,这些API因平台的不同而不同。
  3. 特殊API:用于满足特殊要求的API。如用于JCAJCE的第三方加密类库。

java 4

2002年2月,Sun发布了JDK历史上最为成熟的版本:JDK1.4。此时由于CompaqFujitsuSASSymbianIBM等公司的参与,使JDK1.4成为发展最快的一个JDK版本。JDK14已经可以使用Java实现大多数的应用了。

在此期间,Java语言在企业应用领域大放异彩,涌现出大量基于Java语言的开源框架:StrutsWebWorkHibernateSpring等;大量企业应用服务器也开始涌现:WebLogicWebSphereJBoss等,这些都标志着Java语言进入了飞速发展时期。

java 5

2004年10月,Sun发布了万众期待的JDK1.5,同时,SunJDK1.5改名为Java SE 5.0,J2EEJ2ME也相应地改名为Java EEJava MEJDK1.5增加了诸如泛型增强的for语句可变数量的形参注释(Annotations)、自动拆箱和装箱等功能;同时,也发布了新的企业级平台规范,如通过注释等新特性来简化EJB的复杂性,并推出了EJB 3.0规范。还推出了自己的MVC框架规范:JSF,JSF规范类似于ASP.NET的服务器端控件,通过它可以快速地构建复杂的JSP界面。

java 6

2006年12月,Sun公司发布了JDK1.6(也被称为Java SE6)。一直以来,Sun公司维持着大约2年发布一次JDK新版本的习惯。

Oracle收购Sun

在2009年4月20日,Oracle宣布将以每股95美元的价格收购Sun,该交易的总价值约为74亿美元。而Oracle通过收购Sun公司获得了两项软件资产:JavaSolaris

java 7

2011年7月28日,Oracle公司发布了Java SE7,这次版本升级经过了将近5年时间。Java SE 7也是Oracle发布的第一个Java版本,引入了二进制整数支持字符串的switch语句菱形语法多异常捕捉自动关闭资源的try语句等新特性。

java 8

2014年3月18日,Oracle公司发布了Java SE 8,这次版本升级为Java带来了全新的**Lambda表达式流式编程**等大量新特性,这些新特性使得Java变得更加强大。

Java 9

2017年9月22日,Oracle公司发布了Java SE 9,这次版本升级强化了Java的模块化系统,让庞大的Java语言更轻量化,而且采用了更高效、更智能的G1垃圾回收器,并在核心类库上进行了大量更新,可以进一步简化编程;但对语法本身更新并不多

上面程序虽然已经完成了粗略的通信功能,每个客户端可以看到其他客户端发送的信息,但无法知道是哪个客户端发送的信息,这是因为服务器端从未记录过用户信息,当客户端使用Socket连接到服务器端之后,程序只是使用socketlist集合保存了服务器端对应生成的Socket,并没有保存该Socket关联的客户信息。

下面程序将考虑使用Map来保存用户状态信息,因为本程序将考虑实现私聊功能,也就是说,一个客户端可以将信息发送给另一个指定客户端。实际上,所有客户端只与服务器端连接,客户端之间并没有互相连接,也就是说,当一个客户端信息发送到服务器端之后,服务器端必须可以判断该信息到底是向所有用户发送,还是向指定用户发送,并需要知道向哪个用户发送。这里需要解决如下两个问题。

  • 客户端发送来的信息必须有特殊的标识——让服务器端可以判断是公聊信息,还是私聊信息
  • 如果是私聊信息,客户端会发送该消息的目的用户(私聊对象)给服务器端,服务器端如何将该信息发送给该私聊对象。

协议

为了解决第一个问题,可以让客户端在发送不同信息之前,先对这些信息进行适当处理,比如在内容前后添加一些特殊字符——这种特殊字符被称为协议字符。本例提供了一个CrazyitProtocol接口,该接口专门用于定义协议字符

协议接口CrazyitProtocol

1
2
3
4
5
6
7
8
9
10
11
12
13
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 = "※";
}

实际上,由于服务器端和客户端都需要使用这些协议字符串,所以程序需要在客户端和服务器端同时保留该接口对应的.class文件

用户的Socket的映射

为了解决第二个问题,可以考虑使用一个Map来保存聊天室所有用户和对应Socket之间的映射关系,这样服务器端就可以根据用户名来找到对应的Socket。但实际上本程序并未这么做,程序仅仅是Map保存了聊天室所有用户名和对应输出流之间的映射关系,因为服务器端只要获取该用户名对应的输岀流即可。服务器端提供了一个HashMap的子类,该类不允许value重复,并提供了根据value获取key,根据value删除key等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.util.*;

// 通过组合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);
}
}

严格来讲,CrazyitMap已经不是一个标准的Map结构了,但程序需要这样一个数据结构来保存用户名和对应输岀流之间的映射关系,这样既可以通过用户名找到对应的输出流,也可以根据输出流找到对应的用户名。

服务器端的主类

服务器端的主类一样只是建立ServerSocket来监听来自客户端Socket的连接请求,但该程序增加了一些异常处理,可能看上去比上一节的程序稍微复杂一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.net.*;
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();
}
}

该程序的关键代码只有三行:

1
2
3
ServerSocket ss = new ServerSocket(SERVER_PORT)
Socket socket = ss.accept();
new ServerThread(socket).start();

它们依然是完成建立ServerSocket,监听客户端Socket连接请求,并为已连接的Socket启动单独的线程。

服务器端线程类

服务器端线程类比上一节的程序要复杂一点,因为该线程类要分别处理公聊、私聊两类聊天信息除此之外,还需要处理用户名是否重复的问题。服务器端线程类的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import java.net.*;
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);
}
}

上面程序比前一节的程序除增加了异常处理之外,主要增加了对读取数据的判断。程序读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串对该内容进行相应的处理。

客户端

客户端主类

客户端主类增加了让用户输入用户名的代码,并且不允许用户名重复。除此之外,还可以根据用户的键盘输入来判断用户是否想发送私聊信息。客户端主类的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import java.net.*;
import java.io.*;
import javax.swing.*;

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();
}
}

上面程序使用JOptionPane弹出一个输入对话框让用户输入用户名,如程序init()方法中的①号粗体字代码所示。然后程序立即将用户输入的用户名发送给服务器端,服务器端会返回该用户名是否重复的提示,程序又立即读取服务器端提示,并根据服务器端提示判断是否需要继续让用户输入用户名。
与前一节的客户端主类程序相比,该程序还增加了对用户输入信息的判断——程序判断用户输入的内容是否以斜线()开头,并包含冒号(:),如果满足该特征,系统认为该用户想发送私聊信息,就会将冒号(:)之前的部分当成私聊用户名,冒号(:)之后的部分当成聊天信息,如readAndSend()方法中粗体字代码所示。

客户端线程类

本程序中客户端线程类几乎没有太大的改变,仅仅添加了异常处理部分的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.io.*;

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();
}
}
}
}

虽然上面程序非常简单,但正如程序注释中所指出的,如果服务器端可以返回更多丰富类型的数据,则该线程类的处理将会更复杂,那么该程序可以扩展到非常强大。
先运行上面的Server类,启动服务器;再多次运行Client类启动多个客户端,并输入不同的用户名,登录服务器后的聊天界面如图17.5所示。

本程序没有提供GUI界面部分,直接使用DOS窗口进行聊天——因为增加GUI界面会让程序代码更多,从而引起读者的畏难心理。如果读者理解了本程序之后,相信读者一定乐意为该程序添加界面部分,因为整个程序的所有核心功能都已经实现了。不仅如此,读者完全可以在本程序的基础上扩展成一个仿QQ游戏大厅的网络程序。

17.3.4 加入多线程

前面ServerClient只是进行了简单的通信操作:服务器端接收到客户端连接之后,服务器端向客户端输出一个字符串,而客户端也只是读取服务器端的字符串后就退出了。实际应用中的客户端则可能需要和服务器端保持长时间通信,即

  • 服务器端需要不断地读取客户端数据,并向客户端写入数据;
  • 客户端也需要不断地读取服务器端数据,并向服务器端写入数据。

在使用传统BufferedReaderreadLine()方法读取数据时,在该方法成功返回之前,线程被阻塞,程序无法继续执行。考虑到这个原因,
服务器端应该为每个Socket单独启动一个线程,每个线程负责与一个客户端进行通信
客户端读取服务器端数据的线程同样会被阻塞,所以系统应该单独启动一个线程,该线程专门负责读取服务器端数据

现在考虑实现一个命令行界面的C/S聊天室应用,服务器端应该包含多个线程,每个Socket对应一个线程,该线程负责读取Socket对应输入流的数据(从客户端发送过来的数据),并将读到的数据向每个Socket输出流发送一次(将一个客户端发送的数据“广播”给其他客户端),因此需要在服务器端使用List来保存所有的Socket

程序示例

下面是服务器端的实现代码,程序为服务器端提供了两个类,一个是创建ServerSocket监听的主类个是负责处理每通信的线程类。

服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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所有的通信任务,如程序中4行粗体字代码所示。

服务器端线程类

服务器端线程类的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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;
}
}

上面的服务器端线程类不断地读取客户端数据,程序使用readFromClient()方法来读取客户端数据,如果读取数据过程中捕获到IOException异常,则表明该Socket对应的客户端Socket出现了问题(到底什么问题不用深究,反正不正常),程序就将该Socketsocketlist集合中删除,如readFromClient()方法中①号代码所示
当服务器端线程读到客户端数据之后,程序遍历socketList集合,并将该数据向socketList集合中的每个Socket发送一次,也就是该服务器端线程把从Socket中读到的数据向socketList集合中的每个Socket转发一次。

客户端

毎个客户端应该包含两个线程,

  • 一个负责读取用户的键盘输入,并将用户输入的数据写入Socket对应的输出流中;
  • 一个负责读取Socket对应输入流中的数据(从服务器端发送过来的数据),并将这些数据打印输出。

其中负责读取用户键盘输入的线程由MyClient负责,也就是由程序的主线程负责。客户端主程序代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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对应的输出流
除此之外,当主线程使用Socket连接到服务器之后,启动了ClientThread来处理该线程的Socket通信,如程序中①号代码所示。ClientThread线程负责读取Socket输入流中的内容,并将这些内容在控制台打印出来.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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输入流中的内容,当获取到Socket输入流中的内容后,直接将这些内容打印在控制台
先运行上面程序中的MyServer类,该类运行后只是作为服务器,看不到任何输出。再运行多个相当于启动多个聊天室客户端登录该服务器,然后可以在任何一个客户端通过键盘输入些内容后按回车键,即可在所有客户端(包括自己)的控制台上收到刚刚输入的内容,这就粗略地实现了一个**C/S结构聊天室**的功能。

17.3.3 使用Socket进行通信

客户端通常可以使用Socket的构造器来连接到指定服务器,Socket通常可以使用如下两个构造器。

本地主机只有一个IP地址的情况

方法 描述
Socket(String host, int port) 创建连接到指定远程主机、远程端口Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认IP地址,默认使用系统动态分配的端口。
Socket(InetAddress address, int port) Creates a stream socket and connects it to the specified port number at the specified IP address.

适用于本地主机有多个IP地址的情况

方法 描述
Socket(String host, int port, InetAddress localAddr, int localPort) 创建连接到指定远程主机、远程端口Socket,并指定本地IP地址和本地端口,适用于本地主机有多个IP地址的情形
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) Creates a socket and connects it to the specified remote address on the specified remote port.

上面两个构造器中指定远程主机时既可使用InetAddress来指定,也可直接使用String对象来指定,但程序通常使用String对象(如192.168.2.23)来指定远程IP地址。当本地主机只有一个IP地址时,使用第一类Socket构造器更为简单。如下代码所示。

1
2
3
//创建连接到本机、30000端口的`Socket`
Socket socket = new Socket("127.0.0.1" , 30000);
//下面就可以使用`Socket`进行通信了

当程序执行上面Socket构造器时,该代码将会连接到指定服务器,让服务器端的ServerSocketaccept()方法向下执行,于是服务器端和客户端就产生一对互相连接的Socket

上面程序连接到“远程主机”的地址使用的是127.0.0.1,这个IP地址是一个特殊的地址,它总是代表本机的IP地址。因为本书的示例程序的服务器端、客户端都是在本机运行的,所以Socket连接的远程主机的IP地址使用127.0.0.1

获取Socket的输入输出流

当客户端、服务器端产生了对应的Socket之后,就得到了如图17.4所示的通信示意图,程序无须再区分服务器端、客户端,而是通过各自的Socket进行通信。Socket提供了如下两个方法来获取输入流和输出流。

方法 描述
InputStream getInputStream() 返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。
OutputStream getOutputStream() 返回该Socket对象对应的输岀流,让程序通过该输出流向Socket中输出数据。

看到这两个方法返回的InputStreamOutputStream,读者应该可以明白Java在设计IO体系上的苦心了——不管底层的IO流是怎样的节点流:文件流也好,网络Socket产生的流也好,程序都可以将其包装成处理流,从而提供更多方便的处理。下面以一个最简单的网络通信程序为例来介绍基于TCP协议的网络通信。

程序示例 最简单的TCP通信

下面的服务器端程序非常简单,它仅仅建立ServerSocket监听,并使用Socket获取输出流输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.net.*;
import java.io.*;

public class Server {
public static void main(String[] args) throws IOException {
// 创建一个ServerSocket,用于监听客户端Socket的连接请求
ServerSocket serverSocket = new ServerSocket(30000);
// 采用循环不断接受来自客户端的请求
int count = 0;
while (true) {
// 每当接受到客户端Socket的请求,服务器端也对应产生一个Socket
Socket server = serverSocket.accept();
// 将Socket对应的输出流包装成PrintStream
PrintStream ps = new PrintStream(server.getOutputStream());
// 进行普通IO操作
ps.println("你好! 你是第"+(count++)+"号客户端");
// 关闭输出流,关闭Socket
ps.close();
server.close();
}
}
}

下面的客户端程序也非常简单,它仅仅使用Socket建立与指定IP地址、指定端口的连接,并使用Socket获取输入流读取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.net.*;
import java.io.*;

public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 30000); // ①
// 将Socket对应的输入流包装成BufferedReader
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 进行普通IO操作
String line = br.readLine();
System.out.println("来自服务器的数据:" + line);
// 关闭输入流、socket
br.close();
socket.close();
}
}

上面程序中①号代码是使用SocketServerSocket建立网络连接的代码,接下来代码通过Socket获取输入流、输岀流进行通信的代码。通过程序不难看出,一旦使用ServerSocket,Socket建立网络连接之后,程序通过网络通信与普通IO并没有太大的区别。
先运行程序中的Server类,将看到服务器一直处于等待状态,因为服务器使用了死循环来接收来自客户端的请求;
再运行Client类,将看到程序输岀:“来自服务器的数据:来自服务器的数据:你好! 你是第0号客户端”,这表明客户端和服务器端通信成功。
上面程序为了突出通过ServerSocketSocket建立连接,并通过底层IO流进行通信的主题,程序没有进行异常处理,也没有使用finally块来关闭资源

设置超时时间

在实际应用中,程序可能不想让执行网络连接、读取服务器数据的进程一直阻塞,而是希望当网络连接、读取操作超过合理时间之后,系统自动认为该操作失败,这个合理时间就是超时时长。Socket对象提供了一个setSoTimeout(int timeout)方法来设置超时时长。如下代码片段所示:

1
2
3
Socket s=new Socket("127.0.0.1",30000);
//设置10秒之后即认为超时
s.setsoTimeout(10000);

Socket对象指定了超时时长之后,如果在使用Socket进行读、写操作完成之前超出了该时间限制,那么这些方法就会抛出SocketTimeoutException异常,程序可以对该异常进行捕获,并进行适当处理。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
try{
//使用 Scanner来读取网络输入流中的数据
Scanner scan = new Scanner(s.getInputstream());
//读取一行字符
string line = scan.nextline();
}
//捕获 SocketTimeoutException异常
catch(SocketTimeoutException ex)
{
//对异常进行处理
}

Socket连接服务器时指定超时时长

假设程序需要Socket连接服务器时指定超时时长,即经过指定时间后,如果该Socket还未连接到远程服务器,则系统认为该Socket连接超时。但Socket的所有构造器里都没有提供指定超时时长的参数,所以程序应该先创建一个无连接的Socket,再调用Socketconnect()方法来连接远程服务器而connec方法就可以接收一个超时时长参数。如下代码所示。

1
2
3
4
//创建一个无连接的 Socket
Socket s= new.Socket());
//让该 Socket连接到远程服务器,如果经过10秒还没有连接上,则认为连接超时
s.connect(new InetSocketAddress(host, port), 10000);

17.3.2 使用ServerSocket创建TCP服务器端

看图17.4,并没有看出TCP通信的两个通信实体之间有服务器端、客户端之分,这是因为此图是两个通信实体已经建立虚拟链路之后的示意图。在两个通信实体没有建立虚拟链路之前,必须有一个通信实体先做出“主动姿态”,主动接收来自其他通信实体的连接请求。

Java中能接收其他通信实体连接请求的类是ServerSocket,ServerSocket对象用于监听来自客户端的Socket连接,如果没有连接,它将一直处于等待状态。ServerSocket包含一个监听来自客户端连接请求的方法。

监听客户端的连接请求

方法 描述
Socket accept() 如果接收到一个客户端Socket的连接请求,该方法将返回一个与客户端Socket对应的Socket(如图17.4所示,每个TCP连接有两个Socket);否则该方法将一直处于等待态,线程也被阻塞。

为了创建ServerSocket对象,ServerSocket类提供了如下几个构造器

ServerSocket类构造器

方法 描述
ServerSocket() Creates an unbound server socket.
ServerSocket(int port) 用指定的端口port来创建一个ServerSocket该端口应该有一个有效的端口整数值,即0-65535。
ServerSocket(int port, int backlog) 增加一个用来改变连接队列长度的参数backlog
ServerSocket(int port, int backlog, InetAddress bindAddr) 在机器存在多个IP地址的情况下,允许通过bindAddr参数来指定将ServerSocket绑定到指定的IP地址

关闭ServerSocket

ServerSocket使用完毕后,应使用ServerSocketclose()方法来关闭该ServerSocket

接受多个客户端请求

在通常情况下,服务器不应该只接收一个客户端请求,而应该不断地接收来自客户端的所有请求,所以Java程序通常会通过循环不断地调用ServerSocketaccept()方法。如下代码片段所示

1
2
3
4
5
6
7
8
// 创建一个ServerSocket,用于监听客户端Socket的连接请求
ServerSocket serverSocket = new ServerSocket(30000);
// 采用循环不断接受来自客户端的请求
while (true) {
// 每当接受到客户端Socket的请求,服务器端也对应产生一个Socket
Socket server = serverSocket.accept();
// 下面就可以使用Socket进行通信了
}

上面程序中创建ServerSocket没有指定IP地址,则该ServerSocket将会绑定到本机默认的P地址。程序中使用30000作为该ServerSocket的端口号,通常推荐使用1024以上的端口,主要是为了避免与其他应用程序的通用端口冲突。

17.3 基于TCP协议的网络编程

TCP/IP通信协议是一种可靠的网络协议,它在通信的两端各建立一个Socket,从而在通信的两端之间形成网络虚拟链路。一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。Java对基于TCP协议的网络通信提供了良好的封装,Java使用Socket对象来代表两端的通信端口,并通过Socket产生IO流来进行网络通信

17.3.1 TCP协议基础

IP协议

IP协议是nternet上使用的一个关键协议,它的全称是Internet Protocol,即**Internet协议**,通常简称IP协议。通过使用IP协议,从而使Internet成为一个允许连接不同类型的计算机和不同操作系统的网络。
要使两台计算机彼此能进行通信,必须使两台计算机使用同一种“语言”,IP协议只保证计算机能发送和接收分组数据。IP协议负责将消息从一个主机传送到另一个主机,消息在传送的过程中被分割成一个个的小包。

尽管计算机通过安装IP软件,保证了计算机之间可以发送和接收数据,但IP协议还不能解决数据分组在传输过程中可能出现的问题。因此,若要解决可能出现的问题,连上Internet的计算机还需要安装TCP协议来提供可靠并且无差错的通信服务。

TCP协议

TCP协议被称作一种端对端协议。这是因为它对两台计算机之间的连接起了重要作用,当一台计算机需要与另一台远程计算机连接时,TCP协议会让它们建立一个用于发送和接收数据的虚拟链路.
TCP协议负责收集这些信息包,并将其按适当的次序放好传送,接收端收到后再将其正确地还原TCP协议保证了数据包在传送中准确无误。TCP协议使用重发机制:一当一个通信实体发送一个消息给另一个通信实体后,需要收到另一个通信实体的确认信息,如果没有收到另一个通信实体的确认信息,则会再次重发刚才发送的信息。
通过这种重发机制,TCP协议向应用程序提供了可靠的通信连接,使它能够自动适应网上的各种变化。即使在Internet暂时出现堵塞的情况下,TCP也能够保证通信的可靠性。
图17.4显示了TCP协议控制两个通信实体互相通信的示意图
这里有一张图片
综上所述,虽然P和TCP这两个协议的功能不尽相同,也可以分开单独使用,但它们是在同一时期作为一个协议来设计的,并且在功能上也是互补的。只有两者结合起来,才能保证Internet在复杂的环境下正常运行。凡是要连接到Internet的计算机,都必须同时安装和使用这两个协议,因此在实际中常把这两个协议统称为TCP/IP协议。

17.2.3 URL URLConnection和URLPermission

URL

URL(Uniform Resource Locator)对象代表统一资源定位器,它是指向互联网“资源”的指针。资源可以是简单的文件或目录,也可以是对更为复杂对象的引用,例如对数据库或搜索引擎的查询。在通常情况下,URL可以由协议名主机端口资源组成,即满足如下格式:

URL格式

1
protocol://host:port/resourceName

例如如下的URL地址:

1
https://lanlan2017.github.io/JavaReadingNotes/

URI

JDK中还提供了一个URI(Uniform Resource Identifiers)类,其实例代表一个统一资源标识符,JavaURI不能用于定位任何资源,它的唯一作用就是解析。与此对应的是**URL则包含一个可打开到达该资源的输入流**,可以将URL理解成URI的特例.

URL方法

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,通过该方法可以非常方便地读取远程资源—甚至实现多线程下载。如下程序实现了一个多线程下载工具类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.*;

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();
}
}
}
}

上面程序中定义了DownThread线程类,该线程负责读取从start开始,到end结束的所有字节数据,并写入RandomAccessfile对象。这个DownThread线程类的run()方法就是一个简单的输入、输出实现。
程序中DownUtils类中的download方法负责按如下步骤来实现多线程下载

  1. 创建URL对象。
  2. 获取指定URL对象所指向资源的大小(通过getContentLength()方法获得),此处用到了URLConnection类,该类代表Java应用程序和URL之间的通信链接。后面还有关于URLConnection更详细的介绍。
  3. 在本地磁盘上创建一个与网络资源具有相同大小的空文件。
  4. 计算每个线程应该下载网络资源的哪个部分(从哪个字节开始,到哪个字节结束)
  5. 依次创建、启动多个线程来下载网络资源的指定部分。

如何实现断点下载

上面程序已经实现了多线程下载的核心代码,如果要实现断点下载,则需要额外増加个配置文件(读者可以发现,所有的断点下载工具都会在下载开始时生成两个文件:一个是与网络资源具有相同大小的空文件,一个是配置文件),该配置文件分别记录每个线程已经下载到哪个字节,当网络断开后再次开始下载时,每个线程根据配置文件里记录的位置向后下载即可
有了上面的DownUtils工具类之后,接下来就可以在主程序中调用该工具类的down()方法执行下载,如下程序所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MultiThreadDown {
public static void main(String[] args) throws Exception {
// 初始化DownUtil对象
// final DownUtil downUtil = new DownUtil(
// "http://www.crazyit.org/" +
// "attachments/month_1403/1403202355ff6cc9a4fbf6f14a.png", "ios.png", 4);
String urlStr = "https://lanlan2017.github.io/download/TestJavaDownload_DoNotDelete/TestDownLoad.txt";
String targetFile = "TestDownLoad.md";
final DownUtil downUtil = new DownUtil(urlStr, targetFile, 4);
// 开始下载
downUtil.download();
new Thread(() -> {
while (downUtil.getCompleteRate() < 1) {
// 每隔1秒查询一次任务的完成进度,
// GUI程序中可根据该进度来绘制进度条
System.out.println("已完成:" + (downUtil.getCompleteRate() * 100) + "%");
try {
Thread.sleep(1000);
} catch (Exception ex) {
}
}
System.out.println("已完成:" + "100%");
}).start();

}
}

运行上面程序,即可看到程序从lanlan2017.github.io下载得到一份名为TestDownLoad.md的文本文件。
上面程序还用到URLConnectionHttpURLConnection对象,其中URLConnection表示应用程序和URL之间的通信连接,HttpURLConnection表示与URL之间的HTTP连接。程序可以通过URLConnection实例向该URL发送请求、读取URL引用的资源

URLPermission

Java8新增了一个URLPermission工具类,用于管理HttpURLConnection的权限问题,如果在HttpURLConnection安装了安全管理器,通过该对象打开连接时就需要先获得权限。

通常创建一个和URL的连接,并发送请求、读取此URL引用的资源需要如下几个步骤

  • 通过调用URL对象的openConnection方法来创建URLConnection对象。
  • 设置URLConnection的参数和普通请求属性。
  • 如果只是发送GET方式请求,则使用connect()方法建立和远程资源之间的实际连接即可;
  • 如果需要发送POST方式的请求,则需要获取URLConnection对象对应的输出流来发送请求参数。
  • 远程资源变为可用,程序可以访问远程资源的头字段或通过输入流读取远程资源的数据

URLConnection设置请求头方法

在建立和远程资源的实际连接之前,程序可以通过如下方法来设置请求头字段

方法 描述
void setAllowUserInteraction(boolean allowuserinteraction) 设置该URLConnectionallowUserInteraction请求头字段的值
void setDoInput(boolean doinput) 设置该URLConnectiondoInput请求头字段的值。
void setDoOutput(boolean dooutput) 设置该URLConnectiondoOutput请求头字段的值。
void setIfModifiedSince(long ifModifiedSince) 设置该URLConnectionifModifiedSince请求头字段的值
void setUseCaches(boolean usecaches) 设置该URLConnectionuseCaches请求头字段的值。

URLConnection设置通用请求头

除此之外,还可以使用如下方法来设置或增加通用头字段。

方法 描述
void setRequestProperty(String key, String value) 设置该URLConnectionkey请求头字段的值为value
void addRequestProperty(String key, String value) 为该URLConnectionkey请求头字段增加value值,该方法并不会覆盖原请求头字段的值,而是将新值追加到原请求头字段中。
1
urlConnection.setRequestProperty("accept","*/*");

访问头字段和内容

当远程资源可用之后,程序可以使用以下方法来访问头字段和内容

方法 描述
String getContentEncoding() 获取content-encoding响应头字段的值。
int getContentLength() 获取content-Length响应头字段的值。
String getContentType() 获取content-type响应头字段的值。
long getDate() 获取date响应头字段的值。
long getExpiration() 获取expires响应头字段的值。
long getLastModified() 获取last-modified响应头字段的值。

如果既要使用输入流读取URLConnection响应的内容,又要使用输出流发送请求参数,则一定要先使用输出流,再使用输入流

程序示例 发送GET请求 发送POST请求

下面程序示范了如何向Web站点发送GET请求、POST请求,并从Web站点取得响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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:8080/abc/a.jsp", null);
System.out.println(s);
// 发送POST请求
String s1 = GetPostTest.sendPost("http://localhost:8080/abc/login.jsp", "name=crazyit.org&pass=leegang");
System.out.println(s1);
}
}

如果发送GET请求时,只需将请求参数放在URL字符串之后,以?隔开,然后程序直接调用URLConnection对象的connect()方法即可发送Get请求;
如果程序要发送POST请求,则需要先设置doIndoOut两个请求头字段的值,再使用URLConnection对应的输出流来发送请求参数:

1
2
3
// 发送POST请求必须设置如下两行
conn.setDoOutput(true);
conn.setDoInput(true);

不管是发送GET请求,还是发送POST请求,程序获取URLConnection响应的方式完全一样.如果程序可以确定远程响应是字符流,则可以使用字符流来读取;如果程序无法确定远程响应是字符流则使用字节流读取即可。

上面程序中发送请求的两个URL是部署在本机的Web应用abc,
由于程序可以使用这种方式向服务器发送请求,相当于提交Web应用中的登录表单页,这样就可以让程序不断地变换用户名、密码来提交登录请求,直到返回登录成功,这就是所谓的暴力破解

Web应用abc

该Web应用的源代码如下:

展开/折叠

项目结构

1
2
3
4
5
G:\Desktop\随书源码\疯狂Java讲义(第4版)光盘\codes\17\17.2\abc
├─a.jsp
├─login.jsp
└─WEB-INF\
└─web.xml

a.jsp

1
2
3
4
5
6
7
8
9
10
11
12
<%@ page contentType="text/html; charset=UTF-8" language="java" errorPage="" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title> 测试页面 </title>
<meta name="website" content="http://www.crazyit.org"/>
</head>
<body>
服务器时间为:<%=new java.util.Date()%>
</body>
</html>

login.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<%@ page contentType="text/html; charset=UTF-8" language="java" errorPage="" %>
<%
request.setCharacterEncoding("UTF-8");
String name = request.getParameter("name");
String pass = request.getParameter("pass");
if(name.equals("crazyit.org")
&& pass.equals("leegang"))
{
out.println("登录成功!");
}
else
{
out.println("登录失败!");
}
%>

web.xml

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="ISO-8859-1"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0"
metadata-complete="true">

</web-app>

17.2.2 使用URLDecoder和URLEncoder

URLDecoderURLEncoder用于完成普通字符串和application/x-www-Form-urlencoded MIME字符串之间的相互转换。可能有读者觉得后一个字符串非常专业,以为又是什么特别高深的知识

当地址栏中的关键字包含中文时,这些关键字就会变成”乱码”
例如,在搜索引擎中搜索中秋节这三个中文关键字:
我们在地址栏中看到的是:

1
https://www.baidu.com/s?wd=中秋节

但是如果你复制这个地址,就会得到如图下所示的“乱码”:

1
https://www.baidu.com/s?wd=%E4%B8%AD%E7%A7%8B%E8%8A%82

实际上这不是乱码,这就是所谓的application/x-www-form-urlencoded MIME字符串。

URL地址里包含非西欧字符的字符串时,系统会将这些非西欧字符串转换成类似上面的特殊字符串。编程过程中可能涉及普通字符串和这种特殊字符串的相关转换,这就需要使用URLDecoderURLEncoder类。

  • URLDecoder类包含一个decode(String s, String enc)静态方法,它可以将看上去是乱码的application/x-www-form-urlencode MIME字符串转换成普通字符串。
  • URLEncoder类包含一个encode(String s, String enc)静态方法,它可以将普通字符串转换成application/x-www-form-urlencode MIME字符串。

URLDecoder decode方法

方法 描述
static String decode(String s) Deprecated. The resulting string may vary depending on the platform’s default encoding.
static String decode(String s, String enc) Decodes an application/x-www-form-urlencoded string using a specific encoding scheme.
static String decode(String s, Charset charset) Decodes an application/x-www-form-urlencoded string using a specific Charset.

URLEncoder encode方法

方法 描述
static String encode(String s) Deprecated. The resulting string may vary depending on the platform’s default encoding.
static String encode(String s, String enc) Translates a string into application/x-www-form-urlencoded format using a specific encoding scheme.
static String encode(String s, Charset charset) Translates a string into application/x-www-form-urlencoded format using a specific Charset.

程序示例 MIME字符串和普通字符串的相互转换

下面程序示范了如何将上面的地址栏中的“乱码”转换成普通字符串,并示范了如何将普通字符串转换成application/x-www-fom-urlencode MIME字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.net.*;

public class URLDecoderTest {
public static void main(String[] args) throws Exception {
// 将application/x-www-form-urlencoded字符串转换成普通字符串
String mimeStr = "%E4%B8%AD%E7%A7%8B%E8%8A%82";
String decodeStr = URLDecoder.decode(mimeStr, "utf-8");
System.out.println("解码后得到:" + decodeStr);

// 将普通字符串转换成application/x-www-form-urlencoded字符串
String encodeStr = URLEncoder.encode("中秋节", "UTF-8");
System.out.println("编码后得到:" + encodeStr);
System.out.println(encodeStr.equals(mimeStr));
}
}

上面程序用于完成普通字符串和application/x-www-Form-urlencoded MIME字符串之间的转换。运行上面程序,将看到如下输出:

1
2
3
解码后得到:中秋节
编码后得到:%E4%B8%AD%E7%A7%8B%E8%8A%82
true

哪些字符串需要转换

只有包含西欧字符的普通字符串和application/x-www-fom-urlencode MIME字符串无须转换,而包含中文字符的普通字符串则需要转换,转换方法是每个中文字符占两个字节,每个字节可以转换成两个十六进制的数字,所以每个中文字符将转换成“%XX%XX”的形式。当然,采用不同的字符集时,毎个中文字符对应的字节数并不完全相同,所以使用URLEncoderURLDecoder进行转换时也需要指定字符集。

17.2 Java的基本网络支持

Java为网络支持提供了java.net包,该包下的

  • URLURLConnection等类提供了以编程方式访问Web服务的功能,
  • URLDecoderURLEncoder则提供了普通字符串和application/x-www-form-urlencoded MIME字符串相互转换的静态方法.

17.2.1 使用InetAddress

Java提供了InetAddress类来代表IP地址,InetAddress下还有两个子类:Inet4AddressInet6Adress它们分别代表Internet Protocol version 4(IPv4)地址和Internet Protocol version 6(IP6)地址。

InetAddress

获取InetAddress类对象

InetAddress类没有提供构造器,而是提供了如下两个静态方法来获取InetAddress实例

方法 描述
static InetAddress getByName(String host) 根据主机获取对应的InetAddress对象
static InetAddress getByAddress(byte[] addr) 根据原始IP地址来获取对应的InetAddress对象

除此之外,InetAddress类还提供了一个getLocalHost()方法来获取本机IP地址对应的InetAddress实例

读取InetAddress对象的IP地址和主机名

InetAddress还提供了如下三个方法来获取InetAddress实例对应的IP地址和主机名。

方法 描述
String getCanonicalHostName() 获取此IP地址的全限定域名。
String getHostAddress() 返回该InetAddress实例对应的IP地址字符串(以字符串形式)。
String getHostName() 获取此IP地址的主机名

判断IP地址是否可达

Inetaddress类还提供了一个isReachable()方法,用于测试是否可以到达该地址。该方法将尽最大努力试图到达主机,但防火墙和服务器配置可能阻塞请求,使得它在访问某些特定的端口时处于不可达状态。如果可以获得权限,典型的实现将使用ICMP ECHO REQUEST;否则它将试图在目标主机的端口7(Echo)上建立TCP连接。

程序示例

下面程序测试了InetAddress类的简单用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.net.*;

public class InetAddressTest {
public static void main(String[] args) throws Exception {
// 根据主机名来获取对应的InetAddress实例
InetAddress ip = InetAddress.getByName("lanlan2017.github.io");
// 判断是否可达
System.out.println("lanlan2017.github.io是否可达:" + 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());
}
}

上面程序简单地示范了Inetaddress类的几个方法的用法,InetAddress类本身并没有提供太多功能,它代表一个IP地址对象,是网络通信的基础,在后面介绍中将大量使用该类