一.基本概念。
在UDP/TCP文章中已经说过,在TCP/IP网络模型中,分为了四层,分别是应用层,传输层,网际层,数据链路层,Http是位于应用层的协议,它是基于TCP实现的,TCP是传输层协议,网络层有IP协议,那么我们的Socket在哪呢?它是位于应用层之下,传输层之上的一个接口层,也就是操作系统提供给用户访问网络的系统接口,我们可以借助于Socket接口层,对传输层,网际层以及物理链路层进行操作,来实现不同的应用层协议。
我们知道系统为我们实现网络编程提供了很多已经写好的Socket类,比如上篇文章讲到的在TCP中用Socket与ServerSocket,在UDP中有DatagramSocket等。我们只要知道在什么时候用哪种Socket就行,不用自己单独去实现,因为Java为我们提供的已经足够我们进行日常开发了。
二.应用方式。
下面我们基于TCP由浅入深的来了解一下具体Socket是如何应用的。
1. 简单应用,建立连接,接收从客户端的数据
服务端代码如下:
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author liuyonglei
*简单应用,建立连接,接收从客户端的数据
*/
public class Server {
public static void main(String[] args) throws Exception {
int port = 12347;
ServerSocket server = new ServerSocket(port);
System.out.println("LYL_等待连接");
Socket socket = server.accept();
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
sb.append(new String(bytes, 0, len,"UTF-8"));
}
System.out.println("LYL_客户端数据: " + sb);
inputStream.close();
socket.close();
server.close();
}
}
客户端代码如下:
import java.io.OutputStream;
import java.net.Socket;
public class Client {
public static void main(String args[]) throws Exception {
String host = "127.0.0.1"; // localhost
int port = 12347;
Socket socket = new Socket(host, port);
OutputStream outputStream = socket.getOutputStream();
String message="你好,我是客户端";
socket.getOutputStream().write(message.getBytes("UTF-8"));
outputStream.close();
socket.close();
}
}
以上Socket应用为最基本的应用方式,单项数据传递,服务端监听端口,客户端建立连接,通过输出流写入数据,服务端获取输入流,并通过输入流接收客户端传递的数据。在此过程中,需要注意编码,要保证服务端的编码与客户端一致,防止出现乱码的情况。
2. 双向单线程通信实现
服务端实现代码如下:import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author liuyonglei
*Socket双向通信,客户端发送消息,服务端接收到消息后,回复信息,客户端接收回复信息。
*/
public class Server {
public static void main(String[] args) throws Exception {
int port = 12348;
ServerSocket server = new ServerSocket(port);
System.out.println("LYL_等待连接");
Socket socket = server.accept();
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("LYL_接收到的客户端信息: " + sb);
OutputStream outputStream = socket.getOutputStream();
outputStream.write("我是服务端,您的消息我收到了.".getBytes("UTF-8"));
inputStream.close();
outputStream.close();
socket.close();
server.close();
}
}
客户端代码如下:
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class Client {
public static void main(String args[]) throws Exception {
String host = "127.0.0.1";
int port = 12348;
Socket socket = new Socket(host, port);
OutputStream outputStream = socket.getOutputStream();
String message = "我是客户端发送的数据";
socket.getOutputStream().write(message.getBytes("UTF-8"));
//告诉服务器数据已经发送完,后续只能接受数据
socket.shutdownOutput();
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
sb.append(new String(bytes, 0, len,"UTF-8"));
}
System.out.println("LYL_服务器返回数据: " + sb);
inputStream.close();
outputStream.close();
socket.close();
}
}
以上实例实现了单线程客户端与服务端信息相互发送的逻辑,服务端创建ServerSocket指定端口号,并等待客户端连接,客户端创建Socket绑定到服务端端口,通过输出流向服务端写入数据,服务端获取到客户端发送的数据后,通过输入流读取,读完后通过输出流告诉客户端数据已经处理完成,客户端接收到服务端的回复信息后,关闭流数据关闭socket数据完成最终操作。
这里有必要提一下如何告诉对方,我已经发送完成的信息,这个其实还是挺重要的,一般来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,这样的话服务端会一直等待,直到读取超时。所以怎么通知服务端我已经发送完消息就显得尤为重要。我们可以通过以下几种方式做到:
(1).关闭Socket,当客户端Socket关闭的时候,服务端会收相关关闭信号,这样一来服务端也就知道流已经关闭了,这个时候读取操作完成,就可以继续进行后面的工作了。但是这样会有一些瑕疵,客户端Socket关闭后,将不能接受服务端发送的消息,也不能再次发送消息,如果客户端再想发送消息,那么需要重新建立连接。
(2).调用Socket的shutdownOutput()方法。调用Socket的shutdownOutput()方法,底层会告诉服务端我这边已经写完了,服务端接收到消息后,便知道已经读取完消息,如果服务端有要返回给客户的消息那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭Socket。缺点跟第一种方式一样,如果客户端在想发送信息,那么需要重新建立连接。
(3).服务端与客户端达成一致,统一约定,比如当客户端发送“writeEnd”的时候就表示写入完成了,那么在服务端读取到相关文字的时候就表示知道客户端已经操作完成了,可以进行后续操作了。这种方式的优点就是再次发送数据的时候不需要重新建立连接;缺点就是如果客户端发送的数据中如果有约定字段,那么就会出现问题。
(4).在发送的时候指定数据的长度。我们在发送数据以前获取本次需要发送的数据的总长度,写入输出流,当服务端接收到相关字段后便知道本次需要接收的数据的长度。那么只需要接收制定长度的数据就行。
3. 多线程异步处理网络数据
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Server {
public static void main(String args[]) throws Exception {
int port = 123459;
ServerSocket server = new ServerSocket(port);
System.out.println("LYL_等待连接");
//防止并发过高时创建过多线程耗尽资源
ExecutorService threadPool = Executors.newFixedThreadPool(50);
while (true) {
Socket socket = server.accept();
Receive receiveRunnable = new Receive(socket);
threadPool.submit(receiveRunnable);
}
}
}
Receive实现:
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
public class Receive implements Runnable {
private Socket socket;
public Receive(Socket socket2) {
this.socket=socket;
}
public void run() {
try {
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("LYL_接收到的信息: " + sb);
inputStream.close();
socket.close();
}catch (IOException e) {
e.printStackTrace();
}
}
}
大多数服务器的设计都是这个样子的,因为同一个时间段多台客户端与服务端通信是最常见的了,这里我只修改了服务端代码,客户端就不写了,跟上边的一样。这里通过一个无限While循环来实现监听所有客户端发起的连接请求,用一个实现了Runnable接口的类Receive来处理每一个Socket。最终将Runnable实现类放入一个长度为50的线程池中统一管理,这样的好处是不仅防止了每次连接创建Socket的时间问题,同时也避免了短时间内创建太多线程引起的资源消耗。
三.其他
这里说一下Socket粘包、拆包的小知识点。粘包就是多个单独的数据包连接在了一起。客户端与服务端都有可能发生粘包的情况,客户端需要等到缓冲区满才发送数据会造成粘包;服务端没有及时处理缓冲区接收到的数据,一次接收到多个数据包就会造成服务端粘包现象。可以通过setTcpNoDelay方法设置true,防止出现粘包问题。解决粘包: 对于客户端,我们可以每次发送数据时,将数据长度写入输出流,服务端就可以根据长度定于缓冲区大小了。拆包:当一次发送(Socket)的数据量过大,而底层(TCP/IP)不支持一次发送那么大的数据量,则会发生拆包现象。这个问题我们必须重视,在TCP/IP中:最大报文段长度(MSS)表示TCP传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的 MSS。客户端会尽量满足服务端的要求且不能大于服务端的MSS值,当没有协商时,会使用值536字节。虽然看起来MSS值越大越好,但是考虑到一些其他情况,这个值还是不太好确定,具体详见《TCP/IP详解卷1:协议》。对于拆包发送完一条消息,对于已知数据长度的模式,可以构造相同大小的数组,循环读取。