做这个项目之前,我先说说为什么有“用C语言写HTTP服务器”这个念头。我始终觉得,Web开发玩到今天,框架一大把,脚手架一敲就出来一个服务,但HTTP协议本身对很多人来讲反而成了黑盒。你可以熟练地用Spring写接口、用Express挂路由,但真要问你“浏览器发一个POST请求过来,数据是长什么样的?服务端怎么知道请求体到没到尽头?Cookie又是怎么被识别出来的?”能答清楚的不多。所以我一直建议想深入Web底层的朋友,腾出点时间用C语言手搓一个HTTP服务器,从socket开始,一点点解请求、拼响应,把协议层的东西彻底弄明白。
这个项目的标题是“用C语言制作一个简易HTTP服务器:实现手机商城用户认证与搜索”。它做的事情说起来并不复杂:在Linux上监听端口,用原生socket接口接收请求,解析HTTP协议的请求行、请求头、请求体,然后处理两类核心业务——用户注册登录的认证逻辑,以及商品关键词搜索。整个项目不依赖任何第三方Web框架,纯C语言手写,数据用文件存储,会话用内存哈希表维护。适合什么人呢?正在学计算机网络、学了C语言想实践落地的同学,或者日常工作被框架包围、想重新打一遍底层基础的后端开发。它能帮你把TCP连接、HTTP报文、请求解析、线程并发、数据存储这一条链路完完整整地串起来。
1. 项目拆解:先理清楚这不仅仅是“写个服务器”
光看标题容易被骗,以为这个项目就是调几个socket函数、能返回个Hello World就算完。实际上“手机商城用户认证与搜索”这半句,才是真正的工作量所在。这个项目我拆成三个层次来看,每一层都有各自要解决的核心问题。
1.1 标题里藏着的三层技术栈
第一层是网络协议层。你得用socket API做TCP通信,监听端口、接受连接、读取客户端发来的字节流。HTTP报文是纯文本格式,你需要自己写解析器把请求行、请求头、请求体这三段结构拆出来。这里有个容易被新手忽略的地方:TCP是无边界的字节流,一次recv()拿到的内容可能只是半个请求,也可能已经把下一个请求的一部分带进来了,这就要处理粘包和半包问题。
第二层是业务支撑层。用户认证需要设计用户数据结构、密码存储方式、会话令牌机制;搜索需要设计商品数据结构、关键词匹配算法、结果排序规则。这些业务逻辑在Java或者Python里写起来很顺手,因为字符串操作、哈希表、动态数组这些都有现成的库。但C语言里全要自己动手:哈希表要自己写或者找现成的开源实现,字符串拼接要自己盯着缓冲区长度,动态数组得自己realloc管理生命周期。
第三层才是并发支撑层。手机商城肯定不止一个用户同时访问,服务器至少要能处理多连接并发。我选择用多线程——accept()拿到一个客户端连接之后,就pthread_create()开一个线程去处理。这样实现直观、排查方便,但立刻带来线程安全问题:用户表、会话表、商品表这些全局数据被多个线程同时读写,不加锁就会出现数据错乱。
三层对应三种能力:协议解析能力、C语言数据结构与内存管理能力、多线程并发控制能力。标题看起来只是一个服务器,实际上把这三块都练到了。
1.2 为什么不选更“轻松”的方案
有人可能会问,这个项目用Python写不香吗?几十行代码就能跑起来,为啥非C不可。我的理由有两点。
第一,C语言没有隐形的封装,HTTP解析的所有细节都暴露在你面前。你用Python的HTTP服务器模块,底层早就帮你把请求解析好了,你拿到的直接就是干净的路径和参数。但这恰恰掩盖了HTTP协议的细节:请求行里的空格分隔、头部字段的大小写不敏感、Content-Length与请求体长度的一致性,这些知识光是看文档永远记不牢,亲手写一遍解析器才能形成肌肉记忆。
第二,类似项目在很多高校的课程设计里也有,但普遍的问题是“接口写着写着成了拼接字符串”。如果你没有先理解HTTP协议规范,很容易写出一个只能处理最完美输入的服务器,稍微来个异常请求就崩给你看。C语言会逼着你考虑边界情况:recv()返回0说明对方关闭了连接,返回-1可能是被信号中断;字符串里藏着\r\n,处理不当就直接溢出。这种“被逼着严谨”的体验,在框架开发里很难得到。
1.3 模块边界怎么划
动工之前我先把模块边界画清楚了。整个项目分成五块:
main.c 入口:读配置参数、初始化、启动监听、分发线程 http_parser.c HTTP报文解析:请求行/请求头/请求体,URL解码,查询参数拆分 handler.c 路由分发:根据路径调注册、登录、搜索、静态页面等处理函数 auth.c 用户认证模块:注册、登录、会话管理、密码哈希 search.c 商品搜索模块:加载商品数据、关键词过滤、排序每个模块的头文件对外只暴露几个函数接口,内部实现细节不互相透传。比如handler.c不需要知道user表具体是怎么存的,它只调用auth_register()和auth_login(),拿到成功或失败的标志即可。这样划分的好处是,后续想替换存储方式,把auth.c内部的实现换掉,其他模块一行不用改。
2. 核心数据结构与存储设计
项目想稳定,数据结构先得稳。这一块我花了不少时间设计,因为C语言的字典、动态数组都要自己来,一旦设计不合理,后面写业务逻辑时就会处处别扭。
2.1 用户、商品、会话的基础结构体
三个核心结构体直接决定后续所有代码的写法和业务逻辑的复杂度。我一开始的设计是这样:
typedef struct { int id; // 用户ID,自增 char username[64]; // 用户名,禁止重复 char pass_hash[128]; // 密码哈希(十六进制字符串) char salt[32]; // 随机盐值 long created_at; // 注册时间戳 } user_t; typedef struct { int id; char name[128]; // 商品名称 char category[64]; // 商品分类 char desc[512]; // 商品描述 double price; // 价格 int stock; // 库存 } product_t; typedef struct { char token[65]; // 会话令牌 int user_id; // 所属用户 long expire_at; // 过期时间戳 } session_t;用户密码的存储特别说明一下。真正的生产环境,密码哈希基本用PBKDF2、bcrypt或者argon2这类专门的密码哈希算法,还要配合随机盐。这个Demo里为了代码不至于太长,我用了带盐的FNV1a哈希模拟了两次迭代过程,并且代码注释里明确写了“教学演示,生产环境请用正规算法”。我建议你也这么做,因为“知道什么是正确的”和“在这个项目里能落地”是两回事,在注释里留好方向即可。
盐值生成用的是gettimeofday()的低位微秒部分混合rand()的随机数,凑成十六进制字符串。每个人注册时盐都不同,所以就算两个用户密码一样,存在内存里的哈希值也不会一样,避免泄露明文的规律。
2.2 文件存储与序列化的坑
数据得持久化,不然服务器一重启用户全没了。我在这个项目里选择了最简单的文本文件存储:用户表写进users.dat,商品表写进products.dat。每行一条记录,字段之间用逗号分隔。写的时候用fprintf,读的时候用fgets加strtok拆字段。
看起来很简单,上手就有坑。第一个坑是字段值本身含有逗号怎么办,比如商品描述“智能手环,支持心率监测”里有个中文逗号——中文逗号还好,如果是英文逗号,按逗号拆分就会错位。我处理的方式是把英文逗号在写入前替换成全角逗号,读取后再替换回来。这个方法虽然土,但足够用,比引入转义字符然后写个状态机要省事得多。第二坑是字符串字段里如果包含换行符,整行存储结构就崩了。所以我在写入时对换行做了过滤,遇到直接去掉或者替换成空格。
我在文件里还特意存了一个魔数头:
USER_DATA_V1 3,alice,6b7c...d9,zx8f...,1700000000第一行是文件格式标识。这个太重要了。以后你改了结构体想升级格式,代码里可以先读这个标识判断老文件能不能兼容,而不是一上来就按新版解析,解析出乱码才发现不对。我第三次重构用户表的时候就靠这个标识识别出了旧文件,没让程序直接崩溃。
2.3 会话表的哈希表实现
会话表是要频繁查询的热点数据,每个请求到了服务器,如果带了Cookie,就要根据令牌反查出用户ID。这个查询如果线性遍历,连接一多就吃力了。所以会话表用了链地址法的哈希表来做。
哈希表很简练,核心就两个结构体和一个函数:
typedef struct session_node { session_t sess; struct session_node *next; } session_node_t; typedef struct { session_node_t *buckets[2048]; pthread_rwlock_t lock; } session_table_t; static unsigned int hash_token(const char *token) { unsigned int h = 5381; while (*token) { h = ((h << 5) + h) + (unsigned char)(*token++); } return h % 2048; }djb2这个哈希算法是我印象中字符串哈希里最简洁的之一,速度也快。冲突处理用链地址法简单直接。这里有个设计点值得拿出来说:哈希表配套了一把读写锁。读请求占绝大多数,用pthread_rwlock_t可以让多个线程同时读,只有写的时候才独占锁,比一把大互斥锁性能好不少。虽然这个Demo规模还谈不上性能瓶颈,但从一开始就养成“读写分离加锁”的习惯没有坏处。
3. HTTP协议解析:从字节流到结构化请求
这是整个项目最核心、也最容易把人写崩溃的部分。HTTP协议看着简单,拆起来全是细节。我拆的时候给自己定了个目标:拿到的是字节流,吐出来的是一个结构体,里面躺着方法、路径、参数、头部、请求体。
3.1 请求行的解析
一个典型的GET请求长这样:
GET /search?query=phone&page=1 HTTP/1.1\r\n Host: 192.168.1.100:8080\r\n User-Agent: Mozilla/5.0 ...\r\n \r\n第一行叫请求行,格式是方法 空格 路径[?查询串] 空格 HTTP版本\r\n。我的解析办法是在读入的缓冲区里先找\r\n,把请求行单独剪出来,然后按空格切成三段。
路径里带查询串怎么处理?用strchr(path, '?')找到问号位置,把问号前当作真正的路径,问号后当作查询字符串。查询字符串是key=value&key2=value2这种结构,遍历切割就能拆出一个参数表。但需要注意URL编码:浏览器会把空格编码成%20,把中文编码成%E6%89%8B%E6%9C%BA这些百分号形式。所以搜索参数里的中文必须做URL解码,不然用户搜“手机”,你拿到的关键词是%E6%89%8B%E6%9C%BA,那可就一个商品都搜不出来了。
URL解码函数写得短小精悍:
void url_decode(char *dst, const char *src, size_t dst_size) { size_t i = 0, j = 0; while (src[i] && j < dst_size - 1) { if (src[i] == '%' && isxdigit(src[i+1]) && isxdigit(src[i+2])) { int hi = hex_val(src[i+1]); int lo = hex_val(src[i+2]); dst[j++] = (char)((hi << 4) | lo); i += 3; } else if (src[i] == '+') { dst[j++] = ' '; i++; } else { dst[j++] = src[i++]; } } dst[j] = '\0'; }注意加号也要换成空格,这是application/x-www-form-urlencoded格式的规则。
调试的时候我发现一个很隐蔽的问题:客户端发送的路径可能带有多余的斜杠,比如//search,甚至路径里的某些特殊字符是非法字节。我最后统一在路由匹配前做了一次路径规范化,把连续的斜杠压缩成一个,同时过滤掉非法控制字符,避免后续的字符串比较出幺蛾子。
3.2 请求头与请求体的边界问题
请求头是从请求行之后开始,到空行结束。每一行格式是字段名: 值\r\n。这里有一个特别坑的地方:字段名大小写不敏感。客户端可能发送Content-Length,也可能发送content-length,甚至CONTENT-LENGTH。你在解析的时候千万不能直接strcmp,得用strcasecmp做大小写不敏感比较,否则有的浏览器请求能被解析,有的就解析失败。
请求体的长度判断是另一个大坑。HTTP/1.1规定,要判断请求体有多长,要看Content-Length这个头部,值表示请求体的字节数。读取完请求头空行之后,紧接着就要按照这个长度继续从缓冲区读对应数量的字节作为请求体。但如果客户端用的是Transfer-Encoding: chunked,那请求体就不能按固定长度算,得按chunk块来解析。这个Demo里我选择了只支持Content-Length,遇到chunked直接返回501 Not Implemented。移动端的表单提交一般不会用chunked,这样做对场景是合理的,还能大幅减少解析代码复杂度。
我自己在写请求体解析时犯过一个特别低级的错误:Content-Length的值是个字符串,需要转成整数,但我一开始忘了处理回车符,导致strtol解析出来的长度不对,请求体读少了,后面的字节被当成了下一个请求,整个协议流就乱了。后来我写了一个统一的read_until_crlf函数,读一行就顺手把\r\n剥掉,问题才解决。
3.3 响应构造:Content-Length一个字符都不能差
解析完请求就是构造响应。响应格式更简单:
HTTP/1.1 200 OK\r\n Content-Type: text/html; charset=utf-8\r\n Content-Length: 1234\r\n Connection: close\r\n \r\n [响应体]响应体可以是HTML页面、JSON数据,也可以是纯文本的提示信息。最关键的字段一定是Content-Length,它必须准确等于响应体实际字节数。如果填大了,浏览器会一直等着接收后续数据直到超时;如果填小了,页面内容会被截断。这一点是C语言新手最容易踩的坑,因为你用strlen拿到的字节数和printf输出到缓冲区的长度不一定一样,尤其是响应体里嵌套了数字格式化时,长度算错已经是家常便饭。
我的规避方案是:统一用一块动态缓冲区char *resp_body累积响应体,最后算好长度再拼响应头。响应体构建完毕之前不提前发头部,避免“头已经发出去了,体还没攒完”的尴尬。代码上的表现就是类似build_response_header(resp_head, sizeof(resp_head), 200, "application/json", body_len)这样,先构造完头再一次性send()出去。
4. 用户认证模块:注册、登录与会话
认证是商城的门禁,所有需要“登录才能看”的接口都建立在会话机制上。这个模块写完后,你对Cookie和Session的理解就不再是框架里那几个注解的事,而是真真切切能看到令牌在服务端和客户端之间如何传递。
4.1 注册接口的实现与输入校验
注册路径是POST /register。请求体是表单格式:username=alice&password=123456。服务端拿到后先拆分参数,再做三段校验:
- 用户名长度:3到32个字符,太短没法用,太长浪费内存也容易被恶意填充;
- 密码长度:至少6位,不能超过64位,超过的话哈希处理时间会拉长,而且缓冲区容量有限;
- 用户名是否已存在:遍历用户表做
strcmp比较,这个Demo规模小,线性比较没问题。
我踩过一个坑是忘记限制密码长度。用户输入一个10万字节的“密码”,每个注册请求都能让服务器做一次这么长的哈希计算,配合多线程就能把CPU打满,这其实是挺典型的资源消耗攻击雏形。所以长度校验这个环节别偷懒,不是省事的事,是安全问题。
校验通过后创建user_t,ID用全局自增计数器维护,注册线程里这个ID的分配要加锁,否则并发注册两个用户可能拿到同一个ID。存储用户的时候用fprintf追加到users.dat,同时更新内存用户表。
4.2 登录流程与会话令牌的生成
登录路径是POST /login,逻辑很直接:查用户表,比对用户名是否存在,如果存在,把输入的密码加上该用户的盐做同样的哈希,和存储的pass_hash做strcmp。一致就是登录成功。
登录成功后就是生成会话令牌。令牌的生成我用的方式是拼接四个信息再哈希:
snprintf(seed, sizeof(seed), "%d-%ld-%d-%s", user_id, time(NULL), getpid(), (char *)random_bytes);随机字节可以通过读取/dev/urandom拿到,或者用rand()配合gettimeofday的微秒做伪随机源。拿到的种子做一次SHA-256,得到64位十六进制字符串作为token。为什么不能只用时间戳拼接用户名?因为这样token是高度可预测的,攻击者只要知道注册时间就能伪造会话。Demo里混入随机字节是为了至少保证token的熵足够,让伪造难度上一个台阶。
会话生成后插入会话哈希表,同时给客户端响应两个Set-Cookie头:
Set-Cookie: session_id=abc123...; HttpOnly; Path=/; Max-Age=3600HttpOnly标记很重要,加了之后浏览器里的JavaScript就读不到这个Cookie,能有效防止XSS攻击把会话令牌偷走。移动端商城如果之后要接WebView,这里也是同样的逻辑,Cookie由统一入口管理,JS不直接接触token。
4.3 鉴权接口与Cookie解析
有登录就得有鉴权。受保护接口的流程是这样的:
- 从请求头里找到
Cookie字段; - 把Cookie解析成键值对,找到
session_id的值; - 拿这个值去会话哈希表查询,判断token是否存在且未过期;
- 通过就执行后续业务逻辑,不通过就返回
401 Unauthorized。
我在项目里拿GET /profile当试金石:登录了返回用户信息,没登录返回401。代码实现里Cookie解析就是简单的字符串扫描,按分号切段,再按等号拆键值。有一个细节是多个Cookie之间的顺序是不固定的,session_id可能在第一个也可能在最后一个,所以必须遍历全部键值,不能想当然只解析第一个。
会话过期策略我定的是创建后3600秒失效。每次鉴权时比较current_time > expire_at,超了就删除这个会话记录,同时返回401让客户端重新登录。这背后体现的是“无状态HTTP协议下的有状态会话管理”思路:服务器不记得你是谁,只凭令牌说话。
5. 商品搜索模块:从暴力匹配到可用性提升
商城的主体数据是商品,搜索是入口。这个模块我用了朴素的字符串匹配做关键词过滤,没有引入倒排索引,因为Demo的数据量就几百条,杀鸡用不着牛刀。但就算是朴素实现,也有几个可以打磨的点。
5.1 搜索接口与参数设计
搜索路径是GET /search?query=手机&category=数码&page=1。三个参数各有默认值:query默认空串,category默认“全部”,page默认1。每一页我固定返回20条结果。
响应格式我选了JSON,而不是拼HTML。原因很实际:如果返回HTML,移动端后续想直接做App内嵌,解析HTML的成本远高于解析JSON。JSON格式虽然要自己手拼,但结构清晰,curl测试时一眼就能看出结果对不对。拼JSON的时候要非常小心字符串转义。商品名称里的双引号、反斜杠、控制字符如果不转义,拼出来的JSON就是非法的,客户端一解析就挂。自己写JSON序列化函数时,遇到这些字符要补\前缀。
搜索接口我还特意做了空查询的保护:如果query为空串,直接返回所有商品的前20条,避免把整个商品表一次暴晒出来,也方便移动端首页先展示一部分商品。
5.2 关键词匹配的核心逻辑
商品搜索最基础的做法是遍历商品数组,对每个商品在名称和描述里做strstr子串查找。C语言的strstr是大小写敏感的,但用户搜索“Phone”和“phone”通常都希望能搜到,所以我额外写了一个大小写不敏感的版本。
搜索多关键词时,我把query按空格切分成多个关键词,要求商品必须包含全部关键词才算命中,也就是AND逻辑。比如搜“苹果 手机”,只返回名称或描述里同时含“苹果”和“手机”的商品,而不是只含其中一个的。分词用的是strtok或者手写split,分隔符是空格和中英文逗号。
还有一个很隐蔽的性能陷阱:如果商品描述很长,且关键词很多,对每个商品都做多次strstr,会有一部分无谓的开销。这个Demo里数据量小,问题不大,但我在代码里做了一个短路优化:只要一个关键词没匹配上,立即跳过这个商品,不再检查剩余关键词。排序靠前的商品不必做完全部匹配。
5.3 排序与分页
搜索结果的排序得简单可用。我用了价格升序和名称长度两个排序键。名称长度排序算是给“相关度”一个粗糙的代理:名称短的商品往往更精确地描述自己,排前面不容易离谱。真正的电商搜索排序要考虑销量、评分、相关性打分,那是另一个大课题,Demo不需要。
分页实现我是把结果集收集到一个product_t*数组里,然后按页码切片。这里有一个C语言的经典内存问题:不能把结果集存在固定长度栈数组里然后返回指针,栈帧一退指针就悬空了。我的做法是用malloc分配结果集数组,调用方用完free。代码里做了清晰的注释提醒,谁调用谁负责释放内存。
6. 并发模型与线程安全
连接多了,服务器就得能同时处理。这一块我选了最简单的“一个连接一个线程”模型,也就是传说中的thread-per-connection。代码写起来最少,表现也稳定。
6.1 accept循环与线程拆分
主循环这样写:
while (1) { int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len); if (client_fd < 0) { perror("accept"); continue; } pthread_t tid; int *pfd = malloc(sizeof(int)); *pfd = client_fd; if (pthread_create(&tid, NULL, handle_client, pfd) != 0) { close(client_fd); free(pfd); continue; } pthread_detach(tid); }pthread_detach(tid)很重要,它告诉系统这个线程结束后自动回收资源,主线程不需要去join它。如果不detach,每个请求的线程退出后会积累成僵尸线程,连接一多进程内存就暴涨。
新建线程的参数用malloc包了一个指针传进去,这是必须的:不能传栈变量地址给线程,因为主线程循环下一次迭代栈内容就变了。这个坑我掉过一次,排查了半天找不到客户端数据为什么错乱。
为了防止恶意高频连接把系统线程资源耗尽,我还加了一个简单的信号量限制并发数,比如最大同时处理128个连接,超出部分直接关闭客户端连接并返回503。要知道,无限创建线程是真实生产事故的高发区,轻则资源耗尽,重则拖垮整个服务器,养成限流的习惯总没错。
6.2 共享数据加锁策略
高并发下最危险的就是共享数据。这个项目里全局变量有用户表、会话表、商品表、用户ID计数器、文件写入位置。我的加锁策略按访问频率分成三类:
- 用户表和商品表:读多写少,用
pthread_rwlock_t读写锁; - 会话表:读多写也多(登录、过期删除都在写),但因为它本身就是哈希表且桶是2048个,就按整个表一把读写锁;
- 用户ID计数器和日志缓冲区:高频写,用
pthread_mutex_t互斥锁保护,临界区尽量短。
锁的粒度直接关系到并发性能。我一开始图省事,给整个服务器加了一把大锁,任何访问全局数据的操作都要先拿锁,结果并发请求一上来,许多线程都阻塞在锁上,吞吐量比单线程还难看。后来理清访问模式,才把锁细化到各个模块内。真正理解了那句话:锁不是不用,而是要用得恰到好处。
6.3 多线程下的字符串安全坑
处理函数里有一个隐蔽问题非常值得拿出来说:C里经常用static char buf[1024]这种静态缓冲区作为返回值,在单线程下没问题,但在多线程下,两个线程同时调用同一个函数,就会互相覆盖buf的内容。线程A刚把数据写进buf,线程B也写了进去,A再读出buf时已经变成B的数据,接口就错乱了。
解决办法有两个:一是调用方自己传入缓冲区,函数只往指定地址写;二是用malloc返回新分配的字符串,调用方负责free。我项目里统一采取第一种方式——每个请求处理线程创建自己的栈缓冲区,传到各个函数里,函数之间不共享返回缓冲区。这样从根本上规避了线程不安全的问题。
7. 完整编译运行与实测
理论说了这么多,最后得落在地上跑起来。我把完整的操作流程写在这里,保证你跟着做一遍就能看到效果。
7.1 目录结构与Makefile
项目目录长这样:
httpserver/ ├── main.c ├── http_parser.c ├── http_parser.h ├── handler.c ├── handler.h ├── auth.c ├── auth.h ├── search.c ├── search.h ├── common.h ├── Makefile └── data/ ├── users.dat └── products.datMakefile我写得也很朴素:
CC = gcc CFLAGS = -Wall -Wextra -O2 -pthread LDFLAGS = -pthread all: httpserver httpserver: main.o http_parser.o handler.o auth.o search.o $(CC) $(CFLAGS) -o $@ $^ %.o: %.c common.h $(CC) $(CFLAGS) -c $< clean: rm -f *.o httpserver这里特别强调-Wall -Wextra不要省略。C代码不是编译过就是对的,警告信息里经常藏着缓冲区溢出和类型转换问题。我第一次编译时有一堆警告没管,运行时各种莫名其妙崩溃,后来打开所有警告逐条改掉之后,程序立刻稳定了好多。
7.2 用curl走一遍全流程
服务器启动之后,用curl做接口测试是最高效的方式。下面是我当时的完整测试序列。
注册一个用户:
curl -v -X POST http://127.0.0.1:8080/register \ -d "username=alice&password=123456"预期响应是{"code":0,"msg":"register success"}。然后登录拿Cookie:
curl -v -X POST http://127.0.0.1:8080/login \ -d "username=alice&password=123456"注意响应头里的Set-Cookie字段,你可以先把Cookie值记下来。接着用Cookie访问受保护接口:
curl -v http://127.0.0.1:8080/profile \ -H "Cookie: session_id=<粘贴刚才的token>"登录态下返回用户信息;如果-H里写的session_id是随便编的字符串,返回的就是401 Unauthorized。搜索接口的测试:
curl "http://127.0.0.1:8080/search?query=手机&page=1"返回的JSON数组里就是名称或描述含“手机”的商品列表,还可以看下分页参数是否正确。
7.3 手机端实测
PC上测完,把服务器启动所在的机器IP地址找出来,用ifconfig或ip addr看局域网IP。同一WiFi下的手机浏览器直接访问http://<服务器IP>:8080/就能看到商城首页。如果首页挂了简单的静态页面和搜索框,手机上提交关键词就能看到结果。
这里有一个特别常见的坑:手机访问不通时,第一反应先关掉服务器所在机器的防火墙,或者放行对应端口。排查命令是ss -lntp看端口有没有在监听,再用手机ping下服务器IP确认网络通不通。我调试时一次因为系统防火墙拦了8080端口,折腾了半天才发现是防火墙的事,与代码无关。
8. 踩坑实录:这些问题我debug了最久
最后这部分我完整记录下项目过程中debug时长最久、最值得分享的四个问题。每一个都在真实工程里可能出现,提前知道能帮你省不少时间。
8.1 recv粘包与半包
第一个项目跑通后,我用浏览器连续刷新页面,发现偶尔会出现一个请求返回后页面空白,服务器端偶尔还会多打印一些乱码。用strace跟了一下,发现客户端的长请求被拆成了两次TCP段发送,第一次recv只收到一部分,我的解析还没等第二次recv就认为一次请求结束了,自然解析失败。
解决办法是在读取请求时循环recv直到找到请求头结束的\r\n\r\n,如果Content-Length大于头部长度,还要继续读请求体。简单说就是“边收边判断协议是否完整”,而不是“收到一次就开工”。代码里用一个累计缓冲区和偏移指针管理,每次追加完都检查要不要继续读。
8.2 端口复用与TIME_WAIT
有一次我改了代码想重启服务器,但bind一直报Address already in use。这是因为上次运行的连接还有TIME_WAIT状态没消失,Linux默认不允许占用端口。解决办法是在bind之前设置SO_REUSEADDR:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));设置完后重启就顺畅多了。如果你调试时频繁重启服务器,这个选项几乎是必备的。
8.3 锁的顺序与死锁
会话表和用户表如果同时要修改,比如用户注册成功的同时要创建一条会话登录态,代码里就要拿两把锁。如果两处拿锁的顺序不一致——一个先锁用户表再锁会话表,另一个先锁会话表再锁用户表——并发时就有概率死锁。
我当时就犯了这个错,表现为服务器偶尔整个僵住,所有请求全部无响应。排查时用gdbattach上去看到线程都卡在pthread_rwlock_wrlock上,才发现是锁顺序不一致。解决办法是定下一个全局统一的拿锁顺序,比如永远先拿“用户表锁”再拿“会话表锁”,想拿会话锁的时候发现还需要用户锁,就先把会话锁放了重新按顺序拿,彻底杜绝循环等待。
8.4 HTTP头大小写不敏感
这个问题我在3.1节提了一句,但这里必须再强调一下,因为它导致过一个很有意思的bug:Chrome浏览器发来的请求一切正常,但某个手机浏览器发来的请求User-Agent头始终解析不出来。打印原始报文后发现手机浏览器发的是user-agent,全小写。用strcmp比较字段名直接失败。
所以HTTP头的解析一定一定用strcasecmp,别用strcmp。任何写协议解析器的项目,这个规则都是血泪教训换来的。
最后再分享一个小技巧
如果你想在这个Demo基础上继续扩展,我建议下一个迭代的方向是“日志模块”。把每个请求的时间、方法、路径、状态码、处理耗时输出到文件里,对后续加访问量统计、排查线上问题都会有极大的帮助。我在真机上跑了两天,把日志落盘后发现搜索“手机”的请求占了六成,很多用户上来第一件事就是搜“手机”,这个观察直接影响了后续首页商品推荐的排列——把手机类商品放在第一屏。你看,一个小到只是打印日志的功能,因为有了真实数据,就能变成推动产品决策的依据。这也是我自己在项目里越来越感觉到的一件事:底层能力积累到一定程度,再往上写业务时,眼界和思路是不一样的。