Socket API和BIO

以下是我学习网络编程的一些笔记。

Socket API

任何现代应用程序,如要访问互联网,必须通过Socket API. Socket这个单词的意思是“插座”,曾经被译为“插口”,现在一般翻译为“套接字”。程序员通常会把socket简写为sock.

Socket API最初是在BSD Unix上出现的,后面Linux系统也实现了一套相同的API. Windows系统的实现则是Windows Sockets API (简称WSA).

Windows对Socket API的实现并不正统。要严肃地学习网络编程,最好的方式是使用Linux系统和C语言。这方面最好的书莫过于《UNIX网络编程 卷1》(简称UNPv1). 《深入理解计算机系统》(简称CSAPP)的最后两章也进行了简单的介绍。

字节序

了解计算机底层的人,都知道小端序(Little Endian, LE)和大端序(Big Endian, BE).

小端和大端的英文源自《格列佛游记》里,两个国家的人在吃鸡蛋时,一个国家的人选择先磕碎鸡蛋较大的一端,另一国家的人选择先磕碎较小的一端(而且他们还会因此打起来)。

字节序决定了整数的存储方式。对于32位整数0x12345678,如果使用小端序存储,则为:

78 56 34 12

如果使用大端序存储,则为:

12 34 56 78

现代PC都使用小端序存储数据。由于历史原因(那时的机器使用大端序),网络传输必须使用大端序,因此需要进行转换。

Linux系统提供了以下函数来完成转换:

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

这些函数的命名存在规律:

  • h表示host,即主机
  • n表示net,即网络
  • l表示long,即32位整数 (通常是IPv4地址)
  • s表示short,即16位整数 (通常是端口号)

小端序→大端序,大端序→小端序转换执行的操作一样的,所以htonX()函数和ntohX()函数的内部实现相同,给两个函数只是为了语义上的清晰。

地址

sockaddr_in结构表示套接字的地址。

struct sockaddr_in {
  uint16_t        sin_family;   // = AF_INET
  uint16_t        sin_port;     // 端口
  struct in_addr  sin_addr;     // IP地址
  unsigned char   sin_zero[8];  // 补齐
};

struct in_addr {
  uint32_t        s_addr;
};

对于已有的套接字:

  • 如果要获得它的本地地址,使用getsockname()
  • 如果要获得它的远端地址,使用getpeername()

系统头文件中定义了两个常用地址:

  • INADDR_ANY0.0.0.0 表示 所有的网络接口
  • INADDR_LOOPBACK127.0.0.1 表示 本地环回

要将字符串表示的IP地址转换为其整数形式,可使用inet_addr(). 如果要完成相反的操作,可使用inet_ntoa(). 这两个函数并不支持IPv6. 所以推荐使用更为强大的inet_pton()inet_ntop().

创建套接字

无论是服务器端还是客户端网络编程,第一步都是获得一个套接字文件描述符。

int socket(int domain, int type, int protocol);
  • domain表示套接字的域,取AF_INET即可
  • type表示套接字的类型,可以理解为协议:
    • SOCK_STREAM表示TCP协议(因为TCP是基于字节的)
    • SOCK_DGRAM表示UDP协议(因为UDP是基于数据报(Datagram)的)
    • SOCK_RAW表示,不使用运输层协议,直接通过IP协议通信
  • protocol填0即可,具体的协议会根据type自动选择

如果套接字创建失败了,此函数会返回-1.

服务器端

对服务器端来说,创建好套接字后,紧接着就是把它绑定(bind)到指定的地址,并开始监听(listen),接受(accept)客户端的请求。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int listenfd, struct sockaddr *addr, int *addrlen);

bind()可能会失败,最常见的原因是地址已被占用。可用netstat -lntp命令查看系统中所有处在监听状态的套接字。

listen()的第二个参数backlog表示队列大小。到达的请求在被接受(accept)前,会进入等待队列。如果队列满了,则请求会被直接拒绝。

调用listen()后,套接字将从主动套接字(连接别人)变为被动套接字(被人连接).

accept()从等待队列中取出一个连接,并返回一个表示它的套接字。这个套接字不同于用来等待连接的套接字。

客户端

对于客户端来说,创建好套接字之后,紧接着就是连接(connect)到服务器。

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);

如果企图连接服务器未监听的端口,或者连接的端口未被防火墙允许,则会得到“连接被拒绝”的错误。

通信

Linux系统下“一切皆文件”,套接字可以被当做普通文件进行读写,即使用read()/write()函数。也可以使用更为底层的、专门为套接字准备的函数recv()send().

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

read()返回0表示EOF,对于套接字来说,这意味着收到了对方发送的FIN报文,也就是对方关闭了套接字的写端。

向套接字write()时可能会出现一种情况:实际写入的字节数小于给定的字节数,这种现象称为short write. 为了提高程序的健壮性,在向套接字写时,通常会被write()包装在循环里,直到数据全部写入/出错时才结束循环。

关闭

TCP是一个全双工的协议,这意味着每个套接字都有两个方向:

  • 自己写,对方读
  • 对方写,自己读

关闭套接字时,可以选择仅关闭(shutdown)自己写的方向,告诉对方“我已经说完了,你还有啥要说的赶紧说”。此时,自己这一端处于FIN_WAIT_2状态,而对方那端处于CLOSE_WAIT状态。

int shutdown(int sockfd, int how);

其中howSHUT_WR,表示关闭写端。它还有另外两个取值SHUT_RD, SHUT_RDWR, 但没什么卵用。

在彻底不使用套接字时,要及时将它释放(close),避免泄漏文件描述符。

int close(int fd);

演示

下面这段C语言程序尝试访问baidu,并在命令行下输出收到的回复。

#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
	int sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock < 0) {
		perror("socket()");
		exit(1);
	}

	struct sockaddr_in address;
	address.sin_family = AF_INET;
	address.sin_addr.s_addr = inet_addr("220.181.38.148"); // baidu's ip
	address.sin_port = htons(80);

	if (connect(sock, (struct sockaddr *)&address, sizeof address) < 0) {
		perror("connect()");
		exit(1);
	}

	{
		char request[] =
			"GET / HTTP/1.1\r\n"
			"Host: baidu.com\r\n"
			"Connection: close\r\n"
			"\r\n";

		const char *p = request;
		int remaining = sizeof request - 1; // without '\0'	
		while (remaining > 0) {
			int written = write(sock, p, remaining);
			if (written < 0) {
				perror("write()");
				exit(1);
			}
			p += written;
			remaining -= written;
		}
	}
		
	{
		char response[4096];
		char *p = response;
		int remaining = sizeof response;
		while (remaining > 0) {
			int num_read = read(sock, p, remaining);
			if (num_read < 0) {
				perror("read()");
				exit(1);
			}
			if (num_read == 0) { // EOF
				break;
			}
			p += num_read;
			remaining -= num_read;
		}
		printf("%.*s", (int)(p - response), response);
	}

	close(sock);

	return 0;
}

BIO

Java最开始提供的网络编程API俗称BIO. 即Blocking I/O. 它提供了对底层Socket API的封装,大大简化了网络编程。

服务器端

服务器端使用ServerSocket类来创建监听套接字,接受客户端连接。

它提供了bind()方法,封装了底层的绑定和监听操作:

public void bind(SocketAddress endpoint, int backlog) throws IOException

更简单的方式则是直接使用构造器:

public ServerSocket(int port,
                    int backlog,
                    InetAddress bindAddr)
             throws IOException

如果出现错误,比如要绑定的端口已经被占用,则构造方法会抛出异常,对象会构建失败。

使用它的accept()方法来接受客户端的连接。

public Socket accept() throws IOException

客户端

客户端使用Socket类向服务器发起连接。

使用connect()方法来发起连接:

public void connect(SocketAddress endpoint) throws IOException

或者直接使用构造器:

public Socket(InetAddress address,
              int port)
       throws IOException

如果连接过程中出错,比如找不到要连接的主机,那么此构造器会抛出异常,对象将创建失败。

通信

调用Socket的以下两个方法能够获得InputStreamOutputStream,分别代表套接字的读端和写端。这是两个相当通用的类,能够与诸多其他类进行合作。

public InputStream getInputStream() throws IOException
public OutputStream getOutputStream() throws IOException

注意观察的话,会发现与系统底层提供的write()函数的签名不同,OutputStreamwrite()方法并不返回写入的字节数。它要么全都写入,要么写入出错,不会出现short write的情况。这是JDK提供的封装。如果查看OpenJDK的实现,会发现这个函数中包含了循环,一直进行写入,直到全部写入/出错为止。

关闭

需要注意的一点是,一般不应该关闭getInputStream()/getOutputStream()返回的流,否则它们会把它们关联的Socket关闭。

使用shutdownOutput()方法关闭套接字的写端。

public void shutdownOutput() throws IOException

使用close()方法关闭套接字。

public void close() throws IOException

让人惊讶的是,虽然想不出会在什么情况下出现,close()方法仍然可能会抛出异常。所以如果在finally块中调用close(),要再写一个try-catch块来包裹它。但由于这种事情很难出现,一般不用在意这时会抛出的异常,甚至可以省略掉e.printStackTrace().

演示

下面的Java程序使用BIO访问baidu,并在命令行下打印输出。

import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;

public class AccessBaiduBIO {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress("220.181.38.148", 80)); // baidu's ip & port

        OutputStream outputStream = socket.getOutputStream();
        String request = "GET / HTTP/1.1\r\n" +
                "Host: baidu.com\r\n" +
                "Connection: close\r\n" +
                "\r\n";
        outputStream.write(request.getBytes("UTF-8"));

        InputStream inputStream = socket.getInputStream();
        byte[] responseBytes = inputStream.readAllBytes();
        String response = new String(responseBytes, "UTF-8");
        System.out.println(response);

        socket.close();
    }
}

你可能感兴趣的:(Socket API和BIO)