这个博客本身就是用它自己托管出来的。整份代码只有一个源文件,不依赖任何 Web 框架,核心逻辑就是把 HTTP 请求翻译成一次文件读取。

为什么不用现成的框架

用 nginx 当然更省事。但我想搞清楚一件很具体的事:一个请求从网卡进来,到最后变成浏览器里的一行字,中间到底发生了什么。自己写一遍,比读十篇博客都管用。

HTTP 并不神秘,它只是几行文本加上一段二进制。真正麻烦的是边界情况。

第一步:套接字

服务器做的事可以拆成四步:创建套接字、绑定端口、监听、循环接受连接。在 Windows 上要用 WinSock2,还得先调用 WSAStartup 初始化。

SOCKET serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8080);
serverAddr.sin_addr.s_addr = INADDR_ANY;
bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
listen(serverSocket, SOMAXCONN);

这段代码在 Windows 上能跑,但换到 Linux 就编不过了 —— WinSock2.h 是 Windows 独有的。移植的工作量其实不大,主要是把类型和调用方式换掉。

移植到 Linux

POSIX socket 的接口更直白一些:文件描述符就是 int,关闭用 close(),不需要初始化,也不用手动清理。真正需要改的是这几处:

  • SOCKET 换成 int,INVALID_SOCKET 换成 -1
  • closesocket() 换成 close(),WSACleanup() 直接删掉
  • 补上 <sys/socket.h>、<netinet/in.h>、<arpa/inet.h>
  • 链接时加 -pthread
int srv = ::socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

几个必须处理的细节

能跑起来和能放在公网上,中间隔着好几个坑。下面这几个是我实际踩到的:

1. 目录穿越

最开始的写法是 WEB_ROOT + requestPath 直接拼接。请求 /../../etc/passwd 就能读到系统文件。正确做法是先 realpath() 解析出真实路径,再校验它确实位于网站根目录之下。

2. SIGPIPE

这个坑很隐蔽:客户端在服务器发送响应之前就断开连接,内核会给进程发 SIGPIPE,默认行为是直接终止进程。表现就是"偶尔整个服务挂掉"。一行 signal(SIGPIPE, SIG_IGN) 解决。

3. 并发

单线程阻塞的服务器,浏览器打开一个页面要串行加载 HTML、CSS、JS、图片,体感非常慢。改成每个连接一个线程之后,页面加载时间立刻正常了。

4. 编码

源文件是 UTF-8 且带中文注释,用 MSVC 编译时默认按系统代码页(GBK)解析,会在一行看起来完全正常的地方报语法错误。加 /utf-8 才行。这类问题最难查,因为报错位置和真正的原因毫无关系。

部署

编译和启动都很简单,剩下的交给 systemd 托管,进程挂了会自动拉起:

g++ -O2 -std=c++17 -pthread -o BlogServer main_linux.cpp

# 用环境变量指定网站根目录和端口
WEB_ROOT=/opt/blog/web PORT=80 ./BlogServer

写在最后

这个服务器还很朴素:没有 HTTPS,没有 keep-alive,也不支持动态内容。但它足够小,小到我能完整地理解每一行在做什么。

如果哪天访问量真的上来了,我会毫不犹豫地换 nginx。但在此之前,自己写一遍带来的确定性,是配置现成软件换不来的。


记录技术、生活与思考。