该系列博客衍生于我所参与的一个校企合作项目,作为该项目的一名成员,我无法公开项目的代码,文中出现的所有代码以及流程图均与原项目不同,包括代码的语言选择、通讯的流程图、通讯协议等。
原项目中的实现功能如下:
- 在 Linux 上使用 ROS 的收发机制来实现通讯模块与无人驾驶模块的数据传输;
- 在推耙机与中控室之间主要使用基于 TCP 协议的自定义数据帧的形式来进行数据传输,采用 Python 实现。
而本系列博客的最终需求是在两台不同主机之间使用基于 TCP 协议的自定义数据帧的形式来进行数据传输。代码采用的是基于 Java 的通讯实现。 下面是学习的过程:
目录:
1 需求分析
通过上一个博客,我们可以正确地在实现客户端正常连接服务器端之后,客户端可以通过向服务器端发送具体指令来获得服务器端的相应数据。然而, 事实上,网络的通讯还包括图像的通讯,同时还需要考虑各种异常情况,这其中包括程序的自动断线重连、大数据的粘包过程等。
与上一个博客类似,我先规定指令所代表的含义如下:
指令 | 含义 |
---|---|
1818 | 请求获取实验室目前的图像 |
1819 | 请求实验室人员的数量 |
1820 | 请求服务器中实验室人员的平均补贴 |
2 可能出现的异常
2.1 Socket 网络通讯中的粘包、半包
TCP通讯为何存在粘包呢?主要原因是: TCP 是以流的方式来处理数据,再加上网络上 MTU 的往往小于在应用处理的消息数据,所以就会引发一次接收的数据无法满足消息的需要,导致粘包的存在。
处理粘包的唯一方法就是制定应用层的数据通讯协议,通过协议来规范现有接收的数据是否满足消息数据的需要。
2.1.1 解决途径
-
消息定长:报文大小固定长度,不够空格补全,发送和接收方遵循相同的约定,这样即使粘包了通过接收方编程实现获取定长报文也能区分;
-
包尾添加特殊分隔符:例如每条报文结束都添加回车换行符(例如 FTP 协议)或者指定特殊字符作为报文分隔符,接收方通过特殊分隔符切分报文区分;
-
将消息分为消息头和消息体:消息头中包含表示信息的总长度(或者消息体长度)的字段;
2.1.2 自定义数据包协议
上一个博客中,我通过使用将消息分为消息头和消息体来解决粘包问题,那么现在,我将使用在包尾添加特殊分隔符来解决粘包问题。
首先根据指令所代表的含义,定义好 Socket 的数据包协议如下:
详细关于解决 TCP 粘包、半包的问题可以查阅这篇博客。【传送门】
2.1.3 代码实现
通过逐位判断当前接收到的字节数据是否为结束符来收发数据。
// 连接服务器
Socket socket = new Socket("127.0.0.1", 5612);
// Java 中用于接收数据的流
InputStream in = socket.getInputStream();
// 该字节数组用来存放 Socket 的 IO 流中的数据
byte[] buf = new byte[1024];
int len = 0;
// 因为数组的不可变性,所以需要一个 Java 集合用来临时存放数据
ArrayList<Byte> arr = new ArrayList<Byte>();
flag:
// 先将 IO 流中的数据灌满整个字节数组,然后把 IO 流中的一个协议数据保存到 Java 集合中
while ((len = in.read(buf)) != -1) {
// 逐字读取 IO 中的数据
for (byte temp : buf) {
// 判断结束符
if (temp != '\n') {
arr.add(temp);
} else {
break flag;
}
}
//本来可以直接用 JVM 的垃圾自动回收机制,不过 Socket 的 read 方法必须不为 null,所以就用 Arrays 的方法
Arrays.fill(buf, (byte) 0);
}
// 将 Java 集合中的数据保存到一个新的数组中
byte[] bs = new byte[arr.size()];
for (int j = 0; j < arr.size(); j++) {
bs[j] = arr.get(j);
}
arr.clear();
// 接下来可以将该字节数据转化为 String 字符串进行操作
String bsStr = new String(bs, StandardCharsets.UTF_8);
2.2 图像转换成字节数组发送时所需要进行的转换
图像通过按照如下方式加入到 Socket 的数据包协议中,然后发送并解析,正因为这一步,所以后面我们还需要导入一个 jar 包。
2.2.1 代码实现
// 通过 Java 的文件流得到的图像数组,Utils 为我自己写的方法
byte[] fileArr1818 = Utils.fileToByteArray("C:\\Users\\Sharm\\Desktop\\boke\\src\\main\\resources\\实验室图像.jpg");
assert fileArr1818 != null;
System.out.println("数据正在编码中");
// 将字节数组利用 Base64 解码为字符串,然后根据数据包协议进行拼接
imgStrs = Base64.encodeBase64String(fileArr1818);
sendStrs = head + insStr + imgStrs + end + terminator;
// 利用 UTF-8 编码为字节数组,用于 Socket 的传输
sendBytes = sendStrs.getBytes(StandardCharsets.UTF_8);
3 Maven
因为代码中引入了一个新的 jar 包,即import org.apache.commons.codec.binary.Base64
; ,所以利用 Maven 来构建该项目是极好的,如果不使用 Maven 的话,直接在该项目中导入这个 jar 包就行。
如果使用 Maven,只需在 pom.xml 中加入以下依赖,即可。
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.directory.studio</groupId>
<artifactId>org.apache.commons.codec</artifactId>
<version>1.8</version>
</dependency>
</dependencies>
不要以为不会 Maven 就放弃这个步骤,相信我,用过 Maven 之后,你就再也不想四处找 jar 包了!
Maven 不难,现阶段就把它当成是一个自动安装 jar 包的工具。有不了解的可以阅读一下这个博客。【传送门】
4 项目流程图
依据需求,通过画出对应的流程图来理清自己的思路,然后根据流程图写出相应代码。
5 代码实现
5.1 客户端代码
import org.apache.commons.codec.binary.Base64;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Scanner;
public class Client {
public static void main(String[] args) throws IOException {
int countConnect = 0;
while (true) {
try {
//连接服务器
Socket s = new Socket("127.0.0.1", 5612);
System.out.println("已连接到服务器5612端口,准备向服务器发送指令。");
//获取输出流
OutputStream out = s.getOutputStream();
while (true) {
//服务端开始输入指令
System.out.println("请输入指令:");
System.out.println("1818:请求获取实验室目前的图像");
System.out.println("1819:请求实验室人员的数量");
System.out.println("1820:请求实验室人员的平均补贴");
Scanner scanner = new Scanner(System.in);
int instruction = scanner.nextInt();
//将int型指令转化为byte[]
byte[] instructionArr = intToByteArray(instruction);
// 利用自定义数据帧的形式发送
String instructionStrs = Base64.encodeBase64String(instructionArr);
String terminator = "\r\n";
String sendStrs = "Req0R" + instructionStrs + "End" + terminator;
//一个字符为一个字节,如“Req”为三个字节
byte[] sendBytes = sendStrs.getBytes(StandardCharsets.UTF_8);
// 开始在输出流中发送数据
out.write(sendBytes, 0, sendBytes.length);
//获取开始时间
long startTime = System.currentTimeMillis();
上面是发送,下面是接收
// 为了解决粘包问题所设置的接收代码
InputStream in = s.getInputStream();
byte[] buf = new byte[1024];
int len = 0;
ArrayList<Byte> arr = new ArrayList<Byte>();
flag:
while ((len = in.read(buf)) != -1) {
for (byte temp : buf) {
if (temp != '\n') {
arr.add(temp);
} else {
break flag;
}
}
//原来两台电脑间通讯遇到的问题可以通过清空
Arrays.fill(buf, (byte) 0);
}
byte[] bs = new byte[arr.size()];
for (int j = 0; j < arr.size(); j++) {
bs[j] = arr.get(j);
}
arr.clear();
// bsStr 就是得到的最终字符串数据
String bsStr = new String(bs, StandardCharsets.UTF_8);
//得到目标文件
//这个可以按步解析出对应的指令
String head = bsStr.substring(0, 4);
String insStr = bsStr.substring(4, 12);
String resqStr = bsStr.substring(12, bsStr.length() - 4);
String end = bsStr.substring(bsStr.length() - 4, bsStr.length() - 1);
byte[] receInsBytes = Base64.decodeBase64(insStr);
byte[] receBytes = Base64.decodeBase64(resqStr);
// 按照不同的指令,进行不同的操作
int receInsInt = byteArrayToInt(receInsBytes);
switch (receInsInt) {
case 1818:
//对于图像,原来缺少几个字节真的不会影响整体
File destFile = new File("lab.jpg");
//下面这段代码呢,就是先将字节数组的数据放到数组流中,然后再传给节点流,参考自己的节点流复制代码
//字节数组与程序之间的管道。把数组写到流中
InputStream bais = new ByteArrayInputStream(receBytes);
//程序与新文件之间的管道
OutputStream fos = new FileOutputStream(destFile);
//一样的字节数组缓冲操作
byte[] buf2 = new byte[1024];
int len2;
while ((len2 = bais.read(buf2)) != -1) {
fos.write(buf2, 0, len2);
}
System.out.println("图片保存完毕。");
//获取结束时间
long endTime = System.currentTimeMillis();
System.out.printf("实验室图片传输过来花了%d ms时间:", (endTime - startTime) / 2);
//流的关闭
bais.close();
fos.close();
break;
case 1819:
int speed = byteArrayToInt(receBytes);
System.out.printf("此时,实验室人员的数量有 %d 人\n", speed);
break;
case 1820:
int volume = byteArrayToInt(receBytes);
System.out.printf("实验室人员的平均补贴为 %d 元\n", volume);
break;
default:
break;
}
}
} catch (IOException ex) {
System.out.printf("服务器断开,请求重连,正在进行第%d次连接。", countConnect);
System.out.println();
countConnect += 1;
}
}
}
/**
* int到byte[]
* @param i 输入待转换的int
* @return 返回的对应的byte[]
*/
public static byte[] intToByteArray(int i) {
byte[] result = new byte[4];
//由高位到低位
result[0] = (byte)((i >> 24) & 0xFF);
result[1] = (byte)((i >> 16) & 0xFF);
result[2] = (byte)((i >> 8) & 0xFF);
result[3] = (byte)(i & 0xFF);
return result;
}
/**
* byte[]转int
* @param bytes 指定的byte[]
* @return int型的值
*/
public static int byteArrayToInt(byte[] bytes) {
return (bytes[3] & 0xFF) |
(bytes[2] & 0xFF) << 8 |
(bytes[1] & 0xFF) << 16 |
(bytes[0] & 0xFF) << 24;
}
}
5.2 服务器端代码
import org.apache.commons.codec.binary.Base64;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
public class Server {
public static void main(String[] args) throws IOException {
//服务器开始监听5612端口
ServerSocket serverSocket = new ServerSocket(5612);
System.out.println("服务端已启动,正在监听5612端口");
//等待客户端连接
Socket s = serverSocket.accept();
System.out.println("得到来自一个客户端的连接");
//接收服务器的消息
InputStream in = s.getInputStream();
while (true) {
//一样的字节数组缓冲操作,将IO流中的数据放到buf数组中
byte[] buf = new byte[1024];
int len = 0;
ArrayList<Byte> arr = new ArrayList<Byte>();
flag:
while ((len = in.read(buf)) != -1) {
for (byte temp : buf) {
if (temp != '\n') {
arr.add(temp);
} else {
break flag;
}
}
//本来可以直接用JVM的垃圾自动回收机制,不过socket的read方法必须不为null,所以就用Arrays的方法
Arrays.fill(buf, (byte) 0);
}
byte[] bs = new byte[arr.size()];
for (int j = 0; j < arr.size(); j++) {
bs[j] = arr.get(j);
}
arr.clear();
String bsStr = new String(bs, StandardCharsets.UTF_8);
//原来是要在String中截取出字串,而不能在下面这个字节数组中拿出子数组来得到instruction1818。
//截取服务器请求。协议:
String insStr = bsStr.substring(5, bsStr.length()-4);
String head = "Resp";
String end = "End";
byte[] insBytes = Base64.decodeBase64(insStr);
System.out.println(Arrays.toString(insBytes));
//得到的是服务器的指令
int ins = byteArrayToInt(insBytes);
System.out.println("服务器发过来的指令是:" + ins);
String imgStrs = null;
String sendStrs = null;
byte[] sendBytes = null;
String terminator = "\r\n";
switch (ins) {
case 1818:
System.out.println("正在打包图像");
// 下面的图像的路径我写的是该图像在我电脑中的绝对路径
byte[] fileArr1818 = Utils.fileToByteArray("C:\\Users\\Sharm\\Desktop\\boke\\src\\main\\resources\\实验室图像.jpg");
assert fileArr1818 != null;
System.out.println("数据正在编码中");
imgStrs = Base64.encodeBase64String(fileArr1818);
sendStrs = head + insStr + imgStrs + end + terminator;
sendBytes = sendStrs.getBytes(StandardCharsets.UTF_8);
break;
case 1819:
byte[] fileArr1819 = intToByteArray(18);
System.out.println("数据正在编码中……");
imgStrs = Base64.encodeBase64String(fileArr1819);
sendStrs = head + insStr + imgStrs + end + terminator;
sendBytes = sendStrs.getBytes(StandardCharsets.UTF_8);
break;
case 1820:
byte[] fileArr1820 = intToByteArray(400);
System.out.println("数据正在编码中……");
imgStrs = Base64.encodeBase64String(fileArr1820);
sendStrs = head + insStr + imgStrs + end + terminator;
sendBytes = sendStrs.getBytes(StandardCharsets.UTF_8);
break;
default:
break;
}
OutputStream out = s.getOutputStream();
System.out.println("开始发送");
assert sendBytes != null;
out.write(sendBytes, 0, sendBytes.length);
//测试发送的字节数组的前几个字节,看看是否正确
System.out.println(Arrays.toString(Arrays.copyOfRange(sendBytes, 0, 16)));
System.out.println("发送完毕。\n");
}
}
/**
* int到byte[]
* @param i 输入待转换的int
* @return 返回的对应的byte[]
*/
public static byte[] intToByteArray(int i) {
byte[] result = new byte[4];
//由高位到低位
result[0] = (byte)((i >> 24) & 0xFF);
result[1] = (byte)((i >> 16) & 0xFF);
result[2] = (byte)((i >> 8) & 0xFF);
result[3] = (byte)(i & 0xFF);
return result;
}
/**
* byte[]转int
* @param bytes 指定的byte[]
* @return int型的值
*/
public static int byteArrayToInt(byte[] bytes) {
return (bytes[3] & 0xFF) |
(bytes[2] & 0xFF) << 8 |
(bytes[1] & 0xFF) << 16 |
(bytes[0] & 0xFF) << 24;
}
}
5.3 工具类:通过使用 Java 文件流将本地图像导入到程序中
import java.io.*;
public class Utils {
/**
* 利用System.arraycopy的方法在字节数组中截取指定长度数组
* @param src
* @param begin
* @param count
* @return
*/
public static byte[] subBytes(byte[] src, int begin, int count) {
byte[] bs = new byte[count];
System.arraycopy(src, begin, bs, 0, count);
return bs;
}
/**
* 利用Java字节流的方式将图像转化为字节数组
* 图片到程序FileInputStream
* 程序到数组 ByteArrayOutputStream
*/
public static byte[] fileToByteArray(String FilePath){
//1.创建源与目的的
File src=new File(FilePath);
//在字节数组输出的时候是不需要源的。
byte[] dest=null;
//2.选择流,选择文件输入流
//方便在finally中使用,设置为全局变量
InputStream is=null;
ByteArrayOutputStream os=null;
try {
is=new FileInputStream(src);
os=new ByteArrayOutputStream();
//3.操作,读文件
//10k,创建读取数据时的缓冲,每次读取的字节个数。
byte[] flush=new byte[1024*10];
//接受长度;
int len=-1;
while((len=is.read(flush))!=-1) {
//表示当还没有到文件的末尾时
//字符数组-->字符串,即是解码。将文件内容写出字节数组
os.write(flush,0,len);
}
os.flush();
return os.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
//4.释放资源
try {
//表示当文打开时,才需要通知操作系统关闭
if(is!=null) {
is.close();
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
return null;
}
}
5.4 客户端的 Console 测试
已连接到服务器5612端口,准备向服务器发送指令。
请输入指令:
1818:请求获取实验室目前的图像
1819:请求实验室人员的数量
1820:请求实验室人员的平均补贴
1818
图片保存完毕。
实验室图片传输过来花了52 ms时间:请输入指令:
1818:请求获取实验室目前的图像
1819:请求实验室人员的数量
1820:请求实验室人员的平均补贴
1819
此时,实验室人员的数量有 18 人
请输入指令:
1818:请求获取实验室目前的图像
1819:请求实验室人员的数量
1820:请求实验室人员的平均补贴
1820
实验室人员的平均补贴为 400 元