任务:一批视频文件,需要

  1. 自动化地转码成指定的格式
  2. 上传到服务器
  3. 得到文件的 URL 地址

对于转码工作,目前为止依然没有顺利的完成,由于网上资料贫乏,各种视频音频格式、编解码器、专利开源问题比较纠结,需要很长的时间理清这些关系。我重点研究了 FFmpegyamdi 这两个工具。但是今天用 yamdi 的时候发现一个很奇怪的 bug——它会自动改变原始视频文件的 fps 和 bitrate 作为输出文件,非常奇怪,可能会比较棘手。

对于第二个问题,经过几天的探索,顺带复习许久之前的 Bash Scripting 知识和众多的 Unix Power Tools,终于想出了比较完善可行的方案,诞生了人生第一个比较“成形”的 shell 脚本,惭愧……

首先给出我的脚本和模板配置文件,然后再逐步分析——

send_file.sh

#!/bin/bash

################################################################################
# Purpose:  This interactive script is used for upload files onto a ftp server automatically.
# Author:   Xiao Hanyu(xiaohanyu@taohua.com)
# Usage:    ./send_file.sh
# Depends:
#       lftp:       used to transfer files
#       tree:       used to get the directory tree
#       sed:        used to get the filename
#       sort:       used to match the url and the filename into a csv file
# Notes:
#       This script need a configuration files which is set by -f parameter.
#       I have given a sample configuration file: sample.conf
#       After running this script, it will output the [filename:url] list to a file
################################################################################

# This function checks whether the necessary tools are available
function usage
{
    cat << EOF
`basename $0`: A utility to send files to a remote ftp server automatically

Usage: `basename $0` [Action]
Example: `basename $0` -f config_file

Actions:
    -f:         set the configuration files
    -h:         show this help
EOF
}

# check the necessary tools
function check_version
{
    $1 --version > /dev/null

    if [ $? -ne 0 ]
    then
        echo "You must install $1 before executing this script."
        echo "You can type \"sudo apt-get install $1\" to finish this task under ubuntu os."
        echo "exit ..."
        exit 1
    fi
}

# make sure that you have permission to create a temporary file under current directory
function check_perm
{
    touch tmp_lftp_script_file
    if [ $? -ne "0" ]
    then
        echo "You don't have permission to create temporary file under current directory."
        echo "Type \"chmod u+w current_directory\" to give users write permissions."
        echo "exit ..."
        exit 1
    else
        rm -f tmp_lftp_script_file
    fi
}

# this script use some file to store something, so
# if the file exists, we backup it into file.bak, and
# create a new empty file
function check_bak_file
{
    if [ -e $1 ]
    then
        cp $1 $1.bak
    fi

    cat /dev/null > $1
}

# parse parameters
#   -f for configuration file
#   -h for command help
while getopts "f:h" arg
do
    case $arg in
        f)
            config_file=$OPTARG
            ;;
        h)
            usage
            exit 0
            ;;
        ?)
        echo "!!Wrong command options!"
        usage
        exit 1
        ;;
    esac
done

# check whether or not configuration file exists
if [ -e $config_file ]
then
    # if $config_file exist, import the necessary variable
    source $config_file
else
    echo "configuration file not exist."
    echo "exit ..."
    exit 1
fi

# check lftp version
echo
check_version lftp

# check tree version
echo
check_version tree

# check permission
check_perm

# This file containts the command executed by lftp after login ftp server"
lftp_script=lftp_sh
check_bak_file $lftp_script

# $url_file store the [key:value] for filenames and urls
check_bak_file $url_file

# ftp anonymous login
username=${username:-"anonymous"}
password=${password:-"anonymous"}

# ftp default port
port=${port:-"21"}

# create lftp script executed by lftp
echo "lftp $username:$password@$host:$port" >> $lftp_script
echo "ls" >> $lftp_script
echo "cd $rdir" >> $lftp_script

for file in $lfiles
do
    if [ -d $file ]                 # if $file is a directory, we should use 'lftp mirror -R' command
    then
        echo "mirror -R $file" >> $lftp_script

        # use $(tree -ifp --noreport $file | grep "\[" | grep -v "\[d" | tr -s ' ' | cut -d' ' -f2 | sed -e 's/\.\{1,2\}\///g')
        # to get all the filenames(contains relative path such "../../", "./", "../", "/", so we should use sed to get rid of these
        for tmp_file in $(tree -ifp --noreport $file | grep "\[" | grep -v "\[d" | tr -s ' ' | cut -d' ' -f2 | sed -e 's/\.\{1,2\}\///g')
        do
            if [[ $rdir == "." || $rdir == "" ]]    # if $rdir==".", we shouldn't give a url like 'http://hostname/./filename'
            then
                echo -e "$(basename $tmp_file | sed -e 's/\..*//g')\thttp://$host/$tmp_file" >> $url_file
            else
                echo -e "$(basename $tmp_file | sed -s 's/\..*//g')\thttp://$host/$rdir/$tmp_file" >> $url_file
            fi
        done
    elif [ -e $file ]
    then
        echo "put $file" >> $lftp_script
        if [[ $rdir == "." | $rdir == "" ]]
        then
            echo -e "$file\thttp://$host/$file" >> $url_file
        else
            echo -e "$file\thttp://$host/$rdir/$file" >> $url_file
        fi
    else
        echo "!!Warning: $file not exist!"
    fi
done

lftp -f $lftp_script

if [ $? -ne 0 ]
then
    echo "Sending file failed, please check your ftp information."
    echo "exit ..."
else
    echo "Sending file successfully!"
fi

# echo "rm -f $lftp_script"

sample.conf

代码的注释比较详尽了,函数名称基本也能如实反映函数的作用,我来说明下基本思路。

首先是命令行选项的解析,这个根据复杂度不同有三种方法:

  1. 直接用 $1$2$3 手工处理,暴力解析。这里你需要知道几个 Bash 变量,如 $0 代表 Bash 脚本的名字, $1$9 分别代表着第 1–9个命令行参数等等。优点是比较简单,缺点是太“简单” 了。
  2. getopts ,Bash 内置,只支持短选项如 -a -b -c-a option1 -b -c-abc ,不支持长选项如 --version 这样的,使用比较简单(因为是 Bash 内置嘛)。
  3. getopt ,外部命令,比较复杂,支持长选项,我还不会用。

C++ Boost 库提供 Options 组件,用来解析命令行参数。具体的实例可以参见 Bash Shell 中命令行选项/参数处理。我的脚本中用的是第二种方法。

第二个大问题是参数选项的问题。我们可以通过两种方式配置参数,从而让我们的脚本自动化地做出适应性的处理。第一种方法是通过命令行参数,就是上面谈的 getopt/getopts ,这种方法的好处就是方便直观快捷,变量解析可以用 Bash 内置的 read 或者高级一点的 TCL/Expect(这个我也不会),缺点在于每次敲命令的时候都要敲这一堆命令行参数,而且对于运维人员来说是一种非常不 user-friendly 的方式;第二种方法就是通过配置文件,让我们的 Bash 脚本自己解析指定的配置文件来获取相应的信息——比如 FTP 登录的 username 和 password、需要上传的文件、 上传的远端目录等等。

配置文件的格式有多种选择,Pluskid 大神的闲谈程序的配置文件是篇很不错的说明。我的脚本功能比较简单,配置文件自然也不会太复杂,因此我想出了一个非常“卑鄙无耻”的方法——就是直接将配置文件写成 bash script 变量赋值的形式,然后在脚本中通过这么一句:

直接引入配置变量。我承认我太卑鄙了,当然好处是简单可行——但是对于运维人员(使用这个脚本的人来说),可能会莫名奇妙——为啥等号后面不能有空格,为啥变量赋值最好要加引号——因为他们不懂 Shell Script 的语法——所以每次写脚本的时候、想象一下假设你就是那个要使用脚本的人,怎样才算友好的脚本?——但是我没有时间研究更复杂的脚本解析了——欢迎指正。

第三个大问题是 FTP 自动登录上传文件的问题。如果我们把平时的 FTP 登录操作比作用 Vim 编辑文件,那么自动化的 FTP 登录就是用 sed 来处理文件。想象一下,我们平时登录FTP,Windows 下,我们会点开一个 FTP 软件,点击快速链接,输入 username 和 password,然后下载上传。Linux 有万能的 LFTP 命令行工具,因此实现自动化的功能,从 LFTP 的参数选项着手是比较有希望的选择。

功夫不负有心人,LFTP 有两种手段能够实现自动化的登录上传下载。第一种方式是通过 lftp -f lftp_script_file 的方式, -f 指定一个文件 LFTP_script_file ,这个文件里面包含登录 LFTP 的命令和上传下载文件的命令。第二种方式是通过 LFTP 的 -u 参数指定登录名密码和 -e 选项指定登录后执行的 LFTP 命令。这种方式的缺点在于每执行一条命令都要登录一下 FTP——不过登录 FTP 所耗费的时间与上传文件的时间相比几乎可以忽略不计,所以也算不上一个大的缺点。

除了以上两种方式,我在扫 ABS 的时候偶然发现了 Here Documents 这个东西——这个曾经听说过但从来没有认真看过的东西,才发现这东西也有很多妙处,使用的当,同样可以实现 LFTP 的自动登录上传。我采用的是 LFTP 的 -f 选项,touch 一个临时文件完成自动登录上传的。

第四个问题是 URL 提取的问题。具体来说,比如你远端 FTP 和 HTTP 服务器的地址是 hostname ,远端目录是 videos ,本地上传文件是 send_file.shhpm.avi ,你需要生成如下的 [filename:url] 的 list:

send_file    http://hostname/videos/send_file.sh
hpm    http://hostname/videos/hpm.avi

然后存储这个 list 到一个文件里面,供后面进一步的 URL 生成映射处理之用。这个问题是耗时最久的一个问题。我的脚本里面有这么一段:

其中针对目录的处理尤为复杂,比如你可以指定 ../../tmp 这样的目录,如果你不作合适的处理,生成的 URL 可能是 http://hostname/../../tmp/ 之类的东西。我最开始想的方法是递归目录的处理方法,但是写了好几个版本依然没有写出 Bash 的递归目录遍历。后来偶然间想到了 tree,这个可以列出目录树的命令,仔细研究了它的参数选项,同时以管道的方式结合其他命令如 grep(正向和反向匹配)、tr(压缩相同字符)、 cut(提取某个 column,可以用 awk 的 print 实现同样的功能)、sed(字符串处理,去除文件的路径和后缀),终于胜利地完成了这个任务。所谓成就感就是这么来的,哈。

至此,脚本需要解决的主要问题都已经阐述完毕,其余的问题都是一些小技俩,比如检查相关依赖工具是否安装、检查用户权限、提供帮助信息等等。目前发现一个 bug,还是目录上传的时候有时会出现递归上传的问题,非常奇怪。

脚本的改进之处也有很多,比如:

  • 给出更加友好的提示帮助信息
  • 给出更健壮的配置文件语法
  • 自动检查每个文件是否上传成功,如果没有成功,能否实现断点续传
  • 支持 log 文件输出,便于时候分析和故障分析
  • 如果磁盘空间不够给出警告信息等等

OK,到此为止,睡觉去。