最近手撸了一个超级轻量级的日志系统,以适应项目需求。纯 C 语言编写,实现代码总共才500多行,关键功能还齐全,对于嵌入式开发的猿猿们,再也不用寻找其它开源的日志系统,简直就是福音。话不多说,首先看看功能和优缺点,如果达不到各位猿猿们的要求,可直接跳过。
日志功能:
- 支持日志路径可配,不存在自动创建;
- 支持日志级别,按日志优先级打印;
- 支持打印多种输出方式(串口和网络方式输出待完善);
- 支持日志文件大小和数量可配置,日志文件滚动;
- 支持日志输出超过磁盘限制大小改变输出方式;
- 支持按模块来打印日志,每个模块可独立配置和打印日志;
- 支持多线程输出日志。
日志优点:
- 超级轻量,代码总共才500多行,接口简单易用;
- 性能极高,内部无缓存,内存占用可忽略,对嵌入式开发极为友好;
- 可根据项目要求高度定制。
日志缺点:
- 日志输出格式固定,不能配置(一般项目上配置好之后也不会轻易修改);
- 模块内多线程打印有资源竞争时会等待锁,有一定的业务实时性影响。
日志配置文件:
1 #全局日志配置 2 #日志存储路径,不存在时会自动创建,默认“./log” 3 log_path = "./log" 4 5 #日志实时输出开关 0:关闭,1:开启 6 log_flush = 1 7 8 #日志文件保存数量,超过数量会滚动删除,配置范围[0, 65535] 9 log_file_count = 5 10 11 #日志文件单个大小,单位:MB,超过大小切换文件,配置范围(0, 65535] 12 log_file_size = 30 13 14 #日志预留空间大小,单位:MB,达到阈值日志输出到终端,配置范围[0, +∞) 15 log_reserve_space = 200 16 17 #各模块日志配置,有几个模块配置几个 18 #日志输出类型:0:off,1:console,2:file,4:serial,8:net,可叠加 19 log_A_output = 2 20 log_B_output = 2 21 log_C_output = 2 22 log_D_output = 2 23 24 #日志等级类型:0:trace,1:debug,2:info,3:warn,4:error,5:fatal,6:close 25 log_A_level = 2 26 log_B_level = 2 27 log_C_level = 2 28 log_D_level = 2
日志头文件
1 /** 2 * Copyright (c) 2023 yuwanxian 3 * All rights reserved. 4 * 5 * File: loglite.h 6 * Author: yuwanxian 7 * Time: 2023/07/07 8 * Version: 1.0.0 9 * 10 * Loglite interface declaration 11 * 12 */ 13 14 #ifndef _LOGLITE_H_ 15 #define _LOGLITE_H_ 16 17 #include <string.h> 18 19 #ifdef __cplusplus 20 extern "C" { 21 #endif 22 23 24 /** 25 * @brief 模块ID 26 */ 27 typedef enum { 28 MODULE_A, 29 MODULE_B, 30 MODULE_C, 31 MODULE_D, 32 MODULE_ID_MAX 33 } MODULE_ID; 34 35 /** 36 * @brief 日志等级 37 */ 38 typedef enum { 39 LOG_TRACE, 40 LOG_DEBUG, 41 LOG_INFO, 42 LOG_WARN, 43 LOG_ERROR, 44 LOG_FATAL, 45 LOG_CLOSE, 46 LOG_LEVEL_MAX 47 } LOG_LEVEL; 48 49 /** 50 * @brief 日志初始化 51 * 52 * @param config [in] 日志配置文件路径 53 * 54 * @return int 0:成功,其它:失败 55 */ 56 int log_init(const char* config); 57 58 /** 59 * @brief 日志输出 60 * 61 * @param level [in] 日志等级 62 * @param id [in] 日志模块ID 63 * @param file [in] 日志输出时所在文件名 64 * @param line [in] 日志输出时所在代码行数 65 * @param func [in] 日志输出时所在函数名 66 * @param format [in] 日志格式化字符串 67 * @param suffix [in] 日志后缀,可自定义 68 * @param ... [in] 日志格式化可变参数 69 * 70 * @return void 71 */ 72 void logging(LOG_LEVEL level, MODULE_ID id, const char* file, int line, 73 const char* func, const char* format, const char* suffix, ...); 74 75 /** 76 * @brief 日志结束 77 * 78 * @param void 79 * 80 * @return int 0:成功,其它:失败 81 */ 82 int log_drop(void); 83 84 85 /** 86 * @brief 日志打印宏,子模块可根据需要再定义 87 * 88 * @example 参考子模块自定义日志打印宏 89 */ 90 #define LOG_FILE_NAME (strrchr(__FILE__, '/')+1) 91 #define LOG_VAARGS_EX LOG_FILE_NAME, __LINE__, __func__ 92 93 #define LOGT(id, format, suffix, ...)\ 94 logging(LOG_TRACE, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 95 96 #define LOGD(id, format, suffix, ...)\ 97 logging(LOG_DEBUG, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 98 99 #define LOGI(id, format, suffix, ...)\ 100 logging(LOG_INFO, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 101 102 #define LOGW(id, format, suffix, ...)\ 103 logging(LOG_WARN, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 104 105 #define LOGE(id, format, suffix, ...)\ 106 logging(LOG_ERROR, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 107 108 #define LOGF(id, format, suffix, ...)\ 109 logging(LOG_FATAL, id, LOG_VAARGS_EX, format, suffix, ##__VA_ARGS__) 110 111 /** 112 * @brief 子模块自定义日志打印宏 113 * 114 * @note 注意:1.日志自带换行,2.结尾自带分号 115 */ 116 #define A_LOGT(format, ...) LOGT(MODULE_A, format, "\n", ##__VA_ARGS__); 117 #define A_LOGD(format, ...) LOGD(MODULE_A, format, "\n", ##__VA_ARGS__); 118 #define A_LOGI(format, ...) LOGI(MODULE_A, format, "\n", ##__VA_ARGS__); 119 #define A_LOGW(format, ...) LOGW(MODULE_A, format, "\n", ##__VA_ARGS__); 120 #define A_LOGE(format, ...) LOGE(MODULE_A, format, "\n", ##__VA_ARGS__); 121 #define A_LOGF(format, ...) LOGF(MODULE_A, format, "\n", ##__VA_ARGS__); 122 123 124 #ifdef __cplusplus 125 } 126 #endif 127 128 #endif // _LOGLITE_H_
日志源文件
1 /** 2 * Copyright (c) 2023 yuwanxian 3 * All rights reserved. 4 * 5 * File: loglite.c 6 * Author: yuwanxian 7 * Time: 2023/07/07 8 * Version: 1.0.0 9 * 10 * Loglite interface implementation 11 * 12 */ 13 #include "loglite.h" 14 15 #include <stdio.h> 16 #include <stdlib.h> 17 #include <stdarg.h> 18 #include <unistd.h> 19 #include <dirent.h> 20 #include <sys/stat.h> 21 #include <sys/statvfs.h> 22 #include <sys/time.h> 23 #include <time.h> 24 #include <pthread.h> 25 26 #define PATH_MAX_LEN 200U 27 #define FILE_MAX_LEN 256U 28 #define BINARY_UNIT 1000U 29 30 typedef enum { 31 LOG_OFF = 0, 32 LOG_CONSOLE = 1, 33 LOG_FILE = 2, 34 LOG_SERIAL = 4, 35 LOG_NET = 8, 36 } LOG_OUTPUT; 37 38 typedef struct { 39 char path[PATH_MAX_LEN]; 40 uint16_t output_flush; 41 uint16_t file_count; 42 uint16_t file_size; 43 uint32_t reserve_space; 44 } GlobalLogParam; 45 46 typedef struct { 47 FILE* file; 48 char name[16]; 49 uint16_t file_num; 50 LOG_LEVEL level; 51 LOG_OUTPUT output; 52 pthread_mutex_t mutex; 53 } ModuleLogParam; 54 55 static GlobalLogParam g_global_param = { 56 "./log", 1, 5, 30, 100 57 }; 58 59 static ModuleLogParam g_module_param[MODULE_ID_MAX] = { 60 { NULL, "A", 0, LOG_INFO, LOG_FILE, PTHREAD_MUTEX_INITIALIZER }, 61 { NULL, "B", 0, LOG_INFO, LOG_FILE, PTHREAD_MUTEX_INITIALIZER }, 62 { NULL, "C", 0, LOG_INFO, LOG_FILE, PTHREAD_MUTEX_INITIALIZER }, 63 { NULL, "D", 0, LOG_INFO, LOG_FILE, PTHREAD_MUTEX_INITIALIZER } 64 }; 65 66 static char const* const g_log_level[LOG_LEVEL_MAX] = { 67 "TRACE", "DEBUG", "INFO", "WRAN", "ERROR", "FATAL", "CLOSE" 68 }; 69 70 // remove spaces and endl 71 static void trim(char* str) 72 { 73 char* start = str; 74 char* end = str; 75 76 while (' ' == *start) { 77 start++; 78 } 79 80 while (*end != '\r' && *end != '\n' && *end != '\0') { 81 end++; 82 } 83 84 int i = 0 85 for (; start < end; i++, start++) { 86 str[i] = *start; 87 } 88 89 str[i] = '\0'; 90 } 91 92 // Reading the configuration of numerical types 93 static void get_config_number(const char* data, const char* name, void* dst, uint16_t width) 94 { 95 if (strstr(data, name) != NULL) 96 { 97 char* pos = (char*)strstr(data, "="); 98 if (pos != NULL) 99 { 100 pos += 1; 101 trim(pos); 102 103 char* endptr; 104 long value = strtol(pos, &endptr, 10); 105 if ('\0' == *endptr) 106 { 107 memcpy(dst, &value, width); 108 } 109 } 110 } 111 } 112 113 // Reading the configuration of string types 114 static void get_config_string(const char* data, const char* name, char* dst, uint16_t length) 115 { 116 if (strstr(data, name) != NULL) 117 { 118 const char* posBeg = strstr(data, "\""); 119 const char* posEnd = strrchr(data, '\"'); 120 if (posBeg != NULL && posEnd != NULL) 121 { 122 size_t size = posEnd - posBeg - 1U; 123 size = size > length ? length : size; 124 strncpy(dst, posBeg + 1U, size); 125 } 126 } 127 } 128 129 static char* get_log_time(char time[32]) 130 { 131 struct timeval tm; 132 gettimeofday(&tm, NULL); 133 134 struct tm* ptm = localtime(&tm.tv_sec); 135 strftime(time, 27, "%Y-%m-%d %H:%M:%S", ptm); 136 137 uint32_t msec = tm.tv_usec / 1000U; 138 sprintf(&time[strlen(time)], ".%03d", msec); 139 140 return time; 141 } 142 143 static void rolling_log_file(MODULE_ID id) 144 { 145 // 1.close logfile 146 if (g_module_param[id].file != NULL) 147 { 148 fflush(g_module_param[id].file); 149 fclose(g_module_param[id].file); 150 g_module_param[id].file = NULL; 151 } 152 153 // 2.remove excess logfile 154 if (g_module_param[id].file_num == g_global_param.file_count) 155 { 156 char oldfile[FILE_MAX_LEN] = { 0 }; 157 if (0 == g_global_param.file_count) 158 { 159 snprintf(oldfile, sizeof(oldfile), "%s/%s.log", 160 g_global_param.path, g_module_param[id].name); 161 } 162 else 163 { 164 snprintf(oldfile, sizeof(oldfile), "%s/%s_%d.log", 165 g_global_param.path, g_module_param[id].name, g_global_param.file_count); 166 } 167 168 if (0 == access(oldfile, F_OK)) 169 { 170 if (0 != remove(oldfile)) 171 { 172 printf("logfile: %s remove failed\n", oldfile); 173 } 174 } 175 } 176 177 // 3.rolling logfile 178 for (uint16_t i = g_module_param[id].file_num; i > 0; i--) 179 { 180 char oldname[FILE_MAX_LEN] = { 0 }; 181 char newname[FILE_MAX_LEN] = { 0 }; 182 183 snprintf(oldname, sizeof(oldname), "%s/%s_%d.log", 184 g_global_param.path, g_module_param[id].name, i); 185 snprintf(newname, sizeof(newname), "%s/%s_%d.log", 186 g_global_param.path, g_module_param[id].name, i+1); 187 188 if (0 == access(oldname, F_OK)) 189 { 190 if (0 != rename(oldname, newname)) 191 { 192 printf("logfile: %s rename %s failed\n", oldname, newname); 193 } 194 } 195 } 196 197 // 4.rolling self 198 if (g_global_param.file_count > 0) 199 { 200 char oldname[FILE_MAX_LEN] = { 0 }; 201 char newname[FILE_MAX_LEN] = { 0 }; 202 203 snprintf(oldname, sizeof(oldname), "%s/%s.log" 204 g_global_param.path, g_module_param[id].name); 205 snprintf(newname, sizeof(newname), "%s/%s_1.log" 206 g_global_param.path, g_module_param[id].name); 207 208 if (0 == access(oldname, F_OK)) 209 { 210 if (0 != rename(oldname, newname)) 211 { 212 printf("logfile: %s rename %s failed\n", oldname, newname); 213 } 214 } 215 } 216 217 // 5.file count++ 218 if (g_module_param[id].file_num < g_global_param.file_count) 219 { 220 g_module_param[id].file_num++; 221 } 222 } 223 224 static void open_log_file(MODULE_ID id) 225 { 226 char file[FILE_MAX_LEN] = { 0 }; 227 snprintf(file, sizeof(file), "%s/%s.log", 228 g_global_param.path, g_module_param[id].name); 229 230 g_module_param[id].file = fopen(file, "a"); 231 if (g_module_param[id].file != NULL) 232 { 233 if (g_global_param.output_flush) 234 { 235 // 遇到换行或者缓冲满时写入 236 setvbuf(g_module_param[id].file, NULL, _IOLBF, 0); 237 } 238 } 239 else 240 { 241 printf("logfile: %s open failed\n", file); 242 } 243 } 244 245 static void check_log_file(MODULE_ID id) 246 { 247 long fileSize = ftell(g_module_param[id].file); 248 if (fileSize > g_global_param.file_size * BINARY_UNIT * BINARY_UNIT) 249 { 250 struct statvfs fs; 251 if (0 == statvfs(g_global_param.path, &fs)) 252 { 253 unsigned long freeSpace = fs.f_bavail * fs.f_frsize; 254 if (freeSpace / (BINARY_UNIT * BINARY_UNIT) < g_global_param.reserve_space) 255 { 256 fputs("The disk will be full, and log switch console output\n", 257 g_module_param[id].file); 258 g_module_param[id].output = LOG_CONSOLE; 259 } 260 } 261 262 rolling_log_file(id); 263 } 264 } 265 266 static int init_log_config(const char* config) 267 { 268 int result = 0; 269 270 do { 271 if (NULL == config) 272 { 273 printf("init log config failed, file is null\n"); 274 result = -1; 275 } 276 277 FILE *cfg = fopen(config, "r"); 278 if (NULL == cfg) 279 { 280 printf("init log config failed, open %s error\n", config); 281 result = -1; 282 } 283 284 char line[512] = { 0 }; 285 while (fgets(line, sizeof(line), cfg) != NULL) 286 { 287 trim(line); 288 if (line[0] != '#') 289 { 290 get_config_string(line, "log_path", g_global_param.path, sizeof(g_global_param.path)); 291 get_config_number(line, "log_flush", &g_global_param.output_flush, sizeof(g_global_param.output_flush)); 292 get_config_number(line, "log_file_count", &g_global_param.file_count, sizeof(g_global_param.file_count)); 293 get_config_number(line, "log_file_size", &g_global_param.file_size, sizeof(g_global_param.file_size)); 294 get_config_number(line, "log_reserve_space", &g_global_param.reserve_space, sizeof(g_global_param.reserve_space)); 295 296 get_config_number(line, "log_A_level", &g_module_param[MODULE_A].level, sizeof(g_module_param[MODULE_A].level)); 297 get_config_number(line, "log_B_level", &g_module_param[MODULE_B].level, sizeof(g_module_param[MODULE_B].level)); 298 get_config_number(line, "log_C_level", &g_module_param[MODULE_C].level, sizeof(g_module_param[MODULE_C].level)); 299 get_config_number(line, "log_D_level", &g_module_param[MODULE_D].level, sizeof(g_module_param[MODULE_D].level)); 300 get_config_number(line, "log_A_output", &g_module_param[MODULE_A].output, sizeof(g_module_param[MODULE_A].output)); 301 get_config_number(line, "log_B_output", &g_module_param[MODULE_B].output, sizeof(g_module_param[MODULE_B].output)); 302 get_config_number(line, "log_C_output", &g_module_param[MODULE_C].output, sizeof(g_module_param[MODULE_C].output)); 303 get_config_number(line, "log_D_output", &g_module_param[MODULE_D].output, sizeof(g_module_param[MODULE_D].output)); 304 } 305 } 306 } while(0); 307 308 return result; 309 } 310 311 static int check_log_config(void) 312 { 313 int result = 0; 314 315 for (uint16_t i = 0; i < MODULE_ID_MAX; i++) 316 { 317 if (g_module_param[i].level < 0 || g_module_param[i].level >= LOG_LEVEL_MAX) 318 { 319 printf("%s log level config is invalid\n", g_module_param[i].name); 320 result = -1; 321 break; 322 } 323 324 if (g_module_param[i].output < 0 || g_module_param[i].output > 15) 325 { 326 printf("%s log output config is invalid\n", g_module_param[i].name); 327 result = -1; 328 break; 329 } 330 331 if ((g_module_param[i].output & LOG_FILE) && g_global_param.file_size == 0) 332 { 333 printf("log file size config is invalid\n"); 334 result = -1; 335 break; 336 } 337 } 338 339 return result; 340 } 341 342 static int init_log_path(void) 343 { 344 int result = 0; 345 346 do { 347 if (0 == access(g_global_param.path, F_OK)) 348 { 349 break; 350 } 351 352 for (uint16_t i = 0; g_global_param.path[i] != '\0'; i++) 353 { 354 if ('/' == g_global_param.path[i]) 355 { 356 g_global_param.path[i] = '\0'; 357 if (g_global_param.path[0] != '\0' 358 && 0 != strcmp(g_global_param.path, ".") 359 && 0 != access(g_global_param.path, F_OK)) 360 { 361 if (0 != mkdir(g_global_param.path, 0777)) 362 { 363 printf("logpath: %s mkdir failed\n", g_global_param.path); 364 g_global_param.path[i] = '/'; 365 result = -1; 366 break; 367 } 368 } 369 370 g_global_param.path[i] = '/'; 371 } 372 } 373 374 if (0 == result) 375 { 376 size_t len = strlen(g_global_param.path); 377 if (g_global_param.path[len - 1] != '/') 378 { 379 if (0 != mkdir(g_global_param.path, 0777)) 380 { 381 printf("logpath: %s mkdir failed\n", g_global_param.path); 382 result = -1; 383 } 384 } 385 } 386 387 } while (0); 388 389 return result; 390 } 391 392 static int init_log_file_number(void) 393 { 394 int result = 0; 395 396 DIR* dp = opendir(g_global_param.path); 397 if (NULL != dp) 398 { 399 struct dirent* entry = NULL; 400 while ((entry = readdir(dp)) != NULL) 401 { 402 char* name = entry->d_name; 403 if (0 == strcmp(name, ".") || 0 == strcmp(name, "..")) 404 continue; 405 406 for (uint16_t i = 0; i < MODULE_ID_MAX; i++) 407 { 408 if (strstr(name, g_module_param[i]) != NULL) 409 { 410 char head[20] = { 0 }; 411 size_t headlen = strlen(g_module_param[i].name); 412 strncpy(head, g_module_param[i].name, sizeof(head)); 413 head[headlen] = '_'; 414 head[headlen+1] = '\0'; 415 416 char* posBeg = strstr(name, head); 417 char* posEnd = strstr(name, ".log"); 418 if (posBeg != NULL && posEnd != NULL) 419 { 420 char value[16] = { 0 }; 421 posBeg = posBeg + headlen + 1; 422 strncpy(value, posBeg, posEnd-posBeg); 423 424 char* endptr; 425 long total = strtol(value, &endptr, 10); 426 if (*endptr == '\0') 427 { 428 unsigned char num = total; 429 if (num > g_module_param[i].file_num) 430 { 431 g_module_param[i].file_num = num; 432 } 433 434 // 配置改小时,上次多余的日志文件不主动移除 435 if (g_module_param[i].file_num > g_global_param.file_count) 436 { 437 g_module_param[i].file_num = g_global_param.file_count; 438 } 439 } 440 } 441 } 442 } 443 } 444 } 445 else 446 { 447 printf("logpath: %s open failed\n", g_global_param.path); 448 result = -1; 449 } 450 451 closedir(dp); 452 return result; 453 } 454 455 int log_init(const char* config) 456 { 457 // 1.init log config 458 int result = init_log_config(config); 459 460 // 2.check log config 461 if (0 == result) 462 { 463 result = check_log_config(); 464 } 465 466 // 3.init log path 467 if (0 == result) 468 { 469 result = init_log_path(); 470 } 471 472 // 4.init log file number 473 if (0 == result) 474 { 475 result = init_log_file_number(); 476 } 477 478 return result; 479 } 480 481 void logging(LOG_LEVEL level, MODULE_ID id, const char* file, int line, 482 const char* func, const char* format, const char* suffix, ...) 483 { 484 if (g_module_param[id].level <= level 485 && g_module_param[id].output != LOG_OFF 486 && format != NULL && suffix != NULL) 487 { 488 char tm[32] = { 0 }; 489 get_log_time(tm); 490 pthread_t tid = pthread_self(); 491 492 pthread_mutex_lock(&g_module_param[id].mutex); 493 494 if (g_module_param[id].output & LOG_CONSOLE) 495 { 496 printf("[%s] [%-5s] [%lu] [%s:%d %s] ", 497 tm, g_log_level[level], tid, file, line, func); 498 499 va_list args; 500 va_start(args, suffix); 501 vprintf(format, args); 502 va_end(args); 503 504 printf("%s", suffix); 505 } 506 507 if (g_module_param[id].output & LOG_FILE) 508 { 509 if (NULL == g_module_param[id].file) 510 { 511 open_log_file(id); 512 } 513 514 if (NULL != g_module_param[id].file) 515 { 516 fprintf(g_module_param[id].file, "[%s] [%-5s] [%lu] [%s:%d %s] ", 517 tm, g_log_level[level], tid, file, line, func); 518 519 va_list args; 520 va_start(args, suffix); 521 vfprintf(g_module_param[id].file, format, args); 522 va_end(args); 523 524 fprintf(g_module_param[id].file, "%s", suffix); 525 526 check_log_file(id); 527 } 528 } 529 530 pthread_mutex_unlock(&g_module_param[id].mutex); 531 } 532 } 533 534 int log_drop(void) 535 { 536 for (uint16_t i = 0; i < MODULE_ID_MAX; i++) 537 { 538 if (g_module_param[i].file != NULL) 539 { 540 fflush(g_module_param[i].file); 541 fclose(g_module_param[i].file); 542 g_module_param[i].file = NULL; 543 g_module_param[i].output = LOG_OFF; 544 } 545 } 546 547 return 0; 548 }
测试代码
1 #include <loglite.h> 2 #include <loglite.c> 3 4 int main(int argc, char** argv) 5 { 6 log_init("./log.config"); 7 8 LOGW(MODULE_A, "%s--%d", "\n", "Hello world", 1); 9 LOGE(MODULE_A, "%s--%d", "\n", "Hello world", 2); 10 11 char* test = "Hello world"; 12 13 A_LOGI(test); 14 A_LOGW("%s--%d", test, 4); 15 16 log_drop(); 17 18 retrun 0; 19 }
打印的日志格式为:[%Y-%m-%d %H:%M:%S.%03d] [%-5s] [%lu] [%s:%d %s] %s
[年-月-日 时:分:秒.毫秒] [等级] [线程ID] [文件名:行数 函数名] 自定义日志内容
上面测试环境为 ubuntu16,输出内容为:
[2023-09-11 13:49:56.440] [WARN ] [140737353963328] [main.c:8 main] Hello world--1
[2023-09-11 13:49:56.440] [ERROR] [140737353963328] [main.c:9 main] Hello world--2
[2023-09-11 13:49:56.440] [INFO ] [140737353963328] [main.c:13 main] Hello world
[2023-09-11 13:49:56.440] [WARN ] [140737353963328] [main.c:14 main] Hello world--4
欢迎大家试用,有问题可随时联系讨论。
作者:博客园博主 KeepHopes,对大数据、人工智能领域颇感兴趣,请多多赐教! 原文链接:https://www.cnblogs.com/yuwanxian/p/17693648.html 版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0许可协议。转载请注明出处,谢谢!