Java-网络编程

基础概念

Java 网络API 允许我们通过套接字(Socket)打开或关闭网络连接,但所有的网络通信均是基于Java IO 类 InputStream 和OutputStream 实现的。

此外,我们还可以使用Java NIO API中相关的网络类,用法与Java网络API基本类似,Java NIO API可以以非阻塞模式工作,在某些特定的场景中使用非阻塞模式可以获得较大的性能提升。

Java TCP网络基础

通常情况下,客户端打开一个连接到服务器端的TCP/IP 连接,然后客户端开始与服务器之间通信,当通信结束后客户端关闭连接。

​客户端通过一个已打开的连接可以发送不止一个请求。事实上在服务器处于接收状态下,客户端可以发送尽可能多的数据,服务器也可以主动关闭连接。

Java中Socket类和ServerSocket类

当客户端想要打开一个连接到服务器的TCP/IP连接时,就要使用到Java Socket类。socket类只需要被告知连接的IP地址和TCP端口,其余的都有Java实现。

假如我们想要打开一个监听服务,来监听客户端连接某些指定TCP端口的连接,那就需要使用Java ServerSocket类。当客户端通过Socket连接服务器端的ServerSocket监听时,服务器端会指定这个连接的一个Socket,此时客户端与服务器端间的通信就变成Socket与Socket之间的通信。

关于Socket类和ServerSocket类会在后面的文章中有详细的介绍。

Java UDP网络基础

UDP的工作方式与TCP相比略有不同。使用UDP通信时,在客户端与服务器之间并没有建立连接的概念,客户端发送到服务器的数据,服务器可能(也可能并没有)收到这些数据,而且客户端也并不知道这些数据是否被服务器成功接收。当服务器向客户端发送数据时也是如此。

正因为是不可靠的数据传输,UDP相比与TCP来说少了很多的协议开销。

在某些场景中,使用无连接的UDP要优于TCP,这些在文章Java UDP DatagramSocket类介绍中会有更多介绍。


Socket、ServerSocket

当我们想要在Java中使用TCP/IP通过网络连接到服务器时,就需要创建java.net.Socket对象并连接到服务器。假如希望使用Java NIO,也可以创建Java NIO中的SocketChannel对象。

创建Socket

1
2
3
Socket socket = new Socket("78.46.84.171", 80);
// 我们也可以像如下示例中使用域名代替IP地址:
Socket socket = new Socket("vgbhfive.cn", 80);

Socket发送数据

要通过Socket发送数据,我们需要获取Socket的输出流(OutputStream),示例代码如下:

1
2
3
4
5
6
7
8
Socket socket = new Socket("jenkov.com", 80);
OutputStream out = socket.getOutputStream();

out.write("some data".getBytes());
out.flush();
out.close();

socket.close();

代码非常简单,但是想要通过网络将数据发送到服务器端,一定不要忘记调用flush()方法。操作系统底层的TCP/IP实现会先将数据放入一个更大的数据缓存块中,而缓存块的大小是与TCP/IP的数据包大小相适应的。(译者注:调用flush()方法只是将数据写入操作系统缓存中,并不保证数据会立即发送)

Socket读取数据

从Socket中读取数据,我们就需要获取Socket的输入流(InputStream),代码如下:

1
2
3
4
5
6
7
8
Socket socket = new Socket("jenkov.com", 80);
InputStream in = socket.getInputStream();

int data = in.read();
//... read more data...

in.close();
socket.close();

代码也并不复杂,但需要注意的是,从Socket的输入流中读取数据并不能读取文件那样,一直调用read()方法直到返回-1为止,因为对Socket而言,只有当服务端关闭连接时,Socket的输入流才会返回-1,而是事实上服务器并不会不停地关闭连接。假设我们想要通过一个连接发送多个请求,那么在这种情况下关闭连接就显得非常愚蠢。

因此,从Socket的输入流中读取数据时我们必须要知道需要读取的字节数,这可以通过让服务器在数据中告知发送了多少字节来实现,也可以采用在数据末尾设置特殊字符标记的方式连实现。

关闭Socket

当使用完Socket后我们必须将Socket关闭,断开与服务器之间的连接。关闭Socket只需要调用Socket.close()方法即可,代码如下:

1
2
Socket socket = new Socket("vgbhfive.cn", 80); 
socket.close();

Demo

https://github.com/vgbhfive/VChatDemo


UDP DatagramSocket

DatagramSocket 是Java 通过UDP(而非TCP)进行网络通信的机制。UDP 位于IP 之上。
使用Java 的DatagramSocket 来发送和接收UPD 数据包(数据报)。

UDP、TCP

UDP 的工作方式与TCP 有所不同。
通过TCP 发送数据时,首先要创建一个连接,建立TCP 连接后,TCP 会确保您的数据到达另一端,否则它将另一端告诉发生了错误。

使用UDP,只需将数据包(数据报)发送到网络上的某个IP 地址。但是不能保证数据会到达,并且也无法保证UDP 数据包到达接收方的顺序。
这意味着UDP 比TCP 具有更少的协议开销(无流完整性检查)。

UDP 适用于数据传输,在传输过程中是否丢失数据包并不重要。
例如,想象一下通过互联网传输实况电视信号。您希望信号尽可能接近现场。因此,如果丢失了一两帧,则不必担心,最实际的是不希望延迟直播,只是确保所有帧都在客户端显示。宁愿跳过错过的帧,并始终直接移至最新的帧。

DatagramSocket

通过DatagramSocket 发送数据,要通过Java DatagramSocket 发送数据则必须先创建一个DatagramPacket 。

1
2
3
4
byte[] buffer = new byte[65508];
InetAddress address= InetAddress.getByName("test.com");

DatagramPacket packet = new DatagramPacket(buffer, buffer.length, address, 9000);

字节缓冲区(字节数组)是在UDP 数据报中发送的数据。
在DatagramPacket 构造函数的长度是要发送的缓冲区中数据的长度。在该数据量之后,缓冲区中的所有数据将被忽略。
在InetAddress 实例中包含要向其发送UDP 数据包的节点(例如服务器)的地址。这个InetAddress 类代表一个IP地址(Internet地址)。
这个getByName() 方法返回一个InetAddress 实例,该实例的IP 地址与给定的主机名匹配。
port 参数是服务器接收数据的UDP 端口。

UDP和TCP端口不相同。一台计算机可以具有不同的进程,例如同时侦听UDP和TCP中的端口80。
缓冲区的长度为65508字节,是您可以在单个UDP数据包中发送的最大数据量。

DatagramSocket.send()

发送数据,DatagramPacket 必须创建一个DatagramSocket 针对发送数据的目标。

1
DatagramSocket datagramSocket = new DatagramSocket();

要发送数据,请调用send()方法,如下所示:

1
datagramSocket.send(packet);

Demo:

1
2
3
4
5
6
DatagramSocket datagramSocket = new DatagramSocket();
byte[] buffer = "0123456789".getBytes();
InetAddress address = InetAddress.getLocalHost();

DatagramPacket packet = new DatagramPacket(buffer, buffer.length, address, 80);
datagramSocket.send(packet);

DatagramSocket.receive()

接收数据是通过DatagramSocket 建立一个完成DatagramPacket ,然后通过将数据接收到它DatagramSocket 的receive() 方法。

1
2
3
4
DatagramSocket datagramSocket = new DatagramSocket(80);
byte[] buffer = new byte[10];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
datagramSocket.receive(packet);
  • 首先,80 是DatagramSocket 用于接收UDP 数据包的UDP 端口。如前所述,TCP 和UDP 端口不相同,因此不会重叠。因此可以有两个不同的进程在TCP 和UDP 端口80 侦听,而不会发生任何冲突。
  • 其次,DatagramPacket 创建一个字节缓冲区和一个DatagramPacket。
    DatagramPacket 与用于发送数据的节点一样,该节点没有关于要向其发送数据的节点的信息。这是因为我们将使用DatagramPacket来接收数据,而不是发送数据。因此,不需要目的地地址。
  • 最后,调用DatagramSocket 的receive() 方法。此方法会阻塞直到DatagramPacket 收到数据为止。收到的数据位于DatagramPacket 的字节缓冲区中。
    通过调用以下方法获得此缓冲区:
    1
    byte[] buffer = packet.getData();  
    如果需要确定缓冲区中接收了多少数据。那么就需要在使用的协议中指定每个UDP数据包发送多少数据,或者指定数据的结束标记。

真正的服务器程序可能会receive() 在循环中调用该方法,并将所有接收到的DatagramPacket 传递给工作线程池,就像TCP 服务器处理传入连接一样。


URL + URLConnection

在java.net包中包含两个有趣的类:URL类和URLConnection类。这两个类可以用来创建客户端到web服务器(HTTP服务器)的连接。
下面是一个简单的代码例子:

1
2
3
4
5
6
7
8
9
URL url = new URL("https://vgbhfive.cn");
URLConnection urlConnection = url.openConnection();
InputStream input = urlConnection.getInputStream();
int data = input.read();
while(data != -1){
System.out.print((char) data);
data = input.read();
}
input.close();

HTTP GET和POST

默认情况下URLConnection发送一个HTTP GET请求到web服务器。如果你想发送一个HTTP POST请求,要调用URLConnection.setDoOutput(true)方法,
如下:

1
2
3
URL url = new URL("https://vgbhfive.cn");
URLConnection urlConnection = url.openConnection();
urlConnection.setDoOutput(true);

一旦你调用了setDoOutput(true),你就可以打开URLConnection的OutputStream,如下:

1
OutputStream output = urlConnection.getOutputStream();

你可以使用这个OutputStream向相应的HTTP请求中写任何数据,但你要记得将其转换成URL编码(关于URL编码的解释,自行Google)(译者注:具体名字是:application/x-www-form-urlencoded MIME 格式编码)。
当你写完数据的时候要记得关闭OutputStream。

从URLs到本地文件

URL也被叫做统一资源定位符。如果你的代码不关心文件是来自网络还是来自本地文件系统,URL类是另外一种打开文件的方式。
下面是一个如何使用URL类打开一个本地文件系统文件的例子:

1
2
3
4
5
6
7
8
9
URL url = new URL("file:/c:/data/test.txt");
URLConnection urlConnection = url.openConnection();
InputStream input = urlConnection.getInputStream();
int data = input.read();
while(data != -1){
System.out.print((char) data);
data = input.read();
}
input.close();

注意:这和通过HTTP访问一个web服务器上的文件的唯一不同处就是URL:”file:/c:/data/test.txt”。


JarURLConnection

Java的JarURLConnection类用来连接Java Jar文件。一旦连接上,你可以获取Jar文件的信息。
一个简单的例子如下:

1
2
3
4
5
6
7
8
9
10
String urlString = "http://butterfly.jenkov.com/"
+ "container/download/"
+ "jenkov-butterfly-container-2.9.9-beta.jar";
URL jarUrl = new URL(urlString);
JarURLConnection connection = new JarURLConnection(jarUrl);

Manifest manifest = connection.getManifest();

JarFile jarFile = connection.getJarFile();
//do something with Jar file...

InetAddress

  • 创建一个 InetAddress 实例
  • InetAddress 的内部方法
    InetAddress 是 Java 对 IP 地址的封装。这个类的实例经常和 UDP DatagramSockets 和 Socket,ServerSocket 类一起使用。

创建一个 InetAddress 实例

InetAddress 没有公开的构造方法,因此你必须通过一系列静态方法中的某一个来获取它的实例。

下面是为一个域名实例化 InetAddres 类的例子:

1
InetAddress address = InetAddress.getByName("jenkov.com");

当然也会有为匹配某个 IP 地址来实例化一个 InetAddress:

1
InetAddress address = InetAddress.getByName("78.46.84.171");

另外,它还有通过获取本地 IP 地址的来获取 InetAddress 的方法(正在运行程序的那台机器)

1
InetAddress address = InetAddress.getLocalHost();

InetAddress 内部方法

InetAddress 类还拥有大量你可以调用的其它方法。例如:你可以通过调用getAddress()方法来获取 IP 地址的 byte 数组。如果要了解更多的方法,最简单的方式就是读 JavaDoc 文档中关于 InetAddress 类的部分。


Protocol Design

如果设计一个客户端到服务器的系统,那么同时也需要设计客户端和服务器之间的通信协议。当然,有时候协议已经为你决定好了,比如HTTP、XML_RPC(http response 的 body 使用xml)、或者SOAP(也是http response 的 body 使用xml)。设计客户端到服务端协议的时候,一旦协议决定开启一会儿,来看一些你必须考虑的地方:

  1. 客户端到服务端的往返通讯
  2. 区分请求结束和响应结束。
  3. 防火墙穿透

客户端-服务端往返

当客户端和服务端通信,执行操作时,他们在交换信息。比如,客户端执行一个服务请求,服务端尝试完成这个请求,发回响应告诉客户端结果。这种客户端和服务端的信息交换就叫做往返。
示意图如下:
xxx.jpeg

当一个计算机(客户端或者服务端)在网络中发送数据到另一个计算机时,从数据发送到另一端接收数据完会花费一定时间。这就是数据在网络间的传送的时间花费。这个时间叫做延迟。
协议中含有越多的往返,协议变得越慢,延迟特别高。HTTP协议只包含一个单独的响应来执行服务。换句话说就是一个单独的往返。另一方面,在一封邮件发送前,SMTP协议包含了几个客户端和服务端的往返。
在协议中有多个往返的原因是:有大量的数据从客户端发送到服务端。

这种情况下你有2个选择:

  1. 在分开往返中发送头信息;
  2. 将消息分成更小的数据块。

如果服务端能完成头信息的一些初始验证 ,那么分开发送头信息是很明智的。如果头信息是空白的,发送大量数据本身就是浪费资源。
在传输大量数据时,如果网络连接失败了,得从头开始重新发送数据。数据分割发送时,只需要在网络连接失败处重新发送数据块。已经发送成功的数据块不需要重新发送。

区分请求结束和响应结束

如果协议容许在同一个连接中发送多个请求,需要一个让服务端知道当前请求何时结束、下一个请求何时开始。客户端也需要知道一个响应何时结束了,下一个响应何时开始。

对于请求有2个方法区分结束:

  1. 在请求的开始处发送请求的字长
  2. 在请求数据的最后发送一个结束标记。

HTTP用第一个机制。在请求头中 发送了“Content-Length”。请求头会告诉服务端在头文件后有多少字节是属于请求的。
这个模型的优势在于没有请求结束标志的开销。为了避免数据看上去像请求结束标志,也不需要对数据体进行编码。
第一个方法的劣势:在数据传输前,发送者必须知道多少字节数将被传输。如果数据时动态生成的,在发送前,首先你得缓存所有的数据,这样才能计算出数据的字节数。
运用请求结束标志时,不需要知道发送了多少字节数。只需要知道请求结束标志在数据的末尾。当然,必须确认已发送的数据中不包含会导致请求结束标志错误的数据。可以这样做:
可以说请求结束标志是字节值255。当然数据可能包含值255。因此,对数据中包含值255的每一个字节添加一个额外的字节,还有值255。结束请求标志被从字节值255到255之后的值为0。

如下编码:

1
2
3
255 in data –>255, 255

end-of-request –> 255, 0

这种255,0的序列永远不会出现在数据中,因为你把所有的值255变成了255,255。同时,255,255,0也不会被错认为255,0。255,255被理解成在一起的,0是单独的。

防火墙穿透

比起HTTP协议,大多数防火墙会拦截所有的其他通信。因此把协议放在HTTP的上层是个好方法,像XML-RPC,SOAP和REST也可以这样做。

协议置于HTTP的上层,在客户端和服务端的HTTP请求和响应中可以来回发送数据。记住,HTTP请求和响应不止包含text或者HTML。也可以在里面发送二进制数据。

将请求放置在HTTP协议上,唯一有点奇怪的是:HTTP请求必须包含一个“主机”头字段。如果你在HTTP协议上设计P2P协议,同样的人最可能不会运行多个“主机”。在这种情况下需要头字段是不必要的开销(但是个小开销)。


引用

http://tutorials.jenkov.com/java-networking/udp-datagram-sockets.html
http://ifeve.com/


个人备注

此博客内容均为作者学习所做笔记,侵删!
若转作其他用途,请注明来源!