用 shell 脚本做命令行工具扩展

问题的提出

公司开发机与远程服务器之间有严格的隔离策略,不能直接使用 ssh 登录,而必需通过跳板机。这样一来,本地与服务器之间的一些文件传输变得非常不便。经过咨询,运维教了我一招:

$ nc -l 8080 > filename
$ nc yunhai.xxxxxxxx.xxxxx.com 8080 < filename

使用 nc 建立连接来传输文件,第一行命令在服务器执行,第二行命令在本地执行,它的参数就是远程服务器的 host,运行完成后,将文件从本地拷贝到远程。这样确实可以工作,而且 8080 是运维允许通行的端口,上面的命令是合规的。反过来想将文件从远程拷贝到本地,只需要将上面的重定向符换个方向即可,顺序不变,仍是先在服务器运行第一行命令:

$ nc -l 8080 < filename
$ nc yunhai.xxxxxxxx.xxxxx.com 8080 > filename

从服务器拷贝大量经过选择的文件,运维还教了另外一个办法来实现:

$ python -m SimpleHTTPServer 8080

本地打开浏览器访问 yunhai.xxxxxxxx.xxxxx.com:8080 就能实现类似 ftp 一样的功能啦:

凡是在这条命令启动目录下面的文件,都可以通过点击上面的链接下载,子目录的话则会展开。与 nc 不同,每次下载不再是“一锤子买卖”,你可以一直下一直下……这又一次体现了 python 的强大 (虽然我不怎么用)。

这篇文章写到这里似乎就可以结束了,然而我要说的是,上面的工具都不能满足我的需求。因为我不只是需要一个跨跳板机传输文件的工具 (其实用 secretcrt 的 rz/sz 就挺好,不过我司未购买,禁止员工安装盗版),还想要一个跨多台机器存储和共享文件的机制。例如我本身是在 mac 上开发,还有一台 windows 测试笔记本,远程 linux 服务器目前有一台,但是将来很可能会扩展……想想将来要在这么多机器上找到并传输一个文件我就头大。

就在我一愁莫展的时候,安全组的同事提供了一个基于企业网盘的命令行工具,可以通过命令行的方式上传下载文件,在 mac 上还有桌面端可以用。首先它是合规的,其次它上传、下载的文件位于企业网盘你的个人账户下的一个特定目录,其它人没有权限看不到,而你在开发机上通过浏览器登录时,如果之前已经登录过公司的帐号,就会 SSO 无感登录:

 

 然后就可以在浏览器里查看、编辑、上传、下载文件啦。另一方面,在服务器使用命令行也可以 SSO 免登录直接上传下载:

$ bst_tool --help
当前用户:yunhai

Bxxxx Secure Transmission tool. Version x.x.x.x.

Usage:
  bst_tool COMMAND [flags] [options]

where COMMAND is one of:
  ls            列出指定目录下的文件
  get           下载企业网盘中的文件到本地
  put           上传本地文件到企业网盘
  delete        删除企业网盘中的文件
  version       显示当前版本号

Find more detail by:
  bst_tool COMMAND -help

 

这个工具浓缩的都是精华,只提供列出 (ls)、下载 (get)、上传 (put)、删除 (delete) 这四大基本操作,首先来看 ls:

$ bst_tool ls /init
当前用户:yunhai
total size:  7
│─────────────│──────────│──────────│─────────────────────│──────│─────────────────────│
│ FILEID (7)  │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME            │
│─────────────│──────────│──────────│─────────────────────│──────│─────────────────────│
│ 406869750   │ yunhai   │ 535.00B  │ 2021-04-13 21:24:00 │ file │ init_bst.sh         │
│ 406850107   │ yunhai   │ 820.00B  │ 2021-04-13 19:35:24 │ file │ create_user.sh      │
│ 406842658   │ yunhai   │ 14.28KB  │ 2021-04-13 19:28:05 │ file │ bash_bst.txt        │
│ 404998873   │ yunhai   │ 409.00B  │ 2021-04-07 20:25:16 │ file │ build_cmake.sh      │
│ 402750998   │ yunhai   │ 15.73MB  │ 2021-03-31 16:24:10 │ file │ cmake-3.20.0.zip    │
│ 402361471   │ yunhai   │ 289.00B  │ 2021-03-30 20:41:32 │ file │ build_glibc.sh      │
│ 402359162   │ yunhai   │ 2.85MB   │ 2021-03-30 20:33:41 │ file │ global-6.6.5.tar.gz │
│─────────────│──────────│──────────│─────────────────────│──────│─────────────────────│

 

如果给 ls 的参数是一个文件,那么它只列出这个文件的详细信息:

$ bst_tool ls /init/create_user.sh
当前用户:yunhai
total size:  1
│───────────│──────────│──────────│─────────────────────│──────│────────────────│
│ FILEID    │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME       │
│───────────│──────────│──────────│─────────────────────│──────│────────────────│
│ 406850107 │ yunhai   │ 820.00B  │ 2021-04-13 19:35:24 │ file │ create_user.sh │
│───────────│──────────│──────────│─────────────────────│──────│────────────────│

 

至于递归列出目录中的子目录什么的,想都不要想了,没有。get 每次可以下载一个文件:

$ bst_tool get /init/create_user.sh
当前用户:yunhai
download success. local path: /home/yunh/code/cnblogs/create_user.sh

 

上面这个脚本就是我在新机器上必跑的一个脚本,用来创建非 root 用户,并修改一些常用的配置。如果你本地已经有一个同名文件,bst 会贴心的给出提示:

$ bst_tool get /init/create_user.sh
当前用户:yunhai
错误:本地路径 /home/yunh/code/cnblogs/create_user.sh 指向一个已存在文件,请先自行确认并删除已存在文件,或者选择其他路径

 

这种情况下是不会覆盖本地文件的,防止意外丢失数据。如果就是要覆盖,那么先删除一下就好啦~

有新的文件需要上传时,可以使用 put 子命令:

$ bst_tool put score.txt /data/
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 415268968
  FilePath: /data/score.txt
  Time: 2021-05-06 15:54:31

 

如果不指定目标文件名,自动使用本地文件名;如果指定的目标目录不存在,自动递归创建路径中的每个目录;那如果远程目标已经存在了呢?

$ bst_tool put create_user.sh /init/
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 415278480
  FilePath: /init/create_user(1).sh
  Time: 2021-05-06 16:05:29

 

好家伙,自动重命名了,命名方式是名称后跟序号,这也是一种保护数据的思路。

一些临时文件用完以后,可以删除:

$ bst_tool delete /data/score.txt
当前用户:yunhai
deleting the 1th filePath: /data/score.txt
delete result: 成功

 

在介绍的过程中,我相信你已经了解到了这个小工具的几个先天不足:

  1. 不支持递归列出,想要知道一个文件有没有在远程目录、在哪个子目录下面,很难;
  2. 不支持递归下载,想要一次性下载一个目录,很难;
  3. 不支持覆盖下载,想要将远程文件备份到本地固定目录,很难;
  4. 不支持覆盖上传,想要将一个文件的修改版本上传到同一个位置,很难。

 作为日常使用的一部分,能用是不够的,必需要好用!作为 shell 资深用户,看不惯就改是我们的座右铭,这次就拿它来开刀~

问题的解决

柿子先检软的捏,这个覆盖上传、覆盖下载看起来挺容易,通过预先探测来得知文件是否存在,如果存在了给用户一个告警,并让用户 (就是我) 选择是放弃还是继续覆盖就可以了,当用户选择覆盖时,将已存在后台的文件先删除,再上传,完事儿~

文件是否存在

在正式开始之前,我们先看一下针对以下几种情况,bst_tool ls 的效果,因为这个会影响我们后面的判断逻辑。

  • 目录存在且不为空
  • 文件存在
  • 目录存在但为空
  • 文件或目录不存在

 

$ bst_tool ls /data
当前用户:yunhai
total size:  1
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ FILEID    │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME │
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ 419245020 │ yunhai   │ 283.00B  │ 2021-05-14 15:44:24 │ file │ data     │
│───────────│──────────│──────────│─────────────────────│──────│──────────│
$ bst_tool ls /data/data
当前用户:yunhai
total size:  1
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ FILEID    │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME │
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ 419245020 │ yunhai   │ 283.00B  │ 2021-05-14 15:44:24 │ file │ data     │
│───────────│──────────│──────────│─────────────────────│──────│──────────│
$ bst_tool ls /empty
当前用户:yunhai
total size:  0
$ bst_tool ls /data/data.txt
当前用户:yunhai
total size:  0

 

这里分别列出了 /data 目录、/data/data 文件、/empty 目录、/data/data.txt 文件,最后一个是不存在的文件。可以看到前两组和后两组的差别比较大,关键字就是 total size: 0 这行,为 0 表示不存在或空目录,不为 0 表示存在;但是这样就万事大吉了吗?今天这个例子我举得比较巧,相信细心的人已经看出来了,/data 目录和 /data/data 文件的输出完全一样!巧就巧在它们名称相同、而且目录下只有一个同名文件,这种场景下,第二个 ls 是输出文件的详细信息;第一个 ls 是输出目录下的文件的详细信息、而它刚好就是这个文件,所以输出内容是难辨彼此。

现在关键点就聚焦在一个项到底是文件还是目录了,可能有的人会说,用 TYPE 字段呗,然而上面的例子中 TYPE 都是 file 的区分不出来。当然啦,一般场景下目录会有多个文件,totoal size > 1  的话必然是一个目录,只是这样并不严谨。现在考虑一下 bst 工具中还有什么能帮到我,除了 ls 外就剩 put / get / delete,三种了,put 是我们要执行的命令,delete 是万万不行滴,于是考查一下 get 呢:

$ bst_tool get /data/data
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/data
$ bst_tool get /data
当前用户:yunhai
no file named 'data' found in directory '/'

 

哈哈,真金不怕火炼,这一下目录露馅了 —— get 只能下载文件。可以通过试下载到临时目录来判断到底是文件还是目录,这不失为一个终极手段。于是就有了下面这段脚本:

 1 # $1: dest path
 2 #
 3 # return value:
 4 # 0: not exist
 5 # 1: file exist
 6 # 2: dir exist but empty
 7 # 3: dir exist and not empty
 8 bsttool_query_path()
 9 {
10     local dst=$1
11     local res=$(bst_tool ls "$dst")
12     local line=$(echo "$res" | sed -n '2p')
13     local str=${line/"total size"//}
14     local download="/tmp/bst.tmp"
15     local ret=0
16     if [ "$str" != "$line" ]; then 
17         # normal return
18         ret=$(echo "$line" | awk '{print $NF}')
19         if [ $ret -eq 0 ]; then 
20             ret=2
21         elif [ $ret -gt 1 ]; then 
22             ret=3 # a dir
23         else # size=1
24             # note here we can NOT using type file/dir to determine, 
25             # for example, there is a path /tmp/tmp pointed to a file
26             # when we ls /tmp, it report one item named 'tmp' with type file,
27             # but actually it is a dir.
28             # so here we use another way to determine, that is, download it, 
29             # you can NOT download a dir.
30             echo "dir or file exist, try download to check"
31             rm "$download" > /dev/null 2>&1 # otherwise bst_tool will fail
32             line=$(bst_tool get "$dst" "$download" | sed -n '2p')
33             str=${line/"download success"//}
34             if [ "$str" != "$line" ]; then # string replace changed: download success
35                 # is a file
36                 ret=1
37             else 
38                 # is a dir
39                 ret=3
40             fi
41         fi
42     else
43         # server response error, no such path
44         ret=0
45     fi
46 
47     return $ret
48 }

下面做个简单说明:

  • line 13,16 : 判断是否访问 server 出错
  • line 18:提取 total size 大小
  • line 19-20:为 0 表示不存在或空目录
  • line 21-22:大于 1 为非空目录
  • line 31-32:试下载,为防止下载冲突,提前清理临时文件
  • lline 33:提取 download success 关键信息
  • line 34-36: 下载成功,文件
  • line 37-39:下载失败,目录
  • line 43-44:server 访问出错,可以认为文件不存在

注意这里验证一行中是否包含特定字符串的方法,使用了 shell 字符串替换语法:$(line/"character string"//),如果找到并替换成功 (替换为空其实就是删除),得到的字符串肯定会和原串不同;否则没有变化。

上面这些逻辑封装成 shell 函数 bsttool_get_path,调用时提供一个路径参数、然后根据返回值来判断一个路径的属性。不过这个算法有个不太好的地方,就是遇到上面那种分辨不清的场景而待检查文件又特别大时,需要消耗不少无谓的网络带宽和时间,但是也没有办法,工具本身提供的功能和信息太少了,我们这边可以做的优化就是:不要一上来就用这个试下载去判断,而是其它方法都已经穷尽、迫不得已时才请它出山。

覆盖上传

能确认文件属性后,就可以开始正文了,再回顾一下上一节中待上传文件可能的四种状态及对覆盖上传的影响:

  • 文件存在:提示用户
  • 文件或目录不存在:直接上传
  • 目录存在但为空:直接上传,最终文件将位于目录下
  • 目录存在且不为空:需要继续判断目录下有无同名文件
    • 文件存在:提示用户
    • 文件或目录不存在:直接上传
    • 目录存在:直接上传,最终将和该目录并列位于父目录之下

 

 1 # $1: dest path
 2 #
 3 # return value
 4 # 0: user allow and delete existing remote file
 5 # 1: user disallow and do nothing
 6 bsttool_duplicate_warning()
 7 {
 8     local resp
 9     echo -n "dest file exist, do you want to cover it? (n/y) "
10     read resp
11     case "$resp" in 
12         "y"|"Y")
13             # remove remote reource first
14             bst_tool delete "$1"
15             return 0
16             ;;
17         *)
18             return 1
19             ;;
20     esac
21 }
22 
23 # $1: local file path
24 # $2: remote file path (optional)
25 bsttool_replace ()
26 {
27     if [ $# -lt 1 ]; then 
28         echo "Usage: bstput src [dst]"
29         return 1
30     fi
31 
32     local src=$1
33     local dst="/"
34     if [ $# -gt 1 ]; then 
35         dst=$2
36     fi
37 
38     # first check dest existence
39     local resp
40     local name=${src##*/} # remove path part, want name only
41     if [ -z "$name" ]; then 
42         name="$src"
43     fi
44 
45     local path=$dst/$name
46     bsttool_query_path "$dst"
47     local res=$?
48     case $res in
49         0)
50             # not exist
51             echo "dir/file not exist, start uploading"
52             ;;
53         1)
54             # file exist
55             bsttool_duplicate_warning "$dst"
56             if [ $? -ne 0 ]; then 
57                 return 1
58             fi
59             ;;
60         2)
61             # empty dir exist
62             echo "dir exist but empty, start uploading"
63             ;;
64         3)
65             # dir exist and not empty
66             # try file again
67             echo "dir exist, try file"
68             bsttool_query_path "$path"
69             res=$?
70             if [ $res -eq 1 ]; then 
71                 # dest file exist, warning...
72                 bsttool_duplicate_warning "$path"
73                 if [ $? -ne 0 ]; then 
74                     return 1
75                 fi
76             fi
77             ;;
78         *)
79             echo "should not reach here !!"
80             return 1
81             ;;
82     esac
83 
84     bst_tool put "$src" "$dst"
85     return 0
86 }

 下面做个简单说明:

  • line 1-21:冲突提醒,并获取用户输入,如果用户确认覆盖,则在 put 前调用 delete 删除之;
  • line 27-36: 进入正文,检查并获取输入参数;
  • line 38-47: 检查目标文件是否存在及属性;
  • line 49-52: 不存在,可以上传;
  • line 53-59: 存在,调提醒函数获取用户输入,如果用户拒绝覆盖,退出;否则继续;
  • line 60-63:目录存在但为空,可以上传;
  • line 64-69:目录存在且不为空,继续判断子目录;
  • line 70-76:子目录中存在同名文件,调提醒函数获取用户输入,如果用户拒绝覆盖,退出;否则继续;
  • line 84: 如果能走到这里,说明前面没有文件名称冲突、或用户同意覆盖文件且后台文件已被清理,执行上传。

为了简化调用,还可以使用 alias 重命名上面的 shell function:

# alias
alias bstput=bsttool_replace
alias bstget='bst_tool get'
alias bstdel='bst_tool delete'
alias bstls='bst_tool ls'

 

将上面的脚本保存在 ~/.bash_bst 下并在 shell 配置文件中写入这样一行配置:

source ~/.bash_bst

这样我就可以在命令行使用 bstxx 系列命令代替笨重的 bst_tool xxx 了 (后者仍可用),而且这套 alias 拓展了原命令的功能,使用 bstput 就可以实现覆盖上传啦,下面是执行效果:

$ bstput data /data
dir or file exist, try download to check
dir exist, try file
dir or file exist, try download to check
dest file exist, do you want to cover it? (n/y) y
当前用户:yunhai
deleting the 1th filePath: /data/data
delete result: 成功
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 419402686
  FilePath: /data/data
  Time: 2021-05-14 19:49:20

 

将本地的 data 文件上传到后台 /data,而后台现在已经有 /data/data 的文件,所以这里判断出来有冲突,提示用户是否覆盖,得到授权后,删除后台文件后上传成功。

整个过程调用了两次 bsttool_query_path,第一次针对 /data (total size == 1),确定它是一个目录;第二次针对 /data/data (total size == 1),确定它是一个文件。

覆盖下载

覆盖下载就相对简单多了,因为要判断是否重复的文件位于本地,可以动用的手段就丰富了。下面直接上脚本:

 1 # $1: local path
 2 #
 3 # return value
 4 # 0: user allow and delete existing local file
 5 # 1: user disallow and do nothing
 6 bsttool_existence_warning()
 7 {
 8     local resp
 9     echo -n "local file exist, do you want to cover it? (n/y) "
10     read resp
11     case "$resp" in 
12         "y"|"Y")
13             # remove local reource first
14             rm "$1"
15             return 0
16             ;;
17         *)
18             return 1
19             ;;
20     esac
21 }
22 
23 
24 # $1: remote file path
25 # $2: local file path (optional)
26 bsttool_fetch ()
27 {
28     if [ $# -lt 1 ]; then 
29         echo "Usage: bstget remote [local]"
30         return 1
31     fi
32 
33     local remote=$1
34     local name=${remote##*/} # remove path part, want name only
35     if [ -z "$name" ]; then 
36         name="$remote"
37     fi
38 
39     local local="./"
40     if [ $# -gt 1 ]; then 
41         local=$2
42     fi
43 
44     # check dest existence
45     if [ -e "$local" ]; then 
46         if [ -d "$local" ]; then 
47             # for dir, test sub file existence
48             if [ -e "$local/$name" -a ! -d "$local/$name" ]; then 
49                 # dest file exist, warning...
50                 bsttool_existence_warning "$local/$name"
51                 if [ $? -ne 0 ]; then 
52                     return 1
53                 fi
54             fi
55         else 
56         #elif [ -f "$local" ]; then
57             # dest file exist, warning...
58             bsttool_existence_warning "$local"
59             if [ $? -ne 0 ]; then 
60                 return 1
61             fi
62         fi
63     fi
64 
65     bst_tool get "$remote" "$local"
66     return 0
67 }

下面做个简单说明:

  • line 6-21:本地文件存在时输出的警告信息,如果用户同意覆盖,调用 rm 移除本地同名文件并返回 0,否则返回 1;
  • line 28-42:进入正文,检查并获取输入参数,当用户未提供本地路径或提供的本地路径是个目录时,需要取远程文件名作为本地文件名,所以这里有截取本地路径名的操作;
  • line 45:检查本地文件是否存在,注意这里使用 -e 来检查所有文件类型;
  • line 46:本地目录存在,继续检查目录;
  • line 48-54:目录下有同名文件存在 (如果是目录则没关系,可以共存),调提醒函数获取用户输入,如果用户拒绝覆盖,退出;否则继续;
  • line 57-61:本地文件存在,调提醒函数获取用户输入,如果用户拒绝覆盖,退出;否则继续;
  • line 65:执行下载,此时不会冲突。

同 bstput,加入以下内容来简化命令调用:

alias bstget=bsttool_fetch

下面是执行效果:

$ bstget /data/data 
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/data
$ bstget /data/data data
local file exist, do you want to cover it? (n/y) n
$ bstget /data/data test
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/test/data
$ bstget /data/data test
local file exist, do you want to cover it? (n/y) n
$ bstget /data/data test/data
local file exist, do you want to cover it? (n/y) y
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/test/data

第一次下载时都是没有冲突的,当再次下载时就会提醒了,可以看到针对文件、目录的情况都能正常处理。

递归列出

软柿子捏完了,再来看递归列出、递归下载。它们能否实现的关键就在于能否区分远程路径为目录,因为对目录需要递归调用 shell 函数做遍历。这里如果使用之前判断远程文件属性的 bsttool_query_path 函数就有点儿太重了,其实使用 TYPE 字段就足够了,因为我们只是将文件罗列出来就够了,不需要上传或下载。那如何获取文件的 TYPE 字段呢?让我们先来看一下 bst_tool ls 的输出:

$ bstls /ollvm
当前用户:yunhai
total size:  12
│──────────────│──────────│──────────│─────────────────────│──────│─────────────────────│
│ FILEID (12)  │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME            │
│──────────────│──────────│──────────│─────────────────────│──────│─────────────────────│
│ 423324279    │ yunhai   │ 289.00B  │ 2021-05-24 14:28:33 │ file │ build_glibc.sh      │
│ 423324280    │ yunhai   │ 15.73MB  │ 2021-05-24 14:28:33 │ file │ cmake-3.20.0.zip    │
│ 423324281    │ yunhai   │ 409.00B  │ 2021-05-24 14:28:33 │ file │ build_cmake.sh      │
│ 423324283    │ yunhai   │ 14.28KB  │ 2021-05-24 14:28:33 │ file │ bash_bst.txt        │
│ 423324284    │ yunhai   │ 820.00B  │ 2021-05-24 14:28:33 │ file │ create_user.sh      │
│ 423324278    │ yunhai   │ 2.85MB   │ 2021-05-24 14:28:33 │ file │ global-6.6.5.tar.gz │
│ 423324285    │ yunhai   │ 535.00B  │ 2021-05-24 14:28:33 │ file │ init_bst.sh         │
│ 405005475    │ yunhai   │          │ 2021-04-07 20:49:02 │ dir  │ tutor               │
│ 405004793    │ yunhai   │          │ 2021-04-07 20:41:01 │ dir  │ 81                  │
│ 404965309    │ yunhai   │          │ 2021-04-07 18:19:08 │ dir  │ 8                   │
│ 404943904    │ yunhai   │          │ 2021-04-07 17:46:10 │ dir  │ 4                   │
│ 404938470    │ yunhai   │          │ 2021-04-07 17:37:02 │ dir  │ common              │
│──────────────│──────────│──────────│─────────────────────│──────│─────────────────────│

 

可以看到目录的 TYPE 为 dir,普通文件为 file;除了这两个字段,为了给用户列出文件详情,我们还需要获取 FILENAME / FILESIZE / FILEID 三个字段,为此最好是一次性从输出中获取它们,以分隔符划分并获取各个字段的办法,我第一个想到的就是 awk:

$ bstls /ollvm | sed -n '6p' | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, $4, $6, $7)}'
 423324279    	 289.00B  	 file 	 build_glibc.sh 

 

这段脚本先提取输出中的一行 (sed -n '6p'),这里数据从第6行开始,所以必需大于等于6;然后使用竖线分隔该行各个字段并通过 $n 打印需要的字段,注意这里的竖线不是普通的 ‘|’,而是更大更长的竖线,这个我真不知道怎么从键盘上敲出来,最后还是从 bst 的输出中复制的才搞定。由于我们只关心第 1 / 3 / 5 / 6 列,所以下标选择了 2 / 4 / 6 / 7,这是因为分隔后下标为 1 的第一列对应是个空列,需要跳过。如果我们用这个脚本跑一下目录,它能否正确输出呢?

$ bstls /ollvm | sed -n '14p' | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, $4, $6, $7)}'
 405004793    	          	 dir  	 81 

 

第一个目录位于 14 行,所以需要更新 sed 参数为 14p,后面的不变。可以看到由于目录没有 FILESIZE 字段,导致输出后只有三项,这样一来当我们继续提出的时候,就会少了一列,和上面文件的格式不统一了,有什么办法可以为空字段补零的吗?答案是使用 awk 的条件判断语句:

$ bstls /ollvm | sed -n '14p' | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, match($4, /[a-zA-Z0-9.]+/)?$4:"0", $6, $7)}'
 405004793    	0	 dir  	 81  

 

awk 中的可以用 match 表达式来进行正则匹配,如果第 4 列满足文件大小的正则表达式 ([a-zA-Z0-9.]+),那么就使用对应的字段,否则使用 0 代替。可以看到新的输出中包含了4 个字段,第 2 个字段正确的补零了。ok,有了这个基础,再怎么将它们赋值给 shell 的变量呢?最简单的办法,还是使用 awk,将想要赋值的字段 print 出来,类似这样:

$ filesize=`bstls /ollvm | sed -n '7p' | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, match($4, /[a-zA-Z0-9.]+/)?$4:"0", $6, $7)}' | awk '{printf $2}'`
$ echo $filesize
15.73MB

 

这次以第 7 行的文件数据为例,将之前的 awk 输出再经过 awk 过滤一次,得到的值赋值给 shell 变量,再打印变量即可,可以看到打印出的结果是符合预期的。由于这里使用的是默认的空格和 TAB 键分隔,所以不需要特别指定 awk 的分隔符,从这里也可以看出来上面对目录大小为空的处理是必要的,不然空列会直接被忽略,后面的字段就对不上了。但是这样一个一个  print 的缺点是效率太低了,提取 4 个字段就需要执行 4 次赋值,有没有办法一次提取 4 个字段呢?答案就是使用 eval:

$ eval `bstls /ollvm | sed -n '7p' | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, match($4, /[a-zA-Z0-9.]+/)?$4:"0", $6, $7)}' | awk '{printf ("fid=%s;size=%s;type=%s;name=%s;\n", $1,$2,$3,$4)}'`
$ echo -e "fid=$fid\tsize=$size\ttype=$type\tname=$name"
fid=423324280	size=15.73MB	type=file	name=cmake-3.20.0.zip

 

eval 命令接收一个字符串,并将这个串按 shell 语法去解释并应用于当前 shell;所以问题的关键变成如何构造一个 shell 语句来实现多个字段的同时赋值,于是就有了后面这个 awk 语句:

awk '{printf ("fid=%s;size=%s;type=%s;name=%s;\n", $1,$2,$3,$4)}'

 

它将我们之前得到的输出以 shell 变量赋值的方式输出出来,再交给 eval 去‘评估’,这样就完成了整个赋值过程,于是我们看到,在后面 echo 语句中打印这些变量时,得到了正确的输出。 ok,有了这些做铺垫就可以正式亮出递归列出的代码了:

 1 # $1: remote path
 2 # $2: depth
 3 bsttool_list_recur ()
 4 {
 5     local remote=$1
 6     local depth=$(($2+1))
 7     local res=$(bst_tool ls "$remote")
 8     local line=$(echo "$res" | sed -n '2p')
 9     local str=${line/"total size"//}
10     local ret=0
11     if [ "$str" != "$line" ]; then 
12         # normal return
13         ret=$(echo "$line" | awk '{print $NF}')
14     else
15         # server response error
16         ret=0
17     fi
18 
19     # 6: skip header
20     local max=$(($ret + 6 - 1))
21     local fid=""
22     local size="" # has K/M/G postfix
23     local type=""
24     local name=""
25     echo "$remote [$depth-$ret]:"
26     for n in $(seq 6 $max)
27     do
28         # why sed -n "$np" don't work ?
29         line=$(echo "$res" | sed -n "$n"'p')
30         # row: fid, user, size, timestamp, type, name
31         # if size is empty (for dir, all spaces), using 0 instead
32         # do awk twice to remove redundant space
33         #
34         # add quotation for assignment is important, otherwise we will get errors on bracket (etc) in value
35         eval $(echo "$line" | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, match($4, /[a-zA-Z0-9.]+/)?$4:"0", $6, $7)}' | awk '{printf ("fid=\"%s\";size=\"%s\";type=\"%s\";name=\"%s\";\n", $1,$2,$3,$4)}')
36         if [ "$type" == "dir" ]; then 
37             # do for sub dirs
38             local path=""
39             if [ ${remote:0-1} == "/" ]; then 
40                 path="$remote$name"
41             else
42                 path="$remote/$name"
43             fi
44 
45             bsttool_list_recur "$path" $depth
46         else
47             echo -e "$fid:\t$size,\t$name"
48         fi
49     done
50 
51     return 0
52 }
53 
54 # $1: remote path
55 bsttool_list_all ()
56 {
57     local remote="/"
58     if [ $# -gt 0 ]; then 
59         remote=$1
60     fi
61 
62     bsttool_list_recur "$remote" 0 
63 }

下面做个简单说明:

  • line 7:调用 bst_tool ls 列出远程根目录;
  • line 8-17:获取输出文件项数量,如果后台应答有错误,则按没有输出处理;
  • line 20-26:声明变量,跳过表头,准备遍历文件项;
  • line 29:截取某一行文件数据;
  • line 35:获取该行中各个字段并赋值给变量;
  • line 36-45:对于目录项,递归列出其中文件;
  • line 47:对于文件项,直接列出详情;
  • line 55-63:处理初始情况 (确定根目录、给定深度),开始递归。

同上,加入以下内容来简化命令调用:

alias bstrls=bsttool_list_all

在 bst 和 ls 之间有一个 'r' 字符,表示递归。下面是执行效果:

$ bstrls /ollvm
/ollvm [1-12]:
423324279:	289.00B,	build_glibc.sh
423324280:	15.73MB,	cmake-3.20.0.zip
423324281:	409.00B,	build_cmake.sh
423324283:	14.28KB,	bash_bst.txt
423324284:	820.00B,	create_user.sh
423324278:	2.85MB,	global-6.6.5.tar.gz
423324285:	535.00B,	init_bst.sh
/ollvm/tutor [2-5]:
405006643:	1.32KB,	build_ollvm.sh
405005476:	819.00B,	build_skeleton.sh
404994390:	310.00B,	test.c
403551141:	248.28KB,	llvm-pass-tutorial-dev.zip
403531758:	24.79KB,	llvm-pass-tutorial.tar.gz
/ollvm/81 [2-5]:
405073802:	2.38KB,	copy_ollvm81.sh
405004794:	1.81KB,	build_llvm81.sh
404314927:	44.50KB,	PassManagerBuilder.cpp
403560689:	12.22MB,	cfe-8.0.1.src.tar.xz
403560381:	29.07MB,	llvm-8.0.1.src.tar.xz
/ollvm/8 [2-3]:
406677215:	1.66KB,	copy_ollvm8.sh
406677122:	531.00B,	build_ollvm8.sh
403203783:	83.16MB,	obfuscator-llvm-8.0.zip
/ollvm/4 [2-3]:
404951254:	1.30KB,	copy_ollvm4.sh
404943908:	320.00B,	build_ollvm4.sh
402300052:	180.29MB,	obfuscator.tar.gz
/ollvm/common [2-7]:
406687742:	3.45KB,	compare_instructions.sh
406687716:	1.21KB,	inject_so.sh
406678282:	899.00B,	clone_netdisk.sh
406678068:	401.00B,	download_ndk.sh
404942152:	181.00B,	build_p2psrc.sh
404940645:	748.00B,	clone_p2psrc.sh
402360890:	819.91MB,	ndkr20.zip

对于目录,没有列出 FILEID 和 FILESIZE 字段,代之以目录深度和文件数量 ([x-x]);文件项的话个人觉得反而比原版命令清爽一些,嗯就这样了。使用 bstrls + grep,查找一个文件在不在后台就变得容易多了,不过总体执行速度堪忧,考虑到一个命令下去,底层执行了 N 多次 bst_tool 命令,情有可原~

递归下载

剩下最后一根硬骨头了,不过有了递归列出的基础,递归下载也没那么难了。与覆盖下载遇到相同的问题是需要提前判断文件是否已经在本地存在,防止意外覆盖数据;不同的点是,递归下载可能会有多次覆盖提醒,如果每次都要让用户选择,那也不是不行,毕竟有 yes 这种工具,不过这种工具是提前设置好了 yes 或 no 的选项,没有办法随机应变,想要灵活性与便利性都具备,最好还是自己处理选项,最简单的办法是用大写字母表示应用全部,小写表示只应用一次:

  • y:允许一次
  • Y:全部允许
  • n:禁止一次
  • N:全部禁止

那么这个选项变量能作为 shell 函数的参数传递吗?答案是不能,因为虽然可以向下传递,但是如果用户在深层调用中改变了选择,这个变化却不能向上传递,毕竟 shell 函数参数只能作为输入参数,不能像 c / c++ / java 那种高级程序设计语言一样可以有输出参数,如果使用 shell 函数的 return 语句作为输出,那么它本身的返回码又不能用了。综合考虑,最后用户输入的选项作为全局变量,在递归开始前设置为默认值,在递归过程中改变,以便影响后续的判断过程。

另外一个不同点是,随着目录的深入、函数的递归,需要维护好当前工作目录,保证每次创建文件时所处的目录是正确的,当目录遍历结束返回上层时 (或函数递归调用结束返回调用点),需要返回上级目录。对于遍历过程,直接 cd .. 就可以了,但是对于首次进入递归,需要保存当前工作目录 (PWD),因为下载目录可能是个多级目录,一次 cd .. 是回不来的。

  1 # global variable
  2 # we can NOT pass these as recursive function parameters, 
  3 # as shell function can NOT pass modification back to callee.
  4 #
  5 # cover flag (1:no once 2:no all 3:yes once 4:yes all)
  6 g_cover_flag=1
  7 
  8 # return value
  9 # 0: invalid input
 10 # 1: no once
 11 # 2: no all
 12 # 3: yes once
 13 # 4: yes all
 14 bsttool_duplicate_dir_warning()
 15 {
 16     local resp
 17     echo -n "dest file/dir exist, do you want to cover it? (n/N/y/Y) "
 18     read resp
 19     case "$resp" in 
 20         "n")
 21             return 1
 22             ;;
 23         "N")
 24             return 2
 25             ;;
 26         "y")
 27             return 3
 28             ;;
 29         "Y")
 30             return 4
 31             ;;
 32         *)
 33             return 0
 34             ;;
 35     esac
 36 }
 37 
 38 # $1: remote path
 39 # $2: local path (optional)
 40 bsttool_get_all ()
 41 {
 42     if [ $# -lt 1 ]; then 
 43         echo "Usage: bstrget remote [local]"
 44         return 1
 45     fi
 46 
 47     local remote=$1
 48     local local=""
 49     local type=0
 50     g_cover_flag=1 # no once
 51     local dirold=""
 52     if [ $# -gt 1 ]; then 
 53         local=$2
 54     else
 55         # get name part
 56         local tmp=""
 57         if [ ${remote:0-1} == "/" ]; then 
 58             # cut tailing '/'
 59             tmp="${remote:0:$((${#remote}-1))}"
 60         else
 61             tmp="$remote"
 62         fi
 63 
 64         local="${tmp##*/}"
 65     fi
 66 
 67     if [ -z "$local" -o "$local" == "/" ]; then 
 68         # default name
 69         local="setup"
 70     fi
 71 
 72     bsttool_query_path "$remote"
 73     type=$?
 74     case $type in
 75         0)
 76             # not exist
 77             echo "remote not exist"
 78             return 1
 79             ;;
 80         1)
 81             # file exist
 82             # do nothing, leave file to bsttool_get_recur
 83             ;;
 84         2)
 85             # empty dir exist
 86             echo "nothing to download"
 87             return 1
 88             ;;
 89         3)
 90             # dir exist
 91             if [ ! -d "$local" ]; then 
 92                 mkdir -p "$local"
 93             else
 94                 bsttool_duplicate_dir_warning
 95                 g_cover_flag=$?
 96                 # invalid or no (all)
 97                 if [ $g_cover_flag -eq 0 -o $g_cover_flag -eq 1 -o $g_cover_flag -eq 2 ]; then
 98                     return 1
 99                 fi
100             fi
101 
102             dirold="$PWD"
103             cd "$local"
104             ;;
105         *)
106             echo "should not reach here !"
107             return 1
108             ;;
109     esac
110 111     bsttool_get_recur "$remote" "$local" 0
112     if [ ! -z "$dirold" ]; then 
113         cd "$dirold"
114     fi
115     return 0
116 }

下面做个简单说明:

  • line 6:全局用户覆盖选项变量;
  • line 14-36:用于获取用户选择的提醒函数;
  • line 42-70:进入正文,获取并检查输入参数,如果没有提供本地下载路径,默认为当前目录;文件名为远程路径的文件名部分,如果远程路径为根目录,本地下载目录默认为 setup;
  • line 72-73:查询远程路径属性,bsttool_query_path 在之前的覆盖上传中有过介绍;
  • line 75-79:远程路径不存在,直接退出,退出码为 1;
  • line 80-83:远程文件存在,什么也不做,留给后面的递归函数处理;
  • line 84-88:远程目录为空,直接退出,退出码为 1;
  • line 89-104:远程目录存在且不为空,如果本地路径不存在,递归创建之;否则提示用户是否覆盖该目录,如果用户选择否,直接退出,退出码为 1。如果用户选择覆盖或目录是新创建的,则记录旧目录,切换到下载目录,准备开始递归下载;
  • line 105-108:后台出错,直接退出,退出码为 1;
  • line 111:启动递归下载;
  • line 112-114:恢复初始工作目录。

重头戏都放在了 bsttool_get_recur 中:

 1 # $1: remote path
 2 # $2: local path
 3 # $3: depth
 4 bsttool_get_recur ()
 5 {
 6     local remote=$1
 7     local local=$2
 8     local depth=$(($3+1))
 9 
10     local res=$(bst_tool ls "$remote")
11     local line=$(echo "$res" | sed -n '2p')
12     local str=${line/"total size"//}
13     local ret=0
14     if [ "$str" != "$line" ]; then 
15         # normal return
16         ret=$(echo "$line" | awk '{print $NF}')
17     else
18         # server response error
19         ret=0
20     fi
21 
22     # 6: skip header
23     local max=$(($ret + 6 - 1))
24     local fid=""
25     local size="" # has K/M/G postfix
26     local type=""
27     local name=""
28     local path=""
29     local filesize=0
30     echo "$remote [$depth-$ret]:"
31     for n in $(seq 6 $max)
32     do
33         # why sed -n "$np" don't work ?
34         line=$(echo "$res" | sed -n "$n"'p')
35         # row: fid, user, size, timestamp, type, name
36         # if size is empty (for dir, all spaces), using 0 instead
37         # do awk twice to remove redundant space
38         eval $(echo "$line" | awk -F'│' '{printf ("%s\t%s\t%s\t%s\n", $2, match($4, /[a-zA-Z0-9.]+/)?$4:"0", $6, $7)}' | awk '{printf ("fid=%s;size=%s;type=%s;name=%s;\n", $1,$2,$3,$4)}')
39         if [ "$type" == "dir" ]; then 
40             if [ -d "$name" ]; then 
41                 echo "dir exist: $name"
42                 if [ $g_cover_flag -eq 1 -o $g_cover_flag -eq 3 ]; then 
43                     bsttool_duplicate_dir_warning
44                     g_cover_flag=$?
45                 fi
46 
47                 # do NOT rm dirs even if user select cover it !
48                 # just merge files into it ..
49                 #
50                 # if not yes once or all, then nothing to do
51                 if [ $g_cover_flag -ne 3 -a $g_cover_flag -ne 4 ]; then 
52                     echo "nothing to do"
53                     continue
54                 fi
55             else
56                 mkdir "$name"
57             fi
58 
59             cd "$name"
60             # do for sub dirs
61             if [ ${remote:0-1} == "/" ]; then 
62                 path="$remote$name"
63             else
64                 path="$remote/$name"
65             fi
66 
67             bsttool_get_recur "$path" "$name" $depth
68             cd ..
69         else
70             echo -e "$fid:\t$size,\t$name"
71             if [ -f "$name" ]; then 
72                 echo "file exist: $name"
73                 if [ $g_cover_flag -eq 1 -o $g_cover_flag -eq 3 ]; then 
74                     bsttool_duplicate_dir_warning
75                     g_cover_flag=$?
76                 fi
77 
78                 if [ $g_cover_flag -eq 3 -o $g_cover_flag -eq 4 ]; then 
79                     echo "remove old file before download"
80                     rm "$name"
81                 else
82                     # no need to download
83                     echo "nothing to do"
84                     continue 
85                 fi
86             fi
87 
88             if [ ${remote:0-1} == "/" ]; then 
89                 path="$remote$name"
90             else
91                 path="$remote/$name"
92             fi
93 
94             bst_tool get "$path" "$name"
95         fi
96     done
97 
98     return 0
99 }

前面获取信息这部分和递归列出非常类似,就不赘述了,下面主要对下载这一部分做个简单说明 (line 39+):

  • line 39-54:如果是目录,且本地已存在,且用户之前未选择全部应用 (no once 或 yes once),则提醒用户是否覆盖,如果用户选择了不覆盖,或者之前用户选择的是 no all,则跳过该目录,继续处理下个文件项;
  • line 56:如果目录不存在,则创建之;
  • line 59-68:切换到新目录,构建新的路径,调用自身递归处理目录中的文件项,处理完毕后回退到上级目录;
  • line 70-86:输出下载文件的详细信息,如果文件已存在,且用户之前未选择全部应用 (no once 或 yes once),则提示用户是否覆盖文件,如果用户选择了不覆盖,或者之前用户选择的是 no all,则跳过该文件,继续处理下个文件项。否则删除本地同名文件,防止之后下载时产生冲突;
  • line 88-94:构建下载文件路径,启动 bst_tool 下载文件。

在使用这个工具做文件备份的时候,我发现一个新的需求点,就是我只希望备份自己写的脚本文件,一些安装包、压缩包等较大的文件可以从网上下载,没必要备份,但是这个工具一次性全下载下来了,既占用空间,又浪费带宽。自然而然想到的解决方案就是通过文件尺寸来过滤下载项,只有小于阈值的文件才被下载,和覆盖选项一样,我们希望它能在灵活性和便利性上能达到兼顾,于是依葫芦画瓢,再整一个下载限制的选项:

 1 # global variable
 2 # we can NOT pass these as recursive function parameters, 
 3 # as shell function can NOT pass modification back to callee.
 4 #
 5 # cover flag (1:no once 2:no all 3:yes once 4:yes all)
 6 g_cover_flag=1
 7 # size limit flag (1:no once 2:no all 3:yes once 4:yes all)
 8 g_limit_flag=1
 9 g_size_limit=1 # MB
10 
11 # return value
12 # 0: invalid input
13 # 1: no once
14 # 2: no all
15 # 3: yes once
16 # 4: yes all
17 bsttool_huge_file_warning()
18 {
19     local resp
20     echo -n "do you want to continue download this large file? (n/N/y/Y) "
21     read resp
22     case "$resp" in 
23         "n")
24             return 1
25             ;;
26         "N")
27             return 2
28             ;;
29         "y")
30             return 3
31             ;;
32         "Y")
33             return 4
34             ;;
35         *)
36             return 0
37             ;;
38     esac
39 }
40 
41 # $1: remote path
42 # $2: local path (optional)
43 bsttool_get_all ()
44 {
45     ……
46     g_limit_flag=1 # no once
47     g_size_limit=$((1024*1024)) # in bytes
48     bsttool_get_recur "$remote" "$local" 0
49     if [ ! -z "$dirold" ]; then 
50         cd "$dirold"
51     fi
52 }

它由两个选项组成,其中 g_limit_flag 表示用户的选择,通过调用 bsttool_huge_file_warning 询问用户获取,后者和覆盖选项几乎完全相同; g_size_limit 表示文件尺寸阈值,这个目前固定为 1 MB,后续可更改为通过环境变量设置。有了变量的定义和初值,就可以在递归函数中进行判断了:

 1             echo -e "$fid:\t$size,\t$name"
 2             # contains KB?
 3             local kfactor=${size/"KB"//}
 4             if [ "$kfactor" != "$size" ]; then
 5                 kfactor=1024
 6             else
 7                 kfactor=1
 8             fi
 9 
10             # contains MB?
11             local mfactor=${size/"MB"//}
12             if [ "$mfactor" != "$size" ]; then
13                 mfactor=$((1024*1024))
14             else
15                 mfactor=1
16             fi
17 
18             # contains GB?
19             local gfactor=${size/"GB"//}
20             if [ "$gfactor" != "$size" ]; then
21                 gfactor=$((1024*1024*1024))
22             else
23                 gfactor=1
24             fi
25 
26             # should we support TB?
27             filesize=$(awk -v val="$size" -v k="$kfactor" -v m="$mfactor" -v g="$gfactor" 'BEGIN{ printf ("%d", strtonum(val)*k*m*g) }')
28             if [ $filesize -gt $g_size_limit ]; then 
29                 echo "file too huge: $g_limit_flag"
30                 if [ $g_limit_flag -eq 1 -o $g_limit_flag -eq 3 ]; then
31                     bsttool_huge_file_warning
32                     g_limit_flag=$?
33                 fi
34             
35                 # if not yes once or all, then nothing to do
36                 if [ $g_limit_flag -ne 3 -a $g_limit_flag -ne 4 ]; then 
37                     # no need to download
38                     echo "nothing to do"
39                     continue
40                 fi
41             fi

下面做个简单说明:

  • line 1:当下载的文件类型为普通文件时,已经提取到了文件的尺寸信息,不过这个信息是以各种不同单位结尾的字符串,单位有 KB/MB/GB;
  • line 2-27:将它们统一转换为字节为单位,这里使用 awk strtonum 来将字符串转为 double 精度数字,strtonum 会自动忽略不能转换为数字的单位部分。另外通过检查有无单位关键字来确定使用的乘积因子,最后使用 awk 的乘法来获取最终以字节为单位 double 精度的乘积结果。这里有几点需要注意:
    • 使用 strtonum 将字符串转换为数字;
    • 向 awk 传递 shell 变量 (-v);
    • awk 的乘除默认是 double 精度的,反而想要整数乘除结果会比较费劲; 
  •  line 28-41:当文件尺寸换算为字节数超过阈值时,且用户之前未选择全部应用 (no once 或 yes once),则提示用户是否下载大文件,如果用户选择了不下载,或者之前用户选择的是 no all,则跳过该文件,继续处理下个文件项。

同上,加入以下内容来简化命令调用:

alias bstrget=bsttool_get_all

 ok,至此一个比较实用的备份脚本工具做好了,我们用它来备份一下刚才的目录:

$ bstrget /ollvm
/ollvm [1-12]:
423324279:	289.00B,	build_glibc.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/build_glibc.sh
423324280:	15.73MB,	cmake-3.20.0.zip
file too huge: 1
do you want to continue download this large file? (n/N/y/Y) n
nothing to do
423324281:	409.00B,	build_cmake.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/build_cmake.sh
423324283:	14.28KB,	bash_bst.txt
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/bash_bst.txt
423324284:	820.00B,	create_user.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/create_user.sh
423324278:	2.85MB,	global-6.6.5.tar.gz
file too huge: 1
do you want to continue download this large file? (n/N/y/Y) y
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/global-6.6.5.tar.gz
423324285:	535.00B,	init_bst.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/init_bst.sh
/ollvm/tutor [2-5]:
405006643:	1.32KB,	build_ollvm.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/tutor/build_ollvm.sh
405005476:	819.00B,	build_skeleton.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/tutor/build_skeleton.sh
404994390:	310.00B,	test.c
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/tutor/test.c
403551141:	248.28KB,	llvm-pass-tutorial-dev.zip
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/tutor/llvm-pass-tutorial-dev.zip
403531758:	24.79KB,	llvm-pass-tutorial.tar.gz
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/tutor/llvm-pass-tutorial.tar.gz
/ollvm/81 [2-5]:
405073802:	2.38KB,	copy_ollvm81.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/81/copy_ollvm81.sh
405004794:	1.81KB,	build_llvm81.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/81/build_llvm81.sh
404314927:	44.50KB,	PassManagerBuilder.cpp
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/81/PassManagerBuilder.cpp
403560689:	12.22MB,	cfe-8.0.1.src.tar.xz
file too huge: 3
do you want to continue download this large file? (n/N/y/Y) N
nothing to do
403560381:	29.07MB,	llvm-8.0.1.src.tar.xz
file too huge: 2
nothing to do
/ollvm/8 [2-3]:
406677215:	1.66KB,	copy_ollvm8.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/8/copy_ollvm8.sh
406677122:	531.00B,	build_ollvm8.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/8/build_ollvm8.sh
403203783:	83.16MB,	obfuscator-llvm-8.0.zip
file too huge: 2
nothing to do
/ollvm/4 [2-3]:
404951254:	1.30KB,	copy_ollvm4.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/4/copy_ollvm4.sh
404943908:	320.00B,	build_ollvm4.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/4/build_ollvm4.sh
402300052:	180.29MB,	obfuscator.tar.gz
file too huge: 2
nothing to do
/ollvm/common [2-7]:
406687742:	3.45KB,	compare_instructions.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/compare_instructions.sh
406687716:	1.21KB,	inject_so.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/inject_so.sh
406678282:	899.00B,	clone_netdisk.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/clone_netdisk.sh
406678068:	401.00B,	download_ndk.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/download_ndk.sh
404942152:	181.00B,	build_p2psrc.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/build_p2psrc.sh
404940645:	748.00B,	clone_p2psrc.sh
当前用户:yunhai
download success. local path: /home/yunh/code/bstext/ollvm/common/clone_p2psrc.sh
402360890:	819.91MB,	ndkr20.zip
file too huge: 2
nothing to do

输出中高亮的部分表示我实际输入的选择,第一个 15 MB 多的文件 no once;第二个 3 MB 多的文件 yes once;第三个 12 MB 多的文件 no all。之后凡是大于 1 MB 的文件就自动跳过了,可以看到 file too huge 输出的就是。检查下载目录后,确实如此:

$ ls -lhR ollvm
ollvm:
total 3.0M
drwxrwxr-x 2 yunh yunh 4.0K May 24 20:49 4
drwxrwxr-x 2 yunh yunh 4.0K May 24 20:49 8
drwxrwxr-x 2 yunh yunh 4.0K May 24 20:49 81
-rw-rw-r-- 1 yunh yunh  15K May 24 20:49 bash_bst.txt
-rw-rw-r-- 1 yunh yunh  409 May 24 20:49 build_cmake.sh
-rw-rw-r-- 1 yunh yunh  289 May 24 20:48 build_glibc.sh
drwxrwxr-x 2 yunh yunh 4.0K May 24 20:49 common
-rw-rw-r-- 1 yunh yunh  820 May 24 20:49 create_user.sh
-rw-rw-r-- 1 yunh yunh 2.9M May 24 20:49 global-6.6.5.tar.gz
-rw-rw-r-- 1 yunh yunh  535 May 24 20:49 init_bst.sh
drwxrwxr-x 2 yunh yunh 4.0K May 24 20:49 tutor

ollvm/4:
total 8.0K
-rw-rw-r-- 1 yunh yunh  320 May 24 20:49 build_ollvm4.sh
-rw-rw-r-- 1 yunh yunh 1.4K May 24 20:49 copy_ollvm4.sh

ollvm/8:
total 8.0K
-rw-rw-r-- 1 yunh yunh  531 May 24 20:49 build_ollvm8.sh
-rw-rw-r-- 1 yunh yunh 1.7K May 24 20:49 copy_ollvm8.sh

ollvm/81:
total 56K
-rw-rw-r-- 1 yunh yunh 1.9K May 24 20:49 build_llvm81.sh
-rw-rw-r-- 1 yunh yunh 2.4K May 24 20:49 copy_ollvm81.sh
-rw-rw-r-- 1 yunh yunh  45K May 24 20:49 PassManagerBuilder.cpp

ollvm/common:
total 24K
-rw-rw-r-- 1 yunh yunh  181 May 24 20:49 build_p2psrc.sh
-rw-rw-r-- 1 yunh yunh  899 May 24 20:49 clone_netdisk.sh
-rw-rw-r-- 1 yunh yunh  748 May 24 20:49 clone_p2psrc.sh
-rw-rw-r-- 1 yunh yunh 3.5K May 24 20:49 compare_instructions.sh
-rw-rw-r-- 1 yunh yunh  401 May 24 20:49 download_ndk.sh
-rw-rw-r-- 1 yunh yunh 1.3K May 24 20:49 inject_so.sh

ollvm/tutor:
total 292K
-rw-rw-r-- 1 yunh yunh 1.4K May 24 20:49 build_ollvm.sh
-rw-rw-r-- 1 yunh yunh  819 May 24 20:49 build_skeleton.sh
-rw-rw-r-- 1 yunh yunh 249K May 24 20:49 llvm-pass-tutorial-dev.zip
-rw-rw-r-- 1 yunh yunh  25K May 24 20:49 llvm-pass-tutorial.tar.gz
-rw-rw-r-- 1 yunh yunh  310 May 24 20:49 test.c

perfect! 如果我重新执行一遍上面的命令,还可以看到覆盖选项的使用,这里出于篇幅考虑就不再罗列了。

后记

其实还可以实现目录的递归上传功能,技术上不存在任何障碍,只是对我来说意义不大,就没有做。

在测试的过程中, 我还发现一个脚本的 bug,就是当目录中包含两个同名文件时 (一个是普通文件,一个是目录),则在 bst_tool ls name 时,将优先输出目录的内容,和目录是否为空、目录和文件的创建先后顺序都无关。那么在之前 bsttool_query_path 中对远程路径进行判断时,就有可能出问题 (将上传的目标文件理解为目录)。例如有下面的文件结构:

$ bst_tool ls /tmp/
当前用户:yunhai
total size:  2
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ FILEID    │ USERNAME │ FILESIZE │ TIME                │ TYPE │ FILENAME │
│───────────│──────────│──────────│─────────────────────│──────│──────────│
│ 424833297 │ yunhai   │          │ 2021-05-26 19:45:06 │ dir  │ data     │
│ 424829696 │ yunhai   │ 16.11KB  │ 2021-05-26 19:41:06 │ file │ data     │
│───────────│──────────│──────────│─────────────────────│──────│──────────│

 

当我想将文件上传到 /tmp 并且命名为 data 时,我会发现实际上传的路径是 /tmp/data/xxx 而不是覆盖 /tmp/data:

$ bstput foo /tmp/data
dir exist but empty, start uploading
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 424841196
  FilePath: /tmp/data/foo
  Time: 2021-05-26 19:55:53

 

原因就是我上面说的。这个问题对 bst_tool 也存在,使用同样的参数调用 bst_tool put 会得到下面的输出:

$ bst_tool put foo /tmp/data
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 424842183
  FilePath: /tmp/data/foo(1)
  Time: 2021-05-26 19:57:07

 

可见是上传到了同一个位置。那就没有办法上传到 /tmp/data 了吗? 如果将上传文件提前重命名为目标文件名,再将远程路径改为文件的直属目录路径,是不是就可以了呢?

$ mv foo data
$ bstput data /tmp
dir exist, try file
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 424844325
  FilePath: /tmp/data(1)
  Time: 2021-05-26 20:00:12

 

结果是令人失望的,本来期望 bstput 会提醒我们有同名文件冲突是否覆盖呢,结果直接上传并重命名了,这就是我开头说的 bug。让我们来分析一下为什么是这个样子:

  • 当 bsttool_query_path 以 /tmp 为参数进行检查时,发现它是一个非空目录; 
  • 继续检查它下面是不是有名叫 /tmp/data 的文件,之前我们说过,bst_tool ls /tmp/data 时优先列出目录内容,于是我们认为这里有一个同名目录;
  • 由于文件可以和目录同名,于是我们认为不影响,就继续调用 bst_tool put 去做上传;
  • 而实际上传后发现已经有这样一个文件了,于是将上传文件改名。

可以看出来问题的关键就是,当 bst_tool ls xxx 告诉你这是一个目录时,有可能是不成立的。此时还可能存在一个同名的文件,从而引发上传冲突。那这个问题怎么解决呢?好办,其实还是用之前 ls 解决不了时上 get 的套路,如果后台仅有一个目录,此时 bst_tool get xxx 会报错,否则那个同名文件会被下载下来,这样就能知道有没有同名文件了。下面是补丁代码:

 1         # normal return
 2         ret=$(echo "$line" | awk '{print $NF}')
 3         if [ $ret -eq 0 ]; then 
 4             ret=2
 5         elif [ $ret -gt 1 ]; then 
 6             # a dir
 7             #ret=3 
 8             # at this point, we are NOT sure there is no file with same name exist, 
 9             # as bst_tool ls list directory with preference than file, 
10             # so here we need a download try...
11             echo "dir exist, try download to check existence of file with same name"
12             rm "$download" > /dev/null 2>&1 # otherwise bst_tool will fail
13             line=$(bst_tool get "$remote" "$download" | sed -n '2p')
14             str=${line/"download success"//}
15             if [ "$str" != "$line" ]; then # string replace changed: download success
16                 # has same name file
17                 ret=1
18             else 
19                 # only dir
20                 ret=3
21             fi
22         else # size=1
23             # note here we can NOT using type file/dir to determine, 
24             # for example, there is a path /tmp/tmp pointed to a file
25             # when we ls /tmp, it report one item named 'tmp' with type file,
26             # but actually it is a dir.
27             # so here we use another way to determine, that is, download it, 
28             # you can NOT download a dir.
29             echo "dir or file exist, try download to check"
30             rm "$download" > /dev/null 2>&1 # otherwise bst_tool will fail
31             line=$(bst_tool get "$remote" "$download" | sed -n '2p')
32             str=${line/"download success"//}
33             if [ "$str" != "$line" ]; then # string replace changed: download success
34                 # is a file
35                 ret=1
36             else 
37                 # is a dir
38                 ret=3
39             fi
40         fi

 

原来的代码就是 line 7 了,当 bst_tool ls 返回 size 大于 1 后直接判断为目录 (3) 返回; 现在加了 line 11-21,用于继续判断有无同名文件。可以看到补丁代码和已有的 line 29-39 代码非常类似,所以也可以将它们合并在一起,最终得到:

 1         # normal return
 2         ret=$(echo "$line" | awk '{print $NF}')
 3         if [ $ret -eq 0 ]; then 
 4             ret=2
 5         elif [ $ret -gt 1 ]; then 
 6             # a dir
 7             ret=3 
 8             # at this point, we are NOT sure there is no file with same name exist, 
 9             # as bst_tool ls list directory with preference than file, 
10             # so here we need a download try...
11             echo "dir exist, try download to check existence of file with same name"
12         else # size=1
13             ret=3
14             # note here we can NOT using type file/dir to determine, 
15             # for example, there is a path /tmp/tmp pointed to a file
16             # when we ls /tmp, it report one item named 'tmp' with type file,
17             # but actually it is a dir.
18             # so here we use another way to determine, that is, download it, 
19             # you can NOT download a dir.
20             echo "dir or file exist, try download to check"
21         fi
22 
23         if [ $ret -eq 3 ]; then 
24             rm "$download" > /dev/null 2>&1 # otherwise bst_tool will fail
25             line=$(bst_tool get "$remote" "$download" | sed -n '2p')
26             str=${line/"download success"//}
27             if [ "$str" != "$line" ]; then # string replace changed: download success
28                 # is a file
29                 ret=1
30             else 
31                 # is a dir
32                 ret=3
33             fi
34         fi

 

这里为了避免将 size == 0 的场景也包含进来,在 size > 0 (> 1 及 == 1) 时将 ret 的值临时设置为 3,之后再通过下载来判断。下面是加入补丁后脚本的执行情况:

$ bstput data /tmp
dir exist, try download to check existence of file with same name
dir exist, try file
dir exist, try download to check existence of file with same name
remote file exist, do you want to cover it? (n/y) y
当前用户:yunhai
deleting the 1th filePath: /tmp/data
delete result: 成功
当前用户:yunhai
uploading... 100.00 %
upload success:
  FileId: 425166451
  FilePath: /tmp/data
  Time: 2021-05-27 14:56:45

 

可以看到这回能正确的上传了。不过这里也有一些额外代价,看第一行输出,本来检查完 /tmp 是目录就该结束了,但是依照新的逻辑,需要确认没有一个叫 /tmp 的同名文件,于是又去试下载 /tmp,增加了额外的非必要请求。不过权衡正反两个方面,为了正确性做的这点性能牺牲还是值得的。

除了脚本的 bug,其实细心的人已经发现,这个工具及其配套的后台也有问题,就拿最开始企业网盘在浏览器里那张截图来说吧,里面怎么出现了两个同名目录 (/tmp) ? 其实就是我不断的测试,不知怎么着触发了后台的 bug,导致出现了两个一模一样的目录项,在其中一个里面修改内容,另一个也会跟着变化 (很明确不是文件名重复这样简单的问题)。联系过相关负责人,给的结论是这个东西已经停止维护,甚至准备下线了,所以也不再接收新的 bug report,当时差点晕倒,得,将就用吧~

结语

做这个命令扩展脚本花了不少心血,不过可能由于工具本身不是开源的缘故,能拿过来直接用的可能性比较低,甚至想跑一跑都没有环境 (与公司帐号系统绑定)。不过原理都是相通的,脚本本身可以做为一种参考,这里给出脚本的 github 地址供观摩:

git@github.com:goodpaperman/bstext.git

如果有幸能跑起来 (说明咱们是一个公司的?),可以定义以下环境变量来改变脚本的行为:

  • BST_TOOL_VERBOSE:打开调试输出,可以看到更多中间细节; 
  • BST_TOOL_GET_HUGE:下载阈值,单位为 MB,大于此值的文件均略过,不设置的话默认为 1 MB 。

 

参考

[1]. Shell判断文件或目录是否存在

[2]. shell 字符串包含

[3]. 那些年我用awk时踩过的坑——awk使用注意事项

[4]. shell脚本中如何使用alias

[5]. Linux_shell自动输入y或yes

[6]. awk使用shell变量及shell使用awk中的变量

[7]. Shell高级语法:awk配合eval实现快速变量

posted @ 2021-09-23 10:45  goodcitizen  阅读(779)  评论(0编辑  收藏  举报