[Hical] 一套 API,两套实现:C++26 反射双轨是怎么设计的#
本专栏文章:拆开 Hical · 第 7 篇(完结篇)
这是系列的最后一篇,也是最"前沿"的一篇——C++26 反射在中文技术社区几乎没有实战文章,因为它太新了。Hical 是目前全网唯二(或许唯一)在 open-source 项目中用 C++26 反射的生产级框架。
但 Hical 等不到所有编译器都支持 P2996——它必须兼容 GCC 14、Clang 20、MSVC 2022。所以它做了一套双轨架构:C++26 原生反射和 C++20 宏 fallback 提供相同的用户 API。
1. 问题:你要 JSON 序列化,但不想每个 struct 手写 to_json/from_json#
假设你定义了一个 DTO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| struct User
{
std::string name;
int age;
std::string email;
};
// 你想要的:
auto json = hical::meta::toJson(user); // → {"name":"张三","age":25,"email":"z3@example.com"}
auto user2 = hical::meta::fromJson<User>(json);
// 你不想要的:
boost::json::object to_json(const User& u)
{
boost::json::object obj;
obj["name"] = u.name;
obj["age"] = u.age;
obj["email"] = u.email;
return obj;
}
// ↑ 每个 DTO 手写一遍,字段增删时忘了更新序列化 → 静默 bug
|
传统方案(nlohmann/json)靠宏来自动生成,但需要每个类型手动注册。Hical 的做法是让框架自动发现结构体的字段。
2. 轨道 1:C++26 P2996 原生反射(需要编译器支持)#
如果编译器支持 __cpp_impl_reflection >= 202306L 且 __cpp_lib_reflection >= 202306L(P2996 特性测试宏,2025 年 6 月 Sofia 会议定稿),Hical 直接用原生反射:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| template <typename T>
boost::json::object toJson(const T& obj)
{
boost::json::object jsonObj;
// C++26 原生反射:自动枚举所有非静态公开数据成员
// (P2996R13 合并 P3547R1 访问控制建模后,需传 access_context)
template for (constexpr auto member :std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unprivileged()))
{
// 检查 [[hical::json_ignore]] 属性 → 跳过
if constexpr (!detail::isJsonIgnored<member>())
{
// 获取字段 key:优先 [[hical::json_name("xxx")]],否则原始字段名
constexpr auto key = detail::jsonKeyOf<member>();
jsonObj[key] = valueToJson(obj.[:member:]);
}
}
return jsonObj;
}
|
用户侧:
1
2
3
4
5
6
7
8
| struct User
{
std::string name;
int age;
[[hical::json_ignore]] std::string password; // 不序列化
[[hical::json_name("email_addr")]] std::string email; // 用别名
};
// 不需要宏!编译器通过 ^^T 和 nonstatic_data_members_of 自动发现字段
|
💡 这是 C++26 反射的 killer feature——不是"宏的替代品",而是"编译器替你写了那几百行重复代码"。
3. 轨道 2:C++20 宏回退(兼容现有编译器)#
当 HICAL_HAS_REFLECTION == 0 时(GCC 14、Clang 20、MSVC 2022 都不支持 P2996),转到宏方案:
3.1 字段注册:HICAL_JSON 宏#
1
2
3
4
5
6
7
8
9
10
11
| struct User
{
std::string name;
int age;
std::string password;
std::string email;
// 注册 JSON 序列化字段
HICAL_JSON(User, name, age, email)
// ↑ password 不在列表里 → 不序列化
};
|
3.2 宏展开:VA_OPT 递归#
HICAL_JSON(User, name, age, email) 展开成:
1
2
3
4
5
6
7
8
9
10
| static const auto& hicalJsonFields()
{
static const auto fields = std::make_tuple
(
hical::meta::detail::makeField("name", &User::name),
hical::meta::detail::makeField("age", &User::age),
hical::meta::detail::makeField("email", &User::email)
);
return fields;
}
|
💡 返回 const auto& 引用 + 内部 static const 局部变量保证单次构造——多次调用不会重复构建 tuple。
每个字段变成一个 FieldDescriptor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| template <typename Class, typename FieldType>
struct FieldDescriptor
{
std::string_view name;
FieldType Class::* pointer;
bool required = false;
bool ignored = false;
// 校验约束
std::optional<double> minVal;
std::optional<double> maxVal;
std::optional<std::string> pattern;
bool notEmpty = false;
std::optional<size_t> lengthMin;
std::optional<size_t> lengthMax;
};
|
3.3 字段遍历:index_sequence + 折叠表达式#
1
2
3
4
5
6
7
8
9
10
11
12
| template <typename T, typename Tuple, size_t... I>
void serializeFields(const T& obj, boost::json::object& jsonObj,const Tuple& fields, std::index_sequence<I...>)
{
auto serializeOne = [&](const auto& field)
{
if (!field.ignored)
{
jsonObj[field.name] = valueToJson(obj.*field.pointer);
}
};
(serializeOne(std::get<I>(fields)), ...); // 折叠表达式
}
|
编译期展开成:
1
2
3
| serializeOne(std::get<0>(fields)); // name
serializeOne(std::get<1>(fields)); // age
serializeOne(std::get<2>(fields)); // email
|
3.4 装饰器语法:ALIAS / REQUIRED / HICAL_IGNORE#
1
2
3
4
5
6
| HICAL_JSON(User,
ALIAS(name, "user_name"), // → json 中的 key 是 "user_name"
REQUIRED(age), // → fromJson 时缺失该字段则报错
HICAL_IGNORE(password), // → 显式忽略
ALIAS(email, "email_addr") // → 别名 + 正常序列化
)
|
宏的实现用了 HICAL_IS_PAREN_ 技巧来区分 ALIAS(field, "key") 和普通 field:
1
2
3
4
| // HICAL_IS_PAREN_: 检测参数是否被括号包裹
#define HICAL_IS_PAREN_(x) ...
// 如果 HICAL_IS_PAREN_(ALIAS(field, "key")) → 走 Tag dispatch 的 ALIAS 路径
// 如果 HICAL_IS_PAREN_(field) → 走普通字段路径
|
💡 __VA_OPT__(C++20)解决了可变宏参数的"空参数"问题——当字段列表为空时(HICAL_JSON(Empty, )),宏能正确处理 zero-case。
4. 两轨道共享同一套 API#
用户代码完全不需要知道用的是哪个轨道:
1
2
3
| // 一样的用法
auto user = req.readJson<User>(); // fromJson
auto resp = HttpResponse::json(toJson(status)); // toJson
|
框架内部通过 HasJsonFields<T> trait 自动检测:
1
2
3
| // 两个轨道都产生相同的编译期标记
// C++26: 编译器自动发现 nonstatic_data_members_of
// C++20: HICAL_JSON 宏生成 hicalJsonFields() 静态函数 → HasJsonFields<T> = true_type
|
同样的双轨架构也用于路由注册:
5.1 C++26 轨道:[[hical::route]] 属性#
1
2
3
4
5
6
7
8
9
10
11
| struct UserHandler
{
[[hical::route("/api/users", "GET")]]
HttpResponse listUsers(const HttpRequest& req) { ... }
[[hical::route("/api/users/{id}", "GET")]]
HttpResponse getUser(const HttpRequest& req) { ... }
};
// 一行注册
hical::meta::registerRoutes(router, handler);
|
registerRoutes 通过 ^^T 枚举所有成员函数,找到带有 [[hical::route]] 属性的,自动注册到 router。
5.2 C++20 轨道:HICAL_HANDLER + HICAL_ROUTES 宏#
1
2
3
4
5
6
7
8
9
10
11
12
| struct UserHandler
{
HttpResponse listUsers(const HttpRequest& req) { ... }
HICAL_HANDLER(Get, "/api/users", listUsers)
HttpResponse getUser(const HttpRequest& req) { ... }
HICAL_HANDLER(Get, "/api/users/{id}", getUser)
HICAL_ROUTES(UserHandler, listUsers, getUser)
};
hical::meta::registerRoutes(router, handler);
|
HICAL_HANDLER 展开成 static constexpr RouteInfo,HICAL_ROUTES 汇总所有 Handler 到一个 tuple 里。
序列化错误(类型不匹配、字段缺失、验证失败)的处理函数不依赖模板参数 T——它们只需要字段名和错误描述:
1
2
3
4
5
6
7
8
9
| // 非模板 [[noreturn]] 函数(注意参数用 std::string_view 而非 const char*)
namespace detail
{
[[noreturn]] void throwTypeMismatch(std::string_view expected);
[[noreturn]] void throwMissingField(std::string_view fieldName);
[[noreturn]] void throwParseError(std::string_view detail);
[[noreturn]] void throwValidationErrorNum(std::string_view fieldName, std::string_view rule, double limit);
[[noreturn]] void throwValidationErrorStr(std::string_view fieldName, std::string_view rule);
}
|
如果这些函数是模板的一部分(在 MetaJson.h 的模板上下文中),编译器会为每个 HICAL_JSON 类型生成一份独立的错误抛出代码。对 100 个 DTO 类型,就是 100 份相同的 throwTypeMismatch 机器码。
拆分到非模板的 .cpp 文件中后——100 个类型共享同一份错误处理代码。代码体积更小,icache 利用率更高。
7. OpenAPI Schema 双向复用#
有了字段描述信息(不管是 C++26 原生反射还是 C++20 宏生成的 tuple),框架可以同时做:
1
2
3
4
5
| // 方向 1:JSON 序列化
toJson(user); // 遍历 FieldDescriptor tuple
// 方向 2:OpenAPI Schema 生成
jsonSchema<User>(); // 遍历同一个 tuple → 生成 JSON Schema
|
同一个 FieldDescriptor 元组被两个完全不同的系统使用——不需要额外标注。
1
2
3
4
5
6
7
8
9
| // jsonSchema<User>() 输出:
{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": "string"}
}
}
|
回顾一下你学会了什么#
- C++26 P2996 反射:
^^T + nonstatic_data_members_of(^^T, access_context::unprivileged()) + [[hical::route]] 属性 - C++20 宏回退:
HICAL_JSON 宏 + makeField + std::index_sequence 折叠表达式 __VA_OPT__ 和 HICAL_IS_PAREN_ 的宏技巧解决装饰器语法- 双轨共享用户 API——HasJsonFields trait 自动检测
- MetaRoutes:同样的双轨用于路由注册(
nonstatic_member_functions_of(^^Handler, access_context::unprivileged())) - MetaJsonError 非模板拆分减少每个类型独立的错误处理代码
- OpenAPI Schema 和 JSON 序列化共享同一份 FieldDescriptor 元组
系列完结:回顾一下你走完了什么#
从第 1 篇的 HTTP 请求热路径到这里,我们拆完了 Hical 的全部核心模块:
| 篇 | 主题 | 核心收获 |
|---|
| 1 | HTTP 热路径 | ReadBufferPool borrow/return + picohttpparser 零拷贝 + 响应前缀模板 |
| 2 | Router | 透明哈希 O(1) + dispatchSync ~40ns + 三层路由优先级 |
| 3 | Middleware | tagged union + buildOptimizedChain 合并 Sync 中间件 |
| 4 | GenericConnection | Vyukov MPSC + MpscNodePool + alignas(64) + 写循环 double-check |
| 5 | IdleScanner | 集中式扫描替代 10,000 个 timer + Guard RAII + customTimeoutMs |
| 6 | PMR | 三级 PMR + StringPool + generation 热更新 |
| 7 | 反射双轨 | C++26 P2996 + C++20 宏 fallback + FieldDescriptor 双重用途 |
如果你一路跟下来,现在应该能从第一性原理出发,理解一个现代 C++ HTTP 框架的全部核心设计——不是"怎么用",而是"为什么这样设计"。
本系列完结。有问题直接在评论区留言。
开源地址:github.com/Hical61/Hical