基于UDP的Ping程序(Java实现)

背景

计算机网络的课程设计,我选的题目如下:

1.编程实现PING的服务器端和客户端,实现操作系统提供的ping命令的类似功能。
2.服务器端PingServer功能:
2.1 可以并发地为多个用户服务;
2.2 显示用户通过客户端发送来的消息内容(包含头部和payload);
2.3 能够模拟分组的丢失;能够模拟分组传输延迟;
2.4 将用户发送来的请求request在延迟一段随机选择的时间(小于1s)后返回给客户端,作为收到请求的响应reply;
2.5 通过如下命令行启动服务器:java PingServer port。port为PingServer的工作端口号
3.客户端PingClient功能:
3.1启动后发送10个request。发送一个request后,最多等待1秒以便接收PingServer返回的reply消息。如果在该时间内没有收到服务器的reply,则认为该请求或对该请求的reply已经丢失;在收到reply后立即发送下一个request。
3.2请求消息的payload中至少包含关键字PingUDP、序号、时间戳等内容。如:PingUDP SequenceNumber TimeStamp CRLF
其中:CRLF表示回车换行符(0X0D0A);TimeStamp为发送该消息的机器时间。
3.3 为每个请求计算折返时间(RTT),统计10个请求的平均RTT、最大/小RTT。
3.4 通过如下命令行启动:java PingClient host port。host为PingServer所在的主机地址;port为PingServer的工作端口号

完成后的源码

服务器端代码

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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AlreadyBoundException;
import java.nio.channels.DatagramChannel;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 2.服务器端PingServer功能
* 2.1 可以并发地为多个用户服务
* 2.2 显示用户通过客户端发送来的消息内容(包含头部和payload);
* 2.3 能够模拟分组的丢失;能够模拟分组传输延迟;
* 2.4 将用户发送来的请求request在延迟一段随机选择的时间(小于1s)后返回给客户端,作为收到请求的响应reply;
* 2.5 通过如下命令行启动服务器:java PingServer port。port为PingServer的工作端口号
*/
public class PingServer {
private DatagramChannel channel;
private InetSocketAddress serverAddress;
private InetSocketAddress clientAddress;
public PingServer(int port) throws IOException{
if (port < 1024 || port > 65535) {
showUsageAndExit("invalid port number !");
}
serverAddress = new InetSocketAddress(port);
channel = DatagramChannel.open();
try {
channel.bind(serverAddress);
} catch (Exception e) {
showUsageAndExit("bind port "+port+" failed, may be it has been used, please choose another port !");
}
//设置为阻塞模式
channel.configureBlocking(true);
}
public void execute() throws IOException{
System.out.println("----- Ping Server Running -----");
//创建线程池
ExecutorService pool = Executors.newCachedThreadPool();
// 一直监听是否有请求
while (true) {
ByteBuffer recvBuff = ByteBuffer.allocate(50);
//程序阻塞直到有请求到达
clientAddress = (InetSocketAddress) channel.receive(recvBuff);
EchoPing task = new EchoPing(recvBuff, clientAddress);
pool.submit(task);
}
}
private static void showUsageAndExit(String errMsg) {
if (errMsg != null && errMsg.length() != 0) {
System.out.println(errMsg);
}
System.out.println("usage: java PingServer <port>");
System.exit(1);
}
private class EchoPing extends Thread{
private ByteBuffer receivedData;
private ByteBuffer sendBuff;
private InetSocketAddress client;
//分组丢失比率
private final double lossRate = 0.5;
public EchoPing(ByteBuffer recv, InetSocketAddress client) {
receivedData = recv;
this.client = client;
}
@Override
public void run() {
//用于模拟分组延迟和分组丢失,如果大于1000,则不处理接收到的socket
long randomDelay = (long) (Math.random() * 1000 * (1 + lossRate));
if (randomDelay < 1000) {//只处理延迟小于1秒的socket
if(!echoMessage(receivedData)){
System.out.println("received invalid data, it will be omitted");
return;
}
try {
Thread.sleep(randomDelay);
//将客户端数据报的类型字段改为0,代表ping的回复,然后返回给客户端
byte[] oldData=receivedData.array();
oldData[0] = 0;
sendBuff = ByteBuffer.wrap(oldData);
channel.send(sendBuff, client);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 由PingClient中定义的数据报格式得到其头部以及payload并显示
*
* 报文格式:类型(8) | 代码(0) | 校验和
* 标识符(pid) | 序号
* 时间戳前32bit
* 时间戳后32bit
* 剩余有效信息字节数 |
* "PingUDP"+"CRLF"+填充字节
* Note: 采用big-ending顺序,即JVM与网络都使用的字节序
* @param receivedData
* @return 返回false当且仅当ensureNoErr返回false
*/
private boolean echoMessage(ByteBuffer receivedData) {
String crlf=System.lineSeparator();
receivedData.flip();
byte[] usefulData = new byte[receivedData.limit()];
byte[] wholeBuff=receivedData.array();
System.arraycopy(wholeBuff, 0, usefulData, 0, usefulData.length);
boolean noErr=ensureNoErr(usefulData);
if(!noErr)return false;
//没有错误则显示数据
StringBuilder builder = new StringBuilder();
builder.append("Header: "+crlf);
builder.append("\tType: 8"+crlf);
builder.append("\tCode: 0"+crlf);
builder.append("\tCheckSum: " + bytes2Num(usefulData, 2, 2)+crlf);
//以上为header部分,以下为Payload部分
builder.append("Payload: "+crlf);
builder.append("\tClient-PID: " + bytes2Num(usefulData, 4, 2)+crlf);
builder.append("\tRequest-Seq: " + bytes2Num(usefulData, 6, 2)+crlf);
long time = bytes2Num(usefulData, 8, 8);
String date = new SimpleDateFormat("yyyy/dd/MM HH:mm:ss").format(new Date(time));
builder.append("\tTimeStamp: " + time + " (" + date + ")"+crlf);
//计算出除了填充字节还有多少字节是组成剩下的payload的
int byteNum = (int)bytes2Num(usefulData, 16, 2);
builder.append("\tOther: "+new String(usefulData, 18, byteNum));
System.out.print(builder.toString());
return true;
}
/**
* 从数据报的前四个字节检验数据报的格式是否合法以及校验和是否无误
* @param usefulData
*/
private boolean ensureNoErr(byte[] usefulData) {
if (usefulData[0] == (byte) 8 && usefulData[1] == (byte) 0) {
return getCheckSum(usefulData) == 0;
}
return false;
}
/**
* 由byte数组得到其校验和
* @param buf 数据
* @return 返回校验和,在long型返回值的低16位
*/
private long getCheckSum(byte[] buf) {
int i = 0;
int length = buf.length;
long sum = 0;//checksum只占sum的低16位
//由于sum为long型,下面的加法运算都不会导致符号位改变,等价于无符号加法
while (length > 0) {
sum += (buf[i++] & 0xff) << 8;//与checksum的高8位相加
if ((--length) == 0) break;// 如果buf的byte个数不为偶数
sum += (buf[i++] & 0xff); //与checksum的低8位相加
--length;
}
//处理溢出,将sum的从右往左第二个16位与其第一个16位(最右的16位)相加并取反
return (~((sum & 0xFFFF) + (sum >> 16))) & 0xFFFF;
}
/**
* 将byte数组的一部分byte转换成long
* @param b
* @param begin 开始字节的索引
* @param len 转换的字节数
* @return
*/
private long bytes2Num(byte[] b,int begin,int len) {
int left=len;
long sum = 0;
for (int i = begin; i < begin + len; i++) {
long n = ((int) b[i]) & 0xff;
n <<= (--left) * 8;
sum += n;
}
return sum;
}
}
public static void main(String[] args) throws IOException {
if (args.length != 1) {
showUsageAndExit("");
}
int port = 0;
try {
port = Integer.parseInt(args[0]);
} catch (NumberFormatException nfe) {
showUsageAndExit("invalid port number !");
}
PingServer pingServer =new PingServer(port);
pingServer.execute();
}
}

客户端代码

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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
/**
* 3.客户端PingClient功能:
* 3.1启动后发送10个request。发送一个request后,最多等待1秒以便接收PingServer返回的reply消息。
* 如果在该时间内没有收到服务器的reply,则认为该请求或对该请求的reply已经丢失;在收到reply后立即发送下一个request。
* 3.2请求消息的payload中至少包含关键字PingUDP、序号、时间戳等内容。如:PingUDP SequenceNumber TimeStamp CRLF
* 其中:CRLF表示回车换行符(0X0D0A);TimeStamp为发送该消息的机器时间。
* 3.3 为每个请求计算折返时间(RTT),统计10个请求的平均RTT、最大/小RTT。
* 3.4 通过如下命令行启动:java PingClient host port。host为PingServer所在的主机地址;port为PingServer的工作端口号
*/
public class PingClient {
private DatagramChannel channel;
private InetSocketAddress address;
private InetAddress host;
private double avgRTT ;
private long minRTT ;
private long maxRTT ;
private int packetSend = 0;
private int packetRec = 0;
private int packetLost = 0;
private long sum_time = 0;
public PingClient(String host, int port) {
try {
this.host = InetAddress.getByName(host);
} catch (UnknownHostException e) {
showUsageAndExit("invalid host name !");
}
if (port < 1024 || port > 65535) {
showUsageAndExit("invalid port number !");
}
address = new InetSocketAddress(this.host, port);
}
public void close() throws IOException{
if (channel != null && channel.isOpen()) {
channel.close();
}
}
public void echoResult() {
int lossRate = (int)(packetLost*100.0 / packetSend);
System.out.println("----- Ping Statistics -----");
System.out.println( packetSend + " packets transmitted, " + packetRec + " packets received, " + packetLost + " packets lost. ("+lossRate+"% lost)");
System.out.println("RTT(ms) MIN = " + minRTT + ", AVG = " + avgRTT + ", MAX = " + maxRTT);
}
public void execute() throws IOException {
System.out.println("----- Pinging "+address.getHostString()+" -----");
channel = DatagramChannel.open();
//设置为非阻塞模式
channel.configureBlocking(false);
ByteBuffer sendBuff;
ByteBuffer recvBuff;
short seq = 0;
while (seq < 10) {//一共发十次请求
long start_time = System.currentTimeMillis();
byte[] packet =getPacket(seq,start_time);
sendBuff = ByteBuffer.wrap(packet);
recvBuff = ByteBuffer.allocate(50);
//发送UDP数据报
channel.send(sendBuff, address);
packetSend++;
//等待回复,直到超时
long used_time=waitRecv(channel,recvBuff);
byte[] receiced=recvBuff.array();
//根据返回值判断是否超时
if (used_time == -1) {
System.out.println("PING request " + seq + " time out.");
packetLost++;
seq++;
} else{
//判断收到的回复的确是本程序需要的本次请求的Ping回复
if (!isValidAnswer(receiced,seq)) {
System.out.println("Server Error, exiting ...");
System.exit(1);
}
// 计算总时间和三种 RTT
sum_time += used_time;
if (seq == 0) {
minRTT = used_time;
maxRTT = used_time;
avgRTT = used_time;
} else {
if (used_time < minRTT) minRTT = used_time;
if (used_time > maxRTT) maxRTT = used_time;
//计算平均 RTT
avgRTT = round((double) sum_time / (double) packetRec);
}
System.out.println("PING request " + seq +" RTT(ms): "+used_time);
packetRec++;
seq++;
}
}
echoResult();
}
private boolean isValidAnswer(byte[] receiced, short seq) {
if (receiced[0] != 0 || receiced[1] != 0) {//确保ping的回复报文应该第一个字段和第二个字段都为0
return false;
}
if (bytes2Num(receiced, 4, 2) != getPid() || bytes2Num(receiced, 6, 2) != seq) {
//确保这个数据报对应的请求是本程序发出的且是本次请求的回复
return false;
}
return true;
}
/**
* 将byte数组的一部分byte转换成long
* @param b
* @param begin 开始字节的索引
* @param len 转换的字节数
* @return 除了long,其余整数类型都不负责符号位转换,如果是负数,由调用者负责对返回值强制转换得到
*/
private long bytes2Num(byte[] b,int begin,int len) {
int left=len;
long sum = 0;
for (int i = begin; i < begin + len; i++) {
long n = ((int) b[i]) & 0xff;
n <<= (--left) * 8;
sum += n;
}
return sum;
}
/**
* 发送的报文格式:类型(8) | 代码(0) | 校验和
* 标识符(pid) | 序号
* 时间戳前32bit
* 时间戳后32bit
* 剩余有效信息字节数 |
* "PingUDP"+"CRLF"+填充字节
* Note: 采用big-ending顺序,即JVM与网络都使用的字节序
* @param seq ICMP数据报序号
* @param timestamp 当前时间戳
* @return 组装好的ICMP数据报
*/
private static byte[] getPacket(short seq, long timestamp) {
byte[] firstLine = new byte[]{8, 0, 0, 0};
byte[] seqBs = toBytes(seq, 2);// 序号字节数组
byte[] tsBs = toBytes(timestamp, 8);// 时间戳字节数组
byte[] pidBs = toBytes(getPid(), 2);// 标识符字节数组
String tmp = "PingUDP" + System.lineSeparator();
byte[] tmpbs = tmp.getBytes();
byte[] validBytes = toBytes(tmpbs.length, 2);
int toAdd;//需要填充的字节数
byte[] other;//"PingUDP"+"CRLF"+填充字节
if ((toAdd = tmpbs.length % 4) != 0) {//如果不是四的整数倍的字节
other = new byte[tmpbs.length + toAdd];
System.arraycopy(tmpbs, 0, other, 0, tmpbs.length);
} else {
other = tmpbs;
}
byte[] packet = new byte[18 + other.length];
//将除了校验和的其他字段复制到packet中
copyArray(packet, firstLine, pidBs, seqBs, tsBs, validBytes, other);
//计算校验和
long checkSum = getCheckSum(packet);
//填充packet的checksum字段
byte[] cs = toBytes(checkSum, 2);
System.arraycopy(cs, 0, packet, 2, cs.length);
return packet;
}
/**
* 将多个byte[]的所有数据复制到target中
* @param srcs 来源数组
* @param target
*/
private static void copyArray(byte[] target,byte[] ... srcs) {
int hasCopied=0;
for (byte[] src : srcs) {
System.arraycopy(src, 0, target, hasCopied, src.length);
hasCopied += src.length;
}
}
/**
* 由byte数组得到其校验和
* @param buf 数据
* @return 返回校验和,在long型返回值的低16位
*/
private static long getCheckSum(byte[] buf) {
int i = 0;
int length = buf.length;
long sum = 0;//checksum只占sum的低16位
//由于sum为long型,下面的加法运算都不会导致符号位改变,等价于无符号加法
while (length > 0) {
sum += (buf[i++] & 0xff) << 8;//与checksum的高8位相加
if ((--length) == 0) break;// 如果buf的byte个数不为偶数
sum += (buf[i++] & 0xff); //与checksum的低8位相加
--length;
}
//处理溢出,将sum的从右往左第二个16位与其第一个16位(最右的16位)相加并取反
return (~((sum & 0xFFFF) + (sum >> 16))) & 0xFFFF;
}
/**
* 取value的后几个字节放入byte[]中
* @param value
* @param len 指定value的后len个字节
* @return
*/
private static byte[] toBytes(long value, int len) {
byte[] b = new byte[len];
for (int i = 0; i < len; i++) {
b[len - i - 1] = (byte) (value >> 8 * i);
}
return b;
}
/**
*
* @param x
* @return 将参数x保留小数点后两位
*/
private static double round(double x) {
return Math.round(x * 100.0)/100.0;
}
/**
*
* @return 当前JVM的进程id
*/
private static short getPid() {
// 获取代表当前JVM的字符串
String name = ManagementFactory.getRuntimeMXBean().getName();
// 获得pid
String pid = name.split("@")[0];
return Short.parseShort(pid);
}
/**
*
* @param channel 连接服务器套接字的channel
* @param recvBuff 接收回复数据的ByteBuffer
* @return 收到回复的时间。如果超时,返回-1;
* @throws IOException
*/
private long waitRecv(DatagramChannel channel, ByteBuffer recvBuff) throws IOException {
SocketAddress server;
final long timeout = 1000;
long start_time = System.currentTimeMillis();
long used_time;
while ((used_time=System.currentTimeMillis() - start_time) < timeout) {//time out前一直循环,尝试接收回复
server = channel.receive(recvBuff);
if (server != null) {//如果收到信息
return used_time;
}
}
//超时后返回-1,代表在规定时间内没有收到回复
return -1;
}
private static void showUsageAndExit(String errMsg) {
if (errMsg != null && errMsg.length() != 0) {
System.out.println(errMsg);
}
System.out.println("usage: java PingClient <host> <port>");
System.exit(1);
}
public static void main(String[] args) throws IOException {
if (args.length != 2) {
showUsageAndExit("");
}
int port = 0;
try {
port = Integer.parseInt(args[1]);
} catch (NumberFormatException nfe) {
showUsageAndExit("invalid port number !");
}
PingClient pingClient = new PingClient(args[0], port);
pingClient.execute();
pingClient.close();
}
}

实现过程的难点、疑惑以及思考

下面是我遇到的一些问题以及解决方案,还有自己的一些思考

不清楚报文格式

不清楚ICMP头部以及payload的格式,甚至连payload都不知道指的是什么。。然后查了别人的博客才清楚,也可以参考RFC的文档RFC792

客户端控制最多等待1秒

题目有要求客户端最多对每个ping请求报文等待1秒,然而,BIO为阻塞IO,并不能主动控制阻塞时间。即如果用BIO实现,则需要等到收到应答才能判断是否超时(判断是否大于1秒),假如服务器一直没有回应,将会让程序一直在接收语句处阻塞。为了解决这一问题,可以使用NIO(Non-Blocking IO)来解决。NIO为非阻塞IO,可以在非阻塞模式下接收数据报,因此程序可以加入计时功能的代码,在达到一秒后主动结束等待。关于NIO的知识可以见我之前写的NIO的系列文章

Java实现校验和的计算

校验和算法我是了解的,网上也能够找到c语言的实现,然而就是觉得写起来很烦,毕竟要进行位操作,所以就到StackOverflow上面借(chao)鉴(xi)了别人的实现,详情见StackOverflow

对真正Ping程序的实现以及数据报在整个过程的传输过程感到疑惑

这是令我最头大的一个问题,可以说我对Ping程序运行过程的数据报传输经过是一头雾水。为什么?因为我现在使用的是UDP来模拟用ICMP协议进行通信,但是ICMP可是网络层的协议啊,也就是说Ping的真正实现中是不需要用到传输层的UDP和TCP协议的,那么应该如何实现?由于这个时候我对Socket的理解仅停留在只有UDP Socket和TCP Socket的层面,所以这个问题我纠结了很久。

查了一些资料之后,我发现有人用raw Socket来实现Ping,于是开始去了解raw Socket的有关内容。可以参考《Linux网络编程:原始套接字的魔力【上】》和《有关raw socket的一些知识》这两篇文章,讲的很好。

但是看完这些,我还是对真正的Ping的核心实现一知半解,数据报传递的过程究竟是怎样的呢?幸运的是我看到了一篇非常好的文章,《linux内核网络分层结构》,尤其是文中的下面这幅图,将硬件、计网、操作系统的知识融会贯通了之后再看会有恍然大悟的感觉(不过图中没有标识网络层)

linux-net-received-data.png

另外,由于我对一些硬件知识还是不清楚,尤其是对设备控制器、驱动程序等概念一知半解,然后找到了这篇博文设备、设备控制器和驱动程序,里面讲的比较清楚。

最后,我决定去看一下Ping的源码,验证一下是否用的raw Socket,然后发现居然不是,而是用的icmp Socket,详情见ping源码分析 。不过这时候我的思路已经没有之前那么局限了,所以自己思考一下还是可以理解采用这种socket后,在实现中应该做的事,以及数据报在Ping的运行过程中的传输过程。我的理解是,在ip_local_deliver_finish()函数处理后,检查IP报头的协议字段,然后将报文正文传递给icmp的接收函数,接着再将数据传输到使用了icmp Socket的Ping应用,到达了用户空间。