从零构建现代C++ Web服务器(五):Cookie、Session、静态文件与文件上传

系列导航第一篇:设计理念 | 第二篇:协程与内存池 | 第三篇:路由、中间件与SSL | 第四篇:实战与性能 | 第五篇:Cookie、Session与文件服务(本文) | 第六篇:数据库中间件

前置知识

  • 阅读过本系列前四篇(特别是第三篇的中间件洋葱模型)
  • 了解 HTTP Cookie 与 Session 基本概念
  • 了解 multipart/form-data 编码格式基本原理

目录


1. Web 应用的"最后一公里"

经过前四篇的铺垫,hical 已经具备了完整的 HTTP 服务器骨架——协程驱动的异步 I/O、PMR 内存池、双策略路由、洋葱模型中间件、SSL/WebSocket 支持,以及反射宏系统。

但如果你真正尝试用它搭建一个 Web 应用,很快就会发现少了几样东西:用户登录后刷新页面状态丢失、无法提供前端静态资源、用户没法上传头像文件。这些功能看似"基础",却是 Web 应用从"能跑通"到"能用"的最后一公里

hical v1.0.0 补齐了这四块拼图:

模块解决的问题集成方式核心文件
CookieHTTP 无状态协议下的客户端状态存储req.cookie() / res.setCookie()Cookie.h HttpRequest.cpp HttpResponse.cpp
Session服务端有状态会话管理makeSessionMiddleware() 中间件Session.h Session.cpp
StaticFiles安全地托管前端/资源文件serveStatic() 工厂函数StaticFiles.h
Multipart文件上传(RFC 7578)MultipartParser::parse() 静态方法Multipart.h Multipart.cpp

它们在 hical 整体架构中的位置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
                         ┌─────────────────────────────────┐
                         │          用户请求 (HTTP)          │
                         └────────────────┬────────────────┘
                         ┌────────────────▼────────────────┐
                         │        TcpServer (Accept)        │
                         └────────────────┬────────────────┘
                         ┌────────────────▼────────────────┐
                         │   MiddlewarePipeline (洋葱模型)   │
                         │  ┌───────────────────────────┐  │
                         │  │  ★ Session 中间件 (本篇)   │  │
                         │  │  · CORS 中间件             │  │
                         │  │  · 日志中间件               │  │
                         │  └───────────────────────────┘  │
                         └────────────────┬────────────────┘
                         ┌────────────────▼────────────────┐
                         │       Router (路由分发)          │
                         │  ┌──────────┬─────────────────┐ │
                         │  │ API 路由  │ ★ 静态文件路由   │ │
                         │  └──────────┴─────────────────┘ │
                         └────────────────┬────────────────┘
                    ┌─────────────────────┼──────────────────────┐
                    │                     │                      │
         ┌──────────▼──────────┐ ┌────────▼─────────┐ ┌─────────▼────────┐
         │  ★ Cookie 解析/写入 │ │ ★ Multipart 解析 │ │  JSON / 普通响应  │
         │  req.cookie()       │ │ MultipartParser  │ │  req.jsonBody()  │
         │  res.setCookie()    │ │ ::parse()        │ │                  │
         └─────────────────────┘ └──────────────────┘ └──────────────────┘

★ 标记的即为本篇讲解的四个模块。

接下来我们逐个深入。


2. Cookie:无状态协议的状态记忆

HTTP 是无状态协议——每次请求独立,服务器不记得"你是谁"。Cookie 机制通过在响应中设置 Set-Cookie 头、在后续请求中携带 Cookie 头,让客户端帮服务器"记住"状态。

手动解析 Cookie 头并不复杂,但每个路由处理器都自己写一遍既冗余又容易出错:

1
2
3
4
// ❌ 手动解析:每个 handler 都要重复这段逻辑
auto cookieHeader = req.header("Cookie");
// 手动 split "name1=value1; name2=value2; ..."
// 还要处理空格、分号、RFC 编码...

hical 提供了框架级 Cookie 支持,一行搞定:

1
2
3
// ✅ hical 方式
auto token = req.cookie("auth_token");   // 读取
res.setCookie("theme", "dark", opts);    // 设置

2.2 惰性解析:用时才解析,省了就赚了

并非每个请求都需要读取 Cookie——API 接口可能只看 Authorization 头。如果在每个请求进入时就解析 Cookie 头,对不需要 Cookie 的路由而言是浪费。

hical 采用**惰性解析(lazy parsing)**策略:只有第一次调用 req.cookie()req.cookies() 时才真正解析,之后缓存结果。

关键数据结构:

1
2
// HttpRequest.h 中的成员
mutable std::optional<std::unordered_map<std::string, std::string>> cookies_;

为什么用 mutable optional

设计选择原因
mutablecookie()const 方法(不修改请求语义),但需要修改内部缓存
optional区分"未解析"(nullopt)和"已解析但为空"(空 map)
unordered_mapO(1) 查找,Cookie 数量通常很少(<20),哈希开销可忽略

解析流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  req.cookie("name")
  cookies_ 有值? ──是──► 直接查找返回
  parseCookies()
  读取 "Cookie" 头
  按 ';' 分割键值对
  try_emplace(first-wins)
  缓存到 cookies_
  查找返回

核心实现(HttpRequest.cpp:177-226):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
void HttpRequest::parseCookies() const
{
    cookies_.emplace();
    auto cookieHeader = header("Cookie");
    if (cookieHeader.empty())
    {
        return;
    }

    // 解析 "name1=value1; name2=value2; ..." 格式
    std::string_view sv(cookieHeader);
    while (!sv.empty())
    {
        // 跳过前导空格
        while (!sv.empty() && sv.front() == ' ')
        {
            sv.remove_prefix(1);
        }

        // 查找分隔符 ';'
        auto semi = sv.find(';');
        std::string_view pair = (semi != std::string_view::npos)
                                    ? sv.substr(0, semi) : sv;
        sv = (semi != std::string_view::npos)
                 ? sv.substr(semi + 1) : std::string_view {};

        // 分割 name=value
        auto eq = pair.find('=');
        if (eq == std::string_view::npos)
        {
            continue;
        }
        std::string_view name = pair.substr(0, eq);
        std::string_view value = pair.substr(eq + 1);

        // 去除 name 首尾空格
        while (!name.empty() && name.front() == ' ')
        {
            name.remove_prefix(1);
        }
        while (!name.empty() && name.back() == ' ')
        {
            name.remove_suffix(1);
        }

        if (!name.empty())
        {
            // RFC 6265:同名 Cookie 以先出现的值为准(first-wins)
            (*cookies_).try_emplace(std::string(name), std::string(value));
        }
    }
}

try_emplace 的 first-wins 语义是一个重要细节。RFC 6265 规定:当客户端发送多个同名 Cookie 时,服务器应以第一个出现的为准。try_emplace 在键已存在时不会覆盖,恰好满足这个要求——比 insert 更高效(不需要构造临时 pair),比 operator[] 更安全(不会意外覆盖)。

设置 Cookie 比解析更复杂——不仅要拼接字符串,还要处理安全属性和字符编码。

hical 的 CookieOptions 结构体涵盖了所有 RFC 6265 规定的属性:

1
2
3
4
5
6
7
8
9
struct CookieOptions
{
    std::string path = "/";    // Cookie 作用路径,默认 "/"
    std::string domain;        // Cookie 作用域名,空表示当前域
    int maxAge = -1;           // 有效期(秒),-1 表示会话 Cookie
    bool httpOnly = false;     // 禁止 JavaScript 访问(防 XSS)
    bool secure = false;       // 仅通过 HTTPS 传输
    std::string sameSite;      // SameSite 策略:"Strict"/"Lax"/"None"
};

各属性的安全含义:

属性默认值安全作用
path"/"限制 Cookie 发送路径
domain空(当前域)防止跨子域泄露
maxAge-1(会话)控制持久化时长
httpOnlyfalse防 XSS:JS 无法读取 document.cookie
securefalse防中间人:仅 HTTPS 传输
sameSite防 CSRFStrict/Lax 限制跨站发送

Cookie 值必须经过 RFC 6265 编码。规范规定的合法字符范围:

1
2
3
4
cookie-value = *cookie-octet
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
               │       │          │          │          │
               !       #到+       -到:       <到[       ]到~

不在此范围内的字符需要百分号编码。HttpResponse.cpp:77-95 中的 encodeCookieValue 实现了这个逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
auto encodeCookieValue = [](const std::string& raw) -> std::string
{
    std::ostringstream encoded;
    encoded << std::hex << std::uppercase;
    for (unsigned char c : raw)
    {
        bool safe = (c == 0x21)
                    || (c >= 0x23 && c <= 0x2B)
                    || (c >= 0x2D && c <= 0x3A)
                    || (c >= 0x3C && c <= 0x5B)
                    || (c >= 0x5D && c <= 0x7E);
        if (safe)
        {
            encoded << static_cast<char>(c);
        }
        else
        {
            encoded << '%' << std::setw(2) << std::setfill('0')
                    << static_cast<int>(c);
        }
    }
    return encoded.str();
};

2.4 CRLF 注入防护

HTTP Response Splitting(HTTP 响应拆分) 是一种经典攻击手段:如果攻击者能在 Cookie 的 name 或 value 中注入 \r\n(CRLF),就能伪造 HTTP 头部甚至注入完整的 HTTP 响应。

攻击原理:

1
2
3
4
5
6
7
8
正常 Set-Cookie:
  Set-Cookie: name=value; Path=/

注入攻击(value 包含 \r\n):
  Set-Cookie: name=evil\r\n
  Set-Cookie: admin=true\r\n     ← 伪造的头部
  \r\n                           ← 头部结束
  <script>alert('xss')</script>  ← 注入的响应体

hical 在 setCookie 入口处直接拦截(HttpResponse.cpp:64-73):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void HttpResponse::setCookie(const std::string& name,
                             const std::string& value,
                             const CookieOptions& options)
{
    // HTTP Response Splitting 防护:name/value 不允许包含 CR/LF
    auto containsCRLF = [](const std::string& s) -> bool
    {
        return s.find('\r') != std::string::npos
               || s.find('\n') != std::string::npos;
    };
    if (containsCRLF(name) || containsCRLF(value))
    {
        // 拒绝含控制字符的 Cookie,静默忽略
        return;
    }
    // ... 后续正常构建 Set-Cookie
}

设计决策:检测到 CRLF 时静默忽略而非抛异常。理由是:这通常意味着外部输入被恶意篡改,抛异常可能让攻击者利用错误信息探测服务器。生产环境建议加上告警日志。

2.5 多 Cookie:Beast insert() vs set() 的关键区别

一个 HTTP 响应可能需要设置多个 Cookie。HTTP 规范要求每个 Cookie 用独立的 Set-Cookie 头,不能合并到一行。

Boost.Beast 的 message::set() 方法会覆盖同名头部,而 insert()追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ❌ set() 会覆盖,第二个 Cookie 丢失
res_.set(http::field::set_cookie, "name1=value1");
res_.set(http::field::set_cookie, "name2=value2");
// 结果:只有 Set-Cookie: name2=value2

// ✅ insert() 追加,两个 Cookie 都保留
res_.insert(http::field::set_cookie, "name1=value1");
res_.insert(http::field::set_cookie, "name2=value2");
// 结果:
//   Set-Cookie: name1=value1
//   Set-Cookie: name2=value2

hical 在 HttpResponse.cpp:126 使用 insert

1
2
// Beast 不支持同名字段多值直接 set,使用 insert 追加多个 Set-Cookie
res_.insert(boost::beast::http::field::set_cookie, oss.str());

这是一个容易踩的坑——使用 set() 设置多个 Cookie 时只有最后一个生效,且不会报错。


Cookie 能存储少量客户端状态,但有明显的局限性:

对比维度CookieSession
存储位置客户端浏览器服务器内存/数据库
容量上限4KB(RFC 6265 推荐)理论上无限
安全性客户端可篡改/窃取客户端只持有 ID
数据类型仅字符串任意类型(std::any
传输开销每次请求携带全部数据仅传输 Session ID
适用场景偏好设置、跟踪标识用户登录态、购物车

Session 的本质是:客户端存一把钥匙(Session ID),服务端存一个保险箱(Session 数据)

3.2 Session 类:std::any 实现的万能保险箱

hical 的 Session 类核心设计非常简洁——一个线程安全的 key-value 存储:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Session
{
public:
    explicit Session(std::string id) : id_(std::move(id)) {}

    const std::string& id() const { return id_; }

    void set(const std::string& key, std::any value);

    template <typename T>
    std::optional<T> get(const std::string& key) const;

    bool has(const std::string& key) const;
    void remove(const std::string& key);
    void clear();
    void touch();

private:
    std::string id_;                                       // 构造后不可变
    mutable std::mutex mutex_;                             // 保护下面所有字段
    std::unordered_map<std::string, std::any> data_;       // 数据存储
    bool dirty_ = false;                                   // 是否被修改
    std::chrono::steady_clock::time_point lastAccess_      // 上次访问时间
        = std::chrono::steady_clock::now();
};

关键设计决策:

设计选择原因
std::any 作为值类型无需为每种数据类型定义序列化,直接存任意 C++ 对象
std::mutex 保护Keep-Alive 多路复用下,同一 Session 可能被多个 IO 线程并发访问
id_ 不加锁构造后不可变,天然线程安全
dirty_ 标志只有被写过数据的 Session 才需要刷新 Cookie,减少不必要的 Set-Cookie
lastAccess_ 时间戳用于过期判断和懒 GC

get<T>() 方法通过模板实现类型安全的取值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
template <typename T>
std::optional<T> get(const std::string& key) const
{
    std::lock_guard<std::mutex> lk(mutex_);
    auto it = data_.find(key);
    if (it == data_.end())
    {
        return std::nullopt;
    }
    try
    {
        return std::any_cast<T>(it->second);
    }
    catch (const std::bad_any_cast&)
    {
        return std::nullopt;  // 类型不匹配也返回 nullopt,不抛异常
    }
}

类型不匹配时返回 nullopt 而非抛异常——这是防御性设计。Session 数据可能因代码版本迭代而改变类型,不应因此导致整个请求崩溃。

3.3 SessionManager:ID 生成与懒 GC

SessionManager 管理所有 Session 的生命周期——创建、查找、销毁和垃圾回收。

Session ID 生成:128 位随机数

Session ID 的安全性至关重要——如果攻击者能猜到或暴力破解 ID,就能劫持用户会话。

hical 使用 128 位随机数作为 Session ID(Session.cpp:99-112):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
std::string SessionManager::generateId()
{
    // 使用 thread_local 随机引擎,避免加锁
    thread_local std::mt19937_64 rng(std::random_device {}());
    std::uniform_int_distribution<uint64_t> dist;

    // 生成两个 64 位随机数拼成 128 位 ID
    uint64_t hi = dist(rng);
    uint64_t lo = dist(rng);

    std::ostringstream oss;
    oss << std::hex << std::setfill('0')
        << std::setw(16) << hi << std::setw(16) << lo;
    return oss.str();
}

设计要点:

细节说明
thread_local 引擎每个线程独立的随机引擎,无需加锁,高并发下无竞争
std::random_device 播种使用操作系统熵源初始化,确保不可预测
128 位长度2^128 种可能,暴力破解不可行
碰撞保护while (store_.count(id)) 循环检查(极低概率触发)

生成的 ID 形如:a1b2c3d4e5f60718091a2b3c4d5e6f70——32 个十六进制字符。

懒 GC:搭便车的垃圾回收

Session 会过期,但 hical 不启动单独的定时器线程做清理。而是采用**懒 GC(Lazy Garbage Collection)**策略:在 create() 时顺便检查是否需要清理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  create() 被调用
  距上次 GC ≥ gcInterval? ──否──► 直接创建新 Session
  遍历 store_:
  检查每个 Session 的 lastAccess_
  elapsed ≥ maxAge? ──是──► erase
  更新 lastGc_ 时间戳
  生成新 Session ID
  存入 store_,返回

核心实现(Session.cpp:29-66):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
std::shared_ptr<Session> SessionManager::create()
{
    std::lock_guard<std::mutex> lock(mutex_);

    // 懒 GC:每隔 gcInterval 秒在 create() 时顺带清理过期 Session
    if (opts_.gcInterval > 0)
    {
        auto now = std::chrono::steady_clock::now();
        auto sinceGcMs = std::chrono::duration_cast<std::chrono::milliseconds>(
            now - lastGc_).count();
        if (sinceGcMs >= static_cast<long long>(opts_.gcInterval) * 1000LL)
        {
            lastGc_ = now;
            for (auto it = store_.begin(); it != store_.end();)
            {
                auto elapsedMs =
                    std::chrono::duration_cast<std::chrono::milliseconds>(
                        now - it->second->lastAccess()).count();
                if (opts_.maxAge > 0
                    && elapsedMs >= static_cast<long long>(opts_.maxAge) * 1000LL)
                {
                    it = store_.erase(it);
                }
                else
                {
                    ++it;
                }
            }
        }
    }

    auto id = generateId();
    while (store_.count(id))  // 极低概率碰撞保护
    {
        id = generateId();
    }
    auto session = std::make_shared<Session>(id);
    store_[id] = session;
    return session;
}

为什么不用定时器? 懒 GC 的优势在于简单和零额外开销——不需要额外线程、不需要协程定时器。代价是过期 Session 不会被立即清理,但 find() 方法也会检查过期(Session.cpp:8-27),所以过期 Session 不会被"复活"。

SessionOptions 控制 GC 行为:

选项默认值说明
cookieName"HICAL_SESSION"Cookie 名称
maxAge3600(1小时)Session 有效期(秒)
httpOnlytrue防 XSS
securefalse生产环境应设为 true
sameSite"Lax"防 CSRF
path"/"Cookie 作用路径
gcInterval300(5分钟)懒 GC 触发间隔

3.4 Session 中间件:洋葱模型的完美应用

Session 管理天然适合中间件模式——在请求进入前加载 Session,在响应返回后写回 Cookie。这正是第三篇中介绍的洋葱模型的完美应用场景。

makeSessionMiddleware 工厂函数(Session.h:270-311):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
inline MiddlewareHandler makeSessionMiddleware(
    std::shared_ptr<SessionManager> manager)
{
    return [manager](HttpRequest& req, MiddlewareNext next)
        -> Awaitable<HttpResponse>
    {
        const auto& opts = manager->options();

        // 1. 从 Cookie 中读取 Session ID
        auto sessionId = req.cookie(opts.cookieName);
        std::shared_ptr<Session> session;

        if (!sessionId.empty())
        {
            session = manager->find(sessionId);
        }
        if (!session)
        {
            // 新建 Session
            session = manager->create();
        }
        session->touch();

        // 2. 注入到请求 attribute
        req.setAttribute(SessionManager::hSessionKey, session);

        // 3. 执行后续中间件/路由
        auto res = co_await next(req);

        // 4. 如果 Session 被写过数据(dirty),刷新 Cookie
        if (session->isDirty() || sessionId != session->id())
        {
            CookieOptions cookieOpts;
            cookieOpts.maxAge = opts.maxAge;
            cookieOpts.httpOnly = opts.httpOnly;
            cookieOpts.secure = opts.secure;
            cookieOpts.sameSite = opts.sameSite;
            cookieOpts.path = opts.path;
            res.setCookie(opts.cookieName, session->id(), cookieOpts);
        }

        co_return res;
    };
}

中间件的洋葱模型流转:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
请求进入
┌─────────────────────────────────────────────┐
│ Session 中间件 (before)                      │
│   ① 读 Cookie → find Session                │
│   ② 未找到 → create 新 Session              │
│   ③ touch() 刷新时间                        │
│   ④ setAttribute 注入请求                    │
│   │                                         │
│   ▼                                         │
│ ┌─────────────────────────────────────────┐ │
│ │ 其他中间件 / 路由 Handler               │ │
│ │   session->set("user", "alice")         │ │
│ │   → 标记 dirty_ = true                 │ │
│ └─────────────────────────────────────────┘ │
│   │                                         │
│   ▼                                         │
│ Session 中间件 (after)                       │
│   ⑤ 检查 isDirty() 或 ID 变化              │
│   ⑥ 写 Set-Cookie 到响应                   │
└─────────────────────────────────────────────┘
响应返回

注意步骤 ⑤ 的条件:isDirty() || sessionId != session->id()。两种情况需要写 Cookie:

  • Session 被修改(dirty):用户登录/写入了数据
  • Session ID 变化:旧 Session 已过期,创建了新 Session,需要下发新 ID

3.5 登录/登出完整示例

结合以上所有机制,实现用户登录和登出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <hical/HttpServer.h>
#include <hical/Session.h>

int main()
{
    auto sessionMgr = std::make_shared<hical::SessionManager>();

    hical::HttpServer<hical::AsioBackend> server;
    server.use(hical::makeSessionMiddleware(sessionMgr));

    // 登录
    server.router().post("/login",
        [](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        auto json = req.jsonBody();
        auto username = json.at("username").as_string();

        // 获取 Session(由中间件注入)
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        if (!sessionOpt) return hical::HttpResponse::serverError();

        auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
        session->set("user", std::string(username));  // 标记 dirty

        return hical::HttpResponse::json({{"status", "ok"}});
    });

    // 获取当前用户
    server.router().get("/me",
        [](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        if (!sessionOpt) return hical::HttpResponse::badRequest("No session");

        auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
        auto user = session->get<std::string>("user");
        if (!user) return hical::HttpResponse::badRequest("Not logged in");

        return hical::HttpResponse::json({{"user", *user}});
    });

    // 登出
    server.router().post("/logout",
        [&sessionMgr](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        if (sessionOpt)
        {
            auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
            sessionMgr->destroy(session->id());
        }

        // 清除客户端 Cookie(设置 maxAge=0)
        hical::HttpResponse res = hical::HttpResponse::json({{"status", "logged out"}});
        res.setCookie("HICAL_SESSION", "", {.maxAge = 0});
        return res;
    });

    server.listen(8080);
    server.run();
}

4. 静态文件服务:安全地托管资源

4.1 为什么需要框架内置静态文件服务?

大多数生产环境会用 Nginx/CDN 托管静态文件,但框架内置静态文件服务在以下场景仍然必要:

场景Nginx 反代框架内置
开发调试需要额外配置 Nginx零配置,serveStatic() 一行搞定
单体部署多一个运维组件单进程搞定
嵌入式/IoT资源有限难以安装框架自带
管理后台过度设计恰到好处
生产高并发✅ 推荐⚠️ 非最优(无 sendfile)

hical 的 serveStatic() 工厂函数返回一个 SyncRouteHandler,可直接注册到路由:

1
2
// 将 /static/... 映射到 ./public 目录
server.router().get("/static/{path}", hical::serveStatic("./public", "/static/"));

4.2 serveStatic 工厂:初始化与闭包捕获

serveStatic() 采用工厂模式——在初始化阶段做一次性工作(规范化根目录),然后返回一个闭包处理每个请求。

初始化阶段(StaticFiles.h:134-150):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
inline std::function<HttpResponse(const HttpRequest&)> serveStatic(
    const std::string& rootDir,
    const std::string& urlPrefix,
    std::uintmax_t maxFileSize = 64ULL * 1024 * 1024)
{
    namespace fs = std::filesystem;

    // 提前规范化根目录路径(只做一次)
    std::error_code ec;
    fs::path root = fs::canonical(rootDir, ec);
    if (ec)
    {
        // 根目录不存在时,每次请求都返回 404
        return [rootDir](const HttpRequest&) -> HttpResponse
        {
            return HttpResponse::notFound();
        };
    }

    return [root, urlPrefix, maxFileSize](const HttpRequest& req) -> HttpResponse
    {
        // ... 请求处理逻辑
    };
}

为什么提前 canonicalstd::filesystem::canonical() 会解析符号链接并消除 ...,得到一个绝对路径。这个操作只需要做一次——根目录在服务器运行期间不会变。

4.3 路径遍历防护:不只是字符串前缀

路径遍历攻击(Path Traversal) 是静态文件服务最常见的安全漏洞。攻击者通过 ../ 序列跳出根目录:

1
2
请求: GET /static/../../etc/passwd
期望: ./public/../../etc/passwd → /etc/passwd  ← 泄露系统文件!

很多框架用字符串前缀检查来防护,但这种方式有边界问题:

方法代码问题
字符串前缀target.starts_with(root)/pub/public 的前缀,但不在目录内!
逐段迭代器逐个路径分量比较✅ 准确,不受部分匹配影响

hical 采用逐段迭代器方案(StaticFiles.h:83-97):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
inline bool isSafePath(const std::filesystem::path& root,
                       const std::filesystem::path& target)
{
    // 逐段迭代器比较:root 的每个路径分量必须是 target 的前缀
    // 比字符串前缀比对更可靠,不受 /pub vs /public 等 edge case 影响
    auto rootIt = root.begin();
    auto targetIt = target.begin();
    for (; rootIt != root.end(); ++rootIt, ++targetIt)
    {
        if (targetIt == target.end() || *rootIt != *targetIt)
        {
            return false;
        }
    }
    return true;
}

攻击防御流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
请求: GET /static/../../etc/passwd
拼接路径: root / "../../etc/passwd"
canonical(): 解析为 /etc/passwd
isSafePath(root="/srv/public", target="/etc/passwd")
逐段比较:
  root[0]="/"     == target[0]="/"      ✓
  root[1]="srv"   != target[1]="etc"    ✗ → return false
返回 403 Forbidden

注意 hical 使用的是双重防护canonical() 解析符号链接 + isSafePath() 验证路径归属。即使攻击者构造了绕过 canonical 的路径(理论上不可能),isSafePath 仍然能拦截。

4.4 MIME 类型推断与目录处理

hical 内置了 26 种常见 MIME 类型映射,覆盖 Web 开发中最常用的文件格式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static const std::unordered_map<std::string, std::string> table = {
    {".html", "text/html; charset=utf-8"},
    {".css",  "text/css; charset=utf-8"},
    {".js",   "application/javascript; charset=utf-8"},
    {".json", "application/json; charset=utf-8"},
    {".png",  "image/png"},
    {".jpg",  "image/jpeg"},
    {".svg",  "image/svg+xml"},
    {".woff2","font/woff2"},
    // ... 共 26 种
};
// 未知扩展名 → "application/octet-stream"

当请求路径指向目录时,hical 自动尝试 index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (fs::is_directory(target, ec2))
{
    target /= "index.html";
    // 追加后重新 canonical 解析
    // 防止 index.html 是指向 root 外的符号链接
    target = fs::canonical(target, ec2);
    if (ec2 || !detail::isSafePath(root, target))
    {
        return HttpResponse::notFound();
    }
}

关键细节:追加 index.html重新执行 canonical + isSafePath。原因是 index.html 本身可能是一个符号链接,指向根目录之外的文件。如果不重新验证,攻击者可以在目录中创建一个名为 index.html 的符号链接来逃逸。

4.5 ETag 缓存验证

ETag(Entity Tag)是 HTTP 缓存验证机制:服务器为每个文件生成一个唯一标识符,客户端在后续请求中通过 If-None-Match 头发送缓存的 ETag。如果文件未变,服务器返回 304 Not Modified(无 body),节省带宽。

hical 的 ETag 生成策略(StaticFiles.h:105-109):

1
2
3
4
5
6
inline std::string makeEtag(std::uintmax_t fileSize,
                            std::filesystem::file_time_type lastWrite)
{
    auto ns = lastWrite.time_since_epoch().count();
    return "\"" + std::to_string(fileSize) + "-" + std::to_string(ns) + "\"";
}

文件大小 + 最后修改时间 组合作为 ETag。这不是加密哈希(不像 MD5/SHA),但对于静态文件服务足够——文件修改必然改变这两个值中的至少一个。

缓存交互流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
首次请求:
  客户端 → GET /static/app.js
  服务器 → 200 OK + ETag: "12345-1712345678"
                    Body: (文件内容)

二次请求:
  客户端 → GET /static/app.js
            If-None-Match: "12345-1712345678"
  服务器 → ETag 匹配?
           ├─ 是 → 304 Not Modified(无 body,节省带宽)
           └─ 否 → 200 OK + 新 ETag + 新内容

4.6 大文件限制与 Race Condition 处理

大文件限制:默认 64MB 上限(maxFileSize 参数),防止请求超大文件导致 bad_alloc 崩溃:

1
2
3
4
5
6
7
if (fileSize > maxFileSize)
{
    HttpResponse res;
    res.setStatus(HttpStatusCode::hPayloadTooLarge);
    res.setBody("413 File Too Large");
    return res;
}

stat/read Race Conditionfile_size()read() 之间文件可能被外部修改(截短或删除)。hical 的处理策略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
std::string content(fileSize, '\0');
ifs.read(content.data(), static_cast<std::streamsize>(fileSize));
auto bytesRead = ifs.gcount();
// 文件在 stat 和 read 之间被截短时,
// 截断到实际读取长度,拒绝返回零填充内容
if (bytesRead <= 0)
{
    return HttpResponse::serverError();
}
if (static_cast<std::uintmax_t>(bytesRead) < fileSize)
{
    content.resize(static_cast<std::size_t>(bytesRead));
}

错误码一览:

HTTP 状态码触发条件
200 OK正常返回文件
304 Not ModifiedETag 匹配(缓存有效)
403 Forbidden路径遍历攻击被拦截
404 Not Found文件不存在 / canonical 失败
413 Payload Too Large文件超过 maxFileSize
500 Internal Server Errorfile_size 失败 / read 失败

5. Multipart 文件上传:解析 RFC 7578

5.1 为什么不能用 JSON 上传文件?

JSON 只支持文本数据——二进制文件必须 Base64 编码,体积膨胀 33%。HTTP 提供了三种主要的请求体编码方式:

编码方式Content-Type适用场景二进制支持
JSONapplication/jsonAPI 数据交换❌(需 Base64)
原始字节application/octet-stream单文件上传✅ 但无元数据
Multipartmultipart/form-data表单+多文件上传✅ 原生支持

multipart/form-data(RFC 7578)是 HTML 表单文件上传的标准格式,支持在一次请求中发送多个字段(文本+文件)。

5.2 协议结构

一个 multipart 请求体的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                            boundary:各 Part 的分隔符

请求体:
------WebKitFormBoundary7MA4YWxk\r\n          ← delimiter("--" + boundary)
Content-Disposition: form-data; name="username"\r\n
\r\n                                          ← 空行分隔头部和数据
alice                                         ← Part 数据(文本字段)
------WebKitFormBoundary7MA4YWxk\r\n          ← 下一个 Part
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"\r\n
Content-Type: image/jpeg\r\n
\r\n
<二进制文件数据>                                ← Part 数据(文件)
------WebKitFormBoundary7MA4YWxk--\r\n        ← end delimiter("--" + boundary + "--")

5.3 MultipartPart 与三个静态 API

hical 将每个 Part 解析为 MultipartPart 结构体(Multipart.h:19-35):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct MultipartPart
{
    std::unordered_map<std::string, std::string> headers;  // Part 头部
    std::string name;          // form 字段名
    std::string filename;      // 上传文件名(无则为空)
    std::string contentType;   // Part 的 Content-Type
    std::string data;          // Part 数据

    bool isFile() const { return !filename.empty(); }
};

MultipartParser 提供三个静态方法,覆盖不同使用场景:

1
2
3
4
5
6
7
8
// 完整解析:获取所有 Part
auto parts = MultipartParser::parse(req);

// 快捷方法:直接获取指定文件
auto avatar = MultipartParser::getFile(req, "avatar");

// 快捷方法:直接获取指定文本字段
auto username = MultipartParser::getField(req, "username");

使用示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
server.router().post("/upload",
    [](const hical::HttpRequest& req) -> hical::HttpResponse
{
    auto file = hical::MultipartParser::getFile(req, "avatar");
    if (!file)
    {
        return hical::HttpResponse::badRequest("No file uploaded");
    }

    // file->filename  → "photo.jpg"
    // file->data      → 文件二进制内容
    // file->contentType → "image/jpeg"

    // 保存到磁盘
    std::ofstream ofs("./uploads/" + file->filename, std::ios::binary);
    ofs.write(file->data.data(), file->data.size());

    return hical::HttpResponse::json({
        {"filename", file->filename},
        {"size", file->data.size()}
    });
});

5.4 四步解析流程

MultipartParser::parse() 的解析流程(Multipart.cpp:160-262)可以分为四步:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
步骤 1: 提取 boundary
  Content-Type: multipart/form-data; boundary=----WebKit...
  extractBoundary() → "----WebKit..."

步骤 2: 定位第一个 delimiter
  body 中搜索 "--" + boundary
  跳过 delimiter 后的 CRLF

步骤 3: 循环解析每个 Part
  ┌────────────────────────────────────┐
  │ 搜索下一个 delimiter               │
  │         ↓                          │
  │ partData = 当前位置到下一 delimiter │
  │         ↓                          │
  │ 找 "\r\n\r\n" 分割头部和数据       │
  │         ↓                          │
  │ parsePartHeaders() 解析头部        │
  │         ↓                          │
  │ parts.push_back(part)              │
  │         ↓                          │
  │ 检查 Part 数量上限(256)          │
  │         ↓                          │
  │ 下一 delimiter 后是 "--"?          │
  │   ├─ 是 → 结束                     │
  │   └─ 否 → 继续循环                 │
  └────────────────────────────────────┘

步骤 4: 返回 parts 列表

extractBoundary 实现(Multipart.cpp:54-85):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
std::string MultipartParser::extractBoundary(const std::string& contentType)
{
    auto pos = contentType.find("boundary=");
    if (pos == std::string::npos)
    {
        return "";
    }
    pos += 9;  // 跳过 "boundary="
    std::string_view rest(contentType.c_str() + pos, contentType.size() - pos);

    rest = trim(rest);
    rest = unquote(rest);  // 处理引号包裹的 boundary

    auto semi = rest.find(';');
    if (semi != std::string_view::npos)
    {
        rest = rest.substr(0, semi);
    }

    auto boundary = std::string(trim(rest));

    // RFC 2046: boundary 最长 70 字符
    if (boundary.size() > 70)
    {
        return "";
    }

    return boundary;
}

5.5 安全防护:多层纵深防御

文件上传是 Web 应用中最危险的操作之一。hical 在多个层次提供防护:

防护层机制说明
连接层maxBodySizeTcpServer 限制请求体总大小(默认 10MB),超出直接断开
协议层boundary 长度检查RFC 2046 要求 ≤70 字符,拒绝超长 boundary
解析层256 Part 上限防止在 maxBodySize 内构造大量小 Part 消耗 CPU
应用层用户自行校验检查文件名、大小、类型,防止上传恶意文件

256 Part 上限的实现(Multipart.cpp:236-240):

1
2
3
4
5
6
// Part 数量上限:防止在 maxBodySize 内构造大量小 Part 消耗 CPU/内存
static constexpr std::size_t hMaxMultipartParts = 256;
if (parts.size() >= hMaxMultipartParts)
{
    return std::nullopt;
}

为什么 256 够用? 常见的文件上传表单通常只有几个到几十个字段。256 对正常使用绰绰有余,但能有效阻止 DoS 攻击——攻击者可以构造一个合法大小的请求体,但内含上千个微小 Part,让解析器消耗大量 CPU 和内存。


6. 综合实战:带登录的文件管理服务

现在让我们把四个模块组装起来,构建一个完整的小型文件管理服务:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
#include <hical/HttpServer.h>
#include <hical/Session.h>
#include <hical/StaticFiles.h>
#include <hical/Multipart.h>

int main()
{
    // ========== 配置 ==========
    hical::SessionOptions sessionOpts;
    sessionOpts.maxAge = 7200;  // 2 小时
    auto sessionMgr = std::make_shared<hical::SessionManager>(sessionOpts);

    hical::HttpServer<hical::AsioBackend> server;

    // ========== 中间件 ==========
    server.use(hical::makeSessionMiddleware(sessionMgr));

    // ========== 登录 ==========
    server.router().post("/api/login",
        [](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        auto json = req.jsonBody();
        auto user = std::string(json.at("username").as_string());
        auto pass = std::string(json.at("password").as_string());

        // 简化示例:实际应查数据库 + bcrypt 校验
        if (user != "admin" || pass != "123456")
        {
            return hical::HttpResponse::badRequest("Invalid credentials");
        }

        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
        session->set("user", user);

        return hical::HttpResponse::json({{"status", "ok"}, {"user", user}});
    });

    // ========== 文件列表 ==========
    server.router().get("/api/files",
        [](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        // 检查登录
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
        if (!session->has("user"))
        {
            return hical::HttpResponse::badRequest("Please login first");
        }

        // 列出 uploads 目录
        namespace fs = std::filesystem;
        boost::json::array files;
        for (const auto& entry : fs::directory_iterator("./uploads"))
        {
            if (entry.is_regular_file())
            {
                files.push_back({
                    {"name", entry.path().filename().string()},
                    {"size", entry.file_size()}
                });
            }
        }
        return hical::HttpResponse::json({{"files", files}});
    });

    // ========== 文件上传 ==========
    server.router().post("/api/upload",
        [](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        // 检查登录
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
        if (!session->has("user"))
        {
            return hical::HttpResponse::badRequest("Please login first");
        }

        auto file = hical::MultipartParser::getFile(req, "file");
        if (!file)
        {
            return hical::HttpResponse::badRequest("No file in request");
        }

        // 保存文件
        std::ofstream ofs("./uploads/" + file->filename, std::ios::binary);
        ofs.write(file->data.data(), file->data.size());

        return hical::HttpResponse::json({
            {"filename", file->filename},
            {"size", file->data.size()}
        });
    });

    // ========== 静态文件(前端页面) ==========
    server.router().get("/static/{path}",
        hical::serveStatic("./public", "/static/"));

    // ========== 登出 ==========
    server.router().post("/api/logout",
        [&sessionMgr](const hical::HttpRequest& req) -> hical::HttpResponse
    {
        auto sessionOpt = req.getAttribute(hical::SessionManager::hSessionKey);
        if (sessionOpt)
        {
            auto session = std::any_cast<std::shared_ptr<hical::Session>>(*sessionOpt);
            sessionMgr->destroy(session->id());
        }

        hical::HttpResponse res = hical::HttpResponse::json({{"status", "logged out"}});
        res.setCookie("HICAL_SESSION", "", {.maxAge = 0});
        return res;
    });

    server.listen(8080);
    server.run();
}

完整的请求流转:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
用户访问 /static/index.html
    
    
Session 中间件  创建新 Session  Cookie: HICAL_SESSION=abc123
    
    
静态文件路由  读取 ./public/index.html  200 OK
    
    
用户提交登录表单 POST /api/login
    
    
Session 中间件  Cookie 中找到 abc123  加载 Session
    
    
路由 Handler  session.set("user", "admin")  dirty!
    
    
Session 中间件 (after)  刷新 Set-Cookie
    
    
用户上传文件 POST /api/upload (multipart/form-data)
    
    
Session 中间件  验证登录态
    
    
路由 Handler  MultipartParser::getFile("file")  保存到磁盘
    
    
200 OK {"filename": "report.pdf", "size": 102400}

7. 全系列总结

经过五篇文章,我们从设计理念一路走到 Web 应用的四大基础模块,完整地剖析了 hical 框架的每一层。

知识图谱

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
从零构建现代 C++ Web 服务器
├── 第一篇:设计理念与架构总览
│   ├── 为什么用 C++ 写 Web 框架
│   ├── 两层架构:core(抽象)+ asio(实现)
│   ├── C++20 Concepts 后端抽象
│   └── 线程模型:1 Thread : 1 io_context
├── 第二篇:协程异步与 PMR 内存池
│   ├── 从回调地狱到 co_await
│   ├── Awaitable<T> 协程基石
│   ├── PMR 三层内存架构
│   └── PmrBuffer 零拷贝缓冲区
├── 第三篇:路由、中间件与 SSL
│   ├── 双策略路由(哈希 O(1) + 参数线性)
│   ├── 洋葱模型中间件管道
│   ├── 模板化 SSL(编译期零开销)
│   └── WebSocket 集成
├── 第四篇:实战案例与性能调优
│   ├── RESTful API 完整案例
│   ├── WebSocket 实时通信案例
│   ├── C++26 反射宏系统
│   ├── 性能调优实战
│   └── 安全加固清单
└── 第五篇:Cookie、Session、静态文件与文件上传(本文)
    ├── Cookie 惰性解析与 RFC 6265 编码
    ├── Session 中间件与懒 GC
    ├── 静态文件 ETag 缓存与路径遍历防护
    └── Multipart 文件上传与 DoS 防护

核心设计决策完整表

#决策选择核心理由
1协程模型asio::awaitable<T>与 Boost.Asio 生态无缝集成
2HTTP 解析Boost.Beast成熟、标准、零额外依赖
3内存管理C++17 PMR 三层池全局→线程→请求,逐层减少竞争
4SSL 实现模板化 if constexpr不用 SSL 时零开销
5后端抽象C++20 Concepts编译期约束,零运行时开销
6路由查找哈希表 + 线性匹配静态 O(1),参数路由灵活
7中间件洋葱模型 + 预构建链直觉清晰,运行时零额外分配
8反射C++26 双路线面向未来,C++20 也能用
9线程模型1:1 (Thread:io_context)无锁连接处理,round-robin 分发
10Cookie 解析惰性解析 + mutable optional不用不解析,const 友好
11Cookie 编码RFC 6265 百分号编码标准合规 + CRLF 注入防护
12Session 存储std::any + mutex任意类型,线程安全
13Session ID128 位 thread_local 随机数不可预测 + 无锁生成
14Session GC懒 GC(create 时触发)零额外线程/定时器开销
15路径安全canonical() + 逐段迭代器双重防护,无边界问题
16文件缓存ETag(size + mtime)轻量有效,无哈希计算
17Multipart 防护256 Part 上限阻止小 Part DoS 攻击

上一篇从零构建现代C++ Web服务器(四):实战案例与性能调优

下一篇从零构建现代C++ Web服务器(六):数据库中间件与协程连接池