1:Socket探究
和浏览器不同的是,协议栈的工作我们从表面上是看不见的,可能比较难以想象。因此,在实际探索之前,我们先来对协议栈做个解剖,看看里面到底有些什么。协议栈的内部如图 2.1 所示,分为几个部分,分别承担不同的功能。这张图中的上下关系是有一定规则的,上面的部分会向下面的部分委派工作,下面的部分接受委派的工作并实际执行,这一点大家在看图时可以参考一下。当然,这一上下关系只是一个总体的规则,其中也有一部分上下关系不明确,或者上下关系相反的情况,所以也不必过于纠结。此外,对于图中的每个部分以及它们的工作方式,本章将按顺序进行介绍,因此对于里面的细节现在看不明白也没关系,只要大体上看出有哪些组成要素就可以了。下面我们从上到下来看一遍。图中最上面的部分是网络应用程序,也就是浏览器、电子邮件客户端、Web 服务器、电子邮件服务器等程序,它们会将收发数据等工作委派给下层的部分来完成。当然,除了浏览器之外,其他应用程序在网络上收发数据的操作也都是类似上面这样的,也就是说,尽管不同的应用程序收发的数据内容不同,但收发数据的操作是共通的。因此,下面介绍的内容不仅适用于浏览器,也适用于各种应用程序。
应用程序的下面是 Socket 库,其中包括解析器,解析器用来向 DNS服务器发出查询。再下面就是操作系统内部了,其中包括协议栈。协议栈的上半部分有两块,分别是负责用 TCP 协议收发数据的部分和负责用 UDP 协议收发数
据的部分,它们会接受应用程序的委托执行收发数据的操作。关于 TCP 和UDP 我们将在后面讲解,现在大家只要先记住下面这句话就可以了:像浏览器、邮件等一般的应用程序都是使用 TCP 收发数据的,而像 DNS 查询等收发较短的控制数据的时候则使用 UDP。
下面一半是用 IP 协议控制网络包收发操作的部分。在互联网上传送数据时,数据会被切分成一个一个的网络包 ,而将网络包发送给通信对象的操作就是由 IP 来负责的(网络层)。此外,IP 中还包括 ICMP 协议和 ARP 协议。ICMP 用于告知网络包传送过程中产生的错误以及各种控制消息,ARP 用于根据 IP 地址查询相应的以太网 MAC 地址 。IP 下面的网卡驱动程序负责控制网卡硬件,而最下面的网卡则负责完成实际的收发操作,也就是对网线中的信号执行发送和接收的操作(物理层)。
我们已经了解了协议栈的内部结构,而对于在数据收发中扮演关键角色的套接字,让我们来看一看它具体是个怎样的东西。
在协议栈内部有一块用于存放控制信息的内存空间,这里记录了用于控制通信操作的控制信息,例如通信对象的 IP 地址、端口号、通信操作的进行状态等。本来套接字就只是一个概念而已,并不存在实体,如果一定要赋予它一个实体,我们可以说这些控制信息就是套接字的实体,或者说存放控制信息的内存空间就是套接字的实体(一般来说套接字=ip+端口)。协议栈在执行操作时需要参阅这些控制信息 。例如,在发送数据时,需要看一看套接字中的通信对象 IP 地址和端口号,以便向指定的 IP 地址和端口发送数据。在发送数据之后,协议栈需要等待对方返回收到数据的响应信息,但数据也可能在中途丢失,永远也等不到对方的响应。在这样的情况下,我们不能一直等下去,需要在等待一定时间之后重新发送丢失的数据,这就需要协议栈能够知道执行发送数据操作后过了多长时间。为此,套接字中必须要记录是否已经收到响应,以及发送数据后经过了多长时间,才能根据这些信息按照需要执行重发操作。上面说的只是其中一个例子。套接字中记录了用于控制通信操作的各种控制信息,协议栈则需要根据这些信息判断下一步的行动,这就是套接字的作用。
讲了这么多抽象的概念,可能大家还不太容易理解,所以下面来看看真正的套接字。在 Windows 中可以用 netstat 命令显示套接字内容(图 2.2)图中每一行相当于一个套接字,当创建套接字时,就会在这里增加一行新的控制信息,赋予“即将开始通信”的状态,并进行通信的准备工作,如分配用于临时存放收发数据的缓冲区空间。既然有图,我们就来讲讲图上这些到底都是什么意思。比如第 8 行,
它表示 PID B 为 4 的程序正在使用 IP 地址为 10.10.1.16 的网卡与 IP 地址为10.10.1.80 的对象进行通信。此外我们还可以看出,本机使用 1031 端口,对方使用 139 端口,而 139 端口是 Windows 文件服务器使用的端口,因此我们就能够看出这个套接字是连接到一台文件服务器的。我们再来看第 1行,这一行表示 PID 为 984 的程序正在 135 端口等待另一方的连接,其中本地 IP 地址和远程 IP 地址都是 0.0.0.0,这表示通信还没开始,IP 地址不确定。你还可以看到,本地ip确定的,对方ip不确定,说明还没有确定和谁通信。
注意:Process ID(进程标识符)的缩写,是操作系统为了标识程序而分配的编号,使用任务管理器可以查询所对应的程序名称。
看过套接字的具体样子之后,我们的探索之旅将继续前进,看一看当浏览器调用 socket A 、connect 等 Socket 库中的程序组件时,协议栈内部是如何工作的。
首先,我们再来看一下浏览器通过 Socket 库向协议栈发出委托的一系列操作。这张图和介绍浏览器时用的那张图的内容大体相同,只作了少许修改。正如我们之前讲过的那样,浏览器委托协议栈使用 TCP 协议来收发数据,因此下面的讲解都是关于 TCP 的。首先是创建套接字的阶段 。应用程序调用 socket 申请创建套接字,协议栈根据应用程序的申请执行创建套接字的操作。在这个过程中,协议栈首先会分配用于存放一个套接字所需的内存空间。用于记录套接字控制信息的内存空间并不是一开始就存在的,因此我们先要开辟出这样一块空间来 ,这相当于为控制信息准备一个容器。但光一个容器并没有什么用,还需要往里面存入控制信息。套接字刚刚创建时,数据收发操作还没有开始,因此需要在套接字的内存空间中写入表示这一初始状态的控制信息。到这里,创建套接字的操作就完成了。接下来,需要将表示这个套接字的描述符告知应用程序。描述符相当于用来区分协议栈中的多个套接字的号码牌。收到描述符之后,应用程序在向协议栈进行收发数据委托时就需要提供这个描述符。由于套接字中记录了通信双方的信息以及通信处于怎样的状态,所以只要通过描述符确定了相应的套接字,协议栈就能够获取所有的相关信息,这样一来,应用程序就不需要每次都告诉协议栈应该和谁进行通信了。
2:客户端代码
利用Java的网络API,新建Socket对象,再使用connect方法连接服务器
Socket socket = new Socket(); // 连接本地,服务器端口为2500; socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(), 2500));
并打印客户端以及服务器相关信息
//打印客户端以及服务器相关信息 System.out.println("已发起服务器连接,并进入后续流程~"); System.out.println("客户端信息:" + socket.getLocalAddress() + " P:" + socket.getLocalPort()); System.out.println("服务器信息:" + socket.getInetAddress() + " P:" + socket.getPort());
然后将发送数据这个行为写成一个send函数,在函数中,需要构建三个流,分别是键盘输入流, 用于从键盘接收数据,socket输出流,用于发送数据到服务器,以及输入流,用于从服务器接受响 应信息。
// 构建键盘输入流 InputStream in = System.in; BufferedReader input = new BufferedReader(new InputStreamReader(in)); // 得到Socket输出流,并转换为打印流 OutputStream outputStream = client.getOutputStream(); PrintStream socketPrintStream = new PrintStream(outputStream); // 得到Socket输入流,并转换为BufferedReader InputStream inputStream = client.getInputStream(); BufferedReader socketBufferedReader = new BufferedReader(new InputStreamReader(inputStream));
下面是完整代码:
import java.io.*; import java.net.Inet4Address; import java.net.InetSocketAddress; import java.net.Socket; public class Client { public static void main(String[] args) throws IOException { Socket socket = new Socket(); // 连接本地,端口2500; socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(), 2500)); //打印客户端以及服务器相关信息 System.out.println("已发起服务器连接,并进入后续流程~"); System.out.println("客户端信息:" + socket.getLocalAddress() + " P:" + socket.getLocalPort()); System.out.println("服务器信息:" + socket.getInetAddress() + " P:" + socket.getPort()); try { // 发送接收数据 send(socket); } catch (Exception e) { System.out.println("异常关闭"); } // 释放资源 socket.close(); System.out.println("客户端已退出~"); } private static void send(Socket client) throws IOException { // 构建键盘输入流 InputStream in = System.in; BufferedReader input = new BufferedReader(new InputStreamReader(in)); // 得到Socket输出流,并转换为打印流 OutputStream outputStream = client.getOutputStream(); PrintStream socketPrintStream = new PrintStream(outputStream); // 得到Socket输入流,并转换为BufferedReader InputStream inputStream = client.getInputStream(); BufferedReader socketBufferedReader = new BufferedReader(new InputStreamReader(inputStream)); boolean flag = true; do { // 键盘读取一行 String str = input.readLine(); socketPrintStream.println(str); // 从服务器读取响应信息,如果是bye,退出循环 String echo = socketBufferedReader.readLine(); if ("bye".equalsIgnoreCase(echo)) { flag = false; }else { System.out.println("server:"+echo); } }while (flag); // 资源释放 socketPrintStream.close(); socketBufferedReader.close(); } }
3:服务器端代码
利用ServerSocket接口,将端口设置为2500,由于一个服务器可能会接收到多个客户端请求,所以 使用多线程去处理客户端请求。 在处理客户端请求的过程中,同样需要构建两个流,一个是输出流,用于回复响应信息,一个是输 入流,从客户端收取信息。如果收取的是bye,则把处理客户端请求的函数关闭掉,下面是完整代码:
import java.io.*; import java.net.ServerSocket; import java.net.Socket; public class Server { public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(2500);//服务器端口为2500 System.out.println("服务器准备就绪~"); // 等待客户端连接 for (; ; ) { // 得到客户端 Socket client = server.accept(); // 客户端构建异步线程 ClientHandler clientHandler = new ClientHandler(client); // 启动线程 clientHandler.start(); } } /** * 客户端消息处理 */ private static class ClientHandler extends Thread { private Socket socket; private boolean flag = true; ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { super.run(); System.out.println("新客户端连接:" + socket.getInetAddress() + " P:" + socket.getPort()); try { // 得到打印流,用于数据输出;服务器回送数据使用 PrintStream socketOutput = new PrintStream(socket.getOutputStream()); // 得到输入流,用于接收数据 BufferedReader socketInput = new BufferedReader(new InputStreamReader( socket.getInputStream())); InputStream in = System.in; BufferedReader input = new BufferedReader(new InputStreamReader(in)); do { // 从客户端拿到一条数据 String str = socketInput.readLine(); if ("bye".equalsIgnoreCase(str)) { flag = false; // 回送 socketOutput.println("bye"); } else { // 打印到屏幕。并回送数据长度 System.out.println("client:"+str); String s = input.readLine(); socketOutput.println(s); } } while (flag); socketInput.close(); socketOutput.close(); } catch (Exception e) { System.out.println("连接异常断开"); } finally { // 连接关闭 try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } System.out.println("客户端已退出:" + socket.getInetAddress() + " P:" + socket.getPort()); } } }
4:实验结果
运行服务器端监听端口,再运行客户端
客户端发送hi,服务器回送hello,客户端发送bye结束连接
5:Java调用的Linux Socket接口
在Java Socket类里面有以下函数
private boolean created; private boolean bound; private boolean connected; private boolean closed; private Object closeLock; private boolean shutIn; private boolean shutOut; SocketImpl impl; private boolean oldImpl; private static SocketImplFactory factory = null; private static Set> options; private static boolean optionsSet = false;
bind()函数
正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数的三个参数分别为:
- sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
- addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是:
struct sockaddr_in { sa_family_t sin_family; /* address family: AF_INET */ in_port_t sin_port; /* port in network byte order */ struct in_addr sin_addr; /* internet address */ }; /* Internet address. */ struct in_addr { uint32_t s_addr; /* address in network byte order */ };
struct sockaddr_in6 { sa_family_t sin6_family; /* AF_INET6 */ in_port_t sin6_port; /* port number */ uint32_t sin6_flowinfo; /* IPv6 flow information */ struct in6_addr sin6_addr; /* IPv6 address */ uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */ }; struct in6_addr { unsigned char s6_addr[16]; /* IPv6 address */ };
#define UNIX_PATH_MAX 108 struct sockaddr_un { sa_family_t sun_family; /* AF_UNIX */ char sun_path[UNIX_PATH_MAX]; /* pathname */ };
- addrlen:对应的是地址的长度。
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
listen()和connect()函数
如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
int listen(int sockfd, int backlog); int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
accept()函数
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //返回连接connect_fd
- 参数sockfd
- 参数sockfd就是上面解释中的监听套接字,这个套接字用来监听一个端口,当有一个客户与服务器连接时,它使用这个一个端口号,而此时这个端口号正与这个套接字关联。当然客户不知道套接字这些细节,它只知道一个地址和一个端口号。
- 参数addr
- 这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。
- 参数len
- 如同大家所认为的,它也是结果的参数,用来接受上述addr的结构的大小的,它指明addr结构所占有的字节个数。同样的,它也可以被设置为NULL。
如果accept成功返回,则服务器与客户已经正确建立连接了,此时服务器通过accept返回的套接字来完成与客户的通信。
注意:
accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字,这个套接字是连接套接字。
此时我们需要区分两种套接字,
监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)
连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。
一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
close()函数
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
#includeint close(int fd);
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。