问题背景

公司有一套消息推送系统(简称GCM),由于人事变动接手了其中的客户端部分。看了一下文档,仅通讯协议部分有几页简单的说明,代码呢又多又乱,一时理不出一个头绪。由于消息是从后台推送到端的,所以使用了 tcp 长连接通道来保证消息的及时性,基于 http 的一堆分析工具(如 postman)完全没有用武之地,因此决定写个小工具来模拟 tcp 上的通讯协议,作为深入熟悉代码之前的热身。

问题的解决

一开始想用 c++ 来写这个工具,但是想到 socket 一连串经典的(socket / bind / connect / send / recv…)的繁琐调用我还是算了,之前用 shell 写过几个小工具很舒爽,但那都是借用 curl 命令来处理 http 协议,面对 tcp 协议 curl 肯定是无能为力了,因为命令执行完成后连接也就断开了,无法模拟长连接。那是不是就不能用 shell 写了呢?非也。

连接的建立与断开

我突然想到 shell 本身好像可以支持将 tcp 连接打开为文件:

exec N <> /dev/tcp/host/port

上面这段脚本就可以在句柄为 N 的文件上打开到 host 且端口为 port 的 tcp 连接了,并且可以进行双向读写。于是赶快在 msys2 中试了一下:

1 exec 3<>/dev/tcp/$gcm_host/$gcm_port
2 ret=$?
3 echo "open tcp $ret"
4 if [ $ret != 0 ]; then
5 echo "connect to gcmserver failed"
6 exit 1
7 fi
8
9 echo "connect with server"

这里脚本直接使用标准输入(0)、输出(1)、错误(2)之后的句柄 3 作为连接句柄,跑了一下,似乎什么也没有发生:

好在 Windows 上有 procexp 工具,可以查看进程创建的所有  tcp 连接:

看起来这个连接确实建立成功了。当然你也可以用 windows 上的 netstat 命令查看:

C:\Users\yunh>netstat -no

活动连接

  协议  本地地址          外部地址        状态           PID
TCP 10.2.56.38:1993 10.100.200.2:10003 ESTABLISHED 10320
TCP 10.2.56.38:2346 175.27.0.15:80 ESTABLISHED 14808
TCP 10.2.56.38:2474 121.51.139.161:8080 ESTABLISHED 15092
TCP 10.2.56.38:3147 10.2.56.13:7680 ESTABLISHED 8816
TCP 10.2.56.38:3576 47.97.243.182:80 ESTABLISHED 11292
TCP 10.2.56.38:3602 10.0.24.13:28888 ESTABLISHED 16224
TCP 10.2.56.38:3720 113.96.233.143:443 ESTABLISHED 15252
TCP 10.2.56.38:5006 10.2.61.20:7680 ESTABLISHED 8816
TCP 10.2.56.38:5022 10.2.25.16:7680 ESTABLISHED 8816
TCP 10.2.56.38:5303 49.232.126.211:443 ESTABLISHED 11292
TCP 10.2.56.38:6182 10.0.109.249:443 ESTABLISHED 16168
TCP 10.2.56.38:6183 10.0.109.249:443 ESTABLISHED 16168
TCP 10.2.56.38:6357 52.11.109.209:443 ESTABLISHED 11292
TCP 10.2.56.38:6697 40.90.189.152:443 ESTABLISHED 5268
TCP 10.2.56.38:7065 117.18.237.29:80 CLOSE_WAIT 4724
TCP 10.2.56.38:7100 220.170.53.122:443 TIME_WAIT 0
TCP 10.2.56.38:7113 220.181.174.166:443 TIME_WAIT 0
TCP 10.2.56.38:7117 180.163.150.166:443 ESTABLISHED 11292
TCP 10.2.56.38:7135 140.143.52.226:443 TIME_WAIT 0
TCP 10.2.56.38:7141 10.0.24.13:8888 CLOSE_WAIT 16224
TCP 10.2.56.38:7143 101.201.169.146:443 TIME_WAIT 0
TCP 10.2.56.38:7144 103.15.99.107:443 TIME_WAIT 0
TCP 10.2.56.38:7148 203.119.214.115:443 TIME_WAIT 0
TCP 10.2.56.38:7149 61.151.167.89:443 TIME_WAIT 0
TCP 10.2.56.38:7150 203.119.169.141:443 TIME_WAIT 0
TCP 10.2.56.38:7151 203.119.144.59:443 TIME_WAIT 0
TCP 10.2.56.38:7159 114.55.187.58:443 ESTABLISHED 11292
TCP 10.2.56.38:7160 42.121.254.191:443 TIME_WAIT 0
TCP 10.2.56.38:7162 118.178.109.187:443 TIME_WAIT 0
TCP 10.2.56.38:7165 47.110.223.99:443 TIME_WAIT 0
TCP 10.2.56.38:7166 116.62.93.118:443 TIME_WAIT 0
TCP 10.2.56.38:7195 123.150.76.171:80 CLOSE_WAIT 10772
TCP 10.2.56.38:6974 ################## ESTABLISHED 10984
TCP 10.2.56.38:7215 192.168.0.9:80 CLOSE_WAIT 4700
TCP 10.2.56.38:7218 10.2.100.217:7680 SYN_SENT 8816
TCP 10.2.56.38:7219 192.168.56.1:7680 SYN_SENT 8816
TCP 10.2.56.38:7680 10.2.102.27:53199 ESTABLISHED 8816
TCP 10.2.56.38:9763 192.168.23.23:49156 ESTABLISHED 4600
TCP 10.2.56.38:10267 125.39.132.161:80 ESTABLISHED 10772
TCP 10.2.56.38:10816 60.205.204.27:80 ESTABLISHED 10872
TCP 127.0.0.1:443 127.0.0.1:7216 ESTABLISHED 8108
TCP 127.0.0.1:2002 127.0.0.1:2003 ESTABLISHED 11292
TCP 127.0.0.1:2003 127.0.0.1:2002 ESTABLISHED 11292
TCP 127.0.0.1:2013 127.0.0.1:2014 ESTABLISHED 9600
TCP 127.0.0.1:2014 127.0.0.1:2013 ESTABLISHED 9600
TCP 127.0.0.1:2015 127.0.0.1:2016 ESTABLISHED 12948
TCP 127.0.0.1:2016 127.0.0.1:2015 ESTABLISHED 12948
TCP 127.0.0.1:2040 127.0.0.1:2041 ESTABLISHED 13960
TCP 127.0.0.1:2041 127.0.0.1:2040 ESTABLISHED 13960
TCP 127.0.0.1:2109 127.0.0.1:2110 ESTABLISHED 15092
TCP 127.0.0.1:2110 127.0.0.1:2109 ESTABLISHED 15092
TCP 127.0.0.1:2349 127.0.0.1:50051 ESTABLISHED 6308
TCP 127.0.0.1:2566 127.0.0.1:30031 ESTABLISHED 10624
TCP 127.0.0.1:3032 127.0.0.1:3033 ESTABLISHED 20276
TCP 127.0.0.1:3033 127.0.0.1:3032 ESTABLISHED 20276
TCP 127.0.0.1:3517 127.0.0.1:3518 ESTABLISHED 18200
TCP 127.0.0.1:3518 127.0.0.1:3517 ESTABLISHED 18200
TCP 127.0.0.1:3768 127.0.0.1:3769 ESTABLISHED 14076
TCP 127.0.0.1:3769 127.0.0.1:3768 ESTABLISHED 14076
TCP 127.0.0.1:3854 127.0.0.1:3855 ESTABLISHED 17380
TCP 127.0.0.1:3855 127.0.0.1:3854 ESTABLISHED 17380
TCP 127.0.0.1:4895 127.0.0.1:4896 ESTABLISHED 15524
TCP 127.0.0.1:4896 127.0.0.1:4895 ESTABLISHED 15524
TCP 127.0.0.1:5320 127.0.0.1:5321 ESTABLISHED 16736
TCP 127.0.0.1:5321 127.0.0.1:5320 ESTABLISHED 16736
TCP 127.0.0.1:6688 127.0.0.1:10803 ESTABLISHED 10872
TCP 127.0.0.1:6688 127.0.0.1:10824 ESTABLISHED 10872
TCP 127.0.0.1:6688 127.0.0.1:10841 ESTABLISHED 10872
TCP 127.0.0.1:6688 127.0.0.1:10849 ESTABLISHED 10872
TCP 127.0.0.1:6689 127.0.0.1:10819 ESTABLISHED 10672
TCP 127.0.0.1:7187 127.0.0.1:443 TIME_WAIT 0
TCP 127.0.0.1:7216 127.0.0.1:443 ESTABLISHED 10548
TCP 127.0.0.1:8419 127.0.0.1:8420 ESTABLISHED 14716
TCP 127.0.0.1:8420 127.0.0.1:8419 ESTABLISHED 14716
TCP 127.0.0.1:10803 127.0.0.1:6688 ESTABLISHED 2256
TCP 127.0.0.1:10819 127.0.0.1:6689 ESTABLISHED 13436
TCP 127.0.0.1:10824 127.0.0.1:6688 ESTABLISHED 10672
TCP 127.0.0.1:10841 127.0.0.1:6688 ESTABLISHED 15448
TCP 127.0.0.1:10849 127.0.0.1:6688 ESTABLISHED 9772
TCP 127.0.0.1:30031 127.0.0.1:2566 ESTABLISHED 10608
TCP 127.0.0.1:50051 127.0.0.1:2349 ESTABLISHED 10608
TCP [::1]:5900 [::1]:5901 ESTABLISHED 10548
TCP [::1]:5901 [::1]:5900 ESTABLISHED 10548
TCP [::1]:7188 [::1]:8307 FIN_WAIT_2 8108
TCP [::1]:7217 [::1]:8307 ESTABLISHED 8108
TCP [::1]:8307 [::1]:7188 CLOSE_WAIT 8108
TCP [::1]:8307 [::1]:7217 ESTABLISHED 8108

这里主要是通过过滤进程 ID 来实现快速定位的。连接也可以被主动关闭,这需要使用下面的重定向语法(其实就是关闭普通文件):

exec N < &-

其中 N 就是刚才打开的文件句柄,可以用 > 等效替换 <。在 msys2 中就可以这样验证了:

最后仍然是通过 procexp 工具或 netstat 命令来查看执行结果。另外使用 echo $? 获取 exec 执行结果为 0 似乎并不能确认连接已经建立,因为我对一个错误的 host + port 使用 exec 仍然能得到 0。

机器上下线

连接建立好以后,需要向后台上报本机的一些基本信息,这个协议称为机器上线:

 1 function send_request_100 ()
2 {
3 local msg=$(cat protocol/100.gcm)
4 # do replace
5 msg=$(echo "$msg" | jq --arg guid "$devid" --arg hwid "$hardid" -c '{ version, msgtype, guid: $guid, devinfo: { hwid: $hwid, devid: $guid, os: .devinfo.os, os_version: .devinfo.os_version, sysbit: .devinfo.sysbit, languageid: .devinfo.languageid } }')
6 echo $msg >&3
7 local ret=$?
8 if [ $ret -ne 0 ]; then
9 echo "connection break, send failed"
10 exit 3
11 fi
12 }
13
14 # online myself
15 send_request_100

机器上线的过程被封装成了一个函数:send_request_100,这里 100 是机器上线的消息号。其实消息发送就是一句代码的事儿(line 6),这个函数的主要工作是组装 100 协议的内容(line 3-5)。消息都是 json 格式的串,为了降低代码与协议的耦合度,这里把每个协议都放在单独的文件中,例如上面的 “100.gcm” 文件存放的就是机器上线的消息模板:

{
"version": "3.1",
"msgtype": "100",
"guid": "",
"devinfo": {
"hwid": "",
"devid": "",
"os": "Windows",
"os_version": "7",
"sysbit": "64",
"languageid": "2052"
}
}

从文件中读取到本地变量后,需要做一些填充工作(guid / hwid / devid… 字段),这里使用了 jq 命令的 --arg 选项来传递外部参数并基于它们重新捏合 json 串,这些参数(devid / hardid)又是在脚本启动前就从注册表中读取并传入的。机器上线后才可以进行产品的上下线,而且相应的,当客户端停止时,也要告诉后台机器下线:

 1 function send_request_101
2 {
3 local msg=$(cat protocol/101.gcm)
4 # do replace
5 msg=$(echo "$msg" | jq --arg guid "$devid" -c '{ version, msgtype, guid: $guid }')
6 echo $msg >&3
7 local ret=$?
8 echo "send 101 msg to gcm $ret" >> log.txt
9 if [ $ret -ne 0 ]; then
10 echo "connection break, send failed"
11 exit 3
12 fi
13
14 # no response for 101 message
15 echo "offline success! devid=$devid"
16 }
17
18 # -1st offline myself
19 send_request_101

这个过程封装在 send_request_101 函数中,这里 101 是机器下线的消息号。同样的,这个消息也有模板文件:

{
"version": "3.1",
"msgtype": "101",
"guid": ""
}

相对简单一点。protocol 子目录包含了所有消息协议的模板:

$ ls -lhrt
total 7.0K
-rw-r--r-- 1 yunh 1049089 312 5月 28 2019 102.gcm
-rw-r--r-- 1 yunh 1049089 102 5月 28 2019 101.gcm
-rw-r--r-- 1 yunh 1049089 350 5月 28 2019 100.gcm
-rw-r--r-- 1 yunh 1049089 141 5月 28 2019 412.gcm
-rw-r--r-- 1 yunh 1049089 166 5月 28 2019 108.gcm
-rw-r--r-- 1 yunh 1049089 193 5月 28 2019 103.gcm
-rw-r--r-- 1 yunh 1049089 478 7月 26 2019 custom.gcm

机器下线的其它处理流程和上线差不太多,这里就不赘述了。后面不会对消息内容做详细介绍了,主要是涉及到协议保密的问题。

产品上下线

机器在开机上线,产品在启动时上线,这样当后台有推送内容时,相应的消息就可以推送过来(不会对没上线的产品推送):

 1 # $1: app name
2 # $2: app version
3 # $3: user id
4 # $4: device id
5 function send_request_102 ()
6 {
7 local guid=$(echo "$4$1$3" | sha1sum | awk '{ print $1 }')
8 local msg=$(cat protocol/102.gcm)
9 # do replace
10 msg=$(echo "$msg" | jq --arg appname "$1" --arg appver "$2" --arg userid "$3" --arg guid "$guid" -c '{ version, msgtype, guid: $guid, appclientid: $appname, appuserid: $userid, clientinfo: { appversion: $appver, platform: .clientinfo.platform, bits: .clientinfo.bits } }')
11 echo $msg >&3
12 local ret=$?
13 echo "send 102 msg to gcm $ret" >> log.txt
14 if [ $ret -ne 0 ]; then
15 echo "connection break, send failed"
16 exit 3
17 fi
18 }
19
20 # online GCMPopBox/GUX/GSUP
21 send_request_102 "GCMPopBox" "2.0.0.0" "$hardid" "$devid"
22 send_request_102 "GUX" "$version" "$devid" "$devid"
23 send_request_102 "GSUP" "$version" "$devid" "$devid"

这个过程封装在 send_request_102 函数中,这里 102 是产品上线的消息号。这个函数接收四个参数,分别是产品标识、产品版本、用户标识和机器标识。在机器上线后,会固定上线三个产品:GCMPopBox、GUX 和 GSUP,都是客户端自带的几个服务产品。当产品关闭时,要向后台发送产品下线消息:

 1 # $1: app name
2 # $2: user id
3 # $3: device id
4 function send_request_103
5 {
6 local guid=$(echo "$3$1$2" | sha1sum | awk '{print $1}')
7 local msg=$(cat protocol/103.gcm)
8 # do replace
9 msg=$(echo "$msg" | jq --arg appname "$1" --arg userid "$2" --arg guid "$guid" -c '{ version, msgtype, guid: $guid, appclientid: $appname, appuserid: $userid }')
10 echo $msg >&3
11 local ret=$?
12 echo "send 103 msg to gcm $ret" >> log.txt
13 if [ $ret -ne 0 ]; then
14 echo "connection break, send failed"
15 exit 3
16 fi
17
18 # no response for 103 message
19 echo "$1 offline success! userid=$2"
20 }
21
22 # -2nd offline GCMPopBox/GUX/GSUP
23 send_request_103 "GCMPopBox" "$hardid" "$devid"
24 send_request_103 "GUX" "$devid" "$devid"
25 send_request_103 "GSUP" "$devid" "$devid"

这个过程封装在 send_request_103 函数中,其中 103 是产品下线的消息号。和产品上线消息相比,不用再提供产品版本了,其它方面大同小异。在机器下线前,需要对之前上线的几个客户端服务产品一一下线(GCMPopBox / GUX / GSUP)。除了固定的产品,用户也可以在命令行指定某个产品去上线,这个工具跑起来后长这个样子:

红框中的部分其实是一个循环,用户可以不停的输入要上下线的产品进行操作,这部分的代码相应的也位于一段  while 循环中:

 1 # online/offline products
2 while :
3 do
4 echo "-------------------------------------------"
5 echo -n "product name to operate (exit|quit to quit): "
6 read product
7 if [ "$product" == "exit" -o "$product" == "quit" ]; then
8 break;
9 fi
10
11 echo -n "operation (online|offline): "
12 read resp
13 online=0
14 case "$resp" in
15 ""|"o"|"O"|"on"|"ON"|"online"|"ONLINE")
16 online=1
17 ;;
18 *)
19 ;;
20 esac
21
22 if [ $online == 1 ]; then
23 echo -n "version: "
24 read version
25 fi
26
27 echo -n "user id: "
28 read userid
29
30 if [ $online == 1 ]; then
31 send_request_102 "$product" "$version" "$userid" "$devid"
32 else
33 send_request_103 "$product" "$userid" "$devid"
34 fi
35
36 sleep 1
37 done

下面做个简单讲解:

  • line 4-9:如果用户输入 quit 或 exit,退出循环从而退出整个脚本。否则收集到要操作的产品标识;
  • line 11-20:提示用户输入进行何种操作,上线 or 下线;
  • line 22-25:如果是上线操作,则需要用户输入产品版本;
  • line 27-28:提示用户输入用户 ID;
  • line 30-34:根据用户输入的操作类型,调用前面封装好的函数来完成产品上下线。

接收推送消息

产品上线成功后,就可以接收来自后台的“问候”了。这块和前面那种一问一答模式不一样,需要异步处理连接上到达的数据。我的第一反应就是开个线程来处理,但是 shell 里并没有线程这种东西,只有子进程可以用。问题是开子进程后原句柄 (3) 还能代表以前的连接吗?在 linux 上这一点不容置疑,但是 windows 上可没有 fork 这东东啊,怎么保证新启动的子进程复制父进程的用户空间呢?带着疑问,我尝试了下面的代码:

1 echo "connect with server"
2 on_recv &
3 cpid=$!

将接收消息相关代码封装在 on_recv 函数中,就可以直接用 ‘&’ 启动一个单独的进程去跑这个函数啦!作为测试,一开始我只在 on_recv 中处理了几个简单的应答消息(100->201,102->301……):

 1 function on_recv
2 {
3 # can not break read !
4 #trap "echo recv exit signal from parent" INT
5 while :
6 do
7 #read msg <&3
8 #msg=$(tail -f <&3)
9 #read -t 1 msg <&3
10 local msg=""
11 read -d '}' msg <&3
12 if [ -z "$msg" ]; then
13 echo -e "\nconnection break, receive failed"
14 exit 3
15 fi
16
17 msg="$msg}"
18 local type=$(echo "$msg" | jq -r '.msgtype')
19 case "$type" in
20 "201")
21 local guid=$(echo "$msg" | jq -r '.guid')
22 echo "online success! devid=$guid"
23 ;;
24 "301")
25 local appname=$(echo "$msg" | jq -r '.appclientid')
26 local userid=$(echo "$msg" | jq -r '.dstuserid')
27 echo "$appname online success! userid=$userid"
28 ;;
29 *)
30 echo "unknown response type: $type"
31 #exit
32 ;;
33 esac
34 done
35 }

收到这几个应答消息时,会将其中关键的字段打印在屏幕上。应答消息同请求消息一样,也是纯 json 格式,因此这里使用 jq 来做解析 (line 17-33)。

不过难点倒不在这儿,真正让我费了半天劲儿的地方是在读取,可能有人说了,读取有什么难的,直接 read 不就行了吗?我一开始就是这样做的 (line 7),然而 read 会一直卡在那里读数据,即使已经有消息读到了也不返回。简单分析一下:看起来 read 在等一个结束标志,一般而言就是换行 '\n',这也是为什么你可以一直在 console 界面输入内容、直到回车结束的原因。然而后台应答消息并没有换行符作为消息结束,于是我尝试了另外一个方案,使用 tail -f 读取连接中的内容 (line 8),然而没有任何改进。

因此这里我又试了第三个方案 (line 9),为 read 增加了一个超时时间 (1s),这样当时间足够长了以后也能返回之前读到的消息,缺点是 read 会每隔一秒中断一次;然而我忽略了一个更严重的问题的,那就是当产品积压了很多消息没有推送后,当它上线的一刻后台会同时给它推送多个消息,这样一来,这个带超时的 read 常常会将多个消息粘在一起返回,导致后面的解析出错。

于是我试了现在这个方案 (line 11),告诉 read 一直读、直到遇到 json 结尾符 '}'。当然这也不是完全保险的,因为 json 中有可能存在嵌套的子结构、导致内部含有 ‘}’,但好在现有的协议中应答消息都比较简单,基本上一对花括号之内不会再有花括号了,所以可以这样搞。这个脚本跑起来的效果,其实前面那张图已经展示过了,这次重新划一下重点:

可以看到,新的子进程可以很好的收到机器和产品上线的应答消息(下线没有应答消息),看起来就像它与父进程共享了这个连接一样。验证了 msys2 这个功能没问题后,下面就开始我们的重头戏了 —— 接收后台推送的消息:

 1           "105")
2 local guid=$(echo "$msg" | jq -r '.guid')
3 local appclientid=$(echo "$msg" | jq -r '.appclientid')
4 local msgid=$(echo "$msg" | jq -r '.msgid')
5 local msgbody=$(echo "$msg" | jq -r '.msgbody')
6 local appuserid=$(echo "$msg" | jq -r '.appuserid')
7 local dstuserid=$(echo "$msg" | jq -r '.dstuserid')
8 echo ""
9 echo "*******************************************"
10 echo "receive customer message "
11 echo "product: $appclientid"
12 echo "userid : $appuserid"
13 echo "msgid : $msgid"
14 local body_utf8=$(echo "$msgbody" | base64 -d)
15 local body=$(echo "$body_utf8" | iconv -f utf-8 -t gb2312)
16 echo "content: $body"
17 echo "*******************************************"
18 send_request_108 "$guid" "$appclientid" "$appuserid" "$msgid"
19 ;;

这里直接上 case 语句。105 是自定义消息,这个应用自己“偷摸”处理掉就好啦,不用给用户展示,这边出于演示目的直接将消息内容打印在屏幕上(有一些 base64 解码及 utf8 编码转换的工作:line 14-15)。收完消息后,给后台回复一个 108 消息表示成功接收,send_request_108 与其它 send 函数大同小异,这里不展开说明了。真正复杂的部分是接收弹窗消息:

 1           "401")
2 local guid=$(echo "$msg" | jq -r '.guid')
3 local appclientid=$(echo "$msg" | jq -r '.appclientid')
4 local msgid=$(echo "$msg" | jq -r '.msgid')
5 local msgbody=$(echo "$msg" | jq -r '.msgbody')
6 local appuserid=$(echo "$msg" | jq -r '.appuserid')
7 local dstuserid=$(echo "$msg" | jq -r '.dstuserid')
8 echo ""
9 echo "*******************************************"
10 echo "receive popup message "
11 echo "product: $appclientid"
12 echo "userid : $appuserid"
13 echo "msgid : $msgid"
14 echo ""
15 local body=$(echo "$msgbody" | base64 -d)
16 local title_utf8=$(echo "$body" | jq -r '.title')
17 local title=$(echo "$title_utf8" | iconv -f utf-8 -t gb2312)
18 local content_utf8=$(echo "$body" | jq -r '.content')
19 local content=$(echo "$content_utf8" | iconv -f utf-8 -t gb2312)
20 local ctxurl=$(echo "$body" | jq -r '."content-url"') # - is not recognized
21 local image=$(echo "$body" | jq -r '.image')
22 local imgurl=$(echo "$body" | jq -r '."image-url"') # - is not recognized
23 echo "title : $title"
24 echo "content: $content"
25 echo "ctxurl : $ctxurl"
26 echo "image : $image"
27 echo "imgurl : $imgurl"
28 echo "*******************************************"
29
30 # prepare 108 message
31 send_request_108 "$guid" "$appclientid" "$appuserid" "$msgid"
32 sleep 1
33 # prepare 402 message
34 send_request_402 "$msg" "$hardid"
35 ;;

401 就是弹窗消息,本来是要给用户在屏幕右下角弹个小窗显示的,这里为了简化问题,也直接打印在屏幕上。收到 401 消息后要先给后台回复一个 108 表示成功接收,再回复一个 402 来表示弹窗最终结果,例如用户点击、关闭、查看详情…等等,这里直接返回用户关闭作为模拟。下面是产品上线后,收到推送消息的效果:

这里演示了两个消息,分别是弹窗消息与自定义消息,可以看到都能正常的解析与显示。后台也可以正常的统计到这两个消息的推送情况:

最后,当用户退出操作循环后,需要及时回收子进程:

1 exec 3>&-
2 kill -INT $cpid
3 wait

这里通过 kill 产生 INT 消息来通知子进程退出接收循环,接着通过 wait 等待子进程完全退出。之前也尝试在子进程中捕获 (trap) INT 信号并优雅的退出,但是发现在 windows 环境下加了这个捕获反而导致 read 不能被中断了,so 放弃之。现在这种方式可能是直接把子进程给杀死了,虽然“暴力”一点,但是起码可以正常工作。

后记

通过构建这个小工具,我甚至发现了协议文档中书写错误或不详的地方。不过最让我感到好奇的还是 —— windows 上是怎么实现两个进程共享一个连接句柄的? 为了解答这个问题,祭出 procexp 大杀器:

可以看到连接只在父进程(20612)中展示,子进程(16844)中并没有对应的连接,那它是怎么在连接上读数据的呢?左看右看没有看出什么头绪。想用 msys2 的 lsof 命令查看下进程句柄,但是翻遍了安装目录也没有找到这个命令,看来 msys2 也不是移植了所有的命令。不过好在 lsof 也是通过 proc 文件子系统实现的,那能不能查看进程的 proc 目录呢?答案是可以的:

$ ls -l /proc
total 0
dr-xr-xr-x 3 yunh 1049089 0 11月 24 14:47 16796/
dr-xr-xr-x 3 yunh 1049089 0 11月 24 13:35 17992/
dr-xr-xr-x 3 yunh 1049089 0 11月 24 14:47 18468/
dr-xr-xr-x 3 yunh 1049089 0 11月 25 15:36 20828/
dr-xr-xr-x 3 yunh 1049089 0 11月 24 13:35 7464/
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 cpuinfo
lrwxrwxrwx 1 yunh 1049089 0 11月 25 15:36 cygdrive -> //
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 devices
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 filesystems
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 loadavg
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 meminfo
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 misc
lrwxrwxrwx 1 yunh 1049089 0 11月 25 15:36 mounts -> self/mounts
dr-xr-xr-x 2 yunh 1049089 0 11月 25 15:36 net/
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 partitions
dr-xr-xr-x 8 yunh 1049089 0 11月 25 15:36 registry/
dr-xr-xr-x 8 yunh 1049089 0 11月 25 15:36 registry32/
dr-xr-xr-x 8 yunh 1049089 0 11月 25 15:36 registry64/
lrwxrwxrwx 1 yunh 1049089 0 11月 25 15:36 self -> 20828/
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 stat
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 swaps
drwxrwx--- 1 Administrators 18 0 11月 25 15:36 sys/
dr-xr-xr-x 2 yunh 1049089 0 11月 25 15:36 sysvipc/
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 uptime
-r--r--r-- 1 yunh 1049089 0 11月 25 15:36 version

这是我在另一个终端中打印的内容。不过找了一下,没有找到上面两个进程 ID 对应的目录。那在脚本里直接打印呢?

ls -lhrt /proc/self/

其中 self 就是指自己啦。将这句代码分别放置在父进程连接建立后的位置与子进程 on_recv 函数开头中,得到下面的输出:

connect with server
total 0
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 0 -> /dev/null
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 1 -> /dev/cons0
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 2 -> /tools/gsupgo/error.txt
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 3 -> socket:[1]
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 4 -> /proc/8532/fd
total 0
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 0 -> /dev/cons0
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 1 -> /dev/cons0
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 2 -> /tools/gsupgo/error.txt
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 3 -> socket:[1]
lrwxrwxrwx 1 yunh Domain Users 0 Nov 25 15:31 4 -> /proc/15580/fd

上面一段是父进程的输出,句柄 3 对应的确实是 tcp 连接;下面一段是子进程的输出,看起来与父进程无异。最有意思的是两个进程的 4 号文件句柄,显示出了它们各自的 pid,显然和它们在 windows 上的进程 ID 是不一样的。这也可能是之前我在 /proc 目录下找不到它们的原因吧。但是再次查看 /proc 目录,仍然没有上面两个 pid,可见这个 pid 可能只是局限于本进程组的 (?),并不是全局共享,因而也没有什么利用价值。

探索到这就走到死胡同了,有了解 msys2 在 windows 上实现的大神请不吝赐教。

最后这个小工具没有资源可供下载 —— 涉及到公司内部协议安全的问题。不过写了这么多,相信拿来改改写一个自己的也不是什么难事了吧~

参考

[1]. Linux shell脚本中发起tcp、udp连接

[2]. netstat--查看服务器[有效]连接数--统计端口并发数--access.log分析

[3]. jq add or update a value with multiple --arg

使用 shell 做 tcp 协议模拟的更多相关文章

  1. 简单测试nginx1.90做TCP协议负载均衡的功能

    最近工作中需要做TCP层面的负载均衡,以前网站用的反向代理nginx只支持应用层的负载均衡,对于TCP协议是无能为力的,需要使用LVS(linux虚拟服务器). LVS的特点是高性能和极复杂的配置.对 ...

  2. Jmeter TCP协议性能测试

    最近有在做tcp协议性能测试,总结一下遇到的坑吧. 首先呢,我这边用的是16进制的报文: (1)TCPClient classname:org.apache.jmeter.protocol.tcp.s ...

  3. 基于UDP协议模拟的一个TCP协议传输系统

    TCP协议以可靠性出名,这其中包括三次握手建立连接,流控制和拥塞控制等技术.详细介绍如下: 1. TCP协议将需要发送的数据分割成数据块.数据块大小是通过MSS(maximum segment siz ...

  4. linux视频学习3(linux安装,shell,tcp/ip协议,网络配置)

    linux系统的安装: 1.linux系统的安装方式三种: 1.独立安装linux系统. 2.虚拟机安装linux系统. a.安装虚拟机,基本是一路点下去. b.安装linux. c.linux 安装 ...

  5. python-基于tcp协议的套接字(加强版)及粘包问题

    一.基于tcp协议的套接字(通信循环+链接循环) 服务端应该遵循: 1.绑定一个固定的ip和port 2.一直对外提供服务,稳定运行 3.能够支持并发 基础版套接字: from socket impo ...

  6. Learning-Python【28】:基于TCP协议通信的套接字

    什么是 Socket Socket 是应用层与 TCP/IP 协议通信的中间软件抽象层,它是一组接口.在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Sock ...

  7. TCP协议粘包问题详解

    TCP协议粘包问题详解 前言 在本章节中,我们将探讨TCP协议基于流式传输的最大一个问题,即粘包问题.本章主要介绍TCP粘包的原理与其三种解决粘包的方案.并且还会介绍为什么UDP协议不会产生粘包. 基 ...

  8. TCP协议下大数据传输IOCP乱序问题

    毕业后稀里糊涂的闭门造车了两年,自己的独立博客也写了两年,各种乱七八糟,最近准备把自己博客废了,现在来看了下这两年写的对我来说略微有点意义的文章只此一篇,转载过来以作留念. 写的很肤浅且凌乱,请见谅. ...

  9. 在网络7层协议中,如果想使用UDP协议达到TCP协议的效果,可以在哪层做文章?(QQ 为什么采用 UDP 协议,而不采用 TCP 协议实现?)

    为了解决这题,可以具体看看下面这个讨论. 解灵运工程师 185 人赞同 某次架构师大会上那个58同城做即时通信的人说:原因是因为当时没有epoll这种可以支持成千上万tcp并发连接的技术,所以他们使用 ...

随机推荐

  1. 小程序 怎么发 beta 版本

    小程序 怎么发 beta 版本 微信 https://developers.weixin.qq.com/miniprogram/dev/devtools/mydev.html 小程序助手 支付宝 ht ...

  2. 「NGK每日快讯」12.3日NGK公链第30期官方快讯!

  3. Union international INC评德意志联邦投入十亿欧元重启文化娱乐产业

    当地时间6月4日,德国联邦政府宣布了一项名为"重启文化"(Neustart Kultur)的计划,将投入总计10亿欧元,用以支持德国文化及创意产业的恢复和重建. Union int ...

  4. Spring 中的 MetaData 接口

    什么是元数据(MetaData) 先直接贴一个英文解释: Metadata is simply data about data. It means it is a description and co ...

  5. Scrapy 项目:腾讯招聘

    目的: 通过爬取腾讯招聘网站(https://careers.tencent.com/search.html)练习Scrapy框架的使用 步骤: 1.通过抓包确认要抓取的内容是否在当前url地址中,测 ...

  6. 二分图最小点覆盖构造方案+König定理证明

    前言 博主很笨 ,如有纰漏,欢迎在评论区指出讨论. 二分图的最大匹配使用 \(Dinic\) 算法进行实现,时间复杂度为 \(O(n\sqrt{e})\),其中, \(n\)为二分图中左部点的数量, ...

  7. ajax缺点

    ajax请求在SEO中效率低,SEO就是关键字搜索的匹配度. 比如在百度搜索Java,一般来说内容中出现Java的次数越多排名越靠前,当使用ajax时,它的异步刷新导致必须是页面刷新出来才去刷新数据, ...

  8. python进阶(3)序列化与反序列化

    序列化与反序列化 按照某种规则,把内存中的数据保存到文件中,文件是一个字节序列,所以必须要把内存数据转换成为字节序列,输出到文件,这就是序列化:反之,从文件的字节恢复到内存,就是反序列化: pytho ...

  9. EF获取数据库表名和列名

    EF获取数据库表名和列名 新建 模板 小书匠 /// <summary>  /// 通过当前DBContext上下文获取对应数据库中所有得表  /// </summary>  ...

  10. 女朋友看了会生气的回答 URI和URL有什么区别?

    URL是什么 URL 代表着是统一资源定位符(Uniform Resource Locator).作用是为了告诉使用者 某个资源在 Web 上的地址.这个资源可以是一个 HTML 页面,一个 CSS ...