背景介绍
什么是 PasteBin
Wikipedia 是这样介绍 pastebin 的:
A pastebin or text storage site is a type of online content-hosting service where users can store plain text (e.g. source code snippets for code review via Internet Relay Chat (IRC)). The first pastebin was the eponymous pastebin.com. Other sites with the same functionality have appeared, and several open source pastebin scripts are available. Pastebins may allow commenting where readers can post feedback directly on the page. GitHub Gists are a type of pastebin with version control.
具体而言,pastebin 就是一个能让用户分享一段静态文本的服务,大家可以打开一些公开的 pastebin 站点来了解:
为什么选 Pastebin
- 功能简单:上传文本存储到服务器然后生成一个链接给别人访问,是不是很简单
- 功能完整:虽然说它很简单,但是涉及到的面也一点都不少
- HTTP POST:上传文本肯定要用
- File System:上传完了肯定要落地,放到磁盘里就好啦
- HTTP GET:别人访问的时候也肯定要用
我去年过年闲的没事儿写好了一个
如果使用通常的工具来实现 pastebin 可以说是毫无难度,比如用 Python + Flask,或者 Node.js + Koa,都是百行以内就完成的小项目。但是如果用 C 呢?
POSIX 中的通讯、存储和网络
学过 C 的兄弟肯定知道 C 标准库中提供了一套非常「原始」的文件操作库函数:
c
extern FILE* fopen(const char* restrict, const char* restrict);
extern size_t fread(void* restrict, size_t, size_t, FILE* restrict);
extern size_t fwrite(void* restrict, size_t, size_t, FILE* restrict);
extern int fclose(FILE *);
其实仔细思考一下,文件的存储是不是也是一种通信呢:
- 常规的「文件」使用场景:A 准备写一篇还蛮长的 E-mail,还没写完突然有事需要出门,于是他将邮件的草稿保存到桌面,等事情解决之后重新加载到文本编辑器中继续
- 相同应用程序,利用文件来跨时间通讯:B 玩了半个小时的 I wanna be the guy,死了 444 次,一气之下关掉了游戏,开发者会将 B 存档的位置和死亡次数都保存下来,等 B 怒气消了再打开游戏时从这里继续
- 利用文件系统,用户与操作系统进行通讯 :C 是一位 Linux 用户,此时他需要临时启动内核的 IP 转发能力,于是他使用命令
echo 1 > /proc/sys/net/ipv4/ip_forward
,向系统 procfs 中的某个文件写入了一个字符,完成了配置。 - 看起来根本没有文件的场景 :D 用户是一个前端开发者,某日突然好奇某个项目中有多少行 ts 代码,于是熟练地拿起管道:
find src -name *.ts | xargs cat | wc -l
所以不难想到,使用 *nix 风格系统的开发者们,会采取一套和「文件操作」有那么点像的接口,来完成「通讯」这件事。
管道和 FIFO 文件
管道在 Unix 中非常常用,形如 command1 | command2
的命令就完成了一次管道的应用:Shell 会先执行 command1,然后将输出作为输入,执行 command2。但是管道也缺乏灵活性,它会导致两个程序必须同时运行,command2 的输入也会跟随 command1 的状态随机被阻塞,而且生命周期隐藏在命令的执行过程中不便于控制,于是 POSIX 又整了一套「命名管道」------管道有了名字,写入方和读取方就可以在任意时刻关闭自己对管道的控制,移交给别的程序,也不用担心自己退出后,另一方仍然在读取/写入数据出现异常。
Wikipedia 关于管道的词条包含了以上两种管道:en.wikipedia.org/wiki/Pipeli...
命名管道可以使用 mkfifo(1), mkfifo(5) 和 mknod(1), mkno 进行创建。
命名管道实际上把文件 IO(stdin, stdout)变成了一种进程间通讯的机制,将之前的一对一隐式通讯转变成了一个可以被多个进程读取和写入的通讯符号。但是在使用过程中依然存在很多问题,其中最关键的是 FIFO 文件是单向数据流,发送方只能发送且接收方只能接受;另一个问题是,FIFO 文件的操作是类似「任播」的,写入方随便写点东西,所有打开此文件的程序中只有最先打开的能进行读取。这些问题导致了 FIFO 的可以用的场景受到了限制,在目前的主流用途中,基本都是用 FIFO 来实现对后台程序的单一控制,比如某应用启动了 mplayer 进行媒体播放,并使用 FIFO 文件来控制 mplayer 的播放状态和文件切换,例如:revadig.blogspot.com/2018/07/con...
Unix Domain Socket
Unix Domain Socket 比之前的 FIFO 更近一步,采用了类似网络的模型来服务进程间通讯(其实你看名字也能看出来,socket 嘛)。和 FIFO 类似,Unix domain socket 在操作系统中看起来也是一个空文件:
bash
$ ls /var/run/pastebin-server
/var/run/pastebin-server
$ stat /var/run/pastebin-server
File: /var/run/pastebin-server
Size: 0 Blocks: 0 IO Block: 4096 socket
Device: 16h/22d Inode: 687 Links: 1
Access: (0777/srwxrwxrwx) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-02-18 14:01:17.212000000 +0800
Modify: 2022-02-18 14:01:17.212000000 +0800
Change: 2022-02-18 14:01:17.212000000 +0800
Birth: -
$ file /var/run/pastebin-server
/var/run/pastebin-server: socket
但是和 FIFO 不同,这次对这个文件的操作不再是继续用文件接口,而是另起炉灶弄了一组套接字(socket)接口:
bind (2)
监听一个 socketaccept (2)
接受一个请求recv (2)
从 socket 中读取数据send (2)
向 socket 发送数据close (2)
关闭 socket
这些接口看起来只有 close 和之前的文件操作有重叠,差距似乎很大,但是实际上干的事情差不多,只不过通讯的模型改变了。被连接的程序(listener)会首先去创建 socket 并监听,bind 和 listen 调用成功后文件系统里就会看到这个 socket 文件,后续别的程序也可以用这个 socket 文件来建立连接。和前面的 FIFO 不同,需要通讯的程序不会直接使用这个 socket 来进行通讯,当连接的发起方打开这个 socket 后,服务端的 listen 函数会返回一个 file descriptor(fd),客户端的 connect 函数也会返回一个 fd,这样两端就可以使用这一对 fd 来进行对等的通讯,双方可以自由的使用 send
和 recv
来互相进行数据的读写,整个时序可以用这张图来看:
在这个模式里,服务端处的文件描述符不再用来直接参与数据的交换,而是用来让服务端感知到有人连接上了我们的 socket,并返回一个针对这次连接的一个文件描述符。这样,当 accept 函数返回后,我们可以新建一个线程(甚至可以 fork 一个进程)来进行后续的处理,服务端的 fd 则继续用来等待别人的连接。
不难看出,其实这套通讯方案已经和现代网络的通讯方案(Stream)十分接近了,只不过 IP 通讯需要走路由器和网卡,而 Unix domain socket 只需要将数据走一轮内核,效率更高,也没有不关心的各种概念(什么 MTU,什么 NAT,什么拥塞控制啥的),因此 MySQL 在 Linux 平台上也是建议优先使用 unix domain socket 来进行连接。
用 Unix Domain Socket 实现 HTTP
既然 unix socket 能提供和 TCP 一样的功能又不像 TCP 需要关注大量的细节,那么实现一个 HTTP Server 显然是用 unix socket 比用 TCP 更简单。但是这玩意实现了显然没用,HTTP 服务器最终肯定还是需要暴露给 Web 让别人访问的,unix socket 只支持本地通讯显然做不到。这时候就需要请出世界上最顶的 Web 服务器------nginx,帮我们来完成从 HTTP over TCP 到 HTTP over Unix Socket 的转变。
不过我们先不关注这个访问的问题,把重点放到实现上,先做一个能跑的工具出来。
基本操作,实现一个 Unix Domain Socket Server
先看代码,逻辑都在注释里:
c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <pthread.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/un.h>
int main()
{
const char *socket_path = "/var/run/pastebin-server.sock";
struct sockaddr_un server_addr, client_addr;
int server_fd;
int client_fd;
// 创建一个接口
if ((server_fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0)
{
perror("socket");
exit(1);
}
// 配置好接口,使用 Unix Domain Socket,并设置好文件名
server_addr.sun_family = AF_UNIX;
strncpy(server_addr.sun_path, socket_path, sizeof(server_addr.sun_path));
fchmod(server_fd, S_IRWXU | S_IRWXG | S_IRWXO);
// 如果这个文件名有文件,把他删了
unlink(socket_path);
umask(0111);
// 监听(创建 socket 文件)
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
perror("bind");
exit(1);
}
// 开始接受请求
listen(server_fd, 10);
puts("Server is up.");
umask(0022);
for (;;)
{
size_t client_len = sizeof(client_addr);
puts("Waiting for connection...");
// 代码会在这里阻塞,直到第一个请求进来
if ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len)) < 0)
{
perror("accept");
exit(1);
}
printf("Connected! Client FD = %d, will send some data to client...\n", client_fd);
const char *message = "Hello World!";
// 给客户端发点东西
int sent_bytes = send(client_fd, message, strlen(message), 0);
printf("send (2) OK, bytes sent = %d, will close the connection\n", sent_bytes);
// 关掉客户端
close(client_fd);
}
return 0;
}
代码的运行效果是,当服务端接收到连接后,直接发个 Hello World 给对面,然后就啥都不管了。运行截图:
改进一下,实现个 ping/pong server
Ping/pong server 顾名思义,我们要开始利用 socket 双端独立通讯的能力,开始互相丢垃圾了,我们希望客户端给服务端发送"ping\n"
后,服务端向客户端返回"pong! (connection_id = X, connection = Y, server = Z)\n"
这样的文本,其中 X 是这个连接的 ID,Y 是这个连接第几次 ping,Z 是这个服务总共第几次 ping。直接看代码,非常简单:
c
struct connection_context
{
int client_fd;
};
static void *connection_handler(void *arg)
{
const struct connection_context *context = (const struct connection_context *)arg;
char read_buf[1024];
char send_buf[1024];
size_t read_offset = 0;
const char *ping_text = "ping\n";
static int connection_count = 0;
static int overall_ping_count = 0;
int connection_ping_count = 0;
int connection_id = ++connection_count;
memset(read_buf, 0, sizeof(read_buf));
printf("[#%d] waiting for ping...\n", connection_id);
for (;;)
{
int bytes_read = recv(context->client_fd, read_buf + read_offset, 1000 - read_offset, 0);
read_offset += bytes_read;
if (strncmp(read_buf, ping_text, strlen(ping_text)) == 0)
{
printf("[#%d] ping received, will send statistics...\n", connection_id);
sprintf(send_buf, "pong! (connection = %d, server = %d)\r\n", ++connection_ping_count, ++overall_ping_count);
send(context->client_fd, send_buf, strlen(send_buf), 0);
memset(read_buf, 0, sizeof(read_buf));
read_offset = 0;
continue;
}
if (read_offset > strlen(ping_text))
{
printf("[#%d] unexpected input, close connection.\n", connection_id);
close(context->client_fd);
free((void *)context);
return NULL;
}
}
}
int main()
{
// ... 省略一样的代码
for (;;)
{
size_t client_len = sizeof(client_addr);
pthread_t thread;
struct connection_context *context = NULL;
puts("Waiting for connection...");
// 代码会在这里阻塞,直到第一个请求进来
if ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len)) < 0)
{
perror("accept");
exit(1);
}
printf("Connected! Client FD = %d\n", client_fd);
// 分配内存,准备创建线程
context = (struct connection_context *)malloc(sizeof(struct connection_context));
context->client_fd = client_fd;
// 启动一个线程
pthread_create(&thread, NULL, connection_handler, context);
pthread_detach(thread);
}
return 0;
}
运行效果:
再进一步,实现一个简单的 HTTP 服务器
HTTP 1.0 和之前实现的 ping pong 服务器的区别就是,当连接建立后,客户端会向服务端发送请求的 header,之后服务端解析 header 并返回响应,整个过程一来一回,完成后连接会被关闭。具体关于 HTTP 1.0 的细节可以参考 datatracker.ietf.org/doc/html/rf...
至于为什么不实现 HTTP 1.1 或者 HTTP 2 甚至是 3......因为高版本的 HTTP 增加了很多复杂的机制(例如 connection keepalive,SSL/TLS,multiplexing 等),自己手写已经不太现实了,引入 OpenSSL 之类的又会导致代码庞大不容易理解,不是我们的目标了。
我们首先需要了解一下 HTTP 报文中 Request 长啥样:
http
GET /path/to/the/resource HTTP/1.0\r\n
Host: localhost\r\n
User-Agent: some-browser/1.0\r\n
Accept: */*\r\n
\r\n
所以我们需要做的事情是,持续地读取这个流,并找到其中连续的 "\r\n\r\n"
(header 结束的标记)。如果我们在 Header 中看到了 Content-Length,说明这个请求中包含 body,需要继续读取。为了方便搜索这个 Content-Length 头,我们可以顺便把所有字符改成小写(除了最开头的 path,这个地方需要保留大小写,其他的 header 都是大小写不敏感的)。
至于持续的读流,也不能毫无顾忌的永远读下去,我们姑且设定允许的最大 HTTP 头为 3000 字节,超过后直接关闭连接(后续会实现一下 HTTP 413 request entity too large)
c
static void *connection_handler(void *arg)
{
const struct connection_context *context = (const struct connection_context *)arg;
char read_buf[4096];
size_t read_offset = 0;
static int connection_count = 0;
int http_header_fullfilled = 0;
int connection_id = ++connection_count;
memset(read_buf, 0, sizeof(read_buf));
printf("[#%d] waiting for ping...\n", connection_id);
for (;;)
{
int bytes_read = recv(context->client_fd, read_buf + read_offset, 1000 - read_offset, 0);
read_offset += bytes_read;
char *p = read_buf;
while (*p)
{
if (p[0] == '\r' && p[1] == '\n')
{
if (p[2] == '\r' && p[3] == '\n')
{
http_header_fullfilled = 1;
break;
}
else
{
p += 2;
}
}
else
{
*p = tolower(*p);
p++;
}
}
if (read_offset > 3000)
{
printf("[#%d] request header too large.\n", connection_id);
close(context->client_fd);
return NULL;
}
if (http_header_fullfilled)
{
break;
}
}
// 就返回一个 Hello, you are visiting {PATH} 完事儿了
// HTTP/1.0 报文的第一行是:
// `GET /path HTTP/1.0`
// 我们想办法提取一下 path 就行了,暴力一点
// (连正则都没有真可怜啊......)
char http_request_path[1024] = {0};
char *src = read_buf + 4; // 跳过前面的 GET
char *dst = http_request_path;
while (*src != ' ')
*dst++ = *src++; // *MAGIC*
char http_response[2048];
char http_response_body[2048];
sprintf(http_response_body, "Hello, you are visiting %s", http_request_path);
sprintf(http_response, "HTTP/1.0 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: %ld\r\n"
"\r\n"
"%s\n",
strlen(http_response_body), http_response_body);
send(context->client_fd, http_response, strlen(http_response), 0);
close(context->client_fd);
free((void *)context);
return NULL;
}
因为这次已经是 HTTP 了,可以使用大名鼎鼎的 cURL 来进行测试了!运行效果:
最后一步,实现 PasteBin
感觉没啥好说的了,剩下的就是业务搬砖了,直接看我开源的代码吧:
因为前面的例子的代码都是从这里裁出来的,所以看起来应该不会有什么难度(吧)
部署和使用
因为这个 PasteBin 真的只实现了文件的 POST 请求,完全没有做 GET 访问和前端,所以我们还需要用点别的东西来真正的部署,首先需要一个 nginx 来解决文件的访问:
nginx
server {
listen 80;
server_name localhost;
autoindex on;
sub_filter_once off;
add_header Access-Control-Allow-Origin "*";
client_max_body_size 5m;
default_type text/plain;
location / {
if ($request_method = POST) {
proxy_pass http://unix:/tmp/paste-bin.sock:;
}
}
root /var/www/html;
}
通过合适的配置编译代码,监听 /tmp/paste-bin.sock 并将文件落到 /var/www/html,这样访问文件内容时就可以让 nginx 来完成读取和发送了。
我自己目前有一个实例开着,贴文件可以用命令来完成:
bash
curl -XPOST --data-binary "@path/to/the/file" http://devbox/
服务会返回文件贴好的地址,整个操作过程如下:
因为实在是太底层了,这个服务运行的内存开销也是小到离谱,内存占用不足 1MB:
text
● pastebin.service - None
Loaded: loaded (/etc/systemd/system/pastebin.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2022-02-18 14:01:17 CST; 6h ago
Main PID: 531 (pastebin)
Tasks: 1 (limit: 9530)
Memory: 252.0K
CPU: 3ms
CGroup: /system.slice/pastebin.service
└─531 /usr/local/bin/pastebin
后续预告
有了 C 实现 HTTP 那么用 C++ 实现 WebSocket 也就很顺理成章了,过一阵子就来更新~