1.Socket 定义
套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。
传输层实现端到端的通信,因此,每一个传输层连接由两个端点/。那么,传输层连接的断电是什么呢,不是主机,不是主机的IP地址,不是应用进程,也不是传输层的协议端口,传输层连接的端点叫做套接字(socket)。根据RFC793的定义,端口号拼接到IP地址就构成了套接字。所谓套接字,实际上是一个通信端点,每个套接字都有一个套接字序号,包括主机的IP地址与一个16为的主机端口号,即形如(主机IP地址:端口号)。例如,如果IP地址是210.37.145.1,而端口号是23,那么得到套接字就是(210.37.145.1:23).总之,套接字Socket=(IP地址:端口号),套接字的表示方法是点分十进制的IP地址后面写上端口号,中间用冒号或逗号隔开。每一个传输层链接唯一地被通信两端的两个端点(即两个套接字)所确定。
2. Hello/Hi
下面用Java简单的实现一个基于Socket通信的hello/hi程序:
Server端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; class Server { private Socket server; private Server() { try { System.out.println("启动服务器!"); ServerSocket serverSocket = new ServerSocket(8888); server = serverSocket.accept(); } catch (IOException e) { e.printStackTrace(); } } private void listen() { try { System.out.println("Listening!......"); //从Socket中获得输入流 InputStreamReader in = new InputStreamReader(server.getInputStream()); BufferedReader br = new BufferedReader(in); //读取输入流中的一行并输出 System.out.println(br.readLine()); } catch (IOException e) { e.printStackTrace(); } } private void send(String msg) { try { PrintWriter out = new PrintWriter(server.getOutputStream(), true); out.println("Server:" + msg); } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { Server se = new Server(); String msg = ""; Scanner cin = new Scanner(System.in); while (!msg.equals("#")) { se.listen(); System.out.print("输入信息:"); msg = cin.nextLine(); se.send(msg); } } }
Client端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.Socket; import java.util.Scanner; class Client { private Socket client; private Client() { try { client = new Socket("127.0.0.1", 8888); } catch (Exception e) { e.printStackTrace(); } } private void send(String msg) { try { PrintWriter out = new PrintWriter(client.getOutputStream(), true); out.println("Client:" + msg); } catch (IOException e) { e.printStackTrace(); } } private void listen() { try { System.out.println("Listening!......"); InputStreamReader in = new InputStreamReader(client.getInputStream()); BufferedReader br = new BufferedReader(in); System.out.println(br.readLine()); } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { String msg = ""; Client c = new Client(); Scanner cin = new Scanner(System.in); while (!msg.equals("#")) { System.out.print("Input: "); msg = cin.nextLine(); c.send(msg); c.listen(); } } }
执行结果:
Client发送hello,Server回应hi
调用栈分析:
为什么java实现socket通信这么方便呢,这就需要我们深入源码去一探究竟了,这里以Server端为例,追踪调用栈:
实例化ServerSocket时,构造函数会调用ServerSocket的bind()方法,
public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException { ...if (port >= 0 && port <= 65535) { if (backlog < 1) { backlog = 50; } try { this.bind(new InetSocketAddress(bindAddr, port), backlog); } catch (SecurityException var5) { this.close(); throw var5; } catch (IOException var6) { this.close(); throw var6; } } else { throw new IllegalArgumentException("Port value out of range: " + port); } }
public void bind(SocketAddress endpoint, int backlog) throws IOException { ...try { SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkListen(epoint.getPort()); } this.getImpl().bind(epoint.getAddress(), epoint.getPort()); this.getImpl().listen(backlog); this.bound = true; } catch (SecurityException var5) { this.bound = false; throw var5; } catch (IOException var6) { this.bound = false; throw var6; } ... }
该方法会调用继承自抽象类AbstractPlainSocketImpl的PlainSocketImpl的socketBind()方法,在该方法中会调用native方法bind0(),从而实现将一个socket连接绑定到指定的本地IP地址和端口号。
注:native关键字标注的方法为本地方法,一般是用其他语言写成的函数,常用来实现java语言对OS底层接口的访问。Java语言本身不能直接对操作系统底层进行操作,但是java允许程序通过Java本机接口JNI,使用C/C++等其他语言实现这种操作。在Windows系统中,使用native关键字标注的本地方法在编译时会生成一个动态链接库(.dll文件)为Java语言提供响应的本地服务。
void socketBind(InetAddress address, int port) throws IOException { int nativefd = this.checkAndReturnNativeFD(); if (address == null) { throw new NullPointerException("inet address argument is null."); } else if (preferIPv4Stack && !(address instanceof Inet4Address)) { throw new SocketException("Protocol family not supported"); } else { bind0(nativefd, address, port, useExclusiveBind); if (port == 0) { this.localport = localPort0(nativefd); } else { this.localport = port; } this.address = address; } }
接着,同样的步骤从ServerSocket的listen()方法可以一直追溯到PlainSocketImpl的sokectListen()方法的listen0(),该方法主要为了设置允许的最大连接请求队列长度,当请求队列满时,拒绝后来的连接请求。
最后,同样,从ServerSocket类的accept()追溯到accept0(),等待连接请求的到来。
具体调用关系如下图所示:(图转自https://www.cnblogs.com/Mr-Tiger/p/11969934.html)
3. Java Socekt API与Linux Socket API对比
Linux提供的响应Socket API在sys/socket.h中,分别为:
int socket(int domain, int type, int protocol);
从函数名就可以看出,socket函数可以创建一个socket,
其中,domain参数告诉系统使用哪个底层协议族,对TCP/IP协议族而言,该参数应该设置为PF_INET或PF_INET6,没错,分别对应IPv4和IPv6,对于UNIX本地域协议族而言,该参数应该设置为PF_UNIX,具体socket系统支持的所有协议族,请读者自行参考其man手册。
type参数指定服务类型,主要有SOCK_STREAM流服务和SOCK_UGRAM数据报服务,对TCP/IP协议族而言,其值取SOCK_STREAM表示传输层使用TCP协议,取SOCK_DGRAM表示传输层使用UDP协议。
protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议。不过这个值通常是唯一的,几乎在所有情况下,我们都应该把它设置为0,表示使用默认协议。
熟悉UNIX/Linux的同学应该知道,在这类系统中,所有的东西都是文件,socket也不例外,可读,可写,可控制,可关闭的文件描述符。socket函数调用成功时返回一个socket文件描述符
int bind(int sockfd, const struct sockaddr *addr, socklen_t addelen)
bind将my_addr所指的socket地址分配给未命名的socketfd文件描述符,addrlen参数指出该socket地址的长度。bind成功时返回0,失败则返回-1并设置errno,常见为EACCES和EASSRINUSE,前者代表被绑定的地址是受保护的地址,仅超级用户能够访问,后者表示被绑定的地址正在使用中。
值得注意的是,Client端通常不需要bind socket而是采用匿名方式,OS自动分配socket地址。
int listen(int sockfd, int backlog);
socket被bind之后还不能马上接收客户的连接,需要创建一个监听队列存放待处理的客户连接,服务端通过listen进行监听。
sockfd参数指定被监听的socket,backlog参数体时内核监听队列的最大长度,如果超过,服务器将不再受理新的客户端连接,客户端也将收到ECONNREFUSED错误信息。listen成功返回0,失败返回-1并设置errno。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
最后一步为accept,其中sockfd参数是执行过listen系统调用的监听socket,addr参数用来获取被接受连接的远程socket地址,该socket地址的长度由addlen参数指出,accpet成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写该socket来与被接受连接对应的客户端通信,accept失败时返回-1并设置errno。
其实,Java也是调用Linux网络API实现网络通信的,通过调用这些系统API来实现它的底层功能的,从调用分析时贴出的源码中可以看出,在Java的ServerSocket创建时就对方法进行了socket的bind和listen操作,一个方法就封装了3个API,即ServerSocket的实例化过程就对应了Linux中的socket(),bind(),listen(),而Java中的accept对应了Linux的accept函数,相关对应关系如下图所示(图来源https://blog.csdn.net/vipshop_fin_dev/article/details/102966081):
所以,Java将这一切全都封装起来,这使得面向网络的编程对于Java程序员来说变得十分简单,我们只需要知道使用的哪一个类(实际上也就是ServerSocket和Socket两个类),为它们传入必要的地址参数,就能够轻松实现Socket通信。