本笔记用作个人学习和查漏补缺使用,欢迎借鉴学习,期间部分函数介绍,一些知识科普使用网上各位大佬的文章,若未标明引用,请提醒我,如果不能使用,则删除,转载需标注出处www.jjyaoao.space

项目总览

image-20220406133535462

image-20220406133733000

第一板块-如何开发永不停机的服务程序

章节内容

后台开发的重点

image-20220409183458798

程序的异常

image-20220409183552694

永不停机的服务程序

image-20220409183614902

章节任务

生成测试数据

image-20220409183711887

服务程序的调度

image-20220409183743193

守护进程的实现

image-20220409183810221

两个常见小工具

image-20220409183833529

一:生成测试数据

小结任务

image-20220409185021079

全国气象站点参数

image-20220409184416146

全国气象分钟观测数

image-20220409184543891

需求

image-20220409184619225

①:搭建程序框架

1
2
3
4
/*
* project name: crtsurfdata1.cpp 用于生成全国气象站点观测的分钟数据
* author: jjyaoao
*/

运行的参数、说明文档、运行日志

/tmp/idc/surfdata/SURF_ZH_20220514021227_4976.xml

/tmp/idc/surfdata/SURF_ZH_20220416123000_4973.xml

/tmp/idc/surfdata/SURF_ZH_20220514075211_11385.xml

②:加载站点参数

  1. st_stcode结构体,存放站点
  2. 创建st_stcode向量实例vstcode
  3. LoadSTCode方法来加入
  4. 使用CFile类–自行封装–进行文件读入读出
  5. m_vCmdstr–自行封装–拆分字段

③:模拟观测数据

  1. st_surfdata结构体,实现每个站点的分钟观测
  2. 创造st_surfdata向量实例vsurfdata
  3. CrtSurfData函数,实现分钟观测
    1. 播随机数种子
    2. 获取当前时间,作为观测时间
    3. 遍历站点容器–vstcode
    4. 随机数填充分钟观测数据的结构体
    5. 将结构体放入容器vsurfdata

④:把站点观测数据写入文件

  1. CruSurfFile函数实现写入每分钟的观测数据

    1. 生成临时文件名–以image-20220412105853014

      这里outpath是绝对路径,strddatetime是当前时间,getpid是进程号,datafmt是文件格式,进程号主要是为了保证临时文件名不重复(getpid()),这里不加也可以

      1. 打开文件
      2. 写入第一行标题//csv才需要
      3. 遍历存放观测数据的容器vsurfdata,并且,对临时文件进行写入操作
      4. 关闭文件

支持csv、xml、json

有漏洞

文件在写入过程中,需要时间,如果其他程序,在这个时候读取了这个文件,就会读取到不完整的内容

image-20220412092024212

正确的

我们用临时副本文件来写入,保证了别的程序目前如果要读取文件,仍然是读取的之前的文件,待临时文件准备好以后,

image-20220412092159907

csv,xml,json

image-20220412091959130

image-20220412094053735

image-20220412095559156

再度超级女生

image-20220412093529860

image-20220412093610944

image-20220412093713969

image-20220412093756785

image-20220412093910860

image-20220412093939852

image-20220412094201744

xml与json比较

可读性

JSON和XML的可读性可谓不相上下,一边是简易的语法,一边是规范的标签形式,很难分出胜负。

可扩展性

XML天生有很好的扩展性,JSON当然也有,没有什么是XML可以扩展而JSON却不能扩展的。不过JSON在Javascript主场作战,可以存储Javascript复合对象,有着xml不可比拟的优势。

编码难度

XML有丰富的编码工具,比如Dom4j、Dom、SAX等,JSON也有提供的工具。无工具的情况下,相信熟练的开发人员一样能很快的写出想要的xml文档和JSON字符串,不过,xml文档要多很多结构上的字符。

实例比较

XML和JSON都使用结构化方法来标记数据,下面来做一个简单的比较。

用XML表示中国部分省市数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?xml version="1.0" encoding="utf-8"?>
<country>
<name>中国</name>
<province>
<name>黑龙江</name>
<cities>
<city>哈尔滨</city>
<city>大庆</city>
</cities>
</province>
<province>
<name>广东</name>
<cities>
<city>广州</city>
<city>深圳</city>
<city>珠海</city>
</cities>
</province>
<province>
<name>台湾</name>
<cities>
<city>台北</city>
<city>高雄</city>
</cities>
</province>
<province>
<name>新疆</name>
<cities>
<city>乌鲁木齐</city>
</cities>
</province>
</country>

用JSON表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"name": "中国",
"province": [{
"name": "黑龙江",
"cities": {
"city": ["哈尔滨", "大庆"]
}
}, {
"name": "广东",
"cities": {
"city": ["广州", "深圳", "珠海"]
}
}, {
"name": "台湾",
"cities": {
"city": ["台北", "高雄"]
}
}, {
"name": "新疆",
"cities": {
"city": ["乌鲁木齐"]
}
}]
}

可以看到,JSON 简单的语法格式和清晰的层次结构明显要比 XML 容易阅读,并且在数据交换方面,由于 JSON 所使用的字符要比 XML 少得多,可以大大得节约传输数据所占用的带宽。

二、服务程序的调度

image-20220412110420183

信号

实例引入信号量

像这样的代码

image-20220412142627337

ctrl + c 和 killall 和 kill + 进程号都可以终止

用ctrl + c 或者 killall命令终止程序的本质,是向正在运行的book程序发出一个信号

如果在book程序中没有处理信号,就会按缺省来处理

linux的信号有64种,大部分的信号缺省处理方法是终止程序运行

可是这令人无法接受=-=,因此我们可以在程序里增加捕获信号的代码,不执行系统缺省的动作,而是调用一个函数

image-20220412111139360

第一次优化

我们引入signal函数 传参的第一个ii,为信号量的具体数值,func代表接收到这个信号后应该采用func函数的方式处理

image-20220412142904610

image-20220412143121308

image-20220412143058537

同时……………….

9的信号是不能被忽略,也不能被屏蔽(不能被signal捕获),是一定会执行的强制杀死程序的信号

image-20220412143221306

image-20220412143239273

将15忽略 / 将15缺省,也就是先后

image-20220412143355908

1、信号基本概念

​ 信号(signal)是软件中断,是进程之间相互传递消息的一种方法,用于通知进程发生了事件,但是,不能给进程传递任何数据。

​ 信号产生的原因有很多,在Linux下,可以用kill和killall命令发送信号。

SIG_DFL,SIG_IGN 分别表示无返回值的函数指针,指针值分别是0和1,这两个指针值逻辑上讲是实际程序中不可能出现的函数地址值。
SIG_DFL:默认信号处理程序
SIG_IGN忽略信号的处理程序

2、信号的类型

信号名 信号值 默认处理动作 发出信号的原因
SIGHUP 1 A 终端挂起或者控制进程终止
SIGINT 2 A 键盘中断Ctrl+c
SIGQUIT 3 C 键盘的退出键被按下
SIGILL 4 C 非法指令
SIGABRT 6 C 由abort(3)发出的退出指令
SIGFPE 8 C 浮点异常
SIGKILL 9 AEF 采用kill -9 进程编号 强制杀死程序。
SIGSEGV 11 C 无效的内存引用
SIGPIPE 13 A 管道破裂,写一个没有读端口的管道。
SIGALRM 14 A 由alarm(2)发出的信号
SIGTERM 15 A 采用“kill 进程编号”或“killall 程序名”通知程序。
SIGUSR1 10 A 用户自定义信号1
SIGUSR2 12 A 用户自定义信号2
SIGCHLD 17 B 子进程结束信号
SIGCONT 18 进程继续(曾被停止的进程)
SIGSTOP 19 DEF 终止进程
SIGTSTP 20 D 控制终端(tty)上按下停止键
SIGTTIN 21 D 后台进程企图从控制终端读
SIGTTOU 22 D 后台进程企图从控制终端写

处理动作一项中的字母含义如下

A 缺省的动作是终止进程。

B 缺省的动作是忽略此信号,将该信号丢弃,不做处理。

C 缺省的动作是终止进程并进行内核映像转储(core dump),内核映像转储是指将进程数据在内存的映像和进程在内核结构中的部分内容以一定格式转储到文件系统,并且进程退出执行,这样做的好处是为程序员 提供了方便,使得他们可以得到进程当时执行时的数据值,允许他们确定转储的原因,并且可以调试他们的程序。

D 缺省的动作是停止进程,进入停止状态的程序还能重新继续,一般是在调试的过程中。

E 信号不能被捕获。

F 信号不能被忽略。

3、信号的处理

进程对信号的处理方法有三种:

1)对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。

2)设置中断的处理函数,收到信号后,由该函数来处理。

3)忽略某个信号,对该信号不做任何处理,就像未发生过一样。

signal函数可以设置程序对信号的处理方式。

函数声明:

1
sighandler_t signal(int signum, sighandler_t handler);

参数signum表示信号的编号。

参数handler表示信号的处理方式,有三种情况:

1)SIG_DFL:恢复参数signum所指信号的处理方法为默认值。

2)一个自定义的处理信号的函数,信号的编号为这个自定义函数的参数。

3)SIG_IGN:忽略参数signum所指的信号。

4、信号有什么用

服务程序运行在后台,如果想让中止它,杀掉不是个好办法,因为程序被杀的时候,程序突然死亡,没有安排善后工作。

如果向服务程序发送一个信号,服务程序收到这个信号后,调用一个函数,在函数中编写善后的代码,程序就可以有计划的退出。

向服务程序发送0的信号,可以检测程序是否存活。

5、信号应用示例

在实际开发中,在main函数开始的位置,程序员会先屏蔽掉全部的信号。

1
for (int ii=1;ii<=64;ii++) signal(ii,SIG_IGN);

这么做的目的是不希望程序被干扰。然后,再设置程序员关心的信号的处理函数。

程序在运行的进程中,如果按Ctrl+c,将向程序发出SIGINT信号,编号是2。

采用“kill 进程编号”或“killall 程序名”向程序发出的是SIGTERM信号,编号是15。

采用“kill -9 进程编号”向程序发出的是SIGKILL信号,编号是9,此信号不能被忽略,也无法捕获,程序将突然死亡。

设置SIGINT和SIGTERM两个信号的处理函数,这两个信号可以使用同一个处理函数,函数的代码是释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*

\* 本程序演示信号的用法。

*/

\#include <stdio.h>

\#include <stdlib.h>

\#include <string.h>

\#include <unistd.h>

\#include <signal.h>



void EXIT(int sig)

{

printf("收到了信号%d,程序退出。\n",sig);



// 在这里添加释放资源的代码



exit(0); // 程序退出。

}



int main()

{

for (int ii=1;ii<=64;ii++) signal(ii,SIG_IGN); // 屏蔽全部的信号



signal(SIGINT,EXIT); signal(SIGTERM,EXIT); // 设置SIGINT和SIGTERM的处理函数



while (true) // 每隔一秒执行一次任务。

{

printf("执行了一次任务。\n");



sleep(1);

}

}

运行效果

不管是用Ctrl+c还是kill,程序都能体面的退出。

6、发送信号

Linux操作系统提供了kill和killall命令向程序发送信号,C语言也提供了kill库函数,用于在程序中向其它进程或者线程发送信号。

函数声明:

int kill(pid_t pid, int sig);

kill函数将参数sig指定的信号给参数pid 指定的进程。

参数pid 有几种情况:

1)pid>0 将信号传给进程号为pid 的进程。

2)pid=0 将信号传给和目前进程相同进程组的所有进程,常用于父进程给子进程发送信号,注意,发送信号者进程也会收到自己发出的信号。

3)pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信息。

sig:准备发送的信号代码,假如其值为零则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。

返回值说明: 成功执行时,返回0;失败返回-1,errno被设为以下的某个值。

EINVAL:指定的信号码无效(参数 sig 不合法)。

EPERM:权限不够无法传送信号给指定进程。

ESRCH:参数 pid 所指定的进程或进程组不存在。

Linux多进程

0-1-2号进程

0号进程加载完系统后演变成1号和2号进程

1号负责启动系统服务,例如网络服务,防火墙,SSH,ftp服务,早期的系统中也叫init进程

2号线程负责所有内核的调度和管理

image-20220412145008695

进程标识

查看进程

ps -ef可以查看全部的进程信息 加 | 表示管道 再+more的话表示分页,用空格来跳转下一页

image-20220412145253714

UID:启动进程的用户 PID:进程编号 PPID:父进程编号 C:CPU占用率 STIME:进程的开始时间

TTY:启动进程的终端设备(现在不关心了 TIME:进程运行的总时间 CMD:启动进程时执行的命令

1号2号进程他们的父进程是0号,其他的进程的父进程不是一号就是二号

image-20220412145508411

证明:我们用上节课的book来看

image-20220412150103623

获取进程

image-20220412145929869

程序中创建进程

​ fork是分叉的意思

image-20220412150521817

image-20220412150718741

验证1、2句

image-20220412151142099

image-20220412151247485

image-20220412151308072

验证3、4句

image-20220412151340172

接受了返回值,子进程的返回值是0,父进程的返回值是子进程ID,调用失败返回 -1

返回-1一般是因为:进程太多、内存不足、系统没有资源

image-20220412151404376

我们可以通过返回值不同的这一特性,来使得后续子进程和父进程单独执行他们自己的代码

image-20220412151615031

验证5、6句

我们可以看到,在子进程中ii不断增大,父进程中明明是同一个进程的变量,却不发生改变

image-20220412151948690

image-20220412152018803

第七句image-20220412152844126

image-20220412152906426

image-20220412152924093

​ 这里引出了一个疑问,为什么,我要成为优秀的程序员。这句话执行了两次呢?按照程序的连续性,应该从fork之后,才会分出两个子进程

分析image-20220412153058511

​ 这一行代码,并没有在打开文件时就写入缓冲区,缓冲区说白了就是内存,也就是说fork之前,这一行内容还在内存里,并没有写到文件中去

​ 接下来,父进程的数据空间被复制了一份给子进程,数据空间包括了文件缓冲区,所以fork之后,在父进程的数据空间里面有这行代码的内容,而子进程的文件缓冲区也有这个内容,目前,他们还是把内容放在各自的缓冲区里面。当程序fclose关闭时,再把各自缓冲区的内容写入文件

​ 现在我们来改一下程序

增加了一句flush的意思为刷新—这里刷新缓冲区,这样处理的话,最终生成的文件只会有一行这个输出

image-20220412153454712

image-20220412154220533

现在就是我们预计的结果了

​ 这里我们需要强调一点,父进程和子进程的执行顺序是不确定的,取决于操作系统的调度算法!

一般来说我们不关心那个跑得更快

​ 另外一点强调,虽然进程之间互称子父进程,但其实互不影响,两个进程之间是独立存在的,没有联系

验证结果

​ 如果互相影响,父进程就算跑得快也最多执行一行,因为sleep1s

​ 另外,最下面那个fclose应该放在pid>0的判断语句里,因为,比如子进程已经fclose了,那么再fclose一次的话可能会导致内存错误(找不到那个进程),我这里太懒了,不再截图了

​ 还有就是,我们也看到了pid == 0哪里最下面的aaa 我要xxx未能执行,因为已经关闭了文件

image-20220412154522646

image-20220412154856408

image-20220412154908093

fprintf

只是对这个函数补充,以前用的少噢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
\#include <stdio.h>
\#include <stdlib.h>

int main()
{
FILE * fp;

fp = fopen ("file.txt", "w+");
fprintf(fp, "%s %s %s %d", "We", "are", "in", 2014);

fclose(fp);

return(0);
}

让我们编译并运行上面的程序,这将创建文件 file.txt,它的内容如下:

1
We are in 2014

现在让我们使用下面的程序查看上面文件的内容:

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
\#include <stdio.h>

int main ()
{
FILE *fp;
int c;

fp = fopen("file.txt","r");
while(1)
{
c = fgetc(fp);
if( feof(fp) )
{
**break** ;
}
printf("%c", c);
}
fclose(fp);
return(0);
}

僵尸进程

我们让子进程先退出

image-20220412155145290

image-20220412155215485

我们看,也就是说2816是父进程,2817是子进程,当子进程先退出,它的标识就变了,但是并没有直接结束进程,要等到父进程也退出了,它才一起走

僵尸进程的危害

简而言之,就是子进程死了,它的进程号还被占用,被存入一个数据结构里面,如果父进程不及时处理,进程号就一直被占用,但系统进程号有限,可能之后会因为没有进程号而不能产生新的进程,这就是僵尸进程的危害

image-20220412155402988

解决方法
  1. image-20220412155912884忽略SIGCHLD信号,因为子进程退出,内核向父进程发送这个信号,等待处理,如果没收到,自然就没人管咯,父进程不认儿子咯
  2. 在父进程中增加等待子进程的代码image-20220412160144262wait需要包含的头文件image-20220412160211294wait产生的问题:wait会阻塞父进程,迫使父进程必须接收到子进程结束的信号才能进行下一步的操作,这段时间,父进程就干不了其他事情了
  3. image-20220412165937760在子进程执行结束后,内核给父进程发出这个信号,然后,此时父进程收到了信号(且父进程还在执行sleep10s)就进入func函数,并且sleep的过程被信号软中断强行打断,在函数内定义了int变量sts,后在用sts的地址保存子进程如何退出(地址的每一位,用来保存对应数据啥啥啥的?),由于得到了sig信号,所以能顺利执行wait,并且退出函数,这个时候,由于父进程在sleep10s之后也没有其他的语句了,因此也退出,如果我们要看到效果,可以再sleep10s之后再加入一句sleep10s,父子两就不会同时退出了
wait()

    对 wait() 的调用会阻止调用进程,直到它的一个子进程退出或收到信号为止。子进程终止后,父进程在wait系统调用指令后继续执行。
    子进程可能由于以下原因而终止:

  • 调用exit();
  • 接收到main进程的return值;
  • 接收一个信号(来自操作系统或另一个进程),该信号的默认操作是终止。
    在这里插入图片描述
1
2
3
4
语法:
// 获取子进程退出状态并返回死掉的子进程ID
pid_t wait(int *stat_loc);
123

    如果任何进程有多个子进程,则在调用 wait() 之后,如果没有子进程终止,则父进程必须处于wait状态。
    如果只有一个子进程被终止,那么 wait() 返回被终止的子进程的进程ID。
    如果多个子进程被终止,那么 wait() 将获取任意子进程并返回该子进程的进程ID。
    wait的目的之一是通知父进程子进程结束运行了,它的第二个目的是告诉父进程子进程是如何结束的。wait返回结束的子进程的PID给父进程。父进程如何知道子进程是以何种方式退出的呢?
    答案在传给wait的参数之中。父进程调用wait时传一个整型变量地址给函数。内核将子进程的退出状态保存在这个变量中。如果子进程调用exit退出,那么内核把exit的返回值存放到这个整数变量中;如果进程是被杀死的,那么内核将信号序号存放在这个变量中。这个整数由3部分组成,8个bit记录子进程exit值,7个bit记录信号序号,另一个bit用来指明发生错误并产生了内核映像(core dump)。
    如果进程没有子进程,那么 wait() 返回“**-1**”。

孤儿进程

image-20220412170406542

其实孤儿并不孤儿

当父进程走后,子进程会自动挂到1号进程旗下

image-20220412170519169

服务程序的调度

服务程序一般需要把信号关掉(后台程序,只是执行单一的命令的)

image-20220413104143053

execl()

相关函数:fork, execle, execlp, execv, execve, execvp

头文件:#include <unistd.h>

定义函数:int execl(const char * path, const char * arg, …);

函数说明:execl()用来执行参数path 字符串所代表的文件路径, 接下来的参数代表执行该文件时传递过去的argv(0), argv[1], …, 最后一个参数必须用空指针(NULL)作结束.

返回值:如果执行成功则函数不会返回, 执行失败则直接返回-1, 失败原因存于errno 中.

范例

1
2
3
4
5
#include <unistd.h>
main()
{
execl("/bin/ls", "ls", "-al", "/etc/passwd", (char *)0);
}

执行:

1
2
/*执行/bin/ls -al /etc/passwd */
-rw-r--r-- 1 root root 705 Sep 3 13 :52 /etc/passwd

execl函数,在执行的过程中,等于是把这个程序终止了,用第一个参数的程序来替代现在正在运行的程序

image-20220413113917507

也就是如下,执行完了aaa以后就停止了,之后从ls里面执行tmp目录下project.tgz

image-20220413114043047

当然,如果我们故意把目录写错,就会继续执行后面的语句,并且execl函数执行错误返回值为**-1**image-20220413114307585

image-20220413114338545

execl()+wait()

exec开头的函数,功能大同小异

image-20220413114723044

每经过fork() == 0时,就执行一次,分支image-20220413134515767

这里就使得他每间隔10s执行一次ls,对project.tgz 执行-it指令image-20220413134231961

这样是非常好的,不过也出现了一个问题,我们到底需要兼容多少个参数?难不成一直写判断语句吗

image-20220413134358772

因此,我们引入了execv()

execv()

​ 如果说execl必须明确传参列表,那么execv就可以不用明确,但它底层采用的方式却不是分割字符串这个简单,不过最好空格隔开

​ 同样,也是用第一个参数,开始来代替这个程序接着走下去

main函数传参

argc其实无需传入,它自动显示,是你传的加上该程序本身的个数之和,传入的所有都从argv[1]开始存放,argv[0]为当前程序名字

解释一

这里主要说明带参数的main函数如何使用

1
2
3
4
5
6
7
8
int main(int argc, char* argv[])
{
int i;
for (i=0; i<argc; i++)
printf("%d: %s\r\n", i+1, argv[i]);
return 0;
}
1234567

参数介绍
argc : main函数参数个数,当参数为void的时,argc=1,默认参数为可执行文件名
argv : 指针数组,分别指向个参数字符串首地址,其中argv[0]指向默认参数

代码输出结果
没有参数默认输出./hello
有参数按照参数顺序输出参数
在这里插入图片描述

解释二

main函数的两个传入参数即

1
2

int main(int argc, char *argv[])

其中argc为一个整数,表示程序传入参数的个数,其英文全称也即是argument_count,简称为了argc。

而argv为一个存放参数值的字符串数组,其英文全称argument_value,它会根据传入的数据,将传入的数据存入该指针数组。如果有空格隔开,则表示传入多个参数。

比如我们允许main时传入了参数

1
int main("TrainData_2015.1.1_2015.2.19.txt"  "input_5flavors_cpu_7days.txt"

那么传入进到程序中的argc参数则为3了,因为程序会把自己的名字作为第一个参数,示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
       argc = 3;



argv[0] = "filename";//该主程序所在文件名字



argv[1] = "TrainData_2015.1.1_2015.2.19.txt";//是一个字符串指向第一个字母的指针



argv[2] = "input_5flavors_cpu_7days.txt";//同上

传入文件名后,我们便可以对文件进行读写操作了。

调度服务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <stdio.h>  
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(int argc,char *argv[]) {
if (argc<3) {

printf("Using:./procctl timetvl program argv ...\n");
printf("Example:/project/tools1/bin/procctl 5 /usr/bin/tar zcvf /tmp/tmp.tgz /usr/include\n\n");

printf("本程序是服务程序的调度程序,周期性启动服务程序或shell脚本。\n");
printf("timetvl 运行周期,单位:秒。被调度的程序运行结束后,在timetvl秒后会被procctl重新启动。\n");
printf("program 被调度的程序名,必须使用全路径。\n");
printf("argvs 被调度的程序的参数。\n");
printf("注意,本程序不会被kill杀死,但可以用kill -9强行杀死。\n\n\n");

return -1;//注意,这里不返回,到时候会继续往下执行
}

// 关闭信号和IO,本程序不希望被打扰。
for(int i = 0; i < 64; i++){
signal(i, SIG_IGN);
close(i);
}

// 生成子进程,父进程退出,让程序运行在后台,由系统1号进程托管。
if(fork() != 0) exit(0);


// 启用SIGCHLD信号,让父进程可以wait子进程退出的状态。
signal(SIGCHLD, SIG_DFL);//DFL为默认执行信号
char *pargv[argc];
for(int i = 2; i < argc; i++)
pargv[i-2] = argv[i];

pargv[argc-2] = NULL;//这两步是将argv[2]之后的命令存入pargv;

while(true){
if(fork() == 0){//再度生成子进程?
execv(argv[2], pargv);//以第三个参数为路径 PS:仔细看main函数传参就懂得了
exit(0);//execv成功找到路径,就不执行,不然就退出进程了
}else{
int status;
wait(&status);//第二个参数就是睡眠时间
sleep(atoi(argv[1]));//睡眠,atoi为转化为整数
}
}
}

三、守护进程的实现

image-20220413204018998

Linux共享内存

​ 1.查看共享内存,使用命令:ipcs -m

​ 2.删除共享内存,使用命令:ipcrm -m [shmid]

​ Linux中,每个内存的内存空间是独立的,互相不能访问,共享内存允许多个内存访问同一块内存,是进程之间共享和传递数据最高效的方式

共享内存的操作

删除的情况是,除非整个项目的服务程序都要停止运行

每个函数失败都是返回**-1**啦

image-20220413204337535

shmget

第一个参数,key,和共享内存的key是一样的意思,第二个参数是信号量的个数,一般取值为1,第三个参数是创建信号量的权限和他的一些标志

image-20220413210642166

0640是八进制表示,0不能少(看项目情况,这个是权限,你需要什么,就写什么),后面那部分(IPC_CREAT)表示共享内存存在,就获得他的ID,如果不存在,就创建他(这个基本不能改)

image-20220413211132748

ipcs -m查看内存段 nattch指的是被多少个进程链接了

我们也可以解释为什么key要填16进制,因为如果你填十进制,但是他显示默认是16进制,这样不好区分段,无疑增加了工作量

ipcrm -m xxxshmid 删除内存段

image-20220413211439677

shmat

第一个参数:共享内存的ID,第二个第三个都可以填0 返回值:共享内存的地址,程序中用指针来指image-20220413212339485创建一个共享内存结构体,然后来了把当前进程写入共享内存image-20220413212947240image-20220413213342326

这个过程就证明了,这个内存空间一直存在,因为最后一步已经剥离了进程(创建好以后(0x5005号),所以后面进来执行都是往5005这个内存块写入东西

image-20220413212115327

shmctl

一般来说,这个指令不止删除一个功能,但我们通常使用中,只会使用到他的删除功能

image-20220413214028723image-20220413215822771

至少在这个程序里我们不能用,因为我们刚刚创建好,写入,又把它删除没啥意思image-20220413215732348

Linux信号量

ipcs -s 查看信号量

ipcrm sem xxxxsemid 删除信号量

泪目,操作系统的pv操作,居然学到了image-20220413220917736

引例image-20220413221034539

信号量形式image-20220413221131344

CSEM类

image-20220413221332918

PV

共享内存为什么需要PV?,因为在共享内存正在写入的过程中,是不应该允许别的进程访问他的,这样会导致残缺的数据访问,我们说,这不是我们想要看到的

加锁状态image-20220413225324848

10s以后image-20220413225344613

查/删信号量

image-20220413225956431

多个进程同时抢共享内存

我们先顺序运行 aaa bbb ccc ddd

最开始496s时image-20220414153322776接着我们会发现,ddd的时候,他已经执行了v操作,此时val不是之前演示的1,而是0,是由于bbb此时已经被唤醒,并且得到了这个信号量image-20220414153511864image-20220414153557332可以看到,ddd后面已无等待进程,所以执行v操作以后,信号量+1,恢复为默认的1image-20220414153702443

信号量初始化

引入image-20220414154206957

初始值为0的话,会使得P操作永远处于等待状态,不过我们可能会想到,先初始化1一个为0的信号量,再把这个信号量的第二个参数设置为1不就好了吗?

​ 但如果此时正在有人执行p操作,你这样赋值,不就把锁给解开了吗image-20220414155355461

创造具体过程

IPC_EXCL标志写入后,如果信号量已存在,semget这个函数调用后,会调用失败,多进程的程序,一定要考虑他们之间的竞争关系image-20220414155817992

​ 我们假设有两个进程同时获取,那么他们就会同时往后面走(因为此时信号量不存在,两个都能进入第二个if,如果没有这个IPC_EXCl标记,就会导致,他们都能创建信号量,并且都能设置信号量初始值为1,这看上去,就像各自都持有一把锁,所以他们的p操作都能够成功image-20220414160554924

两个小细节

  1. semget的第二个参数是信号量个数,看需要取 semctl的第二个参数是信号量编号,编号是从0开始,也就是说,如果只有一个信号了,他应该填0
  2. 信号量的初始值(value):二值信号量填1,其他信号量看你实际开发中的需求来填
PV操作再深入

我们可以看到,P和V的含义是不同的,但是他们的函数代码完全相同

​ 但是,信号量的值不能够直接加减运算,要用OP函数,OP函数第一个参数是信号量的ID,如果操作的是单个信号量,第二个参数填一个结构体的地址,第三个参数填1,如果操作的是一组信号量,第二个参数填结构体数组的地址,第三个参数填信号量个数

​ 我们再来看结构体,第一个参数num是 信号量编号,第二个是op信号量,第三个见下面sem_flgimage-20220414160945688 在实际使用的过程中,P缺省把信号量的值减一V缺省把信号量的值加一(也就是sem_op = -1 / 1),当然也可以不用缺省值

sem_flg看情况用image-20220414161550665

sem_flg用法解析image-20220414162214224

​ 如果sem_flg设置为SEM_UNDO,例如对同一个信号量,不同进程操控,在进程未全部终止的时候,进程之间使用该信号量,仍然是上一个进程使用之后的结果,换句话说,也就是不会初始化为init的第三个参数=-=。当所有进程结束的时候,操作系统帮我们执行初始化,这种情况,就适用于IO设备,也就是说,适用于资源有限、不变的模型

​ 另外,再补充一个UNDO的细节,在某些情况下可以避免死锁,例如,aaa进程,还没用完锁就被杀死了,这个时候有bbb在等着,如果没有UNDO恢复信号量为初始值,那么bbb就死等到生命的结尾,我们说,这也不是我们想要看到的

​ 如果sem_fig为0的话就是另外一个情况,也就是 从0开始嘛,不会初始化,该信号量是多少就是多少,不会去管他,随便你们几个进程兄弟伙咋过整,管我求事,这种感觉。适用于生产者-消费者模型,动态供给关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "_public.h"

CSEM sem; // 用于给共享内存加锁的信号量
struct st_pid{
int pid; // 进程编号
char name[51]; // 进程名称
};

int main(int argc, char* argv[]){
if(argc < 2){
printf("Using:./book1 procname\n");
return 0;
}

// 共享内存的标志
int shmid;

//获取或者创建共享内存,键值为0x5005
if((shmid = shmget(0x5005, sizeof(struct st_pid), 0640|IPC_CREAT)) == -1){
printf("shmget(0x5005) failed\n");
return -1;
}

//如果信号量已存在,获取信号量;如果信号量不存在,则创建它并初始化为value
if(sem.init(0x5005) == false){
printf("sem.init(0x5005) failed\n");
return -1;
}

//用于指向共享内存的结构体变量
struct st_pid* stpid = 0;

//把共享内存连接到当前进程的地址空间
//并且处理了未找到情况,需要强转成同一类型才能比对
if((stpid = (struct st_pid *)shmat(shmid,0,0)) == (void *)-1){
printf("shmat failed\n");
return -1;

}

printf("aaa time = %d, val = %d\n", time(0), sem.value());
sem.P(); //加锁
printf("bbb time = %d, val = %d\n", time(0), sem.value());
printf("pid=%d,name=%s\n", stpid -> pid, stpid -> name);
stpid -> pid = getpid(); //进程编号
sleep(10);
strcpy(stpid -> name, argv[1]); //进程名称
printf("pid=%d,name=%s\n", stpid -> pid, stpid -> name);
printf("ccc time = %d, val = %d\n", time(0), sem.value());
sem.V(); //解锁
printf("ddd time = %d, val = %d\n", time(0), sem.value());


//把共享内存从当前进程中分离
shmdt(stpid);
}

守护进程实现

1
g++ -g -o book1 book1.cpp -I/project/public /project/public/_public.cpp

image-20220414163918672

心跳机制

​ 创建一块共享内存,用于存放服务程序心跳信息的结构体数组,每个服务程序启动的时候,会查找共享内存,在共享内存中空白位置把自己的心跳信息写进去,并且程序在运行的过程中,还会不断的把自己的心跳信息写进去,更新到心跳数组中,表示自己是活着,守护进程每隔若干秒,遍历一次共享内存,检查每个服务程序的心跳信息,如果当前时间 - 最后一次心跳时间 > 超时时间表示该服务程序没有心跳了,死掉了,终止他,死掉的服务程序被终止后,调度程序将重新启动它image-20220414164425619

实现目标image-20220414164504510
STRCPY()

安全的copy封装image-20220414171445829image-20220414171553599image-20220414171614172

心跳实现
  1. 创建/获取共享内存,大小为n * sizeof(struct st_pinfo)
  2. 将共享内存连接到当前进程的地址空间
    1. 细节1:这样指到共享内存,我们就可以把它当作结构体数组来用,也可以用地址的运算
    2. 共享内存创建后,系统对其初始化,不会有垃圾值,我们可以用for遍历,找到没有用的共享内存,如果有pid为0的,表示为空位置
  3. 创建当前进程心跳信息结构体变量,把本进程的信息填进去
  4. 更新共享内存中当前进程的心跳时间
  5. 把当前进程从共享内存中移去
  6. 把共享内存从当前进程中分离

更多细节请欣赏下面打了一天的代码=-=

加入了一些异常处理,封装成了类,使之可以被调用,如果需要报告自己的心跳信息,会造对象,会调add方法和uptatime方法足以image-20220414214855707image-20220414214912044

代码里还处理了锁(竞争)的问题,没有空位的问题,id重复的问题……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
#include "_public.h"

#define MAXNUMP_ 1000 // 最大的进程数量
#define SHMKEYP_ 0x5095 // 共享内存的key
#define SEMKEYP_ 0x5095 // 信号量的key

// 进程心跳信息的结构体
struct st_pinfo{
int pid; // 进程id
char pname[51]; // 进程名称,可以为空
int timeout; // 超时时间,单位:秒
time_t atime; // 最后一次心跳的时间,用整数表示
};

class PActive{
private:
CSEM m_sem; // 用于给共享内存枷锁的信号量id
int m_shmid; // 共享内存的id
int m_pos; // 当前进程在共享内存进程组中的位置
struct st_pinfo *m_shm; // 指向共享内存的地址空间
public:
PActive(){
m_shmid = -1; //共享内存的id
m_pos = -1; // 当前进程在共享内存进程组中的位置
m_shm = 0; //指向共享内存的地址空间
}
bool AddPInfo(const int timeout, const char* pname); // 把当前进程的心跳信息加入共享内存中
bool UptATime(); // 更新共享内存中当前进程的心跳时间
~PActive(); // 从共享内存中删除当前进程的心跳记录
};

int main(int argc, char* argv[]){
if(argc < 2){
printf("Using:./book procname\n");
return 0;
}
// 创建/获取共享内存,大小为n * sizeof(struct st_pinfo)
int m_shmid = 0;
if((m_shmid = shmget(SHMKEYP_*sizeof(struct st_pinfo), MAXNUMP_, 0640|IPC_CREAT)) == -1){
printf("shmget(%x) failed\n", MAXNUMP_);
return -1;
}

CSEM m_sem;
if(m_sem.init(SEMKEYP_) == false){//创建失败,提示信息
printf("m_sem.init(%x) failed\n", SEMKEYP_);
return -1;
}
// 将共享内存连接到当前进程的地址空间
struct st_pinfo* m_shm;//细节1:这样指到共享内存,我们就可以把它当作结构体数组来用,也可以用地址的运算
m_shm = (struct st_pinfo*)shmat(m_shmid, 0, 0);//细节2:共享内存创建后,系统对其初始化,不会有垃圾值,我们可以用for遍历,
//找到没有用的共享内存,如果有pid为0的,表示为空位置
// 创建当前进程心跳信息结构体变量,把本进程的信息填进去
struct st_pinfo stpinfo;//创建了结构体变量,所以下面&获取地址
memset(&stpinfo, 0, sizeof(struct st_pinfo));
stpinfo.pid = getpid(); //进程id
STRNCPY(stpinfo.pname, sizeof(stpinfo.pname), argv[1], 50); // 进程名称
stpinfo.timeout = 30; // 超时时间,单位:秒
stpinfo.atime = time(0); // 最后一次心跳的时间/当前时间

int m_pos = -1;
// 进程的id是循环使用的,如果曾经一个进程异常退出,没有清理自己的心跳信息
// 他的进程信息将残存在共享内存中,不巧的是,当前进程重用了上述进程的id
// 这样同一个共享内存会存在两个相同的id记录,守护进程检查到残留进程的
// 心跳时(死了挺久了),会向进程id发送退出信号,这个信号将误杀当前进程
// 在共享内存中查找一个空位置,把当前进程的心跳信息存入共享内存中
// 我们在基于上面文字,做出以下改进,增加下面这个for循环,再用if把下下面的for循环嵌套

// 如果共享内存中存在当前进程编号,一定是其他进程残留,当前进程重用该位置
for(int i = 0; i < SHMKEYP_; i++)
if(m_shm[i].pid == stpinfo.pid){
m_pos = i;
break;
}

m_sem.P();
//一般来说,锁越短越好,我们对有竞争位置的地方加锁就好。
if(m_pos == -1)
for(int i = 0; i < SHMKEYP_; i++){
//if((m_shm+i)->pid==0)//找到了一个空位置
if(m_shm[i].pid == 0){
m_pos = i;
break;
}
}

if(m_pos == -1){//如果没有空位,必然是-1
//如果没有空间了,这里也要退出,所以也要解锁,细节!
m_sem.V();
printf("共享内存空间已用完\n");
return -1;
}

// 把当前进程的心跳信息存入共享内存的进程组中
memcpy(m_shm+m_pos, &stpinfo, sizeof(struct st_pinfo));
// 写入之后解锁
m_sem.V();

while(true){
// 更新共享内存中本进程的心跳时间
m_shm[m_pos].atime = time(0);

sleep(10);
}

// 把当前进程从共享内存中移去
//m_shm[m_pos].pid = 0;
memset(m_shm + m_pos, 0, sizeof(struct st_pinfo));
// 把共享内存从当前进程中分离
shmdt(m_shm);
return 0;
}

bool PActive::AddPInfo(const int timeout, const char* pname){ // 把当前进程的心跳信息加入共享内存中
if(m_pos != -1) return true;//已经找到位置了不需要再找
// 创建/获取共享内存,大小为n * sizeof(struct st_pinfo)
if((m_shmid = shmget(SHMKEYP_*sizeof(struct st_pinfo), MAXNUMP_, 0640|IPC_CREAT)) == -1){
printf("shmget(%x) failed\n", MAXNUMP_);
return false;
}

if(m_sem.init(SEMKEYP_) == false){//创建失败,提示信息
printf("m_sem.init(%x) failed\n", SEMKEYP_);
return false;
}
// 将共享内存连接到当前进程的地址空间
//细节1:这样指到共享内存,我们就可以把它当作结构体数组来用,也可以用地址的运算
m_shm = (struct st_pinfo*)shmat(m_shmid, 0, 0);//细节2:共享内存创建后,系统对其初始化,不会有垃圾值,我们可以用for遍历,
//找到没有用的共享内存,如果有pid为0的,表示为空位置
// 创建当前进程心跳信息结构体变量,把本进程的信息填进去
struct st_pinfo stpinfo;//创建了结构体变量,所以下面&获取地址
memset(&stpinfo, 0, sizeof(struct st_pinfo));
stpinfo.pid = getpid(); //进程id
STRNCPY(stpinfo.pname, sizeof(stpinfo.pname), pname, 50); // 进程名称
stpinfo.timeout = timeout; // 超时时间,单位:秒
stpinfo.atime = time(0); // 最后一次心跳的时间/当前时间

// 进程的id是循环使用的,如果曾经一个进程异常退出,没有清理自己的心跳信息
// 他的进程信息将残存在共享内存中,不巧的是,当前进程重用了上述进程的id
// 这样同一个共享内存会存在两个相同的id记录,守护进程检查到残留进程的
// 心跳时(死了挺久了),会向进程id发送退出信号,这个信号将误杀当前进程
// 在共享内存中查找一个空位置,把当前进程的心跳信息存入共享内存中
// 我们在基于上面文字,做出以下改进,增加下面这个for循环,再用if把下下面的for循环嵌套

// 如果共享内存中存在当前进程编号,一定是其他进程残留,当前进程重用该位置
for(int i = 0; i < SHMKEYP_; i++)
if(m_shm[i].pid == stpinfo.pid){
m_pos = i;
break;
}

m_sem.P();
//一般来说,锁越短越好,我们对有竞争位置的地方加锁就好。
if(m_pos == -1)
for(int i = 0; i < SHMKEYP_; i++){
//if((m_shm+i)->pid==0)//找到了一个空位置
if(m_shm[i].pid == 0){
m_pos = i;
break;
}
}

if(m_pos == -1){//如果没有空位,必然是-1
//如果没有空间了,这里也要退出,所以也要解锁,细节!
m_sem.V();
printf("共享内存空间已用完\n");
return false;
}

// 把当前进程的心跳信息存入共享内存的进程组中
memcpy(m_shm+m_pos, &stpinfo, sizeof(struct st_pinfo));
// 写入之后解锁
m_sem.V();

return true;
}
bool PActive::UptATime(){ // 更新共享内存中当前进程的心跳时间
if(m_pos !=-1) return false;
m_shm[m_pos].atime = time(0);
return true;
}

PActive::~PActive(){ // 从共享内存中删除当前进程的心跳记录
// 把当前进程从共享内存中移去
//m_shm[m_pos].pid = 0;
if(m_pos !=-1) memset(m_shm + m_pos, 0, sizeof(struct st_pinfo));
// 把共享内存从当前进程中分离
if(m_shm != 0) shmdt(m_shm);

}

守护程序实现image-20220414221543732

总体框架呈现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "_public.h"

// 程序运行日志
CLogFile logfile;

int main(int argc, char* argv[]){
// 程序的帮助

// 打开日志文件

// 创造/获取共享内存

// 将共享内存连接到当前进程的地址空间

// 遍历共享内存中全部的进程心跳记录
for(int i = 0; i < MAXNUMP; i++){
// 如果记录的pid == 0, 表示空记录, continue;

// 如果记录的pid != 0,表示是服务程序的心跳记录

// 向进程发送信号0(不管是否超时),判断它是否还存在,如果不存在,从共享内存中删除该记录,continue;

// 如果已经超时

// 发送信号15,尝试正常终止进程

// 如果进程仍存在,发送信号9,强行终止它

// 从共享内存中删除已超时进程的心跳记录
}
// 把共享内存从当前进程中分离
return 0;
}
具体实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include "_public.h"

// 程序运行日志
CLogFile logfile;

int main(int argc, char* argv[]){
// 程序的帮助。
if (argc != 2)
{
printf("\n");
printf("Using:./checkproc logfilename\n");

printf("Example:/project/tools1/bin/procctl 10 /project/tools1/bin/checkproc /tmp/log/checkproc.log\n\n");

printf("本程序用于检查后台服务程序是否超时,如果已超时,就终止它。\n");
printf("注意:\n");
printf(" 1)本程序由procctl启动,运行周期建议为10秒。\n");
printf(" 2)为了避免被普通用户误杀,本程序应该用root用户启动。\n");
printf(" 3)如果要停止本程序,只能用killall -9 终止。\n\n\n");

return 0;
}

// 忽略全部的信号和IO,不希望程序被干扰。
// for(int i = 1; i <= 64; i++) signal(i, SIG_IGN);
CloseIOAndSignal(true);//该函数,缺省false(只关信号不关IO,用true就可以全关

// 打开日志文件
if(logfile.Open(argv[1],"a+") == false){
printf("logfile.Open(%S) failed.\n", argv[1]);
return -1;
}
// 创建/获取共享内存,键值为SHMKEYP,大小为MAXNUMP个st_procinfo结构体的大小、我们自己实现的代码是profo(吴哥的是procinfo)
// 从心跳机制抄过来的(删改了一些),用的是用一个共享内存
int m_shmid = 0;
if((m_shmid = shmget((key_t)SHMKEYP, MAXNUMP*sizeof(struct st_procinfo), 0666|IPC_CREAT)) == -1){
logfile.Write("创建/获取共享内存(%x)失败。\n", SHMKEYP);
return false;
}
// 将共享内存连接到当前进程的地址空间
struct st_procinfo *shm = (struct st_procinfo *)shmat(m_shmid, 0, 0);

// 遍历共享内存中全部的进程心跳记录
for(int i = 0; i < MAXNUMP; i++){
// 如果记录的pid == 0, 表示空记录, continue;
if(shm[i].pid == 0) continue;

// 如果记录的pid != 0,表示是服务程序的心跳记录,程序稳定了就不需要写了,是用于调试
//logfile.Write("i = %d, pid = %d, pname = %s, timeout = %d, atime = %d\n",\
// i, shm[i].pid, shm[i].pname, shm[i].timeout, shm[i].atime);

// 向进程发送信号0(不管是否超时),判断它是否还存在,如果不存在,从共享内存中删除该记录,continue;
int iret = kill(shm[i].pid, 0);//kill,进程不存在会返回-1,进程存在,返回0。
if(iret == -1){
logfile.Write("进程pid = %d(%S)已经不存在.\n", (shm+i) -> pid, (shm + i) -> pname);
memset(shm+i, 0, sizeof(struct st_procinfo));//把结构体清零,细节
continue;
}

time_t now = time(0); //取当前时间
// 如果未超时
if(now - shm[i].atime < shm[i].timeout) continue;

// 如果已经超时
logfile.Write("进程pid = %d(%S)已经超时.\n", (shm+i) -> pid, (shm + i) -> pname);

// 发送信号15,尝试正常终止进程
kill(shm[i].pid, 15); // 发送信号15,尝试正常终止进程

// 每隔1秒判断一次进程是否存在,累计5秒,一般来说,5秒的时间足够让进程退出
for(int j = 0; j < 5; j++){
sleep(1);
iret = kill(shm[i].pid, 0); //向进程发送信号0,判断它是否还存在
if(iret == -1) break; //进程已经退出
}

// 如果进程仍存在,发送信号9,强行终止它
if(iret == -1){
logfile.Write("进程pid = %d(%S)已经正常终止.\n", (shm+i) -> pid, (shm + i) -> pname);
}else{
kill(shm[i].pid, 9); //如果进程仍然存在,就发送信号9,强行终止它
logfile.Write("进程pid = %d(%s)已经强制终止。\n", (shm+i)->pid, (shm+i)->pname);
}

// 从共享内存中删除已超时进程的心跳记录
memset(shm+i, 0, sizeof(struct st_procinfo)); // 从共享内存中删除该记录
}
// 把共享内存从当前进程中分离
shmdt(shm);
return 0;
}
kill(pid, 0)

答案就是

kill -0 pid 不发送任何信号,但是系统会进行错误检查。

我们可以用来检查一个进程是否存在,存在则 echo $? 返回 0 , 不存在返回 1

当然了,各个系统有自己的稍微差异,比如苹果电脑的

存在则什么都不返回

1
kill -0 536

不存在则

1
2
kill -0 99222
-bash: kill: (99222) - No such process
exit与析构的关系

image-20220416132807274

CPactive 对象,如果放在main函数中,按下ctrl+c,bbb显示正常退出,但是共享内存(心跳记录)并没有被删除,而把该对象开成全局变量,按下ctrl+c,终止ddd,ddd就真的被终止了,这是为什么呢?

image-20220416112229797

四、完善生成测试数据程序

  1. 增加生成历史数据文件的功能,为压缩文件和清理文件模块准备历史数据文件。
  2. 增加信号处理函数,处理2和15的信号
  3. 解决调用exit函数退出时局部对象没有调用析构函数的问题
  4. 把心跳信息写入共享内存,(虽然说运行很短,根本不需要使用心跳,但我们现在手里只有他,所以就拿他来玩呗)

生成历史数据文件

首先,我们将crtsurfdata5.cpp里面的,再main函数外面的获取当前时间,移植到main里,并且用

1
2
3
4
5
6
// 获取当前时间,当作观测时间。 
memset(strddatetime,0,sizeof(strddatetime));
if(argc == 5)
LocalTime(strddatetime,"yyyymmddhh24miss");
else
STRCPY(strddatetime, sizeof(strddatetime), argv[5]);

来进行保存历史时间的功能,这样,数据的时间属性就会根据这个来变化,但是文件的时间属性还没有处理好,在这里,我们的开发框架有一个UTimeimage-20220416135928671

我们在关闭文件后,用这个包处理文件的时间就OK了

CrtSurFile(分钟观测写入文件)方法里image-20220416140245388

处理信号

先关闭所有的信号(放在main函数开头)image-20220416142307615

有一个细节

​ 这里关闭io一定不能放在打开文件的后面,在前面随便哪个位置都可以,道理很简单,如果把代码放在打开以后,他会把日志文件的文件描述符也给关掉image-20220416142551367

实例

用了sleep10秒延迟,在关闭写入文件的上面,得以看到结果image-20220416142937451

运行过程中按ctrl + c

exit未调用析构

我们来分析一下,回顾我们存文件的过程,是先创造一个临时文件,然后往里面写数据,数据写完,再改名为正式的数据文件,在过程中,如果程序被终止,应该把这些文件都清理掉,清理的工作在析构函数中会执行,但如果析构函数没有调用,就会在磁盘上留下这些临时文件image-20220416143427332

要解决这个问题很简单,把CFile类的 File变成全局对象image-20220416144720612

我们可以看出,在最初ctrl + c终止后,会存留tmp临时文件,但是将CFile类开到全局,会自动调用析构函数,所以就把tmp删咯

/tmp

(20条消息) linux之tmp文件夹_码莎拉蒂 .的博客-CSDN博客_linux tmp

一个小细节,我注意到了ls /tmp/…. 于是乎,我就去搜索了tmp,好家伙

​ 查了些资料,/tmp文件夹是存放linux临时文件的地方,在Linux系统中/tmp文件夹里面的文件会被清空,至于多长时间被清空,如何清空的,可能就不清楚了。

Linux系统中/tmp文件夹里面的文件会被清空,至于多长时间被清空,如何清空的?
今天我们就来剖析一个这两个问题。

在RHEL\CentOS\Fedora\系统中(本次实验是在RHEL6中进行的)

先来看看tmpwatch这个命令,他的作用就是删除一段时间内不使用的文件(removes files which haven’t been accessed for a period of time)。具体的用法就不多说了,有兴趣的自行研究。我们主要看看和这个命令相关的计划任务文件。
它就是/etc/cron.daily/tmpwatch,我们可以看一下这个文件里面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
#! /bin/sh
flags=-umc
/usr/sbin/tmpwatch "$flags" -x /tmp/.X11-unix -x /tmp/.XIM-unix \
-x /tmp/.font-unix -x /tmp/.ICE-unix -x /tmp/.Test-unix \
-X '/tmp/hsperfdata_*' 10d /tmp
/usr/sbin/tmpwatch "$flags" 30d /var/tmp
for d in /var/{cache/man,catman}/{cat?,X11R6/cat?,local/cat?}; do
if [ -d "$d" ]; then
/usr/sbin/tmpwatch "$flags" -f 30d "$d"
fi
done

第一行相当于一个标记(参数),第二行就是针对/tmp目录里面排除的目录,第三行,这是对这个/tmp目录的清理,下面的是针对其他目录的清理,就不说了。

我们就来看/usr/sbin/tmpwatch “$flags” 30d /var/tmp这一行,关键的是这个30d,就是30天的意思,这个就决定了30天清理/tmp下不访问的文件。如果说,你想一天一清理的话,就把这个30d改成1d。

但有个问题需要注意,如果你设置更短的时间来清理的话,比如说是30分钟、10秒等等,你可以在这个文件中设置,但你会发现重新电脑,他不清理/tmp文件夹里面的内容,这是为什么呢?这就是tmpwatch他所在的位置决定的,他的上层目录是/etc/cron.daily/,而这个目录是第天执行一次计划任务,所以说,你设置了比一天更短的时间,他就不起作用了。这下明白了吧。
所以结论是:在RHEL6中,系统自动清理/tmp文件夹的默认时限是30天

在Debian\Ubuntu系统中(Ubuntu10.10为实验环境)

在Ubuntu系统中,在/tmp文件夹里面的内容,每次开机都会被清空,如果不想让他自动清理的话,只需要更改rcS文件中的TMPTIME的值。
我们看如何来修改
sudo vi /etc/default/rcS

TMPTIME=0
修改成
TMPTIME=-1或者是无限大
改成这样的话,系统在重新启动的时候就不会清理你的/tmp目录了。
依此类推,如果说要限制多少时间来更改的话,就可以改成相应的数字(本人没有测试,我是这么理解的)。

进程的心跳

声明一个CPActive PAcitve;

打开文件,每开始写入一次,记录一条心跳image-20220416145623375

由于每次时间太短了,虽然20s,但肯定用不完,所以心跳的时间就不用更新了

最终数据程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/*
* project name: crtsurfdata.cpp 用于生成全国气象站点观测的分钟数据
* author: jjyaoao
*/

#include "_public.h"
CPActive PActive; //进程心跳

// 全国气象站点参数结构体
struct st_stcode{
char provname[31]; //省
char obtid[11]; //站号
char obtname[31]; //站名
double lat; //维度
double lon; //经度
double height; //海拔高度
};
// 存放全国气象站点参数的容器。
vector<struct st_stcode> vstcode;

// 把站点参数文件加载到vstcode容器中。
bool LoadSTCode(const char *inifile);

// 全国气象站点分钟观测数据结构
struct st_surfdata
{
char obtid[11]; // 站点代码。
char ddatetime[21]; // 数据时间:格式yyyymmddhh24miss
int t; // 气温:单位,0.1摄氏度。
int p; // 气压:0.1百帕。
int u; // 相对湿度,0-100之间的值。
int wd; // 风向,0-360之间的值。
int wf; // 风速:单位0.1m/s
int r; // 降雨量:0.1mm。
int vis; // 能见度:0.1米。
};

// 存放全国气象站点分钟观测数据的容器
vector<struct st_surfdata> vsurfdata;

//观测数据的时间
char strddatetime[21];
// 模拟生成全国气象站点分钟观测数据,存放在vsurfdata容器中
void CrtsurfData();

CFile File;//各种文件操作,封装为CFile

// 把容器vsurfdata中的全国气象站点分钟观测数据写入文件
bool CrtSurFile(const char *outpath, const char *datafmt);

//CLogFile logfile(10);// 指定日志文件大小为10兆
CLogFile logfile; // 日志类

void EXIT(int sig); // 程序退出和信号2、15的处理函数

int main(int argc, char *argv[]){
if((argc != 5) && (argc != 6)){//若传入argc为6,则为指定历史时间,不指定即为当前时间
printf("Using:./crtsurfdata inifile outpath logfile datafmt [datetime]\n");//[]意思即为你可以填,你也可以不填
printf("Example:/project/idc1/bin/crtsurfdata /project/idc1/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv\n\n");
printf("Example:/project/idc1/bin/crtsurfdata /project/idc1/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv 20220416123000\n\n");
printf("Example:/project/tools1/bin/procctl 60 /project/idc1/bin/crtsurfdata /project/idc1/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv\n\n");


printf("inifile 全国气象站点参数文件名。\n");
printf("outpath 全国气象战点数据文件存放的目录。\n");
printf("logfile 本程序运行的日志文件名。\n");
printf("datafmt 生成数据文件的格式,支持xml、json和csv三种格式,中间用逗号分隔。\n");
printf("datetime 这是一个可选参数,表示生成指定时间的数据和文件\n\n\n");

return -1;
}

// 关闭全部的信号和输入输出
// 设置信号,在shell状态下可用"kill + 进程号" 正常终止进程
// 但别用"kill -9 + 进程号"强制终止
CloseIOAndSignal(true);
signal(SIGINT, EXIT); signal(SIGTERM, EXIT);//可用数字也可以名称,为了标准这里使用名称(2, 15)

if (logfile.Open(argv[3], "a+", false) == false) {//打开日志文件失败,程序 退出,没必要继续
printf("logfile.Open(%s) failed.\n", argv[3]);
return -1;
}

logfile.Write("crtsurfdata 开始运行. \n");

PActive.AddPInfo(20, "crtsurfdata");//20秒写入一次,因为太短了,程序,所以心跳的时间就不用更新了

// 把站点参数文件加载到vstcode容器中
if (LoadSTCode(argv[1]) == false) return -1;

// 获取当前时间,当作观测时间。
memset(strddatetime,0,sizeof(strddatetime));
if(argc == 5)
LocalTime(strddatetime,"yyyymmddhh24miss");
else
STRCPY(strddatetime, sizeof(strddatetime), argv[5]);

// 模拟生成全国气象站点分钟观测数据,存放在vsurfdata容器中
CrtsurfData();
if(strstr(argv[4], "xml")!=0) CrtSurFile(argv[2], "xml");
if(strstr(argv[4], "json")!=0) CrtSurFile(argv[2], "json");
if(strstr(argv[4], "csv")!=0) CrtSurFile(argv[2], "csv");

logfile.Write("crtsurfdata 运行结束。 \n");

return 0;
}

// 把站点参数文件加载到vstcode容器中。
bool LoadSTCode(const char *inifile){
//打开站点参数文件。
if(File.Open(inifile, "r") == false){
logfile.Write("File.Open(%s) failed.\n", inifile);
return false;
}
char strBuffer[301];
CCmdStr CmdStr;
struct st_stcode stcode;
while (true){
//从站点参数文件中读取一行,如果读取完,跳出循环
//通常情况我们需要初始化字符串,不然可能会有bug,memset strBuffer已经再Fgets里面做了
if(File.Fgets(strBuffer, 300, true) == false) break;
// 把读到的一行拆分,第三个参数为选择是否删除多余空格
CmdStr.SplitToCmd(strBuffer, ",", true);
//扔掉无效行
if(CmdStr.CmdCount() != 6) continue;
// 把站点参数的每个数据项保存到站点参数结构体中
CmdStr.GetValue(0, stcode.provname, 30); // 省
CmdStr.GetValue(1, stcode.obtid, 10); // 站号
CmdStr.GetValue(2, stcode.obtname, 30); // 站名
CmdStr.GetValue(3, &stcode.lat); // 纬度
CmdStr.GetValue(4, &stcode.lon); // 经度
CmdStr.GetValue(5, &stcode.height); // 海拔高度
// 把站点参数结构体放入站点参数容器
vstcode.push_back(stcode);
}
/*
for (int i = 0; i < vstcode.size(); i++){
logfile.Write("provnmae=%s, obtid=%s, obtname=%s\n",\
vstcode[i].provname, vstcode[i].obtid, vstcode[i].obtname);
} 测试程序
*/
// 关闭文件,在析构函数里面已经自动关闭了(makefile)
return true;
}

// 模拟生成全国气象站点分钟观测数据,存放在vsurfdata容器中
void CrtsurfData(){
// 播随机数种子。
srand(time(0));


struct st_surfdata stsurfdata;

// 遍历气象站点参数的vstcode容器。
for (int i=0;i<vstcode.size();i++)
{
memset(&stsurfdata,0,sizeof(struct st_surfdata));

// 用随机数填充分钟观测数据的结构体。i
// 随机函数,例如下面第一个rand()%351,就是取0-350的随机函数
strncpy(stsurfdata.obtid,vstcode[i].obtid,10); // 站点代码。
strncpy(stsurfdata.ddatetime,strddatetime,14); // 数据时间:格式yyyymmddhh24miss
stsurfdata.t=rand()%351; // 气温:单位,0.1摄氏度
stsurfdata.p=rand()%265+10000; // 气压:0.1百帕
stsurfdata.u=rand()%100+1; // 相对湿度,0-100之间的值。
stsurfdata.wd=rand()%360; // 风向,0-360之间的值。
stsurfdata.wf=rand()%150; // 风速:单位0.1m/s
stsurfdata.r=rand()%16; // 降雨量:0.1mm
stsurfdata.vis=rand()%5001+100000; // 能见度:0.1米

// 把观测数据的结构体放入vsurfdata容器。
vsurfdata.push_back(stsurfdata);
}
}

// 把容器vsurfdata中的全国气象站点分钟观测数据写入文件。
bool CrtSurFile(const char *outpath, const char *datafmt){
CFile File;
// 拼接生成数据的文件名,例如:/tmp/idc/surfdata/SURF_ZH_20210629092200_2254.csv
char strFileName[301];
// 在文件名中加入进程编号,这是为了保证临时文件名不重复(getpid()),这里不加也可以
sprintf(strFileName, "%s/SURF_ZH_%s_%d.%s", outpath, strddatetime, getpid(), datafmt);
// 打开文件。
if(File.OpenForRename(strFileName, "w") == false){//一般是没有磁盘空间或者权限不足
logfile.Write("File.OpenForRename(%s) failed.\n", strFileName);
return false;
}
// 写入第一行标题
if(strcmp(datafmt, "csv") == 0) File.Fprintf("站点代, 数据时间, 气温, 气压, 相对湿度, 风向, 风速, 降雨量, 能见度\n");
// 遍历存放观测数据的vsurfdata容器。
for(int i = 0; i < vsurfdata.size(); i++){
// 写入一条记录
if(strcmp(datafmt, "csv") == 0){
File.Fprintf("%s, %s, %.1f, %.1f, %d, %d, %.1f, %.1f, %.1f\n",\
vsurfdata[i].obtid, vsurfdata[i].ddatetime, vsurfdata[i].t / 10.0, vsurfdata[i].p / 10.0, \
vsurfdata[i].u, vsurfdata[i].wd, vsurfdata[i].wf / 10.0, vsurfdata[i].r / 10.0, vsurfdata[i].vis / 10.0);
}

if (strcmp(datafmt, "xml") == 0)
File.Fprintf("<obtid>%s</obtid><ddatetime>%s</ddatetime><t>%.1f</t><p>%.1f</p>"\
"<u>%d</u><wd>%d</wd><wf>%.1f</wf><r>%.1f</r><vis>%.1f</vis><endl/>\n",\
vsurfdata[i].obtid,vsurfdata[i].ddatetime,vsurfdata[i].t/10.0,vsurfdata[i].p/10.0,\
vsurfdata[i].u,vsurfdata[i].wd,vsurfdata[i].wf/10.0,vsurfdata[i].r/10.0,vsurfdata[i].vis/10.0);

if (strcmp(datafmt, "json") == 0)
{
File.Fprintf("{\"obtid\":\"%s\",\"ddatetime\":\"%s\",\"t\":\"%.1f\",\"p\":\"%.1f\","\
"\"u\":\"%d\",\"wd\":\"%d\",\"wf\":\"%.1f\",\"r\":\"%.1f\",\"vis\":\"%.1f\"}",\
vsurfdata[i].obtid,vsurfdata[i].ddatetime,vsurfdata[i].t/10.0,vsurfdata[i].p/10.0,\
vsurfdata[i].u,vsurfdata[i].wd,vsurfdata[i].wf/10.0,vsurfdata[i].r/10.0,vsurfdata[i].vis/10.0);
if (i<vsurfdata.size()-1) File.Fprintf(",\n");
else File.Fprintf("\n");
}

if (strcmp(datafmt,"xml")==0) File.Fprintf("</data>\n");
if (strcmp(datafmt,"json")==0) File.Fprintf("]}\n");
}
//sleep(10); //单元测试
// 关闭文件
File.CloseAndRename();

UTime(strFileName, strddatetime); // 修改文件的时间属性

logfile.Write("生成数据文件%s成功,数据时间%s,记录数%d.\n", strFileName, strddatetime, vsurfdata.size());
return true;
}


// 程序退出和信号2、15的处理函数
void EXIT(int sig){
logfile.Write("程序退出,sig = %d\n\n", sig);

exit(0);
}

五、开发常用小工具

image-20220416153720602

压缩文件模块

开发框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "_public.h" 

// 程序退出和信号2、15的处理函数。
void EXIT(int sig);

int main(int argc,char *argv[]){
// 程序的帮助
// 关闭全部的信号和输入输出
// 获取文件超时的时间点(人为定义)
// 打开目录,CDir.OpenDir()
// 遍历目录中的文件名
while(true){
// 得到一个文件的信息,CDir.ReadDir()
// 与超时的时间点比较,如果更早,就需要压缩
// 压缩文件,调用操作系统的gzip命令
}
return 0;
}

void EXIT(int sig){
printf("程序退出,sig=%d\n\n", sig);

exit(0);
}

关闭信号和IO

我们通常写代码时需要先打开,调试完毕再关,因为我们的printf这些东西,都必须要打开IO才能显示给我们自己

与超时时间点比较

一个细节,除了判断文件的时间,还要判断文件名,如果文件已经被压缩了,就不需要再压缩了

压缩文件

1>/dev/null 2>/dev/null是我们常用的写法,也就是,把标准输出(1)和标准错误(2)都定位到空里面去,也就是说,不要输出任何东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
char strCmd[1024];        // 存放gzip压缩文件的命令 
while(true){
// 得到一个文件的信息,CDir.ReadDir()
if(Dir.ReadDir() == false) break;
// 与超时的时间点比较,如果更早,就需要压缩
// matchstr 用于判断一个字符串和另外一个字符串是否匹配,为自己封装
if((strcmp(Dir.m_ModifyTime, strTimeOut) < 0) && (MatchStr(Dir.m_FileName, "*.gz") == false)){
// 压缩文件,调用操作系统的gzip命令
// 可以使用execl execv 这里我们介绍新的命令system
// 大写的SNPRINTF函数和小写的sprintf功能是一样的,这样是封装成安全的
SNPRINTF(strCmd, sizeof(strCmd), 1000, "/usr/bin/gzip -f %s 1>/dev/null 2>/dev/null", Dir.m_FullFileName);
if(system(strCmd) == 0)
printf("gzip %s ok.\n", Dir.m_FullFileName);
else
printf("gzip %s failed.\n", Dir.m_FullFileName);
}


CDirimage-20220416164036570image-20220416164202724
OpenDir()

打开目录的指令

​ 第一个参数,目录名,第二个参数,文件名匹配的规则(调用了matchstr),第三个参数,获取文件的最大数量,为什么需要这个参数呢?

​ 是因为OpenDir将获取的文件名统一存放在一个容器里(m_vFileName),如果容器过大,业务处理的时候,可能并不需要一次将全部文件都读取出来,他可以一批一批的处理,一次处理1w个….这样的话就不会对内存造成很大的压力

​ 第四个参数,是否打开各级子目录,第五个参数,是否对文件排序(能不排就不排吧)

SetDateFMT()

设置时间格式

控制那容器属性那几个的输出格式image-20220416170202625

MatchStr()image-20220416170841687
system()image-20220416171122646

很简单,就一个参数—-你需要的命令

细节一:system()也可以调用其它函数,那么他与exec的其它函数有啥区别呢?

​ 本质上其实是没有区别的,是否可以取代呢?

​ 也不是,exec哪些会更加强大,有更多的功能

细节二:是否需要为压缩文件写心跳信息呢?

​ 一般是不用的,原因如下

  1. 这种程序一般是不会死机的,容易卡死的才需要调度
  2. 并不好写,因为有的文件很大,压缩的时间很长,有的短,没有一个合理的超时时间标准
压缩文件实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include "_public.h"

// 程序退出和信号2、15的处理函数。
void EXIT(int sig);

int main(int argc,char *argv[]){
// 程序的帮助
if (argc != 4)
{
printf("\n");//pathname:扫描的目录 matchstr:需要处理这个目录下的什么文件 timeout:时间点,在此之前的会被压缩
printf("Using:/project/tools1/bin/gzipfiles pathname matchstr timeout\n\n");

printf("Example:/project/tools1/bin/gzipfiles /log/idc \"*.log.20*\" 0.02\n");
printf(" /project/tools1/bin/gzipfiles /tmp/idc/surfdata \"*.xml,*.json\" 0.01\n");//下面两行表示需要由调度程序启动
printf(" /project/tools1/bin/procctl 300 /project/tools1/bin/gzipfiles /log/idc \"*.log.20*\" 0.02\n");
printf(" /project/tools1/bin/procctl 300 /project/tools1/bin/gzipfiles /tmp/idc/surfdata \"*.xml,*.json\" 0.01\n\n");

printf("这是一个工具程序,用于压缩历史的数据文件或日志文件。\n");
printf("本程序把pathname目录及子目录中timeout天之前的匹配matchstr文件全部压缩,timeout可以是小数。\n");
printf("本程序不写日志文件,也不会在控制台输出任何信息。\n");
printf("本程序调用/usr/bin/gzip命令压缩文件。\n\n\n");

return -1;
}
// 关闭全部的信号和输入输出
// 设置信号,在shell状态下可用 "kill + 进程号" 正常终止些进程。
// 但请不要用 "kill -9 +进程号" 强行终止。
// CloseIOAndSignal(true); //一般来说开发阶段把这行注释掉,为了方便调试
signal(SIGINT, EXIT); signal(SIGTERM, EXIT);

// 获取文件超时的时间点(人为定义)
char strTimeOut[21];// 0-用来转化为负数
LocalTime(strTimeOut, "yyyy-mm-dd hh24:mi:ss", 0-(int)(atof(argv[3])*24*60*60));
//这里一定要是这种格式的时间(一共就封装了两种缺省,要改自己加源文件)

CDir Dir;
// 打开目录,CDir.OpenDir()
if (Dir.OpenDir(argv[1], argv[2], 10000, true) == false){
printf("Dir.OpenDir(%s) failed.\n", argv[1]);
return -1;
}
// 遍历目录中的文件名


char strCmd[1024]; // 存放gzip压缩文件的命令
while(true){

// 得到一个文件的信息,CDir.ReadDir()
if(Dir.ReadDir() == false) break;
// 与超时的时间点比较,如果更早,就需要压缩
// matchstr 用于判断一个字符串和另外一个字符串是否匹配,为自己封装

if((strcmp(Dir.m_ModifyTime, strTimeOut) < 0) && (MatchStr(Dir.m_FileName, "*.gz") == false)){
// 压缩文件,调用操作系统的gzip命令
// 可以使用execl execv 这里我们介绍新的命令system
// 大写的SNPRINTF函数和小写的sprintf功能是一样的,这样是封装成安全的

SNPRINTF(strCmd, sizeof(strCmd),1000, "/usr/bin/gzip -f %s 1>/dev/null 2>/dev/null", Dir.m_FullFileName);
if (system(strCmd) == 0)
printf("gzip %s ok.\n", Dir.m_FullFileName);
else
printf("gzip %s failed.\n", Dir.m_FullFileName);
}
}
return 0;
}

void EXIT(int sig){
printf("程序退出,sig=%d\n\n", sig);

exit(0);
}

清理历史数据文件

与压缩文件的实现流程,说白了完全相同,只有最后一步不同

  • 压缩文件是调用system执行一个操作系统命令
  • 删除文件是调用c语言的一个函数来删除

remove()image-20220416200255908

REMOVE()

删除不掉,会重复执行一两次删除命令(通常不超过三次,自己定义次数)

image-20220416200404662

RENAME()image-20220416200907915

优势:

  1. 如果不存在该文件名,会重复执行一两次改名命令(通常不超过三次,自己定义次数)
  2. 在以前不存在,rename就会失败,现在这个,失败了就用MKDIR创建文件,因此减少了很多麻烦

代码实现

仅与压缩在最后核心步骤,以及注解之类的存在差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include "_public.h"

// 程序退出和信号2、15的处理函数。
void EXIT(int sig);

int main(int argc,char *argv[]){
// 程序的帮助
if (argc != 4)
{
printf("\n");//pathname:扫描的目录 matchstr:需要处理这个目录下的什么文件 timeout:时间点,在此之前的会被压缩
printf("Using:/project/tools1/bin/deletefiles /gpathname matchstr timeout\n\n");

printf("Example:/project/tools1/bin/deletefiles /log/idc \"*.log.20*\" 0.02\n");
printf(" /project/tools1/bin/deletefiles /tmp/idc/surfdata \"*.xml,*.json\" 0.01\n");//下面两行表示需要由调度程序启动
printf(" /project/tools1/bin/procctl 300 /project/tools1/bin/deletefiles /log/idc \"*.log.20*\" 0.02\n");
printf(" /project/tools1/bin/procctl 300 /project/tools1/bin/deletefiles /tmp/idc/surfdata \"*.xml,*.json\" 0.01\n\n");

printf("这是一个工具程序,用于删除历史的数据文件或日志文件。\n");
printf("本程序把pathname目录及子目录中timeout天之前的匹配matchstr文件全部删除,timeout可以是小数。\n");
printf("本程序不写日志文件,也不会在控制台输出任何信息。\n\n\n");

return -1;
}
// 关闭全部的信号和输入输出
// 设置信号,在shell状态下可用 "kill + 进程号" 正常终止些进程。
// 但请不要用 "kill -9 +进程号" 强行终止。
CloseIOAndSignal(true); //一般来说开发阶段把这行注释掉,为了方便调试
signal(SIGINT, EXIT); signal(SIGTERM, EXIT);

// 获取文件超时的时间点(人为定义)
char strTimeOut[21];// 0-用来转化为负数
LocalTime(strTimeOut, "yyyy-mm-dd hh24:mi:ss", 0-(int)(atof(argv[3])*24*60*60));
//这里一定要是这种格式的时间(一共就封装了两种缺省,要改自己加源文件)

CDir Dir;
// 打开目录,CDir.OpenDir()
if (Dir.OpenDir(argv[1], argv[2], 10000, true) == false){
printf("Dir.OpenDir(%s) failed.\n", argv[1]);
return -1;
}
// 遍历目录中的文件名


char strCmd[1024]; // 存放gzip压缩文件的命令
while(true){

// 得到一个文件的信息,CDir.ReadDir()
if(Dir.ReadDir() == false) break;
// 与超时的时间点比较,如果更早,就需要删除

if(strcmp(Dir.m_ModifyTime, strTimeOut) < 0){
if (REMOVE(Dir.m_FullFileName) == true)
printf("REMOVE %s ok.\n", Dir.m_FullFileName);
else
printf("REMOVE %s failed.\n", Dir.m_FullFileName);
}
}
return 0;
}

void EXIT(int sig){
printf("程序退出,sig=%d\n\n", sig);

exit(0);
}

六、服务程序的运行策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
####################################################################
# 启动数据中心后台服务程序的脚本。
####################################################################

# 检查服务程序是否超时,配置在/etc/rc.local中由root用户执行。
#/project/tools1/bin/procctl 30 /project/tools1/bin/checkproc

# 压缩数据中心后台服务程序的备份日志。
/project/tools1/bin/procctl 300 /project/tools1/bin/gzipfiles /log/idc "*.log.20*" 0.02

# 生成用于测试的全国气象站点观测的分钟数据。
/project/tools1/bin/procctl 60 /project/idc1/bin/crtsurfdata /project/idc/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv

# 清理原始的全国气象站点观测的分钟数据目录/tmp/idc/surfdata中的历史数据文件。
/project/tools1/bin/procctl 300 /project/tools1/bin/deletefiles /tmp/idc/surfdata "*" 0.02

1
2
3
4
5
6
7
8
9
10
11
12
####################################################################
# 停止数据中心后台服务程序的脚本。
####################################################################

killall -9 procctl
killall gzipfiles crtsurfdata deletefiles


sleep 3

killall -9 gzipfiles crtsurfdata deletefiles

image-20220416202922360

注意:这些start,kill脚本,是跟项目,业务相关的,也就是说,我们一定要放在对应的idc1里面,不能乱 放

现在我们知道了如何在命令行启动脚本实现服务程序的运行调度

现在我们来看看如何在操作系统启动的时候把全部的服务程序运行起来

1
ls -l /etc/re.local

Linux下面启动程序的方法比较多,比如说采用系统服务,crontab,但是要启动项目,还是我们这种脚本的方式合适

检查服务程序是否超时

/project/tools/bin/procctl 30 /project/tools/bin/checkproc

启动数据中心的后台服务程序

设置开机自启脚本

1.在/etc/init.d/目录下编写脚本 名字任意

1
2
cd /etc/init.d
touch xxx.sh

复制成功

1
2
3
4
5
6
7
8
9
10
必须要添加 应该是为了设置 defaults的值 否则不成功#! /bin/sh
### BEGIN INIT INFO
# Provides: OnceDoc
# Required-Start: $network $remote_fs $local_fs
# Required-Stop: $network $remote_fs $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: start and stop node
# Description: OnceDoc
### END INIT INFO

添加完上面的文本之后在脚本里写下要自启动的命令,完成后保存。
3.给脚本添加执行权限
chmod 777 xxx.sh
4.添加进开机自启项
update-rc.d xxx.sh defaults number
这里的number是启动顺序,在某些情况下后有先后顺序的要求。

完成后重启。

*移除开机自启:update-rc.d -f xxx remove

调度程序由root启动,服务程序由jjyaoao启动image-20220416211120601

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"project01.sh"                                                                                                
#! /bin/sh
### BEGIN INIT INFO
# Provides: OnceDoc
# Required-Start: $network $remote_fs $local_fs
# Required-Stop: $network $remote_fs $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: start and stop node
# Description: OnceDoc
### END INIT INFO
# 检查服务程序是否超时
/project/tools/bin/procctl 30 /project/tools/bin/checkproc

# 启动数据中心的后台服务程序
su - jjyaoao -c "/bin/sh /project/idc1/c/start.sh"

第二板块-基于ftp协议的文件传输系统

image-20220417105514093

章节内容

一、ftp基础知识image-20220417105248022

二、ftp客户端封装image-20220417105325146

三、文件下载功能实现image-20220417105354106

四、文件上传功能实现image-20220417105422601

ftp协议是否过时?

网上有这么多的缺点例举…..并且说的也没错

image-20220417105552661

可这并不表示ftp就会被淘汰

image-20220417105704371

就好比,铁门坚固,还有密码,但也不可能取代普通木门一样

​ 适用场合不同

image-20220417105805859

ftp不需要考虑这么多的安全性,也不需要这么多的效率,只是用来实现文件的交换而已

技术和应用场景要放在一起讨论,只看缺点不看优点,是不合适的。

ftp查看状态

1
sudo /etc/init.d/vsftpd status

一、FTP基础知识

image-20220417113343345

FTP简介

image-20220417113611421

FTP工作原理

image-20220417114232963

控制连接在整个文件传送过程当中都是保持打开的,ftp客户发出的传送请求,都要通过客户的控制连接来发送给服务器端的控制进程,所以控制连接相当于正式连接之前的一个准备步骤,而数据连接才是文件传输过程中的实际连接

​ 服务器端的控制进程接收到客户端的数据传输请求后,才创建一个数据传送进程,并且创建数据连接

​ 由于控制连接和数据连接是区分开的,因此我们也说ftp的控制信息是带外传送的

image-20220417115416227

主动:服务器端接收到客户端的端口号,然后建立控制连接关系,服务器端主动告诉客户端,它自己的端口号,这样就是20.

被动:如果是建立联系之后,客户端向服务端发送命令,提出自己的需求,那么服务器端一般就会安排一个>1024端口号的端口来进行连接。

传输模式image-20220417115634740

二、ftp客户端封装

基本连接

在这里我采用的是,用root账户来连接jjyaoao账户,模拟远程访问的过程

https://github.com/codebrainz/ftplib

将上面的c代码进一步封装成cpp的库_ftp.h,_ftp.cpp

1
g++ -g -o ftpclient ftpclient.cpp /project/public/_ftp.cpp /project/public/_public.cpp -I/project/public -L/project/public -lftp -lm -lc

会遇到链接动态库问题=-=

linux环境变量LD_LIBRARY_PATH - 小 楼 一 夜 听 春 雨 - 博客园 (cnblogs.com)

image-20220417164155864

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "_ftp.h" 

Cftp ftp;

int main(){
if(ftp.login("192.168.211.130:21", "jjyaoao", "gh") == false){
printf("ftp.login(192.168.211.130:21) failed.\n");
return -1;
}
printf("ftp.login(192.168.211.130:21) ok.\n");

ftp.logout();

return 0;
}

测试size(),time()

image-20220417170528688image-20220417170503059

这里他的尺寸是对的,但是时间,=-=,我还不太清楚底层,感觉奇特

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "_ftp.h"//仅仅是在上一个的基础上增加了查找时间和尺寸的功能

Cftp ftp;

int main(){
if(ftp.login("192.168.211.130:21", "jjyaoao", "gh") == false){
printf("ftp.login(192.168.211.130:21) failed.\n");
return -1;
}
printf("ftp.login(191.168.211.130:21) ok.\n");

if(ftp.mtime("/project/public/socket/demo01.cpp") == false){
printf("ftp.mtime(/project/public/socket/demo01.cpp) failed.\n");
return -1;
}
printf("ftp.mtime(/project/public/socket/demo01.cpp) ok, mtime = %d.\n", ftp.m_mtime);

if(ftp.size("/project/public/socket/demo01.cpp") == false){
printf("ftp.size(/project/public/socket/demo01.cpp) failed.\n");
return -1;
}
printf("ftp.size(/project/public/socket/demo01.cpp) ok, size = %d.\n", ftp.m_size);

ftp.logout();

return 0;
}

测试nlist() image-20220417171422013

意思就是只列出子目录,和文件名,不会列出子目录中的文件名image-20220417171521986

将socket中的文件名,列到了bbb.lst清单里

image-20220417171542362

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "_ftp.h"

Cftp ftp;

int main(){
if(ftp.login("192.168.211.130:21", "jjyaoao", "gh") == false){
printf("ftp.login(192.168.211.130:21) failed.\n");
return -1;
}
printf("ftp.login(191.168.211.130:21) ok.\n");

if(ftp.mtime("/project/public/socket/demo01.cpp") == false){
printf("ftp.mtime(/project/public/socket/demo01.cpp) failed.\n");
return -1;
}
printf("ftp.mtime(/project/public/socket/demo01.cpp) ok, mtime = %d.\n", ftp.m_mtime);

if(ftp.size("/project/public/socket/demo01.cpp") == false){
printf("ftp.size(/project/public/socket/demo01.cpp) failed.\n");
return -1;
}
printf("ftp.size(/project/public/socket/demo01.cpp) ok, size = %d.\n", ftp.m_size);

if(ftp.nlist("/project/public/socket", "/aaa/bbb.lst") == false){
printf("ftp.nlist(/project/public/socket) failed.\n");
return -1;
}
printf("ftp.nlist(/project/public/socket) ok.\n");

ftp.logout();

return 0;
}

上传下载

get()具体细节

get函数也就是ftp的下载image-20220417171907924

​ 第一个参数,ftp服务器上的文件名,第二个参数,保存到本地想要采用的文件名,第三个参数,默认true,核对发送前后的时间,以便确保完整发送

​ 下载采用临时文件命名的方法,即后缀+ .tmp,完成后才正式改为localfilename(第二个参数)

image-20220417172237024image-20220417172318713

put()具体细节image-20220417172504145

第一个参数是本地待发送的文件的文件名,第二个参数是想要发送到ftp服务器上显示的文件名,第三个是核对本地与远程文件的大小是否相同image-20220417172624884

get/put差异

现在可能我们会有一个疑问,为什么上传的时候,采用的是核对文件的大小,下载却是核对文件的时间

​ 一个文件是否发生了变换,只能用文件的时间来判断,不能用文件的大小,比如说把文件aaa改成了文件bbb,大小是一样的,文件的时间就不一样了

​ 在上传文件的函数中,服务器上文件的时间,是上传这个动作,也就是调用FtpPut这个函数的时间,这个时间是没有意义的,我们可以保证本地的文件在上传中不会发生变换,所以只要比较服务器中最后收到的文件的大小和本地的文件大小相同就可以了

get/put测试

image-20220417173131294

这样,就在本地下载了_public.cpp, 在本地显示的名字是_public.cpp.bak

也将本地的ftpclient.cpp上传到了ftp服务器,ftp服务器中文件名为ftpclient.cpp.bak

三、文件下载功能实现image-20220418124554658

我们先思考第一步,从简单的功能出发,逐渐拓展到复杂的功能

makefile问题分析

实现之前,我们先编辑makefile文件,出现了一个问题image-20220418133804493

听着感觉,好像是libftp.a无法调用啥啥的,好像是个动态库?

我们先来看看.a文件是啥,之前听过,就是对开源框架的封装,将其多个.h.cpp封装到.a里?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
linux .o,.a,.so
.o,是目标文件,相当于windows中的.obj文件

  .so 为共享库,是shared object,用于动态连接的,相当于windows下的dll

  .a为静态库,是好多个.o合在一起,用于静态连接



静态函数库
特点:实际上是简单的普通目标文件的集合,在程序执行前就加入到目标程序中。
优点:可以用以前某些程序兼容;描述简单;允许程序员把程序link起来而不用重新编译代码,节省了重新编译代码的时间(该优势目前已不明显);开发者可以对源代码保密;理论上使用ELF格式的静态库函数生成的代码可以比使用共享或动态函数库的程序运行速度快(大概1%-5%)
生成:使用ar程序(archiver的缩写)。ar rcs my_lib.a f1.o f2.o是把目标代码f1.o和f2.o加入到my_lib.a这个函数库文件中(如果my_lib.a不存在则创建)
使用:用gcc生成可执行代码时,使用-l参数指定要加入的库函数。也可以用ld命令的-l和-L参数。

其次,我就去求助广大网友,最终通过三篇文章解决问题

(20条消息) CFLAGS详解_xinyuan0214的博客-CSDN博客_cflags编写makefile

[(20条消息) Resolve “`.rodata’ can not be used when making a PIE object; recompile with -fPIC”_如月灵的博客-CSDN博客](https://blog.csdn.net/hanyulongseucas/article/details/87715186)

(20条消息) “recompile with -fPIC” 错误,及c代码引用C++库_xinyu391的博客-CSDN博客

解决办法就是,在CFLAG宏里面加入 -no-pie

image-20220418134256088

目标一

目前,我们的目标是把服务器上某目录的文件全部下载到本地目录(可以指定文件名的匹配规则)

​ 那么,我们现在就要思考,我们需要传入什么参数,首先,肯定需要日志文件名,其次,ftp的服务器IP和端口,传输模式,FTP的用户名,密码,服务器存放文件的目录,本地存放文件的目录,下载文件名匹配的规则,都是我们需要的,这样数一数,就已经八个参数了,难道我们要一一对应匹配输入吗?我们说这是不现实的,因此,我们采用一种新的方法,使用xml格式,一方面方便拓展,二方面不会输错,考虑顺序之类的

​ 因此,我们现在只需要两个参数,一个是日志文件名(自定义),另外一个就是这个xml格式的文档,将内容放进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "_public.h"
#include "_ftp.h"

CLogFile logfile;
Cftp ftp;

// 程序退出和信号2、15的处理函数
void EXIT(int sig);

void _help();

int main(int argc, char *argv[]){
// 小目标,把ftp服务上某目录中的文件下载到本地的目录中
if(argc != 3){
_help();//帮助文档
return -1;
}
// 处理程序的退出信号,和别的程序一样...
// 打开日志文件
// 解析xml,得到程序运行的参数
// 登录ftp服务器
// 进入ftp服务器存放文件的目录
// 调用ftp.nlist()方法列出服务器目录中的文件,结果存放到本地文件中。
// 把ftp.nlist()方法获取到的list文件加载到容器vfilelist中
// 遍历容器vfilelist
for(int i = 0; i < vlistfile.size(); i++){
// 调用ftp.get()方法从服务器下载文件。
}

ftp.logout();
return 0;
}

void EXIT(int sig){
printf("程序退出, sig = %d\n\n", sig);
exit(0);
}

打开日志文件

虽然和别的之前的也一样,不过这里我忍不住提一句,由于水平上升了,才有机会在这个地方咬文嚼字。

a+,w+,r+到底是什么意思,知道是文件的读写方式,不过今天去了解了更多

  • r:以只读的方式打开文本文件,文件必须存在;

  • w:以只写的方式打开文本文件,文件若存在则清空文件内容文件头部开始写,若不存在则根据文件名创建新文件并只写打开;

  • a:以只写的方式打开文本文件,文件若存在则从文件尾部以追加的方式开始写,文件原来存在的内容不会清除除了文件尾标志EOF),若不存在则根据文件名创建新文件并只写打开;

  • r+:以可读写的方式打开文本文件,文件必须存在;

  • w+:以可读写的方式打开文本文件,其他与w一样;

  • a+:以可读写的方式打开文本文件,其他与a一样;

  • 若打开二进制文件,可在后面加个b注明,其他一样,如rb,r+b(或rb+)。

解析xml

使用xml,就需要先解析他,开发框架中,有解析的函数image-20220418142840573

如果第三个参数是字符串,可以用第四个参数指定字符串的长度,缺省为0,表示不限长度,不限长度就一定要确保value数组空间足够大,否则发生内存溢出的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 把xml解析到参数starg结构中。
bool _xmltoarg(char *strxmlbuffer)
{
memset(&starg,0,sizeof(struct st_arg));

GetXMLBuffer(strxmlbuffer,"host",starg.host,30); // 远程服务器的IP和端口。
if (strlen(starg.host)==0)
{ logfile.Write("host is null.\n"); return false; }

GetXMLBuffer(strxmlbuffer,"mode",&starg.mode); // 传输模式,1-被动模式,2-主动模式,缺省采用被动模式。
if (starg.mode!=2) starg.mode=1;

GetXMLBuffer(strxmlbuffer,"username",starg.username,30); // 远程服务器ftp的用户名。
if (strlen(starg.username)==0)
{ logfile.Write("username is null.\n"); return false; }

GetXMLBuffer(strxmlbuffer,"password",starg.password,30); // 远程服务器ftp的密码。
if (strlen(starg.password)==0)
{ logfile.Write("password is null.\n"); return false; }

GetXMLBuffer(strxmlbuffer,"remotepath",starg.remotepath,300); // 远程服务器存放文件的目录。
if (strlen(starg.remotepath)==0)
{ logfile.Write("remotepath is null.\n"); return false; }

GetXMLBuffer(strxmlbuffer,"localpath",starg.localpath,300); // 本地文件存放的目录。
if (strlen(starg.localpath)==0)
{ logfile.Write("localpath is null.\n"); return false; }

GetXMLBuffer(strxmlbuffer,"matchname",starg.matchname,100); // 待下载文件匹配的规则。
if (strlen(starg.matchname)==0)
{ logfile.Write("matchname is null.\n"); return false; }

return true;
}
1
"<host>127.0.0.1:21</host><mode>1</mode><username>jjyaoao</username><password>gh</password><localpath>/idcdata/surfdata</localpath><remotepath>/tmp/idc/surfdata</remotepath><matchname>SURF_ZH*.XML,SURF_ZH*.CSV</matchname><listfilename>/idc/data/ftplist/ftpgetfiles_surfdata.list<listfilename>"

存放目录的细节

有两种写代码的方式:

image-20220419110211202image-20220419110314999

image-20220419110401543image-20220419110413530

那我们来想一想,是返回全部路径好,还是相对路径好呢?

答案是:相对路径(只返回文件名)好

有以下几点原因:

  1. 如果加上绝对路径,则会增加相当一部分没有必要的带宽,从而加重网络的负担image-20220419110526451
  2. 我们调用这个方法,已经往里面传入了保存文件的路径,也就是/tmp/idc/surfdata为我们已经知道的东西,再把它写入,是没有必要的举措

所以,我们先通过chdir函数,进入starg.remotepath(目录),再nlist得到当前目录的所有文件+目录名,返回是最好的举措。

1
2
/project/tools1/bin/procctl 60 /project/idc1/bin/crtsurfdata /project/idc/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv

1
/project/tools1/bin/procctl 60 /project/idc1/bin/crtsurfdata /project/idc1/ini/stcode.ini /tmp/idc/surfdata /log/idc/crtsurfdata.log xml,json,csv

下载文件的一个细节

踩得大坑,还好自己把他调出来了image-20220419162726863

百思不得其解,这咋失败了image-20220419162812307

原来是对细节把握的不到位,有了路径,和文件名,中间得加 / 呀=-=我还以为/是啥新的特殊用法,结果=-=,悟了误了image-20220419162949998

目标扩充

但是在,实际应用过程中,文件下载功能,不会这么简单……

​ 会有更多的需求。image-20220419163321742

删除/备份文件

​ 删除文件十分简单,只需要调用ftp的一个方法,下载文件并备份就稍微复杂一点点,需要额外多一个备份目录,并使用strremotefilenamebak来暂存新的目录名加文件名,然后再运用ftprename函数,对strremotefilename改名,改成这个xxxxxbak,就ok了image-20220419194001015

增量下载文件

相对困难的是增量下载文件,我们先来理解一下这个过程。

​ 首先需要四个vector容器,第个容器,存放已成功下载的文件,程序第一次运行的时候,这个容器肯定是空的,第个容器,存放nlist返回的结果,也就是当前服务端的文件,第二个容器中有,第一个容器中没有的文件,放在第四个文件(待下载),第二个容器中有,第一个容器中也有的放在第三个容器(不需要下载的,方便区分?)

第一个状态:服务端五个文件,客户端没有

image-20220419194354991image-20220419194416782

通过上面规则对比,我们得到第二个状态:image-20220419194508167image-20220419194559596

第二次运行时的初始状态如下:可能这个时候你就会问了,为什么服务段的1,2不在了呢?这很简单,因为服务端也要清理历史文件嘛,不然文件越堆越多image-20220419194653375image-20220419194824387

然后程序把6,7下载下来,3和4不需要下载image-20220419195255270

第三次运行:可能我们现在会对第一个容器产生疑问,我们只需要思考一下,连服务端都没有一和二了,那么客户端还有没有必要保留一和二呢?这就好比游戏,服务端开发已经取消了一个功能,那么制作GUI的客户端自然可以把那个功能相应的按键都删除掉,虽然保留按键也没算错,但这样会让容器变大,让客户以为还有那个功能,这实在是没有必要的。image-20220419195345235

以下是处理细节:PS:ptype == 1 即为访问,然后什么都不做,不删除也不备份image-20220419201524280

​ 注意,这个意思就是,已下载的3,就是下一次的已下载1,待下载4,就是这一次需要下载的内容,由于我们之前nlist得到的,是存放在vlistfile2里面,所以,为了和后面遍历vlistfile2,读取,下载的过程兼容,我们把待下载容器4里面的内容放入容器2里面,从而使得后面的代码不用修改!

我们把这四步拆解,第一步一个函数LoadOKFile() 第二步CompVector() 第三步WriteToOKFile() 第四步直接

1
vlistfile2.clear();   vlistfile2.swap(vlistfile4);

另外,由于我们第三步,已经更新了OKFile,因此,我们还需要加入一条函数语句,当ptype == 1时,把下载成功的文件记录追加到okfilename文件中去,下面这个是执行的命令

1
/project/tools1/bin/ftpgetfiles /log/idc/ftpgetfiles_surfdata.log "<host>127.0.0.1:21</host><mode>1</mode><username>jjyaoao</username><password>gh</password><localpath>/tmp/client</localpath><remotepath>/tmp/server</remotepath><matchname>*.txt</matchname><listfilename>/idc/data/ftplist/ftpgetfiles_surfdata.list</listfilename><ptype>1</ptype><remotepathbak>/tmp/idc/surfdatabak</remotepathbak><okfilename>/idcdata/ftplist/ftpgetfiles_surfdata.xml</okfilename>" 

313行有问题=-=,现在已经解决,原来是早在167行xmltoarg时,由于copy了一些代码,所以一次性没改完,也就是getxmlbuffer函数的第三个参数我居然还是写的上一个starg.remotepathbak,真是大意了┭┮﹏┭┮image-20220420191910256

增量+修改下载文件

上面已经实现了,仅仅包括新增这种情况,对应应该修改的服务端、客户端文件目录,接下来,我们再包含,加上修改文件内容以后,我们应该考虑的目录情况image-20220420192243227

​ 简而言之,程序的算法是一样的,但是需要把程序的时间考虑进去 5:50

修改结构体st_arg,加入bool checkmtime,修改帮助文档,修改解析xmltoarg,接着,跟着主函数一步一步看哪里需修改,第一处就是image-20220420194436923

第二处………………….将仅仅解析文件名称,变为解析时间和名称image-20220420194526053

1
/project/tools1/bin/ftpgetfiles /log/idc/ftpgetfiles_surfdata.log "<host>127.0.0.1:21</host><mode>1</mode><username>jjyaoao</username><password>gh</password><localpath>/tmp/client</localpath><remotepath>/tmp/server</remotepath><matchname>*.txt</matchname><listfilename>/idc/data/ftplist/ftpgetfiles_surfdata.list</listfilename><ptype>1</ptype><remotepathbak>/tmp/idc/surfdatabak</remotepathbak><okfilename>/idcdata/ftplist/ftpgetfiles_surfdata.xml</okfilename><checkmtime>true</checkmtime>"

于是现在又遇到了新的问题,=-=顺利解决,第一个就是上面这个框框最后那个checkmtime没有反斜线,最开始,被我日志debug找到咯image-20220420204726825

后面又遇到一个新的问题,他无法做到更新时间,始终要取得所有的txt文件,于是我发现我compare这个函数里面敲错了,=-=image-20220420204812878

最开始没加这个 == 0,不过好在已经发现,越来越理解项目了!

接下来,我们测试了true,理所应该也要测试测试false,因为这已经是最终版本了,最后测试显示,成功实现功能

1
2
3
4
5
6
7
8
9
10
11
具体测试步骤:
mkdir /tmp/server
touch /tmp/server/1.txt
touch /tmp/server/2.txt
touch /tmp/server/3.txt
touch /tmp/server/4.txt
touch /tmp/server/5.txt
mkdir /tmp/client
第二次测试,,删除1.txt,新增6.txt,修改2.txt
即可看到结果,(checkmtime)true的话,会重新加载6和2 我们进log就可以看到
false的话,只会重新加载6

收尾工作

这个程序是一个网络的客户端程序,这种程序一定会挂死,不知道什么时候挂死,所以一定要做进程的心跳image-20220421100846841

那些程序会挂死?

image-20220421101051845

为了解决这个问题我们继续引入进程心跳机制……………….image-20220421101238278

​ 我们上一章写的心跳,包括了超时时间,进程名,日志名,但是网络的超时时间难以估计,通常看网络状态,网络好就填小点,网络差就填久一点,也有可能同一个程序启动多个心跳的情况(多个文件下载的任务)

​ 所以我们之前的运行参数结构体,还需要加上超时时间和进程名两个参数,接着改动需要加入这两个参数的地方,例如帮助文档,xmltoarg之类,最后扫描一遍项目流程,将可能会消耗时间超时的地方全部都updatetime,不用担心会不会超时的问题,因为本来就一个赋值语句,相比于没有记录到超时位置而言,显然这个浪费是可以接受的

​ 最后调试运行,并且把多年前那个调度程序无法启动的bug找到了=-=原因是之前配置clion的时候,因为有idc的存在,而导致无法编译(数据库相关还未配置)所以。。。,现在找到了就好啦!

四、文件上传的功能image-20220421112614027

知道了文件下载功能的实现之后,文件上传就变得十分简单。技术流程完全一样,只是有一些细节会发生变化

上传 / 下载的步骤对比image-20220421112739681

先把上次已成功上传的文件加载到容器一,然后用dir2获取本地的文件列表,得到容器二,再把容器二与容器一进行对比,不需要上传的文件放到容器三,需要上传的放到容器四

image-20220421112753498

上传和下载的不同:

  1. 想要得到文件的目录,在下载,需要分三步走,上传,只需要dir一个指令即可image-20220421113515211

  2. listfilename不需要了,remotepathbak改为localpathbak,checkmtime不需要了(检查服务端文件的时间)原因有如下

    • checkmtime里面while循环中有这句,意味着程序每运行一次,都需要把服务端目录中全部的时间取回来,如果服务端的目录很多,取时间这个动作要消耗大量的资源,包括客户端等待的时间,网络带宽,还有对服务端造成的压力,如果服务端中的目录不会更新,就应该把checkmtime设置为false,在文件下载的过程中,checkmtime的取值对性能和服务端的压力有很大的影响,但在上传的过程中checkmtime对程序的性能不会有任何的影响(没有任何代价),所以干脆就不要了image-20220421114104282

    LoadLocalFile的openDir也有一些问题,他的缺省值是获取10000个文件

image-20220421131332675

但这里使用的是我们自己的目录(本地目录),所以可以配置脚本来清理,一般就不会有这个问题

测试:

1
/project/tools1/bin/ftpputfiles /log/idc/ftpputfiles_surfdata.log "<host>127.0.0.1:21</host><mode>1</mode><username>jjyaoao</username><password>gh</password><localpath>/tmp/client</localpath><remotepath>/tmp/server</remotepath><matchname>*.txt</matchname><ptype>1</ptype><localpathbak>/tmp/idc/surfdatabak</localpathbak><okfilename>/idcdata/ftplist/ftpgetfiles_surfdata.xml</okfilename><timeout>80</timeout><pname>ftpgetfiles_surfdata</pname>"

给client 1 2 3 4 5.txt 然后开测就好

最后配置start.sh 和killall.sh脚本 我们就已经有三个主要的程序在运行了,生成数据程序,下载程序,上传程序image-20220421143356108

又一个权限小细节

好家伙!!不给权限就失败是吧!image-20220421144136741

给了秒OK,就TM离谱哦,下次果然还是用jjyaoao给root发信号吧!!!!!

学习总结

image-20220421144830124

  • image-20220421145034783
  • 由于应用场景,每个业务系统的负责人的不同的,我们肯定不能在别人的业务系统上创建目录,这样很可能导致被人甩锅
  • 在ftp服务端创建目录会影响效率
    • 试探打开,不行就创建他,这些没什么代价image-20220421145051938image-20220421145145690
    • 但在ftp服务端就不一样,每执行一次命令,就需要进行一次网络报文的传输

image-20220421145243643

  • image-20220421145333271
  • ftp.get()设置为false,是因为我们还有checkmtime存在,如果服务端的参数会改变,我们把checkmtime设置为true就可以实现重传

第三板块-基于TCP协议的文件传输系统

image-20220421205411572

​ 其实根本没咋过掌握,直接就是一个,裸开TCP!!!!冲冲冲

将socket的常用函数,进行封装,变成更佳好用的工具image-20220421205506867

image-20220421205615924

速度非常快,比FTP快很多倍image-20220421205647866

一、socket基础知识学习

[Socket (一) 基础及接口函数 - Jack王 - 博客园 (cnblogs.com)](https://www.cnblogs.com/blogwww/p/9499811.html#:~:text=socket ()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。,connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。 客户端通过调用connect函数来建立与TCP服务器的连接。)

 Socket,又称为套接字,Socket是计算机网络通信的基本的技术之一。如今大多数基于网络的软件,如浏览器,即时通讯工具甚至是P2P下载都是基于Socket实现的。本篇会介绍一下基于TCP/IP的Socket编程,并且如何写一个客户端/服务器程序。

1.1 背景介绍

  Unix的输入输出(IO)系统遵循Open-Read-Write-Close这样的操作范本。当一个用户进程进行IO操作之前,它需要调用Open来指定并获取待操作文件或设备读取或写入的权限。一旦IO操作对象被打开,那么这个用户进程可以对这个对象进行一次或多次的读取或写入操作。Read操作用来从IO操作对象读取数据,并将数据传递给用户进程。Write操作用来将用户进程中的数据传递(写入)到IO操作对象。 当所有的Read和Write操作结束之后,用户进程需要调用Close来通知系统其完成对IO对象的使用。
  在Unix开始支持进程间通信(InterProcess Communication,简称IPC)时,IPC的接口就设计得类似文件IO操作接口。在Unix中,一个进程会有一套可以进行读取写入的IO描述符。IO描述符可以是文件,设备或者是通信通道(socket套接字)。一个文件描述符由三部分组成:创建(打开socket),读取写入数据(接受和发送到socket)还有销毁(关闭socket)。
  在Unix系统中,类BSD版本的IPC接口是作为TCP和UDP协议之上的一层进行实现的。消息的目的地使用socket地址来表示。一个socket地址是由网络地址和端口号组成的通信标识符。
  进程间通信操作需要一对儿socket。进程间通信通过在一个进程中的一个socket与另一个进程中得另一个socket进行数据传输来完成。当一个消息执行发出后,这个消息在发送端的socket中处于排队状态,直到下层的网络协议将这些消息发送出去。当消息到达接收端的socket后,其也会处于排队状态,直到接收端的进程对这条消息进行了接收处理。

1.2 TCP和UDP通信
  关于socket编程我们有两种通信协议可以进行选择。一种是数据报通信,另一种就是流通信。
  1.2.1 数据报通信
  数据报通信协议,就是我们常说的UDP(User Data Protocol 用户数据报协议)。UDP是一种无连接的协议,这就意味着我们每次发送数据报时,需要同时发送本机的socket描述符和接收端的socket描述符。因此,我们在每次通信时都需要发送额外的数据。
  1.2.2 流通信
  流通信协议,也叫做TCP(Transfer Control Protocol,传输控制协议)。和UDP不同,TCP是一种基于连接的协议。在使用流通信之前,我们必须在通信的一对儿socket之间建立连接。其中一个socket作为服务器进行监听连接请求。另一个则作为客户端进行连接请求。一旦两个socket建立好了连接,他们可以单向或双向进行数据传输。
  我们进行socket编程使用UDP还是TCP呢。选择基于何种协议的socket编程取决于你的具体的客户端-服务器端程序的应用场景。下面我们简单分析一下TCP和UDP协议的区别
  在UDP中,每次发送数据报时,需要附带上本机的socket描述符和接收端的socket描述符。而由于TCP是基于连接的协议,在通信的socket对之间需要在通信之前建立连接,因此会有建立连接这一耗时存在于TCP协议的socket编程。
  在UDP中,数据报数据在大小上有64KB的限制。而TCP中也不存在这样的限制。一旦TCP通信的socket对建立了连接,他们之间的通信就类似IO流,所有的数据会按照接受时的顺序读取。
  UDP是一种不可靠的协议,发送的数据报不一定会按照其发送顺序被接收端的socket接受。然后TCP是一种可靠的协议。接收端收到的包的顺序和包在发送端的顺序是一致的。
  简而言之,TCP适合于诸如远程登录(rlogin,telnet)和文件传输(FTP)这类的网络服务。因为这些需要传输的数据的大小不确定。而UDP相比TCP更加简单轻量一些。UDP用来实现实时性较高或者丢包不重要的一些服务。在局域网中UDP的丢包率都相对比较低。

1.3 C 中的 Socket 编程

   说白了Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。  

  在使用Socket API编程时,需要重点先了解几个API,包括:socket()、bind()、connect()、listen()、accept()、send()和recv()、sendto()和recvfrom()、close()和shutdown()、getpeername()、gethostname()。这些接口是在Winsock2.h 中定义的不是在 MFC 中定义的,只需包含 Winsock2.h 头文件和 Ws2_32.lib 库就可以了。

   服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。

1.3.1 客户端/服务端模式:

  在TCP/IP网络应用中,通信的两个进程相互作用的主要模式是客户/服务器模式,即客户端向服务器发出请求,服务器接收请求后,提供相应的服务。
  服务端:建立socket,声明自身的端口号和地址并绑定到socket,使用listen打开监听,然后不断用accept去查看是否有连接,如果有,捕获socket,并通过recv获取消息的内容,通信完成后调用closeSocket关闭这个对应accept到的socket,如果不再需要等待任何客户端连接,那么用closeSocket关闭掉自身的socket。
  客户端:建立socket,通过端口号和地址确定目标服务器,使用Connect连接到服务器,send发送消息,等待处理,通信完成后调用closeSocket关闭socket。

1.3.2 编程步骤
  (1)服务端
    加载套接字库,创建套接字(WSAStartup()/socket());
    绑定套接字到一个IP地址和一个端口上(bind());
    将套接字设置为监听模式等待连接请求(listen());
    请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字(accept());
    用返回的套接字和客户端进行通信(send()/recv());
    返回,等待另一个连接请求;
    关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup());
  (2)客户端
    加载套接字库,创建套接字(WSAStartup()/socket());
    向服务器发出连接请求(connect());
    和服务器进行通信(send()/recv());
    关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup());

1.4 函数详解

1.4.1 socket()函数
  int socket(int protofamily, int type, int protocol);//返回sockfd

  sockfd是描述符。socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
创建socket的时候,可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
  protofamily:即协议域,又称为协议族(family)。常用的协议族有,AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等。
  protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议(这个协议我将会单独开篇讨论!)。
  注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
  当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

1.4.2 bind()函数
  正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
  int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  函数的三个参数分别为:
  sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。
  addrlen:对应的是地址的长度。
  通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

1.4.3 listen()、connect()函数
  如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
  int listen(int sockfd, int backlog);
  int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
  connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。

1.4.4 accept()函数
  TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
  int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //返回连接connect_fd
  参数sockfd
  参数sockfd就是上面解释中的监听套接字,这个套接字用来监听一个端口,当有一个客户与服务器连接时,它使用这个一个端口号,而此时这个端口号正与这个套接字关联。当然客户不知道套接字这些细节,它只知道一个地址和一个端口号。
  参数addr
  这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。
  参数len

  它也是结果的参数,用来接受上述addr的结构的大小的,它指明addr结构所占有的字节个数。同样的,它也可以被设置为NULL。
  如果accept成功返回,则服务器与客户已经正确建立连接了,此时服务器通过accept返回的套接字来完成与客户的通信。
注意:
accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字,这个套接字是连接套接字。
此时我们需要区分两种套接字,
监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)
连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。

  一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
自然要问的是:为什么要有两种套接字?原因很简单,如果使用一个描述字的话,那么它的功能太多,使得使用很不直观,同时在内核确实产生了一个这样的新的描述字。
  连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号

1.4.5 read()、write()等函数
  万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信!网络I/O操作有下面几组:
  read()/write();recv()/send();readv()/writev();recvmsg()/sendmsg();recvfrom()/sendto()

  int send( SOCKET s, const char FAR *buf, int len,int flags);
  不论是客户还是服务器应用程序都用send函数来向TCP连接的另一端发送数据。
  客户程序一般用send函数向服务器发送请求,而服务器则通常用send函数来向客户程序发送应答。
  该函数的第一个参数指定发送端套接字描述符;第二个参数指明一个存放应用程序要发送数据的缓冲区;第三个参数指明实际要发送的数据的字节数;第四个参数一般置0。
  int recv( SOCKET s, char FAR *buf, int len,int flags);
  不论是客户还是服务器应用程序都用recv函数从TCP连接的另一端接收数据。
  该函数的第一个参数指定接收端套接字描述符;第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;第三个参数指明buf的长度;第四个参数一般置0。

1.4.6 close()函数
  在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
  #include <unistd.h>
  int close(int fd);
  close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
  注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

二、封装socket的API

  • 解决了TCP报文粘包/分包的问题
  • 封装socket的常用函数

粘包和分包

image-20220421211036969

TCP Socket的粘包和分包的处理 - 不懂123 - 博客园 (cnblogs.com)

概述

在进行TCP Socket开发时,都需要处理数据包粘包和分包的情况.实际上解决该问题很简单,在应用层下,定义一个协议:消息头部+消息长度+消息正文即可。

分包和粘包

​ 分包:发送方发送字符串”helloworld”,接收方却接收到了两个字符串”hello”和”world”

​ 粘包:发送方发送两个字符串”hello”+”world”,接收方却一次性接收到了”helloworld”

socket环境有以上问题,但是TCP传输数据能保证几点:

​ \1. 顺序不变,例如发送方发送hello,接收方也一定顺序接收到hello,这个是TCP协议承诺的,因此这点成为我们解决分包,黏包问题的关键.

​ \2. 分割的包中间不会插入其他数据

因此如果要使用socket通信,就一定要自己定义一份协议.目前最常用的协议标准是:消息头部(包头)+消息长度+消息正文

TCP分包的原理

TCP是以(Segment)为单位发送数据的,建立TCP链接后,有一个最大消息长度(MSS).如果应用层数据包超过MSS,就会把应用层数据包拆分,分成两个段来发送.

这个时候接收端的应用层要拼接这两个TCP包,才能正确处理数据。

相关的,路由器有一个MTU( 最大传输单元)一般是1500字节,除去IP头部20字节,留给TCP的就只有MTU-20字节。所以一般TCP的MSS为MTU-20=1460字节

当应用层数据超过1460字节时,TCP会分多个数据包来发送

TCP粘包的原理

TCP为了提高网络的利用率,会使用一个叫做Nagle的算法.该算法是指,发送端即使有要发送的数据,如果很少的话,会延迟发送.

​ 如果应用层给TCP传送数据很快的话,就会把两个应用层数据包“粘”在一起,TCP最后只发一个TCP数据包给接收端.

小结

​ 我们可以看见TCP发出去才会出现分包(接受数据那是应用层的事,我们默认应用层发送过来的都是完整数据呗),而粘包会出现在TCP接受数据和发送数据,分别是不同的原因

TCP的分包与粘包原理的简单理解

TCP的分包与粘包原理简单理解 - 掘金 (juejin.cn)

1,首先我们来看看粘包的图解:如下图:

img

为什么会出现粘包

假如说,我们要发送两个hello数据,一个hello占5个,TCP假如一次性传输能存10个。当第一个hello存进TCP的缓存区里面时,没有存满,还剩下5个空位,这时第二个hello过来,刚好占满剩下的5个,然后这两个hello就粘在一起了,变成hellohello了。

2,再来看看分包的图解:如下图:

img

img

为什么会出现分包

假如说,我们要发送两个hello,一个hello要占领5个空位。但是TCP的一个包只有4个空位,。这时第一个hello传过来,只存了hell,剩下的e被分到下一个包存储,所以就成了分包。

3,在哪种情况下会出现分包与粘包:

  • 1,要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生分包。
  • 2,待发送数据大于MSS(最大报文长度),TCP在传输前将进行分包。
  • 3,要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  • 4,接收数据端的应用层****没及时读取接收缓冲区中的数据,将发生粘包

自定义协议

解决粘包/分包常用方式:

两种方式

  • 1,定义数据包包头,包头众包含数据完整包的长度,接收端接收到数据后,通过读取包头的长度字段,便知道每一个数据包的实际长度了。

img

比如说,将原数据加密,在密文前面加上包头,即:[包头]+[密文]。 包头=[密文长度+加密方式+…]

  • 2,数据包之间设置边界

img

粘包实践

/project/public/socket中的demo03(客户端)和demo04(服务端)image-20220421213304334

TCP协议的保证image-20220421213504042

解决粘包方案

image-20220421213538464

采用ASCII码

在实际开发一般不采用,因为有一个问题,当报文的内容超过四个9的时候,四个字节就存不下了,用整型变量存放报文长度就不会存在这个问题

采用整型

image-20220421213738372

在开发框架中,tcpwrite和tcpread解决了这两个问题(粘包分包)

TcpWrite()/TcpRead()

TcpWrite和TcpRead的使用一定要成双成对的,也就是协议需要大家一起来遵守

image-20220421214301425

TcpWrite()

​ 我们看这几行代码,注意我们发送缓冲区数据是采用的封装的Writen函数,而不是send(c自带),原因就是因为socket有缓冲区,读和写两个缓冲区,并且大小有限,如果这时候的写缓冲区快满了,还有五百字节可以用,调用send函数,只能成功写入500字节,剩下的要等缓冲区空闲了才能再次写入。Writen循环调用send函数,直到全部数据被成功的发送,返回true,如果发送过程中tcp断开了或者其他原因,返回falseimage-20220421222419772

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 向socket的对端发送数据。
// sockfd:可用的socket连接。
// buffer:待发送数据缓冲区的地址。
// ibuflen:待发送数据的字节数,如果发送的是ascii字符串,ibuflen填0或字符串的长度,
// 如果是二进制流数据,ibuflen为二进制数据块的大小。
// 返回值:true-成功;false-失败,如果失败,表示socket连接已不可用。
bool TcpWrite(const int sockfd,const char *buffer,const int ibuflen)
{
if (sockfd==-1) return false;

int ilen=0; // 报文长度。

// 如果ibuflen==0,就认为需要发送的是字符串,报文长度为字符串的长度。
if (ibuflen==0) ilen=strlen(buffer);
else ilen=ibuflen;

int ilenn=htonl(ilen); // 把报文长度转换为网络字节序。

char TBuffer[ilen+4]; // 发送缓冲区。
memset(TBuffer,0,sizeof(TBuffer)); // 清区发送缓冲区。
memcpy(TBuffer,&ilenn,4); // 把报文长度拷贝到缓冲区。
memcpy(TBuffer+4,buffer,ilen); // 把报文内容拷贝到缓冲区。

// 发送缓冲区中的数据。
if (Writen(sockfd,TBuffer,ilen+4) == false) return false;

return true;
}
Writen()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 向已经准备好的socket中写入数据。
// sockfd:已经准备好的socket连接。
// buffer:待发送数据缓冲区的地址。
// n:待发送数据的字节数。
// 返回值:成功发送完n字节的数据后返回true,socket连接不可用返回false。
bool Writen(const int sockfd,const char *buffer,const size_t n)
{
int nLeft=n; // 剩余需要写入的字节数。
int idx=0; // 已成功写入的字节数。
int nwritten; // 每次调用send()函数写入的字节数。

while(nLeft > 0 )
{
if ( (nwritten=send(sockfd,buffer+idx,nLeft,0)) <= 0) return false;

nLeft=nLeft-nwritten;
idx=idx+nwritten;
}

return true;
}
TcpRead()

​ 先不管超时时间,我们来梳理一下过程,把传进来的字节数初始化为0(注意用了解引用*)意思是我们把这个传进来的int型的地址里面的内容改为了0,接着使用Readn()接受sockfd中的内容,用ibuflen来当做容器接受好像不一定必须转为char,但反正recv底层接受的是void*,最后,在将报文长度的 网络字节序 用ntohl转换为主机字节序,最后在buffer接受得到报文的实际内容,同样用readn()函数,读sockfd里面的,读取的长度设置为ibuflen的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 接收socket的对端发送过来的数据。
// sockfd:可用的socket连接。
// buffer:接收数据缓冲区的地址。
// ibuflen:本次成功接收数据的字节数。
// itimeout:接收等待超时的时间,单位:秒,-1-不等待;0-无限等待;>0-等待的秒数。
// 返回值:true-成功;false-失败,失败有两种情况:1)等待超时;2)socket连接已不可用。
bool TcpRead(const int sockfd,char *buffer,int *ibuflen,const int itimeout)
{
if (sockfd==-1) return false;

// 如果itimeout>0,表示需要等待itimeout秒,如果itimeout秒后还没有数据到达,返回false。
if (itimeout>0)
{
struct pollfd fds;
fds.fd=sockfd;
fds.events=POLLIN;
if ( poll(&fds,1,itimeout*1000) <= 0 ) return false;
}

// 如果itimeout==-1,表示不等待,立即判断socket的缓冲区中是否有数据,如果没有,返回false。
if (itimeout==-1)
{
struct pollfd fds;
fds.fd=sockfd;
fds.events=POLLIN;
if ( poll(&fds,1,0) <= 0 ) return false;
}

(*ibuflen) = 0; // 报文长度变量初始化为0。

// 先读取报文长度,4个字节。
if (Readn(sockfd,(char*)ibuflen,4) == false) return false;

(*ibuflen)=ntohl(*ibuflen); // 把报文长度由网络字节序转换为主机字节序。

// 再读取报文内容。
if (Readn(sockfd,buffer,(*ibuflen)) == false) return false;

return true;
}
Readn()

函数反复调用recv,如果读取的过程中接收到n个子杰,函数返回true,如果读写的过程中发送了意外,比如说:连接被断开,那么就返回false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 从已经准备好的socket中读取数据。
// sockfd:已经准备好的socket连接。
// buffer:接收数据缓冲区的地址。
// n:本次接收数据的字节数。
// 返回值:成功接收到n字节的数据后返回true,socket连接不可用返回false。
bool Readn(const int sockfd,char *buffer,const size_t n)
{
int nLeft=n; // 剩余需要读取的字节数。
int idx=0; // 已成功读取的字节数。
int nread; // 每次调用recv()函数读到的字节数。

while(nLeft > 0)
{
if ( (nread=recv(sockfd,buffer+idx,nLeft,0)) <= 0) return false;

idx=idx+nread;
nLeft=nLeft-nread;
}

return true;
}

socket封装函数

我们先来看看一组对比:下面这个是没有用socket封装的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[])
{
if (argc!=3)
{
printf("Using:./demo01 ip port\nExample:./demo01 127.0.0.1 5005\n\n"); return -1;
}

// 第1步:创建客户端的socket。
int sockfd;
if ( (sockfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); return -1; }

// 第2步:向服务器发起连接请求。
struct hostent* h;
if ( (h = gethostbyname(argv[1])) == 0 ) // 指定服务端的ip地址。
{ printf("gethostbyname failed.\n"); close(sockfd); return -1; }
struct sockaddr_in servaddr;
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2])); // 指定服务端的通讯端口。
memcpy(&servaddr.sin_addr,h->h_addr,h->h_length);

if (connect(sockfd, (struct sockaddr *)&servaddr,sizeof(servaddr)) != 0) // 向服务端发起连接清求。
{
perror("connect"); close(sockfd); return -1;
}

int iret;
char buffer[102400];

// 第3步:与服务端通讯,发送一个报文后等待回复,然后再发下一个报文。
for (int ii=0;ii<10;ii++)
{
memset(buffer,0,sizeof(buffer));
sprintf(buffer,"这是第%d个超级女生,编号%03d。",ii+1,ii+1);
if ( (iret=send(sockfd,buffer,strlen(buffer),0))<=0) // 向服务端发送请求报文。
{ perror("send"); break; }
printf("发送:%s\n",buffer);

memset(buffer,0,sizeof(buffer));
if ( (iret=recv(sockfd,buffer,sizeof(buffer),0))<=0) // 接收服务端的回应报文。
{
printf("iret=%d\n",iret); break;
}
printf("接收:%s\n",buffer);

sleep(1); // 每隔一秒后再次发送报文。
}

// 第4步:关闭socket,释放资源。
close(sockfd);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "../_public.h" //这个是已经封装了的

int main(int argc,char *argv[])
{
if (argc!=3)
{
printf("Using:./demo07 ip port\nExample:./demo07 127.0.0.1 5005\n\n"); return -1;
}

CTcpClient TcpClient;

// 向服务端发起连接请求。
if (TcpClient.ConnectToServer(argv[1],atoi(argv[2]))==false)
{
printf("TcpClient.ConnectToServer(%s,%s) failed.\n",argv[1],argv[2]); return -1;
}

char buffer[102400];

// 与服务端通讯,发送一个报文后等待回复,然后再发下一个报文。
for (int ii=0;ii<100000;ii++)
{
SPRINTF(buffer,sizeof(buffer),"这是第%d个超级女生,编号%03d。",ii+1,ii+1);
if (TcpClient.Write(buffer)==false) break; // 向服务端发送请求报文。
printf("发送:%s\n",buffer);

memset(buffer,0,sizeof(buffer));
if (TcpClient.Read(buffer)==false) break; // 接收服务端的回应报文。
printf("接收:%s\n",buffer);

sleep(1); // 每隔一秒后再次发送报文。
}
}

我们可以看到,用了socket,会让我们的程序设计变得简单,更多的精力可以用在业务逻辑的处理

CTcpClient()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// socket通讯的客户端类
class CTcpClient
{
public:
int m_connfd; // 客户端的socket.
char m_ip[21]; // 服务端的ip地址。
int m_port; // 与服务端通讯的端口。
bool m_btimeout; // 调用Read方法时,失败的原因是否是超时:true-超时,false-未超时。
int m_buflen; // 调用Read方法后,接收到的报文的大小,单位:字节。

CTcpClient(); // 构造函数。

// 向服务端发起连接请求。
// ip:服务端的ip地址。
// port:服务端监听的端口。
// 返回值:true-成功;false-失败。
bool ConnectToServer(const char *ip,const int port);

// 接收服务端发送过来的数据。
// buffer:接收数据缓冲区的地址,数据的长度存放在m_buflen成员变量中。
// itimeout:等待数据的超时时间,单位:秒,缺省值是0-无限等待。
// 返回值:true-成功;false-失败,失败有两种情况:1)等待超时,成员变量m_btimeout的值被设置为true;2)socket连接已不可用。
bool Read(char *buffer,const int itimeout=0);

// 向服务端发送数据。
// buffer:待发送数据缓冲区的地址。
// ibuflen:待发送数据的大小,单位:字节,缺省值为0,如果发送的是ascii字符串,ibuflen取0,如果是二进制流数据,ibuflen为二进制数据块的大小。
// 返回值:true-成功;false-失败,如果失败,表示socket连接已不可用。
bool Write(const char *buffer,const int ibuflen=0);

// 断开与服务端的连接
void Close();

~CTcpClient(); // 析构函数自动关闭socket,释放资源。
};
ConnectToServer()

这里有两个细节:

  1. m_connfd是客户端是否已经连接的信号,如果都本身已经处于连接状态了,那就先关闭它,并且再把这个参数变为-1,也可以用false啥的,这个没啥特别的,但是一般连接状态的socket都大于0,所以用-1更容易区分。
  2. 重要细节:SIGPIPE这个信号,在应用开发中,我们如果向已关闭的socket发出数据,内核就会发送SIGPIPE的信号,这个信号是直接关闭异常程序,我们肯定不希望直接就把他关闭了,所以一般来讲要屏蔽
1
2
3
4
5
6
7
8
9
10
11
bool CTcpClient::ConnectToServer(const char *ip,const int port)
{
// 如果已连接到服务端,则断开,这种处理方法没有特别的原因,不要纠结。
if (m_connfd!=-1) { close(m_connfd); m_connfd=-1; }

// 忽略SIGPIPE信号,防止程序异常退出。
// 如果send到一个disconnected socket上,内核就会发出SIGPIPE信号。这个信号
// 的缺省处理方法是终止进程,大多数时候这都不是我们期望的。我们重新定义这
// 个信号的处理方法,大多数情况是直接屏蔽它。
signal(SIGPIPE,SIG_IGN);

屏蔽与否SIGPIPE演示

可以看到仅第一条有输出image-20220423114825550image-20220423114847072

我们再把忽略信号加上…………..image-20220423114911658image-20220423114951526

Read()

​ 程序也像我们打电话一样,如果打了十几秒,还没接,就可能会怀疑对方程序有问题,或者哪哪的问题,要挂掉,这里要用到io复用技术(poll),现在先暂时不了解,我们知道大致意思就好

​ 就是如果itimeout>0就判断程序在itimeout指定的时间内有没有到达,没有的话把m_btimeout关掉,if的那段代码只判断有没有数据到达,不会读取数据,读取用return的TcpRead来读,TcpRead上面已经封装好了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 接收服务端发送过来的数据。
// buffer:接收数据缓冲区的地址,数据的长度存放在m_buflen成员变量中。
// itimeout:等待数据的超时时间,单位:秒,缺省值是0-无限等待。
// 返回值:true-成功;false-失败,失败有两种情况:1)等待超时,成员变量m_btimeout的值被设置为true;2)socket连接已不可用。
bool CTcpClient::Read(char *buffer,const int itimeout)
{
if (m_connfd==-1) return false;

// 如果itimeout>0,表示需要等待itimeout秒,如果itimeout秒后还没有数据到达,返回false。
if (itimeout>0)
{
struct pollfd fds;
fds.fd=m_connfd;
fds.events=POLLIN;
int iret;
m_btimeout=false;
if ( (iret=poll(&fds,1,itimeout*1000)) <= 0 )
{
if (iret==0) m_btimeout = true;
return false;
}
}

m_buflen = 0;
return (TcpRead(m_connfd,buffer,&m_buflen));
}
其余函数

Write中调用了TcpWrite,close关闭socket连接,在析构函数中调用了close

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool CTcpClient::Write(const char *buffer,const int ibuflen)
{
if (m_connfd==-1) return false;

int ilen=ibuflen;

if (ibuflen==0) ilen=strlen(buffer);

return(TcpWrite(m_connfd,buffer,ilen));
}

void CTcpClient::Close()
{
if (m_connfd > 0) close(m_connfd);

m_connfd=-1;
memset(m_ip,0,sizeof(m_ip));
m_port=0;
m_btimeout=false;
}

CTcpClient::~CTcpClient()
{
Close();
}

CTcpServer()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// socket通讯的服务端类
class CTcpServer
{
private:
int m_socklen; // 结构体struct sockaddr_in的大小。
struct sockaddr_in m_clientaddr; // 客户端的地址信息。
struct sockaddr_in m_servaddr; // 服务端的地址信息。
public:
int m_listenfd; // 服务端用于监听的socket。
int m_connfd; // 客户端连接上来的socket。
bool m_btimeout; // 调用Read方法时,失败的原因是否是超时:true-超时,false-未超时。
int m_buflen; // 调用Read方法后,接收到的报文的大小,单位:字节。

CTcpServer(); // 构造函数。

// 服务端初始化。
// port:指定服务端用于监听的端口。
// 返回值:true-成功;false-失败,一般情况下,只要port设置正确,没有被占用,初始化都会成功。
bool InitServer(const unsigned int port,const int backlog=5);

// 阻塞等待客户端的连接请求。
// 返回值:true-有新的客户端已连接上来,false-失败,Accept被中断,如果Accept失败,可以重新Accept。
bool Accept();

// 获取客户端的ip地址。
// 返回值:客户端的ip地址,如"192.168.1.100"。
char *GetIP();

// 接收客户端发送过来的数据。
// buffer:接收数据缓冲区的地址,数据的长度存放在m_buflen成员变量中。
// itimeout:等待数据的超时时间,单位:秒,缺省值是0-无限等待。
// 返回值:true-成功;false-失败,失败有两种情况:1)等待超时,成员变量m_btimeout的值被设置为true;2)socket连接已不可用。
bool Read(char *buffer,const int itimeout=0);

// 向客户端发送数据。
// buffer:待发送数据缓冲区的地址。
// ibuflen:待发送数据的大小,单位:字节,缺省值为0,如果发送的是ascii字符串,ibuflen取0,如果是二进制流数据,ibuflen为二进制数据块的大小。
// 返回值:true-成功;false-失败,如果失败,表示socket连接已不可用。
bool Write(const char *buffer,const int ibuflen=0);

// 关闭监听的socket,即m_listenfd,常用于多进程服务程序的子进程代码中。
void CloseListen();

// 关闭客户端的socket,即m_connfd,常用于多进程服务程序的父进程代码中。
void CloseClient();

~CTcpServer(); // 析构函数自动关闭socket,释放资源。
};
InitServer()

三个细节:

  1. 忽略SIGPIPE信号
  2. 服务端一定要打开SO_REUSEADDR,否则下面的bind调用,很容易出现地址被使用的问题
  3. 传参列表 backlog,会填写在listen里面,在绝大多数情况下5已经够用了,如果客户端成百上千个,可以改大一点,50,100都行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
bool CTcpServer::InitServer(const unsigned int port,const int backlog)
{
// 如果服务端的socket>0,关掉它,这种处理方法没有特别的原因,不要纠结。
if (m_listenfd > 0) { close(m_listenfd); m_listenfd=-1; }

if ( (m_listenfd = socket(AF_INET,SOCK_STREAM,0))<=0) return false;

// 忽略SIGPIPE信号,防止程序异常退出。
signal(SIGPIPE,SIG_IGN);

// 打开SO_REUSEADDR选项,当服务端连接处于TIME_WAIT状态时可以再次启动服务器,
// 否则bind()可能会不成功,报:Address already in use。
//char opt = 1; unsigned int len = sizeof(opt);
int opt = 1; unsigned int len = sizeof(opt);
setsockopt(m_listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,len);

memset(&m_servaddr,0,sizeof(m_servaddr));
m_servaddr.sin_family = AF_INET;
m_servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址。
m_servaddr.sin_port = htons(port);
if (bind(m_listenfd,(struct sockaddr *)&m_servaddr,sizeof(m_servaddr)) != 0 )
{
CloseListen(); return false;
}

if (listen(m_listenfd,backlog) != 0 )
{
CloseListen(); return false;
}

return true;
}
关闭socket

有两个,一个用于关闭监听,一个用于关闭客户端的socket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void CTcpServer::CloseListen()
{
if (m_listenfd > 0)
{
close(m_listenfd); m_listenfd=-1;
}
}

void CTcpServer::CloseClient()
{
if (m_connfd > 0)
{
close(m_connfd); m_connfd=-1;
}
}

CTcpServer::~CTcpServer()
{
CloseListen(); CloseClient();
}

网络字节序与主机字节序

https://www.cnblogs.com/xingguang1130/p/11643446.html

1、大端、小端字节序

考虑一个16位整数,它由2个字节组成。内存中存储这两个字节有两种方法:一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序;另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序。如下所示:

img

术语“大端”和“小端”表示多个字节值的哪一端(小端或大端)存储在该值的起始地址。

遗憾的是,这两种字节序之间没有标准可循,两种格式都有系统使用。比如,Inter x86、ARM核采用的是小端模式,Power PC、MIPS UNIX和HP-PA UNIX采用大端模式。

2、网络字节序和主机字节序

网络字节序

  网络字节序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节序采用big endian排序方式。(大端—-big endian)

主机字节序

  不同的机器主机字节序不相同,与CPU设计有关数据的顺序是由cpu决定的,而与操作系统无关。我们把某个给定系统所用的字节序称为主机字节序(host byte order)。比如x86系列CPU都是little-endian的字节序。

  由于这个原因不同体系结构的机器之间无法通信,所以要转换成一种约定的数序,也就是网络字节顺序。

网络字节序与主机字节序之间的转换函数:htons(), ntohs(), htons(),htonl(),位于头文件<netinet/in.h>,htons和ntohs完成16位无符号数的相互转换,htonl和ntohl完成32位无符号数的相互转换。

在使用little endian的系统中,这些函数会把字节序进行转换

在使用big endian类型的系统中,这些函数会定义成空宏

网络程序开发时 或是跨平台开发时,也应该注意保证只用一种字节序,不然两方的解释不一样就会产生bug。

3、IP地址的三种表示格式及在开发中的应用

1)点分十进制表示格式

2)网络字节序格式

3)主机字节序格式

用IP地址127.0.0.1为例:(我估计这个例子是把小端主机转化为大端网络字节序)

  第一步 127 . 0 . 0 . 1 把IP地址每一部分转换为8位的二进制数。

  第二步 01111111 00000000 00000000 00000001 = 2130706433 (主机字节序)

  然后把上面的四部分二进制数从右往左按部分重新排列,那就变为:

  第三步 00000001 00000000 00000000 01111111 = 16777343 (网络字节序)

eg:

  struct sockaddr_in addrSrv;

1
2
3
4
//  addrSrv.sin_addr.S_un.S_addr=htonl(2130706433);  
addrSrv.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
addrSrv.sin_family=AF_INET;
addrSrv.sin_port=htons(6000);

4、inet_aton()、inet_addr()和inet_ntoa()函数

头文件: <arpa/inet.h>

1)int inet_aton(const char *strptr, struct in_addr *addrptr);

将strptr所指C字符串转换成一个32位的网络字节序二进制值,并通过指针addrptr来存储。若成功则返回1,否则返回0。

2)in_addr_t inet_addr(const char *strptr)

若字符串有效,则返回值32位网络字节序二进制值,否则为INADDR_NONE

该函数存在一个问题,所有2^32^个可能的二进制值都是有效的IP地址(0.0.0.0—255.255.255.255),但是当出错时该函数返回INADDR_NONE常值(通常是一个32位均为1的值)。这意味着点分十进制数串255.255.255.255 ** 不能由该函数处理,因为它的二进制值用来指示该函数失败。所以该函数已经被废弃,应该尽量用inet_aton()函数**,或者将要说到的inet_pton()函数。

3)char *inet_ntoa(struct in_addr inaddr);

将一个32位的网络字节序二进制值IPv4地址转换成相应的点分十进制数串。该函数以一个结构而不是以指向该结构的一个指针作为其参数。

返回:指向一个点分十进制数串的指针

5、inet_pton()和inet_ntop()函数

这两个函数是随着IPv6出现的新函数,对于IPv4地址和IPv6地址都适用。函数名中p和n分别代表表达(presentation)和数值(numeric)。

头文件: <arpa/inet.h>

img

img

img

imgimg

img

img

img

img

img

总结这几个转换函数:

img

C语言-网络/主机字节序

如何判断字节序

字节序的判断只需要一段简单的代码即可:

1
2
3
4
5
6
7
8
9
int main()
{
int x=0x12345678;

unsigned char *p=(char *)&x;
printf("%p %p %p %p\r\n",p,p+1,p+2,p+3);
printf("%0x %0x %0x %0x\r\n",p[0],p[1],p[2],p[3]);
}
12345678

在ubuntu下的执行结果为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h249YuCY-1605505603244)(F9E0E3F35D694C0F9A4767AC1F62F26F)]

从执行结果可以得到低字节的78存储在低地址0x7ffe4970678c,所以ubuntu的字节序是小端序。

linux下还可以使用shell查看字节序

1
2
3
lscpu | grep -i byte

12

lscpu表示查看cup的相关信息,grep -i byte表示过滤字节序字段。

二、网络字节序

TCP/IP协议规定:把接收到的第一个字节当作高位字节看待,所以网络字节序是大端序; 我们的电脑和一些常用的处理器芯片大都是小端序的存储方式,在发送数据之前需要进行字节序的转换。

假设我们需要把0x12345678通过udp发送出去,我们在linux上写如下代码:

1
2
3
int buffer = 0x12345678;
ret = sendto(sockfd,&buffer,sizeof(int),0,&dest_addr,sizeof(dest_addr));
12

通过wireshark抓取得到的数据包为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O1xC6YO5-1605505603246)(3DB291F709EA465C8F22BBF4A87A76BB)]

我们可以使用htonl将主机字节序转换成网络字节序:

1
2
3
4
int buffer = 0x12345678;
buffer = htonl(buffer);
ret = sendto(sockfd,&buffer,sizeof(int),0,&dest_addr,sizeof(dest_addr));
123

使用wireshark抓取得到的数据如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GHXOeh1a-1605505603247)(D765F3E188B944F08A3B5C901E8FF633)]

ipv4/ipv6

IPv4和IPv6有什么区别? - 知乎 (zhihu.com)

Internet协议(IP)是为连接到Internet网络的每个设备分配的数字地址。它类似于电话号码,是一种独特的数字组合,允许用户与他人通信。IP地址主要有两个主要功能。首先,有了IP,用户能够在Internet上被识别。第二,IP地址允许计算机通过Internet发送和接收数据,也就是我们经常说的通信。在本文中,我们将深入研究两种类型的IP地址:IPv4与IPv6。我们将从以下几个方面来给大家介绍这两种类型的IP地址,让大家了解到两类IP的必备知识:

  • 什么是IPv4和IPv6?
  • IPv4和IPv6之间的区别
  • IPv4或IPv6:使用哪个?
  • IPv4与IPv6安全性如何

什么是IPv4和IPv6?

IPv4和IPv6是不同类型的IP地址。它们的主要用途相同,标记不同的用户,并且让用户能通过IP进行通信。主要区别在于IPv6是最新一代的IP地址。

IPv4地址

IPv4地址的概念是在1980年代初期提出的。即使有新版本的IP地址,IPv4地址仍然是Internet用户使用最广泛的地址。通常,IPv4地址以点分十进制表示。每个部分代表一组构成8位地址方案的8位地址。

img

IPv4地址组合的数量是有限的。总体而言,可以算出40亿(256 4)个唯一地址。在IPv4地址才开始时,这个数字似乎永远不会过期。但是,现在情况有所不同了。2011年,全球互联网编号分配机构(IANA)分发了IPv4地址空间的最后一块。2015年,IANA正式宣布美国已用完IPv4地址。直到今天,IPv4地址仍然承载着最多(超过90%)的互联网流量。到目前为止,即使目前存在IPv4地址耗尽的问题,也有一些方法可以继续使用IPv4地址。例如,当仅需要一个唯一的IP地址来代表一组设备时,网络地址转换(NAT)是一种方法。除此之外,IP地址可以重复使用。当然,我们已经有了彻底耗尽的解决方案-IPv6地址。

IPv6地址

仔细观察,您会发现IPv6地址并不是一种全新的技术。它是Internet协议的最新版本,但它是在1998年开发的,旨在替换IPv4地址。IPv6地址使用以冒号分隔的十六进制数字。它分为八个16位块,构成一个128位地址方案。

img

IPv6也存在数量限制。不过可用的IP数量远大于IPv4。从理论上讲,可以创建大约3.4×10 38个地址。这一数据听起来很高,远超于IPv4的总数40亿个,但是有一天也可能出现不够的情况。但就目前而言,这些地址将可以供我们使用很长一段时间。

IPv4和IPv6之间的区别

IPv4和IPv6用于用户标识和Internet上不同设备之间的通信。IPv4是32位IP地址,而IPv6是128位IP地址。IPv4是数字地址,用点分隔。IPv6是一个字母数字地址,用冒号分隔。

我们分别详细介绍了IPv4和IPv6类型。现在,我们可以比较这些类型,并找出这两种协议之间的主要区别。我们列举了IPv4和IPv6之间的八个主要区别。

img

1.地址类型。IPv4具有三种不同类型的地址:多播,广播和单播。IPv6还具有三种不同类型的地址:任意广播,单播和多播。

2.数据包大小。对于IPv4,最小数据包大小为576字节。对于IPv6,最小数据包大小为1208字节。

3.header区域字段数。IPv4具有12个标头字段,而IPv6支持8个标头字段。

4.可选字段。IPv4具有可选字段,而IPv6没有。但是,IPv6具有扩展header,可以在将来扩展协议而不会影响主包结构。

5.配置。在IPv4中,新装的系统必须配置好才能与其他系统通信。在IPv6中,配置是可选的,它允许根据所需功能进行选择。

6.安全性。在IPv4中,安全性主要取决于网站和应用程序。它不是针对安全性而开发的IP协议。而IPv6集成了Internet协议安全标准(IPSec)。IPv6的网络安全不像IPv4是可选项,IPv6里的网络安全项是强制性的。

7.与移动设备的兼容性。IPv4不适合移动网络,因为正如我们前面提到的,它使用点分十进制表示法,而IPv6使用冒号,是移动设备的更好选择。

8.主要功能。IPv6允许直接寻址,因为存在大量可能的地址。但是,IPv4已经广泛传播并得到许多设备的支持,这使其更易于使用。

img

IPv4或IPv6:使用哪个?

对于使用IPv6还是IPv4这个问题,没有标准答案。在考虑未来的网络体验时,IPv6地址就显得至关重要。即使在我们已经没有网络地址的情况下仍然可以有其他办法使用IPv4地址,但是这些选项也可能会轻微影响到网络速度或引起其他问题。不过,使用IPv6需要开发支持IPv6的新技术和产品。IPv6的速度显然不比IPv4快,但是从IPv4完全更改为IPv6将为Internet提供更大的唯一IP池。那么为什么我们仍在使用IPv4?

问题就在于IPv4和IPv6无法相互通信。这就是为什么IPv6的集成和适配很复杂。大多数网站或应用程序仅支持IPv4类型的IP地址。想象一下突然更改每个设备的IP地址。用户将无法访问大多数网站或应用程序,而我们在互联网上将陷入一片混乱。从旧的IP类型转换为新的IP类型的过程应分步完成。例如,这两个协议能够并行运行。此功能称为双重堆栈。它允许用户同时访问IPv4和IPv6内容。

您需要什么才能使用IPv6?

1.操作系统必须与IPv6兼容。Windows Vista和Windows的较新版本,Mac OS X的现代版本以及Linux。

2.大多数路由器不支持IPv6。如果您想尝试使用IPv6,请检查路由器的详细信息。

\3. Internet服务提供商(ISP)也必须支持IPv6。即使您具有合适的操作系统和路由器,您的ISP也必须提供IPv6连接。

IPv4与IPv6的安全性

IPv6的开发考虑了安全性。这就是将IPSec集成在IPv6中的原因,而对于IPv4,IPSec是可选的。

什么是IPSec?

IPSec(Internet协议安全性)是一种安全的网络协议,它对数据包进行身份验证和加密,以在设备之间提供安全的通信。加密是只有经过确认的各方才能理解的一种秘密代码。它有助于确保通过公共网络发送的信息的安全。

由于IPv4还可以选择集成IPSec,因此我们可以假设在安全性方面IPv4与IPv6几乎相同。但是,如果已经集成了安全措施,则要简单得 多。

三、多进程的网络服务端

image-20220423173519983

服务端可以是多进程,也可以是多线程,如果采用IO复用技术,单进程单线程的服务端也可以和多个客户端程序通信,这个章节我们先搞定定多进程和多线程的服务端

主体流程

父进程先初始化服务端,然后Accept等待服务端的连接,新的客户端连上了之后,fork一个子进程出来,然后父进程回到accept继续等待其他客户端的连接请求,让子进程与刚才连进来的客户端进行处理业务。image-20220423173834599

基础实现

寄托于fork函数实现,我们用while反复迭代,达到不断接受客户端的目的(回到Accept),

image-20220423201612991

​ 但接下来就出现了一个问题(demo10为服务端),现在,客户端已经全部结束了,但是服务端还有这么多进程,这是怎么回事呢?

那原因就在那个while,当fork生成子进程,子进程进入while,并且执行完自己的程序之后,就会继续进入上面那层while,并且不断卡在连接阶段,就和父进程一起等待在哪里挂起了

image-20220423201840979

所以我们应该在最下面增加一行代码,用return 0或者exit(0)都可以image-20220423202119413

再优化

​ 现在,我们解决了服务端进程等待的问题,接下来,新的问题又出现了。客户端全部退出之后,服务端的进程变成了这样,也就是产生了很多僵尸进程。因为最后服务端还没有关闭的,至少最初的父进程任然在等待子进程,所以说哪些进程暂时挂起,变成僵尸进程,占用进程号

image-20220423202255720

新的问题

image-20220423202817539

我们在程序连接到,并且不做任何行动的这段时间内,sleep100s,image-20220423203052571

现在我们得到demo10的进程编号,

image-20220423203143047

image-20220423203311325

在fd里,有他打开过的文件描述符image-20220423203403409

0,1,2是标准输入,标准输出,标准错误,3应该是监听的socket,4

image-20220423204148262

现在我们在换个花样,如果我们把sleep放在fork之后,让一个客户端连上去。image-20220423204434027image-20220423204353570

此时我们可以观察到image-20220423204527094

​ 父进程和子进程文件表示符一样的!因为fork那节课,就已经讲过,fork是全部复制,也就是二者的任何信息,不会和彼此有关联,是独立的个体。

​ 在网络服务程序中,父进程只负责监听客户端的连接,客户端连上了之后,对父进程来说,connfd是不需要的,对子进程而言,他使用的只是connfd,他也不需要监听的listenfd,既然这样,那么我们可以在父进程中关掉4,子进程中关掉3image-20220423204906966

​ 当然,不关闭这两个文件描述符,其实也可以,但是对一个进程来说,打开的文件描述符是有限制的,打开的越多,消耗的资源会更多,所以我们肯定会执行这两行的,再开发当中

​ 另外,日志闪亮登场。(将服务端的printf语句改为file.Write(argv[2], a+) == false…..)

fork一点点补充:

至于fork()函数的返回值:
子进程返回:0
父进程返回:>0的整数(返回子进程ID号)
错误返回:-1

补充这个的目的就是说,我们可以完全看出子进程继续往下,父进程再度回去(用continue,等效回去,而且本来就是第一个进程,所以自然也是父进程)

多进程网络服务程序(端)的退出

​ 我们之前知道,可以用信号(killall xx xxx)来实现单进程的程序退出(杀掉),我们现在来思考一下多进程该如何退出,应该说,什么才是我们想要的退出,我们知道,对于一个进程而言,当他的父进程退出了,他不会跟着一起退出,所以说:

  1. 如果杀掉父亲,进行中的子进程不会跟着一起退出,会执行到结束,当子进程结束了,就别让他挂起了,喊他退出,但在这个期间内,新的客户端想要连接客户端,就不会被通过,因为父进程是用于监听的,连监听的都死掉了,你还怎么连进来?
  2. 如果杀掉孩子,我们自然是不希望影响到别的进程的,但是,我们也不想让这个孩子变成僵尸进程,所以,我们最开始的想法是让父进程忽略掉子进程的信号,现在我们可以把它封装成一个chldEXIT函数,具体思路如下面代码
  3. 如果父子都收到了信号,叫你们断掉,那么和第一种情况类似
1
2
3
4
5
6
7
8
9
* 1)在多进程的服务程序中,如果杀掉一个子进程,和这个子进程通讯的客户端会断开,但是,不
* 会影响其它的子进程和客户端,也不会影响父进程。
* 2)如果杀掉父进程,不会影响正在通讯中的子进程,但是,新的客户端无法建立连接。
* 3)如果用killall+程序名,可以杀掉父进程和全部的子进程。
*
* 多进程网络服务端程序退出的三种情况:
* 1)如果是子进程收到退出信号,该子进程断开与客户端连接的socket,然后退出。
* 2)如果是父进程收到退出信号,父进程先关闭监听的socket,然后向全部的子进程发出退出信号。
* 3)如果父子进程都收到退出信号,本质上与第2种情况相同。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 父进程退出函数。
void FathEXIT(int sig)
{
logfile.Write("父进程退出,sig=%d。\n",sig);

TcpServer.CloseListen(); // 关闭监听的socket。

kill(0,15); // 通知全部的子进程退出。具体解释看下面

exit(0);
}

// 子进程退出函数。
void ChldEXIT(int sig)
{
logfile.Write("子进程退出,sig=%d。\n",sig);

TcpServer.CloseClient(); // 关闭客户端的socket。

exit(0);
}
问题再深入

当服务端客户端正常运行的时候,如果我们把服务端突然来个ctrl终止了,他就GG了,但是我们现在观察后台image-20220423225241777

​ 他居然退出了两次,我们说,这不是我们想要的(虽然他实质肯定就退出了一次)

​ 这样的原因是因为,信号退出处理函数在执行的过程中又受到了信号,要解决这个问题,我们可以,给退出函数,做屏蔽处理,具体如下哦,这样无敌的退出函数就出来啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 父进程退出函数。
void FathEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中被信号中断。
signal(SIGINT,SIG_IGN); signal(SIGTERM,SIG_IGN);

logfile.Write("父进程退出,sig=%d。\n",sig);

TcpServer.CloseListen(); // 关闭监听的socket。

kill(0,15); // 通知全部的子进程退出。具体解释看下面

exit(0);
}

// 子进程退出函数。
void ChldEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中被信号中断。
signal(SIGINT,SIG_IGN); signal(SIGTERM,SIG_IGN);

logfile.Write("子进程退出,sig=%d。\n",sig);

TcpServer.CloseClient(); // 关闭客户端的socket。

exit(0);
}
讨论

​ 思路别限制的太死,退出函数除了响应退出信号,还可以在其他的地方调用它,比如说,这个return,我们就可以改成FathEXIT(-1)image-20220423225508724

又或者是这里,我们处理结束以后把客户端关了,可以用我们刚刚写好的ChldEXIT(0)image-20220423225620007

kill()

头文件:#include <sys/types.h> #include <signal.h>

定义函数:int kill(pid_t pid, int sig);

函数说明:kill()可以用来送参数sig 指定的信号给参数pid 指定的进程。参数pid 有几种情况:
1、pid>0 将信号传给进程识别码为pid 的进程.
2、pid=0 将信号传给和目前进程相同进程组的所有进程
3、pid=-1 将信号广播传送给系统内所有的进程
4、pid<0 将信号传给进程组识别码为pid 绝对值的所有进程参数 sig 代表的信号编号可参考附录D

返回值:执行成功则返回0, 如果有错误则返回-1.

错误代码:
1、EINVAL 参数sig 不合法
2、ESRCH 参数pid 所指定的进程或进程组不存在
3、EPERM 权限不够无法传送信号给指定进程

网银APP软件业务

网络通信

首先,我们在介绍之前,先来了解网络通信的过程

  1. 客户端发起连接请求,服务端响应,建立连接,他们之间就可以进行通信了
  2. 请求报文可以由客户端发起,也可以由服务端发起,并且,请求报文和回应报文之间的关系,可以是一对一,也可以是多对多,一对多之类的
  3. 总的来说,没有固定的格式,要看双方的约定,所谓的约定就是通信协议,结束以后,可以由客户端断开,也可以由服务端断开

image-20220424101634171image-20220424101647760image-20220424101701870

业务实例

登录

银行的服务端,收到报文之后,先判断业务代码,如果是1,表示登录业务,再判断手机号码和密码,如果号码和密码都正确,服务端返回0,提示成功,不正确….

image-20220424101820514

我的账户

image-20220424102029128

客户端

我们先从客户端改起,把登录业务和查看余额,封装成两个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "../_public.h"

CTcpClient TcpClient;

bool srv001(); // 登录业务。
bool srv002(); // 我的账户(查询余额)。

int main(int argc,char *argv[])
{
if (argc!=3)
{
printf("Using:./demo11 ip port\nExample:./demo11 127.0.0.1 5005\n\n"); return -1;
}

// 向服务端发起连接请求。
if (TcpClient.ConnectToServer(argv[1],atoi(argv[2]))==false)
{
printf("TcpClient.ConnectToServer(%s,%s) failed.\n",argv[1],argv[2]); return -1;
}

if (srv001()==false) // 登录业务。
{
printf("srv001() failed.\n"); return -1;
}

if (srv002()==false) // 我的账户(查询余额)。
{
printf("srv002() failed.\n"); return -1;
}

return 0;
}
登录业务

三步走,先发送请求,再接受回报,最后解析回报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 登录业务。 
bool srv001()
{
char buffer[1024];

SPRINTF(buffer,sizeof(buffer),"<srvcode>1</srvcode><tel>1392220000</tel><password>123456</password>");
printf("发送:%s\n",buffer);
if (TcpClient.Write(buffer)==false) return false; // 向服务端发送请求报文。

memset(buffer,0,sizeof(buffer));
if (TcpClient.Read(buffer)==false) return false; // 接收服务端的回应报文。
printf("接收:%s\n",buffer);

// 解析服务端返回的xml。
int iretcode=-1;
GetXMLBuffer(buffer,"retcode",&iretcode);
if (iretcode!=0) { printf("登录失败。\n"); return false; }

printf("登录成功。\n");

return true;
}
查询业务

同样三步走,发送请求,接受回报,解析回报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 我的账户(查询余额)。
bool srv002()
{
char buffer[1024];

SPRINTF(buffer,sizeof(buffer),"<srvcode>2</srvcode><cardid>62620000000001</cardid>");
printf("发送:%s\n",buffer);
if (TcpClient.Write(buffer)==false) return false; // 向服务端发送请求报文。

memset(buffer,0,sizeof(buffer));
if (TcpClient.Read(buffer)==false) return false; // 接收服务端的回应报文。
printf("接收:%s\n",buffer);

// 解析服务端返回的xml。
int iretcode=-1;
GetXMLBuffer(buffer,"retcode",&iretcode);
if (iretcode!=0) { printf("查询余额失败。\n"); return false; }

double ye=0;
GetXMLBuffer(buffer,"ye",&ye);

printf("查询余额成功(%.2f)。\n",ye);

return true;
}
服务端

在接受客户端和发送响应之间插入处理业务的主函数

image-20220424114853821

解析+处理,用switch,判断他的isrvcode来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 处理业务的主函数。
bool _main(const char *strrecvbuffer,char *strsendbuffer)
{
// 解析strrecvbuffer,获取服务代码(业务代码)。
int isrvcode=-1;
GetXMLBuffer(strrecvbuffer,"srvcode",&isrvcode);

if ( (isrvcode!=1) && (bsession==false) )
{
strcpy(strsendbuffer,"<retcode>-1</retcode><message>用户未登录。</message>"); return true;
}

// 处理每种业务。
switch (isrvcode)
{
case 1: // 登录。
srv001(strrecvbuffer,strsendbuffer); break;
case 2: // 查询余额。
srv002(strrecvbuffer,strsendbuffer); break;
case 3: // 转账。
srv003(strrecvbuffer,strsendbuffer); break;
default:
logfile.Write("业务代码不合法:%s\n",strrecvbuffer); return false;
}

return true;
}
登录业务

这里仅用登录业务就好,大同小异,都是进一步解析,因为上一步直解析了isrvcode,这里解析更细致的,登录所需要的参数解析在这,再处理业务,最后strsendbuffer发送出去,其他也是一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 登录。
bool srv001(const char *strrecvbuffer,char *strsendbuffer)
{
// <srvcode>1</srvcode><tel>1392220000</tel><password>123456</password>

// 解析strrecvbuffer,获取业务参数。
char tel[21],password[31];
GetXMLBuffer(strrecvbuffer,"tel",tel,20);
GetXMLBuffer(strrecvbuffer,"password",password,30);

// 处理业务。
// 把处理结果生成strsendbuffer。
if ( (strcmp(tel,"1392220000")==0) && (strcmp(password,"123456")==0) )
{
strcpy(strsendbuffer,"<retcode>0</retcode><message>成功。</message>"); bsession=true;
}
else
strcpy(strsendbuffer,"<retcode>-1</retcode><message>失败。</message>");

return true;
}

​ 另外,我们在思考一下,现在服务程序使用了switch case 所以登录和其他业务是并列关系,但是,我们可以知道,对于一个网银系统而言,你连登录都没登录,肯定是不能执行下面的语句的,因此,我们还得做一些修改

​ 在服务端,我们定义一个bession变量image-20220424120307640

并且在处理业务的函数中,switch判断之前,加一个检测到未登录,就退出,具体就是说,如果他的isrvcode不是1,(不是进行登录业务)那么我们检测一下他的bsession打开没有,bsession默认没有打开,没有打开说明没有登录,就让他退出,为了实现这个功能,我们就在登录成功的业务代码哪里加上 bsession = true ,这样就可以说,每一个客户端的子进程,最开始bsession都是false,实现了登录业务后,就永久打开,直到他退出!

image-20220424120522459

Tcp长连接,短连接

[TCP的长连接和短连接-阿里云开发者社区 (aliyun.com)](https://developer.aliyun.com/article/37987#:~:text=长连接与短连接. 所谓长连接,指在一个TCP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维持。.,短连接是指通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此TCP连接,一般银行都使用短连接。. 比如http的,只是连接、请求、关闭,过程时间较短%2C服务器若是一段时间内没有收到请求即可关闭连接。. 其实长连接是相对于通常的短连接而说的,也就是长时间保持客户端与服务端的连接状态。.)

简介: TCP/IP是个协议组,可分为三个层次:网络层、传输层和应用层。 在网络层有IP协议、ICMP协议、ARP协议、RARP协议和BOOTP协议。 在传输层中有TCP协议与UDP协议。在应用层有FTP、HTTP、TELNET、SMTP、DNS等协议。

TCP/IP是个协议组,可分为三个层次:网络层、传输层和应用层。

在网络层有IP协议、ICMP协议、ARP协议、RARP协议和BOOTP协议。
在传输层中有TCP协议与UDP协议。
在应用层有FTP、HTTP、TELNET、SMTP、DNS等协议。

2Q==

长连接与短连接

所谓长连接,指在一个TCP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维持。

短连接是指通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此TCP连接,一般银行都使用短连接。

比如http的,只是连接、请求、关闭,过程时间较短,服务器若是一段时间内没有收到请求即可关闭连接。

其实长连接是相对于通常的短连接而说的,也就是长时间保持客户端与服务端的连接状态。

长连接与短连接的操作过程

通常的短连接操作步骤是:
连接→数据传输→关闭连接;

而长连接通常就是:
连接→数据传输→保持连接(心跳)→数据传输→保持连接(心跳)→……→关闭连接;

这就要求长连接在没有数据通信时,定时发送数据包(心跳),以维持连接状态,短连接在没有数据传输时直接关闭就行了。

什么时候用长连接,短连接

长连接可以省去较多的TCP建立和关闭的操作,减少浪费,节约时间。对于频繁请求资源的客户来说,较适用长连接。不过这里存在一个问题,存活功能的探测周期太长,还有就是它只是探测TCP连接的存活,属于比较斯文的做法,遇到恶意的连接时,保活功能就不够使了。在长连接的应用场景下,client端一般不会主动关闭它们之间的连接,Client与server之间的连接如果一直不关闭的话,会存在一个问题,随着客户端连接越来越多,server早晚有扛不住的时候,这时候server端需要采取一些策略,如关闭一些长时间没有读写事件发生的连接,这样可 以避免一些恶意连接导致server端服务受损;如果条件再允许就可以以客户端机器为颗粒度,限制每个客户端的最大长连接数。

短连接对于服务器来说管理较为简单,存在的连接都是有用的连接,不需要额外的控制手段。但如果客户请求频繁,将在TCP的建立和关闭操作上浪费时间和带宽。

长连接多用于操作频繁,点对点的通讯,而且连接数不能太多情况。每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,次处理时直接发送数据包就OK了,不用建立TCP连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成socket错误,而且频繁的socket 创建也是对资源的浪费。

而像WEB网站的http服务一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像WEB网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源,如果用长连接,而且同时有成千上万的用户,如果每个用户都占用一个连接的话,那可想而知吧。所以并发量大,但每个用户无需频繁操作情况下需用短连接好。

总之,长连接和短连接的选择要视情况而定。

Tcp短连接

一对一的请求与回应,快速+高频,使用资源很多,使用的场景不多

连接之后,马上进行通讯,通讯结束之后,连接就断开了,管理起来非常简单,不需要其他的控制手段image-20220424122105035

Tcp长连接

大部分场景,采用Tcp长连接,这个过程就是,通信的次数和时间,是不确定的image-20220424122250642

n秒之后,服务端也可能主动发起连接请求…….

​ 比如说我们用vx给好友发消息,vx登录之后,我们的手机和腾讯的服务器建立了Tcp连接,服务端在把信息转发给你,服务端向客户端转发的时候,就是服务端主动连接你,vx业务采用的是Tcp长连接,要求客户端一直在线,否则满足不了业务需求,如果你不在线,就收不到别人给你发的信息,很自然image-20220424122403831

​ 长连接和短连接的退出就有很大的区别,连接建立之后,除非程序退出,或者网络断开,否则,这个连接会一直保存,这样的话就产生了一个新的问题,如何管理Tcp连接? 方法是这样的,采用Tcp心跳机制image-20220424122657450

Tcp长连接心跳机制

理解了之后,要实现就很容易,一句话,客户端在空闲的时候,要向服务端,发送心跳报文。

image-20220424122903494

心跳报文的格式也很简单

image-20220424122956990

​ 我们就从网银系统开始改,想一下需要做什么,首先我们得在服务端传参列表,加入心跳一项(自己拟定多少秒算超时),心跳传进来,他也算是一种业务的类型,我们可以放在switch里面 接着,假设我们心跳的业务处理代码为0,我们就可以整一个方法,叫做bool srv000,解析 下xml的心跳一项,其实吧,说是解析,根本不需要解析,只要客户端在规定时间传进来了,不就证明他没死吗,所以只会成功,不会失败

​ 接着我们改客户端,让他具有发送的能力,并且,只要客户端有收到服务端的报文,我们就可以认为他是成功的,没必要解析xml,如果报文发不出去,或者没有收到回应,我们就说,他是失败的

​ 我们现在来测试一下,注意,我们先设置服务端最大空闲时间是11秒,也就是说,虽然这里sleep了两次,加起来超过了10秒,但是我们心跳是针对于,不作任何处理的时间段,所以就没有这个顾虑。注意:为什么说没有顾虑呢?,因为在服务端的switch语句里面,第一行就是先执行心跳语句,也就是说,无论我们执行了什么操作,首先都能进入心跳判断那一行,更新了心跳之后,再执行对应的语句,所以说,每执行一种业务,都刷新一次心跳,具体判断,我们是用的read里面封装的心跳机制,应该用到了IO复用的技术,以后遇到了在研究image-20220424125243606image-20220424125307522

image-20220424123859924

我们先来运行服务端image-20220424124206885

再来运行客户端image-20220424124222220

如果把超时时间改成8image-20220424125354161

从结果来说,是非常顺利的!

应用经验

image-20220424125448637

如果我们有连接云服务器之类的经验,就知道Tcp空闲的时候会被断开,中间经过了很多防火墙路由器什么的image-20220424125617291

例如,在这里,就设置了每50s发送一次心跳,这样的话一天也不会断开,所以,在项目开发中image-20220424125708356

太短没有必要,太长也不合适,肯定要比网络设备之间要短,一般60s

四、基于Tcp协议的文件传输系统

​ 从这里开始,正式开始文件传输系统的实现!

之前开发的Ftp文件传输系统,主要用于系统之间,文件传输交换,Ftp很简单,但是效率不高;基于Tcp的主要用于系统内部的代码交换,代码写起来麻烦一些,但是文件传输的效率特别高,功能也更强大

image-20220424203624343

​ 这是我们的框架,为什么考虑分成三个部分,这能让系统的结构更佳简单,可能这里会产生一个疑惑,为什么客户端分成上传下载,服务端则不分呢?

​ 原因是这样的,服务端是网络服务程序,两个网络服务程序,就需要两个监听的端口,这样的话配置网络参数会更麻烦,比如路由器,要开通两个端口,防火墙也要开通两个端口

文件上传功能

流程图:image-20220424204032965

​ 首先,登录的意义并不是判断用户名和密码,而是与服务端协商文件传输的参数,最重要的参数是文件存放的目录

文件信息:文件名,文件时间,文件大小

文件内容:里面存放的内容

​ 服务端接受文件之后,再向客户端发送报文,客户端收到回复的报文,就算上传成功了,然后用while循环来执行这一段迭代,保证把文件清单里每一个文件都上传,当上传完毕,客户端可以休息几秒,再去获取文件清单,再把文件上传给服务端,按照上述的步骤循环。

要求:image-20220424204843850

​ 第一个要求是因为,该传输功能处于系统内部,对文件传输的时效要求很高,不能延迟太长时间

​ 第二个要求是客户端不需要增量上传的功能,上传以后删除便是,这种处理方法最简单,效率也最高,当然,如果要同时上传到多个服务端,那我们可以上传的时候多拷贝几份嘛

exit与return

​ 在实现的过程中我们用到了return和exit的关系,所以在这里我又去百度了一下…

exit(0):正常运行程序并退出程序;

exit(1):非正常运行导致退出程序;

return():返回函数,若在主函数中,则会退出函数并返回一值。

详细说:

  1. return返回函数值,是关键字; exit 是一个函数

  2. return语言级别的,它表示了调用堆栈的返回;而exit系统调用级别的,它表示了一个进程的结束。

  3. return是函数的退出(返回);exit是进程的退出

  4. return是C语言提供的,exit是操作系统提供的(或者函数库中给出的)。

  5. return用于结束一个函数的执行,将函数的执行信息传出个其他调用函数使用;exit函数是退出应用程序,删除进程使用的内存空间,并将应用程序的一个状态返回给OS,这个状态标识了应用程序的一些运行信息,这个信息和机器和操作系统有关,一般是 0 为正常退出, 非0 为非正常退出。

  6. 非主函数中调用return和exit效果很明显,但是在main函数中调用return和exit的现象就很模糊,多数情况下现象都是一致的。

exit(0)与exit(1)对你的程序来说,没有区别。对使用你的程序的人或者程序来说,区别可就大了。
一般来说,exit 0 可以告知你的程序的使用者:你的程序是正常结束的。如果 exit 非 0 值,那么你的程序的使用者通常会认为你的程序产生了一个错误。

以 shell 为例,在 shell 中调用完你的程序之后,用 echo $? 命令就可以看到你的程序的 exit 值。在 shell 脚本中,通常会根据上一个命令的 $? 值来进行一些流程控制。

文件上传服务端

服务端的业务逻辑是这样的

1
2
3
4
5
6
7
8
9
10
11
// 上传文件的主函数
void RecvFileMain(){
while(true){
// 接受客户端的报文

// 处理心跳报文

// 处理上传文件的请求报文

}
}

​ 进一步的来讲是这样的,注意心跳报文,则是,如果此时接受的是<a…..则处理,最开始还没明白这是怎么处理的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 上传文件的主函数
void RecvFileMain(){
while(true){
memset(strrecvbuffer, 0, sizeof(strrecvbuffer));
memset(strsendbuffer, 0, sizeof(strsendbuffer));

// 接受客户端的报文,timetvl为扫描本地上传文件间隔,+10代表上传的真正时间(随意,+5也可以)
if(TcpServer.Read(strrecvbuffer, starg.timetvl + 10) == false){
logfile.Write("TcpServer.Read() failed.\n");
return;
}
logfile.Write("strrecvbuffer = %s\n", strrecvbuffer);
// 处理心跳报文
if(strcmp(strrecvbuffer, "<activetest>ok</activetest>") == 0){
strcpy(strsendbuffer, "ok");
logfile.Write("strsendbuffer = %s\n", strsendbuffer);
if(TcpServer.Write(strsendbuffer) == false){
logfile.Write("TcpServer.Write() failed.\n");
return;
}
}
// 处理上传文件的请求报文
if(strncmp(strrecvbuffer, "<filename>", 10) == 0){
// 解析上传文件请求报文的xml

// 接受上传文件的内容

// 把接受结果返回给对端
}
}
}

文件上传客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 文件上传的主函数,执行一次文件上传的任务
bool _tcpputfiles(){
// 调用OpenDir()打开starg.clientpath目录

while(true){
// 遍历目录中的每个文件,调用ReadDir()获取一个文件名

// 把文件名、修改时间、文件大小组成报文,发送给对端

// 把文件的内容发送给对端

// 接受对端的确认报文

// 删除或者转存本地的文件
}
return true;
}

上传文件内容

在建立过程中,最困难的工作就是实现传输,因此,我们有必要专门来讲解一下,如何在客户端实现发送和服务端实现接受

客户端
主框架
1
2
3
4
5
6
7
8
// 把文件的内容发送给对端
logfile.Write("send %s(%d) ...", Dir.m_FullFileName, Dir.m_FileSize);
if(SendFile(TcpClient.m_connfd, Dir.m_FullFileName, Dir.m_FileSize) == true){
logfile.Write("ok.\n");
}else{
logfile.Write("failed.\n");
TcpClient.Close(); return false;
}
sendfile()

​ 主要就是打开本地文件,然后发送本地文件,再关闭,就是有个细节,万一文件太大,无法一次打开,因此我们模拟缓冲区的思想,每次最多读1000个字节,但是注意:又是一场毒打,找了两个晚上的错误,真的找吐了,真不应该在脑袋昏昏沉沉的时候写代码,totalbytes局部变量最开始居然没初始化,最后我检查啊检查,已经检查到了,看到surfdata2里面生成了临时文件,但是0字节,又来比对了一次,结果还是没找出来,最后通过控制变量法,用吴哥的服务端,和客户端,以此与自己的匹配,最终确定是客户端的错误,在逐个比对,最终找到,呜呜呜呜呜呜

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

// 把文件的内容发送给对端
bool SendFile(const int sockfd, const char *filename, const int filesize){
int onread = 0; // 每次调用fread时打算读取的字节数
int bytes = 0; // 调用一次fread从文件中读取的字节数
char buffer[1000]; // 存放读取数据的buffer
int totalbytes = 0; // 从文件中已读取的字节总数, 真的是太难了,完全没发现没有初始化
FILE *fp = NULL;

// 以"rb"的模式打开文件
if( (fp = fopen(filename, "rb")) == NULL) return false;
while(true){
memset(buffer, 0, sizeof(buffer));

// 计算本次应该读取的字节数,如果剩余数量超过1000字节,就打算读1000字节(模拟缓冲区,防止太大打不开)
if(filesize - totalbytes > 1000) onread = 1000;
else onread = filesize - totalbytes;

// 从文件中读取数据
bytes = fread(buffer, 1, onread, fp);

// 把读取到的数据发送给对端
if(bytes > 0){
if(Writen(sockfd, buffer, bytes) == false){
fclose(fp);
return false;
}
}
// 计算文件已读取的字节总数,如果文件已读完,跳出循环
totalbytes = totalbytes + bytes;
if(totalbytes == filesize) break;
}
fclose(fp);
return true;
}
服务端
RecvFile()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 接受上传文件的内容
bool RecvFile(const int sockfd, const char *filename, const char *mtime, int filesize){
// 生成临时的文件名

// 创建临时文件

while (true)
{
// 计算本次应该接受的字节数

// 接受文件内容

// 把接收到的内容写入文件

// 计算已接收文件的总字节数,如果文件接受完,跳出循环
}

// 关闭临时文件

// 重置文件的时间

// 把临时文件RENAME为正式的文件。


return true;
}

删除与转存文件

较为简单,简单看一下便是,仅仅只需要改动客户端,因为是上传功能,所以服务端没变化,客户端来决定把本地文件删除或者转存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 删除或者转存本地的文件
bool AckMessage(const char *strrecvbuffer){
char filename[301];
char result[11];

memset(filename, 0, sizeof(filename));
memset(result, 0, sizeof(result));

GetXMLBuffer(strrecvbuffer, "filename", filename, 300);
GetXMLBuffer(strrecvbuffer, "result", result, 10);

// 如果服务端接受文件不成功,直接返回
if(strcmp(result, "ok") != 0) return true;

// ptype == 1, 删除文件
if(starg.ptype == 1){
if(REMOVE(filename) == false) {
logfile.Write("REMOCE(%s) failed.\n", filename);
return false;
}

// ptype == 2, 移动到备份目录
if(starg.ptype == 2){
// 生成转存后备份目录的文件名。
char bakfilename[301];
STRCPY(bakfilename, sizeof(bakfilename), filename);
UpdateStr(bakfilename, starg.clientpath, starg.clientpathbak, false);
if(RENAME(filename, bakfilename) == false){
logfile.Write("RENAME(%s, %s) failed.\n", filename, bakfilename);
return false;
}
}
}
return true;
}

同步–异步通信

​ 不管是FTP协议,还是TCP协议,都是同步通信,接下来会介绍几种异步通信的方式

同步

​ 同步通讯效率比较低,因为把大量的时间都浪费在等待上了,但是也有个好处,就是流程很简单,一问一答这种方式,程序写起来也非常简单image-20220427211128074

异步

​ 客户端发送n个请求,同时也会接收到很多回应,采用异步通信,不会把时间浪费在等待上面,所以效率非常高,但是也带来了新的问题,就是流程控制很麻烦,程序也会更复杂

​ 比如说,客户端向服务端发送了1000个请求,这个时候网络断开了,此时客户端只收到了500个回应,还有500个回应没收到,此时客户端并不知道服务端到底处理成功与否,这种事情没有固定的解决方法,不同的业务有不同的方法,总的来说,要解决都是很麻烦的,不会太简单。

异步通信的实现image-20220427213120763
  • 多进程,即fork(),使得父进程和子进程能够自己做自己的事情
  • 多线程,暂时还未涉及,后面会讲
  • IO复用有点难,不是几句话能讲的,后面再讲
多进程实现

此时还是同步的,按部就班的,发一个接受一个image-20220427214640478

​ 此时进行一次fork分叉,分成一个子进程和一个父进程,父进程负责发送请求,子进程负责接受回应,也就是进行了一个分工,其中,我认为由父进程来发送是很有好处的,理由是,如果用子进程来发送,pid<0为异常情况,可能就会发生,程序已经出现异常了,但仍然发送报文的情况,因此,用父进程来把控发送全局,是很好的。image-20220427214748831

这就是异步的显示情况image-20220427214952066

我们通过改大数据,发现同步通信,每秒传输大概在2000个报文左右,异步通信则是80000个,这明显不是在一个数量级的

IO复用演示

image-20220427215450591

没有数据,直接返回,不会等待,有数据继续下面的流程,读取数据(现在只用知道这样就可以了,以后会详细讲解image-20220427215523611

image-20220427215724609

用while循环装起来,tcpread参数改为-1image-20220427215742193

15秒开始image-20220427215955244

51秒结束image-20220427220015707

但是仔细看,我们这个程序,其实是存在缺陷的,虽然都发过去了,但是采用的IO复用,不等待,所以,服务端还没来得及回应完全部

​ 所以,我们应该在for循环外面,再补接一下最后的结束,我们用一个计数器,来记录总共接收到的个数,确保接收完毕后及时退出for循环外的while循环(判断jj < 1000000)就退了image-20220427220517983

一百万之后,只接受ok,没有报文可以发了。

​ 采用IO复用技术实现异步通信,代码的开销比多进程和多线程要多一些,原因就是因为while循环哪些代码的开销,肯定比不上让一个进程或者线程在哪里等待image-20220427220729489

文件上传(异步)

​ 我们之前实现的上传功能是用的同步方式,接下来,我们尝试将它改成异步

​ 目前为止,我们的程序的效率,大概每秒上传100个左右的文件image-20220427221049221

​ 我们现在考虑采用IO技术来实现异步通信,虽然多进程和多线程的效率更高,但是进程或者线程之间,需要做同步,比较麻烦(考虑这次执行那个进程,这次执行那个线程,需要调度)

​ 在文件上传的主函数,定义一个文件数量的变量,每收到一个报文,这个delayed的值减一image-20220427221441002

​ 再while循环里面,当delayed还有剩余时,疯狂TcpRead,然后delayed一没有,就退出,最后再在while循环外面加追加接受对端确认报文的函数(基本一样=-=)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  // 接受对端的确认报文
while(delayed > 0){
memset(strrecvbuffer, 0, sizeof(strrecvbuffer));
if(TcpRead(TcpClient.m_connfd, strrecvbuffer, &buflen, -1) == false) break;

// logfile.Write("strrecvbuffer = %s\n", strrecvbuffer);

// 删除或者转存本地的文件
delayed--;
AckMessage(strrecvbuffer);
}
}
// 继续接受对端的确认报文
// read不用-1了,最多一两秒就能延迟结束,这里用10吧
while(delayed > 0){
memset(strrecvbuffer, 0, sizeof(strrecvbuffer));
if(TcpRead(TcpClient.m_connfd, strrecvbuffer, &buflen, 10) == false) break;

// logfile.Write("strrecvbuffer = %s\n", strrecvbuffer);

// 删除或者转存本地的文件
delayed--;
AckMessage(strrecvbuffer);
}

运行效果image-20220427222710108image-20220427222747172

5000个文件,大概在10秒左右,每秒500个左右,之前同步通信在100左右,异步比同步快五倍

收尾+优化

核心功能已经全部实现,接下来是优化细节

时间间隔优化

image-20220427222937938

每上传一次,就sleep这么多秒,我们说这样不是最合理的,为什么这么说?image-20220427222953525

​ 如果一个目录下不断地有文件生成,生成的个数和时间都是不定的,例如有20个文件,文件上传一次把20个文件给传出去了,但是传输的过程中又有文件不断生成,这些只能等到下一次sleep结束才能执行,我们可以做一些优化,每次执行文件传输任务的时候,如果有传输成功的文件,说明系统比较忙,那么在执行完这次文件传输之后,就不要sleep了,继续去执行文件传输的任务,如果某次执行了_tcpputfiles之后,文件传输返回失败,说明此时没有这么忙了,才让他去sleep。这么做应该更合理

​ 定义一个全局的bool变量,默认为true,再判断好空闲时候的处理,进入_tcpputfiles(),进去后把bcontinue设置为false,每获得一个文件就把bcontinue设置为true就好啦image-20220427223645345image-20220427223922059

心跳优化

启用心跳

image-20220427224055224

​ 接着就是不断的考虑,在可能需要花费时间的地方做进程的心跳,更新心跳(服务端和客户端一起)

​ 也要在服务端的每一个子进程里面做心跳,最开始使用AddPInfo创建心跳信息,后面while里面不断更新它!image-20220427225008110

文件下载image-20220428102338835

对客户端而言,文件下载,就是服务端的文件上传主函数,文件上传客户端的代码就是服务端的文件下载主函数代码

功能完善后,将此功能加入start.sh和killall.sh脚本,并且将两个文件系统的功能衔接起来使用

1
2
3
4
5
6
7
8
9
10
11
# 文件传输的服务端程序
/project/tools1/bin/procctl 10 /project/tools1/bin/fileserver 5005 /log/idc/fileserver.log

# 把目录/tmp/ftpputest中的文件上传到/tmp/tcpputest目录中。
/project/tools1/bin/procctl 20 /project/tools1/bin/tcpputfiles /log/idc/tcpputfiles_surfdata.log "<ip>127.0.0.1</ip><port>5005</port><ptype>1</ptype><clientpath>/tmp/ftpputest</clientpath><andchild>true</andchild><matchname>*.XML,*.CSV,*.JSON</matchname><srvpath>/tmp/tcpputest</srvpath><timetvl>10</timetvl><timeout>50</timeout><pname>tcpputfiles_surfdata</pname>"

# 把目录/tmp/tcpputest中的文件下载到/tmp/tcpgetest目录中。
/project/tools1/bin/procctl 20 /project/tools1/bin/tcpgetfiles /log/idc/tcpgetfiles_surfdata.log "<ip>127.0.0.1</ip><port>5005</port><ptype>1</ptype><srvpath>/tmp/tcpputest</srvpath><andchild>true</andchild><matchname>*.XML,*.CSV,*.JSON</matchname><clientpath>/tmp/tcpgetest</clientpath><timetvl>10</timetvl><timeout>50</timeout><pname>tcpgetfiles_surfdata</pname>"

# 清理采集的全国气象站点观测的分钟数据目录/tmp/tcpgetest中的历史数据文件。
/project/tools1/bin/procctl 300 /project/tools1/bin/deletefiles /tmp/tcpgetest "*" 0.02

学习总结

image-20220428132635971

  • 计算机网络基础,一定要系统的学习,面试的常考点,并且理论和实践结合才能走的更远

  • 没有封装的socketAPI,我们不知道该怎么开始实现程序

  • 多进程是一种比较传统的方法,对高并发系统不适用,但是对并发需求不是这么高的,是一种不错的选择,程序流程简单,代码实现也比较容易。

  • 实现采用TCP协议的文件传输和下载,这个是十分重要的系统,以后我们做的很多项目都可以使用它,只要学好了网络编程的基础知识,实现文件传输没有什么问题,重要的是采用异步通信提升性能,但是异步通信的实现就会带来编写代码的困难

    image-20220428133226638

    ​ 例如每个月有很多企业向银行发送转账信息,但是银行首先得保证自己不死,然后对每个企业进行限流,如果企业发多了就返回转账失败,这里我们做流量控制,方法有很多,最常用的就是滑动窗口,也是企业常用的,面试常考的

滑动窗口

计网学完记得闪耀回归~

五、MySQL数据库开发

image-20220428202945272

image-20220428203028751

​ 暂时不研究connection和sqlstatement的底层封装,现在能力有限

image-20220428203126644

image-20220428203204611

我们现在开始走马观花的跑一遍头文件

filetobuf和buftofile用于操纵二进制文件image-20220428211737090

CDA_DEF用于存放对数据操作的结果image-20220428211844815

connection连接类,有几个主要的功能,连接,提交,回滚,断开

image-20220428211926373

sqlstatement类用于操纵数据

这些都是到时候再说,下面我们来完成建库操作

MySQL建库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
* 程序名:createtable.cpp 此程序演示开发框架操作MySQL数据库(创建表)
* 作者:jjyaoao
*/

#include "_mysql.h" // 开发框架操作MySQL的头文件

int main(int argc, char *argv[]){
connection conn; // 数据库连接类

// 登录数据库,返回值:0-成功;其它是失败,存放了MySQL的错误代码。
// 失败代码在conn.m_cda.rc中,失败描述在conn.m_cda.message中。
if(conn.connecttodb("127.0.0.1,root,123456,h2,3306","utf8") != 0){
printf("connect database failed.\n%s\n", conn.m_cda.message);
return -1;
}

sqlstatement stmt(&conn); // 执行sql语句的对象
// sqlstatement stmt; stmt.connect(&conn); 和上面一行一样的意思 sqlstatement两个构造函数
stmt.prepare("create table girls(id bigint(10),\
name varchar(30),\
weight decimal(8,2),\
btime datetime,\
memo longtext,\
pic longblob,\
primary key (id))");
if (stmt.execute()!=0){
printf("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return -1;
}

return 0;
}

image-20220507141053221

插入数据

插入,修改代码流程基本完全一样

插入数据的程序虽然没能跑出来,但业务逻辑大抵是这样的:

  1. 登录数据库
  2. 定义存放单个信息的结构体
  3. 将结构体与操作数据库对象的每一个参数连接起来
  4. 执行插入
    1. 清空结构体一次
    2. 注入信息一次
    3. 执行一次操作:execute()
  5. 提交事务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/*
* 程序名:createtable.cpp 此程序演示开发框架操作MySQL数据库(创建表)
* 作者:jjyaoao
*/

#include "_mysql.h" // 开发框架操作MySQL的头文件

int main(int argc, char *argv[]){
connection conn; // 数据库连接类

// 登录数据库,返回值:0-成功;其它是失败,存放了MySQL的错误代码。
// 失败代码在conn.m_cda.rc中,失败描述在conn.m_cda.message中。
if(conn.connecttodb("127.0.0.1,root,123456,h2,3306","utf8") != 0){
printf("connect database failed.\n%s\n", conn.m_cda.message);
return -1;
}

// 定义用于超女信息的结构,与表中字段对应
struct st_girls{
long id; // 超女编号
char name[31]; // 超女姓名
double weight; // 超女体重
char btime[20]; // 报名时间
} stgirls;

sqlstatement stmt(&conn); // 操作SQL语句的对象

// 准备插入表的SQL语句 注意,时间哪里使用一个百分号会段错误,得用转译%,因为%d之类的,代表这需要一个整数... str_to_date(:4,'%%Y-%%m-%%d %%H:%%i:%%s'))");
stmt.prepare("\
insert into girls(id,name,weight,btime) values(:1,:2,:3,str_to_date(:4,'%%Y-%%m-%%d %%H:%%i:%%s'))");// :1 :2 :3 :4统一用?代替也可以,但?的兼容性不太好

//printf("%s", stmt.m_cda.message);
stmt.bindin(1, &stgirls.id);
stmt.bindin(2, stgirls.name, 30);
stmt.bindin(3, &stgirls.weight);
stmt.bindin(4, stgirls.btime, 19);

// 模拟超女数据,向表中插入5条测试数据
for(int i = 0; i < 5; i++){
memset(&stgirls, 0, sizeof(struct st_girls)); // 结构体变量初始化

// 为结构体变量的成员赋值
stgirls.id = i + 1; // 超女编号
sprintf(stgirls.name, "西施%05dgirl", i + 1); // 超女姓名
stgirls.weight = 45.25 + i; // 超女体重
sprintf(stgirls.btime, "2022-05-07 14:47:%02d", i);

if(stmt.execute() != 0){
printf("stmt.execute() failed.\n%s\n%s\n", stmt.m_sql, stmt.m_cda.message);
return -1;
}
printf("成功的插入了1条记录");
}

printf("insert table girls ok.\n");
conn.commit();//提交数据库事务,不提交会回滚事务
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
/*
注意事项:
1、参数的序号从1开始,连续、递增,参数也可以用问号表示,但是,问号的兼容性不好,不建议;
2、SQL语句中的右值才能作为参数,表名、字段名、关键字、函数名等都不能作为参数;
3、参数可以参与运算或用于函数的参数;例如:values(:1+1,:2,:3+45.35)
4、如果SQL语句的主体没有改变,只需要prepare()一次就可以了;
5、SQL语句中的每个参数,必须调用bindin()绑定变量的地址;
6、如果SQL语句的主体已改变,prepare()后,需重新用bindin()绑定变量;
7、prepare()方法有返回值,一般不检查,如果SQL语句有问题,调用execute()方法时能发现;
8、bindin()方法的返回值固定为0,不用判断返回值;
9、prepare()和bindin()之后,每调用一次execute(),就执行一次SQL语句,SQL语句的数据来自被绑定变量的值。
*/

查询数据

手动执行查询语句image-20220510210014029

与其他语句最大的不同点,在于查询语句需要返回一个结果集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
  int iminid,imaxid;  // 查询条件最小和最大的id。

// 准备查询表的SQL语句。
stmt.prepare("\
select id,name,weight,date_format(btime,'%%Y-%%m-%%d %%H:%%i:%%s') from girls where id>=:1 and id<=:2");
/*
注意事项:
1、如果SQL语句的主体没有改变,只需要prepare()一次就可以了;也就是说,prepare尽量放循环外,每prepare一次,会产生IO
2、结果集中的字段,调用bindout()绑定变量的地址;
3、bindout()方法的返回值固定为0,不用判断返回值;
4、如果SQL语句的主体已改变,prepare()后,需重新用bindout()绑定变量;
5、调用execute()方法执行SQL语句,然后再循环调用next()方法获取结果集中的记录;
6、每调用一次next()方法,从结果集中获取一条记录,字段内容保存在已绑定的变量中。
*/
// 为SQL语句绑定输入变量的地址,bindin方法不需要判断返回值。
stmt.bindin(1,&iminid);
stmt.bindin(2,&imaxid);
// 为SQL语句绑定输出变量的地址,bindout方法不需要判断返回值。
stmt.bindout(1,&stgirls.id);
stmt.bindout(2, stgirls.name,30);
stmt.bindout(3,&stgirls.weight);
stmt.bindout(4, stgirls.btime,19);

iminid=1; // 指定待查询记录的最小id的值。
imaxid=3; // 指定待查询记录的最大id的值。

// 执行SQL语句,一定要判断返回值,0-成功,其它-失败。
// 失败代码在stmt.m_cda.rc中,失败描述在stmt.m_cda.message中。
if (stmt.execute() != 0)
{
printf("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return -1;
}

// 本程序执行的是查询语句,执行stmt.execute()后,将会在数据库的缓冲区中产生一个结果集。
while (true)
{
memset(&stgirls,0,sizeof(struct st_girls)); // 结构体变量初始化。

// 从结果集中获取一条记录,一定要判断返回值,0-成功,1403-无记录,其它-失败。
// 在实际开发中,除了0和1403,其它的情况极少出现。
if (stmt.next()!=0) break;

// 把获取到的记录的值打印出来。
printf("id=%ld,name=%s,weight=%.02f,btime=%s\n",stgirls.id,stgirls.name,stgirls.weight,stgirls.btime);
}

// 请注意,stmt.m_cda.rpc变量非常重要,它保存了SQL被执行后影响的记录数。
printf("本次查询了girls表%ld条记录。\n",stmt.m_cda.rpc);

return 0;
}

二进制大对象

将本地大对象存入数据库里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
 // 定义用于超女信息的结构,与表中的字段对应。
struct st_girls
{
long id; // 超女编号
char pic[100000]; // 超女图片的内容。
unsigned long picsize; // 图片内容占用的字节数。
} stgirls;

sqlstatement stmt(&conn); // 操作SQL语句的对象。

// 准备修改表的SQL语句。
stmt.prepare("update girls set pic=:1 where id=:2");
stmt.bindinlob(1, stgirls.pic,&stgirls.picsize);
stmt.bindin(2,&stgirls.id);

// 修改超女信息表中id为1、2的记录。
for (int ii=1;ii<3;ii++)
{
memset(&stgirls,0,sizeof(struct st_girls)); // 结构体变量初始化。

// 为结构体变量的成员赋值。
stgirls.id=ii; // 超女编号。
// 把图片的内容加载到stgirls.pic中。
if (ii==1) stgirls.picsize=filetobuf("1.jpg",stgirls.pic);//这里放在同一个文件夹内,就可以使用相对地址
if (ii==2) stgirls.picsize=filetobuf("2.jpg",stgirls.pic);

// 执行SQL语句,一定要判断返回值,0-成功,其它-失败。
// 失败代码在stmt.m_cda.rc中,失败描述在stmt.m_cda.message中。
if (stmt.execute()!=0)
{
printf("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return -1;
}

printf("成功修改了%ld条记录。\n",stmt.m_cda.rpc); // stmt.m_cda.rpc是本次执行SQL影响的记录数。
}

printf("update table girls ok.\n");

conn.commit(); // 提交数据库事务。

return 0;
}

从数据库取出大对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 准备查询表的SQL语句。
stmt.prepare("select id,pic from girls where id in (1,2)");
stmt.bindout(1,&stgirls.id);
stmt.bindoutlob(2, stgirls.pic,100000,&stgirls.picsize);

// 执行SQL语句,一定要判断返回值,0-成功,其它-失败。
// 失败代码在stmt.m_cda.rc中,失败描述在stmt.m_cda.message中。
if (stmt.execute()!=0)
{
printf("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return -1;
}

// 本程序执行的是查询语句,执行stmt.execute()后,将会在数据库的 缓冲区 中产生一个结果集。
while (true)
{
memset(&stgirls,0,sizeof(stgirls)); // 先把结构体变量初始化。

// 从结果集中获取一条记录,一定要判断返回值,0-成功,1403-无记录,其它-失败。
// 在实际开发中,除了0和1403,其它的情况极少出现。
if (stmt.next()!=0) break;

// 生成文件名。
char filename[101]; memset(filename,0,sizeof(filename));
sprintf(filename,"%d_out.jpg",stgirls.id);

// 把内容写入文件。
buftofile(filename,stgirls.pic,stgirls.picsize);
}

// 请注意,stmt.m_cda.rpc变量非常重要,它保存了SQL被执行后影响的记录数。
printf("本次查询了girls表%ld条记录。\n",stmt.m_cda.rpc);

return 0;
}

大对象操作的启示

这样数据库就不会有压力~~

image-20220511110149257

数据库开发注意事项和技巧

注意事项

一个connection对象同一时间仅连一个数据库,多个connection可以同时连多个数据库,并且每个对象之间的处理,不互相影响,按照顺序依次执行

image-20220511194657524

image-20220511195036825

​ 这个很容易理解,试想一下,如果一个进程要求提交事务,另一个进程却想要回滚,自然只会满足一个的需求,也就是报错了,虽然我们可以选择,比如给数据库连接加锁之类的方法,但是,他仍然是很麻烦的,不如我们先fork()后再登陆image-20220511195419309

这样的话,就没有问题了image-20220511195441666

image-20220511195513385

​ 这句话也就是说,在一个数据库的连接中,可以执行多条sql语句(sqlstatement是执行sql语句的对象)

image-20220511195604976

​ 并且注意,在这里直接execute(),并没有使用prepare,是因为我们封装了方法,如果没有绑定输入和输出变量,那我们可以直接execute(),这样是为了我们写代码的方便

image-20220511195907921

这一点是MySQL的缺点,给我们带来了不少麻烦

image-20220511200212767image-20220511200233370

​ 2014错误,百度的大概意思就是上面的,没有取完之前不能执行,增加一行代码while(只取结果集),最后就不会报错了,具体解决方法,等以后有应用背景了再回来激战

image-20220511200341659

应用技巧

image-20220511200457565

首先我们来演示在命令行是如何处理的….

1
2
3
4
5
6
7
8
9
10
insert into girls(id) values(30);
insert into girls(id, weight) values(31, null);
insert into girls(id, weight) values(32, '45.33');
insert into girls(id, weight) values(33, '');
-- ERROR 1366 (HY000): Incorrect decimal value: '' for column 'weight' at row 1
select * from girls;
| 30 | NULL | NULL | NULL | NULL | NULL |
| 31 | NULL | NULL | NULL | NULL | NULL |
| 32 | NULL | 45.33 | NULL | NULL | NULL |
+----+-----------------+--------+---------------------+------+------+

其次,在程序中

1
stmt.prepare("insert into girls(id,name,weight,btime) values(:1,:2,:3,str_to_date(:4,'%%Y-%%m-%%d %%H:%%i:%%s'))");

我们不可能提前知道weight会不会填 空或者不写

有一个解决方法,我们把体重改成字符串

1
double  weight;     // 超女体重       ---------------->    char	 weight[10];

用框架bindin,可以直接填'' ,因为我们做了封装,现在不研究,因为并不容易,以后有实力的再研究

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 处理字符串字段为空的情况。
for (int ii=0;ii<maxbindin;ii++)
{
if (params_in[ii].buffer_type == MYSQL_TYPE_VAR_STRING )
{
if (strlen((char *)params_in[ii].buffer)==0)
{
params_in_is_null[ii]=true;
}
else
{
params_in_is_null[ii]=false;
params_in_length[ii]=strlen((char *)params_in[ii].buffer);
}
}

if (params_in[ii].buffer_type == MYSQL_TYPE_BLOB )
{
if ((*params_in[ii].length)==0)
params_in_is_null[ii]=true;
else
params_in_is_null[ii]=false;
}
}

注意:这个技巧非常重要,以后工作中应该用得上,面试也可能会被问起

我们可以理解为:只要不做计算,我们对数据的存放,都统一采用字符串!

​ 当然,数据变为字符串,对于浮点数而言我们通常需要将其变为整数,方便存储,这里定义一个临时变量(和wf一样,char类型 [11])

1
2
snprintf(stzhobtmind.wf,10,"%d",(int)(atof(tmp)*10));
// 将tmp字符串的字符转为整数,再乘10(说明tmp里本身小数点后一位),最后存入stzhobtmind.wf(存十位进去,wf为char类型,[11])

强大的PowerDesigner

自增字段,一定要设置为键,而不是组件

最重要的,表名(General),列(Columns),索引(Indexes),键(Keys)

次重要 物理选项(Physical Options):可能以后会设置一些物理参数

站点参数文件入库

这里我们继续做项目……

image-20220512162250959

这里有一个细节,为什么经纬度明明是浮点数,我们却用整数,原因很简单:

  1. 整数运算速度快,相比于浮点数复杂的加减乘除
  2. 整数可以代替浮点数,只要我们记住了保留小数点后多少位
    1. 例如在银行查询余额,如果还剩下1.25元,那么可以使用125在底层存放,只需要在对应的位置加上小数点就可以了
    2. 这样我们使用的存储空间更小,运算的效率也更高

业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int main(int argc, char *argv[]){
// 帮助文档

// 处理程序退出的信号

// 打开日志文件

// 把全国站点参数文件加载到vstcode容器中

// 连接数据库

// 准备插入表的SQL语句

// 准备更新表的SQL语句

//遍历vstcode容器
for(int i = 0; i < vstcode.size(); i++){
// 从容器中取出一条记录放入结构体中

// 执行插入的SQL语句

// 如果记录已存在,执行更新的SQL语句

}

// 提交事务

return 0;
}

这里我们提几点

  1. 为什么要用vstcode加载到容器中,而不直接打开文件,一行一行的循环读入呢?
  2. 处理方法本来就不唯一,习惯哪一种写法,就写哪一种写法
  3. 连接数据库的代码一定要放在加载参数文件之后,如果加载参数失败,后面的流程根本就不需要继续,数据库也不用连了。
  4. 判断记录已存在的方法,是判断SQL语句返回的结果,如果记录已存在,那么插入记录返回的结果是1062
    1. image-20220512164959900

开始战斗

在我们的表里,将mysql数据库的height故意设置为可以为空,这里是为了处理之前留下的应用技巧(就上上上个标题)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mysql> desc T_ZHOBTCODE;
+----------+-------------+------+-----+-------------------+-----------------------------+
| Field | Type | Null | Key | Default | Extra |
+----------+-------------+------+-----+-------------------+-----------------------------+
| obtid | varchar(10) | NO | PRI | NULL | |
| cityname | varchar(30) | NO | | NULL | |
| provname | varchar(30) | NO | | NULL | |
| lat | int(11) | NO | | NULL | |
| lon | int(11) | NO | | NULL | |
| height | int(11) | YES | | NULL | |
| upttime | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
| keyid | int(11) | NO | MUL | NULL | |
+----------+-------------+------+-----+-------------------+-----------------------------+
8 rows in set (0.00 sec)
1
2
3
4
5
6
7
8
9
10
// 全国气象站点参数结构体。    将原本double的lat lon height 全员改为char
struct st_stcode
{
char provname[31]; // 省
char obtid[11]; // 站号
char cityname[31]; // 站名
char lat[11]; // 纬度
char lon[11]; // 经度
char height[11]; // 海拔高度
};

心跳进程小技巧

​ 由于该程序通常来讲执行之间都不会超过1s,所以心跳处理非常简单,并且如果要用GDB调试,可以考虑注释掉心跳(防止被守护进程杀掉),或者心跳时间调的足够长image-20220512193719703

1
2
3
PActive.AddPInfo(10,"obtcodetodb");   // 进程的心跳,10秒足够。
// 注意,在调试程序的时候,可以启用类似以下的代码,防止超时。
// PActive.AddPInfo(5000,"obtcodetodb");

站点数据入库

image-20220514113120416

基础业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 业务处理主函数。
bool _obtmindtodb(char *pathname,char *connstr,char *charset){
// 打开目录

while (true){
// 读取目录,得到一个数据文件名

// 打开文件

while(true){
// 处理文件中的每一行
}

// 删除文件、提交事务
}

return true;
}

接下来我们要创建表

其中有一个细节:

对这个唯一索引而言,是时间在前,obtid在后

image-20220514122449901

而对于主键索引而言,是先obtid(站点代码)后数据时间

image-20220514122703226

image-20220514122423373

因此,写这两行语句,利用的东西是不同的。

​ 对于这个程序,我们只提供插入功能,不提供修改功能,原因是这样的:

  • 观测数据是由观测设备产生,比如说温度,设备感应到是35度,就是35度,这个错了就错了,没办法改变,如果设备的传感器出了问题,可以把数据标志为不可用,但是修改数据是不可能的,这么多设备,产生那么多数据,根本改不过来的。
  • 也可以换一种思维方式:设备已经出问题了,那么就没有正确的数据了,那么,既然没有正确的数据,哪来的修改?

正式业务demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 业务处理主函数。
bool _obtmindtodb(char *pathname,char *connstr,char *charset){
sqlstatement stmt;

CDir Dir;
// 打开目录
if(Dir.OpenDir(pathname, "*.xml") == false){
logfile.Write("Dir.OpenDir(%s) failed.\n", pathname);
return false;
}

CFile File;

while (true){
// 读取目录,得到一个数据文件名
if(Dir.ReadDir() == false) break;

// 连接数据库。
if(conn.m_state == 0){
if (conn.connecttodb(connstr, charset)!=0) {
logfile.Write("connect database(%s) failed.\n%s\n", connstr, conn.m_cda.message); return -1;
}
}
logfile.Write("connect database(%s) ok.\n", connstr);

if(stmt.m_state == 0){
stmt.connect(&conn);
stmt.prepare("insert into T_ZHOBTMIND(obtid, ddatetime, t, p, u, wd, wf, r, vis) \
values(:1, str_to_date(:2, '%%Y%%m%%d%%H%%i%%s'), :3, :4, :5, :6, :7, :8, :9)");
stmt.bindin(1, stzhobtmind.obtid, 10);// bindin2 bindin3..........
}
logfile.Write("filename = %s\n", Dir.m_FullFileName);

// 打开文件
if(File.Open(Dir.m_FullFileName, "r") == false){
logfile.Write("File.Open(%s) failed.\n", Dir.m_FullFileName);
return false;
}

char strBuffer[1001]; // 存放从文件中读取的一行。
while(true){
if(File.FFGETS(strBuffer, 1000, "<endl/>") == false) break;
logfile.Write("strBuffer=%s", strBuffer);
// 处理文件中的每一行
memset(&stzhobtmind, 0, sizeof(struct st_zhobtmind));
GetXMLBuffer(strBuffer, "obtid", stzhobtmind.obtid, 10);
GetXMLBuffer(strBuffer,"ddatetime",stzhobtmind.ddatetime,14);
char tmp[11]; // 统一单位,使得与数据库中相同
GetXMLBuffer(strBuffer, "t", tmp, 10); if(strlen(tmp) > 0) snprintf(stzhobtmind.t, 10, "%d", (int)(atof(tmp)*10));
GetXMLBuffer(strBuffer,"u",stzhobtmind.u,10);
// 这里留两个样例,展示tmp的作用,.u因为已经是整数,所以无需处理
// 把结构体中的数据插入表中
if(stmt.execute() != 0){
// 1、失败的情况有哪些?是否全部的失败都要写日志?
// 答:失败的原因主要有二:一是记录重复,二是数据内容非法。
// 2、如果失败了怎么办?程序是否需要继续?是否rollback?是否返回false?
// 答:如果失败的原因是数据内容非法,记录日志后继续;如果是记录重复,不必记录日志,且继续。
if(stmt.m_cda.rc != 1062){
logfile.Write("Buffer = %s\n", strBuffer);
logfile.Write("stmt.execute() failed.\n%s\n%s\n", stmt.m_sql, stmt.m_cda.message);
}
}
}

// 删除文件、提交事务
//File.CloseAndRemove(); 因为测试,暂时先关闭,不然没数据了

conn.commit();
}

return true;
}

优化业务

任务一:优化日志内容

​ 现在的太乱,基本没法看image-20220515114422623

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 业务处理主函数。
bool _obtmindtodb(char *pathname,char *connstr,char *charset){
// 打开目录
int totalcount = 0; // 文件的总记录数
int insertcount = 0; // 成功插入的记录数
CTimer Timer; // 计时器,记录每个文件的处理耗时

while (true){
// 读取目录,得到一个数据文件名

totalcount = insertcount = 0;
// 打开文件

while(true){
// 处理文件中的每一行
totalcount++;
}
if(stmt.execute() != 0){
........
}else insertcount++;

// 删除文件、提交事务
}
logfile.Write("已处理文件%s(totalcount=%d, insertcount = %d), 耗时%.2f秒。\n", \
Dir.m_FullFileName, totalcount, insertcount, Timer.Elapsed());
return true;
}

image-20220515115632110

​ 现在的日志就非常的整齐,不过我们可以看到还有一个问题,就是totalcount并不是单个文件的totalcount,所以我们可以在打开文件的上端初始化totalcount 和 insertcount,这里insertcount为何为0,其实是因为,之前已经插入过一次了,但是数据来说,还是旧数据反复运行,所以自然有了的就不会再插入咯image-20220515124616036

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TYPE="Ethernet"
PROXY_METHOD="none"
BROWSER_ONLY="no"
BOOTPROTO=static
DEFROUTE="yes"
IPV4_FAILURE_FATAL="no"
IPV6INIT="yes"
IPV6_AUTOCONF="yes"
IPV6_DEFROUTE="yes"
IPV6_FAILURE_FATAL="no"
IPV6_ADDR_GEN_MODE="stable-privacy"
NAME="ens33"
UUID="68f5143d-d91c-4c4c-b55e-523ee2c7ee02"
DEVICE="ens33"
ONBOOT="yes"
IPADDR=192.168.211.129
NETMASK=255.255.255.0
GATEWAY=192.168.133.1
DNS1=114.114.114.114

任务二:处理冗余的程序

​ 我们可以想象,如果我们的操作越来越多,那么程序只会越来越复杂,比如这里提到的表的字段50个,那我们光绑定参数,都要花100行,显然这是很没有必要的空间花费,我们说,还是想让程序变得优雅起来的。

image-20220515160653223

​ 接下来我们尝试把数据结构,数据解析,和对表的操作封装成一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 全国站点分钟测试数据操作类
class CZHOBTMIND{
public:
connection *m_conn; // 数据库连接
CLogFile *m_logfile; // 日志

sqlstatement m_stmt; // 插入表操作的sql

char m_buffer[1024]; // 从文件中读到的一行
struct st_zhobtmind m_zhobtmind; // 全国站点分钟观测数据

CZHOBTMIND();
CZHOBTMIND(connection *conn, CLogFile *logfile);

~CZHOBTMIND();

void BindConnLog(connection *conn, CLogFile *logfile); // 把connection和CLogFile的传进去
bool SplitToBuffer(char *strBuffer); // 把从文件读到的一行数据拆分到m_zhobtmind结构体中
bool InsertTable(); // 把m_zhobtmind结构体中的数据插入到T_ZHOBTMIND表中
};

CZHOBTMIND::CZHOBTMIND(){
m_conn=0; m_logfile=0;
}

CZHOBTMIND::CZHOBTMIND(connection *conn, CLogFile *logfile){
m_conn=conn;
m_logfile=logfile;
}

CZHOBTMIND::~CZHOBTMIND(){
}

void CZHOBTMIND::BindConnLog(connection *conn, CLogFile *logfile){
m_conn=conn;
m_logfile=logfile;
}

// 把从文件读到的一行拆分到m_zhobtmind结构体中
bool CZHOBTMIND::SplitToBuffer(char *strBuffer){
memset(&m_zhobtmind, 0, sizeof(struct st_zhobtmind));
GetXMLBuffer(strBuffer, "obtid", m_zhobtmind.obtid, 10);
char tmp[11]; // 统一单位,使得与数据库中相同
GetXMLBuffer(strBuffer, "t", tmp, 10); if(strlen(tmp) > 0) snprintf(m_zhobtmind.t, 10, "%d", (int)(atof(tmp)*10));
.....

STRCPY(m_buffer, sizeof(m_buffer), strBuffer); // 注意这里要传过去

return true;

}

// 把m_zhobtmind结构体中的数据插入到T_ZHOBTMIND表中
bool CZHOBTMIND::InsertTable(){
if(m_stmt.m_state == 0){
m_stmt.connect(m_conn);
m_stmt.prepare("insert into T_ZHOBTMIND(obtid, ddatetime, t, p, u, wd, wf, r, vis) \
values(:1, str_to_date(:2, '%%Y%%m%%d%%H%%i%%s'), :3, :4, :5, :6, :7, :8, :9)");
m_stmt.bindin(1, m_zhobtmind.obtid, 10);
......
}
// 把结构体中的数据插入表中
if(m_stmt.execute() != 0){
// 1、失败的情况有哪些?是否全部的失败都要写日志?
// 答:失败的原因主要有二:一是记录重复,二是数据内容非法。
// 2、如果失败了怎么办?程序是否需要继续?是否rollback?是否返回false?
// 答:如果失败的原因是数据内容非法,记录日志后继续;如果是记录重复,不必记录日志,且继续。
if(m_stmt.m_cda.rc != 1062){
m_logfile -> Write("Buffer = %s\n", m_buffer);
m_logfile -> Write("m_stmt.execute() failed.\n%s\n%s\n", m_stmt.m_sql, m_stmt.m_cda.message);
}
return false;
}
return true;
}

// 业务处理主函数。
bool _obtmindtodb(char *pathname,char *connstr,char *charset){
// 处理文件中的每一行
totalcount++;

ZHOBTMIND.SplitToBuffer(strBuffer);

if(ZHOBTMIND.InsertTable() == true) insertcount++;
}

​ 这样的话,无论代码有多长(插入绑定bindin,解析xml),我们都只需要两行

任务三:程序模块分离

​ 我们仔细思考不难得到,在数据中心项目中,这种得到数据,然后上传库的程序,他们的逻辑是完全相同的,并且可以这么说,有多少种数据,就得到了多少个程序。并且,我们定义的结构体,在其他程序中也有可能用得上(存放站点代码,数据时间,温度….),我们定义的数据操作类也是同样的道理,那么,这些代码是不是可以分离出去?

​ 我们可以这样,为这个项目,创造一个头文件和一个cpp文件。

  • 结构体和类的声明分离到头文件中
  • 类的实现代码,分离到cpp文件中

所以到了这里,我又去百度了头文件,cpp文件这些标准写法,果然不断学习,就会不断有新的感悟.

(23条消息) C语言笔记——自定义头文件_Sunrise的博客的博客-CSDN博客_c语言自定义头文件

定义了idcapp.h 和 idcapp.cpp将其冗余部分存放

任务四:对csv格式的支持

进入业务处理主函数

  1. 修改Dir.OpenDir里面的代码,加入支持读入*.csv
  2. 加入bool变量isxml true 为 xml false 为 csv
  3. 读取目录,得到数据文件名后,判断他的后缀,根据后缀修改isxml的值
  4. 读取文件的每一行时候,更改读取结束时候的判断条件
  5. SplitBuffer增加传入参数 bisxml
一个细节报错

这里面这个if语句,是有歧义的

1
2
3
4
5
6
7
8
9
10
char strBuffer[1001];   // 存放从文件中读取的一行。
while(true){
if(isxml == true)
if(File.FFGETS(strBuffer, 1000, "<endl/>") == false) break;
else{
if(File.Fgets(strBuffer, 1000, true) == false) break;
if(strstr(strBuffer, "站点") != 0) continue; // 把文件中的第一行扔掉
}
// 处理文件中的每一行
totalcount++;

他还可以解析成这样

1
2
3
4
5
6
if(isxml == true)
if(File.FFGETS(strBuffer, 1000, "<endl/>") == false) break;
else{
if(File.Fgets(strBuffer, 1000, true) == false) break;
if(strstr(strBuffer, "站点") != 0) continue; // 把文件中的第一行扔掉
}

另外一个惨痛教训=-=。由于OpenDir不是我封装的,我习惯性的打了空格

1
2
3
4
5
6
CDir Dir;
// 打开目录
if(Dir.OpenDir(pathname, "*.xml, *.csv") == false){// 空格在这
logfile.Write("Dir.OpenDir(%s) failed.\n", pathname);
return false;
}

最后,就csv测试,日志没有任何东西(我把xml的文件都删除了,只留下csv的),奇了怪了=-=,原来不能加空格啊!

1
2
3
4
5
6
CDir Dir;
// 打开目录
if(Dir.OpenDir(pathname, "*.xml,*.csv") == false){// 空格在这
logfile.Write("Dir.OpenDir(%s) failed.\n", pathname);
return false;
}

image-20220515181047608

最后顺利运行。

最后收尾工作

启用删除业务代码

1
2
// 删除文件、提交事务
File.CloseAndRemove();

心跳从5000s改为10s

1
2
3
PActive.AddPInfo(30,"obtmindtodb");   // 进程的心跳,30秒足够。
// 注意,在调试程序的时候,可以启用类似以下的代码,防止超时。
//PActive.AddPInfo(5000,"obtmindtodb");

​ 现在,我们已经彻底完工,不过,还不能让程序通过调度程序跑起来,因为一旦跑起来之后,数据是无穷无尽的,很快就会把磁盘空间给占满,因此,我们还可以提供一个小的脚本文件,来使得动态的删除清空

执行SQL脚本文件

​ 现在我们有一个新的需求,希望定期执行sql脚本,并清理历史数据,就像之前开发的,清理历史文件一样image-20220515195254820

在linux下面,可以直接这样执行,注意只是报警,并没有报错,不过这里使用了输入重定向功能,而我们的调度程序并不支持,所以只能自己写hhhc

流程

程序流程也十分简单,打开文件,一行一行的读取出来,执行,参数也无需绑定

具体过程是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
   // 打开日志文件
if(logfile.Open(argv[4], "a+") == false){
printf("打开日志文件失败(%s).\n", argv[4]);
return -1;
}

PActive.AddPInfo(500,"obtcodetodb"); // 进程的心跳
// 注意,在调试程序的时候,可以启用类似以下的代码,防止超时。
// PActive.AddPInfo(5000,"obtcodetodb");

// 连接数据库,不启用事务。
if (conn.connecttodb(argv[2], argv[3], 1)!=0){
logfile.Write("connect database(%s) failed.\n%s\n", argv[2], conn.m_cda.message);
return -1;
}

logfile.Write("connect database(%s) ok.\n", argv[2]);

CFile File;

// 打开SQL文件。
if (File.Open(argv[1], "r")==false){
logfile.Write("File.Open(%s) failed.\n", argv[1]);
EXIT(-1);
}

char strsql[1001]; // 存放从SQL文件中读取的SQL语句。

while (true)
{
memset(strsql, 0, sizeof(strsql));

// 从SQL文件中读取以分号结束的一行。
if (File.FFGETS(strsql, 1000, ";") == false) break;

// 如果第一个字符是#,注释,不执行。
if (strsql[0] == '#') continue;

// 删除掉SQL语句最后的分号。也许有更好的方式
char *pp = strstr(strsql, ";");
if (pp == 0) continue;
pp[0] = 0;

logfile.Write("%s\n", strsql);

int iret = conn.execute(strsql); // 执行SQL语句。

// 把SQL语句执行结果写日志。
if (iret == 0) logfile.Write("exec ok(rpc=%d).\n", conn.m_cda.rpc);
else logfile.Write("exec failed(%s).\n", conn.m_cda.message);

PActive.UptATime(); // 进程的心跳。
}

logfile.WriteEx("\n");

image-20220515202710261

接下来再做之前,做镜像,把网络连上,首先换源阿里云,然后配置ftp环境,重新启动调度一系列

搭建ftp

CentOS搭建ftp服务器 - ismallboy - 博客园 (cnblogs.com)

Centos之FTP服务器的搭建与配置_Qwzf的博客-CSDN博客_centos 连接ftp服务器

换源

CentOS7更换国内源 - Jankin-Wen - 博客园 (cnblogs.com)

CentOS7更换国内源 - 蝉声且送阳西 - 博客园 (cnblogs.com)

固定ip

centos配置固定IP(只需三步)_周星星_9527的博客-CSDN博客_centos 固定ip

虚拟机VMware下载与安装教程(详细)_-借我杀死庸碌的情怀-的博客-CSDN博客_vmware

(23条消息) 解决CentOS7虚拟机无法上网并设置CentOS7虚拟机使用静态IP上网_a785975139的博客-CSDN博客_centos7虚拟机网络配置

Linux Centos 安装配置,Centos7设置静态IP地址不能上网 - 蕃薯耀 - 博客园 (cnblogs.com)

六、开发数据抽取子系统

image-20220725105358312

​ 对于每个子系统,它们都会拥有它们自己的数据库,那么我们需要思考一下,如何将每一个子系统的数据,融入数据中心总系统中。

有一种简单的想法,是可以直接在两个系统之间建立connect类,然后通过插入语句使得两表相连,这样是一个思路,但也有一些问题,其中主要的就是有的时候我们不能直接操纵两个表。

image-20220725105632080

​ 比如政府,省气象局等等的数据,我们不可能直接调用它的数据库,因此采用一个数据抽取的模块,利用模块化编程的思想,会使得我们的工作更佳轻松,并且条例清晰

开发目标

image-20220725115409858

搭建框架

宏结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 程序运行参数的结构体
struct st_arg{ //注意都多了1,因为char数组最后要留给\0一个位置
char connstr[101]; // 数据库的连接参数。
char charset[51]; // 数据库的字符集。
char selectsql[1024]; // 从数据源数据库抽取数据的SQL语句。
char fieldstr[501]; // 抽取数据的SQL语句输出结果集字段名,字段名之间用逗号分隔。
char fieldlen[501]; // 抽取数据的SQL语句输出结果集字段的长度,用逗号分隔。
char bfilename[31]; // 输出xml文件的前缀。
char efilename[31]; // 输出xml文件的后缀。
char outpath[301]; // 输出xml文件存放的目录。
char starttime[52]; // 程序运行的时间区间
char incfield[31]; // 递增字段名。
char incfilename[301]; // 已抽取数据的递增字段最大值存放的文件。
int timeout; // 进程心跳的超时时间。
char pname[51]; // 进程名,建议用"dminingmysql_后缀"的方式。
} starg;

#define MAXFIELDCOUNT 100 // 结果集字段的最大数。
//#define MAXFIELDLEN 500 // 结果集字段值的最大长度。
int MAXFIELDLEN=-1; // 结果集字段值的最大长度,存放fieldlen数组中元素的最大值。

char strfieldname[MAXFIELDCOUNT][31]; // 结果集字段名数组,从starg.fieldstr解析得到。
int ifieldlen[MAXFIELDCOUNT]; // 结果集字段的长度数组,从starg.fieldlen解析得到。
int ifieldcount; // strfieldname和ifieldlen数组中有效字段的个数。
int incfieldpos=-1; // 递增字段在结果集数组中的位置。

帮助文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void _help(){
printf("Using:/project/tools1/bin/dminingmysql logfilename xmlbuffer\n\n");

printf("Sample:/project/tools1/bin/procctl 3600 /project/tools1/bin/dminingmysql /log/idc/dminingmysql_ZHOBTCODE.log \"<connstr>127.0.0.1,root,mysqlpwd,mysql,3306</connstr><charset>gbk</charset><selectsql>select obtid,cityname,provname,lat,lon,height from T_ZHOBTCODE</selectsql><fieldstr>obtid,cityname,provname,lat,lon,height</fieldstr><fieldlen>10,30,30,10,10,10</fieldlen><bfilename>ZHOBTCODE</bfilename><efilename>HYCZ</efilename><outpath>/idcdata/dmindata</outpath><timeout>30</timeout><pname>dminingmysql_ZHOBTCODE</pname>\"\n\n");
printf(" /project/tools1/bin/procctl 30 /project/tools1/bin/dminingmysql /log/idc/dminingmysql_ZHOBTMIND.log \"<connstr>127.0.0.1,root,mysqlpwd,mysql,3306</connstr><charset>gbk</charset><selectsql>select obtid,date_format(ddatetime,'%%%%Y-%%%%m-%%%%d %%%%H:%%%%i:%%%%s'),t,p,u,wd,wf,r,vis,keyid from t_zhobtmind where keyid>:1 and ddatetime>timestampadd(minute,-120,now())</selectsql><fieldstr>obtid,ddatetime,t,p,u,wd,wf,r,vis,keyid</fieldstr><fieldlen>10,19,8,8,8,8,8,8,8,15</fieldlen><bfilename>ZHOBTMIND</bfilename><efilename>HYCZ</efilename><outpath>/idcdata/dmindata</outpath><starttime></starttime><incfield>keyid</incfield><incfilename>/idcdata/dmining/dminingmysql_ZHOBTMIND_HYCZ.list</incfilename><timeout>30</timeout><pname>dminingmysql_ZHOBTMIND_HYCZ</pname>\"\n\n");

printf("本程序是数据中心的公共功能模块,用于从mysql数据库源表抽取数据,生成xml文件。\n");
printf("logfilename 本程序运行的日志文件。\n");
printf("xmlbuffer 本程序运行的参数,用xml表示,具体如下:\n\n");

printf("connstr 数据库的连接参数,格式:ip,username,password,dbname,port。\n");
printf("charset 数据库的字符集,这个参数要与数据源数据库保持一致,否则会出现中文乱码的情况。\n");
printf("selectsql 从数据源数据库抽取数据的SQL语句,注意:时间函数的百分号%需要四个,显示出来才有两个,被prepare之后将剩一个。\n");
printf("fieldstr 抽取数据的SQL语句输出结果集字段名,中间用逗号分隔,将作为xml文件的字段名。\n");
printf("fieldlen 抽取数据的SQL语句输出结果集字段的长度,中间用逗号分隔。fieldstr与fieldlen的字段必须一一对应。\n");
printf("bfilename 输出xml文件的前缀。\n");
printf("efilename 输出xml文件的后缀。\n");
printf("outpath 输出xml文件存放的目录。\n");
printf("starttime 程序运行的时间区间,例如02,13表示:如果程序启动时,踏中02时和13时则运行,其它时间不运行。"\
"如果starttime为空,那么starttime参数将失效,只要本程序启动就会执行数据抽取,为了减少数据源"\
"的压力,从数据库抽取数据的时候,一般在对方数据库最闲的时候时进行。\n");
printf("incfield 递增字段名,它必须是fieldstr中的字段名,并且只能是整型,一般为自增字段。"\
"如果incfield为空,表示不采用增量抽取方案。");
printf("incfilename 已抽取数据的递增字段最大值存放的文件,如果该文件丢失,将重新抽取全部的数据。\n");
printf("timeout 本程序的超时时间,单位:秒。\n");
printf("pname 进程名,尽可能采用易懂的、与其它进程不同的名称,方便故障排查。\n\n\n");

}

参数解析

​ 这里主要体现最核心的改动,是基于宏结构体新加入的结果段而制作的,因为我们现在要做的是一个通用的模块,所以我们必须考虑兼容的情况,就会多花一些功夫

主要功能是:

  • 获取字段数量
  • 获取每一个字段的name
  • 核查字段数量是否一一对应
  • 得到想要字段的索引
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
bool _xmltoarg(char *strxmlbuffer){
memset(&starg,0,sizeof(struct st_arg));
....................

// 1、把starg.fieldlen解析到ifieldlen数组中;
CCmdStr CmdStr;

// 1、把starg.fieldlen解析到ifieldlen数组中;
// ifieldlen为CCmdStr自带的容器,当切断变量时,处理过的数据默认保存在内
CmdStr.SplitToCmd(starg.fieldlen,",");

// 判断字段数是否超出MAXFIELDCOUNT的限制。
if (CmdStr.CmdCount()>MAXFIELDCOUNT){
logfile.Write("fieldlen的字段数太多,超出了最大限制%d。\n",MAXFIELDCOUNT); return false;
}

for (int ii=0;ii<CmdStr.CmdCount();ii++){
CmdStr.GetValue(ii,&ifieldlen[ii]);
// if (ifieldlen[ii]>MAXFIELDLEN) ifieldlen[ii]=MAXFIELDLEN; // 字段的长度不能超过MAXFIELDLEN。
// 这里将MAXFIELDLEN从宏改为了变量,一旦出现溢出情况,方便扩容,不存在切断字段的风险。
if (ifieldlen[ii]>MAXFIELDLEN) MAXFIELDLEN=ifieldlen[ii]; // 得到字段长度的最大值。
}

ifieldcount=CmdStr.CmdCount();

// 2、把starg.fieldstr解析到strfieldname数组中;
CmdStr.SplitToCmd(starg.fieldstr,",");

// 判断字段数是否超出MAXFIELDCOUNT的限制。
if (CmdStr.CmdCount()>MAXFIELDCOUNT){
logfile.Write("fieldstr的字段数太多,超出了最大限制%d。\n",MAXFIELDCOUNT); return false;
}

for (int ii=0;ii<CmdStr.CmdCount();ii++){
CmdStr.GetValue(ii,strfieldname[ii],30);
}

// 判断strfieldname和ifieldlen两个数组中的字段是否一致。
if (ifieldcount!=CmdStr.CmdCount()){
logfile.Write("fieldstr和fieldlen的元素数量不一致。\n"); return false;
}

// 3、获取自增字段在结果集中的位置。
if (strlen(starg.incfield)!=0){
for (int ii=0;ii<ifieldcount;ii++)
// strcmp用于判断想要检测的索引是否在当前容器中,若在则返回0,并用incfieldpos记录此时的索引
if (strcmp(starg.incfield,strfieldname[ii])==0) { incfieldpos=ii; break; }

if (incfieldpos==-1){
logfile.Write("递增字段名%s不在列表%s中。\n",starg.incfield,starg.fieldstr); return false;
}
}

return true;
}

全量抽取数据主功能

​ 全量抽取就是原封不动的抽取数据,比较适合全国站点参数表,因为毕竟站点可能就几千个。

image-20220727152302517

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 上传文件功能的主函数
bool _dminingmysql(){
sqlstatement stmt(&conn);
stmt.prepare(starg.selectsql);
char strfieldvalue[ifieldcount][MAXFIELDLEN+1]; // 抽取数据的SQL执行后,存放结果集字段值的数组。
for (int ii=1;ii<=ifieldcount;ii++){
// 这里为了防止绑定的字段长度大于MAXFIELDLEN,所以对宏做了处理,
// 改为变量,利用if语句,一旦出现超过的,那么就赋值max为该值
stmt.bindout(ii,strfieldvalue[ii-1],ifieldlen[ii-1]);
}

// 执行sql语句
if (stmt.execute()!=0){
logfile.Write("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return false;
}

CFile File; // 用于操作xml文件。

while (true){
memset(strfieldvalue,0,sizeof(strfieldvalue));

// 从sql的执行语句获得一条记录
if (stmt.next()!=0) break;

// 打开文件放在循环内,就避免生成空文件的情况
if(File.IsOpened() == false){
// 这个函数在下面有 // 生成xml文件名
crtxmlfilename();

// 设置写入权限
if(File.OpenForRename(strxmlfilename, "w+") == false){
logfile.Write("File.OpenForRename(%s) failed. \n", strxmlfilename);
return false;
}
// 打开一个文件,在行首加<data>\n
File.Fprintf("<data>\n");
}
// 将一句转化为xml格式
for (int ii=1;ii<=ifieldcount;ii++)
// 使得转变为xml格式
File.Fprintf("<%s>%s</%s>",strfieldname[ii-1],strfieldvalue[ii-1],strfieldname[ii-1]);
// 在这一句后面添加<end/>
File.Fprintf("<endl/>\n");
}

// 行尾加</data>\n
if(File.IsOpened() == true){
File.Fprintf("</data>\n");


// 关闭失败,记录原因
if(File.CloseAndRename() == false){
logfile.Write("File.CloseAndRename(%s) failed.\n", strxmlfilename);
return false;
}
// 关闭成功,写日志
logfile.Write("生成文件%s(%d). \n", strxmlfilename, stmt.m_cda.rpc);
}
return true;
}


void crtxmlfilename(){ // 生成xml文件名
// xml全路径文件名=start.outpath + starg.bfilename + 当前时间 + starg.efilename + .xml
// char strxmlfilename[301] xml文件名
char strLocalTime[21];
memset(strLocalTime, 0, sizeof(strLocalTime));
LocalTime(strLocalTime, "yyyymmddhh24miss");
SNPRINTF(strxmlfilename, 300, sizeof(strxmlfilename), "%s/%s_%s_%s.xml", starg.outpath, starg.bfilename, strLocalTime, starg.efilename);

}
进一步优化

​ 我们的上传文件模块虽然能成功的上传标准xml格式模块,但是在实际开发中,可能会涉及数据条数很多,从而导致,如果一次性上传,会对数据库造成一定时间内不小的冲击压力,因此我们使用分块的思想,这里设定每次只上传1000条

image-20220727143804780

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
修改点1
// 在这一句后面添加<end/>
File.Fprintf("<endl/>\n");
}
--------------------------------------------
File.Fprintf("<endl/>\n");

if (stmt.m_cda.rpc%1000 == 0){
File.Fprintf("</data>\n");

// 关闭失败,记录原因
if(File.CloseAndRename() == false){
logfile.Write("File.CloseAndRename(%s) failed.\n", strxmlfilename);
return false;
}
// 关闭成功,写日志
logfile.Write("生成文件%s(1000). \n", strxmlfilename);

}
}
修改点2
void crtxmlfilename(){ // 生成xml文件名
由于命名方式有时间,并且最多只精确到s,如果同一秒内生成多个文件就会重复,所以我们新添加一个序号
-->
void crtxmlfilename(){ // 生成xml文件名
// xml全路径文件名=start.outpath + starg.bfilename + 当前时间 + starg.efilename + 序号.xml
// char strxmlfilename[301] xml文件名,序号可用全局可用静态
char strLocalTime[21];
memset(strLocalTime, 0, sizeof(strLocalTime));
LocalTime(strLocalTime, "yyyymmddhh24miss");

static int iseq = 1;
SNPRINTF(strxmlfilename, 300, sizeof(strxmlfilename), "%s/%s_%s_%s_%d.xml", starg.outpath, starg.bfilename, strLocalTime, starg.efilename, iseq++);
}

增量抽取

这里的增量抽取和百度的不太一样,但增量抽取就是为了记住位置,方便下一步的操作

image-20220727152424176

业务逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
bool _dminingmysql(){
// 从starg.incfilename文件中获取已抽取数据的最大id
readincfile();

// 如果是增量抽取,绑定输入参数(已抽取数据的最大id)。
// imaxincvalue为全局变量。
if(strlen(starg.incfield) != 0) stmt.bindin(1, &imaxincvalue);

if (stmt.execute()!=0){
logfile.Write("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return false;
}

// 更新自增字段的最大值。
// 如果没有自增字段,就不需要这样判断
// if (imaxincvalue<atol(strfieldvalue[incfieldpos])) imaxincvalue=atol(strfieldvalue[incfieldpos]);
if ( (strlen(starg.incfield)!=0) && (imaxincvalue<atol(strfieldvalue[incfieldpos])) )
imaxincvalue=atol(strfieldvalue[incfieldpos]);

// 把最大的自增字段的值写入starg.incfilename文件中。
if (stmt.m_cda.rpc>0) writeincfile();
}

bool readincfile(){ // 从starg.incfilename文件中获取已抽取数据的最大id。
imaxincvalue=0; // 自增字段最大值

// 如果starg.incfield参数为空,表示不是增量抽取。
if(strlen(starg.incfield)==0) return true;

CFile File;

// 如果打开starg.incfilename文件失败,表示是第一次运行程序,也不必返回失败。
// 也可能是文件丢了,那也没办法,只能重新抽取。
if (File.Open(starg.incfilename,"r")==false) return true;

// 从文件中读取已抽取数据的最大id。
char strtemp[31];
File.FFGETS(strtemp, 30);

imaxincvalue = atol(strtemp);

logfile.Write("上次已抽取数据的位置 (%s=%ld)。\n", starg.incfield, imaxincvalue);

return true;
}
bool writeincfile(){ // 把已抽取数据的最大id写入starg.incfilename文件。
// 如果starg.incfield参数为空,表示不是增量抽取。
if (strlen(starg.incfield)==0) return true;

CFile File;

if (File.Open(starg.incfilename,"w+")==false)
{
logfile.Write("File.Open(%s) failed.\n",starg.incfilename); return false;
}

// 把已抽取数据的最大id写入文件。
File.Fprintf("%ld",imaxincvalue);

File.Close();

return true;
}

数据抽取的优化

  • 目标

image-20220727160505736

​ 第一个问题:现在给出的xml批量是1000一次,之前我们的理由是文件数目不能太大,否则不好处理,但也不绝对,就比如,如果数目比较小,我们使用一起插入,只需要先把表中的全部删掉,然后直接一锅端就好,但如果分成了多个文件,反而复杂,因为入库程序不知道我们有多少个文件,也不知道文件是否完整

​ 第二个问题:我们现在的程序,自增字段是保存在logfile文件中,我们实际上可以保存到数据库中,不过也不是说保存在文件中不好,因为万一到了别的服务器,根本就没有数据库环境,就又会出现大麻烦,最好的办法就是都支持。

这里我们先修改储存的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct st_arg{
char connstr[101]; // 数据库的连接参数。
char charset[51]; // 数据库的字符集。
char selectsql[1024]; // 从数据源数据库抽取数据的SQL语句。
char fieldstr[501]; // 抽取数据的SQL语句输出结果集字段名,字段名之间用逗号分隔。
char fieldlen[501]; // 抽取数据的SQL语句输出结果集字段的长度,用逗号分隔。
char bfilename[31]; // 输出xml文件的前缀。
char efilename[31]; // 输出xml文件的后缀。
char outpath[301]; // 输出xml文件存放的目录。
int maxcount; // 输出xml文件最大记录数,0表示无限制。
char starttime[52]; // 程序运行的时间区间
char incfield[31]; // 递增字段名。
char incfilename[301]; // 已抽取数据的递增字段最大值存放的文件。
char connstr1[101]; // 已抽取数据的递增字段最大值存放的数据库的连接参数。
int timeout; // 进程心跳的超时时间。
char pname[51]; // 进程名,建议用"dminingmysql_后缀"的方式。
} starg;

增加了maxcount和connstr1

两点注意:

  1. connstr1是我们自己的数据库、connstr是别人的数据库
  2. 剩下的说明文档,xml解析啥的,要准备改了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 3、获取自增字段在结果集中的位置。
if (strlen(starg.incfield)!=0){
for (int ii=0;ii<ifieldcount;ii++)
if (strcmp(starg.incfield,strfieldname[ii])==0) { incfieldpos=ii; break; }

if (incfieldpos==-1){
logfile.Write("递增字段名%s不在列表%s中。\n",starg.incfield,starg.fieldstr); return false;
}
}

if ((strlen(starg.incfilename) == 0) && (strlen(starg.connstr1) == 0)){
logfile.Write("incfilename和connstr1参数必须二选一 \n");
return false;
}

之前设置一千行的地方,将一千替换为maxcount,并且有的地方注意限制maxcount必须大于0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (starg.maxcount == 0){
-->
if ((starg.maxcount>0) && (stmt.m_cda.rpc%starg.maxcount == 0) ){
File.Fprintf("</data>\n");

// 关闭成功,写日志
logfile.Write("生成文件%s(%d). \n", strxmlfilename, stmt.m_cda.rpc%starg.maxcount);
--->
// 关闭成功,写日志,等于0,写结果集,大于0,写余数
if (starg.maxcount == 0)
logfile.Write("生成文件%s(%d). \n", strxmlfilename, stmt.m_cda.rpc);
else
logfile.Write("生成文件%s(%d). \n", strxmlfilename, stmt.m_cda.rpc%starg.maxcount);

测试的时候,修改里面的文字,另外需要删掉再试

​ 接下来增加,使得自增字段加入数据库功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 连接数据源的数据库。
if (conn.connecttodb(starg.connstr,starg.charset)!=0){
logfile.Write("connect database(%s) failed.\n%s\n",starg.connstr,conn.m_cda.message); return -1;
}
// 连接本地的数据库,用于存放已抽取数据的自增字段的最大值。
if (strlen(starg.connstr1)!=0)
{
if (conn1.connecttodb(starg.connstr1,starg.charset)!=0)
{
logfile.Write("connect database(%s) failed.\n%s\n",starg.connstr1,conn1.m_cda.message); return -1;
}
logfile.Write("connect database(%s) ok.\n",starg.connstr1);
}
logfile.Write("connect database(%s) ok.\n",starg.connstr);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
bool readincfield(){   // 从数据库表中或starg.incfilename文件中获取已抽取数据的最大id。
imaxincvalue=0; // 自增字段最大值

// 如果starg.incfield参数为空,表示不是增量抽取。
if(strlen(starg.incfield) == 0) return true;
if(strlen(starg.connstr1) != 0){
// 从数据库表中加载自增字段的最大值。
// create table T_MAXINCVALUE(pname varchar(50),maxincvalue numeric(15),primary key(pname));
sqlstatement stmt(&conn1);
stmt.prepare("select maxincvalue from T_MAXINCVALUE where pname=:1");
stmt.bindin(1,starg.pname,50);
stmt.bindout(1,&imaxincvalue);
// 处理结果不用管它
stmt.execute();
stmt.next();
}
else{
CFile File;

// 如果打开starg.incfilename文件失败,表示是第一次运行程序,也不必返回失败。
// 也可能是文件丢了,那也没办法,只能重新抽取。
if (File.Open(starg.incfilename,"r")==false) return true;

// 从文件中读取已抽取数据的最大id。
char strtemp[31];
File.FFGETS(strtemp, 30);

imaxincvalue = atol(strtemp);
}
logfile.Write("上次已抽取数据的位置 (%s=%ld)。\n", starg.incfield, imaxincvalue);

return true;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
bool writeincfield(){  // 把已抽取数据的最大id写入数据库表或starg.incfilename文件。
// 如果starg.incfield参数为空,表示不是增量抽取。
if (strlen(starg.incfield)==0) return true;

if(strlen(starg.connstr1) != 0){
// 把自增字段的最大值写入数据库的表。
// create table T_MAXINCVALUE(pname varchar(50),maxincvalue numeric(15),primary key(pname));
sqlstatement stmt(&conn1);
if (stmt.m_cda.rc == 1146){
// 如果表不存在,就创建表。
conn1.execute("create table T_MAXINCVALUE(pname varchar(50),maxincvalue numeric(15),primary key(pname))");
conn1.execute("insert into T_MAXINCVALUE values('%s',%ld)",starg.pname,imaxincvalue);
conn1.commit();
return true;
}
stmt.bindin(1,&imaxincvalue);
stmt.bindin(2,starg.pname,50);
if (stmt.execute()!=0){
logfile.Write("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return false;
}
if (stmt.m_cda.rpc==0){
conn1.execute("insert into T_MAXINCVALUE values('%s',%ld)",starg.pname,imaxincvalue);
}
conn1.commit();
}
else{
CFile File;

if (File.Open(starg.incfilename,"w+")==false)
{
logfile.Write("File.Open(%s) failed.\n",starg.incfilename); return false;
}

// 把已抽取数据的最大id写入文件。
File.Fprintf("%ld",imaxincvalue);

File.Close();

return true;
}
}

学习总结

image-20220727170447271

数据处理对象一般有几种类型。

  1. 网点信息,一般数据量不多
  2. 账户信息,数据量比较大,超过千万的级别
  3. 流水,数据量特别大,超过亿的级别

​ 在我们这节课中,气象站点数据属于第一种,气象观测数据属于第三种,气象行业没有第二种数据

处理经验

  1. 对于第一个表,我们通常采用全量抽取,每次抽取全部的数据
  2. 第二个表,采用全量抽取,他有更新时间的字段,假设一小时更新一次,可以把抽取数据的条件设置为两个小时,肯定不会漏掉数据
  3. 第三个表,是自增字段,采用增量抽取的方法,每次抽取新增数据也很容易

可,这些都只是理想情况,实际项目开发,最根本的问题是数据源表的操作不规范

我们要注意的原则

image-20220727171111183

七、数据入库子系统

数据入库的三种方式

image-20220731142137482

​ 如果只是获取数据,再传入数据中心,那么数据类型不同,就需要开发不同的入库代码,这样是十分繁琐的,因此我们想的是将获取到的数据都转化为不同的xml文件,然后通过数据入库模块来智能选择如何入库

image-20220731142255297

目标

image-20220731142443146

拓展-MySQL数据字典

​ INFORMATION_SCHEMA是信息数据库,其中保存着关于MySQL服务器所维护的所有其他数据库的信息。在INFORMATION_SCHEMA中,有数个只读表。它们实际上是视图,而不是基本表,因此,你将无法看到与之相关的任何文件

image-20220731143449733

入库设计要求

image-20220731143910541

我们的设计是这样的,可以理解一下理论是否能做到。

image-20220731144030336

数据入库基本流程

image-20220731144639957

声明参数

image-20220731145030635

加载文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 把数据入库的参数配置文件starg.inifilename加载到vxmltotable容器中。
bool loadxmltotable(){
vxmltotable.clear();

CFile File;
if (File.Open(starg.inifilename,"r")==false){
logfile.Write("File.Open(%s) 失败。\n",starg.inifilename); return false;
}

char strBuffer[501];

while (true){
if (File.FFGETS(strBuffer,500,"<endl/>")==false) break;

memset(&stxmltotable,0,sizeof(struct st_xmltotable));

GetXMLBuffer(strBuffer,"filename",stxmltotable.filename,100); // xml文件的匹配规则,用逗号分隔。
GetXMLBuffer(strBuffer,"tname",stxmltotable.tname,30); // 待入库的表名。
GetXMLBuffer(strBuffer,"uptbz",&stxmltotable.uptbz); // 更新标志:1-更新;2-不更新。
GetXMLBuffer(strBuffer,"execsql",stxmltotable.execsql,300); // 处理xml文件之前,执行的SQL语句。

vxmltotable.push_back(stxmltotable);
}

logfile.Write("loadxmltotable(%s) ok.\n",starg.inifilename);

return true;
}

查找文件

1
2
3
4
5
6
7
8
9
10
11
// 从vxmltotable容器中查找xmlfilename的入库参数,存放在stxmltotable结构体中。
bool findxmltotable(char *xmlfilename){
for (int ii=0;ii<vxmltotable.size();ii++){
if (MatchStr(xmlfilename,vxmltotable[ii].filename)==true){
memcpy(&stxmltotable,&vxmltotable[ii],sizeof(struct st_xmltotable));
return true;
}
}

return false;
}

加载频率

​ 我们可以思考一下loadxmltotable应该放在程序主流程的哪里,如果放在程序开头,意味着只加载一次,后面无法变动,如果直接放在while里,也意味着每次程序运行都要加载,过于频繁,因此我们可以定义一个计数器

1
2
3
4
5
6
7
8
9
10
11
12
// 业务处理主函数。
bool _xmltodb(){
int counter=50; // 加载入库参数的计数器,初始化为50是为了在第一次进入循环的时候就加载参数。

CDir Dir;

while (true){
if (counter++>30){
counter=0; // 重新计数。
// 把数据入库的参数配置文件starg.inifilename加载到vxmltotable容器中。
if (loadxmltotable()==false) return false;
}

​ 入库的参数文件会修改,但是修改的频率也不是很高。这样每循环三十次,再重新加载,就相对来说节省内存,又能及时加载,也是经典折中处理。

流程补充

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 业务处理主函数。
bool _xmltodb(){
int counter=50; // 加载入库参数的计数器,初始化为50是为了在第一次进入循环的时候就加载参数。

CDir Dir;

while (true){
if (counter++>30){
counter=0; // 重新计数。
// 把数据入库的参数配置文件starg.inifilename加载到vxmltotable容器中。
if (loadxmltotable()==false) return false;
}

// 打开starg.xmlpath目录,为了保证先生成的数据入库,打开目录的时候,应该按文件名排序。
if (Dir.OpenDir(starg.xmlpath,"*.XML",10000,false,true)==false){
logfile.Write("Dir.OpenDir(%s) failed.\n",starg.xmlpath); return false;
}

while (true){
// 读取目录,得到一个xml文件。
if (Dir.ReadDir()==false) break;

logfile.Write("处理文件%s...",Dir.m_FullFileName);

// 调用处理xml文件的子函数。
int iret=_xmltodb(Dir.m_FullFileName,Dir.m_FileName);

// 处理xml文件成功,写日志,备份文件。
if (iret==0){
logfile.WriteEx("ok.\n");
// 把xml文件移动到starg.xmlpathbak参数指定的目录中,一般不会发生错误,如果真发生了,程序将退出。
if (xmltobakerr(Dir.m_FullFileName,starg.xmlpath,starg.xmlpathbak)==false) return false;
}

// 如果处理xml文件失败,分多种情况。
if (iret==1){ // iret==1,找不到入库参数。暂时先一种

logfile.WriteEx("failed,没有配置入库参数。\n");
// 把xml文件移动到starg.xmlpatherr参数指定的目录中,一般不会发生错误,如果真发生了,程序将退出。
if (xmltobakerr(Dir.m_FullFileName,starg.xmlpath,starg.xmlpatherr)==false) return false;
}
}

break;// 测试时只循环一次
sleep(starg.timetvl);
}

return true;
}

备份文件

1
2
3
4
5
6
7
8
9
10
11
12
13
// 把xml文件移动到备份目录或错误目录。
bool xmltobakerr(char *fullfilename,char *srcpath,char *dstpath){
char dstfilename[301]; // 目标文件名。
STRCPY(dstfilename,sizeof(dstfilename),fullfilename);

UpdateStr(dstfilename,srcpath,dstpath,false); // 小心第四个参数,一定要填false。

if (RENAME(fullfilename,dstfilename)==false){
logfile.Write("RENAME(%s,%s) failed.\n",fullfilename,dstfilename); return false;
}

return true;
}

核心入库过程框架

请注意,这里返回值是int,是bool _xmltodb的子函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 处理xml文件的子函数,返回值:0-成功,其它的都是失败,失败的情况有很多种,暂时不确定。
int _xmltodb(char *fullfilename,char *filename){
// 从vxmltotable容器中查找filename的入库参数,存放在stxmltotable结构体中。
if (findxmltotable(filename)==false) return 1;

CTABCOLS TABCOLS;

// 获取表全部的字段和主键信息,如果获取失败,应该是数据库连接已失效。
// 在本程序运行的过程中,如果数据库出现异常,一定会在这里发现。
if (TABCOLS.allcols(&conn,stxmltotable.tname)==false) return 4;
if (TABCOLS.pkcols(&conn,stxmltotable.tname)==false) return 4;

// 如果TABCOLS.m_allcount为0,说明表根本不存在,返回2。
if (TABCOLS.m_allcount==0) return 2; // 待入库的表不存在。

// 拼接生成插入和更新表数据的SQL。

// prepare插入和更新的sql语句,绑定输入变量。

// 在处理xml文件之前,如果stxmltotable.execsql不为空,就执行它。

// 打开xml文件。

/*
while (true)
{
// 从xml文件中读取一行。

// 解析xml,存放在已绑定的输入变量中。

// 执行插入和更新的SQL。
}
*/

return 0;
}
新增结构体定义
1
2
3
4
5
6
7
// 表的列(字段)信息的结构体。
struct st_columns{
char colname[31]; // 列名。为了便捷和兼容,mysql其实可以放下64位,oracle是30
char datatype[31]; // 列的数据类型,分为number、date和char三大类。
int collen; // 列的长度,number固定20,date固定19,char的长度由表结构决定。
int pkseq; // 如果列是主键的字段,存放主键字段的顺序,从1开始,不是主键取值0。
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 获取表全部的列和主键列信息的类。
class CTABCOLS{
public:
CTABCOLS();

int m_allcount; // 全部字段的个数。
int m_pkcount; // 主键字段的个数。

vector<struct st_columns> m_vallcols; // 存放全部字段信息的容器。
vector<struct st_columns> m_vpkcols; // 存放主键字段信息的容器。

char m_allcols[3001]; // 全部的字段名列表,以字符串存放,中间用半角的逗号分隔。
char m_pkcols[301]; // 主键字段名列表,以字符串存放,中间用半角的逗号分隔。

void initdata(); // 成员变量初始化。

// 获取指定表的全部字段信息。
bool allcols(connection *conn,char *tablename);

// 获取指定表的主键字段信息。
bool pkcols(connection *conn,char *tablename);
};
allcols获取全部字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 获取指定表的全部字段信息。
bool CTABCOLS::allcols(connection *conn,char *tablename){
m_allcount=0;
m_vallcols.clear();
memset(m_allcols,0,sizeof(m_allcols));

struct st_columns stcolumns;

sqlstatement stmt;
stmt.connect(conn);
stmt.prepare("select lower(column_name),lower(data_type),character_maximum_length from information_schema.COLUMNS where table_name=:1");
stmt.bindin(1,tablename,30);
stmt.bindout(1, stcolumns.colname,30);
stmt.bindout(2, stcolumns.datatype,30);
stmt.bindout(3,&stcolumns.collen);

if (stmt.execute()!=0) return false;

while (true){
memset(&stcolumns,0,sizeof(struct st_columns));

if (stmt.next()!=0) break;

// 列的数据类型,分为number、date和char三大类。
if (strcmp(stcolumns.datatype,"char")==0) strcpy(stcolumns.datatype,"char");
if (strcmp(stcolumns.datatype,"varchar")==0) strcpy(stcolumns.datatype,"char");

if (strcmp(stcolumns.datatype,"datetime")==0) strcpy(stcolumns.datatype,"date");
if (strcmp(stcolumns.datatype,"timestamp")==0) strcpy(stcolumns.datatype,"date");

if (strcmp(stcolumns.datatype,"tinyint")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"smallint")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"mediumint")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"int")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"integer")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"bigint")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"numeric")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"decimal")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"float")==0) strcpy(stcolumns.datatype,"number");
if (strcmp(stcolumns.datatype,"double")==0) strcpy(stcolumns.datatype,"number");

// 如果业务有需要,可以修改上面的代码,增加对更多数据类型的支持。
// 如果字段的数据类型不在上面列出来的中,忽略它。
if ( (strcmp(stcolumns.datatype,"char")!=0) &&
(strcmp(stcolumns.datatype,"date")!=0) &&
(strcmp(stcolumns.datatype,"number")!=0) ) continue;

// 如果字段类型是date,把长度设置为19。yyyy-mm-dd hh:mi:ss
if (strcmp(stcolumns.datatype,"date")==0) stcolumns.collen=19;

// 如果字段类型是number,把长度设置为20。
if (strcmp(stcolumns.datatype,"number")==0) stcolumns.collen=20;

strcat(m_allcols,stcolumns.colname); strcat(m_allcols,",");

m_vallcols.push_back(stcolumns);

m_allcount++;
}

// 删除m_allcols最后一个多余的逗号。
if (m_allcount>0) m_allcols[strlen(m_allcols)-1]=0;

return true;
}

pkcols获取指定字段同理

拼接SQL语句

​ 实现insert和update功能,目的是组装好对应的mysql语句,然后存放到char strinsertsql[10241]和char strupdatesql[10241]中,方便

主要是用于数据入库的参数配置文件的uptbz参数使用,1为更新,2是不更新

image-20220731173256014

1
2
3
4
5
6
7
8
<?xml version='1.0' encoding='utf-8'?>

<!--该参数文件存放了数据中心入库参数。-->
<xmltodb>
<filename>ZHOBTCODE_*.XML</filename><tname>T_ZHOBTCODE1</tname><uptbz>1</uptbz><execsql>delete from T_ZHOBTCODE1</execsql><endl/>
<filename>ZHOBTMIND_*.XML</filename><tname>T_ZHOBTMIND1</tname><uptbz>2</uptbz><endl/>
</xmltodb>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// 拼接生成插入和更新表数据的SQL。
void crtsql(){
memset(strinsertsql,0,sizeof(strinsertsql)); // 插入表的SQL语句。
memset(strupdatesql,0,sizeof(strupdatesql)); // 更新表的SQL语句。

// 生成插入表的SQL语句。 insert into 表名(%s) values(%s)
// 更新时间和自增字段不需要出现在表的更新里,是自动完成的
char strinsertp1[3001]; // insert语句的字段列表。
char strinsertp2[3001]; // insert语句values后的内容。

memset(strinsertp1,0,sizeof(strinsertp1));
memset(strinsertp2,0,sizeof(strinsertp2));

int colseq=1; // values部分字段的序号。
// 设置在循环外面,没有直接用i,就是因为upttime和keyid的存在。
for (int i=0;i<TABCOLS.m_vallcols.size();i++){
// upttime和keyid这两个字段不需要处理。
if ( (strcmp(TABCOLS.m_vallcols[i].colname,"upttime")==0) ||
(strcmp(TABCOLS.m_vallcols[i].colname,"keyid")==0) ) continue;

// 拼接strinsertp1
strcat(strinsertp1,TABCOLS.m_vallcols[i].colname); strcat(strinsertp1,",");

// 拼接strinsertp2,需要区分date字段和非date字段。
char strtemp[101];
if (strcmp(TABCOLS.m_vallcols[i].datatype,"date")!=0)
SNPRINTF(strtemp,100,sizeof(strtemp),":%d",colseq);
else
SNPRINTF(strtemp,100,sizeof(strtemp),"str_to_date(:%d,'%%%%Y%%%%m%%%%d%%%%H%%%%i%%%%s')",colseq);

strcat(strinsertp2,strtemp); strcat(strinsertp2,",");

colseq++;
}

strinsertp1[strlen(strinsertp1)-1]=0; // 把最后一个多余的逗号删除。
strinsertp2[strlen(strinsertp2)-1]=0; // 把最后一个多余的逗号删除。

SNPRINTF(strinsertsql,10240,sizeof(strinsertsql),\
"insert into %s(%s) values(%s)",stxmltotable.tname,strinsertp1,strinsertp2);

// logfile.Write("strinsertsql=%s=\n",strinsertsql);

// 如果入库参数中指定了表数据不需要更新,就不生成update语句了,函数返回。
if (stxmltotable.uptbz!=1) return;

// 生成修改表的SQL语句。
// update T_ZHOBTMIND1 set t=:1,p=:2,u=:3,wd=:4,wf=:5,r=:6,vis=:7,upttime=now(),mint=:8,minttime=str_to_date(:9,'%Y%m%d%H%i%s') where obtid=:10 and ddatetime=str_to_date(:11,'%Y%m%d%H%i%s')

// 更新TABCOLS.m_vallcols中的pkseq字段,在拼接update语句的时候要用到它。
for (int i=0;i<TABCOLS.m_vpkcols.size();i++)
for (int jj=0;jj<TABCOLS.m_vallcols.size();jj++)
if (strcmp(TABCOLS.m_vpkcols[i].colname,TABCOLS.m_vallcols[jj].colname)==0){
// 更新m_vallcols容器中的pkseq。
TABCOLS.m_vallcols[jj].pkseq=TABCOLS.m_vpkcols[i].pkseq; break;
}

// 先拼接update语句开始的部分。
sprintf(strupdatesql,"update %s set ",stxmltotable.tname);

// 拼接update语句set后面的部分。
colseq=1;
for (int i=0;i<TABCOLS.m_vallcols.size();i++){
// keyid字段不需要处理。
if (strcmp(TABCOLS.m_vallcols[i].colname,"keyid")==0) continue;

// 如果是主键字段,也不需要拼接在set的后面。
// 主键部分不允许修改,避免主记录被删除和重复使用主记录
if (TABCOLS.m_vallcols[i].pkseq!=0) continue;

// upttime字段直接等于now(),这么做是为了考虑数据库的兼容性。
if (strcmp(TABCOLS.m_vallcols[i].colname,"upttime")==0){
strcat(strupdatesql,"upttime=now(),"); continue;
}

// 其它字段需要区分date字段和非date字段。
char strtemp[101];
if (strcmp(TABCOLS.m_vallcols[i].datatype,"date")!=0)
SNPRINTF(strtemp,100,sizeof(strtemp),"%s=:%d",TABCOLS.m_vallcols[i].colname,colseq);
else
SNPRINTF(strtemp,100,sizeof(strtemp),"%s=str_to_date(:%d,'%%%%Y%%%%m%%%%d%%%%H%%%%i%%%%s')",TABCOLS.m_vallcols[i].colname,colseq);

strcat(strupdatesql,strtemp); strcat(strupdatesql,",");

colseq++;
}

strupdatesql[strlen(strupdatesql)-1]=0; // 删除最后一个多余的逗号。

// 然后再拼接update语句where后面的部分。
strcat(strupdatesql," where 1=1 "); // 用1=1是为了后面的拼接方便,这是常用的处理方法。
// 后面如果有判断条件,就一个接一个就可以了,因为where后面必须有值
for (int i=0;i<TABCOLS.m_vallcols.size();i++){
if (TABCOLS.m_vallcols[i].pkseq==0) continue; // 如果不是主键字段,跳过。

// 把主键字段拼接到update语句中,需要区分date字段和非date字段。
char strtemp[101];
if (strcmp(TABCOLS.m_vallcols[i].datatype,"date")!=0)
SNPRINTF(strtemp,100,sizeof(strtemp)," and %s=:%d",TABCOLS.m_vallcols[i].colname,colseq);
else
SNPRINTF(strtemp,100,sizeof(strtemp)," and %s=str_to_date(:%d,'%%%%Y%%%%m%%%%d%%%%H%%%%i%%%%s')",TABCOLS.m_vallcols[i].colname,colseq);

strcat(strupdatesql,strtemp);

colseq++;
}

// logfile.Write("strupdatesql=%s\n",strupdatesql);
}
绑定SQL语句参数
1
2
3
4
5
6
// prepare插入和更新的sql语句,绑定输入变量。
#define MAXCOLCOUNT 300 // 每个表字段的最大数。
#define MAXCOLLEN 100 // 表字段值的最大长度。
char strcolvalue[MAXCOLCOUNT][MAXCOLLEN+1]; // 存放从xml每一行中解析出来的值。
sqlstatement stmtins,stmtupt; // 插入和更新表的sqlstatement对象。
void preparesql();

连接数据库,将之前生成的sql语句用prepare方法,绑定在sqlstatement对象中,并跳过掉比如upttime、keyid、pkseq主键等字段,利用bindin函数绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// prepare插入和更新的sql语句,绑定输入变量。
void preparesql(){
// 绑定插入sql语句的输入变量。
stmtins.connect(&conn);
stmtins.prepare(strinsertsql);

int colseq=1; // values部分字段的序号。

for (int i=0;i<TABCOLS.m_vallcols.size();i++){
// upttime和keyid这两个字段不需要处理。
if ( (strcmp(TABCOLS.m_vallcols[i].colname,"upttime")==0) ||
(strcmp(TABCOLS.m_vallcols[i].colname,"keyid")==0) ) continue;

// 注意,strcolvalue数组的使用不是连续的,是和TABCOLS.m_vallcols的下标是一一对应的。
stmtins.bindin(colseq,strcolvalue[i],TABCOLS.m_vallcols[i].collen);

colseq++;
}

// 绑定更新sql语句的输入变量。
// 如果入库参数中指定了表数据不需要更新,就不处理update语句了,函数返回。
if (stxmltotable.uptbz!=1) return;

stmtupt.connect(&conn);
stmtupt.prepare(strupdatesql);

colseq=1; // set和where部分字段的序号。

// 绑定set部分的输入参数。
for (int i=0;i<TABCOLS.m_vallcols.size();i++){
// upttime和keyid这两个字段不需要处理。
if ( (strcmp(TABCOLS.m_vallcols[i].colname,"upttime")==0) ||
(strcmp(TABCOLS.m_vallcols[i].colname,"keyid")==0) ) continue;

// 如果是主键字段,也不需要拼接在set的后面。
if (TABCOLS.m_vallcols[i].pkseq!=0) continue;

// 注意,strcolvalue数组的使用不是连续的,是和TABCOLS.m_vallcols的下标是一一对应的。
stmtupt.bindin(colseq,strcolvalue[i],TABCOLS.m_vallcols[i].collen);

colseq++;
}

// 绑定where部分的输入参数。
for (int i=0;i<TABCOLS.m_vallcols.size();i++){
// 如果不是主键字段,跳过,只有主键字段才拼接在where的后面。
if (TABCOLS.m_vallcols[i].pkseq==0) continue;

// 注意,strcolvalue数组的使用不是连续的,是和TABCOLS.m_vallcols的下标是一一对应的。
stmtupt.bindin(colseq,strcolvalue[i],TABCOLS.m_vallcols[i].collen);

colseq++;
}
}
执行SQL语句

执行主要是插入和更新操作。

解析XML,将内部语句存放在已绑定的输入变量,方便直接execute()执行命令,execute后全都是判断错误代码,也就是万一发生意外时,能在日志保存下痕迹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
while (true){
// 从xml文件中读取一行。
if (File.FFGETS(strBuffer,10240,"<endl/>")==false) break;

totalcount++;

// 解析xml,存放在已绑定的输入变量strcolvalue数组中。
splitbuffer(strBuffer);

// 执行插入和更新的SQL。
if (stmtins.execute()!=0){
if (stmtins.m_cda.rc==1062){ // 违反唯一性约束,表示记录已存在。
// 判断入库参数的更新标志。
if (stxmltotable.uptbz==1){
if (stmtupt.execute()!=0){
// 数据入库程序是一个通用的模块,不会因为某一行报错就停止,不要停下啊!
// 如果update失败,记录出错的行和错误内容,函数不返回,继续处理数据,也就是说,不理这一行。
logfile.Write("%s",strBuffer);
logfile.Write("stmtupt.execute() failed.\n%s\n%s\n",stmtupt.m_sql,stmtupt.m_cda.message);

// 数据库连接已失效,无法继续,只能返回。
// 1053-在操作过程中服务器关闭。2013-查询过程中丢失了与MySQL服务器的连接。
if ( (stmtupt.m_cda.rc==1053) || (stmtupt.m_cda.rc==2013) ) return 4;
}
else uptcount++;
}
}
else{
// 如果insert失败,记录出错的行和错误内容,函数不返回,继续处理数据,也就是说,不理这一行。
logfile.Write("%s",strBuffer);
logfile.Write("stmtins.execute() failed.\n%s\n%s\n",stmtins.m_sql,stmtins.m_cda.message);

// 数据库连接已失效,无法继续,只能返回。
// 1053-在操作过程中服务器关闭。2013-查询过程中丢失了与MySQL服务器的连接。
if ( (stmtins.m_cda.rc==1053) || (stmtins.m_cda.rc==2013) ) return 4;
}
}
else inscount++;
}

conn.commit();

return 0;
}

解析XML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 解析xml,存放在已绑定的输入变量strcolvalue数组中。
void splitbuffer(char *strBuffer){
// 初始化strcolvalue数组。
for (int i=0; i<TABCOLS.m_allcount; i++)
memset(strcolvalue[i],0,TABCOLS.m_vallcols[i].collen+1);

char strtemp[31];

for (int i=0; i<TABCOLS.m_vallcols.size(); i++){
// 如果是日期时间字段,提取数值就可以了。
// 解析就是指将xml解析成字符串
// 也就是说,xml文件中的日期时间只要包含了yyyymmddhh24miss就行,可以是任意分隔符。
if (strcmp(TABCOLS.m_vallcols[i].datatype,"date")==0){
// 这感觉应该是解析结果存放在strtemp里面,因为strBuffer是文件的一行
GetXMLBuffer(strBuffer,TABCOLS.m_vallcols[i].colname,strtemp,TABCOLS.m_vallcols[i].collen);
// 将时间格式中的数字提取出来存好,不需要+-,圆点。
PickNumber(strtemp,strcolvalue[i],false,false);
continue;
}

// 如果是数值字段,只提取数字、+-符号和圆点。
if (strcmp(TABCOLS.m_vallcols[i].datatype,"number")==0){
GetXMLBuffer(strBuffer,TABCOLS.m_vallcols[i].colname,strtemp,TABCOLS.m_vallcols[i].collen);
PickNumber(strtemp,strcolvalue[i],true,true);
continue;
}

// 如果是字符字段,直接提取。
GetXMLBuffer(strBuffer,TABCOLS.m_vallcols[i].colname,strcolvalue[i],TABCOLS.m_vallcols[i].collen);
}
}

完善与优化

问题一:我们首先观察,入库文件太单调了,正常的程序而言,应该反映出插入多少,删除多少这些虽然不必要但是也应该有的数据。

image-20220801212504895

解决方案:在合适的位置,记录对应指标。(记得初始化)image-20220801212612982

问题二:image-20220801212856401

解决方案:

image-20220801212942976

问题三:长时间没有数据文件处理

image-20220801213112080

​ 我们来梳理一下结构,一般来说,如果只开启一个隧道,那么可能就会出现数据堵塞的情况,造成延迟upttime,因此可以采取开启多条传输通道,但每种数据的特点不同,因此要分开处理,另外,也有可能数据库长时间未连接断开,所以这个判断语句应该放在while里定期检测,而不是放在开头只检测一次

image-20220801213326290

image-20220801213855223

问题四:字符串的大小和长度都是用宏处理,如果表的字段和长度大于定义的宏,那么就会出现内存溢出的问题。

image-20220801214255347

解决方案:动态内存处理

MAXCOLCOUNT调整为500个字段,(实际开发连300个字段基本上都没见到过),并将char二维数组变成char指针数组

image-20220801214426526

在int _xmltodb中动态的分配内存(要记得使用之前,splitbuffer内初始化)image-20220801214847720

释放内存放在int _xmltodb的靠前部分,因为我们每次使用该函数,都需要将上次使用的痕迹清空,也就是把上次读取到的字段啥的都清空呗,并且指针也置为空。image-20220801215212673

最后一个地方就是进程的心跳。在程序中,每处理一次文件,增加一个心跳。

image-20220801215535943

执行错误提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 处理xml文件成功,写日志,备份文件。
if (iret==0){
logfile.WriteEx("ok(%s,total=%d,insert=%d,update=%d).\n",stxmltotable.tname,totalcount,inscount,uptcount);
// 把xml文件移动到starg.xmlpathbak参数指定的目录中,一般不会发生错误,如果真发生了,程序将退出。
if (xmltobakerr(Dir.m_FullFileName,starg.xmlpath,starg.xmlpathbak)==false) return false;
}

// 1-没有配置入库参数;2-待入库的表不存在;5-表的字段数太多。
if ( (iret==1) || (iret==2) || (iret==5) ){
if (iret==1) logfile.WriteEx("failed,没有配置入库参数。\n");
if (iret==2) logfile.WriteEx("failed,待入库的表(%s)不存在。\n",stxmltotable.tname);
if (iret==5) logfile.WriteEx("failed,待入库的表(%s)字段数太多。\n",stxmltotable.tname);

// 把xml文件移动到starg.xmlpatherr参数指定的目录中,一般不会发生错误,如果真发生了,程序将退出。
if (xmltobakerr(Dir.m_FullFileName,starg.xmlpath,starg.xmlpatherr)==false) return false;
}

// 打开xml文件错误,这种错误一般不会发生,如果真发生了,程序将退出。
if (iret==3){
logfile.WriteEx("failed,打开xml文件失败。\n"); return false;
}

// 数据库错误,函数返回,程序将退出。
if (iret==4){
logfile.WriteEx("failed,数据库错误。\n"); return false;
}

// 在处理xml文件之前,如果执行stxmltotable.execsql失败,函数返回,程序将退出。
if (iret==6){
logfile.WriteEx("failed,执行execsql失败。\n"); return false;
}
}

// 如果刚才这次扫描到了有文件,表示不空闲,可能不断的有文件生成,就不sleep了。
if (Dir.m_vFileName.size()==0) sleep(starg.timetvl);

大量数据入库的方案

这个系统能够满足95%以上的业务需求,但业务总是复杂的,总有很多特殊的需求。这里举一个例子说明,车主服务。

image-20220801221628042

​ 然而公安局肯定不会提供他自己的数据库给你 连接,而是提供视图image-20220801221725665

未处理就是还没有交罚款的,这种数据有两个特点:

  1. 数据是视图,没有时间戳,也没有自增字段
  2. 数据量非常大,通常超百万

这种数据,怎么采撷,用数据抽取程序,每次采用全量抽取,每次放在一个文件中,再把文件传回来,那么,数据拿回来之后,如何入库?

image-20220801222026988

因此我们还是采用和创建文件类似的方法,注意是删除表,不是删除表中的数据,这种方法也会有延迟,延迟在于删除到改名那0.00几秒,正常情况不会影响业务。

image-20220801222242318

八、数据处理和统计

数据中心总体结构图

image-20220802103648774

​ 我们之前开发的TCP传输,FTP传输,数据抽取模块都是用于数据采集,为了解决从数据源取数据的问题,如果是xml或者json格式,直接入库,如果不是,则转化为xml和json,这个工作称为数据处理

​ 数据入库到数据库中,可能要进行统计分析,再加工成新的业务产品,这个工作看业务需求。

数据处理的工作内容

image-20220802104150286

不管怎么复杂,我们做的只有三步

image-20220802104546733

数据统计的工作内容

image-20220802104632149

image-20220802104922612

九、数据同步子系统

mysql高可用

(18条消息) 什么是mysql的高可用_如何做到数据库高可用?_weixin_39959236的博客-CSDN博客

那么什么是数据库高可用?

​ 高可用(High Availability)是系统架构设计中必须考虑的因素之一,它通常是指,通过设计减少系统不能提供服务的时间

​ 如果一台系统能够不间断的提供服务,那么这台系统的可用性据说100%。那如果系统每运行100个时间单位,就会出现1个时间单位无法提供服务,那么该台系统的可用性是99%。

​ 目前大部分企业的高可用目标是4个9,也就是99.99%,也就是允许这台系统的年停机时间为52.56分钟。

为实现系统高可用,其架构设计的核心准则是:冗余

​ 系统需要全天候24销售不间断运行,则需要相应的冗余机制,以防某台机器宕掉时无法访问,而冗余则可以通过部署至少两台服务器构成一个集群实现服务高可用。数据库除了定期备份还需要实现冷热备份。甚至可以在全球范围内部署灾备数据中心。

容灾就等价于高可用?

​ 有人说容灾所说的“灾难”指的大范围,高烈度的故障,例如火灾、地震、洪水、大范围的停电,所以往往还会有个限定词叫异地容灾。而高可用只要做到硬件冗余,实现单点故障时的业务接管就行了。这个观点只对了一半。

​ 现如今,企业真正关心的“灾难”可以泛指非正常情况下的故障停机,而高可用则还应当应对由于IT运维工作等原因带来的计划内停机,因为无论是计划内还是计划外的停机都会对企业业务连续性造成损失。并且灾难故障往往是十年一遇甚至百年一遇,而维护停机工作才是无可避免经常会对业务连续性造成威胁的停机事件。

mysql的高可用没什么特别,如果数据量不大,一个master,一个slave(从属),如果读需求很大,那就多加几个slave,如果一个master不能满足写的需求,那就创造多个master,再带多个slave。

image-20220802110627840

image-20220802110637292

三点不足

image-20220802110716853

虽然有第三方的软件可以解决部分问题,但是要给钱,我们可以自己做数据复制,也叫数据同步,弥补mysql高可用方案的不足。

​ mysql的高可用只是简单的提供多个副本,数据库中有很多个数据,很多个表,不管你是否需要,它都给你复制过去。

数据架构

​ 在我们这个课程中,数据架构是这样的,上面那几个数据库可以称为核心数据库,负责数据的入库,统计,加工和管理,如果一个数据库忙不过来,那就增加几个,这也是最理想的解决方案。有多少数据就增加几个。

​ 核心数据库采用一主一备的方案,是为了处理单点故障

单点故障 (英语:single point of failure,缩写 SPOF )是指 系统 中一点失效,就会让整个系统无法运作的部件,换句话说,单点故障即会整体故障。

​ 下面那层数据库是应用数据库,就是把数据提供给别人的库,向其他的系统提供数据支撑服务。应用数据库没有主备之分,但是有冗余,如果某一个应用数据库出现了问题,切换到另一个就行了。

​ 数据同步子系统负责把核心数据库中的数据同步到业务数据库中,数据同步子系统就是我们要开发的内容

image-20220802111358482

​ 这是我们这个系统所用的架构,红色的箭头代表mysql自带的数据复制功能,下面那些密密麻麻的箭头是我们的数据同步程序,对应用数据库来说,它不需要是某个库的副本,他更关心的是他服务的对象需要什么数据。比如说预报库的数据来自于ABC三个库,他需要什么就存什么,不需要就不存,再比如说,实时数据库,他虽然核心数据库的,但是他只有最近三天的,超过三天的都不需要存,他的特点是数据很齐全,访问的效率很高,但是数据的数量不多。

image-20220802111512625

dederated引擎配置

Linux下MySQL开启Federated引擎方法 - MySQL数据库 - 亿速云 (yisu.com)

​ 创建federated引擎,注意,表名字随意,字段只能比远程端那个表的字段少,属于被包含关系。另外一定要创建主键和唯一键,不然使用federated只会导致性能大幅度下降。

image-20220802114231055

注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1、FEDERATED 表可能会被复制到其他的slave数据库,你需要确保slave服务器也能够使用定义在connection中或mysql.servers表中的link的用户名/密码 连接上远程服务器。

2、远程服务器必须是MySQL数据库,更重要是,必须要兼容不同版本的mysql,兼不兼容试了才知道。

3、在访问FEDERATED表中定义的远程数据库的表前,远程数据库中必须存在这张表。

***************************************最重要的第四条
4、FEDERATED不支持普通索引(只支持主键和唯一键的快速查找),就算加了查询条件,也会进行全局扫描,从远程服务器把全部的数据拿回来,在本地进行过滤,会造成数据库性能的大幅度下降,还会增加网络IO磁盘说的压力
***************************************

5、FEDERATED表不支持ALTER TABLE语句或者任何DDL语句
-- 所有的 DDL 语句都会导致事务隐式提交,换句话说,当你在执行 DDL 语句前,事务就已经提交了。这就意味着带有 DDL 语句的事务将来没有办法 rollback。也就是ctrl + z

6、FEDERATED表不支持事务,就算支持,我们也不会使用它,因为远程事务问题很多,不稳定,oracle中也不会用。

7、本地FEDERATED表无法知道远程库中表结构的改变
-- 只是一个链接而已,不是相互奔赴,就好像这个世界上你不知道有多少人认识你一样

8、任何drop语句都只是对本地库的操作,不对远程库有影响,就只是删除本地的链接而已,与远程表无关

image-20220802115147847

目标

image-20220803222315306

1
2
3
4
5
6
7
8
9
10
11
12
13
create table LK_ZHOBTCODE1
(
obtid varchar(10) not null comment '站点代码',
cityname varchar(30) not null comment '城市名称',
provname varchar(30) not null comment '省名称',
lat int not null comment '纬度,单位:0.01度',
lon int not null comment '经度,单位:0.01度',
height int not null comment '海拔高度,单位:0.1米',
upttime timestamp not null comment '更新时间',
keyid int not null auto_increment comment '记录编号,自动增长列',
primary key (obtid),
unique key ZHOBTCODE1_KEYID (keyid)
)ENGINE=FEDERATED CONNECTION='mysql://root:123456@192.168.198.128:3306/mysql/T_ZHOBTCODE1' DEFAULT CHARSET=utf8;

注意:我是在同一台虚拟机上做实验,所以不能都放在3306端口,另外3307默认没有打开,因此需要输入指令nc -lp3307&打开端口。

在打开指令之前,可以先输入netstat -an| grep 3307检测是否打开,另外(18条消息) mysql开启多个端口_mysql单个实例开启多个端口_三金乐了的博客-CSDN博客

(18条消息) mysql多端口配置_mysql多端口配置及其启动方法_weixin_39866817的博客-CSDN博客

算了,还是放弃了,理解思路就行hhc

最后还是解决了,将mysql root 的localhost改为%就行

1
2
3
use mysql;
update user set host = '%' where user ='root';
flush privileges;

全表刷新功能

只需要一行sql语句就能搞定,要按照这个写法写

image-20220804121224987

image-20220804121243384

分批刷新功能

image-20220804121537292

两个注意点

  1. 分批操作的流程需要一个循环,在循环里面执行2、3步,直到全部的数据被处理完。
  2. 从远程表查询需要的数据,为什么不在federated表,原因有两个
    1. federated不支持普通索引,如果同步的条件不是主键,也不是唯一键,就会进行全表扫描。
    2. 就算federated表支持同步索引,也没有直接访问远程表来得好,因为传给federated需要经过一次中转,肯定没有不中转好image-20220805152354891

不同步分批

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 如果是不分批同步,表示需要同步的数据量比较少,执行一次SQL语句就可以搞定。
if (starg.synctype==1){
logfile.Write("sync %s to %s ...",starg.fedtname,starg.localtname);

// 先删除starg.localtname表中满足where条件的记录。
stmtdel.prepare("delete from %s %s",starg.localtname,starg.where);
if (stmtdel.execute()!=0){
logfile.Write("stmtdel.execute() failed.\n%s\n%s\n",stmtdel.m_sql,stmtdel.m_cda.message); return false;
}

// 再把starg.fedtname表中满足where条件的记录插入到starg.localtname表中。
stmtins.prepare("insert into %s(%s) select %s from %s %s",starg.localtname,starg.localcols,starg.remotecols,starg.fedtname,starg.where);
if (stmtins.execute()!=0){
logfile.Write("stmtins.execute() failed.\n%s\n%s\n",stmtins.m_sql,stmtins.m_cda.message);
connloc.rollback(); // 如果这里失败了,可以不用回滚事务,connection类的析构函数会回滚。
return false;
}

logfile.WriteEx(" %d rows in %.2fsec.\n",stmtins.m_cda.rpc,Timer.Elapsed());

connloc.commit();

return true;
}

如果要定义条件查询image-20220805155019909

有两个注意点,

​ 一、为了解决federated表和本地表字段不同的问题,可以用两个while函数,一个用于federated表的查询,一个用于本地表的删除,实际项目中通常是字段相同的,但我们这个项目可以试试不同

​ 二、系统时间可能存在读取数据的延迟,导致数据残缺。不过通常不分批同步数据量都很小(1w以下)所以无所谓,有两个解决办法,第一是写语句的时候就设置好时间image-20220805155355071

替换成image-20220805155412141

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 把connrem的连数据库的代码放在这里,如果synctype==1,根本就不用以下代码了。
if (connrem.connecttodb(starg.remoteconnstr,starg.charset) != 0){
logfile.Write("connect database(%s) failed.\n%s\n",starg.remoteconnstr,connrem.m_cda.message); return false;
}

// logfile.Write("connect database(%s) ok.\n",starg.remoteconnstr);

// 从远程表查找的需要同步记录的key字段的值。
char remkeyvalue[51]; // 从远程表查到的需要同步记录的key字段的值。
sqlstatement stmtsel(&connrem);
stmtsel.prepare("select %s from %s %s",starg.remotekeycol,starg.remotetname,starg.where);
stmtsel.bindout(1,remkeyvalue,50);

// 拼接绑定同步SQL语句参数的字符串(:1,:2,:3,...,:starg.maxcount)。
char bindstr[2001]; // 绑定同步SQL语句参数的字符串。
char strtemp[11];

memset(bindstr,0,sizeof(bindstr));

for (int ii=0;ii<starg.maxcount;ii++){
memset(strtemp,0,sizeof(strtemp));
sprintf(strtemp,":%lu,",ii+1);
strcat(bindstr,strtemp);
}

bindstr[strlen(bindstr)-1]=0; // 最后一个逗号是多余的。

char keyvalues[starg.maxcount][51]; // 存放key字段的值。

// 准备删除本地表数据的SQL语句,一次删除starg.maxcount条记录。
// delete from T_ZHOBTCODE3 where stid in (:1,:2,:3,...,:starg.maxcount);
stmtdel.prepare("delete from %s where %s in (%s)",starg.localtname,starg.localkeycol,bindstr);
for (int ii=0;ii<starg.maxcount;ii++){
stmtdel.bindin(ii+1,keyvalues[ii],50);
}

// 准备插入本地表数据的SQL语句,一次插入starg.maxcount条记录。
// insert into T_ZHOBTCODE3(stid ,cityname,provname,lat,lon,altitude,upttime,keyid)
// select obtid,cityname,provname,lat,lon,height/10,upttime,keyid from LK_ZHOBTCODE1
// where obtid in (:1,:2,:3);
stmtins.prepare("insert into %s(%s) select %s from %s where %s in (%s)",starg.localtname,starg.localcols,starg.remotecols,starg.fedtname,starg.remotekeycol,bindstr);
for (int ii=0;ii<starg.maxcount;ii++){
stmtins.bindin(ii+1,keyvalues[ii],50);
}

int ccount=0; // 记录从结果集中已获取记录的计数器。

memset(keyvalues,0,sizeof(keyvalues));

if (stmtsel.execute()!=0){
logfile.Write("stmtsel.execute() failed.\n%s\n%s\n",stmtsel.m_sql,stmtsel.m_cda.message); return false;
}

while (true){
// 获取需要同步数据的结果集。
if (stmtsel.next()!=0) break;

strcpy(keyvalues[ccount],remkeyvalue);

ccount++;

// 每starg.maxcount条记录执行一次同步。
if (ccount==starg.maxcount){
// 从本地表中删除记录。
if (stmtdel.execute()!=0){
// 执行从本地表中删除记录的操作一般不会出错。
// 如果报错,就肯定是数据库的问题或同步的参数配置不正确,流程不必继续。
logfile.Write("stmtdel.execute() failed.\n%s\n%s\n",stmtdel.m_sql,stmtdel.m_cda.message); return false;
}

// 向本地表中插入记录。
if (stmtins.execute()!=0){
// 执行向本地表中插入记录的操作一般不会出错。
// 如果报错,就肯定是数据库的问题或同步的参数配置不正确,流程不必继续。
logfile.Write("stmtins.execute() failed.\n%s\n%s\n",stmtins.m_sql,stmtins.m_cda.message); return false;
}

logfile.Write("sync %s to %s(%d rows) in %.2fsec.\n",starg.fedtname,starg.localtname,ccount,Timer.Elapsed());

connloc.commit();

ccount=0; // 记录从结果集中已获取记录的计数器。

memset(keyvalues,0,sizeof(keyvalues));

PActive.UptATime();
}
}

// 如果ccount>0,表示还有没同步的记录,再执行一次同步。
if (ccount>0){
// 从本地表中删除记录。
if (stmtdel.execute()!=0){
logfile.Write("stmtdel.execute() failed.\n%s\n%s\n",stmtdel.m_sql,stmtdel.m_cda.message); return false;
}

// 向本地表中插入记录。
if (stmtins.execute()!=0){
logfile.Write("stmtins.execute() failed.\n%s\n%s\n",stmtins.m_sql,stmtins.m_cda.message); return false;
}

logfile.Write("sync %s to %s(%d rows) in %.2fsec.\n",starg.fedtname,starg.localtname,ccount,Timer.Elapsed());

connloc.commit();
}

return true;

增量同步数据模块

​ 开发了刷新同步之后,增量同步只需在此基础改就行。首先,增量同步肯定是分批的,我们删掉不分批的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 业务处理主函数。
bool _syncincrement(bool &bcontinue){
CTimer Timer;

bcontinue=false;

// 从本地表starg.localtname获取自增字段的最大值,存放在maxkeyvalue全局变量中。
if (findmaxkey()==false) return false;

// 从远程表查找自增字段的值大于maxkeyvalue的记录。
char remkeyvalue[51]; // 从远程表查到的需要同步记录的key字段的值。
sqlstatement stmtsel(&connrem);
stmtsel.prepare("select %s from %s where %s>:1 %s order by %s",starg.remotekeycol,starg.remotetname,starg.remotekeycol,starg.where,starg.remotekeycol);
stmtsel.bindin(1,&maxkeyvalue);
stmtsel.bindout(1,remkeyvalue,50);
// 剩下的就是同步刷新功能已经展示过的

image-20220805180924250

1
2
3
4
5
6
7
8
9
10
11
// 执行同步的时间间隔,单位:秒,取值1-30。
GetXMLBuffer(strxmlbuffer,"timetvl",&starg.timetvl);
if (starg.timetvl<=0) { logfile.Write("timetvl is null.\n"); return false; }
if (starg.timetvl>30) starg.timetvl=30;

// 本程序的超时时间,单位:秒,视数据量的大小而定,建议设置30以上。
GetXMLBuffer(strxmlbuffer,"timeout",&starg.timeout);
if (starg.timeout==0) { logfile.Write("timeout is null.\n"); return false; }

// 以下处理timetvl和timeout的方法虽然有点随意,但也问题不大,不让程序超时就可以了。
if (starg.timeout<starg.timetvl+10) starg.timeout=starg.timetvl+10;

获取自增字段最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 从本地表starg.localtname获取自增字段的最大值,存放在maxkeyvalue全局变量中。
bool findmaxkey(){
maxkeyvalue=0;

sqlstatement stmt(&connloc);
stmt.prepare("select max(%s) from %s",starg.localkeycol,starg.localtname);
stmt.bindout(1,&maxkeyvalue);

if (stmt.execute()!=0){
logfile.Write("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return false;
}

// 用于将执行查询结果的返回值传入bindout的maxkeyvalue中
stmt.next();

// logfile.Write("maxkeyvalue=%ld\n",maxkeyvalue);

return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  // 业务处理主函数。一种优化
// bcontinue在_syncincrement方法中先默认设为false,有数据则改为true

bool bcontinue;
while (true){
if (_syncincrement(bcontinue)==false) EXIT(-1);

if (bcontinue==false) sleep(starg.timetvl);

PActive.UptATime();
}

bool _syncincrement(bool &bcontinue){
...................
// logfile.Write("sync %s to %s(%d rows) in %.2fsec.\n",starg.fedtname,starg.localtname,stmtsel.m_cda.rpc,Timer.Elapsed());
// 程序正式启用采用这个
if (stmtsel.m_cda.rpc>0) bcontinue=true;
return true;
}

不启用FEDERATED引擎

直接从远程表提取到数据同步程序,后执行语句储存到本地表

在这里只实现增量功能用来举例

image-20220807172525242

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 业务处理主函数。
bool _syncincrementex(bool &bcontinue) {
CTimer Timer;

bcontinue=false;

// 从本地表starg.localtname获取自增字段的最大值,存放在maxkeyvalue全局变量中。
if (findmaxkey()==false) return false;

// 拆分starg.localcols参数,得到本地表字段的个数。
CCmdStr CmdStr;
CmdStr.SplitToCmd(starg.localcols,",");
int colcount=CmdStr.CmdCount();

// 从远程表查找自增字段的值大于maxkeyvalue的记录,存放在colvalues数组中。
char colvalues[colcount][TABCOLS.m_maxcollen+1];
sqlstatement stmtsel(&connrem);
stmtsel.prepare("select %s from %s where %s>:1 %s order by %s",starg.remotecols,starg.remotetname,starg.remotekeycol,starg.where,starg.remotekeycol);
stmtsel.bindin(1,&maxkeyvalue);
for (int ii=0;ii<colcount;ii++)
stmtsel.bindout(ii+1,colvalues[ii],TABCOLS.m_maxcollen);

// 拼接插入SQL语句绑定参数的字符串 insert ... into starg.localtname values(:1,:2,...:colcount)
char bindstr[2001]; // 绑定同步SQL语句参数的字符串。
char strtemp[11];

memset(bindstr,0,sizeof(bindstr));

for (int ii=0;ii<colcount;ii++){
memset(strtemp,0,sizeof(strtemp));
sprintf(strtemp,":%lu,",ii+1); // 这里可以处理一下时间字段。
strcat(bindstr,strtemp);
}

bindstr[strlen(bindstr)-1]=0; // 最后一个逗号是多余的。

// 准备插入本地表数据的SQL语句。
sqlstatement stmtins(&connloc); // 向本地表中插入数据的SQL语句。
stmtins.prepare("insert into %s(%s) values(%s)",starg.localtname,starg.localcols,bindstr);
for (int ii=0;ii<colcount;ii++){
stmtins.bindin(ii+1,colvalues[ii],TABCOLS.m_maxcollen);
}

if (stmtsel.execute()!=0){
logfile.Write("stmtsel.execute() failed.\n%s\n%s\n",stmtsel.m_sql,stmtsel.m_cda.message); return false;
}

while (true){
memset(colvalues,0,sizeof(colvalues));

// 获取需要同步数据的结果集。
if (stmtsel.next()!=0) break;

// 向本地表中插入记录。
if (stmtins.execute()!=0){
// 执行向本地表中插入记录的操作一般不会出错。
// 如果报错,就肯定是数据库的问题或同步的参数配置不正确,流程不必继续。
logfile.Write("stmtins.execute() failed.\n%s\n%s\n",stmtins.m_sql,stmtins.m_cda.message); return false;
}

// 每1000条提交一次。
if (stmtsel.m_cda.rpc%1000==0){
connloc.commit(); PActive.UptATime();
}
}

// 处理最后未提交的数据。
if (stmtsel.m_cda.rpc>0) {
logfile.Write("sync %s to %s(%d rows) in %.2fsec.\n",starg.remotetname,starg.localtname,stmtsel.m_cda.rpc,Timer.Elapsed());

connloc.commit();

bcontinue=true;
}

return true;
}

学习总结

image-20220807174128810

image-20220807174250259

mysql触发器

1、触发器的概念

[MySQL触发器概念、原理与用法详解_Mysql_脚本之家 (jb51.net)](https://www.jb51.net/article/164675.htm#:~:text= 触发器(trigger)是MySQL提供给程序员和数据分析员来保证数据完整性的一种方法,它是与表事件相关的特殊的存储过程,它的执行不是由程序调用,也不是手工启动,而是由事件来触发,比如当对一个表进行操作(insert,delete, update)时就会激活它执行。.,——百度百科. 上面是百度给的触发器的概念,我理解的触发器的概念,就是你执行一条sql语句,这条sql语句的执行会自动去触发执行其他的sql语句,就这么简单。. 超简说明:sql1->触发->sqlN,一条sql触发多个sql.)

触发器(trigger)是MySQL提供给程序员和数据分析员来保证数据完整性的一种方法,它是与表事件相关的特殊的存储过程,它的执行不是由程序调用,也不是手工启动,而是由事件来触发,比如当对一个表进行操作(insert,delete, update)时就会激活它执行。——百度百科

上面是百度给的触发器的概念,我理解的触发器的概念,就是你执行一条sql语句,这条sql语句的执行会自动去触发执行其他的sql语句,就这么简单。

超简说明:sql1->触发->sqlN,一条sql触发多个sql

细节

为何需要避免删除操作?

     delete物理删除既不能释放磁盘空间,而且会**产生大量的碎片**,导**致索引频繁断裂**,**影响**SQL执行计划的**稳定性**,同时,在碎片回收时,**会耗用大量的CPU,磁盘空间**,影响表的正常DML操作。

​ 在业务代码层面,应该做逻辑标记删除,避免物理删除,为了实现归档需求,可以采用MSQL分区特性来实现,都是DDL操作,没有碎片产生。

我们这个程序需要避免删除操作,但是如果表不是我们设计的怎么办?

​ 用触发器同步,创建操作日志表,在账户基本信息表创建触发器,把对这个表的各种操作记录在日志表中

采用触发器同步的效率比较高,最大的问题是要在远程数据库表上创建触发器,会增加负担,另外也会对业务系统产生影响,出了问题不好归责,但是如果两个数据库都是自己的,那完全可以用image-20220807175334298

​ 第二个方法,增加一个程序,定期扫描远程表本地表,反正检查到不一样就删除。

​ 第三个方法,就是数据抽取程序+数据入库程序

image-20220807175837401

不足

第一个缺点不能说完全怪我们,因为大家都有这个困扰,第二个确实是我们的缺点,因为我们要去读取远程表的数据

image-20220807180106519

mysql的binlog

使用binlog,可以让我们远程访问程序核实正确的时候只需要查询日志,不需要进入表中减缓系统运行速度。

image-20220807180304624

一、初步了解binlog

mysql binlog详解 - Presley - 博客园 (cnblogs.com)

  1、MySQL的二进制日志binlog可以说是MySQL最重要的日志,它记录了所有的DDL和DML语句(除了数据查询语句select),以事件形式记录,还包含语句所执行的消耗的时间,MySQL的二进制日志是事务安全型的。

    a、DDL

      —-Data Definition Language 数据库定义语言

      主要的命令有create、alter、drop等,ddl主要是用在定义或改变表(table)的结构,数据类型,表之间的连接和约束等初始工作上,他们大多在建表时候使用。

    b、DML

      —-Data Manipulation Language 数据操纵语言

      主要命令是slect,update,insert,delete,就像它的名字一样,这4条命令是用来对数据库里的数据进行操作的语言

  2、mysqlbinlog常见的选项有一下几个:

    a、–start-datetime:从二进制日志中读取指定等于时间戳或者晚于本地计算机的时间

    b、–stop-datetime:从二进制日志中读取指定小于时间戳或者等于本地计算机的时间 取值和上述一样

    c、–start-position:从二进制日志中读取指定position 事件位置作为开始。

    d、–stop-position:从二进制日志中读取指定position 事件位置作为事件截至

  3、一般来说开启binlog日志大概会有1%的性能损耗。

  4、binlog日志有两个最重要的使用场景。

    a、mysql主从复制:mysql replication在master端开启binlog,master把它的二进制日志传递给slaves来达到master-slave数据一致的目的。

​ MySQL之间数据复制的基础二进制日志文件(binary log file)。一台MySQL数据库一旦启用二进制日志后,其作为master,它的数据库中所有操作都会以“事件”的方式记录在二进制日志中,其他数据库作为slave通过一个I/O线程与主服务器保持通信,并监控master的二进制日志文件的变化,如果发现master二进制日志文件发生变化,则会把变化复制到自己的中继日志中,然后slave的一个SQL线程会把相关的“事件”执行到自己的数据库中,以此实现从数据库和主数据库的一致性,也就实现了主从复制。

    b、数据恢复**:通过mysqlbinlog工具来恢复数据。

      binlog日志包括两类文件:

      1)、二进制日志索引文件(文件名后缀为.index)用于记录所有的二进制文件。

      2)、二进制日志文件(文件名后缀为.00000*)记录数据库所有的DDL和DML(除了数据查询语句select)语句事件。

十、数据管理子系统

image-20220807182011063

数据清理是指:数据没有价值了,需要删除。

数据迁移是指:出于性能与内存的考虑,把价值没这么大的数据移动一个位置。

数据清理

image-20220807182132178

注意事项

sqlstatement对象一个时间只能执行一条语句是mysql的缺点,别的数据库没有

image-20220807182349554

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 业务处理主函数。
bool _deletetable(){
CTimer Timer;

char tmpvalue[51]; // 存放从表提取待删除记录的唯一键的值。

// 从表提取待删除记录的唯一键。
sqlstatement stmtsel(&conn1);
stmtsel.prepare("select %s from %s %s",starg.keycol,starg.tname,starg.where);
stmtsel.bindout(1,tmpvalue,50);

// 拼接绑定删除SQL语句where 唯一键 in (...)的字符串。
char bindstr[2001];
char strtemp[11];

memset(bindstr,0,sizeof(bindstr));

for (int ii=0;ii<MAXPARAMS;ii++){
memset(strtemp,0,sizeof(strtemp));
sprintf(strtemp,":%lu,",ii+1);
strcat(bindstr,strtemp);
}

bindstr[strlen(bindstr)-1]=0; // 最后一个逗号是多余的。

char keyvalues[MAXPARAMS][51]; // 存放唯一键字段的值。

// 准备删除数据的SQL,一次删除MAXPARAMS条记录。
sqlstatement stmtdel(&conn2);
stmtdel.prepare("delete from %s where %s in (%s)",starg.tname,starg.keycol,bindstr);
for (int ii=0;ii<MAXPARAMS;ii++)
stmtdel.bindin(ii+1,keyvalues[ii],50);

int ccount=0;
memset(keyvalues,0,sizeof(keyvalues));

if (stmtsel.execute()!=0){
logfile.Write("stmtsel.execute() failed.\n%s\n%s\n",stmtsel.m_sql,stmtsel.m_cda.message); return false;
}

while (true){
memset(tmpvalue,0,sizeof(tmpvalue));

// 获取结果集。
if (stmtsel.next()!=0) break;

strcpy(keyvalues[ccount],tmpvalue);
ccount++;

// 每MAXPARAMS条记录执行一次删除语句。
if (ccount==MAXPARAMS){
if (stmtdel.execute()!=0){
logfile.Write("stmtdel.execute() failed.\n%s\n%s\n",stmtdel.m_sql,stmtdel.m_cda.message); return false;
}

ccount=0;
memset(keyvalues,0,sizeof(keyvalues));

PActive.UptATime();
}
}

// 如果不足MAXPARAMS条记录,再执行一次删除。
if (ccount>0){
if (stmtdel.execute()!=0){
logfile.Write("stmtdel.execute() failed.\n%s\n%s\n",stmtdel.m_sql,stmtdel.m_cda.message); return false;
}
}

if (stmtsel.m_cda.rpc>0) logfile.Write("delete from %s %d rows in %.02fsec.\n",starg.tname,stmtsel.m_cda.rpc,Timer.Elapsed());

return true;
}

数据迁移

仅仅只多了中间那个步骤, 再删除以前先备份

1
2
3
// 准备插入和删除表数据的sql,一次迁移starg.maxcount条记录。
sqlstatement stmtins(&conn2);
stmtins.prepare("insert into %s(%s) select %s from %s where %s in (%s)",starg.dsttname,TABCOLS.m_allcols,TABCOLS.m_allcols,starg.srctname,starg.keycol,bindstr);

image-20220808111708002

image-20220808111939610

这个每批次的数量会根据情况改变,例如,如果迁移表中有BLOB字段,那么每批迁移数就不能太多,因为BLOB字段占用的空间可能会很大。如果有可能会出现唯一键冲突,那么会导致一批次都迁移失败,所以有的时候会考虑一批只传送一个数据,出错了就不理他,写日志继续迁移其他的记录。

image-20220808112140210

十一、Oracle数据库开发

Oracle用户的DBA登录sqlplus / as sysdba

用oracle用户登录,执行lsnrctl start启动网络监听服务,执行dbstart启动数据库系统。

用oracle用户登录,执行lsnrctl stop关闭网络监听服务,执行dbshut关闭数据库系统。

配置数据库地址

vi /oracle/home/network/admin/tnsnames.ora

oci头文件

$ORACLE_HOME/rdbms/public

mysql和oracle的区别

概念

其他区别:用到时,网上自行查找工具就行了

image-20220815170446435

​ MySQL可以称为数据库服务,在一个服务中,可以创建多个数据库,在多个数据库中再创建表,索引,视图等对象。

image-20220815170555370

​ Oracle不用数据库这个名词,用实例,在一个实例中可以创建多个用户,在用户中再创建表,索引,视图等对象

image-20220815170755629

数据类型

image-20220815171117415

image-20220815171131130

MySQL中自增字段只有一个,并且还需要设置为唯一键,Oracle没有这个限制,一个表可以有多个自增字段,甚至没有自增字段,可以采用序列生成器,Oracle也不要求把自增字段设置为唯一键,但我们一般也会设置。

Oracle开发基础

image-20220815194029387

connection和sqlstatement基本上和mysql没啥区别。

错误代码也是一一对应就是了不需要记忆

但是注意事项。

image-20220815194122451

image-20220815194239284

Oracle故障排除的方法

​ 如果对方服务器没有启动,网络不通,没有开通防火墙,会提示无法连接目标主机,如果对方程序已经启动,并且开启了防火墙,那么就会提示无监听程序,也就是说对方的监听服务没有启动。

​ 如果可以telnet xxx.xxx.xxx.xxx port(1521) 说明网络没问题,防火墙也没问题,这个时候如果提示监听程序当前无法识别连接描述符所给出的SID, 也就是说你想连接的数据库还没有启动,用dbstart启动。如果这样还不行,说明数据库的SID设置那里出错了,指定的SID根本就不存在

用户和权限管理基本知识

J:\11Projectc++\课程文档(1)\oracle数据库\28.Oracle用户和权限管理.docx

序列生成器基本知识

J:\11Projectc++\课程文档(1)\oracle数据库\11.Oracle序列生成器.docx

Oracle双引号单引号

(18条消息) Oracle数据库中单引号’ ‘ 和双引号” “的区别_Ninewind的博客-CSDN博客_数据库中单引号是什么意思

呜呜呜呜呜呜,利用PowerDesigner造表的时候一定要注意,引号要去掉啊!!!!这点差点把我干碎!

在Oracle数据库中,单引号’ ‘和双引号” “两者都是可以表示字符串的,但是在使用时会有所区别。

在双引号” “中,一般在如下场合使用

  1. 表示其内部的字符串严格区分大小写 (比如用作字段别名时区分大小写)
  2. 用于特殊字符或关键字 (比如包含空格,#或&时)
  3. 不受标识符规则限制
  4. 会被当成一个列来处理
  5. 当出现在to_char的格式字符串中时,双引号有特殊的作用,就是将非法的格式符包装起

而在单引号’ ‘中,一般在如下场合使用

  1. 表示字符串常量 (比如用于条件限定时where=’aa’,单引号用于条件限定时对大小写敏感)
  2. 字符串中的双引号仅仅当作一个字符串”处理,可以在单引号’ ‘中使用双引号”
  3. 如果字符串常量中包含了单引号’ ‘,那么需要使用两个单引号 ‘’ 表示一个单引号常量

数据入库子系统修改

从MySQL的版本改过来大致这样。

image-20220817195154604

这个错误代码应该熟记(mysql是1062)

image-20220817195329586

MYSQL版本,keyid字段无需处理,Oracle版本,keyid需要处理,upttime仍然不需要。image-20220817195543233

在我们这个项目有个约定,序列名和表名除了前缀,其他是一样的,所以我们可以用SEQ_序列名的方式来得到序列名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   // keyid字段需要特殊处理。
// 最下面的colseq是sql语句绑定参数的计数器,由于SEQ语句并不需要,所以要从if语句的外面,加入else里面。
if (strcmp(TABCOLS.m_vallcols[ii].colname,"keyid")==0)
{
SNPRINTF(strtemp,100,sizeof(strtemp),"SEQ_%s.nextval",stxmltotable.tname+2);
}
else
{
if (strcmp(TABCOLS.m_vallcols[ii].datatype,"date")!=0)
SNPRINTF(strtemp,100,sizeof(strtemp),":%d",colseq);
else
SNPRINTF(strtemp,100,sizeof(strtemp),"to_date(:%d,'yyyymmddhh24miss')",colseq);

colseq++;
}

时间格式修改:

将mysql这种讨厌的格式image-20220817200324931

改为这样的格式:image-20220817200356218

另外就是SQL语句中的now()改为sysdate,Oracle没有now()

数据清理子系统修改

image-20220818140437935

​ 数据清理不用考虑2、3、4三个问题,不过需要注意还有更多需要修改的地方。

​ 就比如mysql需要为每一种操作都创造一个数据库连接,否则会串线,Oracle就可以兼容,实现一个对象操纵多种命令。

​ Oracle不需要MAXPARMS这个宏,我们直接定义就好了。

我们直接使用,这样的效率不是最高的,为什么这么说

J:\11Projectc++\课程文档(1)\oracle数据库\17.Oracle伪列.docx

可以看看Oracle的伪列,非常重要

​ 我们只需要将查找的条件keycol中的keyid替换为rowid就行,rowid是直接记录了这条记录在硬盘里的物理位置,肯定比任何索引都来的快,不过他是oracle所特有,用在别的数据库会有兼容问题,并且rowid不是固定的,会随着资源的移动而发生变化。

数据迁移子系统修改

image-20220818144341798

数据抽取子系统修改

image-20220818153244601

​ 如果Mysql语句有语法错误,prepare会返回错误提示,但是oracle不会。

​ 为了程序兼容性考虑,不要去判断prepare的返回值,错误代码943那行语句不能放在执行之前,应该在执行之后判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (strlen(starg.connstr1)!=0)
{
// 把自增字段的最大值写入数据库的表。
// create table T_MAXINCVALUE(pname varchar(50),maxincvalue numeric(15),primary key(pname));
sqlstatement stmt(&conn1);
stmt.prepare("update T_MAXINCVALUE set maxincvalue=:1 where pname=:2");
stmt.bindin(1,&imaxincvalue);
stmt.bindin(2,starg.pname,50);
if (stmt.execute()!=0)
{
if (stmt.m_cda.rc==942)
{
// 如果表不存在,就创建表。
conn1.execute("create table T_MAXINCVALUE(pname varchar2(50),maxincvalue number(15),primary key(pname))");
conn1.execute("insert into T_MAXINCVALUE values('%s',%ld)",starg.pname,imaxincvalue);
conn1.commit();
return true;
}
else
{
logfile.Write("stmt.execute() failed.\n%s\n%s\n",stmt.m_sql,stmt.m_cda.message); return false;
}

数据同步子系统修改

Oracle并没有federated引擎,但是有更强大的DBlink,关于它的使用可以看这篇文章。

J:\11Projectc++\课程文档(1)\oracle数据库\20.Oracle数据库链路.docx

​ 数据库链路(database link),简称dblink,它是一个通道,是本地数据库与远程数据库之间的通道,通过dblink,在本地数据库中可以直接访问远程数据库的对象。

dblink不是应用程序与数据库之间的通道,而是数据库之间的通道

应用经验

​ dblink的知识很容易掌握,用dblink访问远程数据库的对象很方便,但是,如果在程序中采用dblink对远程数据库的表进行增、删、改、查操作时一定要遵守一个原则:尽可能不要产生远程事务,因为数据库对远程的事务难以控制,也就是说,尽可能不要对远程数据库的表进行增、删、改操作,查询是没有问题的。

先创建dblink

1
2
3
4
5
6
7
8
9
10
sqlplus / as sysdba
SQL> grant create database link to qxidc;
#SQL> grant create database link to scott;
# 原本应该给远程服务器的这个授权权限
授权成功。
SQL> exit;
sqlplus scott/tiger
SQL> create database link db128 connect to qxidc identified by qxidcpwd using 'snorcl11g_128';

数据库链接已创建。

​ 通过dblink访问远程数据库的权限是由dblink所采用的用户决定的,就像你拿着别人的员工卡进入公司大楼一样。

就比如qxidc用户没有访问这个表的权限,提示视图不存在,其实是他无法看到而已。image-20220818174233287

另外一台虚拟机上用dblink访问也是一样的意思

image-20220818174330829

syncupdate_oracle.cpp、syncincrement_oracle.cpp解决方案,仅需要修改这两处

1
2
3
4
5
6
#define MAXPARAMS 256

/*
1、程序的帮助;
6、oracle不需要MAXPARAMS宏。
*/

​ 对于syncincrementex_oracle.cpp而言,存在MAXPARAMS宏的问题,这个程序取出远程表的数据放入内存中,再插入本地表,需要绑定输入和输出变量,只要绑定变量,肯定会涉及到绑定参数个数最大值的问题,不过这个问题,也只是对mysql版本,因为oracle封装的方法,无需限制有多大,可以不需要这个宏。

​ 第二个问题,在绑定语句中,如果有时间字段,需要在程序中先转化为字符串,插入本地表的时候,再用to_date将字符串转化为时间,这样是很麻烦的,一种解决方法是数据库的时间缺省方式。

1
2
# oracle用户状态下命令行输入
export NLS_DATE_FORMAT='yyyy-mm-dd hh24:mi:ss'

image-20220818194143527

我们来做一个测试:采用DBlink和不采用DBlink的增量同步程序

image-20220818211333238

这是Dblink的程序maxcount采用1、10、100的情况

image-20220818211524213

因此我们得出结论,Dblink和批量处理的效率是非常高的,比FEDERATED的效率要高得多。

数据库集群方案

​ 作为一个能搭建数据中心的程序员,数据库集群是避不开的问题,我们作为程序员,需要了解概念和原理,不需要动手实践。

image-20220818211930613

RAC

​ 每个结点都安装了oracle数据库和操作系统,他们共享存储,共享存储有容错机制,稳定性比服务器都要好很多,也很贵,如果一般结点坏了,不影响系统工作,共享存储坏了就玩完了。

image-20220818212058390

​ 上面是单实例的数据库,服务器发生故障的时候,客户端的connection会断开,但是对于RAC集群来说,他能使得并列的服务端,接管错误的那个服务端的connection,保证了持续连接,对应用程序来说是没有感觉的,业务也不会受到影响。RAC是高可用的解决方案,不是高性能的解决方案,他能保持服务器永远在线(足够多结点),但是对性能没有提升,因为RAC共享一个存储设备,存储设备的性能决定了整体性能。

​ 虽然只有一份文件,不能提高读写效率,但是却有办法提高查询效率,因为每个结点都有可能能保存账本的信息,直接反馈给客户端。这种处理方法对某些行业非常重要,比如说银行,证券,便利等行业,还有政府部门。它的代价也显而易见,烧硬件。IBM3850是一个最好的选择,一般来说5w块钱的就可以了。如果觉得这个不够好,可以试试IBM小型机。用它的分区就可以了,每个分区相当于一台独立的服务器。

​ 软件方面也特别的昂贵,这么说吧,搭建一个RAC要100w,RAC的服务端可以搭建很多,但实际中一般两个就够了,再多也没啥太大的意义。

image-20220818212229709

image-20220819155332258

​ RAC把数据写入共享内存时,多个节点之间需要协调,所以写速度低一些,不如单实例的数据库,读就因为都有备份,所以快一些。MYSQL也有RAC的功能,但是很脆弱,在几十万的硬件上运行MYSQL也会让人难以理解。

Data Guard

​ Data Guard是Oracle自带的集群方案,类似于MySQL的主从复制,只不过这里是Primary site的数据,定期保存到Standby site,对应MySQL的master slave。

image-20220819155449059

Data Guard的,primary写,standby读,是可以做的,但这样也显得有些笨,Oracle有更好的办法

image-20220819155651435

OGG

​ OGG主要有三个进程,源端的数据抽取进程,网络的文件传输进程,目标端的数据复制进程。最慢的地方就是将解析的数据插入目标端这个步骤,插入需要一点点来,确实是没办法的事情。

image-20220819155931506

image-20220819160105313

OGG vs 数据同步子系统

image-20220819160213220

image-20220819160329794

image-20220819160435745

硬件配置

有可能面试会被问到。

存储设备又叫存储服务器,它的容量一般都很大,具体看业务需求,EMC是专门做这个的公司,比IBM做得要好

IBM3850+EMC是一个方案,IBM P750小型机分成四个区也是一个方案,一个区的性能比IBM3850还要好。如果用的是小型机,操作系统肯定是AIX,是UNIX的一种。

​ 备份的服务器(Standby)不用太好,3650都可以,价格在2w多,内存也不用大,64G足够。

image-20220819160601955

image-20220819160824332

一个槽可以挂一个硬盘。

image-20220819161056092

Oracle DBA

​ 这是一个专门的岗位

image-20220819161222325

Oracle新特性

​ inmemory不能提升写数据的性能,但是能提升读数据(几十倍)。Oracle12才有的

image-20220819161555762

​ 可能我们以后会见到这个框架,但如果有Oracle就不需要这么做了,新版本的Oracle自带这个功能,不再需要redis做缓存,使用起来也更方便。image-20220819161702895

区块链相关知识

等等等等……………….

image-20220819161813047

MySQL何去何从

image-20220819161908960

PostgreSQL及更多

它的性能比mysql强大的多,但因为没人维护,所以没人敢用

image-20220819162005197

image-20220819162143370

十二、linux线程

线程与进程

​ 对程序员来说,调用进程和调用线程的代码不一样。但是,操作系统底层,都是调用同一个类层函数clone,把进程复制一份。对类和函数来说,如果创建的是进程,还需要复制地址空间,如果创建的是线程,就不复制地址空间,让他和原来的进程共享地址空间。

image-20220810111255803

image-20220810141330592

注意:所谓的多线程都是指同一个进程下的多个线程。

线程的优缺点

image-20220810141532248

学习任务

image-20220810141622111

image-20220810141652324

image-20220810141721239

创建简单线程

提前说明,在这里我们把main函数叫做主线程,或者主进程。被创建的线程叫做线程或者子线程,子线程运行的函数叫线程主函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//  Test01.cpp
// 本程序演示线程的创建

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thmain(void *arg); // 线程主函数

int main(int argc, char *argv[]){
pthread_t thid = 0; // 线程id typedef unsigned long pthread_t

// 创建线程。
if(pthread_create(&thid, NULL, thmain, NULL) != 0){
// 一般失败只有操作系统有问题或者创建的线程太多了
printf("pthread_create failed.\n");
exit(-1);
}

// 等待子线程的退出。

printf("join...\n");
pthread_join(thid, NULL);
printf("join ok.\n");
}

void *thmain(void *arg){ // 线程主函数
for(int i = 0; i < 5; i++){
sleep(1);
printf("pthmain sleep(%d) ok.\n", i+1);
}
}

我们要查看进程运行,先查看进程编号这里就是:ps -ef |grep Test01

找到./test01使用ps -Lf 19526来查看,上面那个的LWP和PID一样,所以他是主线程,下面那个是子线程

image-20220810145536477

在多线程程序中,他们的LWP(PCB)是不同的,但是他们的地址空间是一样的

线程非正常终止

在多线程中,切记主线程不能退出,因为他们同处一室,如果他提前退出了,子线程没有机会把该干的事干完,如果主线程实在没事干,可以就在主线程中join()等待子线程结束

image-20220810150139060

对于第三种情况,想要表达的重点是:

image-20220810150903002

Core dump

​ 当程序运行的过程中异常终止或崩溃操作系统会将程序当时的内存状态记录下来保存在一个文件中,这种行为就叫做Core Dump(中文有的翻译成“核心转储”)。我们可以认为 core dump 是“内存快照”,但实际上,除了内存信息之外,还有些关键的程序运行状态也会同时 dump 下来,例如寄存器信息(包括程序指针、栈指针等)、内存管理信息、其他处理器和操作系统状态和信息。core dump 对于编程人员诊断和调试程序是非常有帮助的,因为对于有些程序错误是很难重现的,例如指针异常,而 core dump 文件可以再现程序出错时的情景。

终止线程的三种办法

image-20220810151416365

return 0 代表的是NULL,本身就是地址,所以return 0可以直接写,return别的需要转换为void *地址

image-20220810151441269

image-20220810151551077

任意一个线程都可以。

image-20220810151542016

image-20220810151627285

image-20220810151721073

exit里面放的值效果和return一样,如果不是写0,范回别的都需要提前转变量类型

image-20220810151804043

image-20220810151820279

既然这样,那么第一种和第三种方法又有什么区别呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void fun1()
{ return;}

void *thmain1(void *arg) // 线程主函数。
{
for (int ii=0;ii<5;ii++)
{
var=ii+1;
sleep(1); printf("pthmain1 sleep(%d) ok.\n",var);
if (ii==2) fun1();
}
}

void fun2()
{ pthread_exit(0);}

void *thmain2(void *arg) // 线程主函数。
{
for (int ii=0;ii<5;ii++)
{
sleep(1); printf("pthmain2 sleep(%d) ok.\n",var);
if (ii==2) fun2();
}
}

​ 答案便是,如果在子线程中,exit能终止这个线程,return只会又范回这个线程,这个一般程序的exit(0)与return 0一样的道理。

image-20220810152249851

线程参数的传递

image-20220810152404084

image-20220810152423987

对于第一个问题:

为什么会出现的原因,可以归结于,先创建的线程不一定先运行。

image-20220810154100990

我们可以考虑采用sleep来使得结果不再紊乱

image-20220810154158098

但是实际开发中,不可能采用sleep这种方法,会被别人笑话。TAT

正确的方法是,创建线程的时候,把create的第四个参数传递给线程主函数。

强制转换

​ 在下图中,前四行是用本来存放地址的pv存放了ii的值,也就是pv输出即0xa(10)。后三行是用本来存放数字的jj存放地址的值,并且由于指针占用内存空间八字节,所以不允许直接转换为int,编译器只允许小转大,对此我们可以采用先转化为long,再转化为int的方法处理。

image-20220810155005836

​ 在日常开发中一般不会使用,但是在多线程中却常常使用。

​ 注意品位下面的强制转换。我们传入的不是地址,而是整数类型值的地址,也就是类似于0x1,0x2这种,如果直接传var的地址,如果直接通过参数传入是不正确的,本质上和全局变量var别无二致。

​ 这个程序类似于已经完成了前四个目标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 本程序演示线程参数的传递(用强制转换的方法传变量的值)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thmain1(void *arg); // 线程1的主函数。
void *thmain2(void *arg); // 线程2的主函数。
void *thmain3(void *arg); // 线程3的主函数。
void *thmain4(void *arg); // 线程4的主函数。
void *thmain5(void *arg); // 线程5的主函数。
int var;
int main(int argc,char *argv[])
{
pthread_t thid1=0,thid2=0,thid3=0,thid4=0,thid5=0; // 线程id typedef unsigned long pthread_t

// 创建线程。
var=1;
if (pthread_create(&thid1,NULL,thmain1,(void *)(long)var)!=0) { printf("pthread_create failed.\n"); exit(-1); }

var=2;
if (pthread_create(&thid2,NULL,thmain2,(void *)(long)var)!=0) { printf("pthread_create failed.\n"); exit(-1); }

var=3;
if (pthread_create(&thid3,NULL,thmain3,(void *)(long)var)!=0) { printf("pthread_create failed.\n"); exit(-1); }

var=4;
if (pthread_create(&thid4,NULL,thmain4,(void *)(long)var)!=0) { printf("pthread_create failed.\n"); exit(-1); }

var=5;
if (pthread_create(&thid5,NULL,thmain5,(void *)(long)var)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
pthread_join(thid3,NULL);
pthread_join(thid4,NULL);
pthread_join(thid5,NULL);
printf("join ok.\n");
}

void *thmain1(void *arg) // 线程主函数。
{
printf("var1=%d\n",(int)(long)arg);
printf("线程1开始运行。\n");
}

void *thmain2(void *arg) // 线程主函数。
{
printf("var2=%d\n",(int)(long)arg);
printf("线程2开始运行。\n");
}

void *thmain3(void *arg) // 线程主函数。
{
printf("var3=%d\n",(int)(long)arg);
printf("线程3开始运行。\n");
}

void *thmain4(void *arg) // 线程主函数。
{
printf("var4=%d\n",(int)(long)arg);
printf("线程4开始运行。\n");
}

void *thmain5(void *arg) // 线程主函数。
{
printf("var5=%d\n",(int)(long)arg);
printf("线程5开始运行。\n");
}

如何正确传递地址参数

如果要给线程传地址,一定要保证给每一个线程传不同的地址,而不能都传同一个地址。

并且要注意,如果我们新创建了很多的内存空间,那么一定要在子函数中把这块区域关闭。如果在主函数关闭,那就和在主函数关闭程序一个道理,根本无法判断子函数是否执行完!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建线程。
int *var1=new int; *var1=1;
if (pthread_create(&thid1,NULL,thmain1,var1)!=0) { printf("pthread_create failed.\n"); exit(-1); }

int *var2=new int; *var2=2;
if (pthread_create(&thid2,NULL,thmain2,var2)!=0) { printf("pthread_create failed.\n"); exit(-1); }

int *var3=new int; *var3=3;
if (pthread_create(&thid3,NULL,thmain3,var3)!=0) { printf("pthread_create failed.\n"); exit(-1); }

int *var4=new int; *var4=4;
if (pthread_create(&thid4,NULL,thmain4,var4)!=0) { printf("pthread_create failed.\n"); exit(-1); }

int *var5=new int; *var5=5;
if (pthread_create(&thid5,NULL,thmain5,var5)!=0) { printf("pthread_create failed.\n"); exit(-1); }

传递多个参数

线程的参数,如果传值,只能传一个,如果传地址,传入一个包含多个参数的结构体的地址,那就没问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 本程序演示线程参数的传递(用结构体的地址传递多个参数)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thmain(void *arg); // 线程的主函数。

struct st_args
{
int no; // 线程编号。
char name[51]; // 线程名。
};

int main(int argc,char *argv[])
{
pthread_t thid=0;

// 创建线程。
struct st_args *stargs=new struct st_args;
stargs->no=15; strcpy(stargs->name,"测试线程");
if (pthread_create(&thid,NULL,thmain,stargs)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid,NULL);
printf("join ok.\n");
}

void *thmain(void *arg) // 线程主函数。
{
struct st_args *pst=(struct st_args *)arg;
printf("no=%d,name=%s\n",pst->no,pst->name);
delete pst;
printf("线程开始运行。\n");
}

线程退出状态

实际开发中其实我们往往不关心这个,但是作为学习我们也必须了解,通常子线程的退出就是伴随着join函数的终止。我们先一起来了解一下join函数

image-20220810162132666

join函数的第二个变量是一个二级指针,也就是指针变量的地址。还记得我们的thmain函数返回值类型是void *吗?这个就是指,指向这个类型的指针。也就是能取得返回值地址的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 本程序演示线程线程退出(终止)的状态。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thmain(void *arg); // 线程的主函数。

struct st_ret
{
int retcode; // 返回代码。
char message[1024]; // 返回内容。
};

int main(int argc,char *argv[])
{
pthread_t thid=0;

// 创建线程。
if (pthread_create(&thid,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
struct st_ret *pst=0;
pthread_join(thid,(void **)&pst);
printf("retcode=%d,message=%s\n",pst->retcode,pst->message);
delete pst;
printf("join ok.\n");
}

void *thmain(void *arg) // 线程主函数。
{
printf("线程开始运行。\n");

// 注意,如果用结构体的地址作为线程的返回值,必须保存在线程主函数结束后地址仍是有效的。
// 所以,要采用动态分配内存的方法,不能用局部变量。
struct st_ret *ret=new struct st_ret;
ret->retcode=1121;
strcpy(ret->message,"测试内容。");
pthread_exit((void *)ret);
}

线程资源的回收

image-20220810165846123

先来复习复习进程资源的回收

image-20220810165959212

线程未分离

image-20220810170129829

​ 我们先让它sleep10s,也就是子线程都执行完以后再join

1
2
3
4
5
6
7
8
9
10
11
12
13
  // 创建线程。
if (pthread_create(&thid1,NULL,thmain1,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
if (pthread_create(&thid2,NULL,thmain2,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
sleep(10);
void *ret;
printf("join...\n");
int result=0;
result=pthread_join(thid2,&ret); printf("thid2 result=%d,ret=%ld\n",result,ret);
result=pthread_join(thid1,&ret); printf("thid1 result=%d,ret=%ld\n",result,ret);
ret=0;
result=pthread_join(thid2,&ret); printf("thid2 result=%d,ret=%ld\n",result,ret);
result=pthread_join(thid1,&ret); printf("thid1 result=%d,ret=%ld\n",result,ret);
printf("join ok.\n");

​ 可以发现,函数仍然能捕捉到ret,也就是证明了资源并没完全退出,和租房子的确也是一个道理,但是第二次join就不行了,类似于第一次join就把子函数赶走了。

image-20220810170635392

线程分离

主要用这两种方法分离。

image-20220810170818476

pthread_detach()

只有一个参数,就把线程名输进去就可以了,大概意思就是指,用了这个不需要用join回收了,并且只有返回值为0即函数执行成功。

这个函数可以放在主函数中(需要留给足够的时间让子进程执行完)

image-20220810171006764

也可以放在线程的主函数中,这个时候用pthread_self()得到自己的ID

image-20220810171411379

实际开发中更倾向于放在线程主函数中,因为更简单。

设置线程属性

太麻烦了,所以基本不用

image-20220810171606994

阻塞

​ 偶尔也会用到。tryjoin和join用法一样,不过,如果子线程没有终止,他不会等待,他立即返回。下面那个就是限制多久没终止,就返回。

image-20220810171706281

线程清理函数

入栈和出站必须成对出现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void *thmain(void *arg)    // 线程主函数。
{
pthread_cleanup_push(thcleanup1,NULL); // 把线程清理函数1入栈(关闭文件指针)。
pthread_cleanup_push(thcleanup2,NULL); // 把线程清理函数2入栈(关闭socket)。
pthread_cleanup_push(thcleanup3,NULL); // 把线程清理函数3入栈(回滚数据库事务)。

for (int ii=0;ii<3;ii++)
{
sleep(1); printf("pthmain sleep(%d) ok.\n",ii+1);
}

pthread_cleanup_pop(3); // 把线程清理函数3出栈。
pthread_cleanup_pop(2); // 把线程清理函数2出栈。
pthread_cleanup_pop(1); // 把线程清理函数1出栈。
}

然后在thcleanup1,2,3中释放资源。

image-20220810172619601

image-20220810172714371

只要清理函数已经入栈了,那么肯定会执行。

​ 现在我们来研究一下这个清理函数的参数

image-20220810172845514

execute的取值如果为0,表示让这个函数出栈,并且不执行,反之传入别的任意值,都是执行

进程终止函数

声明

下面是 atexit() 函数的声明。

1
int atexit(void (*func)(void))

参数

  • func – 在程序终止时被调用的函数。

返回值

如果函数成功注册,则该函数返回零,否则返回一个非零值。

实例

下面的实例演示了 atexit() 函数的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

void functionA ()
{
printf("这是函数A\n");
}

int main ()
{
/* 注册终止函数 */
atexit(functionA );

printf("启动主程序...\n");

printf("退出主程序...\n");

return(0);
}

让我们编译并运行上面的程序,这将产生以下结果:

1
2
3
启动主程序...
退出主程序...
这是函数A

线程的取消

取消不意味着终止,只是取消这次执行操作,线程并未结束。

image-20220810173538612

image-20220810173606506

使用方法很简单。

image-20220810175852388

对于取消状态而言,其实没啥用,因为只有取消和不取消两种选择,缺省是取消,所以不用管它

另外,我们还可以设置线程的取消方式

image-20220810175933056

DEFERRED是延迟取消,就你指定多久之后取消,可以理解为,你告诉它你该取消了,它说它知道了,但是他要运行到下一个能取消的地方才取消。ASYNCHRONOUS是立即取消(异步好诶)线程在任何时候都可以被取消

image-20220810180134033

​ 那么什么是取消点呢?我们来看看帮助文档(man 7 pthreads)后按/points搜索定位

image-20220810180653639

只要线程的代码中出现了这一堆(未显示完)函数,则叫做取消点

在实际开发中,如果线程中的代码没有取消点,那我们可以调用下面这个函数设置取消点,这是规范的做法。

image-20220810180803738

应该这样

image-20220810180857741

线程和信号

不管是进程还是线程,信号都比较复杂,可对于我们的开发而言,这个东西需要掌握的知识是比较简单的。

image-20220810181012666

image-20220810181545465

对于信号的执行,如果对同一个信号有多个执行函数,那么我们以最后被执行的那串代码为准。

image-20220810181900990

进程送达函数

pthread_kill():向指定的函数发送信号,和多进程的相似

image-20220810182019544

信号的更多知识

image-20220810181626425

线程安全

什么是线程安全,你真的了解吗? - 知乎 (zhihu.com)

什么是线程安全?

既然是线程安全问题,那么毫无疑问所有的隐患都是出现在多个线程访问的情况下产生的,也就是我们要确保在多条线程访问的时候,我们的程序还能按照我们预期的行为去执行,我们看一下下面的代码。

1
2
3
4
5
6
7
Integer count = 0;

public void getCount() {

count ++;
System.out.println(count);
}

很简单的一段代码,我们就来统计一下这个方法的访问次数,多个线程同时访问会不会出现什么问题,我开启的3条线程每个线程循环10次,得到一下结果

img

我们可以看到,这里出现了两个26,为什么会出现这种情况,出现这种情况显然表明我们这个方法根本就不是线程安全的,出现这种问题的原因有很多,我们说最常见的一种,就是我们A线程在进入方法后,拿到了count的值,刚把这个值读取出来还没有改变count的值的时候,结果线程B也进来的,那么导致线程A和线程B拿到的count值是一样的。

那么由此我们可以了解这确实不是一个线程安全的类,因为他们都需要操作这个共享的变量,其实要对线程安全问题给出一个明确的定义还是蛮复杂的,我们根据我们这个程序来总结下什么是线程安全。

当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类时线程安全的。

线程安全相关定义

image-20220810182954131

image-20220810183002920

image-20220810183015609

在多线程程序中,i++ i+1 写入结果这些可能不是原子操作,你读我也读

image-20220810183115468

image-20220810183212903

volatile关键字也不能解决问题,因为它不是原子的

image-20220810183328447

原子操作

原子锁,了解即可

image-20220810183440197

image-20220810183518560

image-20220810183535798

image-20220810183556921

两条线程一起执行同一个全局变量var

这个和上面那个区别并不大

image-20220810183654106

image-20220810183733770

C11原子类型

image-20220810183845389

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 本程序演示线程安全。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <atomic>
#include <iostream>

std::atomic<int> var; // 创建一个原子int对象

void *thmain(void *arg); // 线程主函数。

int main(int argc,char *argv[])
{
pthread_t thid1,thid2;

// 创建线程。
if (pthread_create(&thid1,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
if (pthread_create(&thid2,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
printf("join ok.\n");

// printf("var=%d\n",var);
std::cout << "var=" << var << std::endl;
}

void *thmain(void *arg) // 线程主函数。
{
for (int ii=0;ii<1000000;ii++)
{
var++;
// __sync_fetch_and_add(&var,1);
}
}

原子操作只支持整数,效率高,但是应用场景非常有限,实际开发中,锁住对象和一串代码是无法做到的,只能用线程同步

线程同步

线程同步的三属性彻底解决了线程安全的问题。

image-20220810184119509

image-20220810185537249

互斥锁

image-20220810185624359

image-20220810185634361

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 本程序演示线程同步-互斥锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int var;

pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER; // 声明互斥锁。

void *thmain(void *arg); // 线程主函数。

int main(int argc,char *argv[])
{
// pthread_mutex_init(&mutex,NULL); // 初始化互斥锁。

pthread_t thid1,thid2;

// 创建线程。
if (pthread_create(&thid1,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
if (pthread_create(&thid2,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
printf("join ok.\n");

printf("var=%d\n",var);

pthread_mutex_destroy(&mutex); // 销毁锁。
}

void *thmain(void *arg) // 线程主函数。
{
for (int ii=0;ii<1000000;ii++)
{
pthread_mutex_lock(&mutex); // 加锁。
var++;
pthread_mutex_unlock(&mutex); // 解锁。
}
}
属性

了解即可

image-20220810185849860

image-20220810185923982

image-20220810185941683

自旋锁

和互斥锁几乎一样,唯一不同的就是自旋锁它会在等待的时候不断地消耗cpu,而互斥锁不会。

​ 但也不能说谁好谁不好,各有应用的场景。

==自旋锁==适用等待时间比较==短==的场景,而==互斥锁==适用于等待时间可能会比较==长==的场景

自旋锁没有等待超时的函数,因为他默认使用场景就是等待时间很短的

image-20220810190019130

自旋锁的参数和互斥锁的区别是多了一个共享标志

image-20220810190340529

​ 这个share和private是这样的,在开发中,我们可以在进程中创建线程,线程中创建进程,但是实际开发的时候这样没有必要,毕竟程序搞得这么复杂,以后也看不懂,这个参数就是如果在多进程中创建多线程,不同进程中的线程是否能够共享锁而设计的,一般也填写priaveimage-20220810190509690

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 本程序演示线程同步-自旋锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int var;

pthread_spinlock_t spin; // 声明自旋锁。

void *thmain(void *arg); // 线程主函数。

int main(int argc,char *argv[])
{
pthread_spin_init(&spin,PTHREAD_PROCESS_PRIVATE); // 初始化自旋锁。

pthread_t thid1,thid2;

// 创建线程。
if (pthread_create(&thid1,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
if (pthread_create(&thid2,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
printf("join ok.\n");

printf("var=%d\n",var);

pthread_spin_destroy(&spin); // 销毁锁。
}

void *thmain(void *arg) // 线程主函数。
{
for (int ii=0;ii<1000000;ii++)
{
pthread_spin_lock(&spin); // 加锁。
var++;
pthread_spin_unlock(&spin); // 解锁。
}
}
读写锁

image-20220810190724465

image-20220810190748426

image-20220810190824426

image-20220810190850126

image-20220810190907308

image-20220810190918019

image-20220810191002906

image-20220810191016011

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 本程序演示线程同步-读写锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <signal.h>

pthread_rwlock_t rwlock=PTHREAD_RWLOCK_INITIALIZER; // 声明读写锁并初始化。

void *thmain(void *arg); // 线程主函数。

void handle(int sig); // 信号15的处理函数。

int main(int argc,char *argv[])
{
signal(15,handle); // 设置信号15的处理函数。

pthread_t thid1,thid2,thid3;

// 创建线程。
if (pthread_create(&thid1,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
sleep(1);
if (pthread_create(&thid2,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
sleep(1);
if (pthread_create(&thid3,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
pthread_join(thid1,NULL); pthread_join(thid2,NULL); pthread_join(thid3,NULL);

pthread_rwlock_destroy(&rwlock); // 销毁锁。
}

void *thmain(void *arg) // 线程主函数。
{
for (int ii=0;ii<100;ii++)
{
printf("线程%lu开始申请读锁...\n",pthread_self());
pthread_rwlock_rdlock(&rwlock); // 加锁。
printf("线程%lu开始申请读锁成功。\n\n",pthread_self());
sleep(5);
pthread_rwlock_unlock(&rwlock); // 解锁。
printf("线程%lu已释放读锁。\n\n",pthread_self());

if (ii==3) sleep(8);
}
}

void handle(int sig) // 信号15的处理函数。
{
printf("开始申请写锁...\n");
pthread_rwlock_wrlock(&rwlock); // 加锁。
printf("申请写锁成功。\n\n");
sleep(10);
pthread_rwlock_unlock(&rwlock); // 解锁。
printf("写锁已释放。\n\n");
}
条件变量

条件变量给多线程提供了复活的机制

image-20220810191653761

API

image-20220810191703809

image-20220810191725487

image-20220810191736210

在信号处理中,发送15信号,唤醒他一次(线程主函数中应该用wait使得它沉睡,等待被唤醒)

image-20220810192111448

暂时先介绍到这里

信号量

image-20220810192307109

API

image-20220810192401088

image-20220810192416123

image-20220810192425126

image-20220810192433594

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 本程序演示线程同步-信号量。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>

int var;

sem_t sem; // 声明信号量。

void *thmain(void *arg); // 线程主函数。

int main(int argc,char *argv[])
{
sem_init(&sem,0,1); // 初始化信号量。

pthread_t thid1,thid2;

// 创建线程。
if (pthread_create(&thid1,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }
if (pthread_create(&thid2,NULL,thmain,NULL)!=0) { printf("pthread_create failed.\n"); exit(-1); }

// 等待子线程退出。
printf("join...\n");
pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
printf("join ok.\n");

printf("var=%d\n",var);

sem_destroy(&sem); // 销毁信号量。
}

void *thmain(void *arg) // 线程主函数。
{
for (int ii=0;ii<1000000;ii++)
{
sem_wait(&sem); // 加锁。
var++;
sem_post(&sem); // 解锁。
}
}
细节说明

image-20220810192636785

互斥锁有两种竞争机制,1、形成等待队列 2、重新竞争

读写锁比较特别,其余几个都是形成等待队列

既然是排队,可能我们会以为是绝对公平的,但实际上并不是这样

image-20220810192827011

这可能与CPU时间片或者操作系统的调度有关系,比如说当前线程虽然释放了锁,但他的时间片并没有用完,就是说本来该轮到下一个线程,但是这个线程还没有被调度,所以刚刚执行过的线程又得到了锁

image-20220810192957768

那我们加一行,让cpu放弃时间片的代码,再来运行。

image-20220810193023876

现在的情况好一些了

这些例子证明了等待机制没有绝对的公平,但是对应用开发没有任何影响,这里举例也只是因为怕钻牛角尖

image-20220810193152271

我们有一个原则,锁的持有时间越短越好,所以实际开发中是不会出现饿死的情况。

读写锁读优先肯定有他的应用场景,如果不合适,不用就行了,不应该说这是读写锁的缺陷,而是物尽其用,都要分清场合。

image-20220810193306777

linux没有提供,可以自己做一个!

生产消费者模型
概念

image-20220811112554857

条件变量+互斥锁实现

我们先来搞清楚条件变量的wait做了什么

第三个步骤的两个操作是原子操作,只有在都成功的情况下才返回。

image-20220811112949273

为何条件变量一定要跟着一把互斥锁,就是因为条件变量就是为了生产消费者模型而设计的,没有其他的用途。

细节都写在代码里了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// 本程序演示用互斥锁和条件变量实现高速缓存。
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <vector>

using namespace std;

// 缓存队列消息的结构体。
struct st_message
{
int mesgid; // 消息的id。
char message[1024]; // 消息的内容。
}stmesg;

vector<struct st_message> vcache; // 用vector容器做缓存。

pthread_cond_t cond=PTHREAD_COND_INITIALIZER; // 声明并初始化条件变量。
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER; // 声明并初始化互斥锁。

void incache(int sig); // 生产者、数据入队。
void *outcache(void *arg); // 消费者、数据出队线程的主函数。

int main()
{
signal(15,incache); // 接收15的信号,调用生产者函数。

// 创建三个消费者线程。
pthread_t thid1,thid2,thid3;
pthread_create(&thid1,NULL,outcache,NULL);
pthread_create(&thid2,NULL,outcache,NULL);
pthread_create(&thid3,NULL,outcache,NULL);

pthread_join(thid1,NULL);
pthread_join(thid2,NULL);
pthread_join(thid3,NULL);

pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mutex);

return 0;
}

void incache(int sig) // 生产者、数据入队。
{
static int mesgid=1; // 消息的计数器。

struct st_message stmesg; // 消息内容。
memset(&stmesg,0,sizeof(struct st_message));

pthread_mutex_lock(&mutex); // 给缓存队列加锁。

// 生产数据,放入缓存队列。
stmesg.mesgid=mesgid++; vcache.push_back(stmesg);
stmesg.mesgid=mesgid++; vcache.push_back(stmesg);
stmesg.mesgid=mesgid++; vcache.push_back(stmesg);
stmesg.mesgid=mesgid++; vcache.push_back(stmesg);

pthread_mutex_unlock(&mutex); // 给缓存队列解锁。

//pthread_cond_signal(&cond); // 发送条件信号,激活一个线程。
pthread_cond_broadcast(&cond); // 发送条件信号,激活全部的线程。
}

void thcleanup(void *arg)
{
// 在这里释放关闭文件、断开网络连接、回滚数据库事务、释放锁等等。
printf("cleanup ok.\n");

// 一定要释放锁,不然回不去主线程里
pthread_mutex_unlock(&mutex);

/*
A condition wait (whether timed or not) is a cancellation point. When the cancelability type of a thread is set to PTHREAD_CAN_CEL_DEFERRED, a side-effect of acting upon a cancellation request while in a condition wait is that the mutex is (in effect) re-acquired before calling the first cancellation cleanup handler. The effect is as if the thread were unblocked, allowed to execute up to the point of returning from the call to pthread_cond_timedwait() or pthread_cond_wait(), but at that point notices the cancellation request and instead of returning to the caller of pthread_cond_timedwait() or pthread_cond_wait(), starts the thread cancellation activities, which includes calling cancellation cleanup handlers.
意思就是在pthread_cond_wait时执行pthread_cancel后,
要先在线程清理函数中要先解锁已与相应条件变量绑定的mutex,
这样是为了保证pthread_cond_wait可以返回到调用线程。
*/
};

void *outcache(void *arg) // 消费者、数据出队线程的主函数。
{
pthread_cleanup_push(thcleanup,NULL); // 把线程清理函数入栈。

struct st_message stmesg; // 用于存放出队的消息。

while (true)
{
pthread_mutex_lock(&mutex); // 给缓存队列加锁。

// 如果缓存队列为空,等待,用while防止条件变量虚假唤醒。
// 就比如总共三个消费者,现在大家都没吃的,阻塞在wait里,
// 突然生产出产品了,但只生成了两个在管道内,他们三个如果是if的话,都同时检测到
// size != 0, 因此被唤醒,但是如果是while,则会不断的判断是否为0,最终肯定两个先抢到的出来
// 最后一个没抢到的被拦在while里面永远无法抵达的真实。

while (vcache.size()==0)
{
pthread_cond_wait(&cond,&mutex);
}

// 从缓存队列中获取第一条记录,然后删除该记录。
memcpy(&stmesg,&vcache[0],sizeof(struct st_message)); // 内存拷贝。
vcache.erase(vcache.begin());

pthread_mutex_unlock(&mutex); // 给缓存队列解锁。

// 以下是处理业务的代码。
printf("phid=%ld,mesgid=%d\n",pthread_self(),stmesg.mesgid);
usleep(100);
}

pthread_cleanup_pop(1); // 把线程清理函数出栈。
}
信号量实现

​ 用一个信号量代替互斥锁,一个信号量代替条件变量,信号量不存在必须解锁才能返回的情况,唯一缺点是本来一个wait搞定的现在要wait前解锁,wait后加锁(也就是从原子操作变为不是原子操作,但是没有关系),不如条件变量方便,但是在多进程的程序中只能使用信号量,操作系统里,也是用信号量实现,也就是条件变量+互斥锁专用于多线程。

​ 在线程里面,生产数据只能不断用sem_post(&xxxx)来加一信号量,想要同时生成多个就该在代码行内冗余写这句,但是在进程里就不是,只需要调用一次v操作就可以取得已经生产好的量的数值

多线程的网络服务端

这里先给出代码,很多细节都在代码里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/*
* 程序名:demo20.cpp,此程序演示采用开发框架的CTcpServer类实现socket通讯多线程的服务端。
* 作者: jjyaoao
*/
#include "../_public.h"

CLogFile logfile; // 服务程序的运行日志。
CTcpServer TcpServer; // 创建服务端对象。

void EXIT(int sig); // 进程的退出函数。

pthread_spinlock_t vthidlock; // 用于锁定vthid的自旋锁。
vector<pthread_t> vthid; // 存放全部线程id的容器。
void *thmain(void *arg); // 线程主函数。

void thcleanup(void *arg); // 线程清理函数。

int main(int argc,char *argv[])
{
if (argc!=3)
{
printf("Using:./demo20 port logfile\nExample:./demo20 5005 /tmp/demo20.log\n\n"); return -1;
}

// 关闭全部的信号和输入输出。
// 设置信号,在shell状态下可用 "kill + 进程号" 正常终止些进程
// 但请不要用 "kill -9 +进程号" 强行终止
CloseIOAndSignal(); signal(SIGINT,EXIT); signal(SIGTERM,EXIT);

if (logfile.Open(argv[2],"a+")==false) { printf("logfile.Open(%s) failed.\n",argv[2]); return -1; }

// 服务端初始化。
if (TcpServer.InitServer(atoi(argv[1]))==false)
{
logfile.Write("TcpServer.InitServer(%s) failed.\n",argv[1]); return -1;
}

pthread_spin_init(&vthidlock,0);

while (true)
{
// 等待客户端的连接请求。
if (TcpServer.Accept()==false)
{
logfile.Write("TcpServer.Accept() failed.\n"); EXIT(-1);
}

logfile.Write("客户端(%s)已连接。\n",TcpServer.GetIP());

// 创建一个新的线程,让它与客户端通讯。
pthread_t thid;
if (pthread_create(&thid,NULL,thmain,(void *)(long)TcpServer.m_connfd)!=0)
{
logfile.Write("pthread_create() failed.\n"); TcpServer.CloseListen(); continue;
}

pthread_spin_lock(&vthidlock);
vthid.push_back(thid); // 把线程id放入容器。
pthread_spin_unlock(&vthidlock);
}
}

void *thmain(void *arg) // 线程主函数。
{
pthread_cleanup_push(thcleanup,arg); // 把线程清理函数入栈(关闭客户端的socket)。

int connfd=(int)(long)arg; // 客户端的socket。

pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS,NULL); // 线程取消方式为立即取消。

pthread_detach(pthread_self()); // 把线程分离出去。

// 子线程与客户端进行通讯,处理业务。
int ibuflen;
char buffer[102400];

// 与客户端通讯,接收客户端发过来的报文后,回复ok。
while (1)
{
// 客户端已经断开的话TcpRead和TcpWrite会跳出循环
memset(buffer,0,sizeof(buffer));
if (TcpRead(connfd,buffer,&ibuflen,30)==false) break; // 接收客户端的请求报文。
logfile.Write("接收:%s\n",buffer);

strcpy(buffer,"ok");
if (TcpWrite(connfd,buffer)==false) break; // 向客户端发送响应结果。
logfile.Write("发送:%s\n",buffer);
}

close(connfd); // 关闭客户端的连接。

// 把本线程id从存放线程id的容器中删除。
// 处理客户端因为网络断开的意外退出。
pthread_spin_lock(&vthidlock);
for (int ii=0;ii<vthid.size();ii++)
{
// 用equal函数代替pthread_self() == vthid[ii]可实现多平台兼容,因为有可能有的平台,线程的ID有的是整数,有的是结构体
if (pthread_equal(pthread_self(),vthid[ii])) { vthid.erase(vthid.begin()+ii); break; }
}
pthread_spin_unlock(&vthidlock);

pthread_cleanup_pop(1); // 把线程清理函数出栈。
}

// 进程的退出函数。
void EXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中被信号中断。
signal(SIGINT,SIG_IGN); signal(SIGTERM,SIG_IGN);

logfile.Write("进程退出,sig=%d。\n",sig);

TcpServer.CloseListen(); // 关闭监听的socket。

// 取消全部的线程。
for (int ii=0;ii<vthid.size();ii++)
{
// 线程被创建后与客户端进行通信,客户端断开了网络连接,子线程就会退出。
// 这个时候子线程应该把自己的ID从容器中删除。
pthread_cancel(vthid[ii]);
}

sleep(1); // 让子线程有足够的时间退出。

pthread_spin_destroy(&vthidlock);

exit(0);
}

void thcleanup(void *arg) // 线程清理函数。
{
close((int)(long)arg); // 关闭客户端的socket。

logfile.Write("线程%lu退出。\n",pthread_self());
}


线程安全

​ 基于多进程网络客户端的思想,主要内容是大同小异的,但是仍然有许多细节点需要注意,第一个是退出函数,考虑因客户端网络断开而意外退出的情况,第二个是线程安全,多个客户端同时启动访问,可带来多个线程,如果不加锁的话后果不堪设想,由于我们这个服务端访问之后执行时间较短,这里采用自旋锁。另外日志文件类型也不是安全的,也需要加锁,构造析构加锁解锁,写日志时加锁,写完的时候解锁,用这个方法来简单举例:image-20220811164246646

linux大部分函数都是安全的,不安全的常见的就这几个。

image-20220811164355077

image-20220811164508901

在框架中用到了三个,其中localtime肯定会用到,我们框架里用到的是不安全版本的,注释里的就是安全版本的image-20220811164555118

image-20220811164621509

拓展学习

看看就好,了解一下不是坏事

image-20220811164830696

image-20220811165034110

image-20220811165103305

面试题

能做出这道题,基本上可以算是合格的程序员。

image-20220811165207870

保证服务程序稳定性

多线程来做有一个好处,监控,调度,程序的功能在同一个程序中,我们之前那个使用了三个程序。

image-20220811165258371

异步通讯

image-20220811165434593

十三、数据服务总线

image-20220811165546150

​ 直连数据访问速度快,并且使用方便,通过数据服务总线(接口),则需要按照HTTP条条框框执行,并且不是随心所欲,这两种方式都有对应的应用场景

image-20220811165822751

image-20220811165850806

image-20220811165909230

HTTP协议的本质

通信方式

HTTP协议的通信方式是最简单直接的,客户端发起TCP连接请求,连接成功后发起请求报文,服务端响应,采用短连接的话通信一次即断开,长连接可以多次通信。

image-20220812160732830

报文格式

把请求的报文按这个格式拼接成一个字符串,发送给服务端就行了。

另外http是不安全的链接,https是安全的,在浏览器会显示一把锁。

image-20220812161546933

GET / HTTP/1.1 是请求行

这个例子,后面的都是请求头部,在GET方法里没有请求数据,别的方法里有些有,有些没有。

头部字段一般比较多,必填的是Host,Connection和Port比较有意义

客户端判断响应是否结束

Content-Length可以不写,写了的话,如果长度多了或者少了,无法显示内容

image-20220812162657427

wget +iconv

wget+地址即可拉取下来。支持http和https,我们这个demo只支持http

image-20220812163842876

image-20220812163922256

iconv可以将一个文件的字符集从一种格式转化为另外一种

image-20220812164045548

数据服务总线概念

给HTTP的数据访问接口起一个高大上的名字,数据访问总线就是这么来的。

image-20220812164353992

简单demo实现

所要实现的框架大体是这样的:

1
2
3
// 1、接收客户端的请求报文;
// 2、解析URL中的参数,参数中指定了查询数据的条件;
// 3、从T_ZHOBTMIND1表中查询数据,以xml格式返回给客户端。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 接受请求
CTcpServer TcpServer;

// 服务端初始化。
if (TcpServer.InitServer(atoi(argv[1]))==false)
{
printf("TcpServer.InitServer(%s) failed.\n",argv[1]); return -1;
}

// 等待客户端的连接请求。
if (TcpServer.Accept()==false)
{
printf("TcpServer.Accept() failed.\n"); return -1;
}

printf("客户端(%s)已连接。\n",TcpServer.GetIP());

char strget[102400];
memset(strget,0,sizeof(strget));

// 接收http客户端发送过来的报文。
recv(TcpServer.m_connfd,strget,1000,0);

printf("%s\n",strget);

// 先把响应报文头部发送给客户端。
char strsend[102400];
memset(strsend,0,sizeof(strsend));
sprintf(strsend,\
"HTTP/1.1 200 OK\r\n"\
"Server: demo28\r\n"\
"Content-Type: text/html;charset=utf-8\r\n"\
"\r\n");
// "Content-Length: 108909\r\n\r\n");
if (Writen(TcpServer.m_connfd,strsend,strlen(strsend))== false) return -1;

//logfile.Write("%s",strsend);

// 解析GET请求中的参数,从T_ZHOBTMIND1表中查询数据,返回给客户端。
SendData(TcpServer.m_connfd,strget);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// http://127.0.0.1:8080/api?username=wucz&passwd=wuczpwd&intetname=getZHOBTMIND1&obtid=51076
// 从GET请求中获取参数的值:strget-GET请求报文的内容;name-参数名;value-参数值;len-参数值的长度。
bool getvalue(const char *strget,const char *name,char *value,const int len)
{
value[0]=0;

char *start,*end;
start=end=0;

// strstr返回字符串name在strget中首次出现的地址。
start=strstr((char *)strget,(char *)name);
if (start==0) return false;

// end为一条参数值结束的位置。
end=strstr(start,"&");
// &和空格都是结束值,只不过空格是最后
if (end==0) end=strstr(start," ");

if (end==0) return false;

// 每一条查询条件的长度
int ilen=end-(start+strlen(name)+1);
if (ilen>len) ilen=len;

// 得到这条内容保存在value中
strncpy(value,start+strlen(name)+1,ilen);

value[ilen]=0;

return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 解析GET请求中的参数,从T_ZHOBTMIND1表中查询数据,返回给客户端。
bool SendData(const int sockfd,const char *strget)
{
// 解析URL中的参数。
// 权限控制:用户名和密码。
// 接口名:访问数据的种类。
// 查询条件:设计接口的时候决定。
// http://127.0.0.1:8080/api?wucz&wuczpwd&getZHOBTMIND1&51076&20211024094318&20211024114020
// http://127.0.0.1:8080/api?username=wucz&passwd=wuczpwd&intetname=getZHOBTMIND1&obtid=51076&begintime=20211024094318&endtime=20211024114020

char username[31],passwd[31],intername[30],obtid[11],begintime[21],endtime[21];
memset(username,0,sizeof(username));

// 类似于解析xml的函数,这个是解析get的
getvalue(strget,"username",username,30); // 获取用户名。
.............

printf("username=%s\n",username);
.............

// 判断用户名/密码和接口名是否合法。

// 连接数据库。
connection conn;
conn.connecttodb("scott/tiger@snorcl11g_132","Simplified Chinese_China.AL32UTF8");

// 准备查询数据的SQL。
sqlstatement stmt(&conn);
stmt.prepare("select '<obtid>'||obtid||'</obtid>'||'<ddatetime>'||to_char(ddatetime,'yyyy-mm-dd hh24:mi:ss')||'</ddatetime>'||'<t>'||t||'</t>'||'<p>'||p||'</p>'||'<u>'||u||'</u>'||'<keyid>'||keyid||'</keyid>'||'<endl/>' from T_ZHOBTMIND1 where obtid=:1 and ddatetime>to_date(:2,'yyyymmddhh24miss') and ddatetime<to_date(:3,'yyyymmddhh24miss')");
char strxml[1001]; // 存放SQL语句的结果集。
stmt.bindout(1,strxml,1000);
stmt.bindin(1,obtid,10);
stmt.bindin(2,begintime,14);
stmt.bindin(3,endtime,14);

stmt.execute(); // 执行查询数据的SQL。

Writen(sockfd,"<data>\n",strlen("<data>\n")); // 返回xml的头部标签。

while (true)
{
memset(strxml,0,sizeof(strxml));
if (stmt.next()!=0) break;

strcat(strxml,"\n"); // 注意加上换行符。
Writen(sockfd,strxml,strlen(strxml)); // 返回xml的每一行。
}

Writen(sockfd,"</data>\n",strlen("</data>\n")); // 返回xml的尾部标签。

return true;
}

url小提示

url中不会出现空格,在语句中出现的空格,会在对方端显示为%20

​ 一个URL的基本组成部分包括协议(scheme),域名,端口号,路径和查询字符串(路径参数和锚点标记就暂不考虑了)。路径和查询字符串之间用问号?分离。例如http://www.example.com/index?param=1,路径为index,查询字符串(Query String)为param=1。URL中关于空格的编码正是与空格所在位置相关:空格被编码成加号+的情况只会在查询字符串部分出现,而被编码成%20则可以出现在路径和查询字符串中。

功能需求

image-20220816195102664

表的设计

一些注意点,比如数据种类的定义那张表,外键指向自己,这种设计方式一般适用于某种具有层次关系的表

image-20220816201516752

有层次的ID,取值技巧:比如说第一种类,用01,02,03,04这些来表示,第二层次就分别先带上01的ID,再后面延续,并且排序

image-20220816201552704

每连接每线程实现

image-20220817170716956

image-20220819163128535

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void *thmain(void *arg)     // 线程主函数。
{
pthread_cleanup_push(thcleanup,arg); // 把线程清理函数入栈(关闭客户端的socket)。

int connfd=(int)(long)arg; // 客户端的socket。

pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS,NULL); // 线程取消方式为立即取消。

pthread_detach(pthread_self()); // 把线程分离出去。

char strrecvbuf[1024]; // 接收客户端请求报文的buffer。
memset(strrecvbuf,0,sizeof(strrecvbuf));

// 读取客户端的报文,如果超时或失败,线程退出。
// 不能用原生的recv,因为服务端不可能一直等待(recv没有超时机制),如果一直等,恶意的客户端建立连接后什么也不干,会耗光服务器资源。
// ReadT用了IO复用技术实现接受,下一章节会讲。
// 正常情况下连接成功,客户端马上发送请求报文。所以设置2s或者3s延时
// ReadT(const int sockfd, char *buffer, const int size, const int itimeout)
if (ReadT(connfd,strrecvbuf,sizeof(strrecvbuf),3)<=0) pthread_exit(0);

// 如果不是GET请求报文不处理,线程退出。
if (strncmp(strrecvbuf,"GET",3)!=0) pthread_exit(0);

logfile.Write("%s\n",strrecvbuf);

// 连接数据库。
connection conn;

if (conn.connecttodb(starg.connstr,starg.charset)!=0)
{
logfile.Write("connect database(%s) failed.\n%s\n",starg.connstr,conn.m_cda.message); pthread_exit(0);
}

// 判断URL中用户名和密码,如果不正确,返回认证失败的响应报文,线程退出。
if (Login(&conn,strrecvbuf,connfd)==false) pthread_exit(0);

// 判断用户是否有调用接口的权限,如果没有,返回没有权限的响应报文,线程退出。
// CheckPerm的提示信息和查询语句和Login有点不同,别的都是一样的。
if (CheckPerm(&conn,strrecvbuf,connfd)==false) pthread_exit(0);

// 先把响应报文头部发送给客户端。
char strsendbuf[1024];
memset(strsendbuf,0,sizeof(strsendbuf));
sprintf(strsendbuf,\
"HTTP/1.1 200 OK\r\n"\
"Server: webserver\r\n"\
"Content-Type: text/html;charset=utf-8\r\n\r\n");
Writen(connfd,strsendbuf,strlen(strsendbuf));

// 再执行接口的sql语句,把数据返回给客户端。
if (ExecSQL(&conn,strrecvbuf,connfd)==false) pthread_exit(0);

pthread_cleanup_pop(1); // 把线程清理函数出栈。
}

Login()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 判断URL中用户名和密码,如果不正确,返回认证失败的响应报文。
bool Login(connection *conn,const char *buffer,const int sockfd)
{
char username[31],passwd[31];

getvalue(buffer,"username",username,30); // 获取用户名。
getvalue(buffer,"passwd",passwd,30); // 获取密码。

// 查询T_USERINFO表,判断用户名和密码是否存在。
sqlstatement stmt;
stmt.connect(conn);
stmt.prepare("select count(*) from T_USERINFO where username=:1 and passwd=:2 and rsts=1");
stmt.bindin(1,username,30);
stmt.bindin(2,passwd,30);
int icount=0;
stmt.bindout(1,&icount);
stmt.execute();
stmt.next();

if (icount==0) // 认证失败,返回认证失败的响应报文。
{
char strbuffer[256];
memset(strbuffer,0,sizeof(strbuffer));

// 客户端调用这个动作不管成功开始失败,响应都用200,也就是说调用这个动作是成功的,有其他问题在报文内容体现
// 重点想说的就是不要用HTTP协议的返回码去表达失败的情况
sprintf(strbuffer,\
"HTTP/1.1 200 OK\r\n"\
"Server: webserver\r\n"\
"Content-Type: text/html;charset=utf-8\r\n\r\n"\
"<retcode>-1</retcode><message>username or passwd is invailed</message>");
Writen(sockfd,strbuffer,strlen(strbuffer));

return false;
}

CheckPerm()

真的就这点点不同=-=,和Login()

1
2
3
4
5
6
stmt.prepare("select count(*) from T_USERANDINTER where username=:1 and intername=:2 and intername in (select intername from T_INTERCFG where rsts=1)");

sprintf(strbuffer,\
.......
"<retcode>-1</retcode><message>permission denied</message>");

ExecSQL()

image-20220819165553078

image-20220819165625139

我们一段一段描述:

  1. getvalue解析接口名
  2. 申明几个变量,用sqlstatement stmt对象将参数拿出来(接口SQL,输出列名,接口参数)
  3. prepare()准备SQL语句
  4. 绑定输入输出变量。
  5. execute()执行,使得SQL语句good,得到应该得到的东西
  6. 再次用getvalue,将输从url中获取的参数绑定到对应的变量中
  7. 发送标签是用Writen()函数

最关键的那一部分是这样做到的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 获取结果集,每获取一条记录,拼接xml报文,发送给客户端。
//////////////////////////////////////////////////
char strtemp[2001]; // 用于拼接xml的临时变量。

// 逐行获取结果集,发送给客户端。
while (true)
{
memset(strsendbuffer,0,sizeof(strsendbuffer));
memset(colvalue,0,sizeof(colvalue));

if (stmt.next() != 0) break; // 从结果集中取一条记录。

// 拼接每个字段的xml。
for (int ii=0;ii<CmdStr.CmdCount();ii++)
{
memset(strtemp,0,sizeof(strtemp));
snprintf(strtemp,2000,"<%s>%s</%s>",CmdStr.m_vCmdStr[ii].c_str(),colvalue[ii],CmdStr.m_vCmdStr[ii].c_str());
strcat(strsendbuffer,strtemp);
}

strcat(strsendbuffer,"<endl/>\n"); // xml每行结束的标志。

Writen(sockfd,strsendbuffer,strlen(strsendbuffer)); // 向客户端返回这行数据。
}

缺点

image-20220819171203300

优化方案

image-20220819171512530

数据库连接池

image-20220819171537795

数据库连接池和公共卫生局的原理和算法是一样的。

image-20220819171618290

声明定义

​ 有两个注意点需要重视,数据库连接池的数组有多大,就需要声明多少把锁,而不是共用一把锁,只能用互斥锁,不能用自旋锁,因为如果数据量比较大,数据库连接占用的时间可能比较长,自旋锁不断刷新,不合适。

1
2
3
4
5
6
7
#define MAXCONNS 10               // 数据库连接池的大小。 
connection conns[MAXCONNS]; // 数据库连接池的数组。
pthread_mutex_t mutex[MAXCONNS]; // 数据库连接池的锁。
bool initconns(); // 初始数据库连接池。
connection *getconns(); // 从数据库连接池中获取一个空闲的连接。函数的返回值是空闲的链接的地址,并且这个函数会对已经取走的函数加上锁。
bool freeconns(connection *conn); // 释放/归还数据库连接。
bool destroyconns(); // 释放数据库连接占用的资源(断开数据库连接,销毁锁)。

initconns()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool initconns()                 // 初始数据库连接池。
{
for (int ii=0;ii<MAXCONNS;ii++)
{
// 连接数据库
if (conns[ii].connecttodb(starg.connstr,starg.charset) != 0)
{
logfile.Write("connect database(%s) failed.\n%s\n",starg.connstr,conns[ii].m_cda.message);
return false;
}

// 初始化互斥锁。
pthread_mutex_init(&mutex[ii],0);
}

return true;
}

getconns()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
connection *getconns()           // 从数据库连接池中获取一个空闲的连接。
{
// 这个代码的思路就是不断遍历所有的空间,尝试逐个加锁,如果加锁成功,则把这个空间返回出去,不然就下次一定。
// 用while循环是一个很笨的办法,更好的办法是connpool::get()有所体现
while (true)
{
for (int ii=0;ii<MAXCONNS;ii++)
{
if (pthread_mutex_trylock(&mutex[ii])==0)
{
logfile.Write("get conns is %d.\n",ii);
return &conns[ii];
}
}
// 全部都失败了,就很短的时候后再次尝试
usleep(10000); // 百分之一秒之后再重试。
}
}

freeconns(connection *conn)

1
2
3
4
5
6
7
8
9
10
11
12
bool freeconns(connection *conn) // 释放/归还数据库连接。
{
for (int ii=0;ii<MAXCONNS;ii++)
{
if (&conns[ii]==conn)
{
pthread_mutex_unlock(&mutex[ii]); return true;
}
}

return false;
}

destroyconns()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool destroyconns()        // 释放数据库连接占用的资源(断开数据库连接,销毁锁)。

{

for (int ii=0;ii<MAXCONNS;ii++)

{

conns[ii].disconnect();

pthread_mutex_destroy(&mutex[ii]);

}



return true;

}

连接池性能

image-20220819211047757

优化

最开始我们是人为的设定线程池的大小,现在我们需要对他进行优化,不然太生硬了

image-20220820095754504

因此定义一个专门的线程池类,方便操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 数据库连接池类。
class connpool
{
private:
struct st_conn
{
connection conn; // 数据库连接。
pthread_mutex_t mutex; // 用于数据库连接的互斥锁。
time_t atime; // 数据库连接上次使用的时间,如果未连接数据库则取值0。
}*m_conns; // 数据库连接池。

int m_maxconns; // 数据库连接池的最大值。
int m_timeout; // 数据库连接超时时间,单位:秒。
char m_connstr[101]; // 数据库连接参数:用户名/密码@连接名
char m_charset[101]; // 数据库的字符集。
public:
connpool(); // 构造函数。
~connpool(); // 析构函数。

// 初始化数据库连接池,初始化锁,如果数据库连接参数有问题,返回false。
bool init(const char *connstr,const char *charset,int maxconns,int timeout);
// 断开数据库连接,销毁锁,释放数据库连接池的内存空间。
void destroy();

// 从数据库连接池中获取一个空闲的连接,成功返回数据库连接的地址。
// 如果连接池已用完或连接数据库失败,返回空。
connection *get();
// 归还数据库连接。
bool free(connection *conn);

// 检查数据库连接池,断开空闲的连接,在服务程序中,用一个专用的子线程调用此函数。
void checkpool();
};
connpool::get()

其实就是前面getconns()demo 的一个更完善的实现。

​ 这串代码先用trylock找出了第一个空闲的位置,如果存在这样一个位置,则将他赋值给pos,并后续将其连接上,如果未找到,则将这次的锁给释放掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 1)从数据库连接池中寻找一个空闲的、已连接好的connection,如果找到了,返回它的地址。
// 2)如果没有找到,在连接池中找一个未连接数据库的connection,连接数据库,如果成功,返回connection的地址。
// 3)如果第2)步找到了未连接数据库的connection,但是,连接数据库失败,返回空。
// 4)如果第2)步没有找到未连接数据库的connection,表示数据库连接池已用完,也返回空。
connection *connpool::get()
{
int pos=-1; // 用于记录第一个未连接数据库的数组位置。

for (int ii=0;ii<m_maxconns;ii++)
{
if (pthread_mutex_trylock(&m_conns[ii].mutex)==0) // 尝试加锁。
{
if (m_conns[ii].atime>0) // 如果数据库连接是已连接的状态。
{
printf("取到连接%d。\n",ii);
m_conns[ii].atime=time(0); // 把数据库连接的使用时间设置为当前时间。
return &m_conns[ii].conn; // 返回数据库连接的地址。
}

if (pos==-1) pos=ii; // 记录第一个未连接数据库的数组位置。
else pthread_mutex_unlock(&m_conns[ii].mutex); // 释放锁。
}
}

if (pos==-1) // 如果连接池已用完,返回空。
{
printf("连接池已用完。\n"); return NULL;
}

// 连接池没有用完,让m_conns[pos].conn连上数据库。
printf("新连接%d。\n",pos);

// 连接数据库。
if (m_conns[pos].conn.connecttodb(m_connstr,m_charset)!=0)
{
// 如果连接数据库失败,释放锁,返回空。
printf("连接数据库失败。\n");
pthread_mutex_unlock(&m_conns[pos].mutex); // 释放锁。
return NULL;
}

m_conns[pos].atime=time(0); // 把数据库连接的使用时间设置为当前时间。

return &m_conns[pos].conn;
}

使用连接池类后的线程主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void *thmain(void *arg)     // 线程主函数。
{
pthread_cleanup_push(thcleanup,arg); // 把线程清理函数入栈(关闭客户端的socket)。

int connfd=(int)(long)arg; // 客户端的socket。

pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS,NULL); // 线程取消方式为立即取消。

pthread_detach(pthread_self()); // 把线程分离出去。

char strrecvbuf[1024]; // 接收客户端请求报文的buffer。
memset(strrecvbuf,0,sizeof(strrecvbuf));

// 读取客户端的报文,如果超时或失败,线程退出。
if (ReadT(connfd,strrecvbuf,sizeof(strrecvbuf),3)<=0) pthread_exit(0);

// 如果不是GET请求报文不处理,线程退出。
if (strncmp(strrecvbuf,"GET",3)!=0) pthread_exit(0);

logfile.Write("%s\n",strrecvbuf);

connection *conn=oraconnpool.get(); // 获取一个数据库连接。

// 如果数据库连接为空,向客户端返回内部错误,线程退出。
char strsendbuf[1024];
if (conn==0)
{
// 在我们这个程序,线程取数据失败,就会很快退出(在实际开发中线程不会这么容易退出的),以至于还没有将线程清理函数放入容器内,这就导致出现了段错误,存在无法清理干净的进程。
usleep(100000); // 防止线程太快退出。
memset(strsendbuf,0,sizeof(strsendbuf));
sprintf(strsendbuf,\
"HTTP/1.1 200 OK\r\n"\
"Server: webserver\r\n"\
"Content-Type: text/html;charset=utf-8\r\n\r\n"\
"<retcode>-1</retcode><message>internal error.</message>");
Writen(connfd,strsendbuf,strlen(strsendbuf));
pthread_exit(0);
}

// 判断URL中用户名和密码,如果不正确,返回认证失败的响应报文,线程退出。
if (Login(conn,strrecvbuf,connfd)==false) { oraconnpool.free(conn); pthread_exit(0); }

// 判断用户是否有调用接口的权限,如果没有,返回没有权限的响应报文,线程退出。
if (CheckPerm(conn,strrecvbuf,connfd)==false) { oraconnpool.free(conn); pthread_exit(0); }

// 先把响应报文头部发送给客户端。
memset(strsendbuf,0,sizeof(strsendbuf));
sprintf(strsendbuf,\
"HTTP/1.1 200 OK\r\n"\
"Server: webserver\r\n"\
"Content-Type: text/html;charset=utf-8\r\n\r\n");
Writen(connfd,strsendbuf,strlen(strsendbuf));

// 再执行接口的sql语句,把数据返回给客户端。
if (ExecSQL(conn,strrecvbuf,connfd)==false) { oraconnpool.free(conn); pthread_exit(0); }

oraconnpool.free(conn);;

pthread_cleanup_pop(1); // 把线程清理函数出栈。
}

image-20220820110549305

容器中还有两个进程未退出,具体解决方法看上面代码块的usleep注释image-20220820110615100

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void thcleanup(void *arg)     // 线程清理函数。
{
close((int)(long)arg); // 关闭客户端的socket。

// 把本线程id从存放线程id的容器中删除。
// 注意,特别注意,如果线程跑得太快,主程序可能还不及把线程的id放入容器。
// 所以,这里可能会出现找不到线程id的情况。
pthread_spin_lock(&vthidlock);
for (int ii=0;ii<vthid.size();ii++)
{
if (pthread_equal(pthread_self(),vthid[ii])) { vthid.erase(vthid.begin()+ii); break; }
}
pthread_spin_unlock(&vthidlock);

logfile.Write("线程%lu退出。\n",pthread_self());
}

线程池

和连接池的思路不一样,并且同样情况下性能比连接池提升了四五倍

生产消费者模型条件变量+互斥锁,有客户端连接,就是生产了一个产品,再在thmain中处理这个任务,类似于消费这个产品。工作主函数就是消费者模型。

image-20220820111307347

image-20220820111334410

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 初始化数据库连接池。
if (oraconnpool.init(starg.connstr,starg.charset,10,50)==false)
{
logfile.Write("oraconnpool.init() failed.\n"); return -1;
}
else
{
// 注意看就会发现,这里如果工作进程满了,就会进入checkpool
// 创建检查数据库连接池的线程。
pthread_t thid;
if (pthread_create(&thid,NULL,checkpool,0)!=0)
{
logfile.Write("pthread_create() failed.\n"); return -1;
}
}

// 启动10个工作线程,线程数比CPU核数略多。
// 如果数量多太多,线程之间的切换也会浪费时间,相等或者少,CPU资源无法充分利用
// 通常一核同一时间处理一个线程。
for (int ii=0;ii<10;ii++)
{
pthread_t thid;
if (pthread_create(&thid,NULL,thmain,(void *)(long)ii)!=0)
{
logfile.Write("pthread_create() failed.\n"); return -1;
}

vthid.push_back(thid); // 把线程id保存到vthid容器中。
}

pthread_spin_init(&spin,0); // 初始化给vthid加锁的自旋锁。

while (true)
{
// 等待客户端的连接请求。
if (TcpServer.Accept()==false)
{
logfile.Write("TcpServer.Accept() failed.\n"); return -1;
}

logfile.Write("客户端(%s)已连接。\n",TcpServer.GetIP());

// 把客户端的socket放入队列,并发送条件信号。
pthread_mutex_lock(&mutex); // 加锁。
sockqueue.push_back(TcpServer.m_connfd); // 入队。
pthread_mutex_unlock(&mutex); // 解锁。
pthread_cond_signal(&cond); // 触发条件,激活一个线程。
}

线程池的监控

image-20220820143341329

在整个程序中,需要等待的地方只有wait哪里,也就是我们工作进程的心跳信息更新地点

1
2
3
4
5
6
    // 如果缓存队列为空,等待,用while防止条件变量虚假唤醒。
while (sockqueue.size()==0)
{
pthread_cond_wait(&cond,&mutex);
}
------------------------->
1
2
3
4
5
6
7
8
9
10
11
12
13
14
while (true)
{
pthread_mutex_lock(&mutex); // 给缓存队列加锁。

// 如果缓存队列为空,等待,用while防止条件变量虚假唤醒。
while (sockqueue.size()==0)
{
struct timeval now;
gettimeofday(&now,NULL); // 获取当前时间。
now.tv_sec=now.tv_sec+20; // 取20秒之后的时间。
// 下面这个函数可以百度下什么意思,大抵就是说不管有没有等待到,过了20s,都会超时返回。
pthread_cond_timedwait(&cond,&mutex,(struct timespec*)&now); // 等待条件被触发。
vthid[pthnum].atime=time(0); // 更新当前线程的活动时间。
}
监控线程

并放置主函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void *checkthmain(void *arg)    // 监控线程主函数。
{
while (true)
{
// 遍历工作线程结构体的容器,检查每个工作线程是否超时。
for (int ii=0;ii<vthid.size();ii++)
{
// 工作线程超时间为20秒,这里用25秒判断超时,足够。
if ((time(0)-vthid[ii].atime)>25)
{
// 已超时。
logfile.Write("thread %d(%lu) timeout(%d).\n",ii,vthid[ii].pthid,time(0)-vthid[ii].atime);

// 取消已超时的工作线程。
pthread_cancel(vthid[ii].pthid);

// 重新创建工作线程。
if (pthread_create(&vthid[ii].pthid,NULL,thmain,(void *)(long)ii)!=0)
{
logfile.Write("pthread_create() failed.\n"); EXIT(-1);
}

vthid[ii].atime=time(0); // 设置工作线程的活动时间。
}
}

sleep(3);
}
}

数据安全策略

image-20220820145217366

通常由硬件解决,软件解决反而麻烦很多,通常不需要我们考虑。

image-20220820145306843

我们能做些什么?

软件意义的登录,和唯一识别。

image-20220820145415334

黑名单和白名单逻辑上是冲突的,只能二选一,不能同时要

前面两种是系统层面的,不针对具体用户,第三种是针对具体用户的。

image-20220820145513223

思路:

不管采用那种,都需要修改这个数据结构:image-20220820145607268

可以选择一个结构体,除了采用客户端的socket,也要采用客户端的ip地址image-20220820145635450

如果采用绑定ip的方法,修改Login代码就可以了。

拿出IP地址,与客户端连接上的对比即可。

image-20220820145737776

image-20220820145757049

image-20220820145834253

​ 为何要将黑名单和白名单放在容器中呢?,原因很简单,这种名单一般是量很少,每当有用户登录,查找内存要比查找数据库快得多 。对于数据库操作,要有一个原则,能够操作数据库,就不操作数据库,这会很快。

​ 并且,判断是否为黑名单白名单的代码也要放在工作线程中,不要直接在socket刚刚连接加锁就解决这个问题,原子操作尽量保证内容少。

学习总结

image-20220820150201918

image-20220820150231645

image-20220820150256028

让每个数据库的数据都是一样的,反正MySQL不要钱,不过使用过程中有一个细节,需要均匀分摊,第一个用户连了A库,那第二个就连B,总之要将用户量平摊,使得每个数据库分配的量均匀。

image-20220820150311463

image-20220820150454410

其实本质都是一样的,为了传输,只是各自的约定不同,带来的效率,安全性,稳定性等因素也会不同。

image-20220820150617268

十四、IO复用&网络代理

image-20220820150806264

三种模型

image-20220820150854360

正向代理&反向代理

image-20220820150945915

select模型

先来了解了解TCP缓冲区

(18条消息) tcp缓冲区_jigetage的博客-CSDN博客_tcp缓冲区

什么是tcp缓冲区?每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。

二、缓冲区的意义
write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,比如nagle算法,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。

如果TCP连接断开,网络缓冲区还有为发出去的数据,下次连接后会继续发送么,还是清空缓冲区?

​ 接收端tcp断开,分为两种情况:1. 调用close主动断开,执行tcp三次挥手,正常结束。2. 接收端断网或者宕机,此时发送端是无法知道接收端已经断开,会一直往那个已经断开的socket缓冲区发数据,知道缓冲区满就会阻塞;如果接收端重新连接上来的话,此时不会继续发送数据,因为重连的socket已经变了,缓冲区会进行了重建;老的已经断开的socket需要发送端自己处理,即调用 clientSocket.close() 清理掉废弃的socket回收相应资源

image-20220820152440757

​ 这是select的帮助文档,readfds是需要监视可读参数的集合,writefds反之,timeout是超时时间。第一个nfds下面讲

fd_set我们可能看不懂是啥,可以去看看他的声明。

image-20220820152628670

​ 把它替换一下。

image-20220820152643130

image-20220820152717623

​ 这张图的意思是,假设现在有一个socket的集合,他们的socketID分别是3,4,5,6,就把位图中对应位置置为1就可以了,现在有一个新的socket连接上来,它的ID取值是9,那么就把第9个位置置为1即可!

​ 如果有连接退出,将其置为0即可。

​ Linux提供的四个宏,用于操作位图,

image-20220820153026403

CLR:将某一个socket置为空,ISSET:判断某一个socket在集合中,SET:将某一个socket置为1,ZERO:将全局置为空。

现在我们可以了解nfds的意义了,它是指readfds,writefds,exceptfds三个位图中,最大的那个,也就是整个图有多大,exceptfds填NULL就行了,后面发展的模型都没用他了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 初始化服务端用于监听的socket。
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d\n",listensock);

if (listensock < 0) { printf("initserver() failed.\n"); return -1; }

fd_set readfds; // 读事件socket的集合,包括监听socket和客户端连接上来的socket。
FD_ZERO(&readfds); // 初始化读事件socket的集合。
FD_SET(listensock,&readfds); // 把listensock添加到读事件socket的集合中。

int maxfd=listensock; // 记录集合中socket的最大值。

while (true)
{
// 事件:1)新客户端的连接请求accept;2)客户端有报文到达recv,可以读;3)客户端连接已断开;
// 4)可以向客户端发送报文send,可以写。可写事件可以简单理解为缓冲区没满,就可以写
// 可读事件 可写事件
// select() 等待事件的发生(监视哪些socket发生了事件)。

fd_set tmpfds=readfds;
// 不能把readfds直接给他,我们后面要处理,需要保留,因此用副本
struct timeval timeout; timeout.tv_sec=10; timeout.tv_usec=0;
int infds=select(maxfd+1,&tmpfds,NULL,NULL,&timeout);

// 返回失败。
if (infds < 0)
{
// ERRORS
// EBADF An invalid file descriptor was given in one of the sets. (Perhaps a file descriptor that was already closed, or one on
// which an error has occurred.)

// EINTR A signal was caught; see signal(7).

// EINVAL nfds is negative or the value contained within timeout is invalid.

// ENOMEM unable to allocate memory for internal tables.
perror("select() failed"); break;
}

// 超时,在本程序中,select函数最后一个参数为空,不存在超时的情况,但以下代码还是留着。
if (infds == 0)
{
printf("select() timeout.\n"); continue;
}

// 如果infds>0,表示有事件发生的socket的数量。
// ISSET检查位图每一位。
for (int eventfd=0;eventfd<=maxfd;eventfd++)
{
if (FD_ISSET(eventfd,&tmpfds)<=0) continue; // 如果没有事件,continue

// 如果发生事件的是listensock,表示有新的客户端连上来。
if (eventfd==listensock)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
if (clientsock < 0) { perror("accept() failed"); continue; }

printf ("accept client(socket=%d) ok.\n",clientsock);

// 把新客户端的socket加入可读socket的集合。
FD_SET(clientsock,&readfds);
if (maxfd<clientsock) maxfd=clientsock; // 更新maxfd的值。
}
else
{
// 如果是客户端连接的socket有事件,表示有报文发过来或者连接已断开。

char buffer[1024]; // 存放从客户端读取的数据。
memset(buffer,0,sizeof(buffer));
if (recv(eventfd,buffer,sizeof(buffer),0)<=0)
{
// 如果客户端的连接已断开。
printf("client(eventfd=%d) disconnected.\n",eventfd);
close(eventfd); // 关闭客户端的socket
FD_CLR(eventfd,&readfds); // 把已关闭客户端的socket从可读socket的集合中删除。

// 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。
if (eventfd == maxfd)
{
for (int ii=maxfd;ii>0;ii--) // 从后面往前找。
// 相比于从前往后找,这是一个聪明的办法。
{
if (FD_ISSET(ii,&readfds))
{
maxfd = ii; break;
}
}
}
}
else
{
// 如果客户端有报文发过来。
printf("recv(eventfd=%d):%s\n",eventfd,buffer);
// 把接收到的报文内容原封不动的发回去。
fd_set tmpfds;
FD_ZERO(&tmpfds);
FD_SET(eventfd,&tmpfds);
if (select(eventfd+1,NULL,&tmpfds,NULL,NULL)<=0)
perror("select() failed");
else
send(eventfd,buffer,strlen(buffer),0);
}
}
}
}

由于时间关系,剩下的select和poll,后面再聊了

epoll模型

image-20220820160705048

​ 创造句柄,类似于select的bit map,注册事件,意思是告诉socket需要监视那些事件,接下来调用wait等待事件的发生。

十五、课程总结

非结构化数据存储方案

​ 总的来说,没有最好的方案,只有最合适的方案。

https://blog.csdn.net/PIPJIN961111/article/details/102664666

结构化与半结构化与非结构化数据
1.结构化数据
结构化的数据是指可以使用关系型数据库表示存储,表现为二维形式的数据。一般特点是:数据以行为单位,一行数据表示一个实体的信息,每一行数据的属性是相同的。例子:

1
2
3
4
id      name    age     gender
1 lyh 12 male
2 liangyh 13 female
3 liang 18 male

所以,结构化的数据的存储和排列是很有规律的,这对查询和修改等操作很有帮助

​ 但是它的扩展性不好,比如需要的时候加个字段,在实际运用中每次都进行反复的表结构变更,这容易导致后台接口从数据库取数据出错。

2.半结构化数据

​ 半结构是有点勉强的说法,可以当做结构化,也可以当做非结构化,这里我们当做非结构化。

​ 半结构化数据是结构化数据的一种形式,它并不符合关系型数据库或其他数据表的形式关联起来的数据模型结构,但包含相关标记,用来分隔语义元素以及对记录和字段进行分层。因此,它也被称为自描述的结构。

​ 半结构化数据,属于同一类实体可以有不同的属性,即使他们被组合在一起,这些属性的顺序并不重要。

常见的半结构数据有XML和JSON,对于XML文件,例如

1
2
3
4
5
<person>
<name>A</name>
<age>13</age>
<gender>female</gender>
</person>

​ 从上面的例子中,属性的顺序是不重要的,不同的半结构化数据的属性的个数是不一定一样的。

​ 标签是树的根节点,和`标签是子节点。通过这样的数据格式,可以自由地表达很多有用的信息,包括自我描述信息(元数据)。所以,半结构化数据的扩展性是很好的。

3.非结构化数据

image-20220820165934876

​ 非结构化数据是数据结构不规则或不完整,没有预定义的数据模型,不方便用数据库二维逻辑表来表现的数据。包括所有格式的办公文档、文本、图片、各类报表、图像和音频/视频信息等等。

hbase数据库是一个NoSql(Not Only SQL,泛指非关系型数据库)。

Hbase是一个分布式的、面向列,运行在HDFS上的数据库

适合存储访问超大规模的数据集,可以提供数据的实时随机读写

*方案一

*意味着用的多

方便简洁,我们还有文件同步程序,快速实现同步功能。

image-20220820170240310

image-20220820170317243

*方案二

如果非结构化数据很少,也可以用啊

image-20220820170505565

数据量大其实也可以用,具体要采用BOLB缓存技术

BLOB缓存

image-20220820171255630

image-20220820171342790

方案三

如果软件有价值,这种方案肯定最合适

image-20220820170536728

*方案四

image-20220820170605869

方案五

至少90%的企业和政府是没有大数据的,用不上这个东西

总的来说HDFS是个好东西(分布式文件系统),适用于大数据,普通的小项目没必要用。

image-20220820170634861

image-20220820170708653

方案六

哪些所谓的云存储,都是建立在别人的基础上搭建的虚拟平台而已,没什么好特别的。

image-20220820170836000

数据中心辅助模块

通常没什么人用,就自己用。

image-20220820185252645

image-20220820190047900

一个朴素的页面,拓展知识就好。

实时同步

早期采用。

image-20220820190137280

服务器资源信息获取

image-20220820190315492

数据表的设计技巧

J:\11Projectc++\课程文档(1)\oracle数据库\37.索引的本质与SQL优化.docx

image-20220820190552276

image-20220820191211366

image-20220820190803801

​ 例如我们在这里产生了外键,其实可以采取移除外键,并用更多相同的变量名来定位,外键保证了数据的完整性,但是也增加了数据库的开销,另外从观测数据表中读取数据的时候往往也需要全国站点参数中把站点名称,经纬度,海拔高度取出来。可以使用关联查询,但是这种方式也肯定比单表查询要慢。

image-20220820191115041

​ 可以设计成这样,这样会浪费磁盘空间,但现在的社会磁盘空间已经不值钱了。另外也不能保证数据的完整性,但是只要应用程序不乱搞通常也无大碍。

image-20220820200314523

最好delete都不要,只insert

解决问题的办法就是,将更新一个表的操作,转换为insert一个表的操作(多个用户操作一个表,必然update存在竞争)

image-20220820200522656

当遇到七天过后还没签收之类的客户,肯定不能用常规的操作处理了

image-20220820200841356

我们可以考虑把它的信息做成归档表,其中,物流信息用xml或者json等格式来存放就好

image-20220820200759255

image-20220820200938523

触发器、自定义函数和存储过程

数据库是项目的瓶颈,能不让他去做,就不让他去做。另外它的编程语言也比较菜=-=

image-20220820201033569

image-20220820201651735

触发器也是以前用的多,只要框架里有的,都可以不用,下面这就是用于兼容问题的代码。image-20220820201804335

数据的缓存方案

数据为什么要缓存?

还是因为数据库太慢,Redis和Memcache做缓存已经不是秘密。

image-20220820201934810

image-20220820202030801

传统数据库稳,但是不快。

​ 但是有的时候Redis和Memcache也不能很好得装下大量的数据,这时候,文件缓存不失为一种很好的解决办法。

​ 第一级是年月(记录存放一个月)第二级是每个城市的号码段,第三极目录是号码的数字,每个txt存放了这一个月的详单文件。详单内容按时间排序,再写一个TCP程序提供详单查询,采用HTTP协议,就像数据服务总线一样。

image-20220820202221901

image-20220820202500235

image-20220820202546243

image-20220820202557782

项目经验

求职面试过程

一些技巧

image-20220820202845478

image-20220820202901263

image-20220820202935401

项目介绍实例

image-20220820203011421

image-20220820203030339

image-20220820203102902

image-20220820203128068

获取项目背景资源

我们如何打造这样一个介绍呢?

从这些地方找

image-20220820203209070

百度

这些是能在网上找到的项目截图。

也要记得去门户网站和官网看

image-20220820203250553

image-20220820203315671

image-20220820203331465

image-20220820203347923

image-20220820203402680

image-20220820203453795

招标平台

搜索关键字::

image-20220820203928254

image-20220820203648358

image-20220820203809767

数据开放平台

image-20220820204122929

另外,这个项目和我们课程中的项目实在是太像了,课程开发的不需要任何修改,都能满足80%以上的需求。

image-20220820204330999

其他细节

后台,三四个,前端,三四个啥的,打错了没关系,但不能吞吞吐吐。

image-20220820204622543

优化工作就是指:比如以前对每一种程序编写一个入库程序,后来,我采用读取数据字典,采用XML这种方法,做成了通用的功能。

另外,负责通用模块开发,对具体业务不太熟,可以推掉很多问题,不知道有多少种数据,有多少数据量都可以理解,但是技术的细节,一定要清楚。

image-20220820204707801

如果还不够,我们还可以新增一个APP项目

image-20220820204944682

image-20220820204957663

​ 我们把APP下载下来,每个功能都用一下,非常简单。APP软件功能一般都非常简单,采用HTTP或者自定义TCP协议都可以,

image-20220820205053745

另外想好面试官可能的提问,例如:

image-20220820205114772

image-20220820205140249

课程总结

image-20220820205246923

image-20220820205404838

image-20220820205517051