以下是我学习网络编程的一些笔记。
任何现代应用程序,如要访问互联网,必须通过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_ANY
即 0.0.0.0
表示 所有的网络接口INADDR_LOOPBACK
即127.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);
其中how
取SHUT_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;
}
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
的以下两个方法能够获得InputStream
和OutputStream
,分别代表套接字的读端和写端。这是两个相当通用的类,能够与诸多其他类进行合作。
public InputStream getInputStream() throws IOException
public OutputStream getOutputStream() throws IOException
注意观察的话,会发现与系统底层提供的write()
函数的签名不同,OutputStream
的write()
方法并不返回写入的字节数。它要么全都写入,要么写入出错,不会出现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();
}
}