一.前言
Socket通讯在银行、图书馆,物联网应用较多,日常都是Http/Https居多。网上关于Java的比较完整的Socket编程例子屈指可数,参考价值不大。要么是短连接且只支持纯文本通讯;要么是短连接且只支持文件通讯;要么是一个短连接文本通讯和一个短连接文件通讯;前面这些基本是单向通讯的短连接例子,而且长连接的例子比较少,主要存在如下特点:残缺,排版错乱,容易引起读者走火入魔。当然也有比较著名的socket框架,如Netty。但这些框架高度封装,对于入门和理解Socket基础编程,未免显得吃力。本文介绍Java Socket(长连接)原始通讯例子,可基于例子自由扩展和完善,例子主要有以下特点:
- 自定义数据报文包格式,解决粘包问题
- 全双工通讯,客户端和服务端互发消息 ,互相监听消息包
- 支持文本和大文件通讯
- 引入心跳机制,主要保持连接,因为设备在网络通讯之间有大量中间设备,无法直接通过判断socket的连接状态判断设备连接
介绍之前先简单说下socket相关的几个概念知识
二.概念知识
通信模式
单工,就是两者通信单向进行,只能一个主动发信号一个被动去接受,不能角色互换。
举例:行人只能接受红绿灯的信号但是不能向红绿灯发信号,红绿灯只能发出信号不能接收信号。
半双工,两个事物都可以发信号,但是不能同时进行。
举例:类似于踢足球,只能一个传给另一个人,两个人不能同时传球,球只有一个,信道只有一个。
全双工,两个事物可以同时发送和接受信息。
举例:两个人互相打电话,你可以说也可以听电话。在Java里套接字Socket就是全双工的。
三种通讯模式如下图:
套接字Socket和Socket编程方式
Socket是应用层与TCP/IP协议族(Socket处于TCP/IP五层模型中的传输层)通信的中间软件抽象层,它是一组接口。平时Socket编程都是使用soket接口来实现自己的业务和协议。
Socket编程有两个典型的接收发送方式:轮询方式和select侦听及管道中断方式
我们平时都是采用轮询阻塞方式创建socket,本篇是采用轮询方式演示例子。
其工作流程结构图如下:
Socket长连接和短连接
Socket短连接,连接一次,进行一次读写操作,然后关闭socekt;
Socket长连接,连接一次,进行多次读写数据,进程退出时或不需要时关闭socekt
上面毫无疑问是长连接占用资源更少,效率更高。
Socket时间参数设置
客户端连接超时时间:
Socket s=new Socket();
s.connect(new InetSocketAddress("127.0.0.1",8090),10000);
不设置连接超时时间的情况下,Socket 默认大概是20s连接超时
客户端读超时时间:
Socket s=new Socket("127.0.0.1",8090);
s.setSoTimeout(10000);
不设置setSoTimeout,默认120s超时
上面设置时间情况,皆抛出异常SocketTimeoutException
,但是第一种连接超时,出现这种异常一般是ip或者port填错(类似打电话过去每没有人接)。
而第二读取数据超时,说明连接成功了,跟服务端通讯read超时(类似接通了电话,对方不说话挂掉)。
Socket读取阻塞问题
参考我之前写的文章:https://blog.csdn.net/u011082160/article/details/100779231
Socket粘包问题
粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。本次通过自定义数据数据包和读写互斥解决粘包问题
说了这么多概念知识,开始正式入门Socket编程。
三.Socket编程
1.数据包定义
通常实际socket通讯中,需要定义一个数据交换格式,约定客户端和服务端的数据包格式,解决粘包问题,易于人阅读和编写,排除问题,易于机器解析和生成,并有效地提升网络传输效率。
约定数据包格式如下:
数据包=类型+标志+命令+包序列号+消息体+结束符
类型: 发送文本或文本和文件,占用1字节
标志: 0客服端发起的请求,1服务端发起的请求,响应请求时把flag带回去
命令: 由用户定义,如100表示登陆,101表示注册之类,响应请求时把cmd带回去
包序列号: 数据包的唯一索引,响应请求时把seq带回去
消息体: 通讯数据
结束符: 表示报文结束标志,本次使用换行符‘’ \n‘’
其中消息体分为两大类:
1.纯文本:
包类型byte,标志byte,命令int,包序列号int,文本长度 int,文本体 byte[],结束符 byte
2.文本和文件:
包类型byte,标志byte,命令int,包序列号int,文本长度 int,文本体 byte[],分隔符 byte,文件名长度 int,文件名byte[],
分隔符 byte,文件数据长度 long,文件数据 byte[],结束符 byte
分隔符:分割文本和文件的标志,本次使用 ‘’?‘’
对应DataPacket
下的代码:
public class DataPacket {
public static final String ENCODE = "UTF-8";
public static final byte CLIENT_REQUEST = 0x00;
public static final byte SERVER_REQUEST = 0x01;
/**
* 报文类型
*/
public byte dataType;
/**
* 标志:0客服端发起的请求,1服务端发起的请求,响应请求时把flag带回去
*/
public byte flag;
/**
* 命令,响应请求时把cmd带回去
*/
public int cmd;
/**
* 包序列号,数据包的唯一索引,响应请求时把seq带回去
*/
public int seq;
/**
* 长度
*/
public int textLength;
/**
* 文本体
*/
public byte[] textData;
/**
* 文件分隔符
*/
public static final byte Spilt = (byte) 0x63;
/**
* 文件名长度
*/
public int fileNameLength;
/**
* 文件名
*/
public byte[] fileNameData;
/**
* 长度
*/
public long fileLength;
/**
* 文件,暂时支持单个文件传输
*/
public File file;
/**
* 暂时不需要,因为边读编写,一次写文件容易oom
*/
public byte[] fileData;
/**
* 结束符
*/
public byte end;
/**
* 报文结束符
*/
public static final byte End = (byte) 0xA;
// ....
}
2.数据包进一步封装
上层可以直接通过DataPacket
组包发送,但这样过于繁琐,可以进步封装上层数据调用,定义请求DataReq
,如下:
public abstract class DataReq {
public abstract DataType geDataType();
public Object data;
public File file;
public String fileName;
/**
* 1已经占用,作为心跳的命令号
*
* @return
*/
public abstract int getCmd();
/**
* 报文序列号,调用一次增加一次
*
* @return
*/
public abstract int getSeq();
public DataReq(Object data) {
this.data = data;
}
public DataReq(Object data, File file, String fileName) {
this.data = data;
this.file = file;
this.fileName = fileName;
}
}
其中data对应消息体的文本,file对应消息体的文件,两者是否有值看发送的数据类型DataType
,
public enum DataType {
TEXT_ONLY((byte) 0x01), FILE_AND_TEXT((byte) 0x02),HEARTBEAT((byte) 0x99);
//...
}
命令号cmd、序列号seq则由用户定义。根据数据包定义数据包=类型+标志+命令+包序列号+消息体+结束符,还差标志,结束符,但这两个是内定的,这样一个数据包定义好了。
下面开始简单封装两端通讯流程,提供应用层若干API,简化应用层调用
2.服务端开发
关键类:SimpleServer
2.1启动服务并绑定端口,阻塞监听客户端连接到来
public void startup() {
running = false;
serverReceiverThread = new Thread(new MyServerThread());
serverReceiverThread.start();
}
private class MyServerThread implements Runnable {
public void run() {
try {
//绑定端口
serverSocket = new ServerSocket(port);
ConsoleUtils.i("Server is running");
running = true;
if (serverListener != null) {
serverListener.onStarted();
}
while (true) {
if (!serverSocket.isClosed()) {
//阻塞监听客户端连接
Socket socket = serverSocket.accept();
//客户端连接成功
ConsoleUtils.i("client is connected");
InetAddress inetAddress = socket.getInetAddress();
String ip = inetAddress.getHostAddress();
//inet address: 127.0.0.1
ConsoleUtils.i("inet address: " + inetAddress.toString());
ServerClient oldServerClient = getClientConnection(ip);
if (oldServerClient != null && oldServerClient.isConnected()) {
oldServerClient.disconnect();
}
ServerClient serverClient = new ServerClient(socket, ip);
Thread clientThread = new Thread(serverClient);
clientThread.start();
//同一个客户端ip,只保留一个最新的长连接
clientConnectionList.put(ip, serverClient);
}
}
} catch (Exception e) {
ConsoleUtils.e("服务端监听异常", e);
running = false;
if (serverListener != null) {
serverListener.onException(e);
}
}
}
}
2.2创建服务端和客户端数据通讯线程
在独立的线程中,进行服务端和客户端数据交互,不影响服务端监听其它客户端连接的到来。
/**
* ServerClient即客户端连接服务端的Client
*/
public class ServerClient implements Runnable {
private Socket socket;
private String ip;
private DataOutputStream out;
private Object obj = new Object();
private Map<Integer, DataPacket> mDataPacketList = new ConcurrentHashMap<>();
/**
* 数据回调
*/
private Map<Integer, DataResponseListener> mDataCallbacks = new ConcurrentHashMap<>();
public ServerClient(Socket socket, String ip) {
this.socket = socket;
this.ip = ip;
}
@Override
public void run() {
try {
OutputStream os = socket.getOutputStream();
out = new DataOutputStream(os);
parseDataPacket(socket);
} catch (Exception e) {
ConsoleUtils.e("客服端连接服务端中断", e);
} finally {
disconnect();
}
}
public void disconnect() {
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException e) {
// e.printStackTrace();
}
}
}
public boolean isConnected() {
if (socket != null && socket.isConnected() && !socket.isClosed()) {
return true;
}
return false;
}
//...
}
2.3服务端阻塞监听客户端发来的数据
parseDataPacket
方法主要负责解析客户端发来的数据包,主要解析三大类数据类型:
- 客户端发来的纯文本数据
- 客户端发来的文本和文件的混合数据
- 客户端向服务端保持连接的心跳数据
/**
* 解析数据包
*
* @param client
* @throws IOException
*/
private synchronized void parseDataPacket(Socket client) throws IOException {
InputStream ins = client.getInputStream();
DataInputStream inputStream = new DataInputStream(ins);
//服务端解包过程
while (true) {
//读取cmd
byte type = inputStream.readByte();
if (type == DataPacket.End) {
continue;
}
DataType dataType = DataType.parseType(type);
if (dataType != null) {
DataPacket responsePacket = new DataPacket();
switch (dataType) {
case TEXT_ONLY: {
//解析文本
responsePacket.readText(type, inputStream);
ConsoleUtils.i("====文本end");
handleResponse(responsePacket);
break;
}
case FILE_AND_TEXT: {
//解析文本
responsePacket.readTextAndFile(type, inputStream, fileSaveDir);
ConsoleUtils.i("文件大小:" + responsePacket.fileLength + ",格式化大小:" + FileUtils.formatFileSize(responsePacket.fileLength)
+ ",文件写入成功:" + responsePacket.file.getName());
ConsoleUtils.i("====文本和文件end");
handleResponse(responsePacket);
break;
}
case HEARTBEAT: {
responsePacket.readHeart(type, inputStream);
ConsoleUtils.i("====收到客服端心跳end");
handleResponse(responsePacket);
break;
}
default:
break;
}
} else {
ConsoleUtils.e("客服端非法消息type:" + type);
}
}
}
解析数据成功后,服务端会立马给客户端发送一个应答数据包,证明服务端接收成功
private void handleResponse(DataPacket responsePacket) {
byte flag = responsePacket.flag;
int seq = responsePacket.seq;
byte end = responsePacket.end;
if (end == DataPacket.End) {
if (flag == REQUEST_FLAG) { //C<-S
DataPacket requestPacket = mDataPacketList.get(seq);
callbackServerRequestResult(requestPacket, responsePacket);
} else {
//C->S
final DataPacket requestPacket = responsePacket;
DataPacket replyResponsePacket = replyData(requestPacket, DataRes.SUCCESS, "Success");
callbackClientRequestResult(requestPacket, replyResponsePacket);
}
} else {
replyData(responsePacket, DataRes.FAIL, "Fail");
}
}
/**
* 应答数据
*
* @param requestDataPacket
* @param code
* @param msg
* @return
*/
private DataPacket replyData(DataPacket requestDataPacket, int code, String msg) {
try {
synchronized (obj) {
DataRes dataRes = getDataRes();
dataRes.code = code;
dataRes.msg = msg;
String text = gson.toJson(dataRes);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
byte dataType = requestDataPacket.dataType;
if (requestDataPacket.dataType != DataType.HEARTBEAT.getDataType()) {
dataType = DataType.TEXT_ONLY.getDataType();
}
DataPacket responsePacket = new DataPacket(dataType, requestDataPacket.flag, requestDataPacket.cmd, requestDataPacket.seq, dataByte);
responsePacket.writeText(out);
return responsePacket;
}
} catch (Exception e) {
ConsoleUtils.e("应答异常", e);
}
return null;
}
2.4服务端往客户端推送数据
public boolean pushDataTpClient(String clientIp, DataReq dataReq) {
SimpleServer.ServerClient clientConnectionList = getClientConnection(clientIp);
if (clientConnectionList != null) {
try {
clientConnectionList.sendData(dataReq);
return true;
} catch (Exception e) {
ConsoleUtils.e("推送-->" + clientIp + "失败", e);
}
}
return false;
}
/**
* 异步推送数据到指定客户端
*
* @param clientIp
* @param dataReq
* @param dataResponseListener
*/
public void pushDataTpClient(String clientIp, DataReq dataReq, DataResponseListener dataResponseListener) {
SimpleServer.ServerClient clientConnectionList = getClientConnection(clientIp);
if (clientConnectionList != null) {
clientConnectionList.sendData(dataReq, dataResponseListener);
} else {
if (dataResponseListener != null) {
dataResponseListener.sendOnError(dataReq.getCmd(), new RuntimeException("客户端未连接,推送失败"));
}
}
}
public void pushDataTpAllClient(DataReq dataReq) {
Map<String, ServerClient> clientConnectionList = getClientConnectionList();
for (Map.Entry<String, ServerClient> entry : clientConnectionList.entrySet()) {
String key = entry.getKey();
ServerClient serverClient = entry.getValue();
if (serverClient.isConnected()) {
try {
serverClient.sendData(dataReq);
} catch (Exception e) {
ConsoleUtils.e("推送-->" + key + "失败", e);
}
}
}
}
上面数据推送,支持指定客户端推送,推送所有客户端,数据发送都调用了方法sendData
:
发送数据和接收数据一样,也是支持三大类数据类型
/**
* 异步发送数据,带响应数据
*
* @param dataReq
* @param dataResponseListener
*/
public void sendData(DataReq dataReq, DataResponseListener dataResponseListener) {
if (dataResponseListener == null) {
return;
}
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
try {
int seq = sendData(dataReq);
mDataCallbacks.put(seq, dataResponseListener);
//10s等待服务端,返回信息
int readWaitTimeout = 10 * 1000;
long start = System.currentTimeMillis();
boolean success = false;
while (System.currentTimeMillis() - start <= readWaitTimeout) {
DataResponseListener temp = mDataCallbacks.get(seq);
if (temp == null) {
//说明被移除成功,证明接收成功
success = true;
break;
}
}
if (!success) {
mDataPacketList.remove(seq);
mDataCallbacks.remove(seq);
dataResponseListener.sendOnError(dataReq.getCmd(), new RuntimeException("超时读取数据"));
}
} catch (Exception e) {
dataResponseListener.sendOnError(dataReq.getCmd(), e);
}
}
});
}
/**
* @param dataReq
* @return 返回报文的序列号
* @throws Exception
*/
public int sendData(DataReq dataReq) throws Exception {
if (dataReq == null) {
throw new NullPointerException("dataReq为空");
}
synchronized (obj) {
DataType dataType = dataReq.geDataType();
if (out == null) {
ConsoleUtils.e("未连接服务端");
return 0;
}
if (dataType == DataType.TEXT_ONLY) {
return sendTextData(out, dataType.getDataType(), dataReq);
} else if (dataType == DataType.FILE_AND_TEXT) {
return sendTextAndFileData(out, dataType.getDataType(), dataReq);
} else if (dataType == DataType.HEARTBEAT) {
return sendHeartData(out, DataType.HEARTBEAT.getDataType(), dataReq);
} else {
ConsoleUtils.e("不支持命令:" + dataType);
throw new IllegalArgumentException("非法数据包发送");
}
}
}
private int sendHeartData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq());
dataPacket.sendHeart(out);
cacheMsg(dataPacket);
return dataPacket.seq;
}
private void cacheMsg(DataPacket dataPacket) {
mDataPacketList.put(dataPacket.seq, dataPacket);
}
private int sendTextData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
String text = gson.toJson(dataReq.data);
ConsoleUtils.i("发送文本:" + text);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq(), dataByte);
dataPacket.writeText(out);
cacheMsg(dataPacket);
return dataPacket.seq;
}
private int sendTextAndFileData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
File file = dataReq.file;
if (file.exists() && file.isFile()) {
String text = gson.toJson(dataReq.data);
ConsoleUtils.i("发送文本:" + text);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
byte[] fileNameData = dataReq.fileName.getBytes(DataPacket.ENCODE);
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq(), dataByte, fileNameData, file);
dataPacket.writeTextAndFile(out);
cacheMsg(dataPacket);
return dataPacket.seq;
} else {
throw new IllegalArgumentException("不存在该文件或不是文件,发送失败:" + dataReq.toString());
}
}
备注:发送文件时边读编写,不是一次性发送整个文件的字节数据,原因是发送超大文件容易oom。
2.5服务端关闭
服务端关闭会断开服务端与所有客户端的连接
public void shutdown() {
running = false;
for (Map.Entry<String, ServerClient> entry : clientConnectionList.entrySet()) {
String key = entry.getKey();
ServerClient serverClient = entry.getValue();
serverClient.disconnect();
ConsoleUtils.i("断开-->" + key + "连接");
}
if (serverSocket != null && !serverSocket.isClosed()) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (serverListener != null) {
serverListener.onStopped();
}
}
3.客户端开发
客户端流程和服务端基本相同,只不过服务端是监听n个客户端连接,客户端只能连接一个服务端(不同客户端和服务端除外)。
客户端的代码和服务端大部分相同,尤其是通讯部分,本次为了说明流程,不做抽取,方便理解。
服务端写好了,现在开始写客户端流程。
3.1客户端绑定IP和端口,连接服务端
连接也是阻塞的,连接成功才能拿到OutputStream
和InputStream
,连接成功后,主要做两方面的工作,一是保持与服务端连接的定时心跳数据发送,二是阻塞监听服务端发来的数据。
public void connect() {
if (isRunning()) {
if (clientListener != null) {
clientListener.onStarted();
}
} else {
running = false;
MyClient myClient = new MyClient();
Thread thread = new Thread(myClient);
thread.start();
}
}
public class MyClient implements Runnable {
@Override
public void run() {
try {
socket = new Socket(ip, port);
//连接成功
running = true;
OutputStream os = socket.getOutputStream();
out = new DataOutputStream(os);
if (clientListener != null) {
clientListener.onStarted();
}
//启动心跳
startHeartbeat();
parseDataPacket(socket);
} catch (Exception e) {
ConsoleUtils.e("客户端IO异常", e);
if (clientListener != null) {
clientListener.onException(e);
}
} finally {
disconnect();
}
}
}
3.2客户端阻塞监听服务端发来的数据
parseDataPacket
方法主要负责解析服务端发来的数据包,主要解析三大类数据类型:
- 服务端发来的纯文本数据
- 服务端发来的文本和文件的混合数据
- 服务端应答客户端的心跳数据
/**
* 解析服务端数据包
*
* @param server
* @throws IOException
*/
private synchronized void parseDataPacket(Socket server) throws IOException {
InputStream ins = server.getInputStream();
DataInputStream inputStream = new DataInputStream(ins);
while (true) {
//读取cmd
byte type = inputStream.readByte();
if (type == DataPacket.End) {
continue;
}
DataType dataType = DataType.parseType(type);
if (dataType != null) {
DataPacket responsePacket = new DataPacket();
switch (dataType) {
case TEXT_ONLY: {
//解析文本
responsePacket.readText(type, inputStream);
ConsoleUtils.i("====文本end");
handleResponse(responsePacket);
break;
}
case FILE_AND_TEXT: {
//解析文本
responsePacket.readTextAndFile(type, inputStream, fileSaveDir);
ConsoleUtils.i("文件大小:" + responsePacket.fileLength + ",格式化大小:" + FileUtils.formatFileSize(responsePacket.fileLength)
+ ",文件写入成功:" + responsePacket.file.getName());
ConsoleUtils.i("====文本和文件end");
handleResponse(responsePacket);
break;
}
case HEARTBEAT: {
//解析服务端应答包
responsePacket.readText(type, inputStream);
ConsoleUtils.i("收到服务端心跳应答end");
handleResponse(responsePacket);
break;
}
default:
break;
}
} else {
ConsoleUtils.e("服务端非法消息type:" + type);
}
}
}
解析数据成功后,客户端会立马给服务端发送一个应答数据包,证明客户端接收成功
private void handleResponse(DataPacket responsePacket) {
byte flag = responsePacket.flag;
int seq = responsePacket.seq;
byte end = responsePacket.end;
if (end == DataPacket.End) {
if (flag == REQUEST_FLAG) { //C->S
DataPacket requestPacket = mDataPacketList.get(seq);
callbackClientRequestResult(requestPacket, responsePacket);
} else {
//C<-S
final DataPacket requestPacket = responsePacket;
DataPacket replyResponsePacket = replyData(requestPacket, DataRes.SUCCESS, "Success");
callbackServerRequestResult(requestPacket, replyResponsePacket);
}
} else {
//异常应答,不回调callbackResult
replyData(responsePacket, DataRes.FAIL, "Fail");
}
}
/**
* 应答数据
*
* @param requestDataPacket
* @param code
* @param msg
* @return
*/
private DataPacket replyData(DataPacket requestDataPacket, int code, String msg) {
try {
synchronized (obj) {
DataRes dataRes = getDataRes();
dataRes.code = code;
dataRes.msg = msg;
String text = gson.toJson(dataRes);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
DataPacket responsePacket = new DataPacket(DataType.TEXT_ONLY.getDataType(), requestDataPacket.flag, requestDataPacket.cmd, requestDataPacket.seq, dataByte);
responsePacket.writeText(out);
return responsePacket;
}
} catch (Exception e) {
ConsoleUtils.e("应答异常", e);
}
return null;
}
3.3客户端往服务端发送数据
发送数据和接收数据一样,也是支持三大类数据类型,其中一种是定时心跳数据,另外两种数据是文本数据、文本和文件数据
/**
* 异步发送数据
*
* @param dataReq
* @param dataResponseListener
*/
public void sendData(DataReq dataReq, DataResponseListener dataResponseListener) {
if (dataResponseListener == null) {
return;
}
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
try {
int seq = sendData(dataReq);
mDataCallbacks.put(seq, dataResponseListener);
//10s等待服务端,返回信息
int readWaitTimeout = 10 * 1000;
long start = System.currentTimeMillis();
boolean success = false;
while (System.currentTimeMillis() - start <= readWaitTimeout) {
DataResponseListener temp = mDataCallbacks.get(seq);
if (temp == null) {
//说明被移除成功,证明接收成功
success = true;
break;
}
}
if (!success) {
mDataPacketList.remove(seq);
mDataCallbacks.remove(seq);
dataResponseListener.sendOnError(dataReq.getCmd(), new RuntimeException("超时读取数据"));
}
} catch (Exception e) {
dataResponseListener.sendOnError(dataReq.getCmd(), e);
}
}
});
}
/**
* @param dataReq
* @return 返回报文的序列号
* @throws Exception
*/
public int sendData(DataReq dataReq) throws Exception {
if (dataReq == null) {
throw new NullPointerException("dataReq为空");
}
synchronized (obj) {
DataType dataType = dataReq.geDataType();
if (out == null) {
ConsoleUtils.e("未连接服务端");
return 0;
}
if (dataType == DataType.TEXT_ONLY) {
return sendTextData(out, dataType.getDataType(), dataReq);
} else if (dataType == DataType.FILE_AND_TEXT) {
return sendTextAndFileData(out, dataType.getDataType(), dataReq);
} else if (dataType == DataType.HEARTBEAT) {
return sendHeartData(out, DataType.HEARTBEAT.getDataType(), dataReq);
} else {
ConsoleUtils.e("不支持命令:" + dataType);
throw new IllegalArgumentException("非法数据包发送");
}
}
}
private int sendHeartData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq());
dataPacket.sendHeart(out);
cacheMsg(dataPacket);
return dataPacket.seq;
}
private int sendTextData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
String text = gson.toJson(dataReq.data);
ConsoleUtils.i("发送文本:" + text);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq(), dataByte);
dataPacket.writeText(out);
cacheMsg(dataPacket);
return dataPacket.seq;
}
private int sendTextAndFileData(DataOutputStream out, byte dataType, DataReq dataReq) throws Exception {
File file = dataReq.file;
if (file.exists() && file.isFile()) {
String text = gson.toJson(dataReq.data);
ConsoleUtils.i("发送文本:" + text);
byte[] dataByte = text.getBytes(DataPacket.ENCODE);
byte[] fileNameData = dataReq.fileName.getBytes(DataPacket.ENCODE);
DataPacket dataPacket = new DataPacket(dataType, REQUEST_FLAG, dataReq.getCmd(), dataReq.getSeq(), dataByte, fileNameData, file);
dataPacket.writeTextAndFile(out);
cacheMsg(dataPacket);
return dataPacket.seq;
} else {
throw new IllegalArgumentException("不存在该文件或不是文件,发送失败:" + dataReq.toString());
}
}
备注:发送文件时也是边读编写,原因同服务端一样。
通过上面发现,两端通讯数据发和收流程基本一样,只是数据来源不一样
3.3.1客户端定时往发送服务端发送心跳数据
心跳数据包比较简单,由cmd为0x01,序号-1自减组成,当然也可以定义别的。
private void startHeartbeat() {
timer = new Timer();
DataReq dataReq = new DataReq("heart") {
@Override
public DataType geDataType() {
return DataType.HEARTBEAT;
}
@Override
public int getCmd() {
return 0x01;
}
@Override
public int getSeq() {
int andDecrement = ai.getAndDecrement();
if (andDecrement == Integer.MIN_VALUE) {
ai.set(-1);
}
return andDecrement;
}
};
timer.schedule(new TimerTask() {
@Override
public void run() {
if (!running) {
return;
}
if (isConnected()) {
try {
sendData(dataReq);
} catch (Exception e) {
ConsoleUtils.e("心跳异常", e);
}
}
}
}, 1000, HEART_TIME);
}
3.4客户端断开与服务端的连接
断开连接会停止心跳发送,套接字关闭。
public void disconnect() {
running = false;
stopHeartbeat();
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException e) {
// e.printStackTrace();
}
}
if (clientListener != null) {
clientListener.onStopped();
}
}
4.数据发送和读取
上面数据的发送和读取都封装在DataPacket
类里面,发送和读取都按数据包格式处理
public class DataPacket {
//...
public void sendHeart(DataOutputStream out) throws IOException {
//占用1个字节
out.writeByte(dataType);
out.writeByte(flag);
out.writeInt(cmd);
out.writeInt(seq);
//结束符,占用1个字节
out.writeByte(End);
out.flush();
}
public void writeText(DataOutputStream out) throws IOException {
//占用1个字节
out.writeByte(dataType);
out.writeByte(flag);
out.writeInt(cmd);
out.writeInt(seq);
out.writeInt(textLength);
out.write(textData);
//结束符,占用1个字节
out.writeByte(End);
out.flush();
}
public void writeTextAndFile(DataOutputStream out) throws IOException {
//占用1个字节
out.writeByte(dataType);
out.writeByte(flag);
out.writeInt(cmd);
out.writeInt(seq);
out.writeInt(textLength);
out.write(textData);
if (fileLength > 0) {
//文件分隔符和文本数据
out.writeByte(DataPacket.Spilt);//占用1个字节
//发送文件名
out.writeInt(fileNameLength);
out.write(fileNameData);
//发送文件
out.writeByte(DataPacket.Spilt);//占用1个字节
out.writeLong(fileLength);//文件的长度,占用8个字节
writeFile(out, file);
}
//结束符,占用1个字节
out.writeByte(End);
out.flush();
}
private void writeFile(DataOutputStream out, File file) throws IOException {
InputStream is = new FileInputStream(file.getPath());
byte[] c = new byte[1024 * 4];
int b;
while ((b = is.read(c)) > 0) {
out.write(c, 0, b);
}
is.close();
}
public void readHeart(byte dataType, DataInputStream inputStream) throws IOException {
byte flag = inputStream.readByte();
int cmd = inputStream.readInt();
int seq = inputStream.readInt();
byte end = inputStream.readByte();
this.dataType = dataType;
this.flag = flag;
this.cmd = cmd;
this.seq = seq;
this.end = end;
}
/**
* 按顺序解析
*
* @param dataType
* @param inputStream
* @throws IOException
*/
public void readText(byte dataType, DataInputStream inputStream) throws IOException {
byte flag = inputStream.readByte();
int cmd = inputStream.readInt();
int seq = inputStream.readInt();
int textLength = inputStream.readInt();
byte[] data = new byte[textLength];
inputStream.readFully(data);
byte end = inputStream.readByte();
this.dataType = dataType;
this.flag = flag;
this.cmd = cmd;
this.seq = seq;
this.textLength = textLength;
this.textData = data;
this.end = end;
}
public void readTextAndFile(byte dataType, DataInputStream inputStream, File dir) throws IOException {
long start = System.currentTimeMillis();
byte flag = inputStream.readByte();
int cmd = inputStream.readInt();
int seq = inputStream.readInt();
//解析文本
int textLength = inputStream.readInt();
byte[] data = new byte[textLength];
inputStream.readFully(data);
byte spiltChar = inputStream.readByte();
if (spiltChar != DataPacket.Spilt) {
throw new IllegalArgumentException("非法字节,无法解析文件名:" + spiltChar);
}
//解析文件名
int fileNameLength = inputStream.readInt();
byte[] fileNameData = new byte[fileNameLength];
inputStream.readFully(fileNameData);
spiltChar = inputStream.readByte();
if (spiltChar != DataPacket.Spilt) {
throw new IllegalArgumentException("非法字节,无法文件数据:" + spiltChar);
}
//解析文件数据
long fileLength = inputStream.readLong();
ConsoleUtils.i("fileLength:" + fileLength);
String fileName = new String(fileNameData);
File file = new File(dir, fileName);
if (file.exists()) {
file.delete();
}
FileOutputStream os = new FileOutputStream(file);
byte[] buffer = new byte[1024 * 10];
int ret;
int readLength = 0;
int surplus = buffer.length;
//80864108
if (fileLength <= buffer.length) {
surplus = (int) fileLength;
}
//非堵塞读取,读多一个字节都会卡死
while ((ret = inputStream.read(buffer, 0, surplus)) != -1) {
os.write(buffer, 0, ret);
readLength += ret;
surplus = (int) (fileLength - readLength);
if (surplus >= buffer.length) {
surplus = buffer.length;
}
ConsoleUtils.i("readLength:" + readLength);
if (readLength == fileLength) {
ConsoleUtils.i("读取文件完毕");
break;
}
}
os.close();
ConsoleUtils.i("文件读取耗时:" + (System.currentTimeMillis() - start) / 1000.0 + "s");
byte end = inputStream.readByte();
this.dataType = dataType;
this.flag = flag;
this.cmd = cmd;
this.seq = seq;
this.textLength = textLength;
this.textData = data;
this.fileNameLength = fileNameLength;
this.fileNameData = fileNameData;
this.fileLength = fileLength;
this.file = file;
this.end = end;
ConsoleUtils.i("发送文件成功:" + fileName);
}
//...
}
四.应用层调用
上面简单说明了两端通讯过程和简单封装,下面贴出应用层的调用过程,比较简单,通俗易懂。
1.1服务端调用
1.1.1启动服务端
SimpleServer simpleServer = new SimpleServer(port);
simpleServer.setServerListener(new SimpleServer.ServerListener() {
@Override
public void onStarted() {
showServerMsg("onStarted");
}
@Override
public void onStopped() {
showServerMsg("onStopped");
}
@Override
public void onException(Exception e) {
showServerMsg("onException:" + e.getMessage());
}
});
//服务端文件保存目录
File dir = new File(SimpleClientTest.getApkDir(), "apk" + File.separator + "download");
simpleServer.setFileSaveDir(dir.getAbsolutePath());
//订阅客户端的请求信息
simpleServer.subscribeDataResponseListener(new DataResponseListener() {
@Override
public void sendOnSuccess(int cmd, DataPacket requestPacket, DataPacket responsePacket) {
//子线程
ConsoleUtils.i("服务端监听客服端:cmd: " + cmd + " ,requestPacket: " + requestPacket + " responsePacket: " + responsePacket);
}
@Override
public void sendOnError(int cmd, Throwable t) {
ConsoleUtils.e("接收客户端信息异常:" + cmd, t);
}
});
//启动服务端
simpleServer.startup();
1.1.2 往客户端推送数据
//定向客户端推送数据
simpleServer.pushDataTpClient("","");
//往所有有客户端推送数据
simpleServer.pushDataTpAllClient();
1.1.3 关闭服务端
simpleServer.shutdown();
1.2客户端调用
1.2.1连接服务端
apkClient = new SimpleClient(ip, port);
apkClient.setClientListener(new SimpleClient.ClientListener() {
@Override
public void onStarted() {
}
@Override
public void onStopped() {
}
@Override
public void onException(Exception e) {
}
});
/* apkClient.setHeartbeatListener(new HeartbeatListener() {
@Override
public void heartBeatPacket(DataPacket requestPacket, DataPacket responsePacket, String source) {
ConsoleUtils.i("心跳响应:" + source);
}
});*/
apkClient.setFileSaveDir(FileUtils.currentWorkDir + "apk");
//订阅服务端的请求信息
apkClient.subscribeDataResponseListener(new DataResponseListener() {
@Override
public void sendOnSuccess(int cmd, DataPacket requestPacket, DataPacket responsePacket) {
ConsoleUtils.i("客服端监听服务端,cmd: " + cmd + " ,requestPacket: " + requestPacket + " responsePacket: " + responsePacket);
}
@Override
public void sendOnError(int cmd, Throwable t) {
ConsoleUtils.e("接收服务端信息异常:" + cmd, t);
}
});
apkClient.connect();
1.2.2往服务端发送数据
apkClient.sendData(dataReq, new DataResponseListener() {
@Override
public void sendOnSuccess(int cmd, DataPacket requestPacket, DataPacket responsePacket) {
ConsoleUtils.i("发送消息成功,cmd: " + cmd + " ,requestPacket: " + requestPacket + " responsePacket: " + responsePacket);
}
@Override
public void sendOnError(int cmd, Throwable t) {
}
});
1.2.3断开连接
apkClient.disconnect();
五.测试
测试其实就是上面的应用层调用。
工程说明:
上面箭头所在工程都是Java类库。本工程是Android工程,建议使用IDEA导入四个module测试
本次测试提供三种测试方式,应对不同用户和不同环境的场景测试。
1.1测试场景1
说明:手机作为服务端,PC运行客户端连接(Java main程序),示例工程在module-demo下和app目录下
网络环境:同一局域网内ip或广域网的ip
操作:
启动手机服务端:
运行main程序连接:
注意:配置的的IP和端口号
1.2测试场景2
说明:PC Android 模拟器(Genymotio 模拟器)作为服务端,PC运行客户端连接(Java main程序),示例工程在module-demo下和app目录下
网络环境:本地测试
操作:
跟测试场景2差不多,唯一不同的是:连接之前执行如命令(CMD窗口执行):
//把PC电脑端TCP端口12580的数据转发到与电脑通过adb连接的Android设备的TCP端口8090上。
adb forward tcp:12580 tcp:8090
1.3测试场景3(建议)
说明:PC作为服务端和客户端,示例工程在module-demo下,适合Java开发人员测试,提供一个Java Swing窗口辅助测试
网络环境:本地测试
操作:
上面三种测试:只是文本发送,如果需要同时发送文件和文件数据,需要:
六.总结
本文主要介绍Socket编程流程,对入门和理解Socket起到事半功倍的作用,同时对理解第三方客户端/服务器框架有较好的辅助作用,实际开发一般不选择原生Socket开发,因为工作量大,难度大,考虑的问题实际更多,实际选择业界流行,强大,稳定的Netty居多,上面如有错误请指出纠正。
七.项目地址
https://github.com/kellysong/Android-Socket 欢迎star,follow