Add project source files and build configuration

This commit is contained in:
Andrey Kutin 2026-07-03 09:40:24 +03:00
parent a7848f56dc
commit 99e0c2387a
11 changed files with 708 additions and 0 deletions

76
CMakeLists.txt Normal file
View File

@ -0,0 +1,76 @@
cmake_minimum_required(VERSION 3.14)
project(cards)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find required packages
find_package(Threads REQUIRED)
find_package(PkgConfig REQUIRED)
# Find PostgreSQL (libpqxx)
pkg_check_modules(PQXX REQUIRED libpqxx>=7.0.0)
pkg_check_modules(PQ REQUIRED libpq)
# Find Boost
find_package(Boost REQUIRED COMPONENTS system)
# Find nlohmann_json
find_package(nlohmann_json REQUIRED)
# Add executable
add_executable(cards
src/main.cpp
src/database.cpp
src/server.cpp
)
# Include directories
target_include_directories(cards PRIVATE
${Boost_INCLUDE_DIRS}
${PQXX_INCLUDE_DIRS}
${PQ_INCLUDE_DIRS}
src
)
# Link libraries
target_link_libraries(cards
${Boost_LIBRARIES}
${PQXX_LIBRARIES}
${PQ_LIBRARIES}
Threads::Threads
nlohmann_json::nlohmann_json
)
# For Windows, add ws2_32 and crypt32
if(WIN32)
target_link_libraries(cards ws2_32 crypt32)
endif()
# Compiler flags
target_compile_definitions(cards PRIVATE BOOST_ALL_NO_LIB)
# Set properties
set_target_properties(cards PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
# ─── Packaging (CPack) ────────────────────────────────────────────────
set(CPACK_PACKAGE_NAME "cards")
set(CPACK_PACKAGE_VERSION "1.0.0")
set(CPACK_PACKAGE_VENDOR "cards")
set(CPACK_PACKAGE_MAINTAINER "root")
set(CPACK_PACKAGE_CONTACT "root@localhost")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Cards web server")
set(CPACK_PACKAGE_DESCRIPTION "A simple C++20 web server for cards")
set(CPACK_PACKAGE_SECTION "web")
set(CPACK_DEBIAN_PACKAGE_SECTION "web")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_DEPENDS
"libpqxx-7.0 (>= 7.0), libpq5 (>= 13), libstdc++6 (>= 10), libgcc-s1"
)
set(CPACK_DEBIAN_FILE_NAME "cards-${CPACK_PACKAGE_VERSION}-Linux.deb")
set(CPACK_GENERATOR "DEB")
include(CPack)

133
README.md
View File

@ -1,2 +1,135 @@
# cards # cards
Веб-сервер на C++20 с использованием Boost.Beast и PostgreSQL. Отдаёт случайные записи из базы данных по REST API.
## Требования
- Компилятор с поддержкой C++20 (GCC 10+, Clang 10+)
- CMake >= 3.14
- PostgreSQL (runtime) + libpq + libpqxx >= 7.0
- Boost (system)
- nlohmann_json 3.x
Установка зависимостей (Debian/Ubuntu):
```bash
sudo apt-get install libboost-all-dev libpq-dev libpqxx-dev nlohmann-json3-dev cmake build-essential
```
## Сборка
```bash
./configure.sh
```
Или вручную:
```bash
mkdir -p build && cd build
cmake ..
make -j"$(nproc)"
cd ..
```
Бинарник: `build/bin/cards`
## Конфигурация
По умолчанию читает `config.json` из рабочей директории. Можно указать путь к другому файлу:
```bash
./build/bin/cards /path/to/config.json
```
### Поля `config.json`
**server**
| Поле | Тип | Значение по умолчанию |
|---|---|---|
| address | string | `"0.0.0.0"` |
| port | int | `8080` |
| threads | int | `4` |
**database**
| Поле | Тип | Значение по умолчанию |
|---|---|---|
| host | string | `"localhost"` |
| port | int | `5432` |
| dbname | string | `"testdb"` |
| user | string | `"postgres"` |
| password | string | (пусто) |
### Переменные окружения
Переопределяют соответствующие поля `config.json`:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
Пример:
```bash
DB_PASSWORD=secret ./build/bin/cards
```
## API
| Метод | Путь | Описание |
|---|---|---|
| GET | `/` | Случайная запись из таблицы `random_data` |
| GET | `/health` | Проверка состояния сервера |
### Пример ответа `GET /`
```json
{
"id": 3,
"name": "Gamma",
"value": 300,
"description": "Third test record",
"created_at": "2025-01-01 12:00:00",
"timestamp": 1735732800000000000
}
```
### Пример ответа `GET /health`
```json
{
"status": "ok",
"timestamp": 1735732800000000000
}
```
## База данных
При первом запуске автоматически создаётся таблица `random_data` и заполняется десятью тестовыми записями.
Схема таблицы:
```sql
CREATE TABLE random_data (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
value INTEGER NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## Сборка deb-пакета
```bash
cd build
cmake ..
cpack
```
Результат: `cards-1.0.0-Linux.deb`
## Завершение работы
Для остановки сервера отправьте сигнал `SIGINT` (Ctrl+C) или `SIGTERM`.

13
config.json Normal file
View File

@ -0,0 +1,13 @@
{
"server": {
"address": "0.0.0.0",
"port": 8080,
"threads": 4
},
"database": {
"host": "localhost",
"port": 5432,
"dbname": "testdb",
"user": "postgres"
}
}

20
configure.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
set -e
echo "=== Установка зависимостей ==="
sudo apt-get update
sudo apt-get install -y libboost-all-dev libpq-dev libpqxx-dev nlohmann-json3-dev cmake build-essential
echo "=== Сборка ==="
mkdir -p build
cd build
cmake ..
make -j"$(nproc)"
cd ..
echo ""
echo "=== Готово ==="
echo "Запуск: ./build/bin/cards"
echo ""
echo "Пароль от БД можно задать через переменную окружения:"
echo " DB_PASSWORD=secret ./build/bin/cards"

5
src/common.hpp Normal file
View File

@ -0,0 +1,5 @@
#pragma once
#include <nlohmann/json.hpp>
using json = nlohmann::json;

57
src/config.hpp Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <string>
#include <fstream>
#include <iostream>
#include <cstdlib>
#include "common.hpp"
struct ServerConfig {
std::string address;
int port;
int threads;
};
struct DatabaseConfig {
std::string host;
int port;
std::string dbname;
std::string user;
std::string password;
};
struct AppConfig {
ServerConfig server;
DatabaseConfig database;
static AppConfig loadFromFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw std::runtime_error("Cannot open config file: " + filename);
}
json j;
file >> j;
AppConfig config;
config.server.address = j.at("server").at("address");
config.server.port = j.at("server").at("port");
config.server.threads = j.at("server").at("threads");
config.database.host = j.at("database").value("host", "localhost");
config.database.port = j.at("database").value("port", 5432);
config.database.dbname = j.at("database").value("dbname", "");
config.database.user = j.at("database").value("user", "");
config.database.password = j.at("database").value("password", "");
// Environment variables override config file
if (const char* val = std::getenv("DB_HOST")) config.database.host = val;
if (const char* val = std::getenv("DB_PORT")) config.database.port = std::stoi(val);
if (const char* val = std::getenv("DB_NAME")) config.database.dbname = val;
if (const char* val = std::getenv("DB_USER")) config.database.user = val;
if (const char* val = std::getenv("DB_PASSWORD")) config.database.password = val;
return config;
}
};

99
src/database.cpp Normal file
View File

@ -0,0 +1,99 @@
#include "database.hpp"
#include <chrono>
Database::Database(const DatabaseConfig& cfg)
: conn(std::make_unique<pqxx::connection>(
"host=" + cfg.host +
" port=" + std::to_string(cfg.port) +
" dbname=" + cfg.dbname +
" user=" + cfg.user +
" password=" + cfg.password)),
rng(std::random_device{}()) {
ensureTableExists();
}
void Database::ensureTableExists() {
pqxx::work txn(*conn);
// Создаем таблицу, если её нет
txn.exec(
"CREATE TABLE IF NOT EXISTS random_data ("
" id SERIAL PRIMARY KEY,"
" name VARCHAR(100) NOT NULL,"
" value INTEGER NOT NULL,"
" description TEXT,"
" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
")"
);
// Проверяем, есть ли данные
auto result = txn.exec("SELECT COUNT(*) FROM random_data");
int count = result[0][0].as<int>();
// Если данных нет, заполняем тестовыми данными
if (count == 0) {
txn.exec(
"INSERT INTO random_data (name, value, description) VALUES "
"('Alpha', 100, 'First test record'), "
"('Beta', 200, 'Second test record'), "
"('Gamma', 300, 'Third test record'), "
"('Delta', 400, 'Fourth test record'), "
"('Epsilon', 500, 'Fifth test record'), "
"('Zeta', 600, 'Sixth test record'), "
"('Eta', 700, 'Seventh test record'), "
"('Theta', 800, 'Eighth test record'), "
"('Iota', 900, 'Ninth test record'), "
"('Kappa', 1000, 'Tenth test record')"
);
}
txn.commit();
}
json Database::getRandomRecord() {
std::lock_guard<std::mutex> lock(mtx);
pqxx::work txn(*conn);
// Получаем общее количество записей
auto countResult = txn.exec("SELECT COUNT(*) FROM random_data");
int totalCount = countResult[0][0].as<int>();
if (totalCount == 0) {
return json{
{"error", "No records found in database"}
};
}
// Генерируем случайный ID
std::uniform_int_distribution<int> idDist(1, totalCount);
int randomId = idDist(rng);
// Запрашиваем случайную запись
auto result = txn.exec_params(
"SELECT id, name, value, description, created_at "
"FROM random_data "
"WHERE id = $1",
randomId
);
if (result.empty()) {
// Если не нашли, берем первую (на всякий случай)
result = txn.exec("SELECT id, name, value, description, created_at FROM random_data LIMIT 1");
}
const auto& row = result[0];
json response = {
{"id", row[0].as<int>()},
{"name", row[1].as<std::string>()},
{"value", row[2].as<int>()},
{"description", row[3].as<std::string>()},
{"created_at", row[4].as<std::string>()},
{"timestamp", std::chrono::system_clock::now().time_since_epoch().count()}
};
txn.commit();
return response;
}

26
src/database.hpp Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include <pqxx/pqxx>
#include <string>
#include <random>
#include <memory>
#include <mutex>
#include "config.hpp"
#include "common.hpp"
class Database {
public:
explicit Database(const DatabaseConfig& config);
~Database() = default;
json getRandomRecord();
private:
std::unique_ptr<pqxx::connection> conn;
std::mt19937 rng;
std::mutex mtx;
// Создание тестовой таблицы, если она не существует
void ensureTableExists();
};

76
src/main.cpp Normal file
View File

@ -0,0 +1,76 @@
#include <iostream>
#include <exception>
#include <csignal>
#include <atomic>
#include "config.hpp"
#include "database.hpp"
#include "server.hpp"
std::atomic<bool> stopServer{false};
void signalHandler(int signal) {
if (signal == SIGINT || signal == SIGTERM) {
std::cout << "\nReceived shutdown signal. Stopping server..." << std::endl;
stopServer = true;
}
}
int main(int argc, char* argv[]) {
try {
// Загрузка конфигурации
std::string configFile = "config.json";
if (argc > 1) {
configFile = argv[1];
}
std::cout << "Loading configuration from: " << configFile << std::endl;
AppConfig config = AppConfig::loadFromFile(configFile);
// Инициализация базы данных
std::cout << "Connecting to database..." << std::endl;
auto db = std::make_shared<Database>(config.database);
std::cout << "Database connected successfully!" << std::endl;
// Инициализация и запуск сервера
Server server(
config.server.address,
config.server.port,
config.server.threads,
db
);
// Установка обработчиков сигналов
std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, signalHandler);
std::cout << "Server is running on "
<< config.server.address << ":"
<< config.server.port
<< " with " << config.server.threads << " threads" << std::endl;
std::cout << "Press Ctrl+C to stop" << std::endl;
// Запуск сервера в отдельном потоке
std::thread serverThread([&server]() {
server.run();
});
// Ожидание сигнала остановки
while (!stopServer) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::cout << "Stopping server..." << std::endl;
server.stop();
serverThread.join();
std::cout << "Server stopped successfully!" << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}

148
src/server.cpp Normal file
View File

@ -0,0 +1,148 @@
#include "server.hpp"
#include <iostream>
#include <chrono>
// Implementation of HttpSession
HttpSession::HttpSession(net::ip::tcp::socket&& socket, std::shared_ptr<Database> db)
: stream(std::move(socket)), db(db) {}
void HttpSession::run() {
readRequest();
}
void HttpSession::readRequest() {
req = {};
http::async_read(
stream,
buffer,
req,
beast::bind_front_handler(
&HttpSession::onRead,
shared_from_this()
)
);
}
void HttpSession::onRead(beast::error_code ec, std::size_t) {
if (ec) {
handleError(ec, "read");
return;
}
processRequest(std::move(req));
}
void HttpSession::processRequest(http::request<http::string_body>&& request) {
http::response<http::string_body> res{http::status::ok, request.version()};
res.set(http::field::server, "Boost.Beast Server");
res.set(http::field::content_type, "application/json");
try {
if (request.method() == http::verb::get && request.target() == "/") {
// Получаем случайный объект из БД
json response = db->getRandomRecord();
res.body() = response.dump(4);
} else if (request.method() == http::verb::get && request.target() == "/health") {
json response = {
{"status", "ok"},
{"timestamp", std::chrono::system_clock::now().time_since_epoch().count()}
};
res.body() = response.dump(4);
} else {
res.result(http::status::not_found);
json error = {
{"error", "Not Found"},
{"path", request.target()}
};
res.body() = error.dump(4);
}
} catch (const std::exception& e) {
res.result(http::status::internal_server_error);
json error = {
{"error", "Internal Server Error"},
{"message", e.what()}
};
res.body() = error.dump(4);
}
res.prepare_payload();
writeResponse(std::move(res));
}
void HttpSession::writeResponse(http::response<http::string_body>&& res) {
auto self = shared_from_this();
http::async_write(
stream,
res,
[self](beast::error_code ec, std::size_t) {
if (ec) {
self->handleError(ec, "write");
}
// Close the connection gracefully
self->stream.socket().shutdown(net::ip::tcp::socket::shutdown_send, ec);
}
);
}
void HttpSession::handleError(beast::error_code ec, const char* what) {
if (ec == net::error::eof || ec == net::error::connection_reset) {
// Normal disconnection
return;
}
std::cerr << "Error in " << what << ": " << ec.message() << std::endl;
}
// Implementation of Server
Server::Server(const std::string& address, int port, int threads, std::shared_ptr<Database> db)
: ioc(threads),
acceptor(ioc, net::ip::tcp::endpoint(net::ip::make_address(address), port), true),
db(db),
threads(threads),
acceptTimer(ioc)
{}
void Server::run() {
accept();
// Run the I/O service on the requested number of threads
threadPool.reserve(threads);
for (int i = 0; i < threads; ++i) {
threadPool.emplace_back([this]() {
ioc.run();
});
}
// Wait for all threads to finish
for (auto& t : threadPool) {
if (t.joinable()) {
t.join();
}
}
}
void Server::stop() {
ioc.stop();
}
void Server::accept() {
acceptor.async_accept(
net::make_strand(ioc),
beast::bind_front_handler(&Server::onAccept, this)
);
}
void Server::onAccept(beast::error_code ec, net::ip::tcp::socket socket) {
if (!ec) {
std::make_shared<HttpSession>(std::move(socket), db)->run();
accept();
} else {
std::cerr << "Accept error: " << ec.message() << std::endl;
if(!ioc.stopped()) {
acceptTimer.expires_after(std::chrono::milliseconds(100));
acceptTimer.async_wait([this](beast::error_code ec) {
if (!ioc.stopped()) { accept(); }
});
}
}
}

55
src/server.hpp Normal file
View File

@ -0,0 +1,55 @@
#pragma once
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/version.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/strand.hpp>
#include <memory>
#include <thread>
#include <vector>
#include "common.hpp"
#include "database.hpp"
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
class HttpSession : public std::enable_shared_from_this<HttpSession> {
public:
HttpSession(net::ip::tcp::socket&& socket, std::shared_ptr<Database> db);
void run();
private:
void readRequest();
void onRead(beast::error_code ec, std::size_t bytes_transferred);
void processRequest(http::request<http::string_body>&& req);
void writeResponse(http::response<http::string_body>&& res);
void handleError(beast::error_code ec, const char* what);
beast::tcp_stream stream;
beast::flat_buffer buffer;
std::shared_ptr<Database> db;
http::request<http::string_body> req;
};
class Server {
public:
Server(const std::string& address, int port, int threads, std::shared_ptr<Database> db);
void run();
void stop();
private:
void accept();
void onAccept(beast::error_code ec, net::ip::tcp::socket socket);
net::io_context ioc;
net::ip::tcp::acceptor acceptor;
std::shared_ptr<Database> db;
int threads;
std::vector<std::thread> threadPool;
net::steady_timer acceptTimer;
};