一、前言
前面一章节已经知道了关于Socket的一些理论知识,下面来看一下现实中是怎样开发的。
二、实例
先来一个简单的demo
//服务端
public class SocketServer {
@SuppressWarnings("CharsetObjectCanBeUsed")
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(9999);
ExecutorService executorService = Executors.newFixedThreadPool(100);
while (true) {
Socket socket = serverSocket.accept();
Runnable runnable = () -> {
try {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "utf-8"));
String string;
while ((string = bufferedReader.readLine()) != null) {
System.out.println("客户端说:" + string);
}
} catch (IOException e) {
e.printStackTrace();
}
};
executorService.submit(runnable);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
//客户端1
public class SocketClient1 {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 9999);
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
while (true) {
String string = bufferedReader.readLine();
bufferedWriter.write(string);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
//客户端2
public class SocketClient2 {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 9999);
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
while (true) {
String string = bufferedReader.readLine();
bufferedWriter.write(string);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
上面使用的线程池优势:
- 线程复用,创建线程耗时,回收线程慢
- 防止短时间内高并发,指定线程池大小,超过数量将等待,方式短时间创建大量线程导致资源耗尽,服务挂掉
但是还有问题就是上面的服务端判断结束是通过换行符(读取到的一行为null)来判定的,这样的优缺点是:
- 优点:不需要关闭流,当发送完一条命令(消息)后可以再次发送新的命令(消息)
- 缺点:需要额外的约定结束标志,太简单的容易出现在要发送的消息中,误被结束,太复杂的不好处理,还占带宽
那么有没有一种好的解决方案呢,其实是有的,就是定长字符串,下面来介绍:
如果你了解一点class文件的结构,就知道这种思想了,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。
现在首要的问题就是用几个字节指定长度呢,我们可以算一算:
- 1个字节:最大256,表示256B
- 2个字节:最大65536,表示64K
- 3个字节:最大16777216,表示16M
- 4个字节:最大4294967296,表示4G
- 依次类推
这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点UTF-8的编码方式,那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:
- 第一个字节首位为0:即0XXXXXXX,表示长度就一个字节,最大128,表示128B
- 第一个字节首位为110,那么附带后面一个字节表示长度:即110XXXXX 10XXXXXX,最大2048,表示2K
- 第一个字节首位为1110,那么附带后面二个字节表示长度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K
- 依次类推
上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用2个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾:
//服务端
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = null;
Socket socket = null;
InputStream inputStream = null;
try {
serverSocket = new ServerSocket(9999);
System.out.println("服务端将一直等待客户端的到来。。。。。。。");
while (true) {
socket = serverSocket.accept();
inputStream = socket.getInputStream();
byte[] bytes;
//读取第一个字节,如果是-1说明已经结束了
int first = inputStream.read();
if (first == -1) {
break;
}
//读取第二个字节
int second = inputStream.read();
//按照双方约定的长度算法进行计算
int length = (first << 8) + second;
bytes = new byte[length];
inputStream.read(bytes);
System.out.println("接受的数据为:" + new String(bytes, "utf-8"));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
serverSocket.close();
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (inputStream != null) {
inputStream.close();
}
}
}
}
//客户端
public class SocketClient1 {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1",9999);
OutputStream outputStream = socket.getOutputStream();
String msg = "你好,我是joy";
byte[] len = msg.getBytes("utf-8");
outputStream.write(len.length>>8);
outputStream.write(len.length);
outputStream.write(len);
outputStream.flush();
msg = "this is second";
len = msg.getBytes("utf-8");
outputStream.write(len.length>>8);
outputStream.write(len.length);
outputStream.write(len);
outputStream.flush();
msg="this didnd g erfgaorg sadglkwene fs是否v哦i是";
len = msg.getBytes("utf-8");
outputStream.write(len.length>>8);
outputStream.write(len.length);
outputStream.write(len);
outputStream.flush();
outputStream.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
如果需要服务端返回结果也是采用这种方式,然后客户端进行读取,现在比较流行的就是长度+类型+数据模式的传输方式。
三、问题
那么是不是按照上面的方式来说就不会出现问题呢,那肯定不是,我们需要考虑拆包和黏包的问题,下面是产生原因
- 拆包:当一次发送(Socket)的数据量过大,而底层(TCP/IP)不支持一次发送那么大的数据量,则会发生拆包现象。
- 黏包:当在短时间内发送(Socket)很多数据量小的包时,底层(TCP/IP)会根据一定的算法(指Nagle)把一些包合作为一个包发送。
首先可以明确的是,大部分情况下我们是不希望发生拆包和黏包的(如果希望发生,什么都去做即可),那么怎么去避免呢,下面进行详解?
黏包
首先我们应该正确看待黏包,黏包实际上是对网络通信的一种优化,假如说上层只发送一个字节数据,而底层却发送了41个字节,其中20字节的I P首部、 20字节的T C P首部和1个字节的数据,而且发送完后还需要确认,这么做浪费了带宽,量大时还会造成网络拥堵。当然它还是有一定的缺点的,就是因为它会合并一些包会导致数据不能立即发送出去,会造成延迟,如果能接受(一般延迟为200ms),那么还是不建议关闭这种优化,如果因为黏包会造成业务上的错误,那么请改正你的服务端读取算法(协议),因为即便不发生黏包,在服务端缓存区也可能会合并起来一起提交给上层,推荐使用长度+类型+数据模式。
如果不希望发生黏包,那么通过禁用TCP_NODELAY即可,Socket中也有相应的方法:
void setTcpNoDelay(boolean on)
通过设置为true即可防止在发送的时候黏包,但是当发送的速率大于读取的速率时,在服务端也会发生黏包,即因服务端读取过慢,导致它一次可能读取多个包。
拆包
这个问题应该引起重视,在TCP/IP详解中说过:最大报文段长度(MSS)表示TCP传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的 MSS。客户端会尽量满足服务端的要求且不能大于服务端的MSS值,当没有协商时,会使用值536字节。虽然看起来MSS值越大越好,但是考虑到一些其他情况,这个值还是不太好确定,具体详见《TCP/IP详解 卷1:协议》。
如何应对拆包,其实在上面2.3节已经介绍过了,那就是如何表明发送完一条消息了,对于已知数据长度的模式,可以构造相同大小的数组,循环读取,示例代码如下:
int length = 1024;//这个是读取的到数据长度,现假定1024
byte[] data = new byte[1024];
int readLength = 0;
while(readLength<length){
int read = inputStream.read(data, readLength, length - readLength);
readLength += read;
}
这样当循环结束后,就能读取到完整的一条数据,而不需要考虑拆包了。
四、其他参数
其实如果经常看有关网络编程的源码的话,就会发现Socket还是有很多设置的,可以学着用,但是还是要有一些基本的了解比较好。下面就对Socket的Java API中涉及到的进行简单讲解。首先呢Socket有哪些可以设置的选项,其实在SocketOptions接口中已经都列出来了:
- int TCP_NODELAY = 0x0001:对此连接禁用 Nagle 算法。
- int SO_BINDADDR = 0x000F:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
- int SO_REUSEADDR = 0x04:设置套接字的 SO_REUSEADDR。
- int SO_BROADCAST = 0x0020:此选项启用和禁用发送广播消息的处理能力。
- int IP_MULTICAST_IF = 0x10:设置用于发送多播包的传出接口。
- int IP_MULTICAST_IF2 = 0x1f:设置用于发送多播包的传出接口。
- int IP_MULTICAST_LOOP = 0x12:此选项启用或禁用多播数据报的本地回送。
- int IP_TOS = 0x3:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
- int SO_LINGER = 0x0080:指定关闭时逗留的超时值。
- int SO_TIMEOUT = 0x1006:设置阻塞 Socket 操作的超时值: ServerSocket.accept(); SocketInputStream.read(); DatagramSocket.receive(); 选项必须在进入阻塞操作前设置才能生效。
- int SO_SNDBUF = 0x1001:设置传出网络 I/O 的平台所使用的基础缓冲区大小的提示。
- int SO_RCVBUF = 0x1002:设置传入网络 I/O 的平台所使用基础缓冲区的大小的提示。
- int SO_KEEPALIVE = 0x0008:为 TCP 套接字设置 keepalive 选项时
- int SO_OOBINLINE = 0x1003:置 OOBINLINE 选项时,在套接字上接收的所有 TCP 紧急数据都将通过套接字输入流接收。
参考: