ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【在线五子棋对战】实用工具类模块实现

【在线五子棋对战】实用工具类模块实现 目录日志类strftime()宏封装声明全局Logger对象MySQL API封装Jsoncpp的序列化/反序列化封装字符串分割封装读取文件封装密码模块二进制字节数据转十六进制字符串生成随机盐计算密码哈希密码验证日志类日志类完成的功能是对日志信息的打印以及日志级别、日期时间、文件名、行号// Log.hpp /* -------------------- Log.hpp Log.cpp 用于实现日志打印工具 -------------------- */ #pragma once #include cstdarg #include ctime #include fstream #include string #include unistd.h // 颜色控制 #define BLACK \033[0;30;1m // 黑色 #define RED \033[0;31;1m // 红色 #define GREEN \033[0;32;1m // 绿色 #define YELLOW \033[0;33;1m // 黄色 #define BLUE \033[0;34;1m // 蓝色 #define PURPLE \033[0;35;1m // 紫色 #define CYAN \033[0;36;1m // 青色 #define WHITE \033[0;37;1m // 白色 #define BLACK_BL \033[40;1m // 背景黑色 #define RED_BL \033[41;1m // 背景红色 #define GREEN_BL \033[42;1m // 背景绿色 #define YELLOW_BL \033[43;1m // 背景黄色 #define BLUE_BL \033[44;1m // 背景蓝色 #define PURPLE_BL \033[45;1m // 背景紫色 #define CYAN_BL \033[46;1m // 背景青色 #define WHITE_BL \033[47;1m // 背景白色 #define ED \033[0m // 结束颜色控制 // 使用宏封装LogMessage #define LOG(level, format, ...) \ logger.LogMessage(level, __FILE__, __LINE__, format, ##__VA_ARGS__) enum class SevEx // 错误码 { }; enum class ErrLv // 错误等级/类型 { DEBUG 0, // 调试 INFO, // 信息 WARNING, // 警告 ERROR, // 非致命错误 FATAL // 致命错误 }; class Logger { public: template typename... Args void LogMessage(ErrLv level, const char* filename, int line, const char *format, Args ...args) // 可变参数列表 { //[错误等级/类型] [日期时间] [pid] [文件名:行号] [message] // 取得日志等级字符串/颜色 std::string strlv, color; switch (level) { case ErrLv::DEBUG: strlv DEBUG; color CYAN_BL; break; case ErrLv::INFO: strlv INFO; color GREEN_BL; break; case ErrLv::WARNING: strlv WARNING; color YELLOW_BL; break; case ErrLv::ERROR: strlv ERROR; color RED_BL; break; case ErrLv::FATAL: strlv FATAL; color PURPLE_BL; break; default: strlv DEBUG; color CYAN_BL; } // 日志类型 // e.g. [DEBUG] [2026/09/08-17:46:34] [pid:33215] char logprefix[1024] {0}; snprintf(logprefix, sizeof(logprefix), [%s] [%s] [pid:%d] [%s:%d], strlv.c_str(), DateTime(time(nullptr)).c_str(), getpid(), filename, line); // 日志信息 // e.g. gobong()失败错误码8错误描述you are a bitch char logcontent[1024] {0}; snprintf(logcontent, sizeof(logcontent), format, args...); std::cout color logprefix # logcontent ED std::endl; } std::string DateTime(time_t timesatamp); // 获取对应时间戳的日期-时间 }; // 对外声明有该全局对象 extern Logger logger; // [TRACE]跟踪级日志通常用于最详细的调试信息 // [DEBUG]调试级日志显示程序执行细节 // [INFO]信息级日志记录程序正常运行状态 // [WARN]警告级日志提示潜在问题但不影响运行 // [ERROR]错误级日志表示发生错误 // [FATAL]致命级日志表示严重错误可能导致程序终止 // [TODO]待办事项表示需要后续处理的代码 // [FIXME]修复事项表示需要修复的代码问题 // [NOTE]注释标记表示需要注意的代码段 // [HACK]表示临时解决方案或不优雅的代码 // Log.cpp /* -------------------- Log.hpp Log.cpp 用于实现日志打印工具 -------------------- */ #include iostream #include Log.hpp Logger logger; // 全局对象 std::string Logger::DateTime(time_t timesatamp) // 获取对应时间戳的日期-时间 { // struct tm *t localtime(timesatamp); // // 2026/5/28-20:39:01 // std::string year std::to_string(t-tm_year 1900); // // 除了年份必须要保持始终为两位数不够用0补齐 // std::string month (std::to_string(t-tm_mon).size() 1) ? (0 std::to_string(t-tm_mon 1)) : (std::to_string(t-tm_mon 1)); // 月 始终是两位数 // std::string day (std::to_string(t-tm_mday).size() 1) ? (0 std::to_string(t-tm_mday)) : (std::to_string(t-tm_mday)); // 日 始终是两位数 // std::string hour (std::to_string(t-tm_hour).size() 1) ? (0 std::to_string(t-tm_hour)) : (std::to_string(t-tm_hour)); // 时 始终是两位数 // std::string min (std::to_string(t-tm_min).size() 1) ? (0 std::to_string(t-tm_min)) : (std::to_string(t-tm_min)); // 分 始终是两位数 // std::string sec (std::to_string(t-tm_sec).size() 1) ? (0 std::to_string(t-tm_sec)) : (std::to_string(t-tm_sec)); // 秒 始终是两位数 // std::string now year / month / day - hour : min : sec; // 更简单的实现用现成函数strftime // e.g. 2026/09/08-17:46:34 char now[32]; strftime(now, sizeof(now) - 1, %Y/%m/%d-%H:%M:%S, localtime(timesatamp)); return now; }strftime()LogMessage()使用模板参数以实现格式化输出DateTime()内的strftime()函数是C/C 标准库里专门用来把时间转换成指定格式字符串的函数#include ctime size_t strftime( char* str, // 把结果写到哪里缓冲区 size_t maxsize, // 缓冲区最大容量 const char* format, // 按什么格式输出 const struct tm* timeptr // 要格式化的时间 );format的常用格式符格式含义例子%Y4位年份2026%y2位年份26%m月份09%d日期08%H24小时制小时20%I12小时制小时08%M分钟17%S秒35%pAM/PMPM宏封装// 使用宏封装LogMessage #define LOG(level, format, ...) \ logger.LogMessage(level, __FILE__, __LINE__, format, ##__VA_ARGS__)因为LogMessage()的参数太多所以封装了宏来简化参数通过logger全局对象调用__VA_ARGS__宏用于把调用这个宏时...收集到的所有参数原样放到这里。但如果...没有参数函数最终就会变成logger.LogMessage(DEBUG, log.hpp, 8, 塞尔达天下第一, )后面会多一个逗号而##__VA_ARGS__宏就是为了解决这个问题当...为空时可以把多余的逗号一起处理掉声明全局Logger对象extern Logger logger;该行代码可以告诉所有包含了此头文件的代码“有一个全局对象logger”这样当执行logger.LogMessage()时第一反应就不是报错logger未定义而是去别的源代码文件中寻找logger的定义MySQL API封装在用MySQL的C API时流程一般分为以下几步初始化句柄登录数据库设置客户端字符集选择数据库执行查询保存查询结果集打印结果集而前四步一般为固定步骤可以整合到一起因此下面进行一个简单的API封装/* ------------------------ Util.hpp 用于实现常用库的简单封装 ------------------------ */ #pragma once #include iostream #include string #include mysql/mysql.h #include Log.hpp class MySQL_Util { public: // MySQL初始化初始化句柄登录服务器进入数据库设置字符集 static MYSQL *MySQL_Init(const std::string host, const std::string user, const std::string passwd, const std::string db, uint16_t port 3306) { // 创建mysql句柄 MYSQL *mysql mysql_init(nullptr); if (mysql nullptr) { LOG(LogLv::FATAL, 创建mysql句柄失败); return nullptr; } // 连接mysql数据库 if (mysql_real_connect(mysql, host.c_str(), user.c_str(), passwd.c_str(), db.c_str(), port, nullptr, 0) nullptr) { LOG(LogLv::FATAL, 连接数据库失败错误码%d错误描述%s, mysql_errno(mysql), mysql_error(mysql)); return nullptr; } // 设置客户端字符集 if (mysql_set_character_set(mysql, utf8mb4) ! 0) { LOG(LogLv::FATAL, 设置字符集失败错误码%d错误描述%s, mysql_errno(mysql), mysql_error(mysql)); return nullptr; } return mysql; } // 执行查询语句 static bool MySQL_Query(MYSQL *mysql, const char *sql) { int ret mysql_query(mysql, sql); if (ret ! 0) { LOG(LogLv::ERROR, mysql查询失败错误码%d错误描述%s\nSQL:%s, mysql_errno(mysql), mysql_error(mysql), sql); return false; } return true; } // 释放MySQL句柄 static void MySQL_Destroy(MYSQL *mysql) { if (mysql ! nullptr) mysql_close(mysql); return; } };Jsoncpp的序列化/反序列化封装序列化和反序列化是两个比较常用的操作把这两个接口封装起来在以后调用时会方便很多//Util.hpp // Json的序列化/反序列化封装 class Json_Util { public: // 序列化Json::Value对象到str static bool Serialization(const Json::Value root, std::string str) { try { // 实例化工厂 Json::StreamWriterBuilder wbuilder; wbuilder[emitUTF8] true; // 非ASCII字符原样输出 // 根据实例化的工厂派发临时工人去序列化root对象 str Json::writeString(wbuilder, root); return true; } // 若Json::writeString()抛异常在这里捕获并返回false catch (const std::exception e) { str.clear(); LOG(LogLv::ERROR, Json序列化失败, 错误描述%s, e.what()); return false; } } // 反序列化str到root对象 static bool Deserialization(const std::string str, Json::Value root) { // 实例化工厂 Json::CharReaderBuilder rbuilder; // 工厂生产一个工人 std::unique_ptrJson::CharReader build(rbuilder.newCharReader()); string errs; // 解析str成Json::Value对象 bool ok build-parse(str.c_str(), str.c_str() str.size(), root, errs); if (ok false) { LOG(LogLv::ERROR, Json反序列化失败错误描述%s, errs.c_str()); return false; } return true; } };wbuilder[emitUTF8] true;用于告诉工厂当遇到非ASCII字符时UTF-8字符不会被转成\uXXXX形式而是原样输出字符串分割封装在后面的网络协议处理会经常用到字符串分割因此封装起来以便使用// 字符串分割封装 class String_Util { public: // 将src字符串分割到res数组返回子串的个数 // 将src按照sep进行分割忽略空字段结果保存到res static size_t split(const std::string src, const std::string sep, std::vectorstd::string res) { if (sep.empty()) { LOG(LogLv::ERROR, split分隔符不能为空); return 0; } res.clear(); // 若res本来有数据就清除 size_t pos 0 /*本次查找sep的位置*/, idx 0 /*本次查找开始的位置*/; while (idx src.size()) { pos src.find(sep, idx); if (pos std::string::npos) // 没找到sep { res.push_back(src.substr(idx)); break; } if (pos idx) // 如果相等代表上一次find的,后什么都没有 { idx sep.size(); continue; } // 放入子串pos-idx是两个sep中间的字符数 res.push_back(src.substr(idx, pos - idx)); idx pos sep.size(); } return res.size(); } };读取文件封装在后面需要将html网页读取上来传给客户端因此这里给读取文件封装接口// 读取文件封装 class File_Util { public: // 读取filename文件内容到dst static bool Read(const std::string filename, std::string dst) { // 二进制方式打开一个文件防止因为特殊字符有特殊操作 std::ifstream ifs(filename, std::ios::binary); if (ifs.is_open() false) { LOG(LogLv::ERROR, 打开文件失败); return false; } // 获取文件大小 ifs.seekg(0, std::ios::end); // 指针移动到文件末尾 std::streampos fsize ifs.tellg(); // 返回当前指针指向的位置文件大小 ifs.seekg(0, std::ios::beg); // 再将指针移动到开头复原 // 读取文件 if (fsize 0) { dst.resize(static_castsize_t(fsize)); // 扩容到能存下该文件 ifs.read(dst[0], static_caststd::streamsize(fsize)); // dst[0]返回的就是char*而c_str()接口返回的是const char* if (ifs.gcount() ! fsize) // gcount()返回读取到的数据量 { LOG(LogLv::ERROR, 读取文件[%s]失败, filename.c_str()); // ifs.close(); // 关闭文件 return false; } } // ifs.close(); // ifstream离开作用域后会自动close() return true; } };ifstream::seekg()用于在指定参考位置移动偏移量ifstream::tellg()用于返回当前获取位置相对于文件开头的偏移位置。resize()负责将string扩容到能装下文件的容量否则直接用下面的dst[0]获取char*会因为装不下而段错误string::c_str()返回const char*只能读取不能修改C17 起string::data()对非const字符串可以返回char*因此可以直接修改字符串内部的字符内容。密码模块在后面进行用户注册操作时需要向数据库插入用户信息其中密码绝对不能明文存储因此这里简单设计一个基于SHA-256随机盐加密的封装盐就是在哈希密码之前额外加入的一段随机字符串且每个人的盐都应该不一样。因为SHA-256太简单常见密码对应的 SHA-256 都可以被预先计算或高速暴力尝试。这里采用Password.h与Password.cpp的组织方法在Password.h中主要设计三个接口生成随机盐、哈希加密、密码验证/* ---------------- Password.h |实用工具模块| 封装密码加密接口 ---------------- */ #pragma once // 用于支持加密算法 #include openssl/evp.h #include openssl/rand.h #include iomanip #include string #include sstream #include Log.hpp class Password { public: // 生成随机盐到salt static bool GenerateSalt(std::string salt); // password salt - SHA-256s static bool Hash(const std::string password, const std::string salt, std::string hash); // 验证密码 static bool Verify(const std::string password, const std::string salt, const std::string hash); };二进制字节数据转十六进制字符串为了显示更直观传输更方便我们需要设计一个仅供Password.cpp使用的二进制字节数据转十六进制字符串的接口用于对生成的随机盐、Hash摘要转换为十六进制字符串原始结果都是二进制即字节数据是以一个字节为单位存储的假设某哈希算法原始结果是 4 个字节0xBA // 186 0x78 // 120 0x16 // 22 0xBF // 191在 C/C 中可以想象成unsigned char data[4] { 186 120 22 191 };每一个元素就是1 个字节转换为十六进制字符串后结果为BA7816BF传输更方便显示更直观代码实现namespace // 匿名命名空间只给Password.cpp内部接口使用 { // 二进制数据字节转十六进制字符串 std::string BytesToHex(const unsigned char *data, size_t len) { // 格式化写进字符串oss std::ostringstream oss; oss std::hex // 整数以后按照十六进制输出 std::setfill(0); // 如果不够指定宽度就用 0 来填充。(默认是空格) for (size_t i 0; i len; i) { oss std::setw(2) // 下一次输出的内容宽度至少为 2 static_castint(data[i]); // 以整数插入字节 } return oss.str(); } }hex、setw、setfill是流操作符本质上是修改流对象内部的状态需要注意的是hex、setfill都为永久生效但setw只生效一次所以每次都需要重新设置由于该接口是仅限于Password.cpp内使用的接口可以用匿名 命名空间包起来生成随机盐// 生成随机盐 bool Password::GenerateSalt(std::string salt) { constexpr size_t SALT_LEN 16; // 编译期常量确认随机盐的长度 16 unsigned char buffer[SALT_LEN]; // 存储随机盐的缓冲区 // RAND_bytes是OpenSSL的安全随机数发生器 if (RAND_bytes(buffer, SALT_LEN) ! 1) // 把生成的随机数存储到buffer { LOG(LogLv::ERROR, 随机数生成失败); return false; } salt BytesToHex(buffer, SALT_LEN); // 将二进制随机数字节转十六进制字符串 return true; }constexprC11引入的关键字表示这个变量/函数的值可以在编译期确定。而#define是在预处理阶段替换宏在C项目中优先使用constexprconstexpr char* a;所实现的const功能相当于char* const a;即指针本身不可变但指针指向的内容可变。因此对char*声明常量更保险的方法为constexpr const char* a; // 或 constexpr char a[];RAND_bytes是OpenSSL的安全随机数发生器来自openssl/rand.h返回1成功否则失败为什么不能用rand()代替因为rand()只是生成一个看起来随机的数字伪随机数生成器它的算法是确定的可以被推算出来的。而盐要求不可预测目的是为了防止攻击者拿到数据库后很容易计算并大量猜测而RAND_bytes()是这是 OpenSSL 提供的密码学安全随机数生成接口CSPRNG是不可预测的计算密码哈希这里是最重点用于根据用户输入的密码与随机盐生成哈希摘要这里用OpenSSL提供的EVP系列API完成SHA-256计算namespace // 匿名命名空间仅供Password.cpp使用 { std::string BytesToHex(const unsigned char *data, size_t len) { // ...... } // EVP_MD_CTX* 删除器 struct EVP_MD_CTX_Deleter { void operator()(EVP_MD_CTX *ctx) const { if (ctx ! nullptr) EVP_MD_CTX_free(ctx); } }; } // password salt - SHA-256 bool Password::Hash(const std::string password, const std::string salt, std::string hash) { std::string input password salt; // 密码随机盐 unsigned char digest[EVP_MAX_MD_SIZE]; // 存放SHA-256结果 // EVP支持多种摘要算法不同算法生成的摘要长度不同。 // 使用EVP_MAX_MD_SIZE分配缓冲区可以容纳EVP支持的最大摘要结果。 unsigned int digest_len 0; // 本次算法真正用了多少字节 // 创建哈希计算上下文 std::unique_ptrEVP_MD_CTX, EVP_MD_CTX_Deleter ctx(EVP_MD_CTX_new()); if (ctx nullptr) { LOG(LogLv::ERROR, OpenSSL:创建上下文失败); return false; } bool success false; // 由于下面一系列步骤中只要任意一步失败就需要立即停止后续操作并最终返回 false使用 do { ... } while (false) 可以统一用 break 退出避免写很多个 return false。 // do while(false) -- 可以break随时退出的代码块 do { // 初始化SHA-256 if (EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) ! 1) break; // 写入要进行哈希的数据 if (EVP_DigestUpdate(ctx.get(), input.data(), input.size()) ! 1) break; // 把结果写入digest if (EVP_DigestFinal_ex(ctx.get(), digest, digest_len) ! 1) break; // 把二进制数据字节转换为十六进制字符串 hash BytesToHex(digest, digest_len); success true; } while (false); // 如果没走到最后success就是false return success; }OpenSSL的EVP接口采用下面这种流程创建上下文 ↓ 初始化 ↓ 喂数据 ↓ 结束计算 ↓ 销毁上下文EVP_MD_CTX_new()会创建一个用于保存当前摘要计算状态的EVP_MD_CTX对象并返回一个EVP_MD_CTX*指针。由于返回的是裸指针可以交给std::unique_ptr管理从而利用RAII自动释放资源。但unique_ptr默认使用delete释放所管理的对象而EVP_MD_CTX*必须通过 OpenSSL 提供的EVP_MD_CTX_free()进行释放因此需要定义一个自定义删除器即函数对象伪函数。std::unique_ptr的第二个模板参数就是用来指定这个删除器的。由于删除器也只会在该接口中用到因此也放在匿名namespace中unique_ptr::get()接口用于获取原始的指针值为什么要用EVP_MAX_MD_SIZE初始化digest数组digest用EVP_MAX_MD_SIZE大小的数组是因为后面的EVP_DigestInit_ex()可以选择不同的摘要算法例如 SHA-224、SHA-256、SHA-384、SHA-512 等而不同算法产生的摘要长度不同。使用EVP_MAX_MD_SIZE可以为digest分配一个足够大的缓冲区从而无需因为更换摘要算法而修改数组大小。EVP_MAX_MD_SIZE表示EVP 支持的摘要结果可能达到的最大长度上限。密码验证密码验证用于在登录时将用户传入的密码与该用户的盐生成的摘要与数据库里的摘要作对比// 验证密码 bool Password::Verify(const std::string password, const std::string salt, const std::string hash) { // 将用户输入的密码与对应的salt生成一遍哈希 std::string caculated_hash; if (Hash(password, salt, caculated_hash) false) return false; return hash caculated_hash; // 比较刚生成的和数据库有的是否一致 }
返回列表