C++快速原型开发:极简HTTP服务与SQLite数据库集成实战

发布时间:2026/7/24 14:28:20
C++快速原型开发:极简HTTP服务与SQLite数据库集成实战 1. 项目概述为什么我们需要一个“极简”的现代C原型方案在C社区里我们常常陷入一种困境一方面我们渴望使用这门强大语言带来的性能优势和精细控制另一方面当我们需要快速验证一个想法、搭建一个演示接口或者只是写个小工具时又会被C传统的“重型”开发流程劝退——光是配环境、选框架、处理依赖和编译链接可能半天就过去了。这种感觉就像你想在自家后院搭个临时工具棚结果却被迫从打地基、烧砖砌墙开始等你把钢筋混凝土结构搭好黄花菜都凉了。这就是“快速原型开发”在现代C语境下的核心痛点。我们需要的不是一个大而全的企业级框架而是一套能“抄起就用”的组合拳。最近我在一个内部工具项目中就遇到了这样的需求需要一个轻量级的HTTP服务能接收一些JSON数据然后存到本地数据库里方便后续查询和分析。听起来很简单对吧但用传统方法你可能会想到用Boost.Beast做HTTP用libpqxx或ODBC连数据库光是引入这些库的依赖管理和编译配置就足以让原型开发的“快速”二字荡然无存。于是我找到了一个堪称“黄金搭档”的组合Cpp-httplib和SQLiteCpp。这个组合的精髓就在于“极简集成”。Cpp-httplib是一个单头文件的HTTP库这意味着你只需要包含一个.hpp文件就能立刻拥有一个功能完整的HTTP服务器或客户端。SQLiteCpp则是对SQLite C API的一个优雅的现代C封装它同样设计简洁依赖极少。将这两者结合你几乎可以在几分钟内从一个空的main.cpp文件搭建起一个具备RESTful API和持久化存储能力的后台服务骨架。这个方案的价值远不止于“快”。它降低了现代C入门服务端开发的门槛让开发者能将精力集中在业务逻辑本身而非底层基础设施的搭建上。无论是做物联网设备的控制接口、开发一个本地数据管理桌面应用的后端还是为机器学习模型提供一个简单的推理服务这个组合都能让你迅速上手看到成果。接下来我就带你从零开始拆解这个极简集成的每一个环节分享我踩过的坑和总结出的最佳实践。2. 核心组件选型解析为何是它们俩在开始动手之前我们得先搞清楚为什么在众多C库中偏偏选中了Cpp-httplib和SQLiteCpp。这不是随便抓两个库凑合而是基于快速原型开发的几个核心诉求做出的权衡。2.1 Cpp-httplib把HTTP服务器变成“单文件”艺术Cpp-httplib的作者是Yuji Hirose它的设计哲学深深吸引了我零依赖、单头文件、易于使用。你不需要CMake不需要找一堆.so或.dll文件更不需要处理复杂的构建系统。只需要从GitHub上把httplib.h文件下载下来放到你的项目目录里然后在代码中#include “httplib.h”一切就绪。它的API设计也非常符合现代C的直觉。创建一个服务器只需要几行代码httplib::Server svr; svr.Get(“/hi”, [](const httplib::Request req, httplib::Response res) { res.set_content(“Hello World!”, “text/plain”); }); svr.listen(“0.0.0.0”, 8080);看定义一个GET /hi的路由并返回“Hello World”清晰得就像在写Python的Flask。它天然支持路由、路径参数、查询参数、多种格式的请求体JSON, Form-data, Multipart解析以及静态文件服务。对于原型开发来说这些功能已经覆盖了90%的API场景。当然它并非全能。它基于阻塞式I/O模型这意味着每个连接都会阻塞一个线程。对于高并发生产环境这显然不是最佳选择。但请记住我们的场景快速原型。在这个阶段我们追求的是开发速度和心智负担的减轻而不是每秒十万级的QPS。当你的原型被验证需要走向生产时你完全可以将业务逻辑迁移到像Drogon、Crow或Boost.Beast这样的异步框架中。Cpp-httplib在这里扮演的角色是一个无比称职的“脚手架”。注意虽然它是单头文件但内部实现仍然依赖于操作系统底层的Socket API。在Windows上你需要链接Ws2_32.lib在Linux/macOS上则需要链接pthread库。这是使用任何网络库都无法避免的底层依赖构建时加上-lws2_32或-lpthread即可。2.2 SQLiteCpp现代C语法糖包裹的经典嵌入式数据库数据库的选择是另一个关键。我们需要一个无需单独安装服务器、零配置、并且能通过C直接操作的数据库。SQLite几乎是唯一且完美的选择。而SQLiteCpp作者Sylvain Benner则是在SQLite的C API之上披上了一层漂亮的现代C外衣。直接使用SQLite的C API是可行的但代码会充斥着大量的sqlite3_*函数调用、手动管理语句对象sqlite3_stmt和繁琐的错误检查代码风格瞬间倒退二十年。SQLiteCpp通过RAII资源获取即初始化机制让这一切变得优雅而安全。看看对比原生C API你需要手动sqlite3_prepare_v2,sqlite3_step,sqlite3_column_*, 最后还要sqlite3_finalize并且每一步都要检查返回值。SQLiteCpp你可以这样写SQLite::Database db(“my_database.db”, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); SQLite::Statement query(db, “INSERT INTO users (name, age) VALUES (?, ?)”); query.bind(1, “Alice”); query.bind(2, 30); query.exec();数据库和语句对象的生命周期由栈上的对象自动管理无需手动关闭或释放。bind方法提供了类型安全的数据绑定exec()会自动执行并清理。这种写法不仅更安全避免了资源泄漏也更具可读性。SQLiteCpp的“极简”体现在它只强依赖于SQLite本身。你通常需要获取SQLite的源码或预编译库然后和SQLiteCpp一起编译。虽然比Cpp-httplib多一步但依然比配置一个MySQL或PostgreSQL客户端库要简单得多。2.3 组合优势112的化学反应单独看它们都是优秀的库。组合起来则产生了奇妙的化学反应开发流极致顺畅你可以在一个CPP文件中同时#include这两个库然后就开始编写“接收HTTP请求 - 处理业务逻辑 - 读写数据库 - 返回HTTP响应”的完整流程。编码过程几乎没有上下文切换。构建过程简单项目依赖图极其清晰。你的构建脚本只需要关心三件事编译你的源代码、链接SQLite库、链接系统网络/线程库。没有复杂的传递依赖没有版本冲突这让跨平台编译Windows, Linux, macOS也变得相对容易。二进制交付简单最终生成的可执行文件是静态链接的如果SQLite是静态库或者只依赖极少数的系统动态库。你可以轻松地将这个可执行文件拷贝到任何同类型系统的机器上运行非常适合打包成桌面工具或嵌入式设备上的服务。这个组合精准地命中了快速原型开发的靶心用最小的环境准备代价获得最大化的功能实现能力。3. 从零开始的极简集成实战理论说得再多不如动手一试。让我们从一个最经典的例子开始构建一个简单的待办事项TodoAPI服务。它支持创建任务、列出所有任务、以及将任务标记为完成。我们将使用JSON作为前后端通信的数据格式。3.1 项目环境与依赖准备首先我们需要准备好“食材”。假设我们使用Linux/macOS系统Windows的步骤在注释中说明并使用CMake作为构建工具这是现代C项目的事实标准。获取Cpp-httplib 直接克隆仓库或下载单头文件。# 进入你的项目目录 mkdir cpp_quick_prototype cd cpp_quick_prototype # 下载单头文件推荐 wget https://raw.githubusercontent.com/yhirose/cpp-httplib/master/httplib.h # 或者克隆整个仓库以获取示例 # git clone https://github.com/yhirose/cpp-httplib.git获取SQLiteCpp和SQLite 这里我们使用add_subdirectory的方式将源码直接纳入项目构建避免手动安装。# 克隆SQLiteCpp仓库 git clone https://github.com/SRombauts/SQLiteCpp.git # 进入SQLiteCpp目录它包含一个指向SQLite源码的子模块 cd SQLiteCpp git submodule init git submodule update cd ..执行完后你的目录结构大致如下cpp_quick_prototype/ ├── httplib.h ├── SQLiteCpp/ │ ├── include/ │ ├── src/ │ └── sqlite3/ # 这是子模块包含了SQLite源码 └── CMakeLists.txt编写CMakeLists.txt 这是集成的核心一个配置正确的CMake脚本能省去无数麻烦。cmake_minimum_required(VERSION 3.10) project(CppQuickPrototype VERSION 1.0.0 LANGUAGES CXX) # 设置C标准为现代版本C11及以上根据你的需求调整 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 将SQLiteCpp目录添加为子目录它会自动编译SQLite和SQLiteCpp add_subdirectory(SQLiteCpp) # 创建我们的可执行目标 add_executable(todo_server main.cpp) # 链接依赖库 # 链接SQLiteCpp库它会自动传递SQLite3的依赖 target_link_libraries(todo_server PRIVATE SQLiteCpp) # 链接系统特定的网络和线程库 if (WIN32) target_link_libraries(todo_server PRIVATE ws2_32) else() target_link_libraries(todo_server PRIVATE pthread) endif() # 包含头文件目录 # httplib是单头文件放在当前目录即可无需额外包含。 # 需要包含SQLiteCpp的头文件目录 target_include_directories(todo_server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/SQLiteCpp/include)实操心得在Windows上使用Visual Studio时除了链接ws2_32.lib有时还需要在代码中定义_WIN32_WINNT宏例如在main.cpp最开头加上#define _WIN32_WINNT 0x0601以启用较新版本的Windows API避免某些Socket函数未定义。3.2 核心代码实现一个完整的Todo API现在我们来编写main.cpp。我会逐段解释并穿插关键技巧。#include “httplib.h” #include SQLiteCpp/SQLiteCpp.h #include nlohmann/json.hpp // 需要一个JSON库这里用nlohmann/json同样是单头文件 #include iostream #include string using json nlohmann::json; // 首先定义我们的数据模型和数据库初始化逻辑 void initialize_database(SQLite::Database db) { try { // 创建todo表如果不存在的话 db.exec(“CREATE TABLE IF NOT EXISTS todos (” “id INTEGER PRIMARY KEY AUTOINCREMENT,” “title TEXT NOT NULL,” “completed BOOLEAN NOT NULL DEFAULT 0,” “created_at DATETIME DEFAULT CURRENT_TIMESTAMP” “);”); std::cout “Database initialized successfully.” std::endl; } catch (const std::exception e) { std::cerr “SQLite exception: ” e.what() std::endl; } } int main() { // 1. 打开或创建数据库 SQLite::Database db(“todo.db”, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); initialize_database(db); // 2. 创建HTTP服务器 httplib::Server svr; // 3. 定义API路由 // GET /todos - 获取所有待办事项 svr.Get(“/todos”, [db](const httplib::Request req, httplib::Response res) { try { SQLite::Statement query(db, “SELECT id, title, completed FROM todos ORDER BY created_at DESC”); json todos json::array(); // 创建一个JSON数组 while (query.executeStep()) { json todo; todo[“id”] query.getColumn(“id”).getInt(); todo[“title”] query.getColumn(“title”).getString(); todo[“completed”] query.getColumn(“completed”).getInt() ! 0; todos.push_back(todo); } res.set_content(todos.dump(), “application/json”); // 设置JSON响应头 } catch (const std::exception e) { res.status 500; // 内部服务器错误 res.set_content(json{{“error”, e.what()}}.dump(), “application/json”); } }); // POST /todos - 创建新的待办事项 svr.Post(“/todos”, [db](const httplib::Request req, httplib::Response res) { try { auto j json::parse(req.body); // 解析请求体中的JSON std::string title j.at(“title”).getstd::string(); // 使用.at()进行安全访问 SQLite::Statement query(db, “INSERT INTO todos (title) VALUES (?)”); query.bind(1, title); query.exec(); // 获取最后插入的ID int64_t lastId db.getLastInsertRowid(); json response {{“id”, lastId}, {“title”, title}, {“completed”, false}}; res.status 201; // Created res.set_content(response.dump(), “application/json”); } catch (const json::exception e) { res.status 400; // 请求错误 res.set_content(json{{“error”, “Invalid JSON: ” std::string(e.what())}}.dump(), “application/json”); } catch (const std::exception e) { res.status 500; res.set_content(json{{“error”, e.what()}}.dump(), “application/json”); } }); // PATCH /todos/:id - 更新待办事项这里用于标记完成 svr.Patch(R”(/todos/(\d))”, [db](const httplib::Request req, httplib::Response res) { try { int todo_id std::stoi(req.matches[1]); // 从正则匹配中提取ID auto j json::parse(req.body); bool completed j.at(“completed”).getbool(); SQLite::Statement query(db, “UPDATE todos SET completed ? WHERE id ?”); query.bind(1, completed ? 1 : 0); query.bind(2, todo_id); if (query.exec() 0) { // exec()返回受影响的行数 res.set_content(json{{“message”, “Updated successfully”}}.dump(), “application/json”); } else { res.status 404; // 未找到 res.set_content(json{{“error”, “Todo not found”}}.dump(), “application/json”); } } catch (const std::exception e) { res.status 400; res.set_content(json{{“error”, e.what()}}.dump(), “application/json”); } }); std::cout “Server starting on http://localhost:8080” std::endl; svr.listen(“localhost”, 8080); // 监听本地8080端口 return 0; }3.3 关键实现细节与技巧解析这段代码虽然不长但包含了几个快速原型开发中至关重要的技巧和避坑点JSON处理Cpp-httplib只负责传输原始的请求体字符串不内置JSON解析。我选择了nlohmann/json库它同样是单头文件只需下载json.hpp放入项目即可。它的API直观json::parse()和j.dump()是核心。务必使用try-catch包裹解析逻辑因为用户可能发送无效JSON。数据库连接管理注意我在main函数开头创建了SQLite::Database对象db并以引用捕获[db]的方式在所有Lambda表达式中使用它。这意味着所有HTTP处理函数共享同一个数据库连接。对于SQLite这种嵌入式数据库并且在原型阶段的低并发场景下这是最简单高效的方式。但在生产多线程环境中你需要为每个线程创建连接或使用连接池。错误处理与HTTP状态码这是让API变得“友好”的关键。我为每个路由都添加了try-catch块并将数据库异常或逻辑错误转化为合适的HTTP状态码400 404 500和JSON错误信息返回。这在前端调试时非常有用。SQL注入防御永远不要使用字符串拼接来构造SQL语句SQLiteCpp的Statement和bind方法提供了参数化查询这是防止SQL注入的标准做法。代码中INSERT INTO todos (title) VALUES (?)和bind(1, title)的用法是安全的。路由与参数提取Cpp-httplib支持简单的正则表达式路由。R”(/todos/(\d))”是一个原始字符串匹配像/todos/123这样的路径并将数字部分捕获到req.matches[1]中。std::stoi用于将其转换为整数记得处理转换失败的情况示例中省略了生产代码应补充。4. 构建、运行与测试代码写好了让我们把它跑起来。构建项目mkdir build cd build cmake .. make -j4如果一切顺利你会在build目录下看到todo_server可执行文件。运行服务器./todo_server控制台会输出Server starting on http://localhost:8080。使用工具测试API 我们可以用最常用的curl命令来测试创建Todo:curl -X POST http://localhost:8080/todos \ -H “Content-Type: application/json” \ -d ‘{“title”: “Buy milk”}’预期返回{“id”:1, “title”:”Buy milk”, “completed”:false}状态码201。列出所有Todo:curl http://localhost:8080/todos标记Todo为完成:curl -X PATCH http://localhost:8080/todos/1 \ -H “Content-Type: application/json” \ -d ‘{“completed”: true}’再次列出会看到该条目completed变为true。你也可以使用图形化工具如Postman或Insomnia操作更直观。5. 进阶技巧与常见问题排查一个能跑的原型只是第一步。在实际开发中你会遇到各种问题。下面是我总结的一些进阶技巧和常见坑位。5.1 静态文件服务与前端集成你的API后端写好了通常还需要一个简单的前端界面来交互。Cpp-httplib可以轻松提供静态文件服务。// 假设你的前端HTML/JS/CSS文件放在项目根目录的 ‘web’ 文件夹下 svr.set_mount_point(“/”, “./web”);添加这行代码后访问http://localhost:8080/index.html就会自动返回./web/index.html文件。这样你就可以用Vue/React写个简单页面或者直接用纯HTMLJavaScript调用你自己的API形成一个完整的前后端原型。注意设置静态文件目录时要注意路径安全问题。Cpp-httplib默认会阻止目录遍历攻击如../../../etc/passwd但最好还是将其指向一个专用于静态资源的子目录。5.2 处理跨域请求CORS当你用前端页面运行在http://127.0.0.1:5500或http://localhost:3000调用后端APIlocalhost:8080时浏览器会因为同源策略而阻止请求。解决方法是在服务器的响应中添加CORS头。你可以为每个路由手动添加但更高效的方式是使用一个全局的预处理过滤器Handlersvr.Options(“.*”, [](const httplib::Request req, httplib::Response res) { // 处理预检请求 res.set_header(“Access-Control-Allow-Origin”, “*”); res.set_header(“Access-Control-Allow-Methods”, “GET, POST, PATCH, DELETE, OPTIONS”); res.set_header(“Access-Control-Allow-Headers”, “Content-Type”); }); svr.Post(“/todos”, [db](const httplib::Request req, httplib::Response res) { // 在处理实际请求前也加上CORS头 res.set_header(“Access-Control-Allow-Origin”, “*”); // ... 原有的业务逻辑 ... });或者写一个通用的PreRouting处理器来为所有响应添加这些头。对于原型阶段允许所有来源*是方便的但在上线前务必根据实际情况限制来源。5.3 数据库并发与线程安全我们的示例中所有HTTP处理函数共享一个SQLite::Database对象。Cpp-httplib默认会为每个连接创建一个新线程如果使用多线程模式。SQLite在默认配置下同一个连接对象不能同时在多个线程中使用这会导致程序崩溃或数据损坏。解决方案使用互斥锁Mutex保护这是最简单的方案但会严重限制并发性能因为所有数据库操作串行化了。std::mutex db_mutex; svr.Get(“/todos”, [db, db_mutex](const httplib::Request req, httplib::Response res) { std::lock_guardstd::mutex lock(db_mutex); // ... 数据库操作 ... });每个线程创建独立连接在Lambda表达式内部每次处理请求时创建新的SQLite::Database对象。SQLite本身支持多线程同时读但写操作会序列化。对于原型或轻量级应用这通常可以接受因为SQLite打开和关闭连接很快。svr.Get(“/todos”, [](const httplib::Request req, httplib::Response res) { SQLite::Database db(“todo.db”, SQLite::OPEN_READONLY); // 只读连接 // ... 操作db ... }); // db对象在此析构自动关闭连接重要提示如果采用此方案写入操作POST PATCH必须使用SQLite::OPEN_READWRITE标志并且要小心处理可能出现的SQLITE_BUSY错误数据库被锁。一个更健壮的做法是使用SQLite::OPEN_READWRITE | SQLite::OPEN_NOMUTEX等标志但这涉及到更深入的SQLite配置。5.4 常见编译与运行问题排查表问题现象可能原因解决方案编译错误undefined reference tosqlite3_xxx‘没有正确链接SQLite3库。确保target_link_libraries中包含了SQLiteCpp它会传递依赖。检查CMake是否成功找到了SQLite源码。编译错误pthread相关错误Linux/macOS没有链接pthread库。在target_link_libraries中添加pthread。链接错误WS2_32.lib找不到Windows没有链接Windows socket库。在target_link_libraries中添加ws2_32。运行时报错Address already in use端口8080被其他程序占用。换一个端口例如svr.listen(“localhost”, 8081)。或用命令lsof -i :8080Linux/macOS或netstat -ano | findstr :8080Windows查找并结束占用进程。数据库操作抛出std::exceptionSQL语句错误、约束违反如重复主键、数据库文件权限问题。仔细查看异常信息e.what()。检查SQL语法。确保运行程序的用户对数据库文件有读写权限。API请求返回404路由路径或方法不匹配。检查curl或前端代码的URL和HTTP方法GET/POST等是否与服务器定义的一致。注意末尾斜杠。前端调用API失败控制台显示CORS错误未设置CORS响应头。按照5.2节的方法在服务器响应中添加Access-Control-Allow-Origin等头信息。5.5 性能与扩展性考量记住这是一个快速原型方案。它的优势在于开发速度而不是极限性能。当你的原型需要向生产环境演进时需要考虑以下几点HTTP服务器如前所述Cpp-httplib是阻塞式的。对于I/O密集型或高并发服务应考虑替换为异步框架如Drogon、Crow基于Boost.Asio或Boost.Beast。迁移时你的核心业务逻辑数据库操作部分通常可以大部分复用。数据库SQLite在并发写入较多时100个连接会成为瓶颈。如果数据量和并发量增长需要迁移到客户端-服务器模式的数据库如PostgreSQL或MySQL。你可以使用ORM库如sqlite_orm它本身也支持SQLite但设计上更容易迁移到其他数据库抽象层来提前抽象数据访问层减少未来迁移的成本。配置与部署原型代码通常将配置如端口、数据库路径硬编码。生产环境需要从配置文件或环境变量读取。可以考虑使用如libconfig、yaml-cpp或简单的环境变量来管理配置。6. 项目结构优化与代码组织当你的原型功能越来越复杂把所有代码堆在main.cpp里会变得难以维护。是时候考虑一个更清晰的项目结构了。这不仅能提升可读性也为未来的扩展打下基础。一个建议的目录结构如下cpp_quick_prototype/ ├── CMakeLists.txt ├── httplib.h ├── nlohmann_json.hpp (可选如果用了json库) ├── main.cpp ├── include/ │ ├── database.hpp │ ├── todo_model.hpp │ └── todo_handler.hpp ├── src/ │ ├── database.cpp │ ├── todo_model.cpp │ └── todo_handler.cpp └── SQLiteCpp/ (作为子模块)关键拆分思路数据模型层Model在todo_model.hpp/cpp中定义Todo结构体或类以及专门操作todos表的DAO数据访问对象函数如getAllTodos(),createTodo(),updateTodo()。这实现了业务逻辑与数据库操作的初步分离。数据库连接管理层Database在database.hpp/cpp中定义一个类来封装SQLite::Database的创建、连接池如果需要以及一些通用的SQL执行辅助函数。这避免了数据库连接细节散落在各处。HTTP请求处理层Handler在todo_handler.hpp/cpp中定义处理各个路由的函数。这些函数接收httplib::Request和httplib::Response调用对应的Model层函数并组织HTTP响应。这样main.cpp就变得非常干净只负责服务器的创建、路由绑定和启动。示例拆分后的main.cpp#include “httplib.h” #include “include/database.hpp” #include “include/todo_handler.hpp” int main() { // 初始化数据库连接 Database::getInstance().initialize(“todo.db”); httplib::Server svr; // 绑定路由到处理函数 svr.Get(“/todos”, TodoHandler::getAllTodos); svr.Post(“/todos”, TodoHandler::createTodo); svr.Patch(R”(/todos/(\d))”, TodoHandler::updateTodo); // ... 可以继续绑定其他模块的处理函数 ... svr.set_mount_point(“/”, “./web”); std::cout “Server starting…” std::endl; svr.listen(“localhost”, 8080); return 0; }这样的结构使得添加新的功能模块例如用户管理user_handler变得非常容易代码也更容易进行单元测试。7. 总结与个人体会走完这一整套流程从环境搭建、依赖集成、代码编写、问题排查到结构优化你会发现用现代C进行快速原型开发并不像传说中那么“可怕”。Cpp-httplib SQLiteCpp 这个组合就像为你提供了一把锋利的瑞士军刀和一块万能的橡皮泥让你能迅速捏出想要的服务形态。我个人在多个小型工具和概念验证项目中反复使用这个组合最大的体会是它极大地释放了创造力。当你不再被复杂的框架配置和依赖管理所困扰就能更专注于解决实际问题本身。很多时候一个周末的功夫就能从零做出一个有实用价值的小工具。当然它有其边界。正如反复强调的这是原型利器而非银弹。当你的服务需要面对成百上千的并发连接或者数据量达到GB/TB级别时架构升级是必然的。但好消息是在这个过程中积累的核心业务逻辑C代码和设计模式如分层架构可以平滑地迁移到更强大的技术栈上。最后给想尝试的朋友一个建议从这个小Todo项目开始亲手敲一遍代码解决掉编译和运行中遇到的所有报错。这个过程会让你对现代C的构建、依赖管理和网络/数据库编程有最直观的认识。之后你可以尝试给它添加用户认证、更复杂的数据关系、文件上传或者用这个后端搭配一个简单的前端框架做一个完整的全栈应用。乐趣才刚刚开始。